Execute real packed event player deactivation

This commit is contained in:
Jan Petykiewicz 2026-04-15 23:24:08 -07:00
commit 991725dba8
10 changed files with 670 additions and 47 deletions

View file

@ -15,15 +15,16 @@ frontier is broader real grouped-descriptor coverage on top of the existing save
overlay-import, compact-control, and symbolic company-target workflows. The runtime already carries overlay-import, compact-control, and symbolic company-target workflows. The runtime already carries
selected-company and controller-role context through overlay imports, and real descriptors `2` selected-company and controller-role context through overlay imports, and real descriptors `2`
`Company Cash`, `13` `Deactivate Company`, and `16` `Company Track Pieces Buildable` now parse and `Company Cash`, `13` `Deactivate Company`, and `16` `Company Track Pieces Buildable` now parse and
execute through the ordinary runtime path, and descriptor `1` `Player Cash` now joins that batch execute through the ordinary runtime path, and descriptors `1` `Player Cash` and `14`
through the same service engine. Synthetic packed records still exercise the same runtime without a `Deactivate Player` now join that batch through the same service engine. Synthetic packed records
parallel packed executor. The first grounded condition-side unlock now exists for negative-sentinel still exercise the same runtime without a parallel packed executor. The first grounded
`raw_condition_id = -1` company scopes, and the first ordinary nonnegative condition batch now condition-side unlock now exists for negative-sentinel `raw_condition_id = -1` company scopes, and
executes too: numeric-threshold company finance, company track, aggregate territory track, and the first ordinary nonnegative condition batch now executes too: numeric-threshold company
company-territory track rows can import through overlay-backed runtime context. Exact finance, company track, aggregate territory track, and company-territory track rows can import
named-territory binding now executes, and the runtime now also carries the minimal event-owned through overlay-backed runtime context. Exact named-territory binding now executes, and the runtime
train roster and opaque economic-status lane needed for real descriptors `8` `Economic Status`, `9` now also carries the minimal event-owned train roster and opaque economic-status lane needed for
`Confiscate All`, and `15` `Retire Train` to execute through the same path. Descriptor `3` real descriptors `8` `Economic Status`, `9` `Confiscate All`, and `15` `Retire Train` to execute
through the same path. Descriptor `3`
`Territory - Allow All` now executes too, reinterpreted as company-to-territory access rights `Territory - Allow All` now executes too, reinterpreted as company-to-territory access rights
rather than a territory-owned policy bit. Whole-game ordinary-condition execution now exists too: rather than a territory-owned policy bit. Whole-game ordinary-condition execution now exists too:
special-condition thresholds, candidate-availability thresholds, and economic-status-code special-condition thresholds, candidate-availability thresholds, and economic-status-code

View file

