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
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
execute through the ordinary runtime path, and descriptor `1` `Player Cash` now joins that batch
through the same service engine. Synthetic packed records still exercise the same runtime without a
parallel packed executor. The first grounded condition-side unlock now exists for negative-sentinel
`raw_condition_id = -1` company scopes, and the first ordinary nonnegative condition batch now
executes too: numeric-threshold company finance, company track, aggregate territory track, and
company-territory track rows can import through overlay-backed runtime context. Exact
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`
`Confiscate All`, and `15` `Retire Train` to execute through the same path. Descriptor `3`
execute through the ordinary runtime path, and descriptors `1` `Player Cash` and `14`
`Deactivate Player` now join that batch through the same service engine. Synthetic packed records
still exercise the same runtime without a parallel packed executor. The first grounded
condition-side unlock now exists for negative-sentinel `raw_condition_id = -1` company scopes, and
the first ordinary nonnegative condition batch now executes too: numeric-threshold company
finance, company track, aggregate territory track, and company-territory track rows can import
through overlay-backed runtime context. Exact 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` `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
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

View file

@ -1051,6 +1051,12 @@ fn lower_condition_targets_in_effect(
)?,
value: *value,
},
RuntimeEffect::DeactivatePlayer { target } => RuntimeEffect::DeactivatePlayer {
target: lower_condition_true_player_target_in_player_target(
target,
lowered_player_target,
)?,
},
RuntimeEffect::SetCompanyTerritoryAccess {
target,
territory,
@ -1378,6 +1384,19 @@ fn smp_runtime_effect_to_runtime_effect(
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 {
target,
territory,
@ -2075,6 +2094,7 @@ fn runtime_effect_uses_condition_true_company(effect: &RuntimeEffect) -> bool {
RuntimeEffect::SetWorldFlag { .. }
| RuntimeEffect::SetEconomicStatusCode { .. }
| RuntimeEffect::SetPlayerCash { .. }
| RuntimeEffect::DeactivatePlayer { .. }
| RuntimeEffect::SetCandidateAvailability { .. }
| RuntimeEffect::SetSpecialCondition { .. }
| RuntimeEffect::ActivateEventRecord { .. }
@ -2088,6 +2108,9 @@ fn runtime_effect_uses_condition_true_player(effect: &RuntimeEffect) -> bool {
RuntimeEffect::SetPlayerCash { target, .. } => {
matches!(target, RuntimePlayerTarget::ConditionTruePlayer)
}
RuntimeEffect::DeactivatePlayer { target } => {
matches!(target, RuntimePlayerTarget::ConditionTruePlayer)
}
RuntimeEffect::AppendEventRecord { record } => record
.effects
.iter()
@ -2119,7 +2142,8 @@ fn runtime_effect_company_target_import_blocker(
company_target_import_blocker(target, company_context)
}
}
RuntimeEffect::SetPlayerCash { target, .. } => {
RuntimeEffect::SetPlayerCash { target, .. }
| RuntimeEffect::DeactivatePlayer { target } => {
player_target_import_blocker(target, company_context)
}
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(
enabled: bool,
notes: Vec<String>,
@ -4938,6 +4991,208 @@ mod tests {
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]
fn keeps_real_deactivate_company_false_row_parity_only() {
let save_slice = SmpLoadedSaveSlice {

View file

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

View file

@ -128,7 +128,7 @@ struct RealGroupedEffectDescriptorMetadata {
executable_in_runtime: bool,
}
const REAL_GROUPED_EFFECT_DESCRIPTOR_METADATA: [RealGroupedEffectDescriptorMetadata; 11] = [
const REAL_GROUPED_EFFECT_DESCRIPTOR_METADATA: [RealGroupedEffectDescriptorMetadata; 12] = [
RealGroupedEffectDescriptorMetadata {
descriptor_id: 1,
label: "Player Cash",
@ -201,6 +201,14 @@ const REAL_GROUPED_EFFECT_DESCRIPTOR_METADATA: [RealGroupedEffectDescriptorMetad
runtime_key: None,
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 {
descriptor_id: 15,
label: "Retire Train",
@ -2378,8 +2386,8 @@ fn parse_real_condition_row_summary(
let comparator = ordinary_metadata
.and_then(|_| decode_real_condition_comparator(subtype))
.map(condition_comparator_label);
let metric =
ordinary_metadata.map(|metadata| real_ordinary_condition_metric_label(metadata, candidate_name_ref));
let metric = ordinary_metadata
.map(|metadata| real_ordinary_condition_metric_label(metadata, candidate_name_ref));
let threshold = ordinary_metadata.and_then(|_| decode_real_condition_threshold(&flag_bytes));
let requires_candidate_name_binding = ordinary_metadata.is_some_and(|metadata| {
matches!(
@ -2409,7 +2417,10 @@ fn parse_real_condition_row_summary(
RealOrdinaryConditionKind::WorldState(RealWorldConditionKind::CandidateAvailability)
) && 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 {
row_index,
@ -2507,7 +2518,9 @@ fn real_ordinary_condition_metric_label(
) -> String {
match metadata.kind {
RealOrdinaryConditionKind::Numeric(_) => metadata.label.to_string(),
RealOrdinaryConditionKind::WorldState(RealWorldConditionKind::SpecialCondition { label }) => {
RealOrdinaryConditionKind::WorldState(RealWorldConditionKind::SpecialCondition {
label,
}) => {
format!("Special Condition: {label}")
}
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 {
RealOrdinaryConditionKind::Numeric(_) => "numeric_threshold",
RealOrdinaryConditionKind::WorldState(_) => "world_state_threshold",
@ -2685,16 +2700,17 @@ fn decode_real_condition_row(
}
RealOrdinaryConditionKind::Numeric(RealOrdinaryConditionMetric::Territory(metric)) => {
negative_sentinel_scope
.filter(|scope| scope.territory_scope_selector_is_0x63)
.map(|_| RuntimeCondition::TerritoryNumericThreshold {
target: RuntimeTerritoryTarget::AllTerritories,
metric,
comparator,
value,
})
.filter(|scope| scope.territory_scope_selector_is_0x63)
.map(|_| RuntimeCondition::TerritoryNumericThreshold {
target: RuntimeTerritoryTarget::AllTerritories,
metric,
comparator,
value,
})
}
RealOrdinaryConditionKind::Numeric(RealOrdinaryConditionMetric::CompanyTerritory(metric)) => {
negative_sentinel_scope
RealOrdinaryConditionKind::Numeric(RealOrdinaryConditionMetric::CompanyTerritory(
metric,
)) => negative_sentinel_scope
.filter(|scope| scope.territory_scope_selector_is_0x63)
.map(|_| RuntimeCondition::CompanyTerritoryNumericThreshold {
target: RuntimeCompanyTarget::ConditionTrueCompany,
@ -2702,8 +2718,7 @@ fn decode_real_condition_row(
metric,
comparator,
value,
})
}
}),
RealOrdinaryConditionKind::WorldState(RealWorldConditionKind::SpecialCondition {
label,
}) => Some(RuntimeCondition::SpecialConditionThreshold {
@ -2711,15 +2726,14 @@ fn decode_real_condition_row(
comparator,
value,
}),
RealOrdinaryConditionKind::WorldState(
RealWorldConditionKind::CandidateAvailability,
) => row.candidate_name.as_ref().map(|name| {
RuntimeCondition::CandidateAvailabilityThreshold {
RealOrdinaryConditionKind::WorldState(RealWorldConditionKind::CandidateAvailability) => row
.candidate_name
.as_ref()
.map(|name| RuntimeCondition::CandidateAvailabilityThreshold {
name: name.clone(),
comparator,
value,
}
}),
}),
RealOrdinaryConditionKind::WorldState(RealWorldConditionKind::EconomicStatus) => {
Some(RuntimeCondition::EconomicStatusCodeThreshold { comparator, value })
}
@ -2813,7 +2827,9 @@ fn runtime_candidate_availability_name(label: &str) -> 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)
}
@ -2939,6 +2955,15 @@ fn decode_real_grouped_effect_action(
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
&& descriptor_metadata.descriptor_id == 16
&& row.row_shape == "scalar_assignment"
@ -3151,6 +3176,7 @@ fn runtime_effect_supported_for_save_import(effect: &RuntimeEffect) -> bool {
| RuntimeEffect::SetSpecialCondition { .. }
| RuntimeEffect::ConfiscateCompanyAssets { .. }
| RuntimeEffect::DeactivateCompany { .. }
| RuntimeEffect::DeactivatePlayer { .. }
| RuntimeEffect::SetCompanyTrackLayingCapacity { .. }
| RuntimeEffect::RetireTrains { .. }
| RuntimeEffect::ActivateEventRecord { .. }
@ -8715,8 +8741,12 @@ mod tests {
#[test]
fn decodes_real_candidate_availability_threshold_from_checked_in_world_condition_metadata() {
let condition_row =
build_real_condition_row_with_threshold(REAL_CANDIDATE_AVAILABILITY_CONDITION_TEMPLATE_ID, 0, 2, Some("Mogul"));
let condition_row = build_real_condition_row_with_threshold(
REAL_CANDIDATE_AVAILABILITY_CONDITION_TEMPLATE_ID,
0,
2,
Some("Mogul"),
);
let record_body = build_real_event_record(
[b"World", b"", b"", b"", b"", b""],
Some(RealCompactControlSpec {
@ -8839,6 +8869,90 @@ mod tests {
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]
fn decodes_real_world_flag_descriptor_with_checked_in_runtime_key() {
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);
}
}
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 {
target,
territory,
@ -1092,9 +1111,9 @@ mod tests {
use super::*;
use crate::{
CalendarPoint, RuntimeCompany, RuntimeCompanyControllerKind, RuntimeCompanyTarget,
RuntimeEffect, RuntimeEventRecord, RuntimeEventRecordTemplate, RuntimeSaveProfileState,
RuntimeServiceState, RuntimeTerritory, RuntimeTerritoryTarget, RuntimeTrackPieceCounts,
RuntimeTrain, RuntimeWorldRestoreState,
RuntimeEffect, RuntimeEventRecord, RuntimeEventRecordTemplate, RuntimePlayer,
RuntimeSaveProfileState, RuntimeServiceState, RuntimeTerritory, RuntimeTerritoryTarget,
RuntimeTrackPieceCounts, RuntimeTrain, RuntimeWorldRestoreState,
};
fn state() -> RuntimeState {
@ -1631,6 +1650,43 @@ mod tests {
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]
fn sets_track_laying_capacity_for_resolved_targets() {
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
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 `1` `Player Cash` now joins that executable real batch through the same ordinary
runtime path, backed by the minimal player runtime and overlay-import context
- descriptors `1` `Player Cash` and `14` `Deactivate Player` now join that executable real batch
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,
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`

View file

@ -39,7 +39,8 @@ Implemented today:
through overlay-backed runtime context
- 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
`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
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
@ -65,10 +66,8 @@ Implemented today:
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,
whole-game, train, player, and numeric-threshold batches, with the world-side frontier now shifted
away from the first world-flag unlock and onto broader descriptor and condition recovery for later
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
whole-game, train, player, and numeric-threshold batches. Richer runtime ownership should still be
added only where a later descriptor or condition family needs more than the current event-owned
roster.
## 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"
]
}
}