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

@ -24,9 +24,10 @@ company-territory track rows can import through overlay-backed runtime context.
named-territory binding now executes, and the runtime now also carries the minimal event-owned named-territory binding now executes, and the runtime now also carries the minimal event-owned
train roster and opaque economic-status lane needed for real descriptors `8` `Economic Status`, `9` train roster and opaque economic-status lane needed for real descriptors `8` `Economic Status`, `9`
`Confiscate All`, and `15` `Retire Train` to execute through the same path. Descriptor `3` `Confiscate All`, and `15` `Retire Train` to execute through the same path. Descriptor `3`
`Territory - Allow All` remains the explicit parity-only descriptor frontier. Mixed `Territory - Allow All` now executes too, reinterpreted as company-to-territory access rights
supported/unsupported real rows still stay parity-only. The PE32 hook remains useful as capture and rather than a territory-owned policy bit. Shell purchase-flow and selected-profile parity remain
integration tooling, but it is no longer the main execution milestone. out of scope. Mixed supported/unsupported real rows still stay parity-only. The PE32 hook remains
useful as capture and integration tooling, but it is no longer the main execution milestone.
## Project Docs ## Project Docs

View file

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

View file

@ -122,7 +122,9 @@ pub struct ExpectedRuntimeSummary {
#[serde(default)] #[serde(default)]
pub packed_event_blocked_unmapped_real_descriptor_count: Option<usize>, pub packed_event_blocked_unmapped_real_descriptor_count: Option<usize>,
#[serde(default)] #[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)] #[serde(default)]
pub packed_event_blocked_missing_train_context_count: Option<usize>, pub packed_event_blocked_missing_train_context_count: Option<usize>,
#[serde(default)] #[serde(default)]
@ -615,11 +617,19 @@ impl ExpectedRuntimeSummary {
)); ));
} }
} }
if let Some(count) = self.packed_event_blocked_territory_policy_descriptor_count { if let Some(count) = self.packed_event_blocked_territory_access_variant_count {
if actual.packed_event_blocked_territory_policy_descriptor_count != count { if actual.packed_event_blocked_territory_access_variant_count != count {
mismatches.push(format!( mismatches.push(format!(
"packed_event_blocked_territory_policy_descriptor_count mismatch: expected {count}, got {}", "packed_event_blocked_territory_access_variant_count mismatch: expected {count}, got {}",
actual.packed_event_blocked_territory_policy_descriptor_count 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(), trains: Vec::new(),
territories: Vec::new(), territories: Vec::new(),
company_territory_track_piece_counts: Vec::new(), company_territory_track_piece_counts: Vec::new(),
company_territory_access: Vec::new(),
packed_event_collection: projection.packed_event_collection, packed_event_collection: projection.packed_event_collection,
event_runtime_records: projection.event_runtime_records, event_runtime_records: projection.event_runtime_records,
candidate_availability: projection.candidate_availability, 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: base_state
.company_territory_track_piece_counts .company_territory_track_piece_counts
.clone(), .clone(),
company_territory_access: base_state.company_territory_access.clone(),
packed_event_collection: projection.packed_event_collection, packed_event_collection: projection.packed_event_collection,
event_runtime_records: projection.event_runtime_records, event_runtime_records: projection.event_runtime_records,
candidate_availability: projection.candidate_availability, candidate_availability: projection.candidate_availability,
@ -1044,6 +1046,18 @@ fn lower_condition_targets_in_effect(
)?, )?,
value: *value, 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 } => {
RuntimeEffect::ConfiscateCompanyAssets { RuntimeEffect::ConfiscateCompanyAssets {
target: lower_condition_true_company_target_in_company_target( 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)) 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 } => { RuntimeEffect::ConfiscateCompanyAssets { target } => {
if company_target_allowed_for_import( if company_target_allowed_for_import(
target, target,
@ -1724,9 +1756,16 @@ fn determine_packed_event_import_outcome(
if record if record
.grouped_effect_rows .grouped_effect_rows
.iter() .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 if record
.grouped_effect_rows .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)) .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( fn real_grouped_row_is_unsupported_confiscation_variant(
row: &SmpLoadedPackedEventGroupedEffectRowSummary, row: &SmpLoadedPackedEventGroupedEffectRowSummary,
) -> bool { ) -> bool {
@ -1894,6 +1951,7 @@ fn real_grouped_row_is_unsupported_retire_train_scope(
fn runtime_effect_uses_condition_true_company(effect: &RuntimeEffect) -> bool { fn runtime_effect_uses_condition_true_company(effect: &RuntimeEffect) -> bool {
match effect { match effect {
RuntimeEffect::SetCompanyCash { target, .. } RuntimeEffect::SetCompanyCash { target, .. }
| RuntimeEffect::SetCompanyTerritoryAccess { target, .. }
| RuntimeEffect::ConfiscateCompanyAssets { target } | RuntimeEffect::ConfiscateCompanyAssets { target }
| RuntimeEffect::DeactivateCompany { target } | RuntimeEffect::DeactivateCompany { target }
| RuntimeEffect::SetCompanyTrackLayingCapacity { target, .. } | RuntimeEffect::SetCompanyTrackLayingCapacity { target, .. }
@ -1939,6 +1997,7 @@ fn runtime_effect_company_target_import_blocker(
) -> Option<ImportBlocker> { ) -> Option<ImportBlocker> {
match effect { match effect {
RuntimeEffect::SetCompanyCash { target, .. } RuntimeEffect::SetCompanyCash { target, .. }
| RuntimeEffect::SetCompanyTerritoryAccess { target, .. }
| RuntimeEffect::ConfiscateCompanyAssets { target } | RuntimeEffect::ConfiscateCompanyAssets { target }
| RuntimeEffect::DeactivateCompany { target } | RuntimeEffect::DeactivateCompany { target }
| RuntimeEffect::SetCompanyTrackLayingCapacity { target, .. } | RuntimeEffect::SetCompanyTrackLayingCapacity { target, .. }
@ -1948,6 +2007,9 @@ fn runtime_effect_company_target_import_blocker(
&& !company_context.has_train_context && !company_context.has_train_context
{ {
Some(ImportBlocker::MissingTrainContext) 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 { } else {
company_target_import_blocker(target, company_context) company_target_import_blocker(target, company_context)
} }
@ -2315,6 +2377,7 @@ mod tests {
trains: Vec::new(), trains: Vec::new(),
territories: Vec::new(), territories: Vec::new(),
company_territory_track_piece_counts: Vec::new(), company_territory_track_piece_counts: Vec::new(),
company_territory_access: Vec::new(),
packed_event_collection: None, packed_event_collection: None,
event_runtime_records: Vec::new(), event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::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 { fn real_economic_status_row(value: i32) -> crate::SmpLoadedPackedEventGroupedEffectRowSummary {
crate::SmpLoadedPackedEventGroupedEffectRowSummary { crate::SmpLoadedPackedEventGroupedEffectRowSummary {
group_index: 0, group_index: 0,
@ -4139,6 +4232,7 @@ mod tests {
trains: Vec::new(), trains: Vec::new(),
territories: Vec::new(), territories: Vec::new(),
company_territory_track_piece_counts: Vec::new(), company_territory_track_piece_counts: Vec::new(),
company_territory_access: Vec::new(),
packed_event_collection: None, packed_event_collection: None,
event_runtime_records: Vec::new(), event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(), candidate_availability: BTreeMap::new(),
@ -4265,6 +4359,298 @@ mod tests {
assert_eq!(import.state.companies[0].current_cash, 250); 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] #[test]
fn overlays_real_deactivate_company_descriptor_into_executable_runtime_record() { fn overlays_real_deactivate_company_descriptor_into_executable_runtime_record() {
let base_state = RuntimeState { let base_state = RuntimeState {
@ -5567,6 +5953,7 @@ mod tests {
trains: Vec::new(), trains: Vec::new(),
territories: Vec::new(), territories: Vec::new(),
company_territory_track_piece_counts: Vec::new(), company_territory_track_piece_counts: Vec::new(),
company_territory_access: Vec::new(),
packed_event_collection: None, packed_event_collection: None,
event_runtime_records: vec![RuntimeEventRecord { event_runtime_records: vec![RuntimeEventRecord {
record_id: 1, record_id: 1,
@ -5742,6 +6129,7 @@ mod tests {
trains: Vec::new(), trains: Vec::new(),
territories: Vec::new(), territories: Vec::new(),
company_territory_track_piece_counts: Vec::new(), company_territory_track_piece_counts: Vec::new(),
company_territory_access: Vec::new(),
packed_event_collection: None, packed_event_collection: None,
event_runtime_records: Vec::new(), event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(), candidate_availability: BTreeMap::new(),

View file

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

View file

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

View file

@ -69,6 +69,12 @@ pub struct RuntimeCompanyTerritoryTrackPieceCount {
pub track_piece_counts: RuntimeTrackPieceCounts, 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 { fn runtime_player_default_active() -> bool {
true true
} }
@ -242,6 +248,11 @@ pub enum RuntimeEffect {
target: RuntimePlayerTarget, target: RuntimePlayerTarget,
value: i64, value: i64,
}, },
SetCompanyTerritoryAccess {
target: RuntimeCompanyTarget,
territory: RuntimeTerritoryTarget,
value: bool,
},
ConfiscateCompanyAssets { ConfiscateCompanyAssets {
target: RuntimeCompanyTarget, target: RuntimeCompanyTarget,
}, },
@ -592,6 +603,8 @@ pub struct RuntimeState {
#[serde(default)] #[serde(default)]
pub company_territory_track_piece_counts: Vec<RuntimeCompanyTerritoryTrackPieceCount>, pub company_territory_track_piece_counts: Vec<RuntimeCompanyTerritoryTrackPieceCount>,
#[serde(default)] #[serde(default)]
pub company_territory_access: Vec<RuntimeCompanyTerritoryAccess>,
#[serde(default)]
pub packed_event_collection: Option<RuntimePackedEventCollectionSummary>, pub packed_event_collection: Option<RuntimePackedEventCollectionSummary>,
#[serde(default)] #[serde(default)]
pub event_runtime_records: Vec<RuntimeEventRecord>, 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(); let mut seen_record_ids = BTreeSet::new();
for record in &self.event_runtime_records { for record in &self.event_runtime_records {
@ -1090,6 +1124,12 @@ fn validate_runtime_effect(
| RuntimeEffect::AdjustCompanyDebt { target, .. } => { | RuntimeEffect::AdjustCompanyDebt { target, .. } => {
validate_company_target(target, valid_company_ids)?; 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, .. } => { RuntimeEffect::SetPlayerCash { target, .. } => {
validate_player_target(target, valid_player_ids)?; validate_player_target(target, valid_player_ids)?;
} }
@ -1315,6 +1355,7 @@ mod tests {
trains: Vec::new(), trains: Vec::new(),
territories: Vec::new(), territories: Vec::new(),
company_territory_track_piece_counts: Vec::new(), company_territory_track_piece_counts: Vec::new(),
company_territory_access: Vec::new(),
packed_event_collection: None, packed_event_collection: None,
event_runtime_records: Vec::new(), event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(), candidate_availability: BTreeMap::new(),
@ -1368,6 +1409,7 @@ mod tests {
trains: Vec::new(), trains: Vec::new(),
territories: Vec::new(), territories: Vec::new(),
company_territory_track_piece_counts: Vec::new(), company_territory_track_piece_counts: Vec::new(),
company_territory_access: Vec::new(),
packed_event_collection: None, packed_event_collection: None,
event_runtime_records: Vec::new(), event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(), candidate_availability: BTreeMap::new(),
@ -1408,6 +1450,7 @@ mod tests {
trains: Vec::new(), trains: Vec::new(),
territories: Vec::new(), territories: Vec::new(),
company_territory_track_piece_counts: Vec::new(), company_territory_track_piece_counts: Vec::new(),
company_territory_access: Vec::new(),
packed_event_collection: None, packed_event_collection: None,
event_runtime_records: vec![RuntimeEventRecord { event_runtime_records: vec![RuntimeEventRecord {
record_id: 7, record_id: 7,
@ -1461,6 +1504,7 @@ mod tests {
trains: Vec::new(), trains: Vec::new(),
territories: Vec::new(), territories: Vec::new(),
company_territory_track_piece_counts: Vec::new(), company_territory_track_piece_counts: Vec::new(),
company_territory_access: Vec::new(),
packed_event_collection: None, packed_event_collection: None,
event_runtime_records: vec![RuntimeEventRecord { event_runtime_records: vec![RuntimeEventRecord {
record_id: 7, record_id: 7,
@ -1514,6 +1558,7 @@ mod tests {
trains: Vec::new(), trains: Vec::new(),
territories: Vec::new(), territories: Vec::new(),
company_territory_track_piece_counts: Vec::new(), company_territory_track_piece_counts: Vec::new(),
company_territory_access: Vec::new(),
packed_event_collection: Some(RuntimePackedEventCollectionSummary { packed_event_collection: Some(RuntimePackedEventCollectionSummary {
source_kind: "packed-event-runtime-collection".to_string(), source_kind: "packed-event-runtime-collection".to_string(),
mechanism_family: "classic-save-rehydrate-v1".to_string(), mechanism_family: "classic-save-rehydrate-v1".to_string(),
@ -1618,6 +1663,7 @@ mod tests {
trains: Vec::new(), trains: Vec::new(),
territories: Vec::new(), territories: Vec::new(),
company_territory_track_piece_counts: Vec::new(), company_territory_track_piece_counts: Vec::new(),
company_territory_access: Vec::new(),
packed_event_collection: None, packed_event_collection: None,
event_runtime_records: Vec::new(), event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(), candidate_availability: BTreeMap::new(),
@ -1658,6 +1704,7 @@ mod tests {
trains: Vec::new(), trains: Vec::new(),
territories: Vec::new(), territories: Vec::new(),
company_territory_track_piece_counts: Vec::new(), company_territory_track_piece_counts: Vec::new(),
company_territory_access: Vec::new(),
packed_event_collection: None, packed_event_collection: None,
event_runtime_records: Vec::new(), event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(), candidate_availability: BTreeMap::new(),
@ -1715,6 +1762,7 @@ mod tests {
], ],
territories: Vec::new(), territories: Vec::new(),
company_territory_track_piece_counts: Vec::new(), company_territory_track_piece_counts: Vec::new(),
company_territory_access: Vec::new(),
packed_event_collection: None, packed_event_collection: None,
event_runtime_records: Vec::new(), event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(), candidate_availability: BTreeMap::new(),
@ -1762,6 +1810,7 @@ mod tests {
}], }],
territories: Vec::new(), territories: Vec::new(),
company_territory_track_piece_counts: Vec::new(), company_territory_track_piece_counts: Vec::new(),
company_territory_access: Vec::new(),
packed_event_collection: None, packed_event_collection: None,
event_runtime_records: Vec::new(), event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(), candidate_availability: BTreeMap::new(),
@ -1813,6 +1862,7 @@ mod tests {
track_piece_counts: RuntimeTrackPieceCounts::default(), track_piece_counts: RuntimeTrackPieceCounts::default(),
}], }],
company_territory_track_piece_counts: Vec::new(), company_territory_track_piece_counts: Vec::new(),
company_territory_access: Vec::new(),
packed_event_collection: None, packed_event_collection: None,
event_runtime_records: Vec::new(), event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(), candidate_availability: BTreeMap::new(),
@ -1860,6 +1910,157 @@ mod tests {
}], }],
territories: Vec::new(), territories: Vec::new(),
company_territory_track_piece_counts: 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, packed_event_collection: None,
event_runtime_records: Vec::new(), event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(), candidate_availability: BTreeMap::new(),

View file

@ -147,7 +147,7 @@ const REAL_GROUPED_EFFECT_DESCRIPTOR_METADATA: [RealGroupedEffectDescriptorMetad
label: "Territory - Allow All", label: "Territory - Allow All",
target_mask_bits: 0x05, target_mask_bits: 0x05,
parameter_family: "territory_access_toggle", parameter_family: "territory_access_toggle",
executable_in_runtime: false, executable_in_runtime: true,
}, },
RealGroupedEffectDescriptorMetadata { RealGroupedEffectDescriptorMetadata {
descriptor_id: 8, descriptor_id: 8,
@ -2102,12 +2102,6 @@ fn parse_real_event_runtime_record_summary(
} }
if let Some(control) = compact_control.as_ref() { if let Some(control) = compact_control.as_ref() {
for row in &mut grouped_effect_rows { 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 let company_target_present = control
.grouped_target_scope_ordinals_0x7fb .grouped_target_scope_ordinals_0x7fb
.get(row.group_index) .get(row.group_index)
@ -2118,10 +2112,24 @@ fn parse_real_event_runtime_record_summary(
.grouped_territory_selectors_0x80f .grouped_territory_selectors_0x80f
.get(row.group_index) .get(row.group_index)
.is_some_and(|selector| *selector >= 0); .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 row.notes
.push("retire train row is missing company and territory scope".to_string()); .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 if descriptor_metadata.executable_in_runtime
&& descriptor_metadata.descriptor_id == 8 && descriptor_metadata.descriptor_id == 8
&& row.row_shape == "scalar_assignment" && row.row_shape == "scalar_assignment"
@ -2896,6 +2925,20 @@ fn runtime_effect_supported_for_save_import(effect: &RuntimeEffect) -> bool {
| RuntimePlayerTarget::SelectedPlayer | RuntimePlayerTarget::SelectedPlayer
| RuntimePlayerTarget::ConditionTruePlayer | 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::SetCompanyCash { target, .. }
| RuntimeEffect::AdjustCompanyCash { target, .. } | RuntimeEffect::AdjustCompanyCash { target, .. }
| RuntimeEffect::AdjustCompanyDebt { target, .. } => matches!( | RuntimeEffect::AdjustCompanyDebt { target, .. } => matches!(

View file

@ -342,6 +342,21 @@ fn apply_runtime_effects(
mutated_player_ids.insert(player_id); 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 } => { RuntimeEffect::ConfiscateCompanyAssets { target } => {
let company_ids = resolve_company_target_ids(state, target, condition_context)?; let company_ids = resolve_company_target_ids(state, target, condition_context)?;
for company_id in company_ids.iter().copied() { 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)] #[cfg(test)]
mod tests { mod tests {
use std::collections::BTreeMap; use std::collections::BTreeMap;
@ -1044,6 +1085,7 @@ mod tests {
trains: Vec::new(), trains: Vec::new(),
territories: Vec::new(), territories: Vec::new(),
company_territory_track_piece_counts: Vec::new(), company_territory_track_piece_counts: Vec::new(),
company_territory_access: Vec::new(),
packed_event_collection: None, packed_event_collection: None,
event_runtime_records: Vec::new(), event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(), candidate_availability: BTreeMap::new(),
@ -1603,6 +1645,117 @@ mod tests {
assert_eq!(result.service_events[0].mutated_company_ids, vec![2]); 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] #[test]
fn rejects_condition_true_company_target_without_condition_context() { fn rejects_condition_true_company_target_without_condition_context() {
let mut state = RuntimeState { 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_unmapped_ordinary_condition_count: usize,
pub packed_event_blocked_missing_compact_control_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_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_context_count: usize,
pub packed_event_blocked_missing_train_territory_context_count: usize, pub packed_event_blocked_missing_train_territory_context_count: usize,
pub packed_event_blocked_confiscation_variant_count: usize, pub packed_event_blocked_confiscation_variant_count: usize,
@ -420,7 +421,7 @@ impl RuntimeSummary {
.count() .count()
}) })
.unwrap_or(0), .unwrap_or(0),
packed_event_blocked_territory_policy_descriptor_count: state packed_event_blocked_territory_access_variant_count: state
.packed_event_collection .packed_event_collection
.as_ref() .as_ref()
.map(|summary| { .map(|summary| {
@ -429,7 +430,21 @@ impl RuntimeSummary {
.iter() .iter()
.filter(|record| { .filter(|record| {
record.import_outcome.as_deref() 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() .count()
}) })
@ -587,6 +602,7 @@ mod tests {
trains: Vec::new(), trains: Vec::new(),
territories: Vec::new(), territories: Vec::new(),
company_territory_track_piece_counts: Vec::new(), company_territory_track_piece_counts: Vec::new(),
company_territory_access: Vec::new(),
packed_event_collection: Some(RuntimePackedEventCollectionSummary { packed_event_collection: Some(RuntimePackedEventCollectionSummary {
source_kind: "packed-event-runtime-collection".to_string(), source_kind: "packed-event-runtime-collection".to_string(),
mechanism_family: "classic-save-rehydrate-v1".to_string(), mechanism_family: "classic-save-rehydrate-v1".to_string(),
@ -815,6 +831,7 @@ mod tests {
trains: Vec::new(), trains: Vec::new(),
territories: Vec::new(), territories: Vec::new(),
company_territory_track_piece_counts: Vec::new(), company_territory_track_piece_counts: Vec::new(),
company_territory_access: Vec::new(),
packed_event_collection: None, packed_event_collection: None,
event_runtime_records: Vec::new(), event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(), candidate_availability: BTreeMap::new(),

View file

@ -94,8 +94,9 @@ The highest-value next passes are now:
- real descriptors `8` `Economic Status`, `9` `Confiscate All`, and `15` `Retire Train` now join - real descriptors `8` `Economic Status`, `9` `Confiscate All`, and `15` `Retire Train` now join
the executable batch through the same ordinary runtime path, backed by the opaque economic-status the executable batch through the same ordinary runtime path, backed by the opaque economic-status
lane and the minimal event-owned train roster lane and the minimal event-owned train roster
- descriptor `3` `Territory - Allow All` remains the explicit parity-only descriptor frontier, and - descriptor `3` `Territory - Allow All` now executes as company-to-territory access rights through
mixed supported/unsupported real rows still stay parity-only the same ordinary runtime path; shell purchase-flow parity remains out of scope, and mixed
supported/unsupported real rows still stay parity-only
- keep in mind that the current local `.gms` corpus still exports with no packed event collection, - keep in mind that the current local `.gms` corpus still exports with no packed event collection,
so real descriptor mapping needs to stay plumbing-first until better captures exist so real descriptor mapping needs to stay plumbing-first until better captures exist
- use `rrt-hook` primarily as optional capture or integration tooling, not as the first execution - use `rrt-hook` primarily as optional capture or integration tooling, not as the first execution

View file

@ -44,13 +44,14 @@ Implemented today:
state, and real descriptors `8` = `Economic Status`, `9` = `Confiscate All`, and `15` = state, and real descriptors `8` = `Economic Status`, `9` = `Confiscate All`, and `15` =
`Retire Train` now import and execute through the ordinary runtime path when overlay context `Retire Train` now import and execute through the ordinary runtime path when overlay context
supplies the required train ownership data supplies the required train ownership data
- descriptor `3` = `Territory - Allow All` remains the explicit parity-only descriptor frontier - descriptor `3` = `Territory - Allow All` now imports and executes too, reinterpreted as
instead of hiding behind the generic unmapped bucket company-to-territory access rights instead of a territory-owned policy bit; shell purchase-flow
and selected-profile parity still remain outside the runtime surface
That means the next implementation work is breadth, not bootstrap. The recommended next slice is That means the next implementation work is breadth, not bootstrap. The recommended next slice is
broader real policy-descriptor coverage beyond `3/8/9/15`, wider ordinary condition-id coverage broader real grouped-descriptor and ordinary condition-id coverage beyond the current access,
beyond the current numeric-threshold batch, and richer train/runtime simulation only if later world, train, player, and numeric-threshold batches, plus richer runtime ownership only where a
descriptor families need more than the current event-owned roster. later descriptor family needs more than the current event-owned roster.
## Why This Boundary ## Why This Boundary
@ -394,8 +395,9 @@ Checked-in fixture families already include:
## Next Slice ## Next Slice
The recommended next implementation slice is broader ordinary-condition breadth on top of the The recommended next implementation slice is broader ordinary-condition and grouped-descriptor
now-stable numeric-threshold, overlay-context, and current company-scoped real-descriptor batch. breadth on top of the now-stable numeric-threshold, overlay-context, named-territory, player,
world/train, and company-territory-access batches.
Target behavior: Target behavior:
@ -412,6 +414,8 @@ Target behavior:
richer player metrics or profile/chairman ownership richer player metrics or profile/chairman ownership
- continue widening real grouped-descriptor execution only when both descriptor identity and - continue widening real grouped-descriptor execution only when both descriptor identity and
runtime effect semantics are grounded enough to map into the normalized runtime path honestly runtime effect semantics are grounded enough to map into the normalized runtime path honestly
- keep descriptor `3` on the now-executable company-territory-access interpretation; do not drift
back into territory-owned policy wording without new contrary evidence
Public-model expectations for that slice: Public-model expectations for that slice:
@ -427,8 +431,8 @@ Fixture work for that slice:
- preserve the new ordinary-condition tracked overlays for executable company finance, company - preserve the new ordinary-condition tracked overlays for executable company finance, company
track, aggregate territory track, and company-territory track thresholds track, aggregate territory track, and company-territory track thresholds
- preserve the named-territory no-match tracked overlay as the explicit binding blocker frontier - preserve the named-territory no-match tracked overlay as the explicit binding blocker frontier
- preserve the territory-policy tracked sample as the explicit descriptor frontier until mutation - preserve the territory-access tracked overlays and parity samples so descriptor `3` access-rights
semantics are grounded strongly enough to move beyond parity-only execution does not regress while other grouped families widen
- keep the older negative-sentinel, mixed real-row, and company-scoped descriptor fixtures green so - keep the older negative-sentinel, mixed real-row, and company-scoped descriptor fixtures green so
ordinary-condition breadth does not regress descriptor-side execution ordinary-condition breadth does not regress descriptor-side execution
- keep synthetic harness, save-slice, and overlay paths green as the real descriptor surface widens - keep synthetic harness, save-slice, and overlay paths green as the real descriptor surface widens
@ -442,6 +446,6 @@ Current local constraint:
Do not mix this slice with: Do not mix this slice with:
- shell queue/modal behavior - shell queue/modal behavior
- territory-access or selected-profile parity - shell territory-access purchase or selected-profile parity
- speculative condition execution without grounded runtime ownership - speculative condition execution without grounded runtime ownership
- speculative executable import for real rows whose descriptor meaning is still weak - speculative executable import for real rows whose descriptor meaning is still weak

View file

@ -0,0 +1,32 @@
{
"format_version": 1,
"fixture_id": "packed-event-territory-access-false-save-slice-fixture",
"source": {
"kind": "captured-runtime",
"description": "Fixture keeping the unsupported FALSE Territory - Allow All variant explicit."
},
"state_save_slice_path": "packed-event-territory-access-false-save-slice.json",
"commands": [
{
"kind": "service_trigger_kind",
"trigger_kind": 6
}
],
"expected_summary": {
"packed_event_collection_present": true,
"packed_event_record_count": 1,
"packed_event_decoded_record_count": 1,
"packed_event_imported_runtime_record_count": 0,
"event_runtime_record_count": 0,
"packed_event_blocked_territory_access_variant_count": 1
},
"expected_state_fragment": {
"packed_event_collection": {
"records": [
{
"import_outcome": "blocked_territory_access_variant"
}
]
}
}
}

View file

@ -1,13 +1,13 @@
{ {
"format_version": 1, "format_version": 1,
"save_slice_id": "packed-event-territory-policy-save-slice", "save_slice_id": "packed-event-territory-access-false-save-slice",
"source": { "source": {
"description": "Tracked save-slice document with a real Territory - Allow All row that stays parity-only.", "description": "Tracked save-slice document with an unsupported FALSE Territory - Allow All row.",
"original_save_filename": "captured-territory-policy.gms", "original_save_filename": "captured-territory-access-false.gms",
"original_save_sha256": "territory-policy-sample-sha256", "original_save_sha256": "territory-access-false-sample-sha256",
"notes": [ "notes": [
"tracked as JSON save-slice document rather than raw .smp", "tracked as JSON save-slice document rather than raw .smp",
"keeps descriptor 3 explicit without guessing territory policy mutation semantics" "keeps the unsupported descriptor 3 FALSE variant explicit"
] ]
}, },
"save_slice": { "save_slice": {
@ -30,16 +30,16 @@
"close_tag_offset": 29696, "close_tag_offset": 29696,
"packed_state_version": 1001, "packed_state_version": 1001,
"packed_state_version_hex": "0x000003e9", "packed_state_version_hex": "0x000003e9",
"live_id_bound": 48, "live_id_bound": 49,
"live_record_count": 1, "live_record_count": 1,
"live_entry_ids": [48], "live_entry_ids": [49],
"decoded_record_count": 1, "decoded_record_count": 1,
"imported_runtime_record_count": 0, "imported_runtime_record_count": 0,
"records": [ "records": [
{ {
"record_index": 0, "record_index": 0,
"live_entry_id": 48, "live_entry_id": 49,
"payload_offset": 29320, "payload_offset": 29360,
"payload_len": 132, "payload_len": 132,
"decode_status": "parity_only", "decode_status": "parity_only",
"payload_family": "real_packed_v1", "payload_family": "real_packed_v1",
@ -71,7 +71,7 @@
"target_mask_bits": 5, "target_mask_bits": 5,
"parameter_family": "territory_access_toggle", "parameter_family": "territory_access_toggle",
"opcode": 1, "opcode": 1,
"raw_scalar_value": 1, "raw_scalar_value": 0,
"value_byte_0x09": 0, "value_byte_0x09": 0,
"value_dword_0x0d": 0, "value_dword_0x0d": 0,
"value_byte_0x11": 0, "value_byte_0x11": 0,
@ -80,7 +80,7 @@
"value_word_0x16": 0, "value_word_0x16": 0,
"row_shape": "bool_toggle", "row_shape": "bool_toggle",
"semantic_family": "bool_toggle", "semantic_family": "bool_toggle",
"semantic_preview": "Set Territory - Allow All to TRUE", "semantic_preview": "Set Territory - Allow All to FALSE",
"locomotive_name": null, "locomotive_name": null,
"notes": [] "notes": []
} }
@ -89,14 +89,13 @@
"decoded_actions": [], "decoded_actions": [],
"executable_import_ready": false, "executable_import_ready": false,
"notes": [ "notes": [
"decoded from grounded real 0x4e9a row framing", "decoded from grounded real 0x4e9a row framing"
"territory policy mutation remains parity-only in this slice"
] ]
} }
] ]
}, },
"notes": [ "notes": [
"real territory policy descriptor sample" "unsupported real territory-access FALSE variant sample"
] ]
} }
} }

View file

@ -0,0 +1,32 @@
{
"format_version": 1,
"fixture_id": "packed-event-territory-access-missing-scope-save-slice-fixture",
"source": {
"kind": "captured-runtime",
"description": "Fixture keeping the missing-scope Territory - Allow All variant explicit."
},
"state_save_slice_path": "packed-event-territory-access-missing-scope-save-slice.json",
"commands": [
{
"kind": "service_trigger_kind",
"trigger_kind": 6
}
],
"expected_summary": {
"packed_event_collection_present": true,
"packed_event_record_count": 1,
"packed_event_decoded_record_count": 1,
"packed_event_imported_runtime_record_count": 0,
"event_runtime_record_count": 0,
"packed_event_blocked_territory_access_scope_count": 1
},
"expected_state_fragment": {
"packed_event_collection": {
"records": [
{
"import_outcome": "blocked_territory_access_scope"
}
]
}
}
}

View file

@ -0,0 +1,104 @@
{
"format_version": 1,
"save_slice_id": "packed-event-territory-access-missing-scope-save-slice",
"source": {
"description": "Tracked save-slice document with a TRUE Territory - Allow All row missing company or territory scope.",
"original_save_filename": "captured-territory-access-missing-scope.gms",
"original_save_sha256": "territory-access-missing-scope-sample-sha256",
"notes": [
"tracked as JSON save-slice document rather than raw .smp",
"keeps the descriptor 3 missing-scope boundary explicit"
]
},
"save_slice": {
"file_extension_hint": "gms",
"container_profile_family": "rt3-classic-save-container-v1",
"mechanism_family": "classic-save-rehydrate-v1",
"mechanism_confidence": "grounded",
"trailer_family": null,
"bridge_family": null,
"profile": null,
"candidate_availability_table": null,
"special_conditions_table": null,
"event_runtime_collection": {
"source_kind": "packed-event-runtime-collection",
"mechanism_family": "classic-save-rehydrate-v1",
"mechanism_confidence": "grounded",
"container_profile_family": "rt3-classic-save-container-v1",
"metadata_tag_offset": 28928,
"records_tag_offset": 29184,
"close_tag_offset": 29696,
"packed_state_version": 1001,
"packed_state_version_hex": "0x000003e9",
"live_id_bound": 50,
"live_record_count": 1,
"live_entry_ids": [50],
"decoded_record_count": 1,
"imported_runtime_record_count": 0,
"records": [
{
"record_index": 0,
"live_entry_id": 50,
"payload_offset": 29400,
"payload_len": 132,
"decode_status": "parity_only",
"payload_family": "real_packed_v1",
"trigger_kind": 6,
"one_shot": false,
"compact_control": {
"mode_byte_0x7ef": 6,
"primary_selector_0x7f0": 12,
"grouped_mode_0x7f4": 2,
"one_shot_header_0x7f5": 0,
"modifier_flag_0x7f9": 0,
"modifier_flag_0x7fa": 0,
"grouped_target_scope_ordinals_0x7fb": [9, 1, 1, 1],
"grouped_scope_checkboxes_0x7ff": [1, 0, 0, 0],
"summary_toggle_0x800": 1,
"grouped_territory_selectors_0x80f": [-1, -1, -1, -1]
},
"text_bands": [],
"standalone_condition_row_count": 0,
"standalone_condition_rows": [],
"negative_sentinel_scope": null,
"grouped_effect_row_counts": [1, 0, 0, 0],
"grouped_effect_rows": [
{
"group_index": 0,
"row_index": 0,
"descriptor_id": 3,
"descriptor_label": "Territory - Allow All",
"target_mask_bits": 5,
"parameter_family": "territory_access_toggle",
"opcode": 1,
"raw_scalar_value": 1,
"value_byte_0x09": 0,
"value_dword_0x0d": 0,
"value_byte_0x11": 0,
"value_byte_0x12": 0,
"value_word_0x14": 0,
"value_word_0x16": 0,
"row_shape": "bool_toggle",
"semantic_family": "bool_toggle",
"semantic_preview": "Set Territory - Allow All to TRUE",
"locomotive_name": null,
"notes": [
"territory access row is missing company or territory scope"
]
}
],
"decoded_conditions": [],
"decoded_actions": [],
"executable_import_ready": false,
"notes": [
"decoded from grounded real 0x4e9a row framing",
"descriptor 3 remains parity-only when company or territory scope is absent"
]
}
]
},
"notes": [
"unsupported real territory-access missing-scope sample"
]
}
}

View file

@ -0,0 +1,64 @@
{
"format_version": 1,
"fixture_id": "packed-event-territory-access-overlay-fixture",
"source": {
"kind": "captured-runtime",
"description": "Fixture proving descriptor 3 Territory - Allow All imports as company territory-access rights and executes through the ordinary runtime path."
},
"state_import_path": "packed-event-territory-access-overlay.json",
"commands": [
{
"kind": "service_trigger_kind",
"trigger_kind": 6
}
],
"expected_summary": {
"calendar_projection_source": "base-snapshot-preserved",
"calendar_projection_is_placeholder": false,
"company_count": 3,
"active_company_count": 3,
"player_count": 2,
"territory_count": 2,
"packed_event_collection_present": true,
"packed_event_record_count": 1,
"packed_event_decoded_record_count": 1,
"packed_event_imported_runtime_record_count": 1,
"event_runtime_record_count": 1,
"total_event_record_service_count": 1,
"total_trigger_dispatch_count": 1
},
"expected_state_fragment": {
"company_territory_access": [
{
"company_id": 1,
"territory_id": 7
}
],
"packed_event_collection": {
"records": [
{
"import_outcome": "imported",
"decoded_actions": [
{
"kind": "set_company_territory_access",
"target": {
"kind": "selected_company"
},
"territory": {
"kind": "ids",
"ids": [7]
},
"value": true
}
]
}
]
},
"event_runtime_records": [
{
"record_id": 48,
"service_count": 1
}
]
}
}

View file

@ -0,0 +1,9 @@
{
"format_version": 1,
"import_id": "packed-event-territory-access-overlay",
"source": {
"description": "Overlay import combining company and territory runtime context with the real Territory - Allow All descriptor sample."
},
"base_snapshot_path": "packed-event-territory-player-overlay-base-snapshot.json",
"save_slice_path": "packed-event-territory-access-save-slice.json"
}

View file

@ -0,0 +1,114 @@
{
"format_version": 1,
"save_slice_id": "packed-event-territory-access-save-slice",
"source": {
"description": "Tracked save-slice document with a real Territory - Allow All row interpreted as company territory-access rights.",
"original_save_filename": "captured-territory-access.gms",
"original_save_sha256": "territory-access-sample-sha256",
"notes": [
"tracked as JSON save-slice document rather than raw .smp",
"locks descriptor 3 import as company-to-territory access rights instead of a territory-owned policy bit"
]
},
"save_slice": {
"file_extension_hint": "gms",
"container_profile_family": "rt3-classic-save-container-v1",
"mechanism_family": "classic-save-rehydrate-v1",
"mechanism_confidence": "grounded",
"trailer_family": null,
"bridge_family": null,
"profile": null,
"candidate_availability_table": null,
"special_conditions_table": null,
"event_runtime_collection": {
"source_kind": "packed-event-runtime-collection",
"mechanism_family": "classic-save-rehydrate-v1",
"mechanism_confidence": "grounded",
"container_profile_family": "rt3-classic-save-container-v1",
"metadata_tag_offset": 28928,
"records_tag_offset": 29184,
"close_tag_offset": 29696,
"packed_state_version": 1001,
"packed_state_version_hex": "0x000003e9",
"live_id_bound": 48,
"live_record_count": 1,
"live_entry_ids": [48],
"decoded_record_count": 1,
"imported_runtime_record_count": 1,
"records": [
{
"record_index": 0,
"live_entry_id": 48,
"payload_offset": 29320,
"payload_len": 132,
"decode_status": "parity_only",
"payload_family": "real_packed_v1",
"trigger_kind": 6,
"one_shot": false,
"compact_control": {
"mode_byte_0x7ef": 6,
"primary_selector_0x7f0": 12,
"grouped_mode_0x7f4": 2,
"one_shot_header_0x7f5": 0,
"modifier_flag_0x7f9": 0,
"modifier_flag_0x7fa": 0,
"grouped_target_scope_ordinals_0x7fb": [1, 1, 1, 1],
"grouped_scope_checkboxes_0x7ff": [1, 0, 0, 0],
"summary_toggle_0x800": 1,
"grouped_territory_selectors_0x80f": [7, -1, -1, -1]
},
"text_bands": [],
"standalone_condition_row_count": 0,
"standalone_condition_rows": [],
"negative_sentinel_scope": null,
"grouped_effect_row_counts": [1, 0, 0, 0],
"grouped_effect_rows": [
{
"group_index": 0,
"row_index": 0,
"descriptor_id": 3,
"descriptor_label": "Territory - Allow All",
"target_mask_bits": 5,
"parameter_family": "territory_access_toggle",
"opcode": 1,
"raw_scalar_value": 1,
"value_byte_0x09": 0,
"value_dword_0x0d": 0,
"value_byte_0x11": 0,
"value_byte_0x12": 0,
"value_word_0x14": 0,
"value_word_0x16": 0,
"row_shape": "bool_toggle",
"semantic_family": "bool_toggle",
"semantic_preview": "Set Territory - Allow All to TRUE",
"locomotive_name": null,
"notes": []
}
],
"decoded_conditions": [],
"decoded_actions": [
{
"kind": "set_company_territory_access",
"target": {
"kind": "selected_company"
},
"territory": {
"kind": "ids",
"ids": [7]
},
"value": true
}
],
"executable_import_ready": true,
"notes": [
"decoded from grounded real 0x4e9a row framing",
"descriptor 3 now lowers to company territory-access grants when company and territory scope are both explicit"
]
}
]
},
"notes": [
"real territory-access descriptor sample"
]
}
}

View file

@ -1,39 +0,0 @@
{
"format_version": 1,
"fixture_id": "packed-event-territory-policy-save-slice-fixture",
"source": {
"kind": "captured-runtime",
"description": "Fixture proving descriptor 3 Territory - Allow All stays parity-only with an explicit blocker."
},
"state_save_slice_path": "packed-event-territory-policy-save-slice.json",
"commands": [
{
"kind": "step_count",
"steps": 1
}
],
"expected_summary": {
"calendar_projection_is_placeholder": true,
"packed_event_collection_present": true,
"packed_event_record_count": 1,
"packed_event_decoded_record_count": 1,
"packed_event_imported_runtime_record_count": 0,
"packed_event_parity_only_record_count": 1,
"packed_event_blocked_territory_policy_descriptor_count": 1,
"event_runtime_record_count": 0
},
"expected_state_fragment": {
"packed_event_collection": {
"records": [
{
"import_outcome": "blocked_territory_policy_descriptor",
"grouped_effect_rows": [
{
"descriptor_label": "Territory - Allow All"
}
]
}
]
}
}
}