@ -1051,6 +1051,12 @@ fn lower_condition_targets_in_effect(
)?, )?,
value: *value, value: *value,
}, },
RuntimeEffect::DeactivatePlayer { target } => RuntimeEffect::DeactivatePlayer {
target: lower_condition_true_player_target_in_player_target(
target,
lowered_player_target,
)?,
},
RuntimeEffect::SetCompanyTerritoryAccess { RuntimeEffect::SetCompanyTerritoryAccess {
target, target,
territory, territory,
@ -1378,6 +1384,19 @@ 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::DeactivatePlayer { target } => {
if player_target_allowed_for_import(
target,
company_context,
allow_condition_true_player,
) {
Ok(RuntimeEffect::DeactivatePlayer {
target: target.clone(),
})
} else {
Err(player_target_import_error_message(target, company_context))
}
}
RuntimeEffect::SetCompanyTerritoryAccess { RuntimeEffect::SetCompanyTerritoryAccess {
target, target,
territory, territory,
@ -2075,6 +2094,7 @@ fn runtime_effect_uses_condition_true_company(effect: &RuntimeEffect) -> bool {
RuntimeEffect::SetWorldFlag { .. } RuntimeEffect::SetWorldFlag { .. }
| RuntimeEffect::SetEconomicStatusCode { .. } | RuntimeEffect::SetEconomicStatusCode { .. }
| RuntimeEffect::SetPlayerCash { .. } | RuntimeEffect::SetPlayerCash { .. }
| RuntimeEffect::DeactivatePlayer { .. }
| RuntimeEffect::SetCandidateAvailability { .. } | RuntimeEffect::SetCandidateAvailability { .. }
| RuntimeEffect::SetSpecialCondition { .. } | RuntimeEffect::SetSpecialCondition { .. }
| RuntimeEffect::ActivateEventRecord { .. } | RuntimeEffect::ActivateEventRecord { .. }
@ -2088,6 +2108,9 @@ fn runtime_effect_uses_condition_true_player(effect: &RuntimeEffect) -> bool {
RuntimeEffect::SetPlayerCash { target, .. } => { RuntimeEffect::SetPlayerCash { target, .. } => {
matches!(target, RuntimePlayerTarget::ConditionTruePlayer) matches!(target, RuntimePlayerTarget::ConditionTruePlayer)
} }
RuntimeEffect::DeactivatePlayer { target } => {
matches!(target, RuntimePlayerTarget::ConditionTruePlayer)
}
RuntimeEffect::AppendEventRecord { record } => record RuntimeEffect::AppendEventRecord { record } => record
.effects .effects
.iter() .iter()
@ -2119,7 +2142,8 @@ fn runtime_effect_company_target_import_blocker(
company_target_import_blocker(target, company_context) company_target_import_blocker(target, company_context)
} }
} }
RuntimeEffect::SetPlayerCash { target, .. } => { RuntimeEffect::SetPlayerCash { target, .. }
| RuntimeEffect::DeactivatePlayer { target } => {
player_target_import_blocker(target, company_context) player_target_import_blocker(target, company_context)
} }
RuntimeEffect::RetireTrains { RuntimeEffect::RetireTrains {
@ -2685,6 +2709,35 @@ mod tests {
} }
} }
fn real_deactivate_player_row(
enabled: bool,
) -> crate::SmpLoadedPackedEventGroupedEffectRowSummary {
crate::SmpLoadedPackedEventGroupedEffectRowSummary {
group_index: 0,
row_index: 0,
descriptor_id: 14,
descriptor_label: Some("Deactivate Player".to_string()),
target_mask_bits: Some(0x02),
parameter_family: Some("player_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 Player to {}",
if enabled { "TRUE" } else { "FALSE" }
)),
locomotive_name: None,
notes: vec![],
}
}
fn real_territory_access_row( fn real_territory_access_row(
enabled: bool, enabled: bool,
notes: Vec<String>, notes: Vec<String>,
@ -4938,6 +4991,208 @@ mod tests {
assert_eq!(import.state.selected_company_id, None); assert_eq!(import.state.selected_company_id, None);
} }
#[test]
fn overlays_real_deactivate_player_descriptor_into_executable_runtime_record() {
let base_state = RuntimeState {
players: vec![
crate::RuntimePlayer {
player_id: 7,
current_cash: 500,
active: true,
controller_kind: RuntimeCompanyControllerKind::Human,
},
crate::RuntimePlayer {
player_id: 8,
current_cash: 250,
active: true,
controller_kind: RuntimeCompanyControllerKind::Ai,
},
],
selected_player_id: Some(7),
..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: 18,
live_record_count: 1,
live_entry_ids: vec![18],
decoded_record_count: 1,
imported_runtime_record_count: 0,
records: vec![crate::SmpLoadedPackedEventRecordSummary {
record_index: 0,
live_entry_id: 18,
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: 0,
modifier_flag_0x7fa: 2,
grouped_target_scope_ordinals_0x7fb: vec![0, 0, 0, 0],
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: 1,
standalone_condition_rows: real_condition_rows(),
negative_sentinel_scope: Some(
crate::SmpLoadedPackedEventNegativeSentinelScopeSummary {
company_test_scope: RuntimeCompanyConditionTestScope::Disabled,
player_test_scope: RuntimePlayerConditionTestScope::SelectedPlayerOnly,
territory_scope_selector_is_0x63: false,
source_row_indexes: vec![0],
},
),
grouped_effect_row_counts: vec![1, 0, 0, 0],
grouped_effect_rows: vec![real_deactivate_player_row(true)],
decoded_conditions: Vec::new(),
decoded_actions: vec![RuntimeEffect::DeactivatePlayer {
target: RuntimePlayerTarget::ConditionTruePlayer,
}],
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-player-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-player descriptor should execute");
assert!(!import.state.players[0].active);
assert!(import.state.players[1].active);
assert_eq!(import.state.selected_player_id, None);
}
#[test]
fn keeps_real_deactivate_player_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: 19,
live_record_count: 1,
live_entry_ids: vec![19],
decoded_record_count: 1,
imported_runtime_record_count: 0,
records: vec![crate::SmpLoadedPackedEventRecordSummary {
record_index: 0,
live_entry_id: 19,
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: 0,
modifier_flag_0x7fa: 2,
grouped_target_scope_ordinals_0x7fb: vec![0, 0, 0, 0],
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: 1,
standalone_condition_rows: real_condition_rows(),
negative_sentinel_scope: Some(
crate::SmpLoadedPackedEventNegativeSentinelScopeSummary {
company_test_scope: RuntimeCompanyConditionTestScope::Disabled,
player_test_scope: RuntimePlayerConditionTestScope::SelectedPlayerOnly,
territory_scope_selector_is_0x63: false,
source_row_indexes: vec![0],
},
),
grouped_effect_row_counts: vec![1, 0, 0, 0],
grouped_effect_rows: vec![real_deactivate_player_row(false)],
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-deactivate-player-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] #[test]
fn keeps_real_deactivate_company_false_row_parity_only() { fn keeps_real_deactivate_company_false_row_parity_only() {
let save_slice = SmpLoadedSaveSlice { let save_slice = SmpLoadedSaveSlice {

View file

@ -262,6 +262,9 @@ pub enum RuntimeEffect {
target: RuntimePlayerTarget, target: RuntimePlayerTarget,
value: i64, value: i64,
}, },
DeactivatePlayer {
target: RuntimePlayerTarget,
},
SetCompanyTerritoryAccess { SetCompanyTerritoryAccess {
target: RuntimeCompanyTarget, target: RuntimeCompanyTarget,
territory: RuntimeTerritoryTarget, territory: RuntimeTerritoryTarget,
@ -1144,7 +1147,8 @@ fn validate_runtime_effect(
validate_company_target(target, valid_company_ids)?; validate_company_target(target, valid_company_ids)?;
validate_territory_target(territory, valid_territory_ids)?; validate_territory_target(territory, valid_territory_ids)?;
} }
RuntimeEffect::SetPlayerCash { target, .. } => { RuntimeEffect::SetPlayerCash { target, .. }
| RuntimeEffect::DeactivatePlayer { target } => {
validate_player_target(target, valid_player_ids)?; validate_player_target(target, valid_player_ids)?;
} }
RuntimeEffect::RetireTrains { RuntimeEffect::RetireTrains {

View file

@ -128,7 +128,7 @@ struct RealGroupedEffectDescriptorMetadata {
executable_in_runtime: bool, executable_in_runtime: bool,
} }
const REAL_GROUPED_EFFECT_DESCRIPTOR_METADATA: [RealGroupedEffectDescriptorMetadata; 11] = [ const REAL_GROUPED_EFFECT_DESCRIPTOR_METADATA: [RealGroupedEffectDescriptorMetadata; 12] = [
RealGroupedEffectDescriptorMetadata { RealGroupedEffectDescriptorMetadata {
descriptor_id: 1, descriptor_id: 1,
label: "Player Cash", label: "Player Cash",
@ -201,6 +201,14 @@ const REAL_GROUPED_EFFECT_DESCRIPTOR_METADATA: [RealGroupedEffectDescriptorMetad
runtime_key: None, runtime_key: None,
executable_in_runtime: true, executable_in_runtime: true,
}, },
RealGroupedEffectDescriptorMetadata {
descriptor_id: 14,
label: "Deactivate Player",
target_mask_bits: 0x02,
parameter_family: "player_lifecycle_toggle",
runtime_key: None,
executable_in_runtime: true,
},
RealGroupedEffectDescriptorMetadata { RealGroupedEffectDescriptorMetadata {
descriptor_id: 15, descriptor_id: 15,
label: "Retire Train", label: "Retire Train",
@ -2378,8 +2386,8 @@ fn parse_real_condition_row_summary(
let comparator = ordinary_metadata let comparator = ordinary_metadata
.and_then(|_| decode_real_condition_comparator(subtype)) .and_then(|_| decode_real_condition_comparator(subtype))
.map(condition_comparator_label); .map(condition_comparator_label);
let metric = let metric = ordinary_metadata
ordinary_metadata.map(|metadata| real_ordinary_condition_metric_label(metadata, candidate_name_ref)); .map(|metadata| real_ordinary_condition_metric_label(metadata, candidate_name_ref));
let threshold = ordinary_metadata.and_then(|_| decode_real_condition_threshold(&flag_bytes)); let threshold = ordinary_metadata.and_then(|_| decode_real_condition_threshold(&flag_bytes));
let requires_candidate_name_binding = ordinary_metadata.is_some_and(|metadata| { let requires_candidate_name_binding = ordinary_metadata.is_some_and(|metadata| {
matches!( matches!(
@ -2409,7 +2417,10 @@ fn parse_real_condition_row_summary(
RealOrdinaryConditionKind::WorldState(RealWorldConditionKind::CandidateAvailability) RealOrdinaryConditionKind::WorldState(RealWorldConditionKind::CandidateAvailability)
) && candidate_name.is_none() ) && candidate_name.is_none()
}) { }) {
notes.push("candidate-availability condition row is missing its candidate-name side string".to_string()); notes.push(
"candidate-availability condition row is missing its candidate-name side string"
.to_string(),
);
} }
Some(SmpLoadedPackedEventConditionRowSummary { Some(SmpLoadedPackedEventConditionRowSummary {
row_index, row_index,
@ -2507,7 +2518,9 @@ fn real_ordinary_condition_metric_label(
) -> String { ) -> String {
match metadata.kind { match metadata.kind {
RealOrdinaryConditionKind::Numeric(_) => metadata.label.to_string(), RealOrdinaryConditionKind::Numeric(_) => metadata.label.to_string(),
RealOrdinaryConditionKind::WorldState(RealWorldConditionKind::SpecialCondition { label }) => { RealOrdinaryConditionKind::WorldState(RealWorldConditionKind::SpecialCondition {
label,
}) => {
format!("Special Condition: {label}") format!("Special Condition: {label}")
} }
RealOrdinaryConditionKind::WorldState(RealWorldConditionKind::CandidateAvailability) => { RealOrdinaryConditionKind::WorldState(RealWorldConditionKind::CandidateAvailability) => {
@ -2522,7 +2535,9 @@ fn real_ordinary_condition_metric_label(
} }
} }
fn real_ordinary_condition_semantic_family(metadata: RealOrdinaryConditionMetadata) -> &'static str { fn real_ordinary_condition_semantic_family(
metadata: RealOrdinaryConditionMetadata,
) -> &'static str {
match metadata.kind { match metadata.kind {
RealOrdinaryConditionKind::Numeric(_) => "numeric_threshold", RealOrdinaryConditionKind::Numeric(_) => "numeric_threshold",
RealOrdinaryConditionKind::WorldState(_) => "world_state_threshold", RealOrdinaryConditionKind::WorldState(_) => "world_state_threshold",
@ -2693,8 +2708,9 @@ fn decode_real_condition_row(
value, value,
}) })
} }
RealOrdinaryConditionKind::Numeric(RealOrdinaryConditionMetric::CompanyTerritory(metric)) => { RealOrdinaryConditionKind::Numeric(RealOrdinaryConditionMetric::CompanyTerritory(
negative_sentinel_scope metric,
)) => negative_sentinel_scope
.filter(|scope| scope.territory_scope_selector_is_0x63) .filter(|scope| scope.territory_scope_selector_is_0x63)
.map(|_| RuntimeCondition::CompanyTerritoryNumericThreshold { .map(|_| RuntimeCondition::CompanyTerritoryNumericThreshold {
target: RuntimeCompanyTarget::ConditionTrueCompany, target: RuntimeCompanyTarget::ConditionTrueCompany,
@ -2702,8 +2718,7 @@ fn decode_real_condition_row(
metric, metric,
comparator, comparator,
value, value,
}) }),
}
RealOrdinaryConditionKind::WorldState(RealWorldConditionKind::SpecialCondition { RealOrdinaryConditionKind::WorldState(RealWorldConditionKind::SpecialCondition {
label, label,
}) => Some(RuntimeCondition::SpecialConditionThreshold { }) => Some(RuntimeCondition::SpecialConditionThreshold {
@ -2711,14 +2726,13 @@ fn decode_real_condition_row(
comparator, comparator,
value, value,
}), }),
RealOrdinaryConditionKind::WorldState( RealOrdinaryConditionKind::WorldState(RealWorldConditionKind::CandidateAvailability) => row
RealWorldConditionKind::CandidateAvailability, .candidate_name
) => row.candidate_name.as_ref().map(|name| { .as_ref()
RuntimeCondition::CandidateAvailabilityThreshold { .map(|name| RuntimeCondition::CandidateAvailabilityThreshold {
name: name.clone(), name: name.clone(),
comparator, comparator,
value, value,
}
}), }),
RealOrdinaryConditionKind::WorldState(RealWorldConditionKind::EconomicStatus) => { RealOrdinaryConditionKind::WorldState(RealWorldConditionKind::EconomicStatus) => {
Some(RuntimeCondition::EconomicStatusCodeThreshold { comparator, value }) Some(RuntimeCondition::EconomicStatusCodeThreshold { comparator, value })
@ -2813,7 +2827,9 @@ fn runtime_candidate_availability_name(label: &str) -> String {
.to_string() .to_string()
} }
fn runtime_world_flag_key(descriptor_metadata: RealGroupedEffectDescriptorMetadata) -> Option<String> { fn runtime_world_flag_key(
descriptor_metadata: RealGroupedEffectDescriptorMetadata,
) -> Option<String> {
descriptor_metadata.runtime_key.map(str::to_string) descriptor_metadata.runtime_key.map(str::to_string)
} }
@ -2939,6 +2955,15 @@ fn decode_real_grouped_effect_action(
return Some(RuntimeEffect::DeactivateCompany { target }); return Some(RuntimeEffect::DeactivateCompany { target });
} }
if descriptor_metadata.executable_in_runtime
&& descriptor_metadata.descriptor_id == 14
&& row.row_shape == "bool_toggle"
&& row.raw_scalar_value != 0
{
let target = real_grouped_player_target(target_scope_ordinal)?;
return Some(RuntimeEffect::DeactivatePlayer { target });
}
if descriptor_metadata.executable_in_runtime if descriptor_metadata.executable_in_runtime
&& descriptor_metadata.descriptor_id == 16 && descriptor_metadata.descriptor_id == 16
&& row.row_shape == "scalar_assignment" && row.row_shape == "scalar_assignment"
@ -3151,6 +3176,7 @@ fn runtime_effect_supported_for_save_import(effect: &RuntimeEffect) -> bool {
| RuntimeEffect::SetSpecialCondition { .. } | RuntimeEffect::SetSpecialCondition { .. }
| RuntimeEffect::ConfiscateCompanyAssets { .. } | RuntimeEffect::ConfiscateCompanyAssets { .. }
| RuntimeEffect::DeactivateCompany { .. } | RuntimeEffect::DeactivateCompany { .. }
| RuntimeEffect::DeactivatePlayer { .. }
| RuntimeEffect::SetCompanyTrackLayingCapacity { .. } | RuntimeEffect::SetCompanyTrackLayingCapacity { .. }
| RuntimeEffect::RetireTrains { .. } | RuntimeEffect::RetireTrains { .. }
| RuntimeEffect::ActivateEventRecord { .. } | RuntimeEffect::ActivateEventRecord { .. }
@ -8715,8 +8741,12 @@ mod tests {
#[test] #[test]
fn decodes_real_candidate_availability_threshold_from_checked_in_world_condition_metadata() { fn decodes_real_candidate_availability_threshold_from_checked_in_world_condition_metadata() {
let condition_row = let condition_row = build_real_condition_row_with_threshold(
build_real_condition_row_with_threshold(REAL_CANDIDATE_AVAILABILITY_CONDITION_TEMPLATE_ID, 0, 2, Some("Mogul")); REAL_CANDIDATE_AVAILABILITY_CONDITION_TEMPLATE_ID,
0,
2,
Some("Mogul"),
);
let record_body = build_real_event_record( let record_body = build_real_event_record(
[b"World", b"", b"", b"", b"", b""], [b"World", b"", b"", b"", b"", b""],
Some(RealCompactControlSpec { Some(RealCompactControlSpec {
@ -8839,6 +8869,90 @@ mod tests {
assert!(metadata.executable_in_runtime); assert!(metadata.executable_in_runtime);
} }
#[test]
fn looks_up_checked_in_deactivate_player_descriptor_metadata() {
let metadata =
real_grouped_effect_descriptor_metadata(14).expect("descriptor metadata should exist");
assert_eq!(metadata.label, "Deactivate Player");
assert_eq!(metadata.parameter_family, "player_lifecycle_toggle");
assert_eq!(metadata.runtime_key, None);
assert!(metadata.executable_in_runtime);
}
#[test]
fn decodes_real_deactivate_player_descriptor_from_checked_in_metadata() {
let grouped_row = build_real_grouped_effect_row(RealGroupedEffectRowSpec {
descriptor_id: 14,
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,
locomotive_name: None,
});
let group0_rows = vec![grouped_row];
let record_body = build_real_event_record(
[b"Players", b"", b"", b"", b"", b""],
Some(RealCompactControlSpec {
mode_byte_0x7ef: 7,
primary_selector_0x7f0: 0,
grouped_mode_0x7f4: 2,
one_shot_header_0x7f5: 0,
modifier_flag_0x7f9: 0,
modifier_flag_0x7fa: 0,
grouped_target_scope_ordinals_0x7fb: [1, 0, 0, 0],
grouped_scope_checkboxes_0x7ff: [1, 0, 0, 0],
summary_toggle_0x800: 1,
grouped_territory_selectors_0x80f: [-1, -1, -1, -1],
}),
&[],
[&group0_rows, &[], &[], &[]],
);
let mut bytes = Vec::new();
bytes.extend_from_slice(&EVENT_RUNTIME_COLLECTION_METADATA_TAG.to_le_bytes());
bytes.extend_from_slice(&EVENT_RUNTIME_COLLECTION_PACKED_STATE_VERSION.to_le_bytes());
let header_words = [1u32, 4, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
for word in header_words {
bytes.extend_from_slice(&word.to_le_bytes());
}
bytes.extend_from_slice(&[0x00, 0x00]);
bytes.extend_from_slice(&[0xaa, 0xbb, 0xcc, 0xdd]);
bytes.extend_from_slice(&EVENT_RUNTIME_COLLECTION_RECORDS_TAG.to_le_bytes());
bytes.extend_from_slice(&record_body);
bytes.extend_from_slice(&EVENT_RUNTIME_COLLECTION_CLOSE_TAG.to_le_bytes());
let report = inspect_smp_bytes(&bytes);
let summary = report
.event_runtime_collection_summary
.as_ref()
.expect("event runtime collection summary should parse");
assert_eq!(
summary.records[0].grouped_effect_rows[0]
.descriptor_label
.as_deref(),
Some("Deactivate Player")
);
assert_eq!(
summary.records[0].grouped_effect_rows[0]
.parameter_family
.as_deref(),
Some("player_lifecycle_toggle")
);
assert_eq!(
summary.records[0].decoded_actions,
vec![RuntimeEffect::DeactivatePlayer {
target: RuntimePlayerTarget::SelectedPlayer,
}]
);
assert!(summary.records[0].executable_import_ready);
}
#[test] #[test]
fn decodes_real_world_flag_descriptor_with_checked_in_runtime_key() { fn decodes_real_world_flag_descriptor_with_checked_in_runtime_key() {
let grouped_row = build_real_grouped_effect_row(RealGroupedEffectRowSpec { let grouped_row = build_real_grouped_effect_row(RealGroupedEffectRowSpec {

View file

@ -342,6 +342,25 @@ fn apply_runtime_effects(
mutated_player_ids.insert(player_id); mutated_player_ids.insert(player_id);
} }
} }
RuntimeEffect::DeactivatePlayer { target } => {
let player_ids = resolve_player_target_ids(state, target, condition_context)?;
for player_id in player_ids {
let player = state
.players
.iter_mut()
.find(|player| player.player_id == player_id)
.ok_or_else(|| {
format!(
"missing player_id {player_id} while applying deactivate effect"
)
})?;
player.active = false;
mutated_player_ids.insert(player_id);
if state.selected_player_id == Some(player_id) {
state.selected_player_id = None;
}
}
}
RuntimeEffect::SetCompanyTerritoryAccess { RuntimeEffect::SetCompanyTerritoryAccess {
target, target,
territory, territory,
@ -1092,9 +1111,9 @@ mod tests {
use super::*; use super::*;
use crate::{ use crate::{
CalendarPoint, RuntimeCompany, RuntimeCompanyControllerKind, RuntimeCompanyTarget, CalendarPoint, RuntimeCompany, RuntimeCompanyControllerKind, RuntimeCompanyTarget,
RuntimeEffect, RuntimeEventRecord, RuntimeEventRecordTemplate, RuntimeSaveProfileState, RuntimeEffect, RuntimeEventRecord, RuntimeEventRecordTemplate, RuntimePlayer,
RuntimeServiceState, RuntimeTerritory, RuntimeTerritoryTarget, RuntimeTrackPieceCounts, RuntimeSaveProfileState, RuntimeServiceState, RuntimeTerritory, RuntimeTerritoryTarget,
RuntimeTrain, RuntimeWorldRestoreState, RuntimeTrackPieceCounts, RuntimeTrain, RuntimeWorldRestoreState,
}; };
fn state() -> RuntimeState { fn state() -> RuntimeState {
@ -1631,6 +1650,43 @@ mod tests {
assert_eq!(result.service_events[0].mutated_company_ids, vec![1]); assert_eq!(result.service_events[0].mutated_company_ids, vec![1]);
} }
#[test]
fn deactivating_selected_player_clears_selection() {
let mut state = RuntimeState {
players: vec![RuntimePlayer {
player_id: 1,
current_cash: 500,
active: true,
controller_kind: RuntimeCompanyControllerKind::Human,
}],
selected_player_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,
conditions: Vec::new(),
effects: vec![RuntimeEffect::DeactivatePlayer {
target: crate::RuntimePlayerTarget::SelectedPlayer,
}],
}],
..state()
};
let result = execute_step_command(
&mut state,
&StepCommand::ServiceTriggerKind { trigger_kind: 7 },
)
.expect("deactivate player effect should succeed");
assert!(!state.players[0].active);
assert_eq!(state.selected_player_id, None);
assert_eq!(result.service_events[0].mutated_player_ids, vec![1]);
}
#[test] #[test]
fn sets_track_laying_capacity_for_resolved_targets() { fn sets_track_laying_capacity_for_resolved_targets() {
let mut state = RuntimeState { let mut state = RuntimeState {

View file

@ -81,8 +81,9 @@ The highest-value next passes are now:
first company-scoped batch already parses, summarizes, and executes through the ordinary runtime first company-scoped batch already parses, summarizes, and executes through the ordinary runtime
path when overlay context resolves its symbolic company scope: descriptor `2` `Company Cash`, path when overlay context resolves its symbolic company scope: descriptor `2` `Company Cash`,
descriptor `13` `Deactivate Company`, and descriptor `16` `Company Track Pieces Buildable` descriptor `13` `Deactivate Company`, and descriptor `16` `Company Track Pieces Buildable`
- descriptor `1` `Player Cash` now joins that executable real batch through the same ordinary - descriptors `1` `Player Cash` and `14` `Deactivate Player` now join that executable real batch
runtime path, backed by the minimal player runtime and overlay-import context through the same ordinary runtime path, backed by the minimal player runtime and overlay-import
context
- widen real packed-event executable coverage descriptor by descriptor after identity, target mask, - widen real packed-event executable coverage descriptor by descriptor after identity, target mask,
and normalized effect semantics are all grounded, not just after row framing is parsed and normalized effect semantics are all grounded, not just after row framing is parsed
- the first grounded condition-side unlock now exists for negative-sentinel `raw_condition_id = -1` - the first grounded condition-side unlock now exists for negative-sentinel `raw_condition_id = -1`

View file

@ -39,7 +39,8 @@ Implemented today:
through overlay-backed runtime context through overlay-backed runtime context
- exact named-territory binding now lowers candidate-name ordinary rows onto tracked territory - exact named-territory binding now lowers candidate-name ordinary rows onto tracked territory
names, a minimal player runtime now carries selected-player and role context, and real descriptor names, a minimal player runtime now carries selected-player and role context, and real descriptor
`1` = `Player Cash` now imports and executes through the ordinary runtime path `1` = `Player Cash` and descriptor `14` = `Deactivate Player` now import and execute through the
ordinary runtime path
- a minimal event-owned train surface and an opaque economic-status lane now exist in runtime - a minimal event-owned train surface and an opaque economic-status lane now exist in runtime
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
@ -65,10 +66,8 @@ Implemented today:
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 grouped-descriptor and ordinary condition-id coverage beyond the current access, broader real grouped-descriptor and ordinary condition-id coverage beyond the current access,
whole-game, train, player, and numeric-threshold batches, with the world-side frontier now shifted whole-game, train, player, and numeric-threshold batches. Richer runtime ownership should still be
away from the first world-flag unlock and onto broader descriptor and condition recovery for later added only where a later descriptor or condition family needs more than the current event-owned
state families that still need stronger checked-in metadata. Richer runtime ownership should still
be added only where a later descriptor or condition family needs more than the current event-owned
roster. roster.
## Why This Boundary ## Why This Boundary

View file

@ -0,0 +1,59 @@
{
"format_version": 1,
"fixture_id": "packed-event-deactivate-player-overlay-fixture",
"source": {
"kind": "captured-runtime",
"description": "Fixture proving descriptor 14 Deactivate Player imports and executes through the ordinary runtime path."
},
"state_import_path": "packed-event-deactivate-player-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,
"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": {
"selected_player_id": null,
"players": [
{
"player_id": 1,
"current_cash": 500,
"active": false
},
{
"player_id": 2,
"current_cash": 250,
"active": true
}
],
"packed_event_collection": {
"records": [
{
"import_outcome": "imported",
"decoded_actions": [
{
"kind": "deactivate_player",
"target": {
"kind": "selected_player"
}
}
]
}
]
}
}
}

View file

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

View file

@ -0,0 +1,125 @@
{
"format_version": 1,
"save_slice_id": "packed-event-deactivate-player-save-slice",
"source": {
"description": "Tracked save-slice document with a real player-scoped Deactivate Player row.",
"original_save_filename": "captured-deactivate-player.gms",
"original_save_sha256": "deactivate-player-sample-sha256",
"notes": [
"tracked as JSON save-slice document rather than raw .smp",
"proves descriptor 14 import through the normal runtime path"
]
},
"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": 29296,
"payload_len": 140,
"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": 2,
"grouped_target_scope_ordinals_0x7fb": [0, 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": 1,
"standalone_condition_rows": [
{
"row_index": 0,
"raw_condition_id": -1,
"subtype": 4,
"flag_bytes": [48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72],
"candidate_name": null,
"notes": [
"negative sentinel-style condition row id"
]
}
],
"negative_sentinel_scope": {
"company_test_scope": "disabled",
"player_test_scope": "selected_player_only",
"territory_scope_selector_is_0x63": false,
"source_row_indexes": [0]
},
"grouped_effect_row_counts": [1, 0, 0, 0],
"grouped_effect_rows": [
{
"group_index": 0,
"row_index": 0,
"descriptor_id": 14,
"descriptor_label": "Deactivate Player",
"target_mask_bits": 2,
"parameter_family": "player_lifecycle_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 Deactivate Player to TRUE",
"locomotive_name": null,
"notes": []
}
],
"decoded_conditions": [],
"decoded_actions": [
{
"kind": "deactivate_player",
"target": {
"kind": "condition_true_player"
}
}
],
"executable_import_ready": true,
"notes": [
"decoded from grounded real 0x4e9a row framing",
"player-side negative-sentinel scope lowers deactivate-player at import time"
]
}
]
},
"notes": [
"real deactivate player descriptor sample"
]
}
}