diff --git a/README.md b/README.md index e1b910a..71ddab5 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,9 @@ selected-company and controller-role context through overlay imports, and real d execute through the ordinary runtime path, and descriptors `1` `Player Cash` and `14` `Deactivate Player` now join that batch through the same service engine. Synthetic packed records still exercise the same runtime without a parallel packed executor. The first grounded +chairman-profile runtime slice now exists too: overlay-backed selected-chairman context plus the +hidden grouped target-subject lane let those same real descriptors `1` and `14` execute on +selected-chairman scope, while wider chairman target scopes remain explicit parity. The first grounded condition-side unlock now exists for negative-sentinel `raw_condition_id = -1` company scopes, and the first ordinary nonnegative condition batch now executes too: numeric-threshold company finance, company track, aggregate territory track, and company-territory track rows can import diff --git a/crates/rrt-cli/src/main.rs b/crates/rrt-cli/src/main.rs index 938e61a..3c8303f 100644 --- a/crates/rrt-cli/src/main.rs +++ b/crates/rrt-cli/src/main.rs @@ -4485,6 +4485,16 @@ mod tests { ); let cargo_catalog_fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../../fixtures/runtime/packed-event-cargo-catalog-save-slice-fixture.json"); + let chairman_cash_overlay_fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/runtime/packed-event-chairman-cash-overlay-fixture.json"); + let deactivate_chairman_overlay_fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/runtime/packed-event-deactivate-chairman-overlay-fixture.json"); + let missing_chairman_context_fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join( + "../../fixtures/runtime/packed-event-chairman-missing-context-save-slice-fixture.json", + ); + let chairman_scope_parity_fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join( + "../../fixtures/runtime/packed-event-chairman-scope-parity-save-slice-fixture.json", + ); run_runtime_summarize_fixture(&parity_fixture) .expect("save-slice-backed parity fixture should summarize"); @@ -4526,6 +4536,14 @@ mod tests { .expect("save-slice-backed parity world-scalar condition fixture should summarize"); run_runtime_summarize_fixture(&cargo_catalog_fixture) .expect("save-slice-backed cargo catalog fixture should summarize"); + run_runtime_summarize_fixture(&chairman_cash_overlay_fixture) + .expect("overlay-backed chairman-cash fixture should summarize"); + run_runtime_summarize_fixture(&deactivate_chairman_overlay_fixture) + .expect("overlay-backed deactivate-chairman fixture should summarize"); + run_runtime_summarize_fixture(&missing_chairman_context_fixture) + .expect("save-slice-backed chairman missing-context fixture should summarize"); + run_runtime_summarize_fixture(&chairman_scope_parity_fixture) + .expect("save-slice-backed chairman scope parity fixture should summarize"); } #[test] diff --git a/crates/rrt-fixtures/src/load.rs b/crates/rrt-fixtures/src/load.rs index e73c95a..dcdca71 100644 --- a/crates/rrt-fixtures/src/load.rs +++ b/crates/rrt-fixtures/src/load.rs @@ -176,6 +176,8 @@ mod tests { selected_company_id: None, players: Vec::new(), selected_player_id: None, + chairman_profiles: Vec::new(), + selected_chairman_profile_id: None, trains: Vec::new(), locomotive_catalog: Vec::new(), cargo_catalog: Vec::new(), @@ -353,6 +355,8 @@ mod tests { selected_company_id: Some(42), players: Vec::new(), selected_player_id: None, + chairman_profiles: Vec::new(), + selected_chairman_profile_id: None, trains: Vec::new(), locomotive_catalog: Vec::new(), cargo_catalog: Vec::new(), diff --git a/crates/rrt-fixtures/src/schema.rs b/crates/rrt-fixtures/src/schema.rs index f1bf091..8665b8f 100644 --- a/crates/rrt-fixtures/src/schema.rs +++ b/crates/rrt-fixtures/src/schema.rs @@ -72,6 +72,12 @@ pub struct ExpectedRuntimeSummary { #[serde(default)] pub player_count: Option, #[serde(default)] + pub chairman_profile_count: Option, + #[serde(default)] + pub active_chairman_profile_count: Option, + #[serde(default)] + pub selected_chairman_profile_id: Option, + #[serde(default)] pub train_count: Option, #[serde(default)] pub active_train_count: Option, @@ -110,6 +116,10 @@ pub struct ExpectedRuntimeSummary { #[serde(default)] pub packed_event_blocked_missing_player_role_context_count: Option, #[serde(default)] + pub packed_event_blocked_missing_chairman_context_count: Option, + #[serde(default)] + pub packed_event_blocked_chairman_target_scope_count: Option, + #[serde(default)] pub packed_event_blocked_missing_condition_context_count: Option, #[serde(default)] pub packed_event_blocked_missing_player_condition_context_count: Option, @@ -439,6 +449,30 @@ impl ExpectedRuntimeSummary { )); } } + if let Some(count) = self.chairman_profile_count { + if actual.chairman_profile_count != count { + mismatches.push(format!( + "chairman_profile_count mismatch: expected {count}, got {}", + actual.chairman_profile_count + )); + } + } + if let Some(count) = self.active_chairman_profile_count { + if actual.active_chairman_profile_count != count { + mismatches.push(format!( + "active_chairman_profile_count mismatch: expected {count}, got {}", + actual.active_chairman_profile_count + )); + } + } + if let Some(selected_id) = self.selected_chairman_profile_id { + if actual.selected_chairman_profile_id != Some(selected_id) { + mismatches.push(format!( + "selected_chairman_profile_id mismatch: expected {selected_id:?}, got {:?}", + actual.selected_chairman_profile_id + )); + } + } if let Some(count) = self.train_count { if actual.train_count != count { mismatches.push(format!( @@ -591,6 +625,22 @@ impl ExpectedRuntimeSummary { )); } } + if let Some(count) = self.packed_event_blocked_missing_chairman_context_count { + if actual.packed_event_blocked_missing_chairman_context_count != count { + mismatches.push(format!( + "packed_event_blocked_missing_chairman_context_count mismatch: expected {count}, got {}", + actual.packed_event_blocked_missing_chairman_context_count + )); + } + } + if let Some(count) = self.packed_event_blocked_chairman_target_scope_count { + if actual.packed_event_blocked_chairman_target_scope_count != count { + mismatches.push(format!( + "packed_event_blocked_chairman_target_scope_count mismatch: expected {count}, got {}", + actual.packed_event_blocked_chairman_target_scope_count + )); + } + } if let Some(count) = self.packed_event_blocked_missing_condition_context_count { if actual.packed_event_blocked_missing_condition_context_count != count { mismatches.push(format!( diff --git a/crates/rrt-runtime/src/import.rs b/crates/rrt-runtime/src/import.rs index 6d417af..dfad6e8 100644 --- a/crates/rrt-runtime/src/import.rs +++ b/crates/rrt-runtime/src/import.rs @@ -5,17 +5,18 @@ use serde::{Deserialize, Serialize}; use crate::persistence::{load_runtime_snapshot_document, validate_runtime_snapshot_document}; use crate::{ - CalendarPoint, RuntimeCargoCatalogEntry, RuntimeCompanyConditionTestScope, - RuntimeCompanyControllerKind, RuntimeCompanyTarget, RuntimeCondition, RuntimeEffect, - RuntimeEventRecord, RuntimeEventRecordTemplate, RuntimeLocomotiveCatalogEntry, - RuntimePackedEventCollectionSummary, RuntimePackedEventCompactControlSummary, - RuntimePackedEventConditionRowSummary, RuntimePackedEventGroupedEffectRowSummary, - RuntimePackedEventNegativeSentinelScopeSummary, RuntimePackedEventRecordSummary, - RuntimePackedEventTextBandSummary, RuntimePlayerConditionTestScope, RuntimePlayerTarget, - RuntimeSaveProfileState, RuntimeServiceState, RuntimeState, RuntimeTerritoryTarget, - RuntimeWorldRestoreState, SmpLoadedPackedEventConditionRowSummary, - SmpLoadedPackedEventGroupedEffectRowSummary, SmpLoadedPackedEventNegativeSentinelScopeSummary, - SmpLoadedPackedEventRecordSummary, SmpLoadedPackedEventTextBandSummary, SmpLoadedSaveSlice, + CalendarPoint, RuntimeCargoCatalogEntry, RuntimeChairmanTarget, + RuntimeCompanyConditionTestScope, RuntimeCompanyControllerKind, RuntimeCompanyTarget, + RuntimeCondition, RuntimeEffect, RuntimeEventRecord, RuntimeEventRecordTemplate, + RuntimeLocomotiveCatalogEntry, RuntimePackedEventCollectionSummary, + RuntimePackedEventCompactControlSummary, RuntimePackedEventConditionRowSummary, + RuntimePackedEventGroupedEffectRowSummary, RuntimePackedEventNegativeSentinelScopeSummary, + RuntimePackedEventRecordSummary, RuntimePackedEventTextBandSummary, + RuntimePlayerConditionTestScope, RuntimePlayerTarget, RuntimeSaveProfileState, + RuntimeServiceState, RuntimeState, RuntimeTerritoryTarget, RuntimeWorldRestoreState, + SmpLoadedPackedEventConditionRowSummary, SmpLoadedPackedEventGroupedEffectRowSummary, + SmpLoadedPackedEventNegativeSentinelScopeSummary, SmpLoadedPackedEventRecordSummary, + SmpLoadedPackedEventTextBandSummary, SmpLoadedSaveSlice, }; pub const STATE_DUMP_FORMAT_VERSION: u32 = 1; @@ -116,6 +117,8 @@ struct ImportRuntimeContext { known_player_ids: BTreeSet, selected_player_id: Option, has_complete_player_controller_context: bool, + known_chairman_profile_ids: BTreeSet, + selected_chairman_profile_id: Option, known_territory_ids: BTreeSet, has_territory_context: bool, territory_name_to_id: BTreeMap, @@ -132,6 +135,8 @@ enum ImportBlocker { MissingPlayerContext, MissingPlayerSelectionContext, MissingPlayerRoleContext, + MissingChairmanContext, + ChairmanTargetScope, MissingConditionContext, MissingPlayerConditionContext, CompanyConditionScopeDisabled, @@ -153,6 +158,8 @@ impl ImportRuntimeContext { known_player_ids: BTreeSet::new(), selected_player_id: None, has_complete_player_controller_context: false, + known_chairman_profile_ids: BTreeSet::new(), + selected_chairman_profile_id: None, known_territory_ids: BTreeSet::new(), has_territory_context: false, territory_name_to_id: BTreeMap::new(), @@ -185,6 +192,12 @@ impl ImportRuntimeContext { .players .iter() .all(|player| player.controller_kind != RuntimeCompanyControllerKind::Unknown), + known_chairman_profile_ids: state + .chairman_profiles + .iter() + .map(|profile| profile.profile_id) + .collect(), + selected_chairman_profile_id: state.selected_chairman_profile_id, known_territory_ids: state .territories .iter() @@ -244,6 +257,8 @@ pub fn project_save_slice_to_runtime_state_import( selected_company_id: None, players: Vec::new(), selected_player_id: None, + chairman_profiles: Vec::new(), + selected_chairman_profile_id: None, trains: Vec::new(), locomotive_catalog: projection.locomotive_catalog.unwrap_or_default(), cargo_catalog: projection.cargo_catalog.unwrap_or_default(), @@ -307,6 +322,8 @@ 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, + chairman_profiles: base_state.chairman_profiles.clone(), + selected_chairman_profile_id: base_state.selected_chairman_profile_id, trains: base_state.trains.clone(), locomotive_catalog: projection .locomotive_catalog @@ -1007,6 +1024,7 @@ fn runtime_packed_event_grouped_effect_row_summary_from_smp( descriptor_label: row.descriptor_label.clone(), target_mask_bits: row.target_mask_bits, parameter_family: row.parameter_family.clone(), + grouped_target_subject: row.grouped_target_subject.clone(), opcode: row.opcode, raw_scalar_value: row.raw_scalar_value, value_byte_0x09: row.value_byte_0x09, @@ -1157,6 +1175,9 @@ fn lower_contextual_real_grouped_effects( let mut effects = Vec::with_capacity(record.grouped_effect_rows.len()); for row in &record.grouped_effect_rows { + if real_grouped_row_is_unsupported_chairman_target_scope(row) { + return Err(ImportBlocker::ChairmanTargetScope); + } if let Some(effect) = lower_contextual_cargo_production_effect(row)? { effects.push(effect); continue; @@ -1425,12 +1446,19 @@ fn lower_condition_targets_in_effect( )?, value: *value, }, + RuntimeEffect::SetChairmanCash { target, value } => RuntimeEffect::SetChairmanCash { + target: target.clone(), + value: *value, + }, RuntimeEffect::DeactivatePlayer { target } => RuntimeEffect::DeactivatePlayer { target: lower_condition_true_player_target_in_player_target( target, lowered_player_target, )?, }, + RuntimeEffect::DeactivateChairman { target } => RuntimeEffect::DeactivateChairman { + target: target.clone(), + }, RuntimeEffect::SetCompanyTerritoryAccess { target, territory, @@ -1590,6 +1618,17 @@ fn lower_condition_targets_in_condition( comparator: *comparator, value: *value, }, + RuntimeCondition::ChairmanNumericThreshold { + target, + metric, + comparator, + value, + } => RuntimeCondition::ChairmanNumericThreshold { + target: target.clone(), + metric: *metric, + comparator: *comparator, + value: *value, + }, RuntimeCondition::TerritoryNumericThreshold { target, metric, @@ -1786,6 +1825,7 @@ fn condition_uses_condition_true_company(condition: &RuntimeCondition) -> bool { | RuntimeCondition::CompanyTerritoryNumericThreshold { target, .. } => { matches!(target, RuntimeCompanyTarget::ConditionTrueCompany) } + RuntimeCondition::ChairmanNumericThreshold { .. } => false, RuntimeCondition::TerritoryNumericThreshold { .. } | RuntimeCondition::SpecialConditionThreshold { .. } | RuntimeCondition::CandidateAvailabilityThreshold { .. } @@ -1803,6 +1843,40 @@ fn condition_uses_condition_true_company(condition: &RuntimeCondition) -> bool { } } +fn chairman_target_import_blocker( + target: &RuntimeChairmanTarget, + company_context: &ImportRuntimeContext, +) -> Option { + match target { + RuntimeChairmanTarget::AllActive => { + if company_context.known_chairman_profile_ids.is_empty() { + Some(ImportBlocker::MissingChairmanContext) + } else { + None + } + } + RuntimeChairmanTarget::SelectedChairman => { + if company_context.selected_chairman_profile_id.is_some() { + None + } else { + Some(ImportBlocker::MissingChairmanContext) + } + } + RuntimeChairmanTarget::Ids { ids } => { + if company_context.known_chairman_profile_ids.is_empty() { + Some(ImportBlocker::MissingChairmanContext) + } else if ids + .iter() + .all(|id| company_context.known_chairman_profile_ids.contains(id)) + { + None + } else { + Some(ImportBlocker::MissingChairmanContext) + } + } + } +} + fn smp_runtime_effects_to_runtime_effects( effects: &[RuntimeEffect], company_context: &ImportRuntimeContext, @@ -1867,6 +1941,16 @@ fn smp_runtime_effect_to_runtime_effect( Err(player_target_import_error_message(target, company_context)) } } + RuntimeEffect::SetChairmanCash { target, value } => { + if chairman_target_import_blocker(target, company_context).is_none() { + Ok(RuntimeEffect::SetChairmanCash { + target: target.clone(), + value: *value, + }) + } else { + Err("packed effect requires chairman runtime context".to_string()) + } + } RuntimeEffect::DeactivatePlayer { target } => { if player_target_allowed_for_import( target, @@ -1880,6 +1964,15 @@ fn smp_runtime_effect_to_runtime_effect( Err(player_target_import_error_message(target, company_context)) } } + RuntimeEffect::DeactivateChairman { target } => { + if chairman_target_import_blocker(target, company_context).is_none() { + Ok(RuntimeEffect::DeactivateChairman { + target: target.clone(), + }) + } else { + Err("packed effect requires chairman runtime context".to_string()) + } + } RuntimeEffect::SetCompanyTerritoryAccess { target, territory, @@ -2236,6 +2329,8 @@ fn company_target_import_error_message( Some(ImportBlocker::MissingPlayerContext) | Some(ImportBlocker::MissingPlayerSelectionContext) | Some(ImportBlocker::MissingPlayerRoleContext) + | Some(ImportBlocker::MissingChairmanContext) + | Some(ImportBlocker::ChairmanTargetScope) | Some(ImportBlocker::MissingPlayerConditionContext) => { "packed company effect is blocked by non-company import context".to_string() } @@ -2324,10 +2419,22 @@ fn determine_packed_event_import_outcome( } if !record.executable_import_ready { if let Err(blocker) = lowered_record_decoded_actions(record, company_context) { - if blocker == ImportBlocker::MissingLocomotiveCatalogContext { + if matches!( + blocker, + ImportBlocker::MissingLocomotiveCatalogContext + | ImportBlocker::MissingChairmanContext + | ImportBlocker::ChairmanTargetScope + ) { return company_target_import_outcome(blocker).to_string(); } } + if record + .grouped_effect_rows + .iter() + .any(real_grouped_row_is_unsupported_chairman_target_scope) + { + return "blocked_chairman_target_scope".to_string(); + } if record .grouped_effect_rows .iter() @@ -2507,6 +2614,9 @@ fn runtime_condition_company_target_import_blocker( RuntimeCondition::CompanyNumericThreshold { target, .. } => { company_target_import_blocker(target, company_context) } + RuntimeCondition::ChairmanNumericThreshold { target, .. } => { + chairman_target_import_blocker(target, company_context) + } RuntimeCondition::TerritoryNumericThreshold { target, .. } => { territory_target_import_blocker(target, company_context) } @@ -2559,6 +2669,8 @@ fn company_target_import_outcome(blocker: ImportBlocker) -> &'static str { ImportBlocker::MissingPlayerContext => "blocked_missing_player_context", ImportBlocker::MissingPlayerSelectionContext => "blocked_missing_player_selection_context", ImportBlocker::MissingPlayerRoleContext => "blocked_missing_player_role_context", + ImportBlocker::MissingChairmanContext => "blocked_missing_chairman_context", + ImportBlocker::ChairmanTargetScope => "blocked_chairman_target_scope", ImportBlocker::MissingConditionContext => "blocked_missing_condition_context", ImportBlocker::MissingPlayerConditionContext => "blocked_missing_player_condition_context", ImportBlocker::CompanyConditionScopeDisabled => "blocked_company_condition_scope_disabled", @@ -2585,6 +2697,17 @@ fn real_grouped_row_is_unsupported_territory_access_variant( row.descriptor_id == 3 && !(row.row_shape == "bool_toggle" && row.raw_scalar_value != 0) } +fn real_grouped_row_is_unsupported_chairman_target_scope( + row: &SmpLoadedPackedEventGroupedEffectRowSummary, +) -> bool { + matches!(row.grouped_target_subject.as_deref(), Some("chairman")) + && matches!(row.descriptor_id, 1 | 14) + && row + .notes + .iter() + .any(|note| note == "chairman row requires selected-chairman scope") +} + fn real_grouped_row_is_unsupported_territory_access_scope( row: &SmpLoadedPackedEventGroupedEffectRowSummary, ) -> bool { @@ -2644,7 +2767,9 @@ fn runtime_effect_uses_condition_true_company(effect: &RuntimeEffect) -> bool { | RuntimeEffect::SetLimitedTrackBuildingAmount { .. } | RuntimeEffect::SetEconomicStatusCode { .. } | RuntimeEffect::SetPlayerCash { .. } + | RuntimeEffect::SetChairmanCash { .. } | RuntimeEffect::DeactivatePlayer { .. } + | RuntimeEffect::DeactivateChairman { .. } | RuntimeEffect::SetCandidateAvailability { .. } | RuntimeEffect::SetNamedLocomotiveAvailability { .. } | RuntimeEffect::SetNamedLocomotiveAvailabilityValue { .. } @@ -2666,6 +2791,7 @@ fn runtime_effect_uses_condition_true_player(effect: &RuntimeEffect) -> bool { RuntimeEffect::DeactivatePlayer { target } => { matches!(target, RuntimePlayerTarget::ConditionTruePlayer) } + RuntimeEffect::SetChairmanCash { .. } | RuntimeEffect::DeactivateChairman { .. } => false, RuntimeEffect::AppendEventRecord { record } => record .effects .iter() @@ -2701,6 +2827,10 @@ fn runtime_effect_company_target_import_blocker( | RuntimeEffect::DeactivatePlayer { target } => { player_target_import_blocker(target, company_context) } + RuntimeEffect::SetChairmanCash { target, .. } + | RuntimeEffect::DeactivateChairman { target } => { + chairman_target_import_blocker(target, company_context) + } RuntimeEffect::RetireTrains { company_target, territory_target, @@ -3067,6 +3197,8 @@ mod tests { selected_company_id: None, players: Vec::new(), selected_player_id: None, + chairman_profiles: Vec::new(), + selected_chairman_profile_id: None, trains: Vec::new(), locomotive_catalog: Vec::new(), cargo_catalog: Vec::new(), @@ -3211,6 +3343,7 @@ mod tests { descriptor_label: Some("Company Cash".to_string()), target_mask_bits: Some(0x01), parameter_family: Some("company_finance_scalar".to_string()), + grouped_target_subject: None, opcode: 8, raw_scalar_value: 7, value_byte_0x09: 1, @@ -3240,6 +3373,7 @@ mod tests { descriptor_label: Some("Deactivate Company".to_string()), target_mask_bits: Some(0x01), parameter_family: Some("company_lifecycle_toggle".to_string()), + grouped_target_subject: None, opcode: 1, raw_scalar_value: if enabled { 1 } else { 0 }, value_byte_0x09: 0, @@ -3270,6 +3404,7 @@ mod tests { descriptor_label: Some("Company Track Pieces Buildable".to_string()), target_mask_bits: Some(0x01), parameter_family: Some("company_build_limit_scalar".to_string()), + grouped_target_subject: None, opcode: 3, raw_scalar_value: value, value_byte_0x09: 0, @@ -3299,6 +3434,7 @@ mod tests { descriptor_label: Some("Deactivate Player".to_string()), target_mask_bits: Some(0x02), parameter_family: Some("player_lifecycle_toggle".to_string()), + grouped_target_subject: None, opcode: 1, raw_scalar_value: if enabled { 1 } else { 0 }, value_byte_0x09: 0, @@ -3332,6 +3468,7 @@ mod tests { descriptor_label: Some("Territory - Allow All".to_string()), target_mask_bits: Some(0x05), parameter_family: Some("territory_access_toggle".to_string()), + grouped_target_subject: None, opcode: 1, raw_scalar_value: if enabled { 1 } else { 0 }, value_byte_0x09: 0, @@ -3362,6 +3499,7 @@ mod tests { descriptor_label: Some("Economic Status".to_string()), target_mask_bits: Some(0x08), parameter_family: Some("whole_game_state_enum".to_string()), + grouped_target_subject: None, opcode: 3, raw_scalar_value: value, value_byte_0x09: 0, @@ -3391,6 +3529,7 @@ mod tests { descriptor_label: Some("Limited Track Building Amount".to_string()), target_mask_bits: Some(0x08), parameter_family: Some("world_track_build_limit_scalar".to_string()), + grouped_target_subject: None, opcode: 3, raw_scalar_value: value, value_byte_0x09: 0, @@ -3420,6 +3559,7 @@ mod tests { descriptor_label: Some("Use Wartime Cargos".to_string()), target_mask_bits: Some(0x08), parameter_family: Some("special_condition_scalar".to_string()), + grouped_target_subject: None, opcode: 3, raw_scalar_value: value, value_byte_0x09: 0, @@ -3449,6 +3589,7 @@ mod tests { descriptor_label: Some("Turbo Diesel Availability".to_string()), target_mask_bits: Some(0x08), parameter_family: Some("candidate_availability_scalar".to_string()), + grouped_target_subject: None, opcode: 3, raw_scalar_value: value, value_byte_0x09: 0, @@ -3479,6 +3620,7 @@ mod tests { descriptor_label: Some("Unknown Loco Available".to_string()), target_mask_bits: Some(0x08), parameter_family: Some("locomotive_availability_scalar".to_string()), + grouped_target_subject: None, opcode: 3, raw_scalar_value: value, value_byte_0x09: 0, @@ -3521,6 +3663,7 @@ mod tests { descriptor_label: Some(descriptor_label.clone()), target_mask_bits: Some(0x08), parameter_family: Some("locomotive_cost_scalar".to_string()), + grouped_target_subject: None, opcode: 3, raw_scalar_value: value, value_byte_0x09: 0, @@ -3616,6 +3759,7 @@ mod tests { descriptor_label: Some(descriptor_label.clone()), target_mask_bits: Some(0x08), parameter_family: Some("cargo_production_scalar".to_string()), + grouped_target_subject: None, opcode: 3, raw_scalar_value: value, value_byte_0x09: 0, @@ -3645,6 +3789,7 @@ mod tests { descriptor_label: Some("Territory Access Cost".to_string()), target_mask_bits: Some(0x08), parameter_family: Some("territory_access_cost_scalar".to_string()), + grouped_target_subject: None, opcode: 3, raw_scalar_value: value, value_byte_0x09: 0, @@ -3676,6 +3821,7 @@ mod tests { descriptor_label: Some(label.to_string()), target_mask_bits: Some(0x08), parameter_family: Some("world_flag_toggle".to_string()), + grouped_target_subject: None, opcode: 0, raw_scalar_value: if enabled { 1 } else { 0 }, value_byte_0x09: 0, @@ -3708,6 +3854,7 @@ mod tests { descriptor_label: Some("Confiscate All".to_string()), target_mask_bits: Some(0x01), parameter_family: Some("company_confiscation_variant".to_string()), + grouped_target_subject: None, opcode: 1, raw_scalar_value: if enabled { 1 } else { 0 }, value_byte_0x09: 0, @@ -3742,6 +3889,7 @@ mod tests { descriptor_label: Some("Retire Train".to_string()), target_mask_bits: Some(0x0d), parameter_family: Some("company_or_territory_asset_toggle".to_string()), + grouped_target_subject: None, opcode: 1, raw_scalar_value: if enabled { 1 } else { 0 }, value_byte_0x09: 0, @@ -3772,6 +3920,7 @@ mod tests { descriptor_label: Some("Confiscate All".to_string()), target_mask_bits: Some(0x01), parameter_family: Some("company_confiscation_variant".to_string()), + grouped_target_subject: None, opcode: 1, raw_scalar_value: 0, value_byte_0x09: 0, @@ -5451,6 +5600,7 @@ mod tests { descriptor_label: Some("Unknown Loco Available".to_string()), target_mask_bits: Some(0x08), parameter_family: Some("locomotive_availability_scalar".to_string()), + grouped_target_subject: None, opcode: 3, raw_scalar_value: 42, value_byte_0x09: 0, @@ -5697,6 +5847,8 @@ mod tests { selected_company_id: None, players: Vec::new(), selected_player_id: None, + chairman_profiles: Vec::new(), + selected_chairman_profile_id: None, trains: Vec::new(), locomotive_catalog: vec![ crate::RuntimeLocomotiveCatalogEntry { @@ -6092,6 +6244,8 @@ mod tests { selected_company_id: None, players: Vec::new(), selected_player_id: None, + chairman_profiles: Vec::new(), + selected_chairman_profile_id: None, trains: Vec::new(), locomotive_catalog: vec![ crate::RuntimeLocomotiveCatalogEntry { @@ -6488,6 +6642,8 @@ mod tests { selected_company_id: Some(42), players: Vec::new(), selected_player_id: None, + chairman_profiles: Vec::new(), + selected_chairman_profile_id: None, trains: Vec::new(), locomotive_catalog: Vec::new(), cargo_catalog: Vec::new(), @@ -6566,6 +6722,7 @@ mod tests { descriptor_label: Some("Company Cash".to_string()), target_mask_bits: Some(0x01), parameter_family: Some("company_finance_scalar".to_string()), + grouped_target_subject: None, opcode: 8, raw_scalar_value: 250, value_byte_0x09: 1, @@ -8145,6 +8302,7 @@ mod tests { descriptor_label: Some("Turbo Diesel Availability".to_string()), target_mask_bits: Some(0x08), parameter_family: Some("candidate_availability_scalar".to_string()), + grouped_target_subject: None, opcode: 3, raw_scalar_value: 1, value_byte_0x09: 0, @@ -9850,6 +10008,8 @@ mod tests { selected_company_id: Some(42), players: Vec::new(), selected_player_id: None, + chairman_profiles: Vec::new(), + selected_chairman_profile_id: None, trains: Vec::new(), locomotive_catalog: Vec::new(), cargo_catalog: Vec::new(), @@ -10034,6 +10194,8 @@ mod tests { selected_company_id: Some(42), players: Vec::new(), selected_player_id: None, + chairman_profiles: Vec::new(), + selected_chairman_profile_id: None, trains: Vec::new(), locomotive_catalog: Vec::new(), cargo_catalog: Vec::new(), diff --git a/crates/rrt-runtime/src/lib.rs b/crates/rrt-runtime/src/lib.rs index e7711aa..c16b2fc 100644 --- a/crates/rrt-runtime/src/lib.rs +++ b/crates/rrt-runtime/src/lib.rs @@ -35,7 +35,8 @@ pub use pk4::{ extract_pk4_entry_bytes, extract_pk4_entry_file, inspect_pk4_bytes, inspect_pk4_file, }; pub use runtime::{ - RuntimeCargoCatalogEntry, RuntimeCargoClass, RuntimeCompany, RuntimeCompanyConditionTestScope, + RuntimeCargoCatalogEntry, RuntimeCargoClass, RuntimeChairmanMetric, RuntimeChairmanProfile, + RuntimeChairmanTarget, RuntimeCompany, RuntimeCompanyConditionTestScope, RuntimeCompanyControllerKind, RuntimeCompanyMetric, RuntimeCompanyTarget, RuntimeCompanyTerritoryAccess, RuntimeCompanyTerritoryTrackPieceCount, RuntimeCondition, RuntimeConditionComparator, RuntimeEffect, RuntimeEventRecord, RuntimeEventRecordTemplate, diff --git a/crates/rrt-runtime/src/persistence.rs b/crates/rrt-runtime/src/persistence.rs index 8bff5ad..6f19317 100644 --- a/crates/rrt-runtime/src/persistence.rs +++ b/crates/rrt-runtime/src/persistence.rs @@ -96,6 +96,8 @@ mod tests { selected_company_id: None, players: Vec::new(), selected_player_id: None, + chairman_profiles: Vec::new(), + selected_chairman_profile_id: None, trains: Vec::new(), locomotive_catalog: Vec::new(), cargo_catalog: Vec::new(), diff --git a/crates/rrt-runtime/src/runtime.rs b/crates/rrt-runtime/src/runtime.rs index 755c9c7..b735c5c 100644 --- a/crates/rrt-runtime/src/runtime.rs +++ b/crates/rrt-runtime/src/runtime.rs @@ -89,6 +89,30 @@ pub struct RuntimePlayer { pub controller_kind: RuntimeCompanyControllerKind, } +fn runtime_chairman_profile_default_active() -> bool { + true +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuntimeChairmanProfile { + pub profile_id: u32, + pub name: String, + #[serde(default = "runtime_chairman_profile_default_active")] + pub active: bool, + #[serde(default)] + pub current_cash: i64, + #[serde(default)] + pub linked_company_id: Option, + #[serde(default)] + pub company_holdings: BTreeMap, + #[serde(default)] + pub holdings_value_total: i64, + #[serde(default)] + pub net_worth_total: i64, + #[serde(default)] + pub purchasing_power_total: i64, +} + fn runtime_train_default_active() -> bool { true } @@ -156,6 +180,14 @@ pub enum RuntimePlayerTarget { Ids { ids: Vec }, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum RuntimeChairmanTarget { + AllActive, + SelectedChairman, + Ids { ids: Vec }, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum RuntimeTerritoryTarget { @@ -211,6 +243,15 @@ pub enum RuntimeCompanyMetric { TrackPiecesNonElectric, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RuntimeChairmanMetric { + CurrentCash, + HoldingsValueTotal, + NetWorthTotal, + PurchasingPowerTotal, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum RuntimeTerritoryMetric { @@ -242,6 +283,12 @@ pub enum RuntimeCondition { comparator: RuntimeConditionComparator, value: i64, }, + ChairmanNumericThreshold { + target: RuntimeChairmanTarget, + metric: RuntimeChairmanMetric, + comparator: RuntimeConditionComparator, + value: i64, + }, TerritoryNumericThreshold { target: RuntimeTerritoryTarget, metric: RuntimeTerritoryMetric, @@ -336,9 +383,16 @@ pub enum RuntimeEffect { target: RuntimePlayerTarget, value: i64, }, + SetChairmanCash { + target: RuntimeChairmanTarget, + value: i64, + }, DeactivatePlayer { target: RuntimePlayerTarget, }, + DeactivateChairman { + target: RuntimeChairmanTarget, + }, SetCompanyTerritoryAccess { target: RuntimeCompanyTarget, territory: RuntimeTerritoryTarget, @@ -590,6 +644,8 @@ pub struct RuntimePackedEventGroupedEffectRowSummary { pub target_mask_bits: Option, #[serde(default)] pub parameter_family: Option, + #[serde(default)] + pub grouped_target_subject: Option, pub opcode: u8, pub raw_scalar_value: i32, pub value_byte_0x09: u8, @@ -733,6 +789,10 @@ pub struct RuntimeState { #[serde(default)] pub selected_player_id: Option, #[serde(default)] + pub chairman_profiles: Vec, + #[serde(default)] + pub selected_chairman_profile_id: Option, + #[serde(default)] pub trains: Vec, #[serde(default)] pub locomotive_catalog: Vec, @@ -801,6 +861,63 @@ impl RuntimeState { active_player_ids.insert(player.player_id); } } + + let mut seen_chairman_profile_ids = BTreeSet::new(); + let mut seen_chairman_names = BTreeSet::new(); + let mut active_chairman_profile_ids = BTreeSet::new(); + for chairman in &self.chairman_profiles { + if !seen_chairman_profile_ids.insert(chairman.profile_id) { + return Err(format!( + "duplicate chairman_profile.profile_id {}", + chairman.profile_id + )); + } + if chairman.name.trim().is_empty() { + return Err(format!( + "chairman_profile {} has an empty name", + chairman.profile_id + )); + } + if !seen_chairman_names.insert(chairman.name.clone()) { + return Err(format!( + "duplicate chairman_profile.name {:?}", + chairman.name + )); + } + if chairman.active { + active_chairman_profile_ids.insert(chairman.profile_id); + } + if let Some(linked_company_id) = chairman.linked_company_id { + if !seen_company_ids.contains(&linked_company_id) { + return Err(format!( + "chairman_profile {} references unknown linked_company_id {}", + chairman.profile_id, linked_company_id + )); + } + } + for company_id in chairman.company_holdings.keys() { + if !seen_company_ids.contains(company_id) { + return Err(format!( + "chairman_profile {} references unknown holdings company_id {}", + chairman.profile_id, company_id + )); + } + } + } + if let Some(selected_chairman_profile_id) = self.selected_chairman_profile_id { + if !seen_chairman_profile_ids.contains(&selected_chairman_profile_id) { + return Err(format!( + "selected_chairman_profile_id {} does not reference a live chairman profile", + selected_chairman_profile_id + )); + } + if !active_chairman_profile_ids.contains(&selected_chairman_profile_id) { + return Err(format!( + "selected_chairman_profile_id {} must reference an active chairman profile", + selected_chairman_profile_id + )); + } + } if let Some(selected_player_id) = self.selected_player_id { if !seen_player_ids.contains(&selected_player_id) { return Err(format!( @@ -956,19 +1073,25 @@ impl RuntimeState { return Err(format!("duplicate record_id {}", record.record_id)); } for (condition_index, condition) in record.conditions.iter().enumerate() { - validate_runtime_condition(condition, &seen_company_ids, &seen_territory_ids) - .map_err(|err| { + validate_runtime_condition( + condition, + &seen_company_ids, + &seen_chairman_profile_ids, + &seen_territory_ids, + ) + .map_err(|err| { format!( "event_runtime_records[record_id={}].conditions[{condition_index}] {err}", record.record_id ) - })?; + })?; } for (effect_index, effect) in record.effects.iter().enumerate() { validate_runtime_effect( effect, &seen_company_ids, &seen_player_ids, + &seen_chairman_profile_ids, &seen_territory_ids, ) .map_err(|err| { @@ -1315,6 +1438,7 @@ fn validate_runtime_effect( effect: &RuntimeEffect, valid_company_ids: &BTreeSet, valid_player_ids: &BTreeSet, + valid_chairman_profile_ids: &BTreeSet, valid_territory_ids: &BTreeSet, ) -> Result<(), String> { match effect { @@ -1343,6 +1467,10 @@ fn validate_runtime_effect( | RuntimeEffect::DeactivatePlayer { target } => { validate_player_target(target, valid_player_ids)?; } + RuntimeEffect::SetChairmanCash { target, .. } + | RuntimeEffect::DeactivateChairman { target } => { + validate_chairman_target(target, valid_chairman_profile_ids)?; + } RuntimeEffect::RetireTrains { company_target, territory_target, @@ -1403,6 +1531,7 @@ fn validate_runtime_effect( record, valid_company_ids, valid_player_ids, + valid_chairman_profile_ids, valid_territory_ids, )?; } @@ -1418,23 +1547,29 @@ fn validate_event_record_template( record: &RuntimeEventRecordTemplate, valid_company_ids: &BTreeSet, valid_player_ids: &BTreeSet, + valid_chairman_profile_ids: &BTreeSet, valid_territory_ids: &BTreeSet, ) -> Result<(), String> { 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 - ) - }, - )?; + validate_runtime_condition( + condition, + valid_company_ids, + valid_chairman_profile_ids, + valid_territory_ids, + ) + .map_err(|err| { + format!( + "template record_id={}.conditions[{condition_index}] {err}", + record.record_id + ) + })?; } for (effect_index, effect) in record.effects.iter().enumerate() { validate_runtime_effect( effect, valid_company_ids, valid_player_ids, + valid_chairman_profile_ids, valid_territory_ids, ) .map_err(|err| { @@ -1451,12 +1586,16 @@ fn validate_event_record_template( fn validate_runtime_condition( condition: &RuntimeCondition, valid_company_ids: &BTreeSet, + valid_chairman_profile_ids: &BTreeSet, valid_territory_ids: &BTreeSet, ) -> Result<(), String> { match condition { RuntimeCondition::CompanyNumericThreshold { target, .. } => { validate_company_target(target, valid_company_ids) } + RuntimeCondition::ChairmanNumericThreshold { target, .. } => { + validate_chairman_target(target, valid_chairman_profile_ids) + } RuntimeCondition::TerritoryNumericThreshold { target, .. } => { validate_territory_target(target, valid_territory_ids) } @@ -1562,6 +1701,28 @@ fn validate_player_target( } } +fn validate_chairman_target( + target: &RuntimeChairmanTarget, + valid_chairman_profile_ids: &BTreeSet, +) -> Result<(), String> { + match target { + RuntimeChairmanTarget::AllActive | RuntimeChairmanTarget::SelectedChairman => Ok(()), + RuntimeChairmanTarget::Ids { ids } => { + if ids.is_empty() { + return Err("target ids must not be empty".to_string()); + } + for profile_id in ids { + if !valid_chairman_profile_ids.contains(profile_id) { + return Err(format!( + "target references unknown chairman profile_id {profile_id}" + )); + } + } + Ok(()) + } + } +} + fn validate_territory_target( target: &RuntimeTerritoryTarget, valid_territory_ids: &BTreeSet, @@ -1628,6 +1789,8 @@ mod tests { selected_company_id: None, players: Vec::new(), selected_player_id: None, + chairman_profiles: Vec::new(), + selected_chairman_profile_id: None, trains: Vec::new(), locomotive_catalog: Vec::new(), cargo_catalog: Vec::new(), @@ -1689,6 +1852,8 @@ mod tests { selected_company_id: None, players: Vec::new(), selected_player_id: None, + chairman_profiles: Vec::new(), + selected_chairman_profile_id: None, trains: Vec::new(), locomotive_catalog: Vec::new(), cargo_catalog: Vec::new(), @@ -1735,6 +1900,8 @@ mod tests { selected_company_id: None, players: Vec::new(), selected_player_id: None, + chairman_profiles: Vec::new(), + selected_chairman_profile_id: None, trains: Vec::new(), locomotive_catalog: Vec::new(), cargo_catalog: Vec::new(), @@ -1794,6 +1961,8 @@ mod tests { selected_company_id: None, players: Vec::new(), selected_player_id: None, + chairman_profiles: Vec::new(), + selected_chairman_profile_id: None, trains: Vec::new(), locomotive_catalog: Vec::new(), cargo_catalog: Vec::new(), @@ -1853,6 +2022,8 @@ mod tests { selected_company_id: None, players: Vec::new(), selected_player_id: None, + chairman_profiles: Vec::new(), + selected_chairman_profile_id: None, trains: Vec::new(), locomotive_catalog: Vec::new(), cargo_catalog: Vec::new(), @@ -1963,6 +2134,8 @@ mod tests { selected_company_id: Some(2), players: Vec::new(), selected_player_id: None, + chairman_profiles: Vec::new(), + selected_chairman_profile_id: None, trains: Vec::new(), locomotive_catalog: Vec::new(), cargo_catalog: Vec::new(), @@ -2009,6 +2182,8 @@ mod tests { selected_company_id: Some(1), players: Vec::new(), selected_player_id: None, + chairman_profiles: Vec::new(), + selected_chairman_profile_id: None, trains: Vec::new(), locomotive_catalog: Vec::new(), cargo_catalog: Vec::new(), @@ -2055,6 +2230,8 @@ mod tests { selected_company_id: None, players: Vec::new(), selected_player_id: None, + chairman_profiles: Vec::new(), + selected_chairman_profile_id: None, trains: vec![ RuntimeTrain { train_id: 7, @@ -2118,6 +2295,8 @@ mod tests { selected_company_id: None, players: Vec::new(), selected_player_id: None, + chairman_profiles: Vec::new(), + selected_chairman_profile_id: None, trains: vec![RuntimeTrain { train_id: 7, owner_company_id: 2, @@ -2171,6 +2350,8 @@ mod tests { selected_company_id: None, players: Vec::new(), selected_player_id: None, + chairman_profiles: Vec::new(), + selected_chairman_profile_id: None, trains: vec![RuntimeTrain { train_id: 7, owner_company_id: 1, @@ -2228,6 +2409,8 @@ mod tests { selected_company_id: None, players: Vec::new(), selected_player_id: None, + chairman_profiles: Vec::new(), + selected_chairman_profile_id: None, trains: vec![RuntimeTrain { train_id: 7, owner_company_id: 1, @@ -2281,6 +2464,8 @@ mod tests { selected_company_id: None, players: Vec::new(), selected_player_id: None, + chairman_profiles: Vec::new(), + selected_chairman_profile_id: None, trains: Vec::new(), locomotive_catalog: Vec::new(), cargo_catalog: Vec::new(), @@ -2340,6 +2525,8 @@ mod tests { selected_company_id: None, players: Vec::new(), selected_player_id: None, + chairman_profiles: Vec::new(), + selected_chairman_profile_id: None, trains: Vec::new(), locomotive_catalog: Vec::new(), cargo_catalog: Vec::new(), @@ -2393,6 +2580,8 @@ mod tests { selected_company_id: None, players: Vec::new(), selected_player_id: None, + chairman_profiles: Vec::new(), + selected_chairman_profile_id: None, trains: Vec::new(), locomotive_catalog: Vec::new(), cargo_catalog: Vec::new(), diff --git a/crates/rrt-runtime/src/smp.rs b/crates/rrt-runtime/src/smp.rs index db5f9fb..1556978 100644 --- a/crates/rrt-runtime/src/smp.rs +++ b/crates/rrt-runtime/src/smp.rs @@ -7,10 +7,10 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use crate::{ - RuntimeCargoClass, RuntimeCompanyConditionTestScope, RuntimeCompanyMetric, - RuntimeCompanyTarget, RuntimeCondition, RuntimeConditionComparator, RuntimeEffect, - RuntimeEventRecordTemplate, RuntimePlayerConditionTestScope, RuntimePlayerTarget, - RuntimeTerritoryMetric, RuntimeTerritoryTarget, RuntimeTrackMetric, + RuntimeCargoClass, RuntimeChairmanTarget, RuntimeCompanyConditionTestScope, + RuntimeCompanyMetric, RuntimeCompanyTarget, RuntimeCondition, RuntimeConditionComparator, + RuntimeEffect, RuntimeEventRecordTemplate, RuntimePlayerConditionTestScope, + RuntimePlayerTarget, RuntimeTerritoryMetric, RuntimeTerritoryTarget, RuntimeTrackMetric, }; pub const SMP_FOUR_SIDECAR_BYTE_PLANES_MIN_BUNDLE_VERSION: u32 = 0x03ec; @@ -1876,6 +1876,8 @@ pub struct SmpLoadedPackedEventGroupedEffectRowSummary { pub target_mask_bits: Option, #[serde(default)] pub parameter_family: Option, + #[serde(default)] + pub grouped_target_subject: Option, pub opcode: u8, pub raw_scalar_value: i32, pub value_byte_0x09: u8, @@ -1901,6 +1903,15 @@ pub struct SmpLoadedPackedEventGroupedEffectRowSummary { pub notes: Vec, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RealGroupedTargetSubject { + Company, + Player, + Chairman, + Territory, + WholeGame, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SmpLoadedSaveSlice { pub file_extension_hint: Option, @@ -2590,12 +2601,20 @@ fn parse_real_event_runtime_record_summary( } if let Some(control) = compact_control.as_ref() { for row in &mut grouped_effect_rows { + row.grouped_target_subject = derive_real_grouped_target_subject(row, control) + .map(real_grouped_target_subject_name) + .map(str::to_string); let company_target_present = control .grouped_target_scope_ordinals_0x7fb .get(row.group_index) .copied() .and_then(real_grouped_company_target) .is_some(); + let chairman_target_present = control + .grouped_target_scope_ordinals_0x7fb + .get(row.group_index) + .copied() + .is_some_and(real_grouped_chairman_target_supported_in_runtime); let territory_target_present = control .grouped_territory_selectors_0x80f .get(row.group_index) @@ -2617,6 +2636,14 @@ fn parse_real_event_runtime_record_summary( row.notes .push("territory access row is missing company or territory scope".to_string()); } + if matches!( + derive_real_grouped_target_subject(row, control), + Some(RealGroupedTargetSubject::Chairman) + ) && !chairman_target_present + { + row.notes + .push("chairman row requires selected-chairman scope".to_string()); + } } } @@ -3174,6 +3201,7 @@ fn parse_real_grouped_effect_row_summary( descriptor_label: descriptor_metadata.map(|metadata| metadata.label.to_string()), target_mask_bits: descriptor_metadata.map(|metadata| metadata.target_mask_bits), parameter_family: descriptor_metadata.map(|metadata| metadata.parameter_family.to_string()), + grouped_target_subject: None, opcode, raw_scalar_value, value_byte_0x09, @@ -3665,6 +3693,44 @@ fn runtime_world_flag_key_from_label(label: &str) -> String { key } +fn derive_real_grouped_target_subject( + row: &SmpLoadedPackedEventGroupedEffectRowSummary, + compact_control: &SmpLoadedPackedEventCompactControlSummary, +) -> Option { + match row.target_mask_bits { + Some(0x08) => Some(RealGroupedTargetSubject::WholeGame), + Some(0x01) => Some(RealGroupedTargetSubject::Company), + Some(0x02) => match compact_control + .grouped_scope_checkboxes_0x7ff + .get(row.group_index) + .copied() + { + Some(2) => Some(RealGroupedTargetSubject::Chairman), + _ => Some(RealGroupedTargetSubject::Player), + }, + _ if row.descriptor_id == 3 => Some(RealGroupedTargetSubject::Territory), + _ if row.descriptor_id == 15 + && compact_control + .grouped_territory_selectors_0x80f + .get(row.group_index) + .is_some_and(|selector| *selector >= 0) => + { + Some(RealGroupedTargetSubject::Territory) + } + _ => None, + } +} + +fn real_grouped_target_subject_name(subject: RealGroupedTargetSubject) -> &'static str { + match subject { + RealGroupedTargetSubject::Company => "company", + RealGroupedTargetSubject::Player => "player", + RealGroupedTargetSubject::Chairman => "chairman", + RealGroupedTargetSubject::Territory => "territory", + RealGroupedTargetSubject::WholeGame => "whole_game", + } +} + fn decode_real_grouped_effect_actions( grouped_effect_rows: &[SmpLoadedPackedEventGroupedEffectRowSummary], compact_control: &SmpLoadedPackedEventCompactControlSummary, @@ -3684,17 +3750,23 @@ fn decode_real_grouped_effect_action( .grouped_target_scope_ordinals_0x7fb .get(row.group_index) .copied()?; + let target_subject = derive_real_grouped_target_subject(row, compact_control); if descriptor_metadata.executable_in_runtime && descriptor_metadata.descriptor_id == 1 && row.opcode == 8 && row.row_shape == "multivalue_scalar" { - let target = real_grouped_player_target(target_scope_ordinal)?; - return Some(RuntimeEffect::SetPlayerCash { - target, - value: i64::from(row.raw_scalar_value), - }); + return match target_subject { + Some(RealGroupedTargetSubject::Chairman) => Some(RuntimeEffect::SetChairmanCash { + target: real_grouped_chairman_target(target_scope_ordinal)?, + value: i64::from(row.raw_scalar_value), + }), + _ => Some(RuntimeEffect::SetPlayerCash { + target: real_grouped_player_target(target_scope_ordinal)?, + value: i64::from(row.raw_scalar_value), + }), + }; } if descriptor_metadata.executable_in_runtime @@ -3823,8 +3895,14 @@ fn decode_real_grouped_effect_action( && row.row_shape == "bool_toggle" && row.raw_scalar_value != 0 { - let target = real_grouped_player_target(target_scope_ordinal)?; - return Some(RuntimeEffect::DeactivatePlayer { target }); + return match target_subject { + Some(RealGroupedTargetSubject::Chairman) => Some(RuntimeEffect::DeactivateChairman { + target: real_grouped_chairman_target(target_scope_ordinal)?, + }), + _ => Some(RuntimeEffect::DeactivatePlayer { + target: real_grouped_player_target(target_scope_ordinal)?, + }), + }; } if descriptor_metadata.executable_in_runtime @@ -3886,6 +3964,17 @@ fn real_grouped_player_target(ordinal: u8) -> Option { } } +fn real_grouped_chairman_target(ordinal: u8) -> Option { + match ordinal { + 1 => Some(RuntimeChairmanTarget::SelectedChairman), + _ => None, + } +} + +fn real_grouped_chairman_target_supported_in_runtime(ordinal: u8) -> bool { + real_grouped_chairman_target(ordinal).is_some() +} + fn parse_synthetic_packed_event_action(bytes: &[u8], cursor: &mut usize) -> Option { let opcode = read_u8_at(bytes, *cursor)?; *cursor += 1; @@ -4033,6 +4122,13 @@ fn parse_optional_u16_len_prefixed_string( fn runtime_effect_supported_for_save_import(effect: &RuntimeEffect) -> bool { match effect { + RuntimeEffect::SetChairmanCash { target, .. } + | RuntimeEffect::DeactivateChairman { target } => matches!( + target, + RuntimeChairmanTarget::AllActive + | RuntimeChairmanTarget::SelectedChairman + | RuntimeChairmanTarget::Ids { .. } + ), RuntimeEffect::SetWorldFlag { .. } | RuntimeEffect::SetLimitedTrackBuildingAmount { .. } | RuntimeEffect::SetEconomicStatusCode { .. } @@ -4097,6 +4193,7 @@ fn runtime_effect_supported_for_save_import(effect: &RuntimeEffect) -> bool { fn runtime_condition_supported_for_save_import(condition: &RuntimeCondition) -> bool { match condition { RuntimeCondition::CompanyNumericThreshold { .. } + | RuntimeCondition::ChairmanNumericThreshold { .. } | RuntimeCondition::TerritoryNumericThreshold { .. } | RuntimeCondition::CompanyTerritoryNumericThreshold { .. } | RuntimeCondition::SpecialConditionThreshold { .. } diff --git a/crates/rrt-runtime/src/step.rs b/crates/rrt-runtime/src/step.rs index f5540dc..369f1bf 100644 --- a/crates/rrt-runtime/src/step.rs +++ b/crates/rrt-runtime/src/step.rs @@ -3,10 +3,10 @@ use std::collections::BTreeSet; use serde::{Deserialize, Serialize}; use crate::{ - RuntimeCargoClass, RuntimeCompanyControllerKind, RuntimeCompanyMetric, RuntimeCompanyTarget, - RuntimeCondition, RuntimeConditionComparator, RuntimeEffect, RuntimeEventRecordTemplate, - RuntimePlayerTarget, RuntimeState, RuntimeSummary, RuntimeTerritoryMetric, - RuntimeTerritoryTarget, RuntimeTrackMetric, RuntimeTrackPieceCounts, + RuntimeCargoClass, RuntimeChairmanMetric, RuntimeChairmanTarget, RuntimeCompanyControllerKind, + RuntimeCompanyMetric, RuntimeCompanyTarget, RuntimeCondition, RuntimeConditionComparator, + RuntimeEffect, RuntimeEventRecordTemplate, RuntimePlayerTarget, RuntimeState, RuntimeSummary, + RuntimeTerritoryMetric, RuntimeTerritoryTarget, RuntimeTrackMetric, RuntimeTrackPieceCounts, calendar::BoundaryEventKind, }; @@ -87,6 +87,8 @@ struct AppliedEffectsSummary { struct ResolvedConditionContext { matching_company_ids: BTreeSet, matching_player_ids: BTreeSet, + #[allow(dead_code)] + matching_chairman_profile_ids: BTreeSet, } pub fn execute_step_command( @@ -346,6 +348,21 @@ fn apply_runtime_effects( mutated_player_ids.insert(player_id); } } + RuntimeEffect::SetChairmanCash { target, value } => { + let profile_ids = resolve_chairman_target_ids(state, target, condition_context)?; + for profile_id in profile_ids { + let chairman = state + .chairman_profiles + .iter_mut() + .find(|profile| profile.profile_id == profile_id) + .ok_or_else(|| { + format!( + "missing chairman profile_id {profile_id} while applying cash effect" + ) + })?; + chairman.current_cash = *value; + } + } RuntimeEffect::DeactivatePlayer { target } => { let player_ids = resolve_player_target_ids(state, target, condition_context)?; for player_id in player_ids { @@ -365,6 +382,39 @@ fn apply_runtime_effects( } } } + RuntimeEffect::DeactivateChairman { target } => { + let profile_ids = resolve_chairman_target_ids(state, target, condition_context)?; + for profile_id in profile_ids.iter().copied() { + let linked_company_id = state + .chairman_profiles + .iter() + .find(|profile| profile.profile_id == profile_id) + .and_then(|profile| profile.linked_company_id); + let chairman = state + .chairman_profiles + .iter_mut() + .find(|profile| profile.profile_id == profile_id) + .ok_or_else(|| { + format!( + "missing chairman profile_id {profile_id} while applying deactivate effect" + ) + })?; + chairman.active = false; + chairman.linked_company_id = None; + if state.selected_chairman_profile_id == Some(profile_id) { + state.selected_chairman_profile_id = None; + } + if let Some(linked_company_id) = linked_company_id { + for other in &mut state.chairman_profiles { + if other.profile_id != profile_id + && other.linked_company_id == Some(linked_company_id) + { + other.linked_company_id = None; + } + } + } + } + } RuntimeEffect::SetCompanyTerritoryAccess { target, territory, @@ -607,6 +657,7 @@ fn evaluate_record_conditions( } let mut company_matches: Option> = None; + let mut chairman_matches: Option> = None; for condition in conditions { match condition { @@ -657,6 +708,41 @@ fn evaluate_record_conditions( return Ok(None); } } + RuntimeCondition::ChairmanNumericThreshold { + target, + metric, + comparator, + value, + } => { + let resolved = resolve_chairman_target_ids( + state, + target, + &ResolvedConditionContext::default(), + )?; + let matching = resolved + .into_iter() + .filter(|profile_id| { + state + .chairman_profiles + .iter() + .find(|profile| profile.profile_id == *profile_id) + .is_some_and(|profile| { + compare_condition_value( + chairman_metric_value(profile, *metric), + *comparator, + *value, + ) + }) + }) + .collect::>(); + if matching.is_empty() { + return Ok(None); + } + intersect_chairman_matches(&mut chairman_matches, matching); + if chairman_matches.as_ref().is_some_and(BTreeSet::is_empty) { + return Ok(None); + } + } RuntimeCondition::CompanyTerritoryNumericThreshold { target, territory, @@ -840,6 +926,7 @@ fn evaluate_record_conditions( Ok(Some(ResolvedConditionContext { matching_company_ids: company_matches.unwrap_or_default(), matching_player_ids: BTreeSet::new(), + matching_chairman_profile_ids: chairman_matches.unwrap_or_default(), })) } @@ -854,6 +941,17 @@ fn intersect_company_matches(company_matches: &mut Option>, next: } } +fn intersect_chairman_matches(chairman_matches: &mut Option>, next: BTreeSet) { + match chairman_matches { + Some(existing) => { + existing.retain(|profile_id| next.contains(profile_id)); + } + None => { + *chairman_matches = Some(next); + } + } +} + fn resolve_company_target_ids( state: &RuntimeState, target: &RuntimeCompanyTarget, @@ -1043,6 +1141,53 @@ fn resolve_player_target_ids( } } +fn resolve_chairman_target_ids( + state: &RuntimeState, + target: &RuntimeChairmanTarget, + _condition_context: &ResolvedConditionContext, +) -> Result, String> { + match target { + RuntimeChairmanTarget::AllActive => Ok(state + .chairman_profiles + .iter() + .filter(|profile| profile.active) + .map(|profile| profile.profile_id) + .collect()), + RuntimeChairmanTarget::Ids { ids } => { + let known_ids = state + .chairman_profiles + .iter() + .map(|profile| profile.profile_id) + .collect::>(); + for profile_id in ids { + if !known_ids.contains(profile_id) { + return Err(format!( + "target references unknown chairman profile_id {profile_id}" + )); + } + } + Ok(ids.clone()) + } + RuntimeChairmanTarget::SelectedChairman => { + let selected_profile_id = state.selected_chairman_profile_id.ok_or_else(|| { + "target requires selected_chairman_profile_id context".to_string() + })?; + if state + .chairman_profiles + .iter() + .any(|profile| profile.profile_id == selected_profile_id && profile.active) + { + Ok(vec![selected_profile_id]) + } else { + Err( + "target requires selected_chairman_profile_id to reference an active chairman profile" + .to_string(), + ) + } + } + } +} + fn resolve_territory_target_ids( state: &RuntimeState, target: &RuntimeTerritoryTarget, @@ -1090,6 +1235,18 @@ fn company_metric_value(company: &crate::RuntimeCompany, metric: RuntimeCompanyM } } +fn chairman_metric_value( + profile: &crate::RuntimeChairmanProfile, + metric: RuntimeChairmanMetric, +) -> i64 { + match metric { + RuntimeChairmanMetric::CurrentCash => profile.current_cash, + RuntimeChairmanMetric::HoldingsValueTotal => profile.holdings_value_total, + RuntimeChairmanMetric::NetWorthTotal => profile.net_worth_total, + RuntimeChairmanMetric::PurchasingPowerTotal => profile.purchasing_power_total, + } +} + fn territory_metric_value( state: &RuntimeState, territory_ids: &[u32], @@ -1277,6 +1434,8 @@ mod tests { selected_company_id: None, players: Vec::new(), selected_player_id: None, + chairman_profiles: Vec::new(), + selected_chairman_profile_id: None, trains: Vec::new(), locomotive_catalog: Vec::new(), cargo_catalog: Vec::new(), diff --git a/crates/rrt-runtime/src/summary.rs b/crates/rrt-runtime/src/summary.rs index f72e22a..9ab33de 100644 --- a/crates/rrt-runtime/src/summary.rs +++ b/crates/rrt-runtime/src/summary.rs @@ -33,6 +33,9 @@ pub struct RuntimeSummary { pub company_count: usize, pub active_company_count: usize, pub player_count: usize, + pub chairman_profile_count: usize, + pub active_chairman_profile_count: usize, + pub selected_chairman_profile_id: Option, pub train_count: usize, pub active_train_count: usize, pub retired_train_count: usize, @@ -52,6 +55,8 @@ pub struct RuntimeSummary { pub packed_event_blocked_missing_player_context_count: usize, pub packed_event_blocked_missing_player_selection_context_count: usize, pub packed_event_blocked_missing_player_role_context_count: usize, + pub packed_event_blocked_missing_chairman_context_count: usize, + pub packed_event_blocked_chairman_target_scope_count: usize, pub packed_event_blocked_missing_condition_context_count: usize, pub packed_event_blocked_missing_player_condition_context_count: usize, pub packed_event_blocked_company_condition_scope_disabled_count: usize, @@ -169,6 +174,13 @@ impl RuntimeSummary { .filter(|company| company.active) .count(), player_count: state.players.len(), + chairman_profile_count: state.chairman_profiles.len(), + active_chairman_profile_count: state + .chairman_profiles + .iter() + .filter(|profile| profile.active) + .count(), + selected_chairman_profile_id: state.selected_chairman_profile_id, 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(), @@ -298,6 +310,34 @@ impl RuntimeSummary { .count() }) .unwrap_or(0), + packed_event_blocked_missing_chairman_context_count: state + .packed_event_collection + .as_ref() + .map(|summary| { + summary + .records + .iter() + .filter(|record| { + record.import_outcome.as_deref() + == Some("blocked_missing_chairman_context") + }) + .count() + }) + .unwrap_or(0), + packed_event_blocked_chairman_target_scope_count: state + .packed_event_collection + .as_ref() + .map(|summary| { + summary + .records + .iter() + .filter(|record| { + record.import_outcome.as_deref() + == Some("blocked_chairman_target_scope") + }) + .count() + }) + .unwrap_or(0), packed_event_blocked_missing_condition_context_count: state .packed_event_collection .as_ref() @@ -666,6 +706,8 @@ mod tests { selected_company_id: None, players: Vec::new(), selected_player_id: None, + chairman_profiles: Vec::new(), + selected_chairman_profile_id: None, trains: Vec::new(), locomotive_catalog: vec![ crate::RuntimeLocomotiveCatalogEntry { @@ -909,6 +951,8 @@ mod tests { selected_company_id: None, players: Vec::new(), selected_player_id: None, + chairman_profiles: Vec::new(), + selected_chairman_profile_id: None, trains: Vec::new(), locomotive_catalog: vec![ crate::RuntimeLocomotiveCatalogEntry { @@ -956,6 +1000,8 @@ mod tests { selected_company_id: None, players: Vec::new(), selected_player_id: None, + chairman_profiles: Vec::new(), + selected_chairman_profile_id: None, trains: Vec::new(), locomotive_catalog: vec![ crate::RuntimeLocomotiveCatalogEntry { @@ -1009,6 +1055,8 @@ mod tests { selected_company_id: None, players: Vec::new(), selected_player_id: None, + chairman_profiles: Vec::new(), + selected_chairman_profile_id: None, trains: Vec::new(), locomotive_catalog: Vec::new(), cargo_catalog: Vec::new(), @@ -1053,6 +1101,8 @@ mod tests { selected_company_id: None, players: Vec::new(), selected_player_id: None, + chairman_profiles: Vec::new(), + selected_chairman_profile_id: None, trains: Vec::new(), locomotive_catalog: Vec::new(), cargo_catalog: Vec::new(), @@ -1092,6 +1142,8 @@ mod tests { selected_company_id: None, players: Vec::new(), selected_player_id: None, + chairman_profiles: Vec::new(), + selected_chairman_profile_id: None, trains: Vec::new(), locomotive_catalog: Vec::new(), cargo_catalog: Vec::new(), @@ -1200,6 +1252,8 @@ mod tests { selected_company_id: None, players: Vec::new(), selected_player_id: None, + chairman_profiles: Vec::new(), + selected_chairman_profile_id: None, trains: Vec::new(), locomotive_catalog: Vec::new(), cargo_catalog: Vec::new(), diff --git a/docs/README.md b/docs/README.md index e10dbe6..06d5d5e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -84,6 +84,9 @@ The highest-value next passes are now: - descriptors `1` `Player Cash` and `14` `Deactivate Player` now join that executable real batch through the same ordinary runtime path, backed by the minimal player runtime and overlay-import context +- the first chairman-targeted real grouped rows now execute too through that same path when the + hidden grouped target-subject lane resolves to selected-chairman scope; broader chairman target + scopes stay parity-only under `blocked_chairman_target_scope` - widen real packed-event executable coverage descriptor by descriptor after identity, target mask, and normalized effect semantics are all grounded, not just after row framing is parsed - the first grounded condition-side unlock now exists for negative-sentinel `raw_condition_id = -1` diff --git a/docs/runtime-rehost-plan.md b/docs/runtime-rehost-plan.md index 43492d3..016e01d 100644 --- a/docs/runtime-rehost-plan.md +++ b/docs/runtime-rehost-plan.md @@ -41,6 +41,11 @@ Implemented today: names, a minimal player runtime now carries selected-player and role context, and real descriptor `1` = `Player Cash` and descriptor `14` = `Deactivate Player` now import and execute through the ordinary runtime path +- a first-class chairman-profile runtime now exists too, with overlay-backed selected-chairman + context and the first chairman-targeted grouped-effect subset: the same real descriptors + `1` = `Player Cash` and `14` = `Deactivate Player` now also import and execute through the + hidden grouped target-subject lane when it resolves to selected-chairman scope, while broader + chairman target scopes remain explicit parity on `blocked_chairman_target_scope` - 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 @@ -111,8 +116,8 @@ Implemented today: That means the next implementation work is breadth, not bootstrap. The recommended next slice is broader real grouped-descriptor and ordinary condition-id coverage beyond the current access, -whole-game toggle, train, player, numeric-threshold, named locomotive availability, named -locomotive cost, world scalar override, and world-scalar condition batches. +whole-game toggle, train, player, chairman selected-scope, numeric-threshold, named locomotive +availability, named locomotive cost, world scalar override, and world-scalar condition batches. Richer runtime ownership should still be added only where a later descriptor or condition family needs more than the current event-owned roster. diff --git a/fixtures/runtime/packed-event-chairman-cash-overlay-fixture.json b/fixtures/runtime/packed-event-chairman-cash-overlay-fixture.json new file mode 100644 index 0000000..a6e0f51 --- /dev/null +++ b/fixtures/runtime/packed-event-chairman-cash-overlay-fixture.json @@ -0,0 +1,63 @@ +{ + "format_version": 1, + "fixture_id": "packed-event-chairman-cash-overlay-fixture", + "source": { + "kind": "captured-runtime", + "description": "Fixture proving descriptor 1 imports and executes on selected-chairman scope." + }, + "state_import_path": "packed-event-chairman-cash-overlay.json", + "commands": [ + { + "kind": "service_trigger_kind", + "trigger_kind": 6 + } + ], + "expected_summary": { + "calendar_projection_source": "base-snapshot-preserved", + "calendar_projection_is_placeholder": false, + "company_count": 2, + "chairman_profile_count": 2, + "active_chairman_profile_count": 2, + "selected_chairman_profile_id": 1, + "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": { + "chairman_profiles": [ + { + "profile_id": 1, + "current_cash": 999 + }, + { + "profile_id": 2, + "current_cash": 250 + } + ], + "packed_event_collection": { + "records": [ + { + "import_outcome": "imported", + "decoded_actions": [ + { + "kind": "set_chairman_cash", + "target": { + "kind": "selected_chairman" + }, + "value": 999 + } + ], + "grouped_effect_rows": [ + { + "grouped_target_subject": "chairman" + } + ] + } + ] + } + } +} diff --git a/fixtures/runtime/packed-event-chairman-cash-overlay.json b/fixtures/runtime/packed-event-chairman-cash-overlay.json new file mode 100644 index 0000000..9c8a2d8 --- /dev/null +++ b/fixtures/runtime/packed-event-chairman-cash-overlay.json @@ -0,0 +1,9 @@ +{ + "format_version": 1, + "import_id": "packed-event-chairman-cash-overlay", + "source": { + "description": "Overlay import combining chairman runtime context with the real chairman-targeted cash descriptor sample." + }, + "base_snapshot_path": "packed-event-chairman-overlay-base-snapshot.json", + "save_slice_path": "packed-event-chairman-cash-save-slice.json" +} diff --git a/fixtures/runtime/packed-event-chairman-cash-save-slice.json b/fixtures/runtime/packed-event-chairman-cash-save-slice.json new file mode 100644 index 0000000..a01ec9b --- /dev/null +++ b/fixtures/runtime/packed-event-chairman-cash-save-slice.json @@ -0,0 +1,110 @@ +{ + "format_version": 1, + "save_slice_id": "packed-event-chairman-cash-save-slice", + "source": { + "description": "Tracked save-slice document with a chairman-targeted Player Cash row using the hidden grouped target-subject lane.", + "original_save_filename": "captured-chairman-cash.gms", + "original_save_sha256": "chairman-cash-sample-sha256", + "notes": [ + "tracked as JSON save-slice document rather than raw .smp", + "proves selected-chairman descriptor import through the normal runtime path" + ] + }, + "save_slice": { + "file_extension_hint": "gms", + "container_profile_family": "rt3-classic-save-container-v1", + "mechanism_family": "classic-save-rehydrate-v1", + "mechanism_confidence": "grounded", + "trailer_family": null, + "bridge_family": null, + "profile": null, + "candidate_availability_table": null, + "special_conditions_table": null, + "event_runtime_collection": { + "source_kind": "packed-event-runtime-collection", + "mechanism_family": "classic-save-rehydrate-v1", + "mechanism_confidence": "grounded", + "container_profile_family": "rt3-classic-save-container-v1", + "metadata_tag_offset": 28928, + "records_tag_offset": 29184, + "close_tag_offset": 29696, + "packed_state_version": 1001, + "packed_state_version_hex": "0x000003e9", + "live_id_bound": 61, + "live_record_count": 1, + "live_entry_ids": [61], + "decoded_record_count": 1, + "imported_runtime_record_count": 1, + "records": [ + { + "record_index": 0, + "live_entry_id": 61, + "payload_offset": 29296, + "payload_len": 140, + "decode_status": "parity_only", + "payload_family": "real_packed_v1", + "trigger_kind": 6, + "one_shot": false, + "compact_control": { + "mode_byte_0x7ef": 6, + "primary_selector_0x7f0": 12, + "grouped_mode_0x7f4": 2, + "one_shot_header_0x7f5": 0, + "modifier_flag_0x7f9": 0, + "modifier_flag_0x7fa": 0, + "grouped_target_scope_ordinals_0x7fb": [1, 1, 1, 1], + "grouped_scope_checkboxes_0x7ff": [2, 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": [], + "grouped_effect_row_counts": [1, 0, 0, 0], + "grouped_effect_rows": [ + { + "group_index": 0, + "row_index": 0, + "descriptor_id": 1, + "descriptor_label": "Player Cash", + "target_mask_bits": 2, + "parameter_family": "player_cash_scalar", + "grouped_target_subject": "chairman", + "opcode": 8, + "raw_scalar_value": 999, + "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": "multivalue_scalar", + "semantic_family": "multivalue_scalar", + "semantic_preview": "Set Player Cash to 999 with aux [0, 0, 0, 0]", + "locomotive_name": null, + "notes": [] + } + ], + "decoded_conditions": [], + "decoded_actions": [ + { + "kind": "set_chairman_cash", + "target": { + "kind": "selected_chairman" + }, + "value": 999 + } + ], + "executable_import_ready": true, + "notes": [ + "decoded from grounded real 0x4e9a row framing", + "hidden grouped target-subject lane resolves descriptor 1 to selected chairman scope" + ] + } + ] + }, + "notes": [ + "real chairman-targeted cash descriptor sample" + ] + } +} diff --git a/fixtures/runtime/packed-event-chairman-missing-context-save-slice-fixture.json b/fixtures/runtime/packed-event-chairman-missing-context-save-slice-fixture.json new file mode 100644 index 0000000..21790aa --- /dev/null +++ b/fixtures/runtime/packed-event-chairman-missing-context-save-slice-fixture.json @@ -0,0 +1,40 @@ +{ + "format_version": 1, + "fixture_id": "packed-event-chairman-missing-context-save-slice-fixture", + "source": { + "kind": "captured-runtime", + "description": "Fixture proving chairman-targeted rows stay parity-only without runtime chairman context." + }, + "state_import_path": "packed-event-chairman-cash-save-slice.json", + "commands": [ + { + "kind": "service_trigger_kind", + "trigger_kind": 6 + } + ], + "expected_summary": { + "calendar_projection_source": "default-1830-placeholder", + "calendar_projection_is_placeholder": true, + "chairman_profile_count": 0, + "packed_event_collection_present": true, + "packed_event_record_count": 1, + "packed_event_decoded_record_count": 1, + "packed_event_imported_runtime_record_count": 0, + "packed_event_blocked_missing_chairman_context_count": 1, + "event_runtime_record_count": 0 + }, + "expected_state_fragment": { + "packed_event_collection": { + "records": [ + { + "import_outcome": "blocked_missing_chairman_context", + "grouped_effect_rows": [ + { + "grouped_target_subject": "chairman" + } + ] + } + ] + } + } +} diff --git a/fixtures/runtime/packed-event-chairman-overlay-base-snapshot.json b/fixtures/runtime/packed-event-chairman-overlay-base-snapshot.json new file mode 100644 index 0000000..9bbefe9 --- /dev/null +++ b/fixtures/runtime/packed-event-chairman-overlay-base-snapshot.json @@ -0,0 +1,96 @@ +{ + "format_version": 1, + "snapshot_id": "packed-event-chairman-overlay-base-snapshot", + "source": { + "description": "Base runtime snapshot supplying selected-chairman context for chairman-targeted packed-event overlays." + }, + "state": { + "calendar": { + "year": 1840, + "month_slot": 1, + "phase_slot": 2, + "tick_slot": 3 + }, + "world_flags": { + "base.only": true + }, + "metadata": { + "base.note": "chairman overlay context" + }, + "companies": [ + { + "company_id": 1, + "current_cash": 150, + "debt": 80, + "credit_rating_score": 650, + "prime_rate": 5, + "controller_kind": "human", + "track_piece_counts": { + "total": 20, + "single": 5, + "double": 8, + "transition": 1, + "electric": 3, + "non_electric": 17 + } + }, + { + "company_id": 2, + "current_cash": 90, + "debt": 40, + "credit_rating_score": 480, + "prime_rate": 6, + "controller_kind": "ai", + "track_piece_counts": { + "total": 8, + "single": 2, + "double": 2, + "transition": 0, + "electric": 1, + "non_electric": 7 + } + } + ], + "selected_company_id": 1, + "players": [], + "selected_player_id": null, + "chairman_profiles": [ + { + "profile_id": 1, + "name": "Chairman One", + "active": true, + "current_cash": 500, + "linked_company_id": 1, + "company_holdings": { + "1": 1000 + }, + "holdings_value_total": 700, + "net_worth_total": 1200, + "purchasing_power_total": 1500 + }, + { + "profile_id": 2, + "name": "Chairman Two", + "active": true, + "current_cash": 250, + "linked_company_id": 2, + "company_holdings": { + "2": 900 + }, + "holdings_value_total": 600, + "net_worth_total": 900, + "purchasing_power_total": 1100 + } + ], + "selected_chairman_profile_id": 1, + "event_runtime_records": [], + "candidate_availability": {}, + "special_conditions": {}, + "service_state": { + "periodic_boundary_calls": 0, + "trigger_dispatch_counts": {}, + "total_event_record_services": 0, + "dirty_rerun_count": 0 + } + } +} diff --git a/fixtures/runtime/packed-event-chairman-scope-parity-save-slice-fixture.json b/fixtures/runtime/packed-event-chairman-scope-parity-save-slice-fixture.json new file mode 100644 index 0000000..870bd9c --- /dev/null +++ b/fixtures/runtime/packed-event-chairman-scope-parity-save-slice-fixture.json @@ -0,0 +1,39 @@ +{ + "format_version": 1, + "fixture_id": "packed-event-chairman-scope-parity-save-slice-fixture", + "source": { + "kind": "captured-runtime", + "description": "Fixture proving unsupported chairman scopes stay parity-only under an explicit blocker." + }, + "state_import_path": "packed-event-chairman-scope-parity-save-slice.json", + "commands": [ + { + "kind": "service_trigger_kind", + "trigger_kind": 6 + } + ], + "expected_summary": { + "calendar_projection_source": "default-1830-placeholder", + "calendar_projection_is_placeholder": true, + "packed_event_collection_present": true, + "packed_event_record_count": 1, + "packed_event_decoded_record_count": 1, + "packed_event_imported_runtime_record_count": 0, + "packed_event_blocked_chairman_target_scope_count": 1, + "event_runtime_record_count": 0 + }, + "expected_state_fragment": { + "packed_event_collection": { + "records": [ + { + "import_outcome": "blocked_chairman_target_scope", + "grouped_effect_rows": [ + { + "grouped_target_subject": "chairman" + } + ] + } + ] + } + } +} diff --git a/fixtures/runtime/packed-event-chairman-scope-parity-save-slice.json b/fixtures/runtime/packed-event-chairman-scope-parity-save-slice.json new file mode 100644 index 0000000..2c644b7 --- /dev/null +++ b/fixtures/runtime/packed-event-chairman-scope-parity-save-slice.json @@ -0,0 +1,104 @@ +{ + "format_version": 1, + "save_slice_id": "packed-event-chairman-scope-parity-save-slice", + "source": { + "description": "Tracked save-slice document with a chairman-targeted row on an unsupported non-selected scope.", + "original_save_filename": "captured-chairman-scope-parity.gms", + "original_save_sha256": "chairman-scope-parity-sample-sha256", + "notes": [ + "tracked as JSON save-slice document rather than raw .smp", + "pins the selected-chairman-only execution boundary" + ] + }, + "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": 63, + "live_record_count": 1, + "live_entry_ids": [63], + "decoded_record_count": 1, + "imported_runtime_record_count": 0, + "records": [ + { + "record_index": 0, + "live_entry_id": 63, + "payload_offset": 29296, + "payload_len": 140, + "decode_status": "parity_only", + "payload_family": "real_packed_v1", + "trigger_kind": 6, + "one_shot": false, + "compact_control": { + "mode_byte_0x7ef": 6, + "primary_selector_0x7f0": 12, + "grouped_mode_0x7f4": 2, + "one_shot_header_0x7f5": 0, + "modifier_flag_0x7f9": 0, + "modifier_flag_0x7fa": 0, + "grouped_target_scope_ordinals_0x7fb": [0, 1, 1, 1], + "grouped_scope_checkboxes_0x7ff": [2, 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": [], + "grouped_effect_row_counts": [1, 0, 0, 0], + "grouped_effect_rows": [ + { + "group_index": 0, + "row_index": 0, + "descriptor_id": 1, + "descriptor_label": "Player Cash", + "target_mask_bits": 2, + "parameter_family": "player_cash_scalar", + "grouped_target_subject": "chairman", + "opcode": 8, + "raw_scalar_value": 700, + "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": "multivalue_scalar", + "semantic_family": "multivalue_scalar", + "semantic_preview": "Set Player Cash to 700 with aux [0, 0, 0, 0]", + "locomotive_name": null, + "notes": [ + "chairman row requires selected-chairman scope" + ] + } + ], + "decoded_conditions": [], + "decoded_actions": [], + "executable_import_ready": false, + "notes": [ + "decoded from grounded real 0x4e9a row framing", + "selected-chairman scope is the only grounded chairman-target subset in this slice" + ] + } + ] + }, + "notes": [ + "real chairman-targeted scope parity sample" + ] + } +} diff --git a/fixtures/runtime/packed-event-deactivate-chairman-overlay-fixture.json b/fixtures/runtime/packed-event-deactivate-chairman-overlay-fixture.json new file mode 100644 index 0000000..88c185a --- /dev/null +++ b/fixtures/runtime/packed-event-deactivate-chairman-overlay-fixture.json @@ -0,0 +1,64 @@ +{ + "format_version": 1, + "fixture_id": "packed-event-deactivate-chairman-overlay-fixture", + "source": { + "kind": "captured-runtime", + "description": "Fixture proving descriptor 14 imports and executes on selected-chairman scope." + }, + "state_import_path": "packed-event-deactivate-chairman-overlay.json", + "commands": [ + { + "kind": "service_trigger_kind", + "trigger_kind": 6 + } + ], + "expected_summary": { + "calendar_projection_source": "base-snapshot-preserved", + "calendar_projection_is_placeholder": false, + "company_count": 2, + "chairman_profile_count": 2, + "active_chairman_profile_count": 1, + "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": { + "selected_chairman_profile_id": null, + "chairman_profiles": [ + { + "profile_id": 1, + "active": false, + "linked_company_id": null + }, + { + "profile_id": 2, + "active": true, + "linked_company_id": 2 + } + ], + "packed_event_collection": { + "records": [ + { + "import_outcome": "imported", + "decoded_actions": [ + { + "kind": "deactivate_chairman", + "target": { + "kind": "selected_chairman" + } + } + ], + "grouped_effect_rows": [ + { + "grouped_target_subject": "chairman" + } + ] + } + ] + } + } +} diff --git a/fixtures/runtime/packed-event-deactivate-chairman-overlay.json b/fixtures/runtime/packed-event-deactivate-chairman-overlay.json new file mode 100644 index 0000000..5000b75 --- /dev/null +++ b/fixtures/runtime/packed-event-deactivate-chairman-overlay.json @@ -0,0 +1,9 @@ +{ + "format_version": 1, + "import_id": "packed-event-deactivate-chairman-overlay", + "source": { + "description": "Overlay import combining chairman runtime context with the real chairman-targeted deactivation descriptor sample." + }, + "base_snapshot_path": "packed-event-chairman-overlay-base-snapshot.json", + "save_slice_path": "packed-event-deactivate-chairman-save-slice.json" +} diff --git a/fixtures/runtime/packed-event-deactivate-chairman-save-slice.json b/fixtures/runtime/packed-event-deactivate-chairman-save-slice.json new file mode 100644 index 0000000..566a266 --- /dev/null +++ b/fixtures/runtime/packed-event-deactivate-chairman-save-slice.json @@ -0,0 +1,109 @@ +{ + "format_version": 1, + "save_slice_id": "packed-event-deactivate-chairman-save-slice", + "source": { + "description": "Tracked save-slice document with a chairman-targeted Deactivate Player row using the hidden grouped target-subject lane.", + "original_save_filename": "captured-deactivate-chairman.gms", + "original_save_sha256": "deactivate-chairman-sample-sha256", + "notes": [ + "tracked as JSON save-slice document rather than raw .smp", + "proves selected-chairman lifecycle import through the normal runtime path" + ] + }, + "save_slice": { + "file_extension_hint": "gms", + "container_profile_family": "rt3-classic-save-container-v1", + "mechanism_family": "classic-save-rehydrate-v1", + "mechanism_confidence": "grounded", + "trailer_family": null, + "bridge_family": null, + "profile": null, + "candidate_availability_table": null, + "special_conditions_table": null, + "event_runtime_collection": { + "source_kind": "packed-event-runtime-collection", + "mechanism_family": "classic-save-rehydrate-v1", + "mechanism_confidence": "grounded", + "container_profile_family": "rt3-classic-save-container-v1", + "metadata_tag_offset": 28928, + "records_tag_offset": 29184, + "close_tag_offset": 29696, + "packed_state_version": 1001, + "packed_state_version_hex": "0x000003e9", + "live_id_bound": 62, + "live_record_count": 1, + "live_entry_ids": [62], + "decoded_record_count": 1, + "imported_runtime_record_count": 1, + "records": [ + { + "record_index": 0, + "live_entry_id": 62, + "payload_offset": 29296, + "payload_len": 140, + "decode_status": "parity_only", + "payload_family": "real_packed_v1", + "trigger_kind": 6, + "one_shot": false, + "compact_control": { + "mode_byte_0x7ef": 6, + "primary_selector_0x7f0": 12, + "grouped_mode_0x7f4": 2, + "one_shot_header_0x7f5": 0, + "modifier_flag_0x7f9": 0, + "modifier_flag_0x7fa": 0, + "grouped_target_scope_ordinals_0x7fb": [1, 1, 1, 1], + "grouped_scope_checkboxes_0x7ff": [2, 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": [], + "grouped_effect_row_counts": [1, 0, 0, 0], + "grouped_effect_rows": [ + { + "group_index": 0, + "row_index": 0, + "descriptor_id": 14, + "descriptor_label": "Deactivate Player", + "target_mask_bits": 2, + "parameter_family": "player_lifecycle_toggle", + "grouped_target_subject": "chairman", + "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 Deactivate Player to TRUE", + "locomotive_name": null, + "notes": [] + } + ], + "decoded_conditions": [], + "decoded_actions": [ + { + "kind": "deactivate_chairman", + "target": { + "kind": "selected_chairman" + } + } + ], + "executable_import_ready": true, + "notes": [ + "decoded from grounded real 0x4e9a row framing", + "hidden grouped target-subject lane resolves descriptor 14 to selected chairman scope" + ] + } + ] + }, + "notes": [ + "real chairman-targeted lifecycle descriptor sample" + ] + } +}