Implement real company-scoped event descriptors

This commit is contained in:
Jan Petykiewicz 2026-04-15 12:11:29 -07:00
commit 780e739daa
21 changed files with 1483 additions and 56 deletions

View file

@ -710,7 +710,7 @@ fn smp_packed_record_to_runtime_event_record(
if record.decode_status == "unsupported_framing" {
return None;
}
if record.payload_family == "real_packed_v1" && record.decoded_actions.is_empty() {
if record.payload_family == "real_packed_v1" && !record.executable_import_ready {
return None;
}
@ -771,6 +771,25 @@ fn smp_runtime_effect_to_runtime_effect(
Err(company_target_import_error_message(target, company_context))
}
}
RuntimeEffect::DeactivateCompany { target } => {
if company_target_import_blocker(target, company_context).is_none() {
Ok(RuntimeEffect::DeactivateCompany {
target: target.clone(),
})
} else {
Err(company_target_import_error_message(target, company_context))
}
}
RuntimeEffect::SetCompanyTrackLayingCapacity { target, value } => {
if company_target_import_blocker(target, company_context).is_none() {
Ok(RuntimeEffect::SetCompanyTrackLayingCapacity {
target: target.clone(),
value: *value,
})
} else {
Err(company_target_import_error_message(target, company_context))
}
}
RuntimeEffect::AdjustCompanyCash { target, delta } => {
if company_target_import_blocker(target, company_context).is_none() {
Ok(RuntimeEffect::AdjustCompanyCash {
@ -912,17 +931,14 @@ fn determine_packed_event_import_outcome(
if record.compact_control.is_none() {
return "blocked_missing_compact_control".to_string();
}
if !record.executable_import_ready {
return "blocked_unmapped_real_descriptor".to_string();
}
if let Some(blocker) = packed_record_company_target_import_blocker(record, company_context)
{
return company_target_import_outcome(blocker).to_string();
}
if !record.decoded_actions.is_empty() {
return "blocked_unsupported_decode".to_string();
}
if let Some(blocker) = real_record_company_target_import_blocker(record, company_context) {
return company_target_import_outcome(blocker).to_string();
}
return "blocked_unmapped_real_descriptor".to_string();
return "blocked_unsupported_decode".to_string();
}
if let Some(blocker) = packed_record_company_target_import_blocker(record, company_context) {
return company_target_import_outcome(blocker).to_string();
@ -946,6 +962,8 @@ fn runtime_effect_company_target_import_blocker(
) -> Option<CompanyTargetImportBlocker> {
match effect {
RuntimeEffect::SetCompanyCash { target, .. }
| RuntimeEffect::DeactivateCompany { target }
| RuntimeEffect::SetCompanyTrackLayingCapacity { target, .. }
| RuntimeEffect::AdjustCompanyCash { target, .. }
| RuntimeEffect::AdjustCompanyDebt { target, .. } => {
company_target_import_blocker(target, company_context)
@ -996,16 +1014,6 @@ fn classify_real_grouped_company_target(ordinal: u8) -> Option<RuntimeCompanyTar
}
}
fn real_record_company_target_import_blocker(
record: &SmpLoadedPackedEventRecordSummary,
company_context: &ImportCompanyContext,
) -> Option<CompanyTargetImportBlocker> {
classify_real_grouped_company_targets(record)
.into_iter()
.flatten()
.find_map(|target| company_target_import_blocker(&target, company_context))
}
fn company_target_import_outcome(blocker: CompanyTargetImportBlocker) -> &'static str {
match blocker {
CompanyTargetImportBlocker::MissingCompanyContext => "blocked_missing_company_context",
@ -1417,6 +1425,83 @@ mod tests {
}]
}
fn real_deactivate_company_row(enabled: bool) -> crate::SmpLoadedPackedEventGroupedEffectRowSummary {
crate::SmpLoadedPackedEventGroupedEffectRowSummary {
group_index: 0,
row_index: 0,
descriptor_id: 13,
descriptor_label: Some("Deactivate Company".to_string()),
target_mask_bits: Some(0x01),
parameter_family: Some("company_lifecycle_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 Deactivate Company to {}",
if enabled { "TRUE" } else { "FALSE" }
)),
locomotive_name: None,
notes: vec![],
}
}
fn real_track_capacity_row(value: i32) -> crate::SmpLoadedPackedEventGroupedEffectRowSummary {
crate::SmpLoadedPackedEventGroupedEffectRowSummary {
group_index: 0,
row_index: 0,
descriptor_id: 16,
descriptor_label: Some("Company Track Pieces Buildable".to_string()),
target_mask_bits: Some(0x01),
parameter_family: Some("company_build_limit_scalar".to_string()),
opcode: 3,
raw_scalar_value: value,
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: "scalar_assignment".to_string(),
semantic_family: Some("scalar_assignment".to_string()),
semantic_preview: Some(format!(
"Set Company Track Pieces Buildable to {value}"
)),
locomotive_name: None,
notes: vec![],
}
}
fn unsupported_real_grouped_row() -> crate::SmpLoadedPackedEventGroupedEffectRowSummary {
crate::SmpLoadedPackedEventGroupedEffectRowSummary {
group_index: 1,
row_index: 0,
descriptor_id: 8,
descriptor_label: Some("Economic Status".to_string()),
target_mask_bits: Some(0x08),
parameter_family: Some("whole_game_state_enum".to_string()),
opcode: 3,
raw_scalar_value: 2,
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: "scalar_assignment".to_string(),
semantic_family: Some("scalar_assignment".to_string()),
semantic_preview: Some("Set Economic Status to 2".to_string()),
locomotive_name: None,
notes: vec![],
}
}
fn real_compact_control() -> crate::SmpLoadedPackedEventCompactControlSummary {
crate::SmpLoadedPackedEventCompactControlSummary {
mode_byte_0x7ef: 6,
@ -2173,12 +2258,16 @@ mod tests {
controller_kind: RuntimeCompanyControllerKind::Human,
current_cash: 100,
debt: 10,
active: true,
available_track_laying_capacity: None,
},
crate::RuntimeCompany {
company_id: 2,
controller_kind: RuntimeCompanyControllerKind::Ai,
current_cash: 50,
debt: 20,
active: true,
available_track_laying_capacity: None,
},
],
selected_company_id: Some(1),
@ -2312,7 +2401,7 @@ mod tests {
target: RuntimeCompanyTarget::ConditionTrueCompany,
value: 7,
}],
executable_import_ready: false,
executable_import_ready: true,
notes: vec!["decoded from grounded real 0x4e9a row framing".to_string()],
}],
}),
@ -2398,8 +2487,11 @@ mod tests {
standalone_condition_rows: real_condition_rows(),
grouped_effect_row_counts: vec![1, 0, 0, 0],
grouped_effect_rows: real_grouped_rows(),
decoded_actions: vec![],
executable_import_ready: false,
decoded_actions: vec![RuntimeEffect::SetCompanyCash {
target: RuntimeCompanyTarget::ConditionTrueCompany,
value: 7,
}],
executable_import_ready: true,
notes: vec!["decoded from grounded real 0x4e9a row framing".to_string()],
}],
}),
@ -2521,6 +2613,8 @@ mod tests {
controller_kind: RuntimeCompanyControllerKind::Human,
current_cash: 500,
debt: 20,
active: true,
available_track_laying_capacity: None,
}],
selected_company_id: Some(42),
packed_event_collection: None,
@ -2610,7 +2704,7 @@ mod tests {
target: RuntimeCompanyTarget::SelectedCompany,
value: 250,
}],
executable_import_ready: false,
executable_import_ready: true,
notes: vec![
"decoded from grounded real 0x4e9a row framing".to_string(),
"grouped descriptor labels and target masks come from the checked-in effect table recovered around 0x006103a0".to_string(),
@ -2647,6 +2741,381 @@ mod tests {
assert_eq!(import.state.companies[0].current_cash, 250);
}
#[test]
fn overlays_real_deactivate_company_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,
active: true,
available_track_laying_capacity: None,
}],
selected_company_id: Some(42),
..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: 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(7),
active: None,
marks_collection_dirty: None,
one_shot: Some(false),
compact_control: Some(crate::SmpLoadedPackedEventCompactControlSummary {
mode_byte_0x7ef: 7,
primary_selector_0x7f0: 0x63,
grouped_mode_0x7f4: 2,
one_shot_header_0x7f5: 0,
modifier_flag_0x7f9: 1,
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![-1, -1, -1, -1],
}),
text_bands: packed_text_bands(),
standalone_condition_row_count: 0,
standalone_condition_rows: vec![],
grouped_effect_row_counts: vec![1, 0, 0, 0],
grouped_effect_rows: vec![real_deactivate_company_row(true)],
decoded_actions: vec![RuntimeEffect::DeactivateCompany {
target: RuntimeCompanyTarget::SelectedCompany,
}],
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-deactivate-company-overlay",
None,
)
.expect("overlay import should project");
assert_eq!(import.state.event_runtime_records.len(), 1);
execute_step_command(
&mut import.state,
&StepCommand::ServiceTriggerKind { trigger_kind: 7 },
)
.expect("real deactivate-company descriptor should execute");
assert!(!import.state.companies[0].active);
assert_eq!(import.state.selected_company_id, None);
}
#[test]
fn keeps_real_deactivate_company_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: 14,
live_record_count: 1,
live_entry_ids: vec![14],
decoded_record_count: 1,
imported_runtime_record_count: 0,
records: vec![crate::SmpLoadedPackedEventRecordSummary {
record_index: 0,
live_entry_id: 14,
payload_offset: Some(0x7202),
payload_len: Some(120),
decode_status: "parity_only".to_string(),
payload_family: "real_packed_v1".to_string(),
trigger_kind: Some(7),
active: None,
marks_collection_dirty: None,
one_shot: Some(false),
compact_control: Some(crate::SmpLoadedPackedEventCompactControlSummary {
mode_byte_0x7ef: 7,
primary_selector_0x7f0: 0x63,
grouped_mode_0x7f4: 2,
one_shot_header_0x7f5: 0,
modifier_flag_0x7f9: 1,
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![-1, -1, -1, -1],
}),
text_bands: packed_text_bands(),
standalone_condition_row_count: 0,
standalone_condition_rows: vec![],
grouped_effect_row_counts: vec![1, 0, 0, 0],
grouped_effect_rows: vec![real_deactivate_company_row(false)],
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-deactivate-company-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_unmapped_real_descriptor")
);
}
#[test]
fn overlays_real_track_capacity_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,
active: true,
available_track_laying_capacity: None,
}],
selected_company_id: Some(42),
..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: 16,
live_record_count: 1,
live_entry_ids: vec![16],
decoded_record_count: 1,
imported_runtime_record_count: 0,
records: vec![crate::SmpLoadedPackedEventRecordSummary {
record_index: 0,
live_entry_id: 16,
payload_offset: Some(0x7202),
payload_len: Some(120),
decode_status: "parity_only".to_string(),
payload_family: "real_packed_v1".to_string(),
trigger_kind: Some(7),
active: None,
marks_collection_dirty: None,
one_shot: Some(false),
compact_control: Some(crate::SmpLoadedPackedEventCompactControlSummary {
mode_byte_0x7ef: 7,
primary_selector_0x7f0: 0x63,
grouped_mode_0x7f4: 2,
one_shot_header_0x7f5: 0,
modifier_flag_0x7f9: 1,
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![-1, -1, -1, -1],
}),
text_bands: packed_text_bands(),
standalone_condition_row_count: 0,
standalone_condition_rows: vec![],
grouped_effect_row_counts: vec![1, 0, 0, 0],
grouped_effect_rows: vec![real_track_capacity_row(18)],
decoded_actions: vec![RuntimeEffect::SetCompanyTrackLayingCapacity {
target: RuntimeCompanyTarget::SelectedCompany,
value: Some(18),
}],
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-track-capacity-overlay",
None,
)
.expect("overlay import should project");
execute_step_command(
&mut import.state,
&StepCommand::ServiceTriggerKind { trigger_kind: 7 },
)
.expect("real track-capacity descriptor should execute");
assert_eq!(
import.state.companies[0].available_track_laying_capacity,
Some(18)
);
}
#[test]
fn keeps_mixed_real_records_out_of_event_runtime_records() {
let base_state = RuntimeState {
companies: vec![crate::RuntimeCompany {
company_id: 42,
controller_kind: RuntimeCompanyControllerKind::Human,
current_cash: 500,
debt: 20,
active: true,
available_track_laying_capacity: None,
}],
selected_company_id: Some(42),
..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: 17,
live_record_count: 1,
live_entry_ids: vec![17],
decoded_record_count: 1,
imported_runtime_record_count: 0,
records: vec![crate::SmpLoadedPackedEventRecordSummary {
record_index: 0,
live_entry_id: 17,
payload_offset: Some(0x7202),
payload_len: Some(160),
decode_status: "parity_only".to_string(),
payload_family: "real_packed_v1".to_string(),
trigger_kind: Some(7),
active: None,
marks_collection_dirty: None,
one_shot: Some(false),
compact_control: Some(crate::SmpLoadedPackedEventCompactControlSummary {
mode_byte_0x7ef: 7,
primary_selector_0x7f0: 0x63,
grouped_mode_0x7f4: 2,
one_shot_header_0x7f5: 0,
modifier_flag_0x7f9: 1,
modifier_flag_0x7fa: 0,
grouped_target_scope_ordinals_0x7fb: vec![1, 1, 1, 1],
grouped_scope_checkboxes_0x7ff: vec![1, 1, 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![],
grouped_effect_row_counts: vec![1, 1, 0, 0],
grouped_effect_rows: vec![
real_track_capacity_row(18),
unsupported_real_grouped_row(),
],
decoded_actions: vec![RuntimeEffect::SetCompanyTrackLayingCapacity {
target: RuntimeCompanyTarget::SelectedCompany,
value: Some(18),
}],
executable_import_ready: false,
notes: vec!["decoded from grounded real 0x4e9a row framing".to_string()],
}],
}),
notes: vec![],
};
let import = project_save_slice_overlay_to_runtime_state_import(
&base_state,
&save_slice,
"mixed-real-record-overlay",
None,
)
.expect("overlay import 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_unmapped_real_descriptor")
);
}
#[test]
fn overlays_save_slice_events_onto_base_company_context() {
let base_state = RuntimeState {
@ -2665,6 +3134,8 @@ mod tests {
controller_kind: RuntimeCompanyControllerKind::Human,
current_cash: 500,
debt: 20,
active: true,
available_track_laying_capacity: None,
}],
selected_company_id: Some(42),
packed_event_collection: None,
@ -2827,6 +3298,8 @@ mod tests {
controller_kind: RuntimeCompanyControllerKind::Human,
current_cash: 100,
debt: 0,
active: true,
available_track_laying_capacity: None,
}],
selected_company_id: Some(42),
packed_event_collection: None,

