Add chairman packed event runtime support

This commit is contained in:
Jan Petykiewicz 2026-04-16 16:07:10 -07:00
commit 86cf89b26c
23 changed files with 1431 additions and 41 deletions

View file

@ -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<u32>,
selected_player_id: Option<u32>,
has_complete_player_controller_context: bool,
known_chairman_profile_ids: BTreeSet<u32>,
selected_chairman_profile_id: Option<u32>,
known_territory_ids: BTreeSet<u32>,
has_territory_context: bool,
territory_name_to_id: BTreeMap<String, u32>,
@ -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<ImportBlocker> {
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(),