diff --git a/README.md b/README.md index f104509..5bf0a92 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,10 @@ pure runtime reader. The annual dividend lane now runs there too: the runtime no shared year-or-control-transfer metric seam, the board-approved dividend ceiling helper, and the full annual dividend adjustment branch over owned current cash, public float, current dividend, building-growth policy, and recent profit history instead of leaving that policy on shell-side -dialog notes. +dialog notes. `simulation_service_periodic_boundary_work` is now beginning to use that same owner +surface too: the runtime chooses one annual-finance action per active company and already commits +the shellless dividend-adjustment and stock-issue branches by mutating owned dividend, cash, +outstanding-share, and issue-calendar state instead of stopping at reader-only diagnostics. The same seam now also carries the fixed-world building-density growth setting plus the linked chairman personality byte, which is enough to run the annual stock-repurchase gate as another pure reader over owned save-native state instead of a guessed finance-side approximation. diff --git a/crates/rrt-runtime/src/import.rs b/crates/rrt-runtime/src/import.rs index 106ef67..659278e 100644 --- a/crates/rrt-runtime/src/import.rs +++ b/crates/rrt-runtime/src/import.rs @@ -13984,11 +13984,15 @@ mod tests { special_conditions: BTreeMap::new(), service_state: RuntimeServiceState { periodic_boundary_calls: 9, + annual_finance_service_calls: 0, trigger_dispatch_counts: BTreeMap::new(), total_event_record_services: 4, dirty_rerun_count: 2, world_issue_opinion_base_terms_raw_i32: Vec::new(), company_market_state: BTreeMap::new(), + annual_finance_last_actions: BTreeMap::new(), + annual_finance_action_counts: BTreeMap::new(), + annual_dividend_adjustment_commit_count: 0, chairman_issue_opinion_terms_raw_i32: BTreeMap::new(), chairman_personality_raw_u8: BTreeMap::new(), }, diff --git a/crates/rrt-runtime/src/lib.rs b/crates/rrt-runtime/src/lib.rs index 3df9195..aa9f28e 100644 --- a/crates/rrt-runtime/src/lib.rs +++ b/crates/rrt-runtime/src/lib.rs @@ -54,6 +54,7 @@ pub use runtime::{ RuntimeChairmanProfile, RuntimeChairmanTarget, RuntimeCompany, RuntimeCompanyAnnualBondPolicyState, RuntimeCompanyAnnualCreditorPressureState, RuntimeCompanyAnnualDeepDistressState, RuntimeCompanyAnnualDividendPolicyState, + RuntimeCompanyAnnualFinancePolicyAction, RuntimeCompanyAnnualFinancePolicyState, RuntimeCompanyAnnualFinanceState, RuntimeCompanyAnnualStockIssueState, RuntimeCompanyAnnualStockRepurchaseState, RuntimeCompanyBondSlot, RuntimeCompanyConditionTestScope, RuntimeCompanyControllerKind, RuntimeCompanyMarketMetric, @@ -70,7 +71,9 @@ pub use runtime::{ RuntimeTrackPieceCounts, RuntimeTrain, RuntimeWorldFinanceNeighborhoodCandidate, RuntimeWorldIssueState, RuntimeWorldRestoreState, runtime_company_annual_bond_policy_state, runtime_company_annual_creditor_pressure_state, runtime_company_annual_deep_distress_state, - runtime_company_annual_dividend_policy_state, runtime_company_annual_finance_state, + runtime_company_annual_dividend_policy_state, + runtime_company_annual_finance_policy_action_label, + runtime_company_annual_finance_policy_state, runtime_company_annual_finance_state, runtime_company_annual_stock_issue_state, runtime_company_annual_stock_repurchase_state, runtime_company_assigned_share_pool, runtime_company_average_live_bond_coupon, runtime_company_book_value_per_share, runtime_company_credit_rating, diff --git a/crates/rrt-runtime/src/runtime.rs b/crates/rrt-runtime/src/runtime.rs index f5bb797..6347827 100644 --- a/crates/rrt-runtime/src/runtime.rs +++ b/crates/rrt-runtime/src/runtime.rs @@ -376,6 +376,31 @@ pub struct RuntimeCompanyAnnualDividendPolicyState { pub eligible_for_dividend_adjustment_branch: bool, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, PartialOrd, Ord)] +#[serde(rename_all = "snake_case")] +pub enum RuntimeCompanyAnnualFinancePolicyAction { + #[default] + None, + CreditorPressureBankruptcy, + DeepDistressBankruptcyFallback, + BondIssue, + StockRepurchase, + StockIssue, + DividendAdjustment, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuntimeCompanyAnnualFinancePolicyState { + pub company_id: u32, + pub action: RuntimeCompanyAnnualFinancePolicyAction, + pub creditor_pressure_bankruptcy_eligible: bool, + pub deep_distress_bankruptcy_fallback_eligible: bool, + pub bond_issue_eligible: bool, + pub stock_repurchase_eligible: bool, + pub stock_issue_eligible: bool, + pub dividend_adjustment_eligible: bool, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] pub struct RuntimeTrackPieceCounts { #[serde(default)] @@ -1182,6 +1207,8 @@ pub struct RuntimeServiceState { #[serde(default)] pub periodic_boundary_calls: u64, #[serde(default)] + pub annual_finance_service_calls: u64, + #[serde(default)] pub trigger_dispatch_counts: BTreeMap, #[serde(default)] pub total_event_record_services: u64, @@ -1192,6 +1219,12 @@ pub struct RuntimeServiceState { #[serde(default)] pub company_market_state: BTreeMap, #[serde(default)] + pub annual_finance_last_actions: BTreeMap, + #[serde(default)] + pub annual_finance_action_counts: BTreeMap, + #[serde(default)] + pub annual_dividend_adjustment_commit_count: u64, + #[serde(default)] pub chairman_issue_opinion_terms_raw_i32: BTreeMap>, #[serde(default)] pub chairman_personality_raw_u8: BTreeMap, @@ -2094,6 +2127,14 @@ impl RuntimeState { )); } } + for company_id in self.service_state.annual_finance_last_actions.keys() { + if !seen_company_ids.contains(company_id) { + return Err(format!( + "service_state.annual_finance_last_actions references unknown company_id {}", + company_id + )); + } + } for chairman_profile_id in self .service_state .chairman_issue_opinion_terms_raw_i32 @@ -3398,6 +3439,78 @@ pub fn runtime_company_annual_dividend_policy_state( }) } +pub fn runtime_company_annual_finance_policy_state( + state: &RuntimeState, + company_id: u32, +) -> Option { + runtime_company_annual_finance_state(state, company_id)?; + let creditor_pressure_bankruptcy_eligible = + runtime_company_annual_creditor_pressure_state(state, company_id) + .map(|state| state.eligible_for_bankruptcy_branch) + .unwrap_or(false); + let deep_distress_bankruptcy_fallback_eligible = + runtime_company_annual_deep_distress_state(state, company_id) + .map(|state| state.eligible_for_bankruptcy_fallback) + .unwrap_or(false); + let bond_issue_eligible = runtime_company_annual_bond_policy_state(state, company_id) + .map(|state| state.eligible_for_bond_issue_branch) + .unwrap_or(false); + let stock_repurchase_eligible = + runtime_company_annual_stock_repurchase_state(state, company_id) + .map(|state| state.eligible_for_single_batch_repurchase) + .unwrap_or(false); + let stock_issue_eligible = runtime_company_annual_stock_issue_state(state, company_id) + .map(|state| state.eligible_for_double_tranche_issue) + .unwrap_or(false); + let dividend_adjustment_eligible = + runtime_company_annual_dividend_policy_state(state, company_id) + .map(|state| state.eligible_for_dividend_adjustment_branch) + .unwrap_or(false); + let action = if creditor_pressure_bankruptcy_eligible { + RuntimeCompanyAnnualFinancePolicyAction::CreditorPressureBankruptcy + } else if deep_distress_bankruptcy_fallback_eligible { + RuntimeCompanyAnnualFinancePolicyAction::DeepDistressBankruptcyFallback + } else if bond_issue_eligible { + RuntimeCompanyAnnualFinancePolicyAction::BondIssue + } else if stock_repurchase_eligible { + RuntimeCompanyAnnualFinancePolicyAction::StockRepurchase + } else if stock_issue_eligible { + RuntimeCompanyAnnualFinancePolicyAction::StockIssue + } else if dividend_adjustment_eligible { + RuntimeCompanyAnnualFinancePolicyAction::DividendAdjustment + } else { + RuntimeCompanyAnnualFinancePolicyAction::None + }; + Some(RuntimeCompanyAnnualFinancePolicyState { + company_id, + action, + creditor_pressure_bankruptcy_eligible, + deep_distress_bankruptcy_fallback_eligible, + bond_issue_eligible, + stock_repurchase_eligible, + stock_issue_eligible, + dividend_adjustment_eligible, + }) +} + +pub fn runtime_company_annual_finance_policy_action_label( + action: RuntimeCompanyAnnualFinancePolicyAction, +) -> &'static str { + match action { + RuntimeCompanyAnnualFinancePolicyAction::None => "none", + RuntimeCompanyAnnualFinancePolicyAction::CreditorPressureBankruptcy => { + "creditor_pressure_bankruptcy" + } + RuntimeCompanyAnnualFinancePolicyAction::DeepDistressBankruptcyFallback => { + "deep_distress_bankruptcy_fallback" + } + RuntimeCompanyAnnualFinancePolicyAction::BondIssue => "bond_issue", + RuntimeCompanyAnnualFinancePolicyAction::StockRepurchase => "stock_repurchase", + RuntimeCompanyAnnualFinancePolicyAction::StockIssue => "stock_issue", + RuntimeCompanyAnnualFinancePolicyAction::DividendAdjustment => "dividend_adjustment", + } +} + fn runtime_company_stock_issue_price_to_book_ratio_f64( pressured_support_adjusted_share_price_scalar: f64, book_value_per_share: f64, @@ -8459,6 +8572,143 @@ mod tests { assert!(dividend_state.eligible_for_dividend_adjustment_branch); } + #[test] + fn derives_annual_finance_policy_action_from_branch_priority_order() { + let mut year_stat_family_qword_bits = vec![ + 0u64; + ((RUNTIME_COMPANY_STAT_SLOT_COUNT + 2) * RUNTIME_COMPANY_YEAR_STAT_FAMILY_SPAN) + as usize + ]; + let write_current_value = |bits: &mut Vec, slot_id: u32, value: f64| { + let index = (slot_id * RUNTIME_COMPANY_YEAR_STAT_FAMILY_SPAN) as usize; + bits[index] = value.to_bits(); + }; + let write_prior_year_value = + |bits: &mut Vec, slot_id: u32, year_delta: u32, value: f64| { + let index = (slot_id * RUNTIME_COMPANY_YEAR_STAT_FAMILY_SPAN + year_delta) as usize; + bits[index] = value.to_bits(); + }; + write_current_value(&mut year_stat_family_qword_bits, 0x0d, 300_000.0); + write_current_value(&mut year_stat_family_qword_bits, 0x01, 300_000.0); + write_current_value(&mut year_stat_family_qword_bits, 0x09, -180_000.0); + write_prior_year_value(&mut year_stat_family_qword_bits, 0x01, 1, 280_000.0); + write_prior_year_value(&mut year_stat_family_qword_bits, 0x09, 1, -190_000.0); + write_prior_year_value(&mut year_stat_family_qword_bits, 0x01, 2, 260_000.0); + write_prior_year_value(&mut year_stat_family_qword_bits, 0x09, 2, -200_000.0); + write_prior_year_value(&mut year_stat_family_qword_bits, 0x1c, 1, 5.0); + + let state = RuntimeState { + calendar: CalendarPoint { + year: 1845, + month_slot: 0, + phase_slot: 0, + tick_slot: 0, + }, + world_flags: BTreeMap::new(), + save_profile: RuntimeSaveProfileState::default(), + world_restore: RuntimeWorldRestoreState { + packed_year_word_raw_u16: Some(1845), + partial_year_progress_raw_u8: Some(0x0c), + dividend_policy_raw_u8: Some(0), + dividend_adjustment_allowed: Some(true), + stock_issue_and_buyback_policy_raw_u8: Some(0), + stock_issue_and_buyback_allowed: Some(true), + bond_issue_and_repayment_policy_raw_u8: Some(0), + bond_issue_and_repayment_allowed: Some(true), + bankruptcy_policy_raw_u8: Some(0), + bankruptcy_allowed: Some(true), + building_density_growth_setting_raw_u32: Some(1), + ..RuntimeWorldRestoreState::default() + }, + metadata: BTreeMap::new(), + companies: vec![RuntimeCompany { + company_id: 16, + current_cash: 0, + debt: 0, + credit_rating_score: None, + prime_rate: None, + active: true, + available_track_laying_capacity: None, + controller_kind: RuntimeCompanyControllerKind::Unknown, + linked_chairman_profile_id: Some(4), + book_value_per_share: 0, + investor_confidence: 0, + management_attitude: 0, + takeover_cooldown_year: None, + merger_cooldown_year: None, + track_piece_counts: RuntimeTrackPieceCounts::default(), + }], + selected_company_id: Some(16), + players: Vec::new(), + selected_player_id: None, + chairman_profiles: vec![RuntimeChairmanProfile { + profile_id: 4, + name: "Chairman Four".to_string(), + active: true, + current_cash: 0, + linked_company_id: Some(16), + company_holdings: BTreeMap::from([(16, 9_500)]), + holdings_value_total: 0, + net_worth_total: 0, + purchasing_power_total: 0, + }], + selected_chairman_profile_id: None, + trains: Vec::new(), + locomotive_catalog: Vec::new(), + cargo_catalog: Vec::new(), + territories: Vec::new(), + company_territory_track_piece_counts: Vec::new(), + company_territory_access: Vec::new(), + packed_event_collection: None, + event_runtime_records: Vec::new(), + candidate_availability: BTreeMap::new(), + named_locomotive_availability: BTreeMap::new(), + named_locomotive_cost: BTreeMap::new(), + all_cargo_price_override: None, + named_cargo_price_overrides: BTreeMap::new(), + all_cargo_production_override: None, + factory_cargo_production_override: None, + farm_mine_cargo_production_override: None, + named_cargo_production_overrides: BTreeMap::new(), + cargo_production_overrides: BTreeMap::new(), + world_runtime_variables: BTreeMap::new(), + company_runtime_variables: BTreeMap::new(), + player_runtime_variables: BTreeMap::new(), + territory_runtime_variables: BTreeMap::new(), + world_scalar_overrides: BTreeMap::new(), + special_conditions: BTreeMap::new(), + service_state: RuntimeServiceState { + chairman_personality_raw_u8: BTreeMap::from([(4, 20)]), + company_market_state: BTreeMap::from([( + 16, + RuntimeCompanyMarketState { + outstanding_shares: 10_000, + founding_year: 1840, + last_dividend_year: 1844, + city_connection_latch: true, + year_stat_family_qword_bits, + direct_control_transfer_float_fields_raw_u32: BTreeMap::from([( + 0x33f, + 0.4f32.to_bits(), + )]), + ..RuntimeCompanyMarketState::default() + }, + )]), + ..RuntimeServiceState::default() + }, + }; + + let policy_state = runtime_company_annual_finance_policy_state(&state, 16) + .expect("annual finance policy state"); + assert_eq!( + policy_state.action, + RuntimeCompanyAnnualFinancePolicyAction::DividendAdjustment + ); + assert!(!policy_state.stock_repurchase_eligible); + assert!(!policy_state.stock_issue_eligible); + assert!(policy_state.dividend_adjustment_eligible); + } + #[test] fn reads_company_market_metrics_from_annual_finance_reader() { let current_issue_calendar_word = 0x0101_0726; diff --git a/crates/rrt-runtime/src/step.rs b/crates/rrt-runtime/src/step.rs index 825a609..a154e2e 100644 --- a/crates/rrt-runtime/src/step.rs +++ b/crates/rrt-runtime/src/step.rs @@ -8,12 +8,15 @@ use crate::{ RuntimeCompanyMetric, RuntimeCompanyTarget, RuntimeCondition, RuntimeConditionComparator, RuntimeEffect, RuntimeEventRecordTemplate, RuntimePlayerTarget, RuntimeState, RuntimeSummary, RuntimeTerritoryMetric, RuntimeTerritoryTarget, RuntimeTrackMetric, RuntimeTrackPieceCounts, - calendar::BoundaryEventKind, runtime_company_book_value_per_share, - runtime_company_credit_rating, runtime_company_investor_confidence, - runtime_company_management_attitude, runtime_company_prime_rate, + calendar::BoundaryEventKind, runtime_company_annual_dividend_policy_state, + runtime_company_annual_finance_policy_state, runtime_company_annual_stock_issue_state, + runtime_company_book_value_per_share, runtime_company_credit_rating, + runtime_company_investor_confidence, runtime_company_management_attitude, + runtime_company_prime_rate, }; const PERIODIC_TRIGGER_KIND_ORDER: [u8; 6] = [1, 0, 3, 2, 5, 4]; +const COMPANY_DIRECT_DIVIDEND_RATE_FIELD_SLOT: u32 = 0x33f; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] @@ -189,6 +192,154 @@ fn service_periodic_boundary( for trigger_kind in PERIODIC_TRIGGER_KIND_ORDER { service_trigger_kind(state, trigger_kind, service_events)?; } + service_company_annual_finance_policy(state, service_events)?; + + Ok(()) +} + +fn service_company_annual_finance_policy( + state: &mut RuntimeState, + service_events: &mut Vec, +) -> Result<(), String> { + let active_company_ids = state + .companies + .iter() + .filter(|company| company.active) + .map(|company| company.company_id) + .collect::>(); + let active_company_id_set = active_company_ids.iter().copied().collect::>(); + state + .service_state + .annual_finance_last_actions + .retain(|company_id, _| active_company_id_set.contains(company_id)); + state.service_state.annual_finance_service_calls += 1; + + let mut mutated_company_ids = BTreeSet::new(); + let mut applied_effect_count = 0u32; + + for company_id in active_company_ids { + let Some(policy_state) = runtime_company_annual_finance_policy_state(state, company_id) + else { + continue; + }; + state + .service_state + .annual_finance_last_actions + .insert(company_id, policy_state.action); + *state + .service_state + .annual_finance_action_counts + .entry(policy_state.action) + .or_insert(0) += 1; + + match policy_state.action { + crate::RuntimeCompanyAnnualFinancePolicyAction::DividendAdjustment => { + let Some(dividend_state) = + runtime_company_annual_dividend_policy_state(state, company_id) + else { + continue; + }; + let Some(proposed_tenths) = dividend_state.proposed_dividend_per_share_tenths + else { + continue; + }; + let Some(market_state) = state + .service_state + .company_market_state + .get_mut(&company_id) + else { + continue; + }; + let raw_bits = ((proposed_tenths as f32) / 10.0).to_bits(); + let prior_bits = market_state + .direct_control_transfer_float_fields_raw_u32 + .insert(COMPANY_DIRECT_DIVIDEND_RATE_FIELD_SLOT, raw_bits); + if prior_bits != Some(raw_bits) { + applied_effect_count += 1; + state.service_state.annual_dividend_adjustment_commit_count += 1; + mutated_company_ids.insert(company_id); + } + } + crate::RuntimeCompanyAnnualFinancePolicyAction::StockIssue => { + let Some(issue_state) = runtime_company_annual_stock_issue_state(state, company_id) + else { + continue; + }; + let Some(batch_size) = issue_state.trimmed_issue_batch_size else { + continue; + }; + let Some(proceeds_per_tranche) = issue_state.pressured_proceeds else { + continue; + }; + let Some(current_cash) = issue_state.current_cash else { + continue; + }; + let Some(current_tuple_word_0) = + state.world_restore.current_calendar_tuple_word_raw_u32 + else { + continue; + }; + let Some(current_tuple_word_1) = + state.world_restore.current_calendar_tuple_word_2_raw_u32 + else { + continue; + }; + let Some(total_share_delta) = batch_size.checked_mul(2) else { + continue; + }; + let Some(total_cash_delta) = proceeds_per_tranche.checked_mul(2) else { + continue; + }; + let Some(next_cash) = current_cash.checked_add(total_cash_delta) else { + continue; + }; + let Some(company) = state + .companies + .iter_mut() + .find(|company| company.company_id == company_id) + else { + continue; + }; + let Some(market_state) = state + .service_state + .company_market_state + .get_mut(&company_id) + else { + continue; + }; + let Some(next_outstanding_shares) = market_state + .outstanding_shares + .checked_add(total_share_delta) + else { + continue; + }; + company.current_cash = next_cash; + market_state.outstanding_shares = next_outstanding_shares; + market_state.prior_issue_calendar_word = market_state.current_issue_calendar_word; + market_state.prior_issue_calendar_word_2 = + market_state.current_issue_calendar_word_2; + market_state.current_issue_calendar_word = current_tuple_word_0; + market_state.current_issue_calendar_word_2 = current_tuple_word_1; + applied_effect_count += 1; + mutated_company_ids.insert(company_id); + } + _ => {} + } + } + + service_events.push(ServiceEvent { + kind: "annual_finance_policy".to_string(), + trigger_kind: None, + serviced_record_ids: Vec::new(), + applied_effect_count, + mutated_company_ids: mutated_company_ids.into_iter().collect(), + mutated_player_ids: Vec::new(), + appended_record_ids: Vec::new(), + activated_record_ids: Vec::new(), + deactivated_record_ids: Vec::new(), + removed_record_ids: Vec::new(), + dirty_rerun: false, + }); Ok(()) } @@ -1899,7 +2050,7 @@ mod tests { state.service_state.trigger_dispatch_counts.get(&0x0a), Some(&1) ); - assert_eq!(result.service_events.len(), 7); + assert_eq!(result.service_events.len(), 8); assert_eq!(result.service_events[0].applied_effect_count, 1); assert_eq!( result @@ -1928,6 +2079,332 @@ mod tests { .mutated_company_ids, vec![1] ); + assert!( + result + .service_events + .iter() + .any(|event| event.kind == "annual_finance_policy") + ); + } + + #[test] + fn periodic_boundary_applies_dividend_adjustment_from_annual_finance_policy() { + let mut year_stat_family_qword_bits = vec![ + 0u64; + ((crate::RUNTIME_COMPANY_STAT_SLOT_COUNT + 2) + * crate::RUNTIME_COMPANY_YEAR_STAT_FAMILY_SPAN) + as usize + ]; + let write_current_value = |bits: &mut Vec, slot_id: u32, value: f64| { + let index = (slot_id * crate::RUNTIME_COMPANY_YEAR_STAT_FAMILY_SPAN) as usize; + bits[index] = value.to_bits(); + }; + let write_prior_year_value = + |bits: &mut Vec, slot_id: u32, year_delta: u32, value: f64| { + let index = + (slot_id * crate::RUNTIME_COMPANY_YEAR_STAT_FAMILY_SPAN + year_delta) as usize; + bits[index] = value.to_bits(); + }; + write_current_value(&mut year_stat_family_qword_bits, 0x0d, 300_000.0); + write_current_value(&mut year_stat_family_qword_bits, 0x01, 300_000.0); + write_current_value(&mut year_stat_family_qword_bits, 0x09, -180_000.0); + write_prior_year_value(&mut year_stat_family_qword_bits, 0x01, 1, 280_000.0); + write_prior_year_value(&mut year_stat_family_qword_bits, 0x09, 1, -190_000.0); + write_prior_year_value(&mut year_stat_family_qword_bits, 0x01, 2, 260_000.0); + write_prior_year_value(&mut year_stat_family_qword_bits, 0x09, 2, -200_000.0); + write_prior_year_value(&mut year_stat_family_qword_bits, 0x1c, 1, 5.0); + + let mut state = RuntimeState { + calendar: crate::CalendarPoint { + year: 1845, + month_slot: 0, + phase_slot: 0, + tick_slot: 0, + }, + world_flags: BTreeMap::new(), + save_profile: crate::RuntimeSaveProfileState::default(), + world_restore: crate::RuntimeWorldRestoreState { + packed_year_word_raw_u16: Some(1845), + partial_year_progress_raw_u8: Some(0x0c), + dividend_policy_raw_u8: Some(0), + dividend_adjustment_allowed: Some(true), + stock_issue_and_buyback_policy_raw_u8: Some(0), + stock_issue_and_buyback_allowed: Some(true), + bond_issue_and_repayment_policy_raw_u8: Some(0), + bond_issue_and_repayment_allowed: Some(true), + bankruptcy_policy_raw_u8: Some(0), + bankruptcy_allowed: Some(true), + building_density_growth_setting_raw_u32: Some(1), + ..crate::RuntimeWorldRestoreState::default() + }, + metadata: BTreeMap::new(), + companies: vec![crate::RuntimeCompany { + company_id: 21, + controller_kind: crate::RuntimeCompanyControllerKind::Unknown, + current_cash: 0, + debt: 0, + credit_rating_score: None, + prime_rate: None, + track_piece_counts: crate::RuntimeTrackPieceCounts::default(), + active: true, + available_track_laying_capacity: None, + linked_chairman_profile_id: Some(7), + book_value_per_share: 0, + investor_confidence: 0, + management_attitude: 0, + takeover_cooldown_year: None, + merger_cooldown_year: None, + }], + selected_company_id: Some(21), + players: Vec::new(), + selected_player_id: None, + chairman_profiles: vec![crate::RuntimeChairmanProfile { + profile_id: 7, + name: "Chairman Seven".to_string(), + active: true, + current_cash: 0, + linked_company_id: Some(21), + company_holdings: BTreeMap::from([(21, 9_500)]), + holdings_value_total: 0, + net_worth_total: 0, + purchasing_power_total: 0, + }], + selected_chairman_profile_id: None, + trains: Vec::new(), + locomotive_catalog: Vec::new(), + cargo_catalog: Vec::new(), + territories: Vec::new(), + company_territory_track_piece_counts: Vec::new(), + company_territory_access: Vec::new(), + packed_event_collection: None, + event_runtime_records: Vec::new(), + candidate_availability: BTreeMap::new(), + named_locomotive_availability: BTreeMap::new(), + named_locomotive_cost: BTreeMap::new(), + all_cargo_price_override: None, + named_cargo_price_overrides: BTreeMap::new(), + all_cargo_production_override: None, + factory_cargo_production_override: None, + farm_mine_cargo_production_override: None, + named_cargo_production_overrides: BTreeMap::new(), + cargo_production_overrides: BTreeMap::new(), + world_runtime_variables: BTreeMap::new(), + company_runtime_variables: BTreeMap::new(), + player_runtime_variables: BTreeMap::new(), + territory_runtime_variables: BTreeMap::new(), + world_scalar_overrides: BTreeMap::new(), + special_conditions: BTreeMap::new(), + service_state: crate::RuntimeServiceState { + company_market_state: BTreeMap::from([( + 21, + crate::RuntimeCompanyMarketState { + outstanding_shares: 10_000, + founding_year: 1840, + last_dividend_year: 1844, + year_stat_family_qword_bits, + direct_control_transfer_float_fields_raw_u32: BTreeMap::from([( + 0x33f, + 0.4f32.to_bits(), + )]), + ..crate::RuntimeCompanyMarketState::default() + }, + )]), + ..crate::RuntimeServiceState::default() + }, + }; + + let result = execute_step_command(&mut state, &StepCommand::ServicePeriodicBoundary) + .expect("periodic boundary should apply annual finance policy"); + + assert_eq!(state.service_state.annual_finance_service_calls, 1); + assert_eq!( + state.service_state.annual_dividend_adjustment_commit_count, + 1 + ); + assert_eq!( + state.service_state.annual_finance_last_actions.get(&21), + Some(&crate::RuntimeCompanyAnnualFinancePolicyAction::DividendAdjustment) + ); + assert_eq!( + state.service_state.company_market_state[&21] + .direct_control_transfer_float_fields_raw_u32 + .get(&0x33f), + Some(&1.8f32.to_bits()) + ); + assert!( + result + .service_events + .iter() + .any(|event| event.kind == "annual_finance_policy" + && event.applied_effect_count == 1 + && event.mutated_company_ids == vec![21]) + ); + } + + #[test] + fn periodic_boundary_applies_stock_issue_from_annual_finance_policy() { + let mut year_stat_family_qword_bits = vec![ + 0u64; + (crate::RUNTIME_COMPANY_STAT_SLOT_COUNT * crate::RUNTIME_COMPANY_YEAR_STAT_FAMILY_SPAN) + as usize + ]; + year_stat_family_qword_bits[(crate::RUNTIME_COMPANY_STAT_SLOT_CURRENT_CASH + * crate::RUNTIME_COMPANY_YEAR_STAT_FAMILY_SPAN) + as usize] = 250_000.0f64.to_bits(); + + let current_issue_calendar_word = 0x0101_0725; + let current_issue_calendar_word_2 = 0x0001_0001; + let prior_issue_calendar_word = 0x0101_0701; + let prior_issue_calendar_word_2 = 0x0001_0001; + + let mut state = RuntimeState { + calendar: crate::CalendarPoint { + year: 1845, + month_slot: 0, + phase_slot: 0, + tick_slot: 0, + }, + world_flags: BTreeMap::new(), + save_profile: crate::RuntimeSaveProfileState::default(), + world_restore: crate::RuntimeWorldRestoreState { + packed_year_word_raw_u16: Some(1845), + partial_year_progress_raw_u8: Some(0x0c), + current_calendar_tuple_word_raw_u32: Some(0x0101_0726), + current_calendar_tuple_word_2_raw_u32: Some(0x0001_0001), + absolute_counter_raw_u32: Some(885_911_040), + stock_issue_and_buyback_policy_raw_u8: Some(0), + stock_issue_and_buyback_allowed: Some(true), + bond_issue_and_repayment_policy_raw_u8: Some(0), + bond_issue_and_repayment_allowed: Some(true), + bankruptcy_policy_raw_u8: Some(0), + bankruptcy_allowed: Some(true), + issue_37_value: Some(5.0f32.to_bits()), + issue_38_value: Some(2), + ..crate::RuntimeWorldRestoreState::default() + }, + metadata: BTreeMap::new(), + companies: vec![crate::RuntimeCompany { + company_id: 22, + controller_kind: crate::RuntimeCompanyControllerKind::Unknown, + current_cash: 0, + debt: 0, + credit_rating_score: None, + prime_rate: None, + track_piece_counts: crate::RuntimeTrackPieceCounts::default(), + active: true, + available_track_laying_capacity: None, + linked_chairman_profile_id: None, + book_value_per_share: 0, + investor_confidence: 0, + management_attitude: 0, + takeover_cooldown_year: None, + merger_cooldown_year: None, + }], + selected_company_id: Some(22), + players: Vec::new(), + selected_player_id: None, + chairman_profiles: Vec::new(), + selected_chairman_profile_id: None, + trains: Vec::new(), + locomotive_catalog: Vec::new(), + cargo_catalog: Vec::new(), + territories: Vec::new(), + company_territory_track_piece_counts: Vec::new(), + company_territory_access: Vec::new(), + packed_event_collection: None, + event_runtime_records: Vec::new(), + candidate_availability: BTreeMap::new(), + named_locomotive_availability: BTreeMap::new(), + named_locomotive_cost: BTreeMap::new(), + all_cargo_price_override: None, + named_cargo_price_overrides: BTreeMap::new(), + all_cargo_production_override: None, + factory_cargo_production_override: None, + farm_mine_cargo_production_override: None, + named_cargo_production_overrides: BTreeMap::new(), + cargo_production_overrides: BTreeMap::new(), + world_runtime_variables: BTreeMap::new(), + company_runtime_variables: BTreeMap::new(), + player_runtime_variables: BTreeMap::new(), + territory_runtime_variables: BTreeMap::new(), + world_scalar_overrides: BTreeMap::new(), + special_conditions: BTreeMap::new(), + service_state: crate::RuntimeServiceState { + world_issue_opinion_base_terms_raw_i32: vec![0; 0x3b], + company_market_state: BTreeMap::from([( + 22, + crate::RuntimeCompanyMarketState { + outstanding_shares: 20_000, + bond_count: 2, + highest_coupon_live_bond_principal: Some(300_000), + founding_year: 1840, + cached_share_price_raw_u32: 35.0f32.to_bits(), + recent_per_share_cache_absolute_counter: 885_911_040, + recent_per_share_cached_value_bits: 34.0f64.to_bits(), + current_issue_calendar_word, + current_issue_calendar_word_2, + prior_issue_calendar_word, + prior_issue_calendar_word_2, + live_bond_slots: vec![ + crate::RuntimeCompanyBondSlot { + slot_index: 0, + principal: 300_000, + coupon_rate_raw_u32: 0.11f32.to_bits(), + }, + crate::RuntimeCompanyBondSlot { + slot_index: 1, + principal: 200_000, + coupon_rate_raw_u32: 0.07f32.to_bits(), + }, + ], + direct_control_transfer_float_fields_raw_u32: BTreeMap::from([( + 0x32f, + 30.0f32.to_bits(), + )]), + year_stat_family_qword_bits, + ..crate::RuntimeCompanyMarketState::default() + }, + )]), + ..crate::RuntimeServiceState::default() + }, + }; + + let result = execute_step_command(&mut state, &StepCommand::ServicePeriodicBoundary) + .expect("periodic boundary should apply annual stock issue policy"); + + assert_eq!( + state.service_state.annual_finance_last_actions.get(&22), + Some(&crate::RuntimeCompanyAnnualFinancePolicyAction::StockIssue) + ); + assert_eq!(state.companies[0].current_cash, 390_000); + assert_eq!( + state.service_state.company_market_state[&22].outstanding_shares, + 24_000 + ); + assert_eq!( + state.service_state.company_market_state[&22].prior_issue_calendar_word, + current_issue_calendar_word + ); + assert_eq!( + state.service_state.company_market_state[&22].prior_issue_calendar_word_2, + current_issue_calendar_word_2 + ); + assert_eq!( + state.service_state.company_market_state[&22].current_issue_calendar_word, + 0x0101_0726 + ); + assert_eq!( + state.service_state.company_market_state[&22].current_issue_calendar_word_2, + 0x0001_0001 + ); + assert!( + result + .service_events + .iter() + .any(|event| event.kind == "annual_finance_policy" + && event.applied_effect_count == 1 + && event.mutated_company_ids == vec![22]) + ); } #[test] diff --git a/crates/rrt-runtime/src/summary.rs b/crates/rrt-runtime/src/summary.rs index 27fce76..e29fa65 100644 --- a/crates/rrt-runtime/src/summary.rs +++ b/crates/rrt-runtime/src/summary.rs @@ -3,7 +3,9 @@ use serde::{Deserialize, Serialize}; use crate::{ CalendarPoint, RuntimeState, runtime_company_annual_bond_policy_state, runtime_company_annual_creditor_pressure_state, runtime_company_annual_deep_distress_state, - runtime_company_annual_dividend_policy_state, runtime_company_annual_finance_state, + runtime_company_annual_dividend_policy_state, + runtime_company_annual_finance_policy_action_label, + runtime_company_annual_finance_policy_state, runtime_company_annual_finance_state, runtime_company_annual_stock_issue_state, runtime_company_annual_stock_repurchase_state, runtime_company_unassigned_share_pool, }; @@ -167,6 +169,14 @@ pub struct RuntimeSummary { pub selected_company_dividend_board_approved_ceiling_tenths: Option, pub selected_company_dividend_proposed_per_share_tenths: Option, pub selected_company_dividend_eligible_for_adjustment_branch: Option, + pub selected_company_annual_finance_policy_action: Option, + pub selected_company_annual_finance_policy_creditor_pressure_bankruptcy_eligible: Option, + pub selected_company_annual_finance_policy_deep_distress_bankruptcy_fallback_eligible: + Option, + pub selected_company_annual_finance_policy_bond_issue_eligible: Option, + pub selected_company_annual_finance_policy_stock_repurchase_eligible: Option, + pub selected_company_annual_finance_policy_stock_issue_eligible: Option, + pub selected_company_annual_finance_policy_dividend_adjustment_eligible: Option, pub player_count: usize, pub chairman_profile_count: usize, pub active_chairman_profile_count: usize, @@ -243,6 +253,8 @@ pub struct RuntimeSummary { pub save_profile_staged_profile_copy_on_restore: Option, pub total_event_record_service_count: u64, pub periodic_boundary_call_count: u64, + pub annual_finance_service_call_count: u64, + pub annual_dividend_adjustment_commit_count: u64, pub total_trigger_dispatch_count: u64, pub dirty_rerun_count: u64, pub total_company_cash: i64, @@ -276,6 +288,9 @@ impl RuntimeSummary { let selected_company_dividend_state = state .selected_company_id .and_then(|company_id| runtime_company_annual_dividend_policy_state(state, company_id)); + let selected_company_annual_finance_policy_state = state + .selected_company_id + .and_then(|company_id| runtime_company_annual_finance_policy_state(state, company_id)); Self { calendar: state.calendar, calendar_projection_source: state.metadata.get("save_slice.calendar_source").cloned(), @@ -761,6 +776,37 @@ impl RuntimeSummary { selected_company_dividend_state .as_ref() .map(|dividend_state| dividend_state.eligible_for_dividend_adjustment_branch), + selected_company_annual_finance_policy_action: + selected_company_annual_finance_policy_state + .as_ref() + .map(|policy_state| { + runtime_company_annual_finance_policy_action_label(policy_state.action) + .to_string() + }), + selected_company_annual_finance_policy_creditor_pressure_bankruptcy_eligible: + selected_company_annual_finance_policy_state + .as_ref() + .map(|policy_state| policy_state.creditor_pressure_bankruptcy_eligible), + selected_company_annual_finance_policy_deep_distress_bankruptcy_fallback_eligible: + selected_company_annual_finance_policy_state + .as_ref() + .map(|policy_state| policy_state.deep_distress_bankruptcy_fallback_eligible), + selected_company_annual_finance_policy_bond_issue_eligible: + selected_company_annual_finance_policy_state + .as_ref() + .map(|policy_state| policy_state.bond_issue_eligible), + selected_company_annual_finance_policy_stock_repurchase_eligible: + selected_company_annual_finance_policy_state + .as_ref() + .map(|policy_state| policy_state.stock_repurchase_eligible), + selected_company_annual_finance_policy_stock_issue_eligible: + selected_company_annual_finance_policy_state + .as_ref() + .map(|policy_state| policy_state.stock_issue_eligible), + selected_company_annual_finance_policy_dividend_adjustment_eligible: + selected_company_annual_finance_policy_state + .as_ref() + .map(|policy_state| policy_state.dividend_adjustment_eligible), player_count: state.players.len(), chairman_profile_count: state.chairman_profiles.len(), active_chairman_profile_count: state @@ -1311,6 +1357,10 @@ impl RuntimeSummary { .staged_profile_copy_on_restore, total_event_record_service_count: state.service_state.total_event_record_services, periodic_boundary_call_count: state.service_state.periodic_boundary_calls, + annual_finance_service_call_count: state.service_state.annual_finance_service_calls, + annual_dividend_adjustment_commit_count: state + .service_state + .annual_dividend_adjustment_commit_count, total_trigger_dispatch_count: state .service_state .trigger_dispatch_counts @@ -3549,5 +3599,15 @@ mod tests { summary.selected_company_dividend_eligible_for_adjustment_branch, Some(true) ); + assert_eq!( + summary + .selected_company_annual_finance_policy_action + .as_deref(), + Some("dividend_adjustment") + ); + assert_eq!( + summary.selected_company_annual_finance_policy_dividend_adjustment_eligible, + Some(true) + ); } } diff --git a/docs/README.md b/docs/README.md index 74d7bc9..c8f5916 100644 --- a/docs/README.md +++ b/docs/README.md @@ -144,7 +144,9 @@ The highest-value next passes are now: shared year-or-control-transfer reader and board-approved dividend ceiling helper; the same owner seam now also carries the fixed-world building-density growth setting plus the linked chairman personality byte, which is enough to run the annual - stock-repurchase gate headlessly as another pure reader + stock-repurchase gate headlessly as another pure reader; periodic boundary service now also + chooses one annual-finance action per active company and already commits the shellless + dividend-adjustment and stock-issue branches against owned runtime state - the project rule on the remaining closure work is now explicit too: when one runtime-facing field is still ambiguous, prefer rehosting the owning source state or real reader/setter family first instead of guessing another derived leaf field from neighboring raw offsets diff --git a/docs/runtime-rehost-plan.md b/docs/runtime-rehost-plan.md index 5590122..bc365ef 100644 --- a/docs/runtime-rehost-plan.md +++ b/docs/runtime-rehost-plan.md @@ -242,7 +242,10 @@ That same seam now also carries the fixed-world building-density growth setting chairman personality byte, which is enough to rehost the annual stock-repurchase gate on owned save/runtime state instead of another threshold-only note. The stock-capital issue branch now rides that same seam too, with share-pressure, cooldown, and price-to-book gate state exposed as -normal runtime readers. +normal runtime readers. Periodic boundary service now also uses that owner seam as a real chooser: +the runtime selects one annual-finance action per active company and already commits the shellless +dividend-adjustment and stock-issue branches directly into owned dividend, cash, outstanding-share, +and issue-calendar state. ## Why This Boundary