Execute descriptor 3 as territory access rights

This commit is contained in:
Jan Petykiewicz 2026-04-15 20:53:35 -07:00
commit e9c8bfbb9c
20 changed files with 1226 additions and 89 deletions

View file

@ -179,6 +179,7 @@ mod tests {
trains: Vec::new(),
territories: Vec::new(),
company_territory_track_piece_counts: Vec::new(),
company_territory_access: Vec::new(),
packed_event_collection: None,
event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(),
@ -347,6 +348,7 @@ mod tests {
trains: Vec::new(),
territories: Vec::new(),
company_territory_track_piece_counts: Vec::new(),
company_territory_access: Vec::new(),
packed_event_collection: None,
event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(),

View file

@ -122,7 +122,9 @@ pub struct ExpectedRuntimeSummary {
#[serde(default)]
pub packed_event_blocked_unmapped_real_descriptor_count: Option<usize>,
#[serde(default)]
pub packed_event_blocked_territory_policy_descriptor_count: Option<usize>,
pub packed_event_blocked_territory_access_variant_count: Option<usize>,
#[serde(default)]
pub packed_event_blocked_territory_access_scope_count: Option<usize>,
#[serde(default)]
pub packed_event_blocked_missing_train_context_count: Option<usize>,
#[serde(default)]
@ -615,11 +617,19 @@ impl ExpectedRuntimeSummary {
));
}
}
if let Some(count) = self.packed_event_blocked_territory_policy_descriptor_count {
if actual.packed_event_blocked_territory_policy_descriptor_count != 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!(
"packed_event_blocked_territory_policy_descriptor_count mismatch: expected {count}, got {}",
actual.packed_event_blocked_territory_policy_descriptor_count
"packed_event_blocked_territory_access_variant_count mismatch: expected {count}, got {}",
actual.packed_event_blocked_territory_access_variant_count
));
}
}
if let Some(count) = self.packed_event_blocked_territory_access_scope_count {
if actual.packed_event_blocked_territory_access_scope_count != count {
mismatches.push(format!(
"packed_event_blocked_territory_access_scope_count mismatch: expected {count}, got {}",
actual.packed_event_blocked_territory_access_scope_count
));
}
}

View file

