From fc1ba2810959e70aa465f1108588f6c8b2ef2bc8 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sat, 18 Apr 2026 01:57:06 -0700 Subject: [PATCH] Rehost annual bond repayment and compaction --- README.md | 3 +- crates/rrt-runtime/src/runtime.rs | 113 ++++- crates/rrt-runtime/src/step.rs | 663 +++++++++++++++++++++++++----- docs/README.md | 8 +- docs/runtime-rehost-plan.md | 4 +- 5 files changed, 686 insertions(+), 105 deletions(-) diff --git a/README.md b/README.md index 399cfc1..f08691d 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,8 @@ 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. The annual dividend lane now runs there too: the runtime now rehosts the +pure runtime reader, and periodic boundary service now commits the same shellless matured-bond repayment-and- +compaction path before issuing the exact staged count. 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 diff --git a/crates/rrt-runtime/src/runtime.rs b/crates/rrt-runtime/src/runtime.rs index fdf183f..3de7c3e 100644 --- a/crates/rrt-runtime/src/runtime.rs +++ b/crates/rrt-runtime/src/runtime.rs @@ -3298,7 +3298,8 @@ pub fn runtime_company_annual_bond_policy_state( let eligible_for_bond_issue_branch = runtime_world_annual_finance_mode_active(state) == Some(true) && runtime_world_bond_issue_and_repayment_allowed(state) == Some(true) - && proposed_issue_bond_count.is_some_and(|count| count > 0); + && (matured_live_bond_principal_total.is_some_and(|principal| principal > 0) + || proposed_issue_bond_count.is_some_and(|count| count > 0)); Some(RuntimeCompanyAnnualBondPolicyState { company_id, annual_mode_active: runtime_world_annual_finance_mode_active(state), @@ -8350,6 +8351,116 @@ mod tests { assert!(bond_state.eligible_for_bond_issue_branch); } + #[test] + fn annual_bond_policy_stays_eligible_for_repayment_without_new_issue() { + 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(); + }; + write_current_value(&mut year_stat_family_qword_bits, 0x0d, 900_000.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 { + partial_year_progress_raw_u8: Some(0x0c), + bond_issue_and_repayment_policy_raw_u8: Some(0), + bond_issue_and_repayment_allowed: Some(true), + ..RuntimeWorldRestoreState::default() + }, + metadata: BTreeMap::new(), + companies: vec![RuntimeCompany { + company_id: 12, + 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::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: RuntimeServiceState { + company_market_state: BTreeMap::from([( + 12, + RuntimeCompanyMarketState { + bond_count: 2, + live_bond_slots: vec![ + RuntimeCompanyBondSlot { + slot_index: 0, + principal: 200_000, + maturity_year: 1845, + coupon_rate_raw_u32: 0.09f32.to_bits(), + }, + RuntimeCompanyBondSlot { + slot_index: 1, + principal: 150_000, + maturity_year: 1847, + coupon_rate_raw_u32: 0.08f32.to_bits(), + }, + ], + year_stat_family_qword_bits, + ..RuntimeCompanyMarketState::default() + }, + )]), + ..RuntimeServiceState::default() + }, + }; + + let bond_state = + runtime_company_annual_bond_policy_state(&state, 12).expect("bond policy state"); + assert_eq!(bond_state.live_bond_principal_total, Some(350_000)); + assert_eq!(bond_state.cash_after_full_repayment, Some(550_000)); + assert_eq!(bond_state.proposed_issue_bond_count, Some(0)); + assert!(bond_state.eligible_for_bond_issue_branch); + } + #[test] fn derives_annual_stock_issue_state_from_rehosted_owner_state() { let mut year_stat_family_qword_bits = vec![ diff --git a/crates/rrt-runtime/src/step.rs b/crates/rrt-runtime/src/step.rs index 37ab470..209db92 100644 --- a/crates/rrt-runtime/src/step.rs +++ b/crates/rrt-runtime/src/step.rs @@ -2,25 +2,25 @@ use std::collections::BTreeSet; use serde::{Deserialize, Serialize}; -use crate::{ - RuntimeCargoClass, RuntimeCargoPriceTarget, RuntimeCargoProductionTarget, - RuntimeChairmanMetric, RuntimeChairmanTarget, RuntimeCompanyControllerKind, - RuntimeCompanyMetric, RuntimeCompanyTarget, RuntimeCondition, RuntimeConditionComparator, - RuntimeEffect, RuntimeEventRecordTemplate, RuntimePlayerTarget, RuntimeState, RuntimeSummary, - RuntimeTerritoryMetric, RuntimeTerritoryTarget, RuntimeTrackMetric, RuntimeTrackPieceCounts, - calendar::BoundaryEventKind, runtime_company_annual_dividend_policy_state, - runtime_company_annual_finance_policy_state, runtime_company_annual_stock_issue_state, - runtime_company_annual_stock_repurchase_state, - runtime_company_book_value_per_share, runtime_company_credit_rating, - runtime_company_investor_confidence, runtime_company_management_attitude, - runtime_company_prime_rate, RUNTIME_COMPANY_STAT_SLOT_COUNT, - RUNTIME_COMPANY_STAT_SLOT_CURRENT_CASH, RUNTIME_COMPANY_YEAR_STAT_FAMILY_SPAN, -}; use crate::runtime::{ runtime_company_bond_interest_rate_quote_f64, runtime_company_support_adjusted_share_price_scalar_with_pressure_f64, runtime_round_f64_to_i64, }; +use crate::{ + RUNTIME_COMPANY_STAT_SLOT_COUNT, RUNTIME_COMPANY_STAT_SLOT_CURRENT_CASH, + RUNTIME_COMPANY_YEAR_STAT_FAMILY_SPAN, RuntimeCargoClass, RuntimeCargoPriceTarget, + RuntimeCargoProductionTarget, RuntimeChairmanMetric, RuntimeChairmanTarget, + RuntimeCompanyControllerKind, RuntimeCompanyMetric, RuntimeCompanyTarget, RuntimeCondition, + RuntimeConditionComparator, RuntimeEffect, RuntimeEventRecordTemplate, RuntimePlayerTarget, + RuntimeState, RuntimeSummary, RuntimeTerritoryMetric, RuntimeTerritoryTarget, + RuntimeTrackMetric, RuntimeTrackPieceCounts, calendar::BoundaryEventKind, + runtime_company_annual_dividend_policy_state, runtime_company_annual_finance_policy_state, + runtime_company_annual_stock_issue_state, runtime_company_annual_stock_repurchase_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; @@ -219,8 +219,8 @@ fn service_ensure_company_stat_post_capacity( .checked_mul(RUNTIME_COMPANY_YEAR_STAT_FAMILY_SPAN)? .try_into() .ok()?; - let required_year_len = ((RUNTIME_COMPANY_STAT_SLOT_COUNT + 2) - * RUNTIME_COMPANY_YEAR_STAT_FAMILY_SPAN) as usize; + let required_year_len = + ((RUNTIME_COMPANY_STAT_SLOT_COUNT + 2) * RUNTIME_COMPANY_YEAR_STAT_FAMILY_SPAN) as usize; if market_state.year_stat_family_qword_bits.len() < required_year_len { market_state .year_stat_family_qword_bits @@ -247,7 +247,11 @@ fn service_post_company_stat_delta( } let Some(refreshed_current_cash) = ({ - let Some(market_state) = state.service_state.company_market_state.get_mut(&company_id) else { + let Some(market_state) = state + .service_state + .company_market_state + .get_mut(&company_id) + else { return false; }; let Some(index) = service_ensure_company_stat_post_capacity(market_state, slot_id) else { @@ -285,8 +289,10 @@ fn service_post_company_stat_delta( market_state .year_stat_family_qword_bits - .get((RUNTIME_COMPANY_STAT_SLOT_CURRENT_CASH * RUNTIME_COMPANY_YEAR_STAT_FAMILY_SPAN) - as usize) + .get( + (RUNTIME_COMPANY_STAT_SLOT_CURRENT_CASH * RUNTIME_COMPANY_YEAR_STAT_FAMILY_SPAN) + as usize, + ) .copied() .and_then(service_decode_saved_f64_bits) .and_then(runtime_round_f64_to_i64) @@ -317,15 +323,24 @@ fn service_apply_company_bankruptcy(state: &mut RuntimeState, company_id: u32) - }; let mut company_mutated = false; - if let Some(market_state) = state.service_state.company_market_state.get_mut(&company_id) { + if let Some(market_state) = state + .service_state + .company_market_state + .get_mut(&company_id) + { market_state.last_bankruptcy_year = bankruptcy_year; for slot in &mut market_state.live_bond_slots { slot.principal /= 2; } - market_state.live_bond_slots.retain(|slot| slot.principal > 0); + market_state + .live_bond_slots + .retain(|slot| slot.principal > 0); market_state.bond_count = market_state.live_bond_slots.len().min(u8::MAX as usize) as u8; - market_state.largest_live_bond_principal = - market_state.live_bond_slots.iter().map(|slot| slot.principal).max(); + market_state.largest_live_bond_principal = market_state + .live_bond_slots + .iter() + .map(|slot| slot.principal) + .max(); market_state.highest_coupon_live_bond_principal = market_state .live_bond_slots .iter() @@ -367,6 +382,108 @@ fn service_apply_company_bankruptcy(state: &mut RuntimeState, company_id: u32) - company_mutated } +fn service_repay_matured_company_live_bonds_and_compact( + state: &mut RuntimeState, + company_id: u32, +) -> bool { + let Some(current_year_word) = state + .world_restore + .packed_year_word_raw_u16 + .map(u32::from) + .or_else(|| Some(state.calendar.year)) + else { + return false; + }; + let retired_principal_total = state + .service_state + .company_market_state + .get(&company_id) + .map(|market_state| { + market_state + .live_bond_slots + .iter() + .filter(|slot| slot.maturity_year != 0 && slot.maturity_year <= current_year_word) + .map(|slot| u64::from(slot.principal)) + .sum::() + }) + .unwrap_or(0); + if retired_principal_total == 0 { + return false; + } + + let retired_principal_total_f64 = retired_principal_total as f64; + let mut company_mutated = false; + company_mutated |= service_post_company_stat_delta( + state, + company_id, + RUNTIME_COMPANY_STAT_SLOT_CURRENT_CASH, + -retired_principal_total_f64, + false, + ); + company_mutated |= service_post_company_stat_delta( + state, + company_id, + 0x12, + retired_principal_total_f64, + false, + ); + + if let Some(market_state) = state + .service_state + .company_market_state + .get_mut(&company_id) + { + market_state + .live_bond_slots + .retain(|slot| slot.maturity_year == 0 || slot.maturity_year > current_year_word); + for (slot_index, slot) in market_state.live_bond_slots.iter_mut().enumerate() { + slot.slot_index = slot_index as u32; + } + market_state.bond_count = market_state.live_bond_slots.len().min(u8::MAX as usize) as u8; + market_state.largest_live_bond_principal = market_state + .live_bond_slots + .iter() + .map(|slot| slot.principal) + .max(); + market_state.highest_coupon_live_bond_principal = market_state + .live_bond_slots + .iter() + .filter_map(|slot| { + let coupon = f32::from_bits(slot.coupon_rate_raw_u32) as f64; + coupon.is_finite().then_some((coupon, slot.principal)) + }) + .max_by(|left, right| { + left.0 + .partial_cmp(&right.0) + .unwrap_or(std::cmp::Ordering::Equal) + }) + .map(|(_, principal)| principal); + company_mutated = true; + } + + if let Some(company) = state + .companies + .iter_mut() + .find(|company| company.company_id == company_id) + { + company.debt = state + .service_state + .company_market_state + .get(&company_id) + .map(|market_state| { + market_state + .live_bond_slots + .iter() + .map(|slot| u64::from(slot.principal)) + .sum::() + }) + .unwrap_or(0); + company_mutated = true; + } + + company_mutated +} + fn service_company_annual_finance_policy( state: &mut RuntimeState, service_events: &mut Vec, @@ -515,10 +632,21 @@ fn service_company_annual_finance_policy( if !bond_state.eligible_for_bond_issue_branch { continue; } + mutated |= service_repay_matured_company_live_bonds_and_compact(state, company_id); + + let issue_bond_count = bond_state.proposed_issue_bond_count.unwrap_or(0); let Some(principal) = bond_state.issue_principal_step else { + if mutated { + applied_effect_count += 1; + mutated_company_ids.insert(company_id); + } continue; }; let Some(years_to_maturity) = bond_state.proposed_issue_years_to_maturity else { + if mutated { + applied_effect_count += 1; + mutated_company_ids.insert(company_id); + } continue; }; let Some(maturity_year) = state @@ -527,77 +655,104 @@ fn service_company_annual_finance_policy( .map(u32::from) .and_then(|year| year.checked_add(years_to_maturity)) else { - continue; - }; - let Some(quote_rate) = runtime_company_bond_interest_rate_quote_f64( - state, - company_id, - principal, - years_to_maturity, - ) else { + if mutated { + applied_effect_count += 1; + mutated_company_ids.insert(company_id); + } continue; }; - mutated |= service_post_company_stat_delta( - state, - company_id, - 0x0c, - (principal as f64) * COMPANY_STOCK_AND_BOND_CAPITAL_POST_SCALE, - true, - ); - mutated |= service_post_company_stat_delta( - state, - company_id, - 0x12, - -(principal as f64), - false, - ); - mutated |= service_post_company_stat_delta( - state, - company_id, - RUNTIME_COMPANY_STAT_SLOT_CURRENT_CASH, - principal as f64, - false, - ); + for _ in 0..issue_bond_count { + let Some(quote_rate) = runtime_company_bond_interest_rate_quote_f64( + state, + company_id, + principal, + years_to_maturity, + ) else { + break; + }; - let Some(market_state) = state - .service_state - .company_market_state - .get_mut(&company_id) - else { - continue; - }; - let slot_index = market_state.bond_count as u32; - if market_state.bond_count == u8::MAX { - continue; - } - market_state.live_bond_slots.push(crate::RuntimeCompanyBondSlot { - slot_index, - principal, - maturity_year, - coupon_rate_raw_u32: (quote_rate as f32).to_bits(), - }); - market_state.bond_count = market_state.bond_count.saturating_add(1); - market_state.largest_live_bond_principal = Some( + mutated |= service_post_company_stat_delta( + state, + company_id, + 0x0c, + (principal as f64) * COMPANY_STOCK_AND_BOND_CAPITAL_POST_SCALE, + true, + ); + mutated |= service_post_company_stat_delta( + state, + company_id, + 0x12, + -(principal as f64), + false, + ); + mutated |= service_post_company_stat_delta( + state, + company_id, + RUNTIME_COMPANY_STAT_SLOT_CURRENT_CASH, + principal as f64, + false, + ); + + let Some(market_state) = state + .service_state + .company_market_state + .get_mut(&company_id) + else { + break; + }; + let slot_index = market_state.bond_count as u32; + if market_state.bond_count == u8::MAX { + break; + } market_state - .largest_live_bond_principal - .unwrap_or(0) - .max(principal), - ); - let highest_coupon_live_principal = market_state - .live_bond_slots - .iter() - .filter_map(|slot| { - let coupon = f32::from_bits(slot.coupon_rate_raw_u32) as f64; - coupon.is_finite().then_some((coupon, slot.principal)) - }) - .max_by(|left, right| { - left.0 - .partial_cmp(&right.0) - .unwrap_or(std::cmp::Ordering::Equal) - }) - .map(|(_, principal)| principal); - market_state.highest_coupon_live_bond_principal = highest_coupon_live_principal; + .live_bond_slots + .push(crate::RuntimeCompanyBondSlot { + slot_index, + principal, + maturity_year, + coupon_rate_raw_u32: (quote_rate as f32).to_bits(), + }); + market_state.bond_count = market_state.bond_count.saturating_add(1); + market_state.largest_live_bond_principal = Some( + market_state + .largest_live_bond_principal + .unwrap_or(0) + .max(principal), + ); + let highest_coupon_live_principal = market_state + .live_bond_slots + .iter() + .filter_map(|slot| { + let coupon = f32::from_bits(slot.coupon_rate_raw_u32) as f64; + coupon.is_finite().then_some((coupon, slot.principal)) + }) + .max_by(|left, right| { + left.0 + .partial_cmp(&right.0) + .unwrap_or(std::cmp::Ordering::Equal) + }) + .map(|(_, principal)| principal); + market_state.highest_coupon_live_bond_principal = highest_coupon_live_principal; + } + if let Some(company) = state + .companies + .iter_mut() + .find(|company| company.company_id == company_id) + { + company.debt = state + .service_state + .company_market_state + .get(&company_id) + .map(|market_state| { + market_state + .live_bond_slots + .iter() + .map(|slot| u64::from(slot.principal)) + .sum::() + }) + .unwrap_or(company.debt); + } if mutated { applied_effect_count += 1; mutated_company_ids.insert(company_id); @@ -617,9 +772,9 @@ fn service_company_annual_finance_policy( let Some(batch_size) = repurchase_state.repurchase_batch_size else { break; }; - let Some(pressure_shares) = - runtime_round_f64_to_i64(batch_size as f64 * COMPANY_REPURCHASE_PRESSURE_SCALE) - else { + let Some(pressure_shares) = runtime_round_f64_to_i64( + batch_size as f64 * COMPANY_REPURCHASE_PRESSURE_SCALE, + ) else { break; }; let Some(share_price_scalar) = @@ -2872,9 +3027,11 @@ mod tests { }, }; - let pressured_share_price = crate::runtime:: - runtime_company_support_adjusted_share_price_scalar_with_pressure_f64( - &base_state, 23, 700, + let pressured_share_price = + crate::runtime::runtime_company_support_adjusted_share_price_scalar_with_pressure_f64( + &base_state, + 23, + 700, ) .expect("repurchase share price"); let expected_repurchase_total = @@ -2889,7 +3046,10 @@ mod tests { state.service_state.annual_finance_last_actions.get(&23), Some(&crate::RuntimeCompanyAnnualFinancePolicyAction::StockRepurchase) ); - assert_eq!(state.companies[0].current_cash, 1_600_000 - expected_repurchase_total); + assert_eq!( + state.companies[0].current_cash, + 1_600_000 - expected_repurchase_total + ); assert_eq!( state.service_state.company_market_state[&23].outstanding_shares, 9_000 @@ -3032,12 +3192,318 @@ mod tests { ); } + #[test] + fn periodic_boundary_retires_live_bonds_when_annual_bond_lane_needs_no_reissue() { + 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(); + }; + write_current_value( + &mut year_stat_family_qword_bits, + crate::RUNTIME_COMPANY_STAT_SLOT_CURRENT_CASH, + 900_000.0, + ); + write_current_value(&mut year_stat_family_qword_bits, 0x12, -350_000.0); + + let mut state = crate::RuntimeState { + calendar: crate::CalendarPoint { + year: 1845, + month_slot: 0, + phase_slot: 0, + tick_slot: 0, + }, + world_restore: crate::RuntimeWorldRestoreState { + packed_year_word_raw_u16: Some(1845), + partial_year_progress_raw_u8: Some(0x0c), + bond_issue_and_repayment_policy_raw_u8: Some(0), + bond_issue_and_repayment_allowed: Some(true), + stock_issue_and_buyback_policy_raw_u8: Some(0), + stock_issue_and_buyback_allowed: Some(true), + bankruptcy_policy_raw_u8: Some(0), + bankruptcy_allowed: Some(true), + ..crate::RuntimeWorldRestoreState::default() + }, + world_flags: BTreeMap::new(), + save_profile: crate::RuntimeSaveProfileState::default(), + metadata: BTreeMap::new(), + companies: vec![crate::RuntimeCompany { + company_id: 25, + current_cash: 900_000, + debt: 350_000, + active: true, + credit_rating_score: Some(7), + prime_rate: Some(6), + controller_kind: crate::RuntimeCompanyControllerKind::Unknown, + linked_chairman_profile_id: None, + available_track_laying_capacity: 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(25), + 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 { + company_market_state: BTreeMap::from([( + 25, + crate::RuntimeCompanyMarketState { + bond_count: 2, + live_bond_slots: vec![ + crate::RuntimeCompanyBondSlot { + slot_index: 0, + principal: 200_000, + maturity_year: 1845, + coupon_rate_raw_u32: 0.09f32.to_bits(), + }, + crate::RuntimeCompanyBondSlot { + slot_index: 1, + principal: 150_000, + maturity_year: 1845, + coupon_rate_raw_u32: 0.08f32.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 bond repayment lane"); + + assert_eq!( + state.service_state.annual_finance_last_actions.get(&25), + Some(&crate::RuntimeCompanyAnnualFinancePolicyAction::BondIssue) + ); + assert_eq!(state.companies[0].current_cash, 550_000); + assert_eq!(state.companies[0].debt, 0); + assert_eq!(state.service_state.company_market_state[&25].bond_count, 0); + assert!( + state.service_state.company_market_state[&25] + .live_bond_slots + .is_empty() + ); + assert_eq!( + state.service_state.company_market_state[&25].largest_live_bond_principal, + None + ); + assert_eq!( + state.service_state.company_market_state[&25].highest_coupon_live_bond_principal, + None + ); + assert!( + result + .service_events + .iter() + .any(|event| event.kind == "annual_finance_policy" + && event.applied_effect_count == 1 + && event.mutated_company_ids == vec![25]) + ); + } + + #[test] + fn periodic_boundary_retires_then_reissues_exact_annual_bond_count() { + 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(); + }; + write_current_value( + &mut year_stat_family_qword_bits, + crate::RUNTIME_COMPANY_STAT_SLOT_CURRENT_CASH, + -400_000.0, + ); + write_current_value(&mut year_stat_family_qword_bits, 0x12, -350_000.0); + + let mut state = crate::RuntimeState { + calendar: crate::CalendarPoint { + year: 1845, + month_slot: 0, + phase_slot: 0, + tick_slot: 0, + }, + world_restore: crate::RuntimeWorldRestoreState { + packed_year_word_raw_u16: Some(1845), + partial_year_progress_raw_u8: Some(0x0c), + bond_issue_and_repayment_policy_raw_u8: Some(0), + bond_issue_and_repayment_allowed: Some(true), + stock_issue_and_buyback_policy_raw_u8: Some(0), + stock_issue_and_buyback_allowed: Some(true), + bankruptcy_policy_raw_u8: Some(0), + bankruptcy_allowed: Some(true), + ..crate::RuntimeWorldRestoreState::default() + }, + world_flags: BTreeMap::new(), + save_profile: crate::RuntimeSaveProfileState::default(), + metadata: BTreeMap::new(), + companies: vec![crate::RuntimeCompany { + company_id: 26, + current_cash: -400_000, + debt: 350_000, + active: true, + credit_rating_score: Some(7), + prime_rate: Some(6), + controller_kind: crate::RuntimeCompanyControllerKind::Unknown, + linked_chairman_profile_id: None, + available_track_laying_capacity: 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(26), + 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 { + company_market_state: BTreeMap::from([( + 26, + crate::RuntimeCompanyMarketState { + bond_count: 2, + linked_transit_latch: true, + live_bond_slots: vec![ + crate::RuntimeCompanyBondSlot { + slot_index: 0, + principal: 200_000, + maturity_year: 1845, + coupon_rate_raw_u32: 0.09f32.to_bits(), + }, + crate::RuntimeCompanyBondSlot { + slot_index: 1, + principal: 150_000, + maturity_year: 1845, + coupon_rate_raw_u32: 0.08f32.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 bond restructure lane"); + + assert_eq!( + state.service_state.annual_finance_last_actions.get(&26), + Some(&crate::RuntimeCompanyAnnualFinancePolicyAction::BondIssue) + ); + assert_eq!(state.companies[0].current_cash, 250_000); + assert_eq!(state.companies[0].debt, 1_000_000); + assert_eq!(state.service_state.company_market_state[&26].bond_count, 2); + assert_eq!( + state.service_state.company_market_state[&26].largest_live_bond_principal, + Some(500_000) + ); + assert_eq!( + state.service_state.company_market_state[&26].highest_coupon_live_bond_principal, + Some(500_000) + ); + assert_eq!( + state.service_state.company_market_state[&26].live_bond_slots, + vec![ + crate::RuntimeCompanyBondSlot { + slot_index: 0, + principal: 500_000, + maturity_year: 1875, + coupon_rate_raw_u32: 0.09f32.to_bits(), + }, + crate::RuntimeCompanyBondSlot { + slot_index: 1, + principal: 500_000, + maturity_year: 1875, + coupon_rate_raw_u32: 0.09f32.to_bits(), + }, + ] + ); + assert!( + result + .service_events + .iter() + .any(|event| event.kind == "annual_finance_policy" + && event.applied_effect_count == 1 + && event.mutated_company_ids == vec![26]) + ); + } + #[test] fn periodic_boundary_applies_creditor_pressure_bankruptcy_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 + * 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; @@ -3207,7 +3673,8 @@ mod tests { 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 + * 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; diff --git a/docs/README.md b/docs/README.md index 98f55fa..6119f21 100644 --- a/docs/README.md +++ b/docs/README.md @@ -150,12 +150,12 @@ The highest-value next passes are now: 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 creditor-pressure-bankruptcy, deep-distress-bankruptcy, dividend-adjustment, stock-repurchase, - stock-issue, and bond-issue branches against owned runtime state, with bankruptcy now following + stock-issue, and annual bond-restructure branches against owned runtime state, with bankruptcy now following the grounded “halve live bond debt and stamp the year” path rather than a liquidation shortcut; the same live bond-slot owner - surface now also carries save-native maturity years into annual bond policy summaries as the - next seam for shellless repayment work, and now also derives the current live coupon burden - directly from owned bond slots + surface now also carries save-native maturity years into annual bond policy summaries, derives the current live coupon burden + directly from owned bond slots, and now also commits the shellless “repay matured live bonds, + compact the table, then issue the exact staged count” path during periodic service - 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 04bc10a..deceb0b 100644 --- a/docs/runtime-rehost-plan.md +++ b/docs/runtime-rehost-plan.md @@ -233,7 +233,9 @@ adjusted share price, and those policy bytes rather than staying in atlas prose deep-distress bankruptcy fallback now rides the same owner-state seam too, using the save-native 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. +linked-transit threshold split to stage `500000` principal issue counts without shell ownership, +and periodic boundary service now commits the same shellless matured-bond repay/compact/issue path instead of +stopping at the staging reader. 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