diff --git a/README.md b/README.md index 2627531..f104509 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,11 @@ distress bankruptcy fallback is now rehosted on that same owner surface too, usi cash reader seam plus the first three trailing net-profit years instead of another ad hoc probe. The annual bond lane now runs on that same owner surface too, using the simulated post-repayment cash window plus the linked-transit threshold split to stage `500000` principal issue counts as a -pure runtime reader. +pure runtime reader. The annual dividend lane now runs there too: the runtime now rehosts the +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. 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/lib.rs b/crates/rrt-runtime/src/lib.rs index 0a5f201..3df9195 100644 --- a/crates/rrt-runtime/src/lib.rs +++ b/crates/rrt-runtime/src/lib.rs @@ -53,23 +53,24 @@ pub use runtime::{ RuntimeCargoPriceTarget, RuntimeCargoProductionTarget, RuntimeChairmanMetric, RuntimeChairmanProfile, RuntimeChairmanTarget, RuntimeCompany, RuntimeCompanyAnnualBondPolicyState, RuntimeCompanyAnnualCreditorPressureState, - RuntimeCompanyAnnualDeepDistressState, RuntimeCompanyAnnualFinanceState, - RuntimeCompanyAnnualStockIssueState, RuntimeCompanyAnnualStockRepurchaseState, - RuntimeCompanyBondSlot, RuntimeCompanyConditionTestScope, RuntimeCompanyControllerKind, - RuntimeCompanyMarketMetric, RuntimeCompanyMarketState, RuntimeCompanyMetric, - RuntimeCompanyStatBandCandidate, RuntimeCompanyStatSelector, RuntimeCompanyTarget, - RuntimeCompanyTerritoryAccess, RuntimeCompanyTerritoryTrackPieceCount, RuntimeCondition, - RuntimeConditionComparator, RuntimeEffect, RuntimeEventRecord, RuntimeEventRecordTemplate, - RuntimeLocomotiveCatalogEntry, RuntimePackedEventCollectionSummary, - RuntimePackedEventCompactControlSummary, RuntimePackedEventConditionRowSummary, - RuntimePackedEventGroupedEffectRowSummary, RuntimePackedEventNegativeSentinelScopeSummary, - RuntimePackedEventRecordSummary, RuntimePackedEventTextBandSummary, RuntimePlayer, - RuntimePlayerConditionTestScope, RuntimePlayerTarget, RuntimeSaveProfileState, - RuntimeServiceState, RuntimeState, RuntimeTerritory, RuntimeTerritoryMetric, - RuntimeTerritoryTarget, RuntimeTrackMetric, 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_finance_state, + RuntimeCompanyAnnualDeepDistressState, RuntimeCompanyAnnualDividendPolicyState, + RuntimeCompanyAnnualFinanceState, RuntimeCompanyAnnualStockIssueState, + RuntimeCompanyAnnualStockRepurchaseState, RuntimeCompanyBondSlot, + RuntimeCompanyConditionTestScope, RuntimeCompanyControllerKind, RuntimeCompanyMarketMetric, + RuntimeCompanyMarketState, RuntimeCompanyMetric, RuntimeCompanyStatBandCandidate, + RuntimeCompanyStatSelector, RuntimeCompanyTarget, RuntimeCompanyTerritoryAccess, + RuntimeCompanyTerritoryTrackPieceCount, RuntimeCondition, RuntimeConditionComparator, + RuntimeEffect, RuntimeEventRecord, RuntimeEventRecordTemplate, RuntimeLocomotiveCatalogEntry, + RuntimePackedEventCollectionSummary, RuntimePackedEventCompactControlSummary, + RuntimePackedEventConditionRowSummary, RuntimePackedEventGroupedEffectRowSummary, + RuntimePackedEventNegativeSentinelScopeSummary, RuntimePackedEventRecordSummary, + RuntimePackedEventTextBandSummary, RuntimePlayer, RuntimePlayerConditionTestScope, + RuntimePlayerTarget, RuntimeSaveProfileState, RuntimeServiceState, RuntimeState, + RuntimeTerritory, RuntimeTerritoryMetric, RuntimeTerritoryTarget, RuntimeTrackMetric, + 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_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 95410b6..f5bb797 100644 --- a/crates/rrt-runtime/src/runtime.rs +++ b/crates/rrt-runtime/src/runtime.rs @@ -339,6 +339,43 @@ pub struct RuntimeCompanyAnnualBondPolicyState { pub eligible_for_bond_issue_branch: bool, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuntimeCompanyAnnualDividendPolicyState { + pub company_id: u32, + #[serde(default)] + pub annual_mode_active: Option, + #[serde(default)] + pub dividend_adjustment_allowed: Option, + #[serde(default)] + pub years_since_last_dividend: Option, + #[serde(default)] + pub years_since_founding: Option, + #[serde(default)] + pub outstanding_shares: Option, + #[serde(default)] + pub unassigned_share_pool: Option, + #[serde(default)] + pub weighted_recent_net_profit_total: Option, + #[serde(default)] + pub weighted_recent_net_profit_average: Option, + #[serde(default)] + pub current_cash: Option, + pub tiny_unassigned_share_cash_supplement_branch: bool, + #[serde(default)] + pub tentative_target_dividend_per_share_tenths: Option, + #[serde(default)] + pub current_dividend_per_share_tenths: Option, + #[serde(default)] + pub building_density_growth_setting: Option, + #[serde(default)] + pub growth_adjusted_current_dividend_per_share_tenths: Option, + #[serde(default)] + pub board_approved_dividend_rate_ceiling_tenths: Option, + #[serde(default)] + pub proposed_dividend_per_share_tenths: Option, + pub eligible_for_dividend_adjustment_branch: bool, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] pub struct RuntimeTrackPieceCounts { #[serde(default)] @@ -2615,6 +2652,34 @@ fn runtime_company_trailing_full_year_stat_series( Some((year_words, values)) } +fn runtime_company_year_or_control_transfer_metric_value_f64( + state: &RuntimeState, + company_id: u32, + year_word: u32, + slot_id: u32, +) -> Option { + let current_year_word = u32::from(state.world_restore.packed_year_word_raw_u16?); + if year_word == current_year_word { + runtime_company_stat_value_f64( + state, + company_id, + RuntimeCompanyStatSelector { + family_id: RUNTIME_COMPANY_STAT_FAMILY_CONTROL_TRANSFER, + slot_id, + }, + ) + } else { + runtime_company_stat_value_f64( + state, + company_id, + RuntimeCompanyStatSelector { + family_id: year_word, + slot_id, + }, + ) + } +} + pub fn runtime_world_issue_opinion_term_sum_raw( state: &RuntimeState, issue_id: u32, @@ -3115,6 +3180,224 @@ pub fn runtime_company_annual_bond_policy_state( }) } +fn runtime_company_board_approved_dividend_rate_ceiling_f64( + state: &RuntimeState, + company_id: u32, +) -> Option { + const REVENUE_GUARD_DIVISOR: f64 = 2.0; + const EARLY_SUPPORT_MULTIPLIER: f64 = 0.05; + const HISTORICAL_GUARD_SCALE: f64 = 1.25; + const ANCHOR_SCALE: f64 = 0.35; + + let market_state = state.service_state.company_market_state.get(&company_id)?; + let current_cash = runtime_company_control_transfer_stat_value_f64( + state, + company_id, + RUNTIME_COMPANY_STAT_SLOT_CURRENT_CASH, + )?; + let shares_plus_one = market_state.outstanding_shares.checked_add(1)?; + let shares_plus_one_f64 = shares_plus_one as f64; + let current_cash_per_share_ceiling = current_cash / shares_plus_one_f64; + let current_year_word = u32::from(state.world_restore.packed_year_word_raw_u16?); + let years_since_founding = current_year_word + .checked_sub(market_state.founding_year) + .unwrap_or(0) + .min(3); + let start_year_offset = if state.world_restore.partial_year_progress_raw_u8 == Some(0x0c) { + 0 + } else { + 1 + }; + + let mut strongest_net_profit_guard = 0.0f64; + let mut strongest_revenue_guard = 0.0f64; + if start_year_offset <= years_since_founding { + for year_offset in start_year_offset..=years_since_founding { + let year_word = current_year_word.checked_sub(year_offset)?; + let net_profit = runtime_company_year_or_control_transfer_metric_value_f64( + state, company_id, year_word, 0x2b, + )?; + strongest_net_profit_guard = strongest_net_profit_guard.max(net_profit); + + let revenue = runtime_company_year_or_control_transfer_metric_value_f64( + state, company_id, year_word, 0x2c, + )?; + strongest_revenue_guard = strongest_revenue_guard.max(revenue); + } + } + + let mut historical_guard_total = + strongest_net_profit_guard.min(strongest_revenue_guard / REVENUE_GUARD_DIVISOR); + if years_since_founding <= 1 { + let early_support_guard = market_state.outstanding_shares as f64 + * runtime_decode_saved_f32_value_f64( + market_state.young_company_support_scalar_raw_u32, + )? + * EARLY_SUPPORT_MULTIPLIER; + historical_guard_total = historical_guard_total.max(early_support_guard); + } + + let historical_guard_per_share_ceiling = + historical_guard_total / shares_plus_one_f64 * HISTORICAL_GUARD_SCALE; + let mut ceiling = current_cash_per_share_ceiling.min(historical_guard_per_share_ceiling); + let anchor_value = if years_since_founding == 0 { + runtime_decode_saved_f32_value_f64(market_state.young_company_support_scalar_raw_u32)? + } else { + runtime_company_year_or_control_transfer_metric_value_f64( + state, + company_id, + current_year_word.checked_sub(1)?, + 0x1c, + )? + }; + ceiling = ceiling.min(anchor_value * ANCHOR_SCALE); + Some(ceiling.max(0.0)) +} + +pub fn runtime_company_annual_dividend_policy_state( + state: &RuntimeState, + company_id: u32, +) -> Option { + const WEIGHTED_NET_PROFIT_DIVISOR: f64 = 6.0; + const CASH_SUPPLEMENT_DIVISOR: f64 = 3.0; + const STANDARD_TARGET_DIVISOR: f64 = 6.0; + const DIVIDEND_DELTA_COLLAPSE_THRESHOLD: f64 = 0.1; + const GROWTH_SETTING_ONE_DIVIDEND_SCALE: f64 = 0.66; + + let annual_finance_state = runtime_company_annual_finance_state(state, company_id)?; + let current_cash = runtime_company_control_transfer_stat_value_f64( + state, + company_id, + RUNTIME_COMPANY_STAT_SLOT_CURRENT_CASH, + ) + .and_then(runtime_round_f64_to_i64); + let current_year_word = u32::from(state.world_restore.packed_year_word_raw_u16?); + let current_dividend_per_share = + runtime_company_control_transfer_stat_value_f64(state, company_id, 0x20)?; + let building_density_growth_setting = runtime_world_building_density_growth_setting(state); + let weighted_recent_net_profit_total = Some( + runtime_company_year_or_control_transfer_metric_value_f64( + state, + company_id, + current_year_word, + 0x2b, + ) + .and_then(runtime_round_f64_to_i64)? + .checked_mul(3)? + .checked_add( + runtime_company_year_or_control_transfer_metric_value_f64( + state, + company_id, + current_year_word.checked_sub(1)?, + 0x2b, + ) + .and_then(runtime_round_f64_to_i64)? + .checked_mul(2)?, + )? + .checked_add( + runtime_company_year_or_control_transfer_metric_value_f64( + state, + company_id, + current_year_word.checked_sub(2)?, + 0x2b, + ) + .and_then(runtime_round_f64_to_i64)?, + )?, + ); + let weighted_recent_net_profit_average = weighted_recent_net_profit_total + .and_then(|value| runtime_round_f64_to_i64(value as f64 / WEIGHTED_NET_PROFIT_DIVISOR)); + let tiny_unassigned_share_cash_supplement_branch = + annual_finance_state.unassigned_share_pool <= 1_000; + let tentative_target_dividend_per_share = + weighted_recent_net_profit_average.and_then(|value| { + if annual_finance_state.outstanding_shares == 0 { + return None; + } + let shares = annual_finance_state.outstanding_shares as f64; + if tiny_unassigned_share_cash_supplement_branch { + let cash_component = current_cash.unwrap_or(0).max(0) as f64; + Some( + ((value as f64 / CASH_SUPPLEMENT_DIVISOR) + + cash_component / CASH_SUPPLEMENT_DIVISOR) + / shares, + ) + } else { + Some((value as f64 / STANDARD_TARGET_DIVISOR) / shares) + } + }); + let growth_adjusted_current_dividend_per_share = Some(match building_density_growth_setting { + Some(1) => current_dividend_per_share * GROWTH_SETTING_ONE_DIVIDEND_SCALE, + Some(2) => 0.0, + _ => current_dividend_per_share, + }); + let proposed_dividend_per_share = if tentative_target_dividend_per_share + .is_some_and(|value| value <= DIVIDEND_DELTA_COLLAPSE_THRESHOLD) + { + Some(0.0) + } else { + growth_adjusted_current_dividend_per_share + .zip(tentative_target_dividend_per_share) + .map(|(current_dividend, target)| { + ((current_dividend + target + DIVIDEND_DELTA_COLLAPSE_THRESHOLD) / 2.0 * 10.0) + .round() + / 10.0 + }) + }; + let board_approved_dividend_rate_ceiling = + runtime_company_board_approved_dividend_rate_ceiling_f64(state, company_id); + let proposed_dividend_per_share = proposed_dividend_per_share + .zip(board_approved_dividend_rate_ceiling) + .map(|(proposed, ceiling)| proposed.min(ceiling)); + let current_dividend_per_share_tenths = + runtime_round_f64_to_i64(current_dividend_per_share * 10.0); + let eligible_for_dividend_adjustment_branch = runtime_world_annual_finance_mode_active(state) + == Some(true) + && runtime_world_dividend_adjustment_allowed(state) == Some(true) + && annual_finance_state + .years_since_last_dividend + .is_some_and(|years| years >= 1) + && annual_finance_state + .years_since_founding + .is_some_and(|years| years >= 2) + && !runtime_company_annual_creditor_pressure_state(state, company_id)? + .eligible_for_bankruptcy_branch + && !runtime_company_annual_deep_distress_state(state, company_id)? + .eligible_for_bankruptcy_fallback + && !runtime_company_annual_bond_policy_state(state, company_id)? + .eligible_for_bond_issue_branch + && !runtime_company_annual_stock_repurchase_state(state, company_id)? + .eligible_for_single_batch_repurchase + && !runtime_company_annual_stock_issue_state(state, company_id)? + .eligible_for_double_tranche_issue + && proposed_dividend_per_share.and_then(|value| runtime_round_f64_to_i64(value * 10.0)) + != current_dividend_per_share_tenths; + Some(RuntimeCompanyAnnualDividendPolicyState { + company_id, + annual_mode_active: runtime_world_annual_finance_mode_active(state), + dividend_adjustment_allowed: runtime_world_dividend_adjustment_allowed(state), + years_since_last_dividend: annual_finance_state.years_since_last_dividend, + years_since_founding: annual_finance_state.years_since_founding, + outstanding_shares: Some(annual_finance_state.outstanding_shares), + unassigned_share_pool: Some(annual_finance_state.unassigned_share_pool), + weighted_recent_net_profit_total, + weighted_recent_net_profit_average, + current_cash, + tiny_unassigned_share_cash_supplement_branch, + tentative_target_dividend_per_share_tenths: tentative_target_dividend_per_share + .and_then(|value| runtime_round_f64_to_i64(value * 10.0)), + current_dividend_per_share_tenths, + building_density_growth_setting, + growth_adjusted_current_dividend_per_share_tenths: + growth_adjusted_current_dividend_per_share + .and_then(|value| runtime_round_f64_to_i64(value * 10.0)), + board_approved_dividend_rate_ceiling_tenths: board_approved_dividend_rate_ceiling + .and_then(|value| runtime_round_f64_to_i64(value * 10.0)), + proposed_dividend_per_share_tenths: proposed_dividend_per_share + .and_then(|value| runtime_round_f64_to_i64(value * 10.0)), + eligible_for_dividend_adjustment_branch, + }) +} + fn runtime_company_stock_issue_price_to_book_ratio_f64( pressured_support_adjusted_share_price_scalar: f64, book_value_per_share: f64, @@ -8019,6 +8302,163 @@ mod tests { assert!(stock_issue_state.eligible_for_double_tranche_issue); } + #[test] + fn derives_annual_dividend_policy_state_from_rehosted_owner_state() { + 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: 15, + 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: None, + 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: None, + players: Vec::new(), + selected_player_id: None, + chairman_profiles: vec![RuntimeChairmanProfile { + profile_id: 3, + name: "Chairman Three".to_string(), + active: true, + current_cash: 0, + linked_company_id: Some(15), + company_holdings: BTreeMap::from([(15, 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 { + company_market_state: BTreeMap::from([( + 15, + 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(), + )]), + ..RuntimeCompanyMarketState::default() + }, + )]), + ..RuntimeServiceState::default() + }, + }; + + let dividend_state = runtime_company_annual_dividend_policy_state(&state, 15) + .expect("annual dividend policy state"); + assert_eq!(dividend_state.years_since_last_dividend, Some(1)); + assert_eq!(dividend_state.years_since_founding, Some(5)); + assert_eq!(dividend_state.outstanding_shares, Some(10_000)); + assert_eq!(dividend_state.unassigned_share_pool, Some(500)); + assert_eq!( + dividend_state.weighted_recent_net_profit_total, + Some(600_000) + ); + assert_eq!( + dividend_state.weighted_recent_net_profit_average, + Some(100_000) + ); + assert_eq!(dividend_state.current_cash, Some(300_000)); + assert!(dividend_state.tiny_unassigned_share_cash_supplement_branch); + assert_eq!( + dividend_state.tentative_target_dividend_per_share_tenths, + Some(133) + ); + assert_eq!(dividend_state.current_dividend_per_share_tenths, Some(4)); + assert_eq!( + dividend_state.growth_adjusted_current_dividend_per_share_tenths, + Some(3) + ); + assert_eq!( + dividend_state.board_approved_dividend_rate_ceiling_tenths, + Some(18) + ); + assert_eq!(dividend_state.proposed_dividend_per_share_tenths, Some(18)); + assert!(dividend_state.eligible_for_dividend_adjustment_branch); + } + #[test] fn reads_company_market_metrics_from_annual_finance_reader() { let current_issue_calendar_word = 0x0101_0726; diff --git a/crates/rrt-runtime/src/summary.rs b/crates/rrt-runtime/src/summary.rs index c4058fa..27fce76 100644 --- a/crates/rrt-runtime/src/summary.rs +++ b/crates/rrt-runtime/src/summary.rs @@ -3,8 +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_finance_state, runtime_company_annual_stock_issue_state, - runtime_company_annual_stock_repurchase_state, runtime_company_unassigned_share_pool, + runtime_company_annual_dividend_policy_state, runtime_company_annual_finance_state, + runtime_company_annual_stock_issue_state, runtime_company_annual_stock_repurchase_state, + runtime_company_unassigned_share_pool, }; fn raw_u32_to_f32_text(raw: u32) -> String { @@ -156,6 +157,16 @@ pub struct RuntimeSummary { pub selected_company_stock_issue_passes_issue_cooldown_gate: Option, pub selected_company_stock_issue_passes_coupon_price_to_book_gate: Option, pub selected_company_stock_issue_eligible_for_double_tranche: Option, + pub selected_company_dividend_weighted_recent_net_profit_total: Option, + pub selected_company_dividend_weighted_recent_net_profit_average: Option, + pub selected_company_dividend_current_cash: Option, + pub selected_company_dividend_tiny_unassigned_share_cash_supplement_branch: Option, + pub selected_company_dividend_tentative_target_per_share_tenths: Option, + pub selected_company_dividend_current_per_share_tenths: Option, + pub selected_company_dividend_growth_adjusted_current_per_share_tenths: Option, + 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 player_count: usize, pub chairman_profile_count: usize, pub active_chairman_profile_count: usize, @@ -262,6 +273,9 @@ impl RuntimeSummary { let selected_company_stock_issue_state = state .selected_company_id .and_then(|company_id| runtime_company_annual_stock_issue_state(state, company_id)); + let selected_company_dividend_state = state + .selected_company_id + .and_then(|company_id| runtime_company_annual_dividend_policy_state(state, company_id)); Self { calendar: state.calendar, calendar_projection_source: state.metadata.get("save_slice.calendar_source").cloned(), @@ -702,6 +716,51 @@ impl RuntimeSummary { selected_company_stock_issue_state .as_ref() .map(|issue_state| issue_state.eligible_for_double_tranche_issue), + selected_company_dividend_weighted_recent_net_profit_total: + selected_company_dividend_state + .as_ref() + .and_then(|dividend_state| dividend_state.weighted_recent_net_profit_total), + selected_company_dividend_weighted_recent_net_profit_average: + selected_company_dividend_state + .as_ref() + .and_then(|dividend_state| dividend_state.weighted_recent_net_profit_average), + selected_company_dividend_current_cash: selected_company_dividend_state + .as_ref() + .and_then(|dividend_state| dividend_state.current_cash), + selected_company_dividend_tiny_unassigned_share_cash_supplement_branch: + selected_company_dividend_state + .as_ref() + .map(|dividend_state| { + dividend_state.tiny_unassigned_share_cash_supplement_branch + }), + selected_company_dividend_tentative_target_per_share_tenths: + selected_company_dividend_state + .as_ref() + .and_then(|dividend_state| { + dividend_state.tentative_target_dividend_per_share_tenths + }), + selected_company_dividend_current_per_share_tenths: selected_company_dividend_state + .as_ref() + .and_then(|dividend_state| dividend_state.current_dividend_per_share_tenths), + selected_company_dividend_growth_adjusted_current_per_share_tenths: + selected_company_dividend_state + .as_ref() + .and_then(|dividend_state| { + dividend_state.growth_adjusted_current_dividend_per_share_tenths + }), + selected_company_dividend_board_approved_ceiling_tenths: + selected_company_dividend_state + .as_ref() + .and_then(|dividend_state| { + dividend_state.board_approved_dividend_rate_ceiling_tenths + }), + selected_company_dividend_proposed_per_share_tenths: selected_company_dividend_state + .as_ref() + .and_then(|dividend_state| dividend_state.proposed_dividend_per_share_tenths), + selected_company_dividend_eligible_for_adjustment_branch: + selected_company_dividend_state + .as_ref() + .map(|dividend_state| dividend_state.eligible_for_dividend_adjustment_branch), player_count: state.players.len(), chairman_profile_count: state.chairman_profiles.len(), active_chairman_profile_count: state @@ -3322,4 +3381,173 @@ mod tests { Some(true) ); } + + #[test] + fn summarizes_selected_company_annual_dividend_policy_state() { + 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 state = RuntimeState { + calendar: 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: 15, + current_cash: 0, + debt: 0, + credit_rating_score: None, + prime_rate: None, + active: true, + available_track_laying_capacity: None, + controller_kind: crate::RuntimeCompanyControllerKind::Unknown, + linked_chairman_profile_id: None, + book_value_per_share: 0, + investor_confidence: 0, + management_attitude: 0, + takeover_cooldown_year: None, + merger_cooldown_year: None, + track_piece_counts: crate::RuntimeTrackPieceCounts::default(), + }], + selected_company_id: Some(15), + players: Vec::new(), + selected_player_id: None, + chairman_profiles: vec![crate::RuntimeChairmanProfile { + profile_id: 3, + name: "Chairman Three".to_string(), + active: true, + current_cash: 0, + linked_company_id: Some(15), + company_holdings: BTreeMap::from([(15, 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([( + 15, + 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 summary = RuntimeSummary::from_state(&state); + assert_eq!( + summary.selected_company_dividend_weighted_recent_net_profit_total, + Some(600_000) + ); + assert_eq!( + summary.selected_company_dividend_weighted_recent_net_profit_average, + Some(100_000) + ); + assert_eq!( + summary.selected_company_dividend_current_cash, + Some(300_000) + ); + assert_eq!( + summary.selected_company_dividend_tiny_unassigned_share_cash_supplement_branch, + Some(true) + ); + assert_eq!( + summary.selected_company_dividend_tentative_target_per_share_tenths, + Some(133) + ); + assert_eq!( + summary.selected_company_dividend_current_per_share_tenths, + Some(4) + ); + assert_eq!( + summary.selected_company_dividend_growth_adjusted_current_per_share_tenths, + Some(3) + ); + assert_eq!( + summary.selected_company_dividend_board_approved_ceiling_tenths, + Some(18) + ); + assert_eq!( + summary.selected_company_dividend_proposed_per_share_tenths, + Some(18) + ); + assert_eq!( + summary.selected_company_dividend_eligible_for_adjustment_branch, + Some(true) + ); + } } diff --git a/docs/README.md b/docs/README.md index 4901ae2..74d7bc9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -140,7 +140,9 @@ The highest-value next passes are now: executes as a pure runtime reader over that owner state instead of remaining atlas-only; the later deep-distress bankruptcy fallback now runs on that same save-native cash and trailing- profit seam; the annual bond, stock-repurchase, and stock-capital issue branches now do too - net-profit surface too; the same owner seam now also carries the fixed-world building-density + net-profit surface too; the annual dividend-adjustment branch now does as well through the + 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 - the project rule on the remaining closure work is now explicit too: when one runtime-facing field diff --git a/docs/runtime-rehost-plan.md b/docs/runtime-rehost-plan.md index d76e4f1..5590122 100644 --- a/docs/runtime-rehost-plan.md +++ b/docs/runtime-rehost-plan.md @@ -234,6 +234,10 @@ deep-distress bankruptcy fallback now rides the same owner-state seam too, using cash reader plus the first three trailing net-profit years instead of a parallel raw-offset guess. The annual bond lane now rides it as well, using the simulated post-repayment cash window plus the linked-transit threshold split to stage `500000` principal issue counts without shell ownership. +The annual dividend-adjustment lane now rides that same seam too: the runtime now rehosts the +shared year-or-control-transfer metric reader, the board-approved dividend ceiling helper, and the +full annual dividend branch over owned cash, public float, current dividend, and building-growth +policy instead of treating dividend changes as shell-dialog-only logic. That same seam now also carries the fixed-world building-density growth setting plus the linked 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