@ -233,6 +233,7 @@ pub fn project_save_slice_to_runtime_state_import(
trains: Vec::new(),
territories: Vec::new(),
company_territory_track_piece_counts: Vec::new(),
company_territory_access: Vec::new(),
packed_event_collection: projection.packed_event_collection,
event_runtime_records: projection.event_runtime_records,
candidate_availability: projection.candidate_availability,
@ -289,6 +290,7 @@ pub fn project_save_slice_overlay_to_runtime_state_import(
company_territory_track_piece_counts: base_state
.company_territory_track_piece_counts
.clone(),
company_territory_access: base_state.company_territory_access.clone(),
packed_event_collection: projection.packed_event_collection,
event_runtime_records: projection.event_runtime_records,
candidate_availability: projection.candidate_availability,
@ -1044,6 +1046,18 @@ fn lower_condition_targets_in_effect(
)?,
value: *value,
},
RuntimeEffect::SetCompanyTerritoryAccess {
target,
territory,
value,
} => RuntimeEffect::SetCompanyTerritoryAccess {
target: lower_condition_true_company_target_in_company_target(
target,
lowered_company_target,
)?,
territory: territory.clone(),
value: *value,
},
RuntimeEffect::ConfiscateCompanyAssets { target } => {
RuntimeEffect::ConfiscateCompanyAssets {
target: lower_condition_true_company_target_in_company_target(
@ -1332,6 +1346,24 @@ fn smp_runtime_effect_to_runtime_effect(
Err(player_target_import_error_message(target, company_context))
}
}
RuntimeEffect::SetCompanyTerritoryAccess {
target,
territory,
value,
} => {
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())
} else {
Ok(RuntimeEffect::SetCompanyTerritoryAccess {
target: target.clone(),
territory: territory.clone(),
value: *value,
})
}
}
RuntimeEffect::ConfiscateCompanyAssets { target } => {
if company_target_allowed_for_import(
target,
@ -1724,9 +1756,16 @@ fn determine_packed_event_import_outcome(
if record
.grouped_effect_rows
.iter()
.any(|row| row.descriptor_id == 3)
.any(real_grouped_row_is_unsupported_territory_access_scope)
{
return "blocked_territory_policy_descriptor".to_string();
return "blocked_territory_access_scope".to_string();
}
if record
.grouped_effect_rows
.iter()
.any(real_grouped_row_is_unsupported_territory_access_variant)
{
return "blocked_territory_access_variant".to_string();
}
if record
.grouped_effect_rows
@ -1867,6 +1906,24 @@ fn territory_ids_match_known_context(ids: &[u32], company_context: &ImportRuntim
.all(|territory_id| company_context.known_territory_ids.contains(territory_id))
}
fn real_grouped_row_is_unsupported_territory_access_variant(
row: &SmpLoadedPackedEventGroupedEffectRowSummary,
) -> bool {
row.descriptor_id == 3 && !(row.row_shape == "bool_toggle" && row.raw_scalar_value != 0)
}
fn real_grouped_row_is_unsupported_territory_access_scope(
row: &SmpLoadedPackedEventGroupedEffectRowSummary,
) -> bool {
row.descriptor_id == 3
&& row.row_shape == "bool_toggle"
&& row.raw_scalar_value != 0
&& row
.notes
.iter()
.any(|note| note == "territory access row is missing company or territory scope")
}
fn real_grouped_row_is_unsupported_confiscation_variant(
row: &SmpLoadedPackedEventGroupedEffectRowSummary,
) -> bool {
@ -1894,6 +1951,7 @@ fn real_grouped_row_is_unsupported_retire_train_scope(
fn runtime_effect_uses_condition_true_company(effect: &RuntimeEffect) -> bool {
match effect {
RuntimeEffect::SetCompanyCash { target, .. }
| RuntimeEffect::SetCompanyTerritoryAccess { target, .. }
| RuntimeEffect::ConfiscateCompanyAssets { target }
| RuntimeEffect::DeactivateCompany { target }
| RuntimeEffect::SetCompanyTrackLayingCapacity { target, .. }
@ -1939,6 +1997,7 @@ fn runtime_effect_company_target_import_blocker(
) -> Option<ImportBlocker> {
match effect {
RuntimeEffect::SetCompanyCash { target, .. }
| RuntimeEffect::SetCompanyTerritoryAccess { target, .. }
| RuntimeEffect::ConfiscateCompanyAssets { target }
| RuntimeEffect::DeactivateCompany { target }
| RuntimeEffect::SetCompanyTrackLayingCapacity { target, .. }
@ -1948,6 +2007,9 @@ fn runtime_effect_company_target_import_blocker(
&& !company_context.has_train_context
{
Some(ImportBlocker::MissingTrainContext)
} else if let RuntimeEffect::SetCompanyTerritoryAccess { territory, .. } = effect {
company_target_import_blocker(target, company_context)
.or_else(|| territory_target_import_blocker(territory, company_context))
} else {
company_target_import_blocker(target, company_context)
}
@ -2315,6 +2377,7 @@ mod tests {
trains: Vec::new(),
territories: Vec::new(),
company_territory_track_piece_counts: Vec::new(),
company_territory_access: Vec::new(),
packed_event_collection: None,
event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(),
@ -2517,6 +2580,36 @@ mod tests {
}
}
fn real_territory_access_row(
enabled: bool,
notes: Vec<String>,
) -> crate::SmpLoadedPackedEventGroupedEffectRowSummary {
crate::SmpLoadedPackedEventGroupedEffectRowSummary {
group_index: 0,
row_index: 0,
descriptor_id: 3,
descriptor_label: Some("Territory - Allow All".to_string()),
target_mask_bits: Some(0x05),
parameter_family: Some("territory_access_toggle".to_string()),
opcode: 1,
raw_scalar_value: if enabled { 1 } else { 0 },
value_byte_0x09: 0,
value_dword_0x0d: 0,
value_byte_0x11: 0,
value_byte_0x12: 0,
value_word_0x14: 0,
value_word_0x16: 0,
row_shape: "bool_toggle".to_string(),
semantic_family: Some("bool_toggle".to_string()),
semantic_preview: Some(format!(
"Set Territory - Allow All to {}",
if enabled { "TRUE" } else { "FALSE" }
)),
locomotive_name: None,
notes,
}
}
fn real_economic_status_row(value: i32) -> crate::SmpLoadedPackedEventGroupedEffectRowSummary {
crate::SmpLoadedPackedEventGroupedEffectRowSummary {
group_index: 0,
@ -4139,6 +4232,7 @@ mod tests {
trains: Vec::new(),
territories: Vec::new(),
company_territory_track_piece_counts: Vec::new(),
company_territory_access: Vec::new(),
packed_event_collection: None,
event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(),
@ -4265,6 +4359,298 @@ mod tests {
assert_eq!(import.state.companies[0].current_cash, 250);
}
#[test]
fn overlays_real_territory_access_descriptor_into_executable_runtime_record() {
let base_state = RuntimeState {
companies: vec![crate::RuntimeCompany {
company_id: 42,
controller_kind: RuntimeCompanyControllerKind::Human,
current_cash: 500,
debt: 20,
credit_rating_score: None,
prime_rate: None,
track_piece_counts: RuntimeTrackPieceCounts::default(),
active: true,
available_track_laying_capacity: None,
}],
selected_company_id: Some(42),
territories: vec![crate::RuntimeTerritory {
territory_id: 7,
name: Some("Appalachia".to_string()),
track_piece_counts: RuntimeTrackPieceCounts::default(),
}],
..state()
};
let save_slice = SmpLoadedSaveSlice {
file_extension_hint: Some("gms".to_string()),
container_profile_family: Some("rt3-classic-save-container-v1".to_string()),
mechanism_family: "classic-save-rehydrate-v1".to_string(),
mechanism_confidence: "grounded".to_string(),
trailer_family: None,
bridge_family: None,
profile: None,
candidate_availability_table: None,
special_conditions_table: None,
event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary {
source_kind: "packed-event-runtime-collection".to_string(),
mechanism_family: "classic-save-rehydrate-v1".to_string(),
mechanism_confidence: "grounded".to_string(),
container_profile_family: Some("rt3-classic-save-container-v1".to_string()),
metadata_tag_offset: 0x7100,
records_tag_offset: 0x7200,
close_tag_offset: 0x7600,
packed_state_version: 0x3e9,
packed_state_version_hex: "0x000003e9".to_string(),
live_id_bound: 11,
live_record_count: 1,
live_entry_ids: vec![11],
decoded_record_count: 1,
imported_runtime_record_count: 0,
records: vec![crate::SmpLoadedPackedEventRecordSummary {
record_index: 0,
live_entry_id: 11,
payload_offset: Some(0x7202),
payload_len: Some(120),
decode_status: "parity_only".to_string(),
payload_family: "real_packed_v1".to_string(),
trigger_kind: Some(6),
active: None,
marks_collection_dirty: None,
one_shot: Some(false),
compact_control: Some(crate::SmpLoadedPackedEventCompactControlSummary {
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: vec![1, 1, 1, 1],
grouped_scope_checkboxes_0x7ff: vec![1, 0, 0, 0],
summary_toggle_0x800: 1,
grouped_territory_selectors_0x80f: vec![7, -1, -1, -1],
}),
text_bands: packed_text_bands(),
standalone_condition_row_count: 0,
standalone_condition_rows: vec![],
negative_sentinel_scope: None,
grouped_effect_row_counts: vec![1, 0, 0, 0],
grouped_effect_rows: vec![real_territory_access_row(true, vec![])],
decoded_conditions: Vec::new(),
decoded_actions: vec![RuntimeEffect::SetCompanyTerritoryAccess {
target: RuntimeCompanyTarget::SelectedCompany,
territory: RuntimeTerritoryTarget::Ids { ids: vec![7] },
value: true,
}],
executable_import_ready: true,
notes: vec!["decoded from grounded real 0x4e9a row framing".to_string()],
}],
}),
notes: vec![],
};
let mut import = project_save_slice_overlay_to_runtime_state_import(
&base_state,
&save_slice,
"real-territory-access-overlay",
None,
)
.expect("overlay import should project");
assert_eq!(import.state.event_runtime_records.len(), 1);
assert_eq!(
import
.state
.packed_event_collection
.as_ref()
.and_then(|summary| summary.records[0].import_outcome.as_deref()),
Some("imported")
);
execute_step_command(
&mut import.state,
&StepCommand::ServiceTriggerKind { trigger_kind: 6 },
)
.expect("real territory-access descriptor should execute");
assert_eq!(
import.state.company_territory_access,
vec![crate::RuntimeCompanyTerritoryAccess {
company_id: 42,
territory_id: 7,
}]
);
}
#[test]
fn keeps_real_territory_access_false_row_parity_only() {
let save_slice = SmpLoadedSaveSlice {
file_extension_hint: Some("gms".to_string()),
container_profile_family: Some("rt3-classic-save-container-v1".to_string()),
mechanism_family: "classic-save-rehydrate-v1".to_string(),
mechanism_confidence: "grounded".to_string(),
trailer_family: None,
bridge_family: None,
profile: None,
candidate_availability_table: None,
special_conditions_table: None,
event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary {
source_kind: "packed-event-runtime-collection".to_string(),
mechanism_family: "classic-save-rehydrate-v1".to_string(),
mechanism_confidence: "grounded".to_string(),
container_profile_family: Some("rt3-classic-save-container-v1".to_string()),
metadata_tag_offset: 0x7100,
records_tag_offset: 0x7200,
close_tag_offset: 0x7600,
packed_state_version: 0x3e9,
packed_state_version_hex: "0x000003e9".to_string(),
live_id_bound: 12,
live_record_count: 1,
live_entry_ids: vec![12],
decoded_record_count: 1,
imported_runtime_record_count: 0,
records: vec![crate::SmpLoadedPackedEventRecordSummary {
record_index: 0,
live_entry_id: 12,
payload_offset: Some(0x7202),
payload_len: Some(120),
decode_status: "parity_only".to_string(),
payload_family: "real_packed_v1".to_string(),
trigger_kind: Some(6),
active: None,
marks_collection_dirty: None,
one_shot: Some(false),
compact_control: Some(crate::SmpLoadedPackedEventCompactControlSummary {
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: vec![1, 1, 1, 1],
grouped_scope_checkboxes_0x7ff: vec![1, 0, 0, 0],
summary_toggle_0x800: 1,
grouped_territory_selectors_0x80f: vec![7, -1, -1, -1],
}),
text_bands: packed_text_bands(),
standalone_condition_row_count: 0,
standalone_condition_rows: vec![],
negative_sentinel_scope: None,
grouped_effect_row_counts: vec![1, 0, 0, 0],
grouped_effect_rows: vec![real_territory_access_row(false, vec![])],
decoded_conditions: Vec::new(),
decoded_actions: vec![],
executable_import_ready: false,
notes: vec!["decoded from grounded real 0x4e9a row framing".to_string()],
}],
}),
notes: vec![],
};
let import = project_save_slice_to_runtime_state_import(
&save_slice,
"real-territory-access-false",
None,
)
.expect("save slice should project");
assert!(import.state.event_runtime_records.is_empty());
assert_eq!(
import
.state
.packed_event_collection
.as_ref()
.and_then(|summary| summary.records[0].import_outcome.as_deref()),
Some("blocked_territory_access_variant")
);
}
#[test]
fn keeps_real_territory_access_missing_scope_row_parity_only() {
let save_slice = SmpLoadedSaveSlice {
file_extension_hint: Some("gms".to_string()),
container_profile_family: Some("rt3-classic-save-container-v1".to_string()),
mechanism_family: "classic-save-rehydrate-v1".to_string(),
mechanism_confidence: "grounded".to_string(),
trailer_family: None,
bridge_family: None,
profile: None,
candidate_availability_table: None,
special_conditions_table: None,
event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary {
source_kind: "packed-event-runtime-collection".to_string(),
mechanism_family: "classic-save-rehydrate-v1".to_string(),
mechanism_confidence: "grounded".to_string(),
container_profile_family: Some("rt3-classic-save-container-v1".to_string()),
metadata_tag_offset: 0x7100,
records_tag_offset: 0x7200,
close_tag_offset: 0x7600,
packed_state_version: 0x3e9,
packed_state_version_hex: "0x000003e9".to_string(),
live_id_bound: 13,
live_record_count: 1,
live_entry_ids: vec![13],
decoded_record_count: 1,
imported_runtime_record_count: 0,
records: vec![crate::SmpLoadedPackedEventRecordSummary {
record_index: 0,
live_entry_id: 13,
payload_offset: Some(0x7202),
payload_len: Some(120),
decode_status: "parity_only".to_string(),
payload_family: "real_packed_v1".to_string(),
trigger_kind: Some(6),
active: None,
marks_collection_dirty: None,
one_shot: Some(false),
compact_control: Some(crate::SmpLoadedPackedEventCompactControlSummary {
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: vec![9, 1, 1, 1],
grouped_scope_checkboxes_0x7ff: vec![1, 0, 0, 0],
summary_toggle_0x800: 1,
grouped_territory_selectors_0x80f: vec![-1, -1, -1, -1],
}),
text_bands: packed_text_bands(),
standalone_condition_row_count: 0,
standalone_condition_rows: vec![],
negative_sentinel_scope: None,
grouped_effect_row_counts: vec![1, 0, 0, 0],
grouped_effect_rows: vec![real_territory_access_row(
true,
vec!["territory access row is missing company or territory scope"
.to_string()],
)],
decoded_conditions: Vec::new(),
decoded_actions: vec![],
executable_import_ready: false,
notes: vec!["decoded from grounded real 0x4e9a row framing".to_string()],
}],
}),
notes: vec![],
};
let import = project_save_slice_to_runtime_state_import(
&save_slice,
"real-territory-access-missing-scope",
None,
)
.expect("save slice should project");
assert!(import.state.event_runtime_records.is_empty());
assert_eq!(
import
.state
.packed_event_collection
.as_ref()
.and_then(|summary| summary.records[0].import_outcome.as_deref()),
Some("blocked_territory_access_scope")
);
}
#[test]
fn overlays_real_deactivate_company_descriptor_into_executable_runtime_record() {
let base_state = RuntimeState {
@ -5567,6 +5953,7 @@ mod tests {
trains: Vec::new(),
territories: Vec::new(),
company_territory_track_piece_counts: Vec::new(),
company_territory_access: Vec::new(),
packed_event_collection: None,
event_runtime_records: vec![RuntimeEventRecord {
record_id: 1,
@ -5742,6 +6129,7 @@ mod tests {
trains: Vec::new(),
territories: Vec::new(),
company_territory_track_piece_counts: Vec::new(),
company_territory_access: Vec::new(),
packed_event_collection: None,
event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(),

View file

@ -36,9 +36,10 @@ pub use pk4::{
};
pub use runtime::{
RuntimeCompany, RuntimeCompanyConditionTestScope, RuntimeCompanyControllerKind,
RuntimeCompanyMetric, RuntimeCompanyTarget, RuntimeCompanyTerritoryTrackPieceCount,
RuntimeCondition, RuntimeConditionComparator, RuntimeEffect, RuntimeEventRecord,
RuntimeEventRecordTemplate, RuntimePackedEventCollectionSummary,
RuntimeCompanyMetric, RuntimeCompanyTarget, RuntimeCompanyTerritoryAccess,
RuntimeCompanyTerritoryTrackPieceCount, RuntimeCondition, RuntimeConditionComparator,
RuntimeEffect, RuntimeEventRecord, RuntimeEventRecordTemplate,
RuntimePackedEventCollectionSummary,
RuntimePackedEventCompactControlSummary, RuntimePackedEventConditionRowSummary,
RuntimePackedEventGroupedEffectRowSummary, RuntimePackedEventNegativeSentinelScopeSummary,
RuntimePackedEventRecordSummary, RuntimePackedEventTextBandSummary, RuntimePlayer,

View file

@ -99,6 +99,7 @@ mod tests {
trains: Vec::new(),
territories: Vec::new(),
company_territory_track_piece_counts: Vec::new(),
company_territory_access: Vec::new(),
packed_event_collection: None,
event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(),

View file

@ -69,6 +69,12 @@ pub struct RuntimeCompanyTerritoryTrackPieceCount {
pub track_piece_counts: RuntimeTrackPieceCounts,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeCompanyTerritoryAccess {
pub company_id: u32,
pub territory_id: u32,
}
fn runtime_player_default_active() -> bool {
true
}
@ -242,6 +248,11 @@ pub enum RuntimeEffect {
target: RuntimePlayerTarget,
value: i64,
},
SetCompanyTerritoryAccess {
target: RuntimeCompanyTarget,
territory: RuntimeTerritoryTarget,
value: bool,
},
ConfiscateCompanyAssets {
target: RuntimeCompanyTarget,
},
@ -592,6 +603,8 @@ pub struct RuntimeState {
#[serde(default)]
pub company_territory_track_piece_counts: Vec<RuntimeCompanyTerritoryTrackPieceCount>,
#[serde(default)]
pub company_territory_access: Vec<RuntimeCompanyTerritoryAccess>,
#[serde(default)]
pub packed_event_collection: Option<RuntimePackedEventCollectionSummary>,
#[serde(default)]
pub event_runtime_records: Vec<RuntimeEventRecord>,
@ -725,6 +738,27 @@ impl RuntimeState {
));
}
}
let mut seen_company_territory_access = BTreeSet::new();
for entry in &self.company_territory_access {
if !seen_company_ids.contains(&entry.company_id) {
return Err(format!(
"company_territory_access references unknown company_id {}",
entry.company_id
));
}
if !seen_territory_ids.contains(&entry.territory_id) {
return Err(format!(
"company_territory_access references unknown territory_id {}",
entry.territory_id
));
}
if !seen_company_territory_access.insert((entry.company_id, entry.territory_id)) {
return Err(format!(
"duplicate company_territory_access pair ({}, {})",
entry.company_id, entry.territory_id
));
}
}
let mut seen_record_ids = BTreeSet::new();
for record in &self.event_runtime_records {
@ -1090,6 +1124,12 @@ fn validate_runtime_effect(
| RuntimeEffect::AdjustCompanyDebt { target, .. } => {
validate_company_target(target, valid_company_ids)?;
}
RuntimeEffect::SetCompanyTerritoryAccess {
target, territory, ..
} => {
validate_company_target(target, valid_company_ids)?;
validate_territory_target(territory, valid_territory_ids)?;
}
RuntimeEffect::SetPlayerCash { target, .. } => {
validate_player_target(target, valid_player_ids)?;
}
@ -1315,6 +1355,7 @@ mod tests {
trains: Vec::new(),
territories: Vec::new(),
company_territory_track_piece_counts: Vec::new(),
company_territory_access: Vec::new(),
packed_event_collection: None,
event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(),
@ -1368,6 +1409,7 @@ mod tests {
trains: Vec::new(),
territories: Vec::new(),
company_territory_track_piece_counts: Vec::new(),
company_territory_access: Vec::new(),
packed_event_collection: None,
event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(),
@ -1408,6 +1450,7 @@ mod tests {
trains: Vec::new(),
territories: Vec::new(),
company_territory_track_piece_counts: Vec::new(),
company_territory_access: Vec::new(),
packed_event_collection: None,
event_runtime_records: vec![RuntimeEventRecord {
record_id: 7,
@ -1461,6 +1504,7 @@ mod tests {
trains: Vec::new(),
territories: Vec::new(),
company_territory_track_piece_counts: Vec::new(),
company_territory_access: Vec::new(),
packed_event_collection: None,
event_runtime_records: vec![RuntimeEventRecord {
record_id: 7,
@ -1514,6 +1558,7 @@ mod tests {
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(),
@ -1618,6 +1663,7 @@ mod tests {
trains: Vec::new(),
territories: Vec::new(),
company_territory_track_piece_counts: Vec::new(),
company_territory_access: Vec::new(),
packed_event_collection: None,
event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(),
@ -1658,6 +1704,7 @@ mod tests {
trains: Vec::new(),
territories: Vec::new(),
company_territory_track_piece_counts: Vec::new(),
company_territory_access: Vec::new(),
packed_event_collection: None,
event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(),
@ -1715,6 +1762,7 @@ mod tests {
],
territories: Vec::new(),
company_territory_track_piece_counts: Vec::new(),
company_territory_access: Vec::new(),
packed_event_collection: None,
event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(),
@ -1762,6 +1810,7 @@ mod tests {
}],
territories: Vec::new(),
company_territory_track_piece_counts: Vec::new(),
company_territory_access: Vec::new(),
packed_event_collection: None,
event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(),
@ -1813,6 +1862,7 @@ mod tests {
track_piece_counts: RuntimeTrackPieceCounts::default(),
}],
company_territory_track_piece_counts: Vec::new(),
company_territory_access: Vec::new(),
packed_event_collection: None,
event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(),
@ -1860,6 +1910,157 @@ mod tests {
}],
territories: Vec::new(),
company_territory_track_piece_counts: Vec::new(),
company_territory_access: Vec::new(),
packed_event_collection: None,
event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(),
special_conditions: BTreeMap::new(),
service_state: RuntimeServiceState::default(),
};
assert!(state.validate().is_err());
}
#[test]
fn rejects_duplicate_company_territory_access_pairs() {
let state = RuntimeState {
calendar: CalendarPoint {
year: 1830,
month_slot: 0,
phase_slot: 0,
tick_slot: 0,
},
world_flags: BTreeMap::new(),
save_profile: RuntimeSaveProfileState::default(),
world_restore: RuntimeWorldRestoreState::default(),
metadata: BTreeMap::new(),
companies: vec![RuntimeCompany {
company_id: 1,
current_cash: 100,
debt: 0,
credit_rating_score: None,
prime_rate: None,
track_piece_counts: RuntimeTrackPieceCounts::default(),
active: true,
available_track_laying_capacity: None,
controller_kind: RuntimeCompanyControllerKind::Human,
}],
selected_company_id: None,
players: Vec::new(),
selected_player_id: None,
trains: Vec::new(),
territories: vec![RuntimeTerritory {
territory_id: 7,
name: Some("Appalachia".to_string()),
track_piece_counts: RuntimeTrackPieceCounts::default(),
}],
company_territory_track_piece_counts: Vec::new(),
company_territory_access: vec![
RuntimeCompanyTerritoryAccess {
company_id: 1,
territory_id: 7,
},
RuntimeCompanyTerritoryAccess {
company_id: 1,
territory_id: 7,
},
],
packed_event_collection: None,
event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(),
special_conditions: BTreeMap::new(),
service_state: RuntimeServiceState::default(),
};
assert!(state.validate().is_err());
}
#[test]
fn rejects_company_territory_access_with_unknown_company() {
let state = RuntimeState {
calendar: CalendarPoint {
year: 1830,
month_slot: 0,
phase_slot: 0,
tick_slot: 0,
},
world_flags: BTreeMap::new(),
save_profile: RuntimeSaveProfileState::default(),
world_restore: RuntimeWorldRestoreState::default(),
metadata: BTreeMap::new(),
companies: vec![RuntimeCompany {
company_id: 1,
current_cash: 100,
debt: 0,
credit_rating_score: None,
prime_rate: None,
track_piece_counts: RuntimeTrackPieceCounts::default(),
active: true,
available_track_laying_capacity: None,
controller_kind: RuntimeCompanyControllerKind::Human,
}],
selected_company_id: None,
players: Vec::new(),
selected_player_id: None,
trains: Vec::new(),
territories: vec![RuntimeTerritory {
territory_id: 7,
name: Some("Appalachia".to_string()),
track_piece_counts: RuntimeTrackPieceCounts::default(),
}],
company_territory_track_piece_counts: Vec::new(),
company_territory_access: vec![RuntimeCompanyTerritoryAccess {
company_id: 2,
territory_id: 7,
}],
packed_event_collection: None,
event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(),
special_conditions: BTreeMap::new(),
service_state: RuntimeServiceState::default(),
};
assert!(state.validate().is_err());
}
#[test]
fn rejects_company_territory_access_with_unknown_territory() {
let state = RuntimeState {
calendar: CalendarPoint {
year: 1830,
month_slot: 0,
phase_slot: 0,
tick_slot: 0,
},
world_flags: BTreeMap::new(),
save_profile: RuntimeSaveProfileState::default(),
world_restore: RuntimeWorldRestoreState::default(),
metadata: BTreeMap::new(),
companies: vec![RuntimeCompany {
company_id: 1,
current_cash: 100,
debt: 0,
credit_rating_score: None,
prime_rate: None,
track_piece_counts: RuntimeTrackPieceCounts::default(),
active: true,
available_track_laying_capacity: None,
controller_kind: RuntimeCompanyControllerKind::Human,
}],
selected_company_id: None,
players: Vec::new(),
selected_player_id: None,
trains: Vec::new(),
territories: vec![RuntimeTerritory {
territory_id: 7,
name: Some("Appalachia".to_string()),
track_piece_counts: RuntimeTrackPieceCounts::default(),
}],
company_territory_track_piece_counts: Vec::new(),
company_territory_access: vec![RuntimeCompanyTerritoryAccess {
company_id: 1,
territory_id: 8,
}],
packed_event_collection: None,
event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(),

View file

@ -147,7 +147,7 @@ const REAL_GROUPED_EFFECT_DESCRIPTOR_METADATA: [RealGroupedEffectDescriptorMetad
label: "Territory - Allow All",
target_mask_bits: 0x05,
parameter_family: "territory_access_toggle",
executable_in_runtime: false,
executable_in_runtime: true,
},
RealGroupedEffectDescriptorMetadata {
descriptor_id: 8,
@ -2102,12 +2102,6 @@ fn parse_real_event_runtime_record_summary(
}
if let Some(control) = compact_control.as_ref() {
for row in &mut grouped_effect_rows {
if row.descriptor_id != 15
|| row.row_shape != "bool_toggle"
|| row.raw_scalar_value == 0
{
continue;
}
let company_target_present = control
.grouped_target_scope_ordinals_0x7fb
.get(row.group_index)
@ -2118,10 +2112,24 @@ fn parse_real_event_runtime_record_summary(
.grouped_territory_selectors_0x80f
.get(row.group_index)
.is_some_and(|selector| *selector >= 0);
if !company_target_present && !territory_target_present {
if row.descriptor_id == 15
&& row.row_shape == "bool_toggle"
&& row.raw_scalar_value != 0
&& !company_target_present
&& !territory_target_present
{
row.notes
.push("retire train row is missing company and territory scope".to_string());
}
if row.descriptor_id == 3
&& row.row_shape == "bool_toggle"
&& 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(),
);
}
}
}
@ -2643,6 +2651,27 @@ fn decode_real_grouped_effect_action(
});
}
if descriptor_metadata.executable_in_runtime
&& descriptor_metadata.descriptor_id == 3
&& row.row_shape == "bool_toggle"
&& row.raw_scalar_value != 0
{
let target = real_grouped_company_target(target_scope_ordinal)?;
let territory = compact_control
.grouped_territory_selectors_0x80f
.get(row.group_index)
.copied()
.filter(|selector| *selector >= 0)
.map(|selector| RuntimeTerritoryTarget::Ids {
ids: vec![selector as u32],
})?;
return Some(RuntimeEffect::SetCompanyTerritoryAccess {
target,
territory,
value: true,
});
}
if descriptor_metadata.executable_in_runtime
&& descriptor_metadata.descriptor_id == 8
&& row.row_shape == "scalar_assignment"
@ -2896,6 +2925,20 @@ fn runtime_effect_supported_for_save_import(effect: &RuntimeEffect) -> bool {
| RuntimePlayerTarget::SelectedPlayer
| RuntimePlayerTarget::ConditionTruePlayer
),
RuntimeEffect::SetCompanyTerritoryAccess {
target, territory, ..
} => 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!(

View file

@ -342,6 +342,21 @@ fn apply_runtime_effects(
mutated_player_ids.insert(player_id);
}
}
RuntimeEffect::SetCompanyTerritoryAccess {
target,
territory,
value,
} => {
let company_ids = resolve_company_target_ids(state, target, condition_context)?;
let territory_ids = resolve_territory_target_ids(state, territory)?;
set_company_territory_access_pairs(
&mut state.company_territory_access,
&company_ids,
&territory_ids,
*value,
);
mutated_company_ids.extend(company_ids);
}
RuntimeEffect::ConfiscateCompanyAssets { target } => {
let company_ids = resolve_company_target_ids(state, target, condition_context)?;
for company_id in company_ids.iter().copied() {
@ -1003,6 +1018,32 @@ fn retire_matching_trains(
}
}
fn set_company_territory_access_pairs(
access_entries: &mut Vec<crate::RuntimeCompanyTerritoryAccess>,
company_ids: &[u32],
territory_ids: &[u32],
value: bool,
) {
if value {
for company_id in company_ids {
for territory_id in territory_ids {
if !access_entries.iter().any(|entry| {
entry.company_id == *company_id && entry.territory_id == *territory_id
}) {
access_entries.push(crate::RuntimeCompanyTerritoryAccess {
company_id: *company_id,
territory_id: *territory_id,
});
}
}
}
} else {
access_entries.retain(|entry| {
!(company_ids.contains(&entry.company_id) && territory_ids.contains(&entry.territory_id))
});
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
@ -1044,6 +1085,7 @@ mod tests {
trains: Vec::new(),
territories: Vec::new(),
company_territory_track_piece_counts: Vec::new(),
company_territory_access: Vec::new(),
packed_event_collection: None,
event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(),
@ -1603,6 +1645,117 @@ mod tests {
assert_eq!(result.service_events[0].mutated_company_ids, vec![2]);
}
#[test]
fn sets_and_clears_company_territory_access_for_resolved_targets() {
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,
},
RuntimeCompany {
company_id: 2,
controller_kind: RuntimeCompanyControllerKind::Ai,
current_cash: 20,
debt: 0,
credit_rating_score: None,
prime_rate: None,
track_piece_counts: RuntimeTrackPieceCounts::default(),
active: true,
available_track_laying_capacity: None,
},
],
territories: vec![
RuntimeTerritory {
territory_id: 7,
name: Some("Appalachia".to_string()),
track_piece_counts: RuntimeTrackPieceCounts::default(),
},
RuntimeTerritory {
territory_id: 8,
name: Some("Great Plains".to_string()),
track_piece_counts: RuntimeTrackPieceCounts::default(),
},
],
event_runtime_records: vec![
RuntimeEventRecord {
record_id: 21,
trigger_kind: 7,
active: true,
service_count: 0,
marks_collection_dirty: false,
one_shot: true,
has_fired: false,
conditions: Vec::new(),
effects: vec![RuntimeEffect::SetCompanyTerritoryAccess {
target: RuntimeCompanyTarget::SelectedCompany,
territory: RuntimeTerritoryTarget::Ids { ids: vec![7, 8] },
value: true,
}],
},
RuntimeEventRecord {
record_id: 22,
trigger_kind: 8,
active: true,
service_count: 0,
marks_collection_dirty: false,
one_shot: true,
has_fired: false,
conditions: Vec::new(),
effects: vec![RuntimeEffect::SetCompanyTerritoryAccess {
target: RuntimeCompanyTarget::SelectedCompany,
territory: RuntimeTerritoryTarget::Ids { ids: vec![8] },
value: false,
}],
},
],
selected_company_id: Some(1),
..state()
};
let first = execute_step_command(
&mut state,
&StepCommand::ServiceTriggerKind { trigger_kind: 7 },
)
.expect("territory access grant should succeed");
assert_eq!(
state.company_territory_access,
vec![
crate::RuntimeCompanyTerritoryAccess {
company_id: 1,
territory_id: 7,
},
crate::RuntimeCompanyTerritoryAccess {
company_id: 1,
territory_id: 8,
},
]
);
assert_eq!(first.service_events[0].mutated_company_ids, vec![1]);
execute_step_command(
&mut state,
&StepCommand::ServiceTriggerKind { trigger_kind: 8 },
)
.expect("territory access clear should succeed");
assert_eq!(
state.company_territory_access,
vec![crate::RuntimeCompanyTerritoryAccess {
company_id: 1,
territory_id: 7,
}]
);
}
#[test]
fn rejects_condition_true_company_target_without_condition_context() {
let mut state = RuntimeState {

View file

@ -58,7 +58,8 @@ pub struct RuntimeSummary {
pub packed_event_blocked_unmapped_ordinary_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_territory_policy_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,
pub packed_event_blocked_missing_train_territory_context_count: usize,
pub packed_event_blocked_confiscation_variant_count: usize,
@ -420,7 +421,7 @@ impl RuntimeSummary {
.count()
})
.unwrap_or(0),
packed_event_blocked_territory_policy_descriptor_count: state
packed_event_blocked_territory_access_variant_count: state
.packed_event_collection
.as_ref()
.map(|summary| {
@ -429,7 +430,21 @@ impl RuntimeSummary {
.iter()
.filter(|record| {
record.import_outcome.as_deref()
== Some("blocked_territory_policy_descriptor")
== Some("blocked_territory_access_variant")
})
.count()
})
.unwrap_or(0),
packed_event_blocked_territory_access_scope_count: state
.packed_event_collection
.as_ref()
.map(|summary| {
summary
.records
.iter()
.filter(|record| {
record.import_outcome.as_deref()
== Some("blocked_territory_access_scope")
})
.count()
})
@ -587,6 +602,7 @@ mod tests {
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(),
@ -815,6 +831,7 @@ mod tests {
trains: Vec::new(),
territories: Vec::new(),
company_territory_track_piece_counts: Vec::new(),
company_territory_access: Vec::new(),
packed_event_collection: None,
event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(),