Implement runtime variable event conditions

This commit is contained in:
Jan Petykiewicz 2026-04-17 08:50:35 -07:00
commit bd9e1421a1
15 changed files with 1442 additions and 29 deletions

View file

@ -4481,11 +4481,17 @@ mod tests {
);
let runtime_variable_overlay_fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../fixtures/runtime/packed-event-runtime-variable-overlay-fixture.json");
let runtime_variable_condition_overlay_fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join(
"../../fixtures/runtime/packed-event-runtime-variable-condition-overlay-fixture.json",
);
let cargo_economics_fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../fixtures/runtime/packed-event-cargo-economics-save-slice-fixture.json");
let cargo_economics_parity_fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(
"../../fixtures/runtime/packed-event-cargo-economics-parity-save-slice-fixture.json",
);
let add_building_shell_fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../fixtures/runtime/packed-event-add-building-shell-save-slice-fixture.json");
let world_scalar_condition_fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(
"../../fixtures/runtime/packed-event-world-scalar-condition-save-slice-fixture.json",
);
@ -4599,10 +4605,14 @@ mod tests {
.expect("save-slice-backed world-scalar override fixture should summarize");
run_runtime_summarize_fixture(&runtime_variable_overlay_fixture)
.expect("overlay-backed runtime-variable fixture should summarize");
run_runtime_summarize_fixture(&runtime_variable_condition_overlay_fixture)
.expect("overlay-backed runtime-variable condition fixture should summarize");
run_runtime_summarize_fixture(&cargo_economics_fixture)
.expect("save-slice-backed cargo-economics fixture should summarize");
run_runtime_summarize_fixture(&cargo_economics_parity_fixture)
.expect("save-slice-backed cargo-economics parity fixture should summarize");
run_runtime_summarize_fixture(&add_building_shell_fixture)
.expect("save-slice-backed add-building shell fixture should summarize");
run_runtime_summarize_fixture(&world_scalar_condition_fixture)
.expect("save-slice-backed executable world-scalar condition fixture should summarize");
run_runtime_summarize_fixture(&world_scalar_condition_parity_fixture)

View file

@ -1302,6 +1302,7 @@ fn lowered_record_decoded_conditions(
}
let lowered_company_target = lowered_condition_true_company_target(record)?;
let lowered_player_target = lowered_condition_true_player_target(record)?;
let ordinary_rows = record
.standalone_condition_rows
.iter()
@ -1313,6 +1314,7 @@ fn lowered_record_decoded_conditions(
condition,
row,
lowered_company_target.as_ref(),
lowered_player_target.as_ref(),
company_context,
)
})
@ -1949,9 +1951,19 @@ fn lower_condition_targets_in_condition(
condition: &RuntimeCondition,
row: &SmpLoadedPackedEventConditionRowSummary,
lowered_company_target: Option<&RuntimeCompanyTarget>,
lowered_player_target: Option<&RuntimePlayerTarget>,
company_context: &ImportRuntimeContext,
) -> Result<RuntimeCondition, ImportBlocker> {
Ok(match condition {
RuntimeCondition::WorldVariableThreshold {
index,
comparator,
value,
} => RuntimeCondition::WorldVariableThreshold {
index: *index,
comparator: *comparator,
value: *value,
},
RuntimeCondition::CompanyNumericThreshold {
target,
metric,
@ -1966,6 +1978,20 @@ fn lower_condition_targets_in_condition(
comparator: *comparator,
value: *value,
},
RuntimeCondition::CompanyVariableThreshold {
target,
index,
comparator,
value,
} => RuntimeCondition::CompanyVariableThreshold {
target: lower_condition_true_company_target_in_company_target(
target,
lowered_company_target,
)?,
index: *index,
comparator: *comparator,
value: *value,
},
RuntimeCondition::ChairmanNumericThreshold {
target,
metric,
@ -1977,6 +2003,20 @@ fn lower_condition_targets_in_condition(
comparator: *comparator,
value: *value,
},
RuntimeCondition::PlayerVariableThreshold {
target,
index,
comparator,
value,
} => RuntimeCondition::PlayerVariableThreshold {
target: lower_condition_true_player_target_in_player_target(
target,
lowered_player_target,
)?,
index: *index,
comparator: *comparator,
value: *value,
},
RuntimeCondition::TerritoryNumericThreshold {
target,
metric,
@ -1988,6 +2028,17 @@ fn lower_condition_targets_in_condition(
comparator: *comparator,
value: *value,
},
RuntimeCondition::TerritoryVariableThreshold {
target,
index,
comparator,
value,
} => RuntimeCondition::TerritoryVariableThreshold {
target: lower_territory_target_in_condition(target, row, company_context)?,
index: *index,
comparator: *comparator,
value: *value,
},
RuntimeCondition::CompanyTerritoryNumericThreshold {
target,
territory,
@ -2187,11 +2238,17 @@ fn record_uses_condition_true_player(record: &SmpLoadedPackedEventRecordSummary)
fn condition_uses_condition_true_company(condition: &RuntimeCondition) -> bool {
match condition {
RuntimeCondition::CompanyNumericThreshold { target, .. }
| RuntimeCondition::CompanyVariableThreshold { target, .. }
| RuntimeCondition::CompanyTerritoryNumericThreshold { target, .. } => {
matches!(target, RuntimeCompanyTarget::ConditionTrueCompany)
}
RuntimeCondition::PlayerVariableThreshold { target, .. } => {
matches!(target, RuntimePlayerTarget::ConditionTruePlayer)
}
RuntimeCondition::ChairmanNumericThreshold { .. } => false,
RuntimeCondition::TerritoryNumericThreshold { .. }
| RuntimeCondition::TerritoryVariableThreshold { .. }
| RuntimeCondition::WorldVariableThreshold { .. }
| RuntimeCondition::SpecialConditionThreshold { .. }
| RuntimeCondition::CandidateAvailabilityThreshold { .. }
| RuntimeCondition::NamedLocomotiveAvailabilityThreshold { .. }
@ -2719,6 +2776,7 @@ fn conditions_provide_company_context(conditions: &[RuntimeCondition]) -> bool {
matches!(
condition,
RuntimeCondition::CompanyNumericThreshold { .. }
| RuntimeCondition::CompanyVariableThreshold { .. }
| RuntimeCondition::CompanyTerritoryNumericThreshold { .. }
)
})
@ -3026,7 +3084,8 @@ fn record_has_world_state_condition_rows(record: &SmpLoadedPackedEventRecordSumm
fn runtime_condition_is_world_state(condition: &RuntimeCondition) -> bool {
matches!(
condition,
RuntimeCondition::SpecialConditionThreshold { .. }
RuntimeCondition::WorldVariableThreshold { .. }
| RuntimeCondition::SpecialConditionThreshold { .. }
| RuntimeCondition::CandidateAvailabilityThreshold { .. }
| RuntimeCondition::NamedLocomotiveAvailabilityThreshold { .. }
| RuntimeCondition::NamedLocomotiveCostThreshold { .. }
@ -3180,15 +3239,25 @@ fn runtime_condition_company_target_import_blocker(
company_context: &ImportRuntimeContext,
) -> Option<ImportBlocker> {
match condition {
RuntimeCondition::WorldVariableThreshold { .. } => None,
RuntimeCondition::CompanyNumericThreshold { target, .. } => {
company_target_import_blocker(target, company_context)
}
RuntimeCondition::CompanyVariableThreshold { target, .. } => {
company_target_import_blocker(target, company_context)
}
RuntimeCondition::PlayerVariableThreshold { target, .. } => {
player_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)
}
RuntimeCondition::TerritoryVariableThreshold { target, .. } => {
territory_target_import_blocker(target, company_context)
}
RuntimeCondition::CompanyTerritoryNumericThreshold {
target, territory, ..
} => company_target_import_blocker(target, company_context)

View file

@ -311,24 +311,47 @@ pub enum RuntimeTrackMetric {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum RuntimeCondition {
WorldVariableThreshold {
index: u32,
comparator: RuntimeConditionComparator,
value: i64,
},
CompanyNumericThreshold {
target: RuntimeCompanyTarget,
metric: RuntimeCompanyMetric,
comparator: RuntimeConditionComparator,
value: i64,
},
CompanyVariableThreshold {
target: RuntimeCompanyTarget,
index: u32,
comparator: RuntimeConditionComparator,
value: i64,
},
ChairmanNumericThreshold {
target: RuntimeChairmanTarget,
metric: RuntimeChairmanMetric,
comparator: RuntimeConditionComparator,
value: i64,
},
PlayerVariableThreshold {
target: RuntimePlayerTarget,
index: u32,
comparator: RuntimeConditionComparator,
value: i64,
},
TerritoryNumericThreshold {
target: RuntimeTerritoryTarget,
metric: RuntimeTerritoryMetric,
comparator: RuntimeConditionComparator,
value: i64,
},
TerritoryVariableThreshold {
target: RuntimeTerritoryTarget,
index: u32,
comparator: RuntimeConditionComparator,
value: i64,
},
CompanyTerritoryNumericThreshold {
target: RuntimeCompanyTarget,
territory: RuntimeTerritoryTarget,
@ -1209,6 +1232,7 @@ impl RuntimeState {
validate_runtime_condition(
condition,
&seen_company_ids,
&seen_player_ids,
&seen_chairman_profile_ids,
&seen_territory_ids,
)
@ -1810,6 +1834,7 @@ fn validate_event_record_template(
validate_runtime_condition(
condition,
valid_company_ids,
valid_player_ids,
valid_chairman_profile_ids,
valid_territory_ids,
)
@ -1842,19 +1867,51 @@ fn validate_event_record_template(
fn validate_runtime_condition(
condition: &RuntimeCondition,
valid_company_ids: &BTreeSet<u32>,
valid_player_ids: &BTreeSet<u32>,
valid_chairman_profile_ids: &BTreeSet<u32>,
valid_territory_ids: &BTreeSet<u32>,
) -> Result<(), String> {
match condition {
RuntimeCondition::WorldVariableThreshold { index, .. } => {
if !(1..=4).contains(index) {
Err("index must be in 1..=4".to_string())
} else {
Ok(())
}
}
RuntimeCondition::CompanyNumericThreshold { target, .. } => {
validate_company_target(target, valid_company_ids)
}
RuntimeCondition::CompanyVariableThreshold { target, index, .. } => {
validate_company_target(target, valid_company_ids)?;
if !(1..=4).contains(index) {
Err("index must be in 1..=4".to_string())
} else {
Ok(())
}
}
RuntimeCondition::ChairmanNumericThreshold { target, .. } => {
validate_chairman_target(target, valid_chairman_profile_ids)
}
RuntimeCondition::PlayerVariableThreshold { target, index, .. } => {
validate_player_target(target, valid_player_ids)?;
if !(1..=4).contains(index) {
Err("index must be in 1..=4".to_string())
} else {
Ok(())
}
}
RuntimeCondition::TerritoryNumericThreshold { target, .. } => {
validate_territory_target(target, valid_territory_ids)
}
RuntimeCondition::TerritoryVariableThreshold { target, index, .. } => {
validate_territory_target(target, valid_territory_ids)?;
if !(1..=4).contains(index) {
Err("index must be in 1..=4".to_string())
} else {
Ok(())
}
}
RuntimeCondition::CompanyTerritoryNumericThreshold {
target, territory, ..
} => {

View file

@ -333,9 +333,13 @@ pub(crate) fn grouped_effect_descriptor_runtime_status_name(
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RealOrdinaryConditionMetric {
WorldVariable(u32),
Company(RuntimeCompanyMetric),
CompanyVariable(u32),
PlayerVariable(u32),
Chairman(RuntimeChairmanMetric),
Territory(RuntimeTerritoryMetric),
TerritoryVariable(u32),
CompanyTerritory(RuntimeTrackMetric),
}
@ -511,6 +515,22 @@ const GROUNDED_LOCOMOTIVE_PREFIX: [&str; 61] = [
];
const REAL_CANDIDATE_AVAILABILITY_CONDITION_TEMPLATE_ID: i32 = 435;
const REAL_WORLD_VARIABLE_1_CONDITION_ID: i32 = 2241;
const REAL_WORLD_VARIABLE_2_CONDITION_ID: i32 = 2242;
const REAL_WORLD_VARIABLE_3_CONDITION_ID: i32 = 2243;
const REAL_WORLD_VARIABLE_4_CONDITION_ID: i32 = 2244;
const REAL_COMPANY_VARIABLE_1_CONDITION_ID: i32 = 2245;
const REAL_COMPANY_VARIABLE_2_CONDITION_ID: i32 = 2246;
const REAL_COMPANY_VARIABLE_3_CONDITION_ID: i32 = 2247;
const REAL_COMPANY_VARIABLE_4_CONDITION_ID: i32 = 2248;
const REAL_PLAYER_VARIABLE_1_CONDITION_ID: i32 = 2249;
const REAL_PLAYER_VARIABLE_2_CONDITION_ID: i32 = 2250;
const REAL_PLAYER_VARIABLE_3_CONDITION_ID: i32 = 2251;
const REAL_PLAYER_VARIABLE_4_CONDITION_ID: i32 = 2252;
const REAL_TERRITORY_VARIABLE_1_CONDITION_ID: i32 = 2253;
const REAL_TERRITORY_VARIABLE_2_CONDITION_ID: i32 = 2254;
const REAL_TERRITORY_VARIABLE_3_CONDITION_ID: i32 = 2255;
const REAL_TERRITORY_VARIABLE_4_CONDITION_ID: i32 = 2256;
const REAL_CHAIRMAN_CASH_CONDITION_ID: i32 = 2218;
const REAL_CHAIRMAN_HOLDINGS_TOTAL_CONDITION_ID: i32 = 2239;
const REAL_CHAIRMAN_NET_WORTH_CONDITION_ID: i32 = 2240;
@ -530,7 +550,87 @@ const REAL_OTHER_CARGO_PRODUCTION_TOTAL_CONDITION_ID: i32 = 2421;
const REAL_LIMITED_TRACK_BUILDING_AMOUNT_CONDITION_ID: i32 = 2547;
const REAL_TERRITORY_ACCESS_COST_CONDITION_ID: i32 = 1516;
const REAL_ORDINARY_CONDITION_METADATA: [RealOrdinaryConditionMetadata; 40] = [
const REAL_ORDINARY_CONDITION_METADATA: [RealOrdinaryConditionMetadata; 56] = [
RealOrdinaryConditionMetadata {
raw_condition_id: REAL_WORLD_VARIABLE_1_CONDITION_ID,
label: "Game Variable 1",
kind: RealOrdinaryConditionKind::Numeric(RealOrdinaryConditionMetric::WorldVariable(1)),
},
RealOrdinaryConditionMetadata {
raw_condition_id: REAL_WORLD_VARIABLE_2_CONDITION_ID,
label: "Game Variable 2",
kind: RealOrdinaryConditionKind::Numeric(RealOrdinaryConditionMetric::WorldVariable(2)),
},
RealOrdinaryConditionMetadata {
raw_condition_id: REAL_WORLD_VARIABLE_3_CONDITION_ID,
label: "Game Variable 3",
kind: RealOrdinaryConditionKind::Numeric(RealOrdinaryConditionMetric::WorldVariable(3)),
},
RealOrdinaryConditionMetadata {
raw_condition_id: REAL_WORLD_VARIABLE_4_CONDITION_ID,
label: "Game Variable 4",
kind: RealOrdinaryConditionKind::Numeric(RealOrdinaryConditionMetric::WorldVariable(4)),
},
RealOrdinaryConditionMetadata {
raw_condition_id: REAL_COMPANY_VARIABLE_1_CONDITION_ID,
label: "Company Variable 1",
kind: RealOrdinaryConditionKind::Numeric(RealOrdinaryConditionMetric::CompanyVariable(1)),
},
RealOrdinaryConditionMetadata {
raw_condition_id: REAL_COMPANY_VARIABLE_2_CONDITION_ID,
label: "Company Variable 2",
kind: RealOrdinaryConditionKind::Numeric(RealOrdinaryConditionMetric::CompanyVariable(2)),
},
RealOrdinaryConditionMetadata {
raw_condition_id: REAL_COMPANY_VARIABLE_3_CONDITION_ID,
label: "Company Variable 3",
kind: RealOrdinaryConditionKind::Numeric(RealOrdinaryConditionMetric::CompanyVariable(3)),
},
RealOrdinaryConditionMetadata {
raw_condition_id: REAL_COMPANY_VARIABLE_4_CONDITION_ID,
label: "Company Variable 4",
kind: RealOrdinaryConditionKind::Numeric(RealOrdinaryConditionMetric::CompanyVariable(4)),
},
RealOrdinaryConditionMetadata {
raw_condition_id: REAL_PLAYER_VARIABLE_1_CONDITION_ID,
label: "Player Variable 1",
kind: RealOrdinaryConditionKind::Numeric(RealOrdinaryConditionMetric::PlayerVariable(1)),
},
RealOrdinaryConditionMetadata {
raw_condition_id: REAL_PLAYER_VARIABLE_2_CONDITION_ID,
label: "Player Variable 2",
kind: RealOrdinaryConditionKind::Numeric(RealOrdinaryConditionMetric::PlayerVariable(2)),
},
RealOrdinaryConditionMetadata {
raw_condition_id: REAL_PLAYER_VARIABLE_3_CONDITION_ID,
label: "Player Variable 3",
kind: RealOrdinaryConditionKind::Numeric(RealOrdinaryConditionMetric::PlayerVariable(3)),
},
RealOrdinaryConditionMetadata {
raw_condition_id: REAL_PLAYER_VARIABLE_4_CONDITION_ID,
label: "Player Variable 4",
kind: RealOrdinaryConditionKind::Numeric(RealOrdinaryConditionMetric::PlayerVariable(4)),
},
RealOrdinaryConditionMetadata {
raw_condition_id: REAL_TERRITORY_VARIABLE_1_CONDITION_ID,
label: "Territory Variable 1",
kind: RealOrdinaryConditionKind::Numeric(RealOrdinaryConditionMetric::TerritoryVariable(1)),
},
RealOrdinaryConditionMetadata {
raw_condition_id: REAL_TERRITORY_VARIABLE_2_CONDITION_ID,
label: "Territory Variable 2",
kind: RealOrdinaryConditionKind::Numeric(RealOrdinaryConditionMetric::TerritoryVariable(2)),
},
RealOrdinaryConditionMetadata {
raw_condition_id: REAL_TERRITORY_VARIABLE_3_CONDITION_ID,
label: "Territory Variable 3",
kind: RealOrdinaryConditionKind::Numeric(RealOrdinaryConditionMetric::TerritoryVariable(3)),
},
RealOrdinaryConditionMetadata {
raw_condition_id: REAL_TERRITORY_VARIABLE_4_CONDITION_ID,
label: "Territory Variable 4",
kind: RealOrdinaryConditionKind::Numeric(RealOrdinaryConditionMetric::TerritoryVariable(4)),
},
RealOrdinaryConditionMetadata {
raw_condition_id: 1802,
label: "Current Cash",
@ -3580,6 +3680,13 @@ fn decode_real_condition_row(
let comparator = decode_real_condition_comparator(row.subtype)?;
let value = decode_real_condition_threshold(&row.flag_bytes)?;
match metadata.kind {
RealOrdinaryConditionKind::Numeric(RealOrdinaryConditionMetric::WorldVariable(index)) => {
Some(RuntimeCondition::WorldVariableThreshold {
index,
comparator,
value,
})
}
RealOrdinaryConditionKind::Numeric(RealOrdinaryConditionMetric::Company(metric)) => {
Some(RuntimeCondition::CompanyNumericThreshold {
target: RuntimeCompanyTarget::ConditionTrueCompany,
@ -3588,6 +3695,26 @@ fn decode_real_condition_row(
value,
})
}
RealOrdinaryConditionKind::Numeric(RealOrdinaryConditionMetric::CompanyVariable(index)) => {
Some(RuntimeCondition::CompanyVariableThreshold {
target: RuntimeCompanyTarget::ConditionTrueCompany,
index,
comparator,
value,
})
}
RealOrdinaryConditionKind::Numeric(RealOrdinaryConditionMetric::PlayerVariable(index)) => {
negative_sentinel_scope.and_then(|scope| {
real_condition_player_target(scope).map(|target| {
RuntimeCondition::PlayerVariableThreshold {
target,
index,
comparator,
value,
}
})
})
}
RealOrdinaryConditionKind::Numeric(RealOrdinaryConditionMetric::Chairman(metric)) => {
negative_sentinel_scope.and_then(|scope| {
real_condition_chairman_target(scope).map(|target| {
@ -3600,6 +3727,16 @@ fn decode_real_condition_row(
})
})
}
RealOrdinaryConditionKind::Numeric(RealOrdinaryConditionMetric::TerritoryVariable(
index,
)) => negative_sentinel_scope
.filter(|scope| scope.territory_scope_selector_is_0x63)
.map(|_| RuntimeCondition::TerritoryVariableThreshold {
target: RuntimeTerritoryTarget::AllTerritories,
index,
comparator,
value,
}),
RealOrdinaryConditionKind::Numeric(RealOrdinaryConditionMetric::Territory(metric)) => {
negative_sentinel_scope
.filter(|scope| scope.territory_scope_selector_is_0x63)
@ -3727,6 +3864,22 @@ fn real_condition_chairman_target(
}
}
fn real_condition_player_target(
scope: &SmpLoadedPackedEventNegativeSentinelScopeSummary,
) -> Option<RuntimePlayerTarget> {
match scope.player_test_scope {
RuntimePlayerConditionTestScope::AllPlayers => Some(RuntimePlayerTarget::AllActive),
RuntimePlayerConditionTestScope::SelectedPlayerOnly => {
Some(RuntimePlayerTarget::SelectedPlayer)
}
RuntimePlayerConditionTestScope::AiPlayersOnly => Some(RuntimePlayerTarget::AiPlayers),
RuntimePlayerConditionTestScope::HumanPlayersOnly => {
Some(RuntimePlayerTarget::HumanPlayers)
}
RuntimePlayerConditionTestScope::Disabled => None,
}
}
fn real_grouped_effect_descriptor_metadata(
descriptor_id: u32,
) -> Option<RealGroupedEffectDescriptorMetadata> {
@ -4981,9 +5134,13 @@ 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::WorldVariableThreshold { .. }
| RuntimeCondition::CompanyNumericThreshold { .. }
| RuntimeCondition::CompanyVariableThreshold { .. }
| RuntimeCondition::PlayerVariableThreshold { .. }
| RuntimeCondition::ChairmanNumericThreshold { .. }
| RuntimeCondition::TerritoryNumericThreshold { .. }
| RuntimeCondition::TerritoryVariableThreshold { .. }
| RuntimeCondition::CompanyTerritoryNumericThreshold { .. }
| RuntimeCondition::SpecialConditionThreshold { .. }
| RuntimeCondition::CandidateAvailabilityThreshold { .. }
@ -11188,6 +11345,14 @@ mod tests {
#[test]
fn looks_up_checked_in_chairman_and_governance_condition_metadata() {
let world_variable = real_ordinary_condition_metadata(REAL_WORLD_VARIABLE_1_CONDITION_ID)
.expect("world-variable condition metadata should exist");
assert_eq!(world_variable.label, "Game Variable 1");
let player_variable = real_ordinary_condition_metadata(REAL_PLAYER_VARIABLE_3_CONDITION_ID)
.expect("player-variable condition metadata should exist");
assert_eq!(player_variable.label, "Player Variable 3");
let chairman_cash = real_ordinary_condition_metadata(REAL_CHAIRMAN_CASH_CONDITION_ID)
.expect("chairman cash condition metadata should exist");
assert_eq!(chairman_cash.label, "Player Cash");
@ -11228,6 +11393,116 @@ mod tests {
assert_eq!(book_value.label, "Book Value Per Share");
}
#[test]
fn decodes_world_variable_condition() {
let row = SmpLoadedPackedEventConditionRowSummary {
row_index: 0,
raw_condition_id: REAL_WORLD_VARIABLE_1_CONDITION_ID,
subtype: 4,
flag_bytes: {
let mut bytes = vec![0; 25];
bytes[0..4].copy_from_slice(&111_i32.to_le_bytes());
bytes
},
candidate_name: None,
comparator: Some("eq".to_string()),
metric: Some("Game Variable 1".to_string()),
semantic_family: Some("numeric_threshold".to_string()),
semantic_preview: Some("Test Game Variable 1 == 111".to_string()),
recovered_cargo_slot: None,
recovered_cargo_class: None,
requires_candidate_name_binding: false,
notes: vec![],
};
assert_eq!(
decode_real_condition_row(&row, None),
Some(RuntimeCondition::WorldVariableThreshold {
index: 1,
comparator: RuntimeConditionComparator::Eq,
value: 111,
})
);
}
#[test]
fn decodes_player_variable_condition_from_selected_player_scope() {
let row = SmpLoadedPackedEventConditionRowSummary {
row_index: 0,
raw_condition_id: REAL_PLAYER_VARIABLE_3_CONDITION_ID,
subtype: 4,
flag_bytes: {
let mut bytes = vec![0; 25];
bytes[0..4].copy_from_slice(&333_i32.to_le_bytes());
bytes
},
candidate_name: None,
comparator: Some("eq".to_string()),
metric: Some("Player Variable 3".to_string()),
semantic_family: Some("numeric_threshold".to_string()),
semantic_preview: Some("Test Player Variable 3 == 333".to_string()),
recovered_cargo_slot: None,
recovered_cargo_class: None,
requires_candidate_name_binding: false,
notes: vec![],
};
let negative_scope = SmpLoadedPackedEventNegativeSentinelScopeSummary {
company_test_scope: RuntimeCompanyConditionTestScope::Disabled,
player_test_scope: RuntimePlayerConditionTestScope::SelectedPlayerOnly,
territory_scope_selector_is_0x63: false,
source_row_indexes: vec![0],
};
assert_eq!(
decode_real_condition_row(&row, Some(&negative_scope)),
Some(RuntimeCondition::PlayerVariableThreshold {
target: RuntimePlayerTarget::SelectedPlayer,
index: 3,
comparator: RuntimeConditionComparator::Eq,
value: 333,
})
);
}
#[test]
fn decodes_territory_variable_condition_with_world_territory_scope() {
let row = SmpLoadedPackedEventConditionRowSummary {
row_index: 0,
raw_condition_id: REAL_TERRITORY_VARIABLE_4_CONDITION_ID,
subtype: 4,
flag_bytes: {
let mut bytes = vec![0; 25];
bytes[0..4].copy_from_slice(&444_i32.to_le_bytes());
bytes
},
candidate_name: None,
comparator: Some("eq".to_string()),
metric: Some("Territory Variable 4".to_string()),
semantic_family: Some("numeric_threshold".to_string()),
semantic_preview: Some("Test Territory Variable 4 == 444".to_string()),
recovered_cargo_slot: None,
recovered_cargo_class: None,
requires_candidate_name_binding: false,
notes: vec![],
};
let negative_scope = SmpLoadedPackedEventNegativeSentinelScopeSummary {
company_test_scope: RuntimeCompanyConditionTestScope::Disabled,
player_test_scope: RuntimePlayerConditionTestScope::Disabled,
territory_scope_selector_is_0x63: true,
source_row_indexes: vec![0],
};
assert_eq!(
decode_real_condition_row(&row, Some(&negative_scope)),
Some(RuntimeCondition::TerritoryVariableThreshold {
target: RuntimeTerritoryTarget::AllTerritories,
index: 4,
comparator: RuntimeConditionComparator::Eq,
value: 444,
})
);
}
#[test]
fn decodes_chairman_cash_condition_from_selected_player_scope() {
let row = SmpLoadedPackedEventConditionRowSummary {

View file

@ -782,10 +782,25 @@ fn evaluate_record_conditions(
}
let mut company_matches: Option<BTreeSet<u32>> = None;
let mut player_matches: Option<BTreeSet<u32>> = None;
let mut chairman_matches: Option<BTreeSet<u32>> = None;
for condition in conditions {
match condition {
RuntimeCondition::WorldVariableThreshold {
index,
comparator,
value,
} => {
let actual = state
.world_runtime_variables
.get(index)
.copied()
.unwrap_or(0);
if !compare_condition_value(actual, *comparator, *value) {
return Ok(None);
}
}
RuntimeCondition::CompanyNumericThreshold {
target,
metric,
@ -821,6 +836,37 @@ fn evaluate_record_conditions(
return Ok(None);
}
}
RuntimeCondition::CompanyVariableThreshold {
target,
index,
comparator,
value,
} => {
let resolved = resolve_company_target_ids(
state,
target,
&ResolvedConditionContext::default(),
)?;
let matching = resolved
.into_iter()
.filter(|company_id| {
let actual = state
.company_runtime_variables
.get(company_id)
.and_then(|vars| vars.get(index))
.copied()
.unwrap_or(0);
compare_condition_value(actual, *comparator, *value)
})
.collect::<BTreeSet<_>>();
if matching.is_empty() {
return Ok(None);
}
intersect_company_matches(&mut company_matches, matching);
if company_matches.as_ref().is_some_and(BTreeSet::is_empty) {
return Ok(None);
}
}
RuntimeCondition::TerritoryNumericThreshold {
target,
metric,
@ -833,6 +879,56 @@ fn evaluate_record_conditions(
return Ok(None);
}
}
RuntimeCondition::TerritoryVariableThreshold {
target,
index,
comparator,
value,
} => {
let territory_ids = resolve_territory_target_ids(state, target)?;
let actual = territory_ids
.iter()
.map(|territory_id| {
state
.territory_runtime_variables
.get(territory_id)
.and_then(|vars| vars.get(index))
.copied()
.unwrap_or(0)
})
.sum::<i64>();
if !compare_condition_value(actual, *comparator, *value) {
return Ok(None);
}
}
RuntimeCondition::PlayerVariableThreshold {
target,
index,
comparator,
value,
} => {
let resolved =
resolve_player_target_ids(state, target, &ResolvedConditionContext::default())?;
let matching = resolved
.into_iter()
.filter(|player_id| {
let actual = state
.player_runtime_variables
.get(player_id)
.and_then(|vars| vars.get(index))
.copied()
.unwrap_or(0);
compare_condition_value(actual, *comparator, *value)
})
.collect::<BTreeSet<_>>();
if matching.is_empty() {
return Ok(None);
}
intersect_player_matches(&mut player_matches, matching);
if player_matches.as_ref().is_some_and(BTreeSet::is_empty) {
return Ok(None);
}
}
RuntimeCondition::ChairmanNumericThreshold {
target,
metric,
@ -1050,7 +1146,7 @@ fn evaluate_record_conditions(
Ok(Some(ResolvedConditionContext {
matching_company_ids: company_matches.unwrap_or_default(),
matching_player_ids: BTreeSet::new(),
matching_player_ids: player_matches.unwrap_or_default(),
matching_chairman_profile_ids: chairman_matches.unwrap_or_default(),
}))
}
@ -1066,6 +1162,17 @@ fn intersect_company_matches(company_matches: &mut Option<BTreeSet<u32>>, next:
}
}
fn intersect_player_matches(player_matches: &mut Option<BTreeSet<u32>>, next: BTreeSet<u32>) {
match player_matches {
Some(existing) => {
existing.retain(|player_id| next.contains(player_id));
}
None => {
*player_matches = Some(next);
}
}
}
fn intersect_chairman_matches(chairman_matches: &mut Option<BTreeSet<u32>>, next: BTreeSet<u32>) {
match chairman_matches {
Some(existing) => {
@ -1586,8 +1693,9 @@ mod tests {
RuntimeChairmanMetric, RuntimeChairmanProfile, RuntimeChairmanTarget, RuntimeCompany,
RuntimeCompanyControllerKind, RuntimeCompanyTarget, RuntimeCondition,
RuntimeConditionComparator, RuntimeEffect, RuntimeEventRecord, RuntimeEventRecordTemplate,
RuntimePlayer, RuntimeSaveProfileState, RuntimeServiceState, RuntimeTerritory,
RuntimeTerritoryTarget, RuntimeTrackPieceCounts, RuntimeTrain, RuntimeWorldRestoreState,
RuntimePlayer, RuntimePlayerTarget, RuntimeSaveProfileState, RuntimeServiceState,
RuntimeTerritory, RuntimeTerritoryTarget, RuntimeTrackPieceCounts, RuntimeTrain,
RuntimeWorldRestoreState,
};
fn state() -> RuntimeState {
@ -2965,6 +3073,122 @@ mod tests {
assert_eq!(state.world_flags.get("world_scalar_condition_failed"), None);
}
#[test]
fn evaluates_runtime_variable_conditions_before_effects_run() {
let mut state = RuntimeState {
companies: vec![RuntimeCompany {
company_id: 1,
controller_kind: RuntimeCompanyControllerKind::Human,
current_cash: 10,
debt: 0,
credit_rating_score: None,
prime_rate: None,
track_piece_counts: RuntimeTrackPieceCounts::default(),
active: true,
available_track_laying_capacity: None,
linked_chairman_profile_id: None,
book_value_per_share: 0,
investor_confidence: 0,
management_attitude: 0,
takeover_cooldown_year: None,
merger_cooldown_year: None,
}],
selected_company_id: Some(1),
players: vec![RuntimePlayer {
player_id: 1,
current_cash: 50,
active: true,
controller_kind: RuntimeCompanyControllerKind::Human,
}],
selected_player_id: Some(1),
territories: vec![RuntimeTerritory {
territory_id: 7,
name: Some("North".to_string()),
track_piece_counts: RuntimeTrackPieceCounts::default(),
}],
world_runtime_variables: BTreeMap::from([(1, 111)]),
company_runtime_variables: BTreeMap::from([(1, BTreeMap::from([(2, 222)]))]),
player_runtime_variables: BTreeMap::from([(1, BTreeMap::from([(3, 333)]))]),
territory_runtime_variables: BTreeMap::from([(7, BTreeMap::from([(4, 444)]))]),
event_runtime_records: vec![
RuntimeEventRecord {
record_id: 27,
trigger_kind: 7,
active: true,
service_count: 0,
marks_collection_dirty: false,
one_shot: false,
has_fired: false,
conditions: vec![
RuntimeCondition::WorldVariableThreshold {
index: 1,
comparator: RuntimeConditionComparator::Eq,
value: 111,
},
RuntimeCondition::CompanyVariableThreshold {
target: RuntimeCompanyTarget::SelectedCompany,
index: 2,
comparator: RuntimeConditionComparator::Eq,
value: 222,
},
RuntimeCondition::PlayerVariableThreshold {
target: RuntimePlayerTarget::SelectedPlayer,
index: 3,
comparator: RuntimeConditionComparator::Eq,
value: 333,
},
RuntimeCondition::TerritoryVariableThreshold {
target: RuntimeTerritoryTarget::Ids { ids: vec![7] },
index: 4,
comparator: RuntimeConditionComparator::Eq,
value: 444,
},
],
effects: vec![RuntimeEffect::SetWorldFlag {
key: "runtime_variable_condition_passed".to_string(),
value: true,
}],
},
RuntimeEventRecord {
record_id: 28,
trigger_kind: 7,
active: true,
service_count: 0,
marks_collection_dirty: false,
one_shot: false,
has_fired: false,
conditions: vec![RuntimeCondition::PlayerVariableThreshold {
target: RuntimePlayerTarget::SelectedPlayer,
index: 4,
comparator: RuntimeConditionComparator::Gt,
value: 0,
}],
effects: vec![RuntimeEffect::SetWorldFlag {
key: "runtime_variable_condition_failed".to_string(),
value: true,
}],
},
],
..state()
};
let result = execute_step_command(
&mut state,
&StepCommand::ServiceTriggerKind { trigger_kind: 7 },
)
.expect("runtime-variable conditions should evaluate successfully");
assert_eq!(result.service_events[0].serviced_record_ids, vec![27]);
assert_eq!(
state.world_flags.get("runtime_variable_condition_passed"),
Some(&true)
);
assert_eq!(
state.world_flags.get("runtime_variable_condition_failed"),
None
);
}
#[test]
fn one_shot_record_only_fires_once() {
let mut state = RuntimeState {