Implement whole-game packed event conditions

This commit is contained in:
Jan Petykiewicz 2026-04-15 21:41:40 -07:00
commit cc54a00e25
16 changed files with 1184 additions and 42 deletions

View file

@ -118,10 +118,14 @@ pub struct ExpectedRuntimeSummary {
#[serde(default)]
pub packed_event_blocked_unmapped_ordinary_condition_count: Option<usize>,
#[serde(default)]
pub packed_event_blocked_unmapped_world_condition_count: Option<usize>,
#[serde(default)]
pub packed_event_blocked_missing_compact_control_count: Option<usize>,
#[serde(default)]
pub packed_event_blocked_unmapped_real_descriptor_count: Option<usize>,
#[serde(default)]
pub packed_event_blocked_unmapped_world_descriptor_count: Option<usize>,
#[serde(default)]
pub packed_event_blocked_territory_access_variant_count: Option<usize>,
#[serde(default)]
pub packed_event_blocked_territory_access_scope_count: Option<usize>,
@ -601,6 +605,14 @@ impl ExpectedRuntimeSummary {
));
}
}
if let Some(count) = self.packed_event_blocked_unmapped_world_condition_count {
if actual.packed_event_blocked_unmapped_world_condition_count != count {
mismatches.push(format!(
"packed_event_blocked_unmapped_world_condition_count mismatch: expected {count}, got {}",
actual.packed_event_blocked_unmapped_world_condition_count
));
}
}
if let Some(count) = self.packed_event_blocked_missing_compact_control_count {
if actual.packed_event_blocked_missing_compact_control_count != count {
mismatches.push(format!(
@ -617,6 +629,14 @@ impl ExpectedRuntimeSummary {
));
}
}
if let Some(count) = self.packed_event_blocked_unmapped_world_descriptor_count {
if actual.packed_event_blocked_unmapped_world_descriptor_count != count {
mismatches.push(format!(
"packed_event_blocked_unmapped_world_descriptor_count mismatch: expected {count}, got {}",
actual.packed_event_blocked_unmapped_world_descriptor_count
));
}
}
if let Some(count) = self.packed_event_blocked_territory_access_variant_count {
if actual.packed_event_blocked_territory_access_variant_count != count {
mismatches.push(format!(

View file

@ -132,6 +132,7 @@ enum ImportBlocker {
MissingTerritoryContext,
NamedTerritoryBinding,
UnmappedOrdinaryCondition,
UnmappedWorldCondition,
MissingTrainContext,
MissingTrainTerritoryContext,
}
@ -923,7 +924,11 @@ fn packed_record_condition_scope_import_blocker(
.count();
if ordinary_condition_row_count != 0 {
if ordinary_condition_row_count != record.decoded_conditions.len() {
return Some(ImportBlocker::UnmappedOrdinaryCondition);
return Some(if record_has_world_state_condition_rows(record) {
ImportBlocker::UnmappedWorldCondition
} else {
ImportBlocker::UnmappedOrdinaryCondition
});
}
if (!company_context.has_territory_context)
&& (record
@ -1205,6 +1210,30 @@ fn lower_condition_targets_in_condition(
comparator: *comparator,
value: *value,
},
RuntimeCondition::SpecialConditionThreshold {
label,
comparator,
value,
} => RuntimeCondition::SpecialConditionThreshold {
label: label.clone(),
comparator: *comparator,
value: *value,
},
RuntimeCondition::CandidateAvailabilityThreshold {
name,
comparator,
value,
} => RuntimeCondition::CandidateAvailabilityThreshold {
name: name.clone(),
comparator: *comparator,
value: *value,
},
RuntimeCondition::EconomicStatusCodeThreshold { comparator, value } => {
RuntimeCondition::EconomicStatusCodeThreshold {
comparator: *comparator,
value: *value,
}
}
})
}
@ -1281,7 +1310,10 @@ fn condition_uses_condition_true_company(condition: &RuntimeCondition) -> bool {
| RuntimeCondition::CompanyTerritoryNumericThreshold { target, .. } => {
matches!(target, RuntimeCompanyTarget::ConditionTrueCompany)
}
RuntimeCondition::TerritoryNumericThreshold { .. } => false,
RuntimeCondition::TerritoryNumericThreshold { .. }
| RuntimeCondition::SpecialConditionThreshold { .. }
| RuntimeCondition::CandidateAvailabilityThreshold { .. }
| RuntimeCondition::EconomicStatusCodeThreshold { .. } => false,
}
}
@ -1351,8 +1383,11 @@ fn smp_runtime_effect_to_runtime_effect(
territory,
value,
} => {
if !company_target_allowed_for_import(target, company_context, allow_condition_true_company)
{
if !company_target_allowed_for_import(
target,
company_context,
allow_condition_true_company,
) {
Err(company_target_import_error_message(target, company_context))
} else if territory_target_import_blocker(territory, company_context).is_some() {
Err("packed effect requires territory runtime context".to_string())
@ -1657,6 +1692,9 @@ fn company_target_import_error_message(
Some(ImportBlocker::UnmappedOrdinaryCondition) => {
"packed ordinary condition is not yet mapped".to_string()
}
Some(ImportBlocker::UnmappedWorldCondition) => {
"packed whole-game condition is not yet mapped".to_string()
}
Some(ImportBlocker::MissingTrainContext) => {
"packed effect requires runtime train context".to_string()
}
@ -1788,12 +1826,23 @@ fn determine_packed_event_import_outcome(
{
return "blocked_retire_train_variant".to_string();
}
if record
.grouped_effect_rows
.iter()
.any(real_grouped_row_is_world_state_family)
{
return "blocked_unmapped_world_descriptor".to_string();
}
return if record
.standalone_condition_rows
.iter()
.any(|row| row.raw_condition_id >= 0)
{
"blocked_unmapped_ordinary_condition".to_string()
if record_has_world_state_condition_rows(record) {
"blocked_unmapped_world_condition".to_string()
} else {
"blocked_unmapped_ordinary_condition".to_string()
}
} else {
"blocked_unmapped_real_descriptor".to_string()
};
@ -1814,6 +1863,58 @@ fn determine_packed_event_import_outcome(
"blocked_unsupported_decode".to_string()
}
fn record_has_world_state_condition_rows(record: &SmpLoadedPackedEventRecordSummary) -> bool {
record
.decoded_conditions
.iter()
.any(runtime_condition_is_world_state)
|| record
.standalone_condition_rows
.iter()
.any(ordinary_condition_row_is_world_state_family)
}
fn runtime_condition_is_world_state(condition: &RuntimeCondition) -> bool {
matches!(
condition,
RuntimeCondition::SpecialConditionThreshold { .. }
| RuntimeCondition::CandidateAvailabilityThreshold { .. }
| RuntimeCondition::EconomicStatusCodeThreshold { .. }
)
}
fn ordinary_condition_row_is_world_state_family(
row: &SmpLoadedPackedEventConditionRowSummary,
) -> bool {
row.metric.as_deref().is_some_and(|metric| {
metric.contains("Special Condition")
|| metric.contains("Candidate Availability")
|| metric.contains("Economic Status")
|| metric.contains("World Flag")
}) || row
.semantic_family
.as_deref()
.is_some_and(|family| family == "world_state_threshold" || family == "world_flag_equals")
}
fn real_grouped_row_is_world_state_family(
row: &SmpLoadedPackedEventGroupedEffectRowSummary,
) -> bool {
row.target_mask_bits == Some(0x08)
|| row.parameter_family.as_deref().is_some_and(|family| {
family.starts_with("whole_game_")
|| family.starts_with("special_condition")
|| family.starts_with("candidate_availability")
|| family.starts_with("world_flag")
})
|| row.descriptor_label.as_deref().is_some_and(|label| {
label.contains("Special Condition")
|| label.contains("Candidate Availability")
|| label.contains("World Flag")
|| label == "Economic Status"
})
}
fn packed_record_company_target_import_blocker(
record: &SmpLoadedPackedEventRecordSummary,
company_context: &ImportRuntimeContext,
@ -1858,6 +1959,9 @@ fn runtime_condition_company_target_import_blocker(
target, territory, ..
} => company_target_import_blocker(target, company_context)
.or_else(|| territory_target_import_blocker(territory, company_context)),
RuntimeCondition::SpecialConditionThreshold { .. }
| RuntimeCondition::CandidateAvailabilityThreshold { .. }
| RuntimeCondition::EconomicStatusCodeThreshold { .. } => None,
}
}
@ -1896,6 +2000,7 @@ fn company_target_import_outcome(blocker: ImportBlocker) -> &'static str {
ImportBlocker::MissingTerritoryContext => "blocked_missing_territory_context",
ImportBlocker::NamedTerritoryBinding => "blocked_named_territory_binding",
ImportBlocker::UnmappedOrdinaryCondition => "blocked_unmapped_ordinary_condition",
ImportBlocker::UnmappedWorldCondition => "blocked_unmapped_world_condition",
ImportBlocker::MissingTrainContext => "blocked_missing_train_context",
ImportBlocker::MissingTrainTerritoryContext => "blocked_missing_train_territory_context",
}
@ -4621,8 +4726,10 @@ mod tests {
grouped_effect_row_counts: vec![1, 0, 0, 0],
grouped_effect_rows: vec![real_territory_access_row(
true,
vec!["territory access row is missing company or territory scope"
.to_string()],
vec![
"territory access row is missing company or territory scope"
.to_string(),
],
)],
decoded_conditions: Vec::new(),
decoded_actions: vec![],

View file

@ -39,14 +39,13 @@ pub use runtime::{
RuntimeCompanyMetric, RuntimeCompanyTarget, RuntimeCompanyTerritoryAccess,
RuntimeCompanyTerritoryTrackPieceCount, RuntimeCondition, RuntimeConditionComparator,
RuntimeEffect, RuntimeEventRecord, RuntimeEventRecordTemplate,
RuntimePackedEventCollectionSummary,
RuntimePackedEventCompactControlSummary, RuntimePackedEventConditionRowSummary,
RuntimePackedEventGroupedEffectRowSummary, RuntimePackedEventNegativeSentinelScopeSummary,
RuntimePackedEventRecordSummary, RuntimePackedEventTextBandSummary, RuntimePlayer,
RuntimePlayerConditionTestScope, RuntimePlayerTarget, RuntimeSaveProfileState,
RuntimeServiceState, RuntimeState, RuntimeTerritory, RuntimeTerritoryMetric,
RuntimeTerritoryTarget, RuntimeTrackMetric, RuntimeTrackPieceCounts, RuntimeTrain,
RuntimeWorldRestoreState,
RuntimePackedEventCollectionSummary, RuntimePackedEventCompactControlSummary,
RuntimePackedEventConditionRowSummary, RuntimePackedEventGroupedEffectRowSummary,
RuntimePackedEventNegativeSentinelScopeSummary, RuntimePackedEventRecordSummary,
RuntimePackedEventTextBandSummary, RuntimePlayer, RuntimePlayerConditionTestScope,
RuntimePlayerTarget, RuntimeSaveProfileState, RuntimeServiceState, RuntimeState,
RuntimeTerritory, RuntimeTerritoryMetric, RuntimeTerritoryTarget, RuntimeTrackMetric,
RuntimeTrackPieceCounts, RuntimeTrain, RuntimeWorldRestoreState,
};
pub use smp::{
SMP_FOUR_SIDECAR_BYTE_PLANES_MIN_BUNDLE_VERSION, SmpAlignedRuntimeRuleBandLane,

View file

@ -228,6 +228,20 @@ pub enum RuntimeCondition {
comparator: RuntimeConditionComparator,
value: i64,
},
SpecialConditionThreshold {
label: String,
comparator: RuntimeConditionComparator,
value: i64,
},
CandidateAvailabilityThreshold {
name: String,
comparator: RuntimeConditionComparator,
value: i64,
},
EconomicStatusCodeThreshold {
comparator: RuntimeConditionComparator,
value: i64,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@ -1235,6 +1249,21 @@ fn validate_runtime_condition(
validate_company_target(target, valid_company_ids)?;
validate_territory_target(territory, valid_territory_ids)
}
RuntimeCondition::SpecialConditionThreshold { label, .. } => {
if label.trim().is_empty() {
Err("label must not be empty".to_string())
} else {
Ok(())
}
}
RuntimeCondition::CandidateAvailabilityThreshold { name, .. } => {
if name.trim().is_empty() {
Err("name must not be empty".to_string())
} else {
Ok(())
}
}
RuntimeCondition::EconomicStatusCodeThreshold { .. } => Ok(()),
}
}

View file

@ -2126,9 +2126,8 @@ fn parse_real_event_runtime_record_summary(
&& row.raw_scalar_value != 0
&& (!company_target_present || !territory_target_present)
{
row.notes.push(
"territory access row is missing company or territory scope".to_string(),
);
row.notes
.push("territory access row is missing company or territory scope".to_string());
}
}
}
@ -2927,18 +2926,20 @@ fn runtime_effect_supported_for_save_import(effect: &RuntimeEffect) -> bool {
),
RuntimeEffect::SetCompanyTerritoryAccess {
target, territory, ..
} => matches!(
target,
RuntimeCompanyTarget::AllActive
| RuntimeCompanyTarget::Ids { .. }
| RuntimeCompanyTarget::HumanCompanies
| RuntimeCompanyTarget::AiCompanies
| RuntimeCompanyTarget::SelectedCompany
| RuntimeCompanyTarget::ConditionTrueCompany
) && matches!(
territory,
RuntimeTerritoryTarget::AllTerritories | RuntimeTerritoryTarget::Ids { .. }
),
} => {
matches!(
target,
RuntimeCompanyTarget::AllActive
| RuntimeCompanyTarget::Ids { .. }
| RuntimeCompanyTarget::HumanCompanies
| RuntimeCompanyTarget::AiCompanies
| RuntimeCompanyTarget::SelectedCompany
| RuntimeCompanyTarget::ConditionTrueCompany
) && matches!(
territory,
RuntimeTerritoryTarget::AllTerritories | RuntimeTerritoryTarget::Ids { .. }
)
}
RuntimeEffect::SetCompanyCash { target, .. }
| RuntimeEffect::AdjustCompanyCash { target, .. }
| RuntimeEffect::AdjustCompanyDebt { target, .. } => matches!(
@ -2961,7 +2962,10 @@ fn runtime_condition_supported_for_save_import(condition: &RuntimeCondition) ->
match condition {
RuntimeCondition::CompanyNumericThreshold { .. }
| RuntimeCondition::TerritoryNumericThreshold { .. }
| RuntimeCondition::CompanyTerritoryNumericThreshold { .. } => true,
| RuntimeCondition::CompanyTerritoryNumericThreshold { .. }
| RuntimeCondition::SpecialConditionThreshold { .. }
| RuntimeCondition::CandidateAvailabilityThreshold { .. }
| RuntimeCondition::EconomicStatusCodeThreshold { .. } => true,
}
}

View file

@ -651,6 +651,46 @@ fn evaluate_record_conditions(
return Ok(None);
}
}
RuntimeCondition::SpecialConditionThreshold {
label,
comparator,
value,
} => {
let actual = state
.special_conditions
.get(label)
.copied()
.map(i64::from)
.unwrap_or(0);
if !compare_condition_value(actual, *comparator, *value) {
return Ok(None);
}
}
RuntimeCondition::CandidateAvailabilityThreshold {
name,
comparator,
value,
} => {
let actual = state
.candidate_availability
.get(name)
.copied()
.map(i64::from)
.unwrap_or(0);
if !compare_condition_value(actual, *comparator, *value) {
return Ok(None);
}
}
RuntimeCondition::EconomicStatusCodeThreshold { comparator, value } => {
let actual = state
.world_restore
.economic_status_code
.map(i64::from)
.unwrap_or(0);
if !compare_condition_value(actual, *comparator, *value) {
return Ok(None);
}
}
}
}
@ -1039,7 +1079,8 @@ fn set_company_territory_access_pairs(
}
} else {
access_entries.retain(|entry| {
!(company_ids.contains(&entry.company_id) && territory_ids.contains(&entry.territory_id))
!(company_ids.contains(&entry.company_id)
&& territory_ids.contains(&entry.territory_id))
});
}
}
@ -1785,6 +1826,78 @@ mod tests {
assert!(error.contains("condition-evaluation context"));
}
#[test]
fn evaluates_world_state_conditions_before_effects_run() {
let mut state = RuntimeState {
world_restore: RuntimeWorldRestoreState {
economic_status_code: Some(3),
..RuntimeWorldRestoreState::default()
},
candidate_availability: BTreeMap::from([(String::from("Mogul"), 2)]),
special_conditions: BTreeMap::from([(String::from("Use Wartime Cargos"), 1)]),
event_runtime_records: vec![
RuntimeEventRecord {
record_id: 23,
trigger_kind: 7,
active: true,
service_count: 0,
marks_collection_dirty: false,
one_shot: false,
has_fired: false,
conditions: vec![
RuntimeCondition::SpecialConditionThreshold {
label: "Use Wartime Cargos".to_string(),
comparator: RuntimeConditionComparator::Ge,
value: 1,
},
RuntimeCondition::CandidateAvailabilityThreshold {
name: "Mogul".to_string(),
comparator: RuntimeConditionComparator::Eq,
value: 2,
},
RuntimeCondition::EconomicStatusCodeThreshold {
comparator: RuntimeConditionComparator::Eq,
value: 3,
},
],
effects: vec![RuntimeEffect::SetWorldFlag {
key: "world_condition_passed".to_string(),
value: true,
}],
},
RuntimeEventRecord {
record_id: 24,
trigger_kind: 7,
active: true,
service_count: 0,
marks_collection_dirty: false,
one_shot: false,
has_fired: false,
conditions: vec![RuntimeCondition::SpecialConditionThreshold {
label: "Disable Cargo Economy".to_string(),
comparator: RuntimeConditionComparator::Gt,
value: 0,
}],
effects: vec![RuntimeEffect::SetWorldFlag {
key: "world_condition_failed".to_string(),
value: true,
}],
},
],
..state()
};
let result = execute_step_command(
&mut state,
&StepCommand::ServiceTriggerKind { trigger_kind: 7 },
)
.expect("world-state conditions should evaluate successfully");
assert_eq!(result.service_events[0].serviced_record_ids, vec![23]);
assert_eq!(state.world_flags.get("world_condition_passed"), Some(&true));
assert_eq!(state.world_flags.get("world_condition_failed"), None);
}
#[test]
fn one_shot_record_only_fires_once() {
let mut state = RuntimeState {

View file

@ -56,8 +56,10 @@ pub struct RuntimeSummary {
pub packed_event_blocked_missing_territory_context_count: usize,
pub packed_event_blocked_named_territory_binding_count: usize,
pub packed_event_blocked_unmapped_ordinary_condition_count: usize,
pub packed_event_blocked_unmapped_world_condition_count: usize,
pub packed_event_blocked_missing_compact_control_count: usize,
pub packed_event_blocked_unmapped_real_descriptor_count: usize,
pub packed_event_blocked_unmapped_world_descriptor_count: usize,
pub packed_event_blocked_territory_access_variant_count: usize,
pub packed_event_blocked_territory_access_scope_count: usize,
pub packed_event_blocked_missing_train_context_count: usize,
@ -393,6 +395,20 @@ impl RuntimeSummary {
.count()
})
.unwrap_or(0),
packed_event_blocked_unmapped_world_condition_count: state
.packed_event_collection
.as_ref()
.map(|summary| {
summary
.records
.iter()
.filter(|record| {
record.import_outcome.as_deref()
== Some("blocked_unmapped_world_condition")
})
.count()
})
.unwrap_or(0),
packed_event_blocked_missing_compact_control_count: state
.packed_event_collection
.as_ref()
@ -421,6 +437,20 @@ impl RuntimeSummary {
.count()
})
.unwrap_or(0),
packed_event_blocked_unmapped_world_descriptor_count: state
.packed_event_collection
.as_ref()
.map(|summary| {
summary
.records
.iter()
.filter(|record| {
record.import_outcome.as_deref()
== Some("blocked_unmapped_world_descriptor")
})
.count()
})
.unwrap_or(0),
packed_event_blocked_territory_access_variant_count: state
.packed_event_collection
.as_ref()
@ -843,4 +873,107 @@ mod tests {
assert_eq!(summary.company_count, 2);
assert_eq!(summary.active_company_count, 1);
}
#[test]
fn counts_world_frontier_buckets_separately() {
let state = RuntimeState {
calendar: CalendarPoint {
year: 1830,
month_slot: 0,
phase_slot: 0,
tick_slot: 0,
},
world_flags: BTreeMap::new(),
save_profile: RuntimeSaveProfileState::default(),
world_restore: RuntimeWorldRestoreState::default(),
metadata: BTreeMap::new(),
companies: Vec::new(),
selected_company_id: None,
players: Vec::new(),
selected_player_id: None,
trains: Vec::new(),
territories: Vec::new(),
company_territory_track_piece_counts: Vec::new(),
company_territory_access: Vec::new(),
packed_event_collection: Some(RuntimePackedEventCollectionSummary {
source_kind: "packed-event-runtime-collection".to_string(),
mechanism_family: "classic-save-rehydrate-v1".to_string(),
mechanism_confidence: "grounded".to_string(),
container_profile_family: Some("rt3-classic-save-container-v1".to_string()),
packed_state_version: 0x3e9,
packed_state_version_hex: "0x000003e9".to_string(),
live_id_bound: 2,
live_record_count: 2,
live_entry_ids: vec![21, 22],
decoded_record_count: 2,
imported_runtime_record_count: 0,
records: vec![
RuntimePackedEventRecordSummary {
record_index: 0,
live_entry_id: 21,
payload_offset: Some(0x7202),
payload_len: Some(96),
decode_status: "parity_only".to_string(),
payload_family: "real_packed_v1".to_string(),
trigger_kind: Some(7),
active: None,
marks_collection_dirty: None,
one_shot: None,
compact_control: None,
text_bands: Vec::new(),
standalone_condition_row_count: 0,
standalone_condition_rows: Vec::new(),
negative_sentinel_scope: None,
grouped_effect_row_counts: vec![1, 0, 0, 0],
grouped_effect_rows: Vec::new(),
grouped_company_targets: Vec::new(),
decoded_conditions: Vec::new(),
decoded_actions: Vec::new(),
executable_import_ready: false,
import_outcome: Some("blocked_unmapped_world_descriptor".to_string()),
notes: Vec::new(),
},
RuntimePackedEventRecordSummary {
record_index: 1,
live_entry_id: 22,
payload_offset: Some(0x7242),
payload_len: Some(96),
decode_status: "parity_only".to_string(),
payload_family: "real_packed_v1".to_string(),
trigger_kind: Some(7),
active: None,
marks_collection_dirty: None,
one_shot: None,
compact_control: None,
text_bands: Vec::new(),
standalone_condition_row_count: 1,
standalone_condition_rows: Vec::new(),
negative_sentinel_scope: None,
grouped_effect_row_counts: vec![0, 0, 0, 0],
grouped_effect_rows: Vec::new(),
grouped_company_targets: Vec::new(),
decoded_conditions: Vec::new(),
decoded_actions: Vec::new(),
executable_import_ready: false,
import_outcome: Some("blocked_unmapped_world_condition".to_string()),
notes: Vec::new(),
},
],
}),
event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(),
special_conditions: BTreeMap::new(),
service_state: RuntimeServiceState::default(),
};
let summary = RuntimeSummary::from_state(&state);
assert_eq!(
summary.packed_event_blocked_unmapped_world_descriptor_count,
1
);
assert_eq!(
summary.packed_event_blocked_unmapped_world_condition_count,
1
);
}
}