diff --git a/README.md b/README.md index ff62fa6..948746e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/crates/rrt-runtime/src/import.rs b/crates/rrt-runtime/src/import.rs index 3535c88..0d31d32 100644 --- a/crates/rrt-runtime/src/import.rs +++ b/crates/rrt-runtime/src/import.rs @@ -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, @@ -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 { diff --git a/crates/rrt-runtime/src/runtime.rs b/crates/rrt-runtime/src/runtime.rs index 3c25c9f..d8d398e 100644 --- a/crates/rrt-runtime/src/runtime.rs +++ b/crates/rrt-runtime/src/runtime.rs @@ -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 { diff --git a/crates/rrt-runtime/src/smp.rs b/crates/rrt-runtime/src/smp.rs index 805d3d7..a0b3ec6 100644 --- a/crates/rrt-runtime/src/smp.rs +++ b/crates/rrt-runtime/src/smp.rs @@ -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 { +fn runtime_world_flag_key( + descriptor_metadata: RealGroupedEffectDescriptorMetadata, +) -> Option { 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 { diff --git a/crates/rrt-runtime/src/step.rs b/crates/rrt-runtime/src/step.rs index 26baf7a..0e85fd9 100644 --- a/crates/rrt-runtime/src/step.rs +++ b/crates/rrt-runtime/src/step.rs @@ -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 { diff --git a/docs/README.md b/docs/README.md index c5bc96c..82444d8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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` diff --git a/docs/runtime-rehost-plan.md b/docs/runtime-rehost-plan.md index 27d1e64..edc6daa 100644 --- a/docs/runtime-rehost-plan.md +++ b/docs/runtime-rehost-plan.md @@ -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 diff --git a/fixtures/runtime/packed-event-deactivate-player-overlay-fixture.json b/fixtures/runtime/packed-event-deactivate-player-overlay-fixture.json new file mode 100644 index 0000000..a94f4d6 --- /dev/null +++ b/fixtures/runtime/packed-event-deactivate-player-overlay-fixture.json @@ -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" + } + } + ] + } + ] + } + } +} diff --git a/fixtures/runtime/packed-event-deactivate-player-overlay.json b/fixtures/runtime/packed-event-deactivate-player-overlay.json new file mode 100644 index 0000000..7bfc00f --- /dev/null +++ b/fixtures/runtime/packed-event-deactivate-player-overlay.json @@ -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" +} diff --git a/fixtures/runtime/packed-event-deactivate-player-save-slice.json b/fixtures/runtime/packed-event-deactivate-player-save-slice.json new file mode 100644 index 0000000..63fc518 --- /dev/null +++ b/fixtures/runtime/packed-event-deactivate-player-save-slice.json @@ -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" + ] + } +}