//! Economy simulation integration — runs econ-sim inside the server tick loop. //! //! Bridges the standalone `econ_sim` library into the Bevy ECS tick loop. //! The simulation advances one economy tick every `ECON_TICK_RATE` game ticks. //! //! ## Architecture //! //! - [`EconSimResource`] — holds the running `Simulation` + price history for trends. //! Loaded once at startup from `server/data/systems.db`. Never reloaded mid-session. //! //! - [`EconStateResource`] — all 7 D-181 signals per active (system_id, commodity_id). //! Updated every `ECON_TICK_RATE` game ticks by `tick_economy_simulation`. //! Queryable by the IPC bridge (#822) and debug commands (#823). //! //! - `tick_economy_simulation` — bevy System registered in `SimulationPlugin`. //! Advances one economy tick, then rebuilds `EconStateResource`. //! //! ## Rate (D-031) //! //! `ECON_TICK_RATE = 10` game ticks per economy tick. //! At 10 game ticks/game-minute (D-031), this means the economy advances once //! per game-minute — a reasonable granularity for macro-scale price dynamics. //! //! ## D-181 signals //! //! 1. `price_current` — current market price (Public) //! 2. `price_trend` — Δprice over the last `TREND_WINDOW` economy ticks (Public) //! 3. `trade_flow_volume` — supply volume proxy (Observable; Phase 3 will refine) //! 4. `corporate_presence` — corp count at this node (Observable) //! 5. `stockpile_weeks` — stockpile ÷ weekly demand rate (Semi-private) //! 6. `production_vs_baseline` — supply ÷ initial baseline supply (Private) //! 7. `official_coverage_ratio` — 1 − shadow_intensity (Meta-signal) use std::collections::BTreeMap; use bevy_ecs::prelude::*; use econ_sim::Simulation; use crate::bridge::types::{EconNodeSnapshot, EconomySnapshot, SnapshotBuffer}; use crate::simulation::time::SimulationTime; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- /// Game ticks between each economy tick (D-031: 10 ticks/game-minute). pub const ECON_TICK_RATE: u64 = 10; /// Number of economy ticks to average for price trend signal 2 (D-181). const TREND_WINDOW: usize = 5; // --------------------------------------------------------------------------- // Resources // --------------------------------------------------------------------------- /// The running economics simulation (loaded once at startup). /// /// Never reinitialize mid-session — the economy state is continuous. #[derive(Resource)] pub struct EconSimResource { pub sim: Simulation, /// Price history for signal 2 (price_trend) computation. /// Ring-buffer keyed by (system_id, commodity_id) → last TREND_WINDOW prices. price_history: BTreeMap<(String, String), Vec>, /// Baseline supply from first economy tick for signal 6 (production_vs_baseline). baseline_supply: BTreeMap<(String, String), f64>, } impl EconSimResource { pub fn new(sim: Simulation) -> Self { Self { sim, price_history: BTreeMap::new(), baseline_supply: BTreeMap::new(), } } } /// The 7 D-181 signals for a single active (system_id, commodity_id) pair. /// /// Updated every `ECON_TICK_RATE` game ticks. All fields present when the /// node is active; queries for inactive nodes return nothing. #[derive(Debug, Clone)] pub struct EconNodeSignals { pub system_id: String, pub commodity_id: String, /// Signal 1: current market price in Tractus (Public). pub price_current: f64, /// Signal 2: price delta over last `TREND_WINDOW` economy ticks (Public). /// Positive = price rising; negative = falling. Absolute delta, not percentage. pub price_trend: f64, /// Signal 3: trade flow volume proxy — supply volume this tick (Observable). /// Phase 2 proxy: actual inter-node flow tracking is Phase 3. pub trade_flow_volume: f64, /// Signal 4: number of corporations operating at this node (Observable). pub corporate_presence: u32, /// Signal 5: estimated stockpile in weeks at current demand rate (Semi-private). pub stockpile_weeks: f64, /// Signal 6: supply vs. baseline supply from first tick (Private). /// 1.0 = at baseline; < 1.0 = below baseline; > 1.0 = above. pub production_vs_baseline: f64, /// Signal 7: ratio of formal to total (formal + shadow) activity (Meta-signal). /// Derived from `shadow_intensity`: 1.0 = fully formal, 0.0 = fully shadow. pub official_coverage_ratio: f64, } /// Current economy state — all 7 D-181 signals for all active nodes. /// /// Updated every `ECON_TICK_RATE` game ticks. Queryable by the IPC bridge /// (#822) and debug command handler (#823). Absent when the economy DB is /// not loaded (graceful degradation). #[derive(Resource, Default)] pub struct EconStateResource { /// Economy tick at which this snapshot was produced. pub econ_tick: u64, /// Current Tractus/Mark exchange rate (1.0 = parity). pub tractus_mark_rate: f64, /// Signal map: (system_id, commodity_id) → 7-signal snapshot. pub signals: BTreeMap<(String, String), EconNodeSignals>, } // --------------------------------------------------------------------------- // System // --------------------------------------------------------------------------- /// System: advance the economy simulation one tick every `ECON_TICK_RATE` game ticks. /// /// Runs after `time::advance_tick` (needs current game tick) and before /// `compute_observer_snapshot` (so signals are fresh for the snapshot). /// /// No-op when the game tick is not divisible by `ECON_TICK_RATE`. /// Both `EconSimResource` and `EconStateResource` must be present (inserted /// at startup only when the economy DB loaded successfully). pub fn tick_economy_simulation( time: Res, econ_sim_opt: Option>, econ_state_opt: Option>, ) { let (mut econ_sim, mut econ_state) = match (econ_sim_opt, econ_state_opt) { (Some(s), Some(st)) => (s, st), _ => return, // economy not loaded — no-op }; if time.tick % ECON_TICK_RATE != 0 { return; } // Step the simulation one economy tick econ_sim.sim.step(); let econ_tick = econ_sim.sim.tick(); let fx_rate = econ_sim.sim.tractus_mark_rate(); // Rebuild signal map from updated node states rebuild_signals(&mut econ_sim, &mut econ_state, econ_tick, fx_rate); } // --------------------------------------------------------------------------- // Signal computation // --------------------------------------------------------------------------- fn rebuild_signals( econ_sim: &mut EconSimResource, econ_state: &mut EconStateResource, econ_tick: u64, fx_rate: f64, ) { econ_state.econ_tick = econ_tick; econ_state.tractus_mark_rate = fx_rate; econ_state.signals.clear(); // Collect shadow intensities and corp counts once (avoid repeated borrows) let shadow_intensities: BTreeMap = econ_sim .sim .shadow() .intensity .iter() .map(|(k, v)| (k.clone(), *v)) .collect(); let corp_counts: BTreeMap = econ_sim .sim .economy() .presences_by_system .iter() .map(|(sys, corps)| (sys.clone(), corps.len() as u32)) .collect(); // Snapshot current node states into the price_history and baseline_supply maps, // then build signals. We need to separate the borrow from the iteration. let node_snapshots: Vec<(String, Vec<(String, f64, f64)>)> = econ_sim .sim .nodes .iter() .map(|(system_id, node)| { let commodities: Vec<(String, f64, f64)> = node .commodities .iter() .map(|(commodity_id, state)| { (commodity_id.clone(), state.price, state.supply) }) .collect(); (system_id.clone(), commodities) }) .collect(); for (system_id, commodities) in &node_snapshots { let shadow_intensity = shadow_intensities.get(system_id).copied().unwrap_or(0.0); let corp_count = corp_counts.get(system_id).copied().unwrap_or(0); for (commodity_id, price, supply) in commodities { let key = (system_id.clone(), commodity_id.clone()); // Signal 6 baseline: record first-tick supply econ_sim .baseline_supply .entry(key.clone()) .or_insert(*supply); let baseline = *econ_sim.baseline_supply.get(&key).unwrap_or(supply); // Signal 2: price trend via ring buffer let history = econ_sim.price_history.entry(key.clone()).or_default(); history.push(*price); if history.len() > TREND_WINDOW { history.remove(0); } let price_trend = if history.len() >= 2 { price - history[0] } else { 0.0 }; // Signal 5: stockpile in weeks (7 economy ticks per week approximation) let stockpile = econ_sim .sim .nodes .get(system_id) .and_then(|n| n.commodities.get(commodity_id)) .map(|s| s.stockpile) .unwrap_or(0.0); let demand = econ_sim .sim .nodes .get(system_id) .and_then(|n| n.commodities.get(commodity_id)) .map(|s| s.demand) .unwrap_or(0.0); let weekly_demand = demand * 7.0; // 7 econ ticks ≈ 1 week let stockpile_weeks = if weekly_demand > 1e-9 { stockpile / weekly_demand } else { 0.0 }; // Signal 6: production vs baseline let production_vs_baseline = if baseline > 1e-9 { supply / baseline } else { 1.0 }; // Signal 7: official coverage ratio let official_coverage_ratio = 1.0 - shadow_intensity; econ_state.signals.insert( key, EconNodeSignals { system_id: system_id.clone(), commodity_id: commodity_id.clone(), price_current: *price, price_trend, trade_flow_volume: *supply, // Phase 2 proxy corporate_presence: corp_count, stockpile_weeks, production_vs_baseline, official_coverage_ratio, }, ); } } } // --------------------------------------------------------------------------- // Startup helper // --------------------------------------------------------------------------- /// Attempt to load the economy simulation. /// /// Returns `Some((EconSimResource, EconStateResource))` on success, `None` on /// failure (with the error logged at warn level). The server inserts these as /// resources when present; the economy features degrade gracefully when absent. // --------------------------------------------------------------------------- // IPC query buffer (#822) // --------------------------------------------------------------------------- /// Pending system_id from an `EconStateQuery` PlayerAction. /// /// Populated by `process_player_input`; consumed by `serve_econ_state_query`. /// `None` on ticks when no query was received. #[derive(Resource, Default)] pub struct EconQueryBuffer { pub pending: Option, } /// System: serve a pending `EconStateQuery` by building an `EconomySnapshot` /// and storing it in `SnapshotBuffer.pending_economy_response`. /// /// Runs after `tick_economy_simulation` (signals must be fresh) and before /// `compute_observer_snapshot` (which consumes the response). /// No-op when `EconStateResource` is absent or no query is pending. pub fn serve_econ_state_query( mut query_buf: ResMut, econ_state: Option>, mut snapshot_buf: ResMut, ) { let system_id = match query_buf.pending.take() { Some(s) => s, None => return, }; let econ_state = match econ_state { Some(s) => s, None => { // Economy not loaded — no response (client receives None in snapshot) tracing::debug!(system_id = %system_id, "EconStateQuery: economy not loaded"); return; } }; // Collect signals for all commodities in the requested system let nodes: Vec = econ_state .signals .iter() .filter(|((sys, _), _)| sys == &system_id) .map(|((_, commodity_id), sig)| EconNodeSnapshot { commodity_id: commodity_id.clone(), price_current: sig.price_current, price_trend: sig.price_trend, trade_flow_volume: sig.trade_flow_volume, corporate_presence: sig.corporate_presence, stockpile_weeks: sig.stockpile_weeks, production_vs_baseline: sig.production_vs_baseline, official_coverage_ratio: sig.official_coverage_ratio, }) .collect(); if nodes.is_empty() { tracing::debug!(system_id = %system_id, "EconStateQuery: system not found in economy state"); return; } snapshot_buf.pending_economy_response = Some(EconomySnapshot { system_id, econ_tick: econ_state.econ_tick, tractus_mark_rate: econ_state.tractus_mark_rate, nodes, }); } // --------------------------------------------------------------------------- // Startup helper // --------------------------------------------------------------------------- pub fn try_load_economy(run_seed: u64) -> Option<(EconSimResource, EconStateResource)> { match Simulation::load_auto(run_seed) { Ok(sim) => { tracing::info!( commodities = sim.economy().commodities.len(), active_nodes = sim.nodes.len(), "Economy simulation loaded" ); Some((EconSimResource::new(sim), EconStateResource::default())) } Err(e) => { tracing::warn!( error = %e, "Economy simulation not loaded — economics features disabled for this session" ); None } } }