View file

@ -13,11 +13,19 @@ pub enum RuntimeCompanyControllerKind {
Ai,
}
fn runtime_company_default_active() -> bool {
true
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeCompany {
pub company_id: u32,
pub current_cash: i64,
pub debt: u64,
#[serde(default = "runtime_company_default_active")]
pub active: bool,
#[serde(default)]
pub available_track_laying_capacity: Option<u32>,
#[serde(default)]
pub controller_kind: RuntimeCompanyControllerKind,
}
@ -44,6 +52,13 @@ pub enum RuntimeEffect {
target: RuntimeCompanyTarget,
value: i64,
},
DeactivateCompany {
target: RuntimeCompanyTarget,
},
SetCompanyTrackLayingCapacity {
target: RuntimeCompanyTarget,
value: Option<u32>,
},
AdjustCompanyCash {
target: RuntimeCompanyTarget,
delta: i64,
@ -352,10 +367,14 @@ impl RuntimeState {
self.calendar.validate()?;
let mut seen_company_ids = BTreeSet::new();
let mut active_company_ids = BTreeSet::new();
for company in &self.companies {
if !seen_company_ids.insert(company.company_id) {
return Err(format!("duplicate company_id {}", company.company_id));
}
if company.active {
active_company_ids.insert(company.company_id);
}
}
if let Some(selected_company_id) = self.selected_company_id {
if !seen_company_ids.contains(&selected_company_id) {
@ -364,6 +383,12 @@ impl RuntimeState {
selected_company_id
));
}
if !active_company_ids.contains(&selected_company_id) {
return Err(format!(
"selected_company_id {} must reference an active company",
selected_company_id
));
}
}
let mut seen_record_ids = BTreeSet::new();
@ -669,6 +694,8 @@ fn validate_runtime_effect(
}
}
RuntimeEffect::SetCompanyCash { target, .. }
| RuntimeEffect::DeactivateCompany { target }
| RuntimeEffect::SetCompanyTrackLayingCapacity { target, .. }
| RuntimeEffect::AdjustCompanyCash { target, .. }
| RuntimeEffect::AdjustCompanyDebt { target, .. } => {
validate_company_target(target, valid_company_ids)?;
@ -756,12 +783,16 @@ mod tests {
company_id: 1,
current_cash: 100,
debt: 0,
active: true,
available_track_laying_capacity: None,
controller_kind: RuntimeCompanyControllerKind::Unknown,
},
RuntimeCompany {
company_id: 1,
current_cash: 200,
debt: 0,
active: true,
available_track_laying_capacity: None,
controller_kind: RuntimeCompanyControllerKind::Unknown,
},
],
@ -840,6 +871,8 @@ mod tests {
company_id: 1,
current_cash: 100,
debt: 0,
active: true,
available_track_laying_capacity: None,
controller_kind: RuntimeCompanyControllerKind::Unknown,
}],
selected_company_id: None,
@ -882,6 +915,8 @@ mod tests {
company_id: 1,
current_cash: 100,
debt: 0,
active: true,
available_track_laying_capacity: None,
controller_kind: RuntimeCompanyControllerKind::Unknown,
}],
selected_company_id: None,
@ -1018,6 +1053,8 @@ mod tests {
company_id: 1,
current_cash: 100,
debt: 0,
active: true,
available_track_laying_capacity: None,
controller_kind: RuntimeCompanyControllerKind::Human,
}],
selected_company_id: Some(2),
@ -1030,4 +1067,36 @@ mod tests {
assert!(state.validate().is_err());
}
#[test]
fn rejects_selected_company_id_that_is_inactive() {
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,
active: false,
available_track_laying_capacity: None,
controller_kind: RuntimeCompanyControllerKind::Human,
}],
selected_company_id: Some(1),
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());
}
}

View file

@ -163,7 +163,7 @@ const REAL_GROUPED_EFFECT_DESCRIPTOR_METADATA: [RealGroupedEffectDescriptorMetad
label: "Deactivate Company",
target_mask_bits: 0x01,
parameter_family: "company_lifecycle_toggle",
executable_in_runtime: false,
executable_in_runtime: true,
},
RealGroupedEffectDescriptorMetadata {
descriptor_id: 15,
@ -177,7 +177,7 @@ const REAL_GROUPED_EFFECT_DESCRIPTOR_METADATA: [RealGroupedEffectDescriptorMetad
label: "Company Track Pieces Buildable",
target_mask_bits: 0x01,
parameter_family: "company_build_limit_scalar",
executable_in_runtime: false,
executable_in_runtime: true,
},
];
@ -1944,7 +1944,8 @@ fn parse_real_event_runtime_record_summary(
.as_ref()
.map(|control| decode_real_grouped_effect_actions(&grouped_effect_rows, control))
.unwrap_or_default();
let executable_import_ready = !decoded_actions.is_empty()
let executable_import_ready = !grouped_effect_rows.is_empty()
&& decoded_actions.len() == grouped_effect_rows.len()
&& decoded_actions
.iter()
.all(runtime_effect_supported_for_save_import);
@ -2265,6 +2266,25 @@ fn decode_real_grouped_effect_action(
});
}
if descriptor_metadata.executable_in_runtime
&& descriptor_metadata.descriptor_id == 13
&& row.row_shape == "bool_toggle"
&& row.raw_scalar_value != 0
{
return Some(RuntimeEffect::DeactivateCompany { target });
}
if descriptor_metadata.executable_in_runtime
&& descriptor_metadata.descriptor_id == 16
&& row.row_shape == "scalar_assignment"
&& row.raw_scalar_value >= 0
{
return Some(RuntimeEffect::SetCompanyTrackLayingCapacity {
target,
value: Some(row.raw_scalar_value as u32),
});
}
None
}
@ -2417,14 +2437,22 @@ fn runtime_effect_supported_for_save_import(effect: &RuntimeEffect) -> bool {
RuntimeEffect::SetWorldFlag { .. }
| RuntimeEffect::SetCandidateAvailability { .. }
| RuntimeEffect::SetSpecialCondition { .. }
| RuntimeEffect::DeactivateCompany { .. }
| RuntimeEffect::SetCompanyTrackLayingCapacity { .. }
| RuntimeEffect::ActivateEventRecord { .. }
| RuntimeEffect::DeactivateEventRecord { .. }
| RuntimeEffect::RemoveEventRecord { .. } => true,
RuntimeEffect::SetCompanyCash { target, .. }
| RuntimeEffect::AdjustCompanyCash { target, .. }
| RuntimeEffect::AdjustCompanyDebt { target, .. } => {
matches!(target, RuntimeCompanyTarget::AllActive)
}
| RuntimeEffect::AdjustCompanyDebt { target, .. } => matches!(
target,
RuntimeCompanyTarget::AllActive
| RuntimeCompanyTarget::Ids { .. }
| RuntimeCompanyTarget::HumanCompanies
| RuntimeCompanyTarget::AiCompanies
| RuntimeCompanyTarget::SelectedCompany
| RuntimeCompanyTarget::ConditionTrueCompany
),
RuntimeEffect::AppendEventRecord { record } => record
.effects
.iter()
@ -7513,10 +7541,10 @@ mod tests {
.expect("event runtime collection summary should parse");
assert_eq!(summary.decoded_record_count, 1);
assert_eq!(summary.imported_runtime_record_count, 0);
assert_eq!(summary.records[0].decode_status, "parity_only");
assert_eq!(summary.imported_runtime_record_count, 1);
assert_eq!(summary.records[0].decode_status, "executable");
assert_eq!(summary.records[0].payload_family, "synthetic_harness");
assert!(!summary.records[0].executable_import_ready);
assert!(summary.records[0].executable_import_ready);
}
#[test]

View file

@ -299,6 +299,41 @@ fn apply_runtime_effects(
mutated_company_ids.insert(company_id);
}
}
RuntimeEffect::DeactivateCompany { target } => {
let company_ids = resolve_company_target_ids(state, target)?;
for company_id in company_ids {
let company = state
.companies
.iter_mut()
.find(|company| company.company_id == company_id)
.ok_or_else(|| {
format!(
"missing company_id {company_id} while applying deactivate effect"
)
})?;
company.active = false;
mutated_company_ids.insert(company_id);
if state.selected_company_id == Some(company_id) {
state.selected_company_id = None;
}
}
}
RuntimeEffect::SetCompanyTrackLayingCapacity { target, value } => {
let company_ids = resolve_company_target_ids(state, target)?;
for company_id in company_ids {
let company = state
.companies
.iter_mut()
.find(|company| company.company_id == company_id)
.ok_or_else(|| {
format!(
"missing company_id {company_id} while applying track capacity effect"
)
})?;
company.available_track_laying_capacity = *value;
mutated_company_ids.insert(company_id);
}
}
RuntimeEffect::AdjustCompanyCash { target, delta } => {
let company_ids = resolve_company_target_ids(state, target)?;
for company_id in company_ids {
@ -429,6 +464,7 @@ fn resolve_company_target_ids(
RuntimeCompanyTarget::AllActive => Ok(state
.companies
.iter()
.filter(|company| company.active)
.map(|company| company.company_id)
.collect()),
RuntimeCompanyTarget::Ids { ids } => {
@ -458,7 +494,10 @@ fn resolve_company_target_ids(
Ok(state
.companies
.iter()
.filter(|company| company.controller_kind == RuntimeCompanyControllerKind::Human)
.filter(|company| {
company.active
&& company.controller_kind == RuntimeCompanyControllerKind::Human
})
.map(|company| company.company_id)
.collect())
}
@ -476,14 +515,27 @@ fn resolve_company_target_ids(
Ok(state
.companies
.iter()
.filter(|company| company.controller_kind == RuntimeCompanyControllerKind::Ai)
.filter(|company| {
company.active && company.controller_kind == RuntimeCompanyControllerKind::Ai
})
.map(|company| company.company_id)
.collect())
}
RuntimeCompanyTarget::SelectedCompany => state
.selected_company_id
.map(|company_id| vec![company_id])
.ok_or_else(|| "target requires selected_company_id context".to_string()),
RuntimeCompanyTarget::SelectedCompany => {
let selected_company_id = state
.selected_company_id
.ok_or_else(|| "target requires selected_company_id context".to_string())?;
if state
.companies
.iter()
.any(|company| company.company_id == selected_company_id && company.active)
{
Ok(vec![selected_company_id])
} else {
Err("target requires selected_company_id to reference an active company"
.to_string())
}
}
RuntimeCompanyTarget::ConditionTrueCompany => {
Err("target requires condition-evaluation context".to_string())
}
@ -530,6 +582,8 @@ mod tests {
controller_kind: RuntimeCompanyControllerKind::Unknown,
current_cash: 10,
debt: 0,
active: true,
available_track_laying_capacity: None,
}],
selected_company_id: None,
packed_event_collection: None,
@ -692,12 +746,16 @@ mod tests {
controller_kind: RuntimeCompanyControllerKind::Unknown,
current_cash: 10,
debt: 5,
active: true,
available_track_laying_capacity: None,
},
RuntimeCompany {
company_id: 2,
controller_kind: RuntimeCompanyControllerKind::Unknown,
current_cash: 20,
debt: 8,
active: true,
available_track_laying_capacity: None,
},
],
event_runtime_records: vec![RuntimeEventRecord {
@ -744,12 +802,16 @@ mod tests {
controller_kind: RuntimeCompanyControllerKind::Human,
current_cash: 10,
debt: 0,
active: true,
available_track_laying_capacity: None,
},
RuntimeCompany {
company_id: 2,
controller_kind: RuntimeCompanyControllerKind::Ai,
current_cash: 20,
debt: 2,
active: true,
available_track_laying_capacity: None,
},
],
selected_company_id: Some(1),
@ -866,6 +928,179 @@ mod tests {
assert!(error.contains("controller_kind"));
}
#[test]
fn all_active_and_role_targets_exclude_inactive_companies() {
let mut state = RuntimeState {
companies: vec![
RuntimeCompany {
company_id: 1,
controller_kind: RuntimeCompanyControllerKind::Human,
current_cash: 10,
debt: 1,
active: true,
available_track_laying_capacity: None,
},
RuntimeCompany {
company_id: 2,
controller_kind: RuntimeCompanyControllerKind::Human,
current_cash: 20,
debt: 2,
active: false,
available_track_laying_capacity: None,
},
RuntimeCompany {
company_id: 3,
controller_kind: RuntimeCompanyControllerKind::Ai,
current_cash: 30,
debt: 3,
active: true,
available_track_laying_capacity: None,
},
],
event_runtime_records: vec![
RuntimeEventRecord {
record_id: 16,
trigger_kind: 7,
active: true,
service_count: 0,
marks_collection_dirty: false,
one_shot: false,
has_fired: false,
effects: vec![RuntimeEffect::AdjustCompanyCash {
target: RuntimeCompanyTarget::AllActive,
delta: 5,
}],
},
RuntimeEventRecord {
record_id: 17,
trigger_kind: 7,
active: true,
service_count: 0,
marks_collection_dirty: false,
one_shot: false,
has_fired: false,
effects: vec![RuntimeEffect::AdjustCompanyDebt {
target: RuntimeCompanyTarget::HumanCompanies,
delta: 4,
}],
},
RuntimeEventRecord {
record_id: 18,
trigger_kind: 7,
active: true,
service_count: 0,
marks_collection_dirty: false,
one_shot: false,
has_fired: false,
effects: vec![RuntimeEffect::AdjustCompanyDebt {
target: RuntimeCompanyTarget::AiCompanies,
delta: 6,
}],
},
],
..state()
};
execute_step_command(
&mut state,
&StepCommand::ServiceTriggerKind { trigger_kind: 7 },
)
.expect("active-company filtering should succeed");
assert_eq!(state.companies[0].current_cash, 15);
assert_eq!(state.companies[1].current_cash, 20);
assert_eq!(state.companies[2].current_cash, 35);
assert_eq!(state.companies[0].debt, 5);
assert_eq!(state.companies[1].debt, 2);
assert_eq!(state.companies[2].debt, 9);
}
#[test]
fn deactivating_selected_company_clears_selection() {
let mut state = RuntimeState {
companies: vec![RuntimeCompany {
company_id: 1,
controller_kind: RuntimeCompanyControllerKind::Human,
current_cash: 10,
debt: 0,
active: true,
available_track_laying_capacity: Some(8),
}],
selected_company_id: Some(1),
event_runtime_records: vec![RuntimeEventRecord {
record_id: 19,
trigger_kind: 7,
active: true,
service_count: 0,
marks_collection_dirty: false,
one_shot: false,
has_fired: false,
effects: vec![RuntimeEffect::DeactivateCompany {
target: RuntimeCompanyTarget::SelectedCompany,
}],
}],
..state()
};
let result = execute_step_command(
&mut state,
&StepCommand::ServiceTriggerKind { trigger_kind: 7 },
)
.expect("deactivate company effect should succeed");
assert!(!state.companies[0].active);
assert_eq!(state.selected_company_id, None);
assert_eq!(result.service_events[0].mutated_company_ids, vec![1]);
}
#[test]
fn sets_track_laying_capacity_for_resolved_targets() {
let mut state = RuntimeState {
companies: vec![
RuntimeCompany {
company_id: 1,
controller_kind: RuntimeCompanyControllerKind::Human,
current_cash: 10,
debt: 0,
active: true,
available_track_laying_capacity: None,
},
RuntimeCompany {
company_id: 2,
controller_kind: RuntimeCompanyControllerKind::Ai,
current_cash: 20,
debt: 0,
active: true,
available_track_laying_capacity: None,
},
],
event_runtime_records: vec![RuntimeEventRecord {
record_id: 20,
trigger_kind: 7,
active: true,
service_count: 0,
marks_collection_dirty: false,
one_shot: false,
has_fired: false,
effects: vec![RuntimeEffect::SetCompanyTrackLayingCapacity {
target: RuntimeCompanyTarget::Ids { ids: vec![2] },
value: Some(14),
}],
}],
..state()
};
let result = execute_step_command(
&mut state,
&StepCommand::ServiceTriggerKind { trigger_kind: 7 },
)
.expect("track capacity effect should succeed");
assert_eq!(state.companies[0].available_track_laying_capacity, None);
assert_eq!(state.companies[1].available_track_laying_capacity, Some(14));
assert_eq!(result.service_events[0].mutated_company_ids, vec![2]);
}
#[test]
fn rejects_condition_true_company_target_without_condition_context() {
let mut state = RuntimeState {
@ -941,6 +1176,8 @@ mod tests {
controller_kind: RuntimeCompanyControllerKind::Unknown,
current_cash: 10,
debt: 2,
active: true,
available_track_laying_capacity: None,
}],
event_runtime_records: vec![RuntimeEventRecord {
record_id: 30,

View file

@ -28,6 +28,7 @@ pub struct RuntimeSummary {
pub world_restore_absolute_counter_adjustment_context: Option<String>,
pub metadata_count: usize,
pub company_count: usize,
pub active_company_count: usize,
pub packed_event_collection_present: bool,
pub packed_event_record_count: usize,
pub packed_event_decoded_record_count: usize,
@ -122,6 +123,7 @@ impl RuntimeSummary {
.clone(),
metadata_count: state.metadata.len(),
company_count: state.companies.len(),
active_company_count: state.companies.iter().filter(|company| company.active).count(),
packed_event_collection_present: state.packed_event_collection.is_some(),
packed_event_record_count: state
.packed_event_collection
@ -302,7 +304,8 @@ mod tests {
use std::collections::BTreeMap;
use crate::{
CalendarPoint, RuntimePackedEventCollectionSummary, RuntimePackedEventRecordSummary,
CalendarPoint, RuntimeCompany, RuntimeCompanyControllerKind,
RuntimePackedEventCollectionSummary, RuntimePackedEventRecordSummary,
RuntimeSaveProfileState, RuntimeServiceState, RuntimeState, RuntimeWorldRestoreState,
};
@ -399,4 +402,48 @@ mod tests {
assert_eq!(summary.packed_event_blocked_missing_company_role_context_count, 0);
assert_eq!(summary.packed_event_blocked_missing_condition_context_count, 0);
}
#[test]
fn counts_active_companies_separately_from_total_companies() {
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: 10,
debt: 0,
active: true,
available_track_laying_capacity: None,
controller_kind: RuntimeCompanyControllerKind::Human,
},
RuntimeCompany {
company_id: 2,
current_cash: 20,
debt: 0,
active: false,
available_track_laying_capacity: Some(7),
controller_kind: RuntimeCompanyControllerKind::Ai,
},
],
selected_company_id: None,
packed_event_collection: None,
event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(),
special_conditions: BTreeMap::new(),
service_state: RuntimeServiceState::default(),
};
let summary = RuntimeSummary::from_state(&state);
assert_eq!(summary.company_count, 2);
assert_eq!(summary.active_company_count, 1);
}
}