Files
settled-reach/server/src/simulation/economy.rs
T
jpmschweitzerandClaude Sonnet 4.6 5f4139bc9e feat(simulation): implement economics integration sprint — #810 #821 #822 #823
Implements the full D-180/D-181 economics pipeline:

**#810 — Event input port (D-180)**
- Add EconEvent struct with Target/Effect/Duration/Visibility variants
- Implement EventPort as typed input queue for external disruptions
- Apply events in simulation step; D-179 Test 3 now uses real shock injection

**#821 — Integrate econ-sim into server tick loop**
- Extract econ-sim as library crate (lib.rs + sim.rs, Cargo.toml [lib] section)
- Add Simulation stateful runner; step() advances one economy tick
- Add EconSimResource, EconStateResource (7 D-181 signals), tick_economy_simulation
- Economy loads once at startup; graceful no-op when systems.db absent
- Server advances economy 1 tick per 10 game ticks (D-031)

**#822 — Expose economy state over IPC bridge**
- Protocol version 20 → 21
- Add EconomySnapshot, EconNodeSnapshot wire types
- Add EconStateQuery PlayerAction variant; response in economy_snapshot field
- Add EconQueryBuffer resource + serve_econ_state_query system

**#823 — Economics debug commands**
- Add InjectEconEvent, SetEconParam, GetEconState to DebugCommandKind
- Add EconDebugEffect, EconParamKind enums
- SetEconParam mutates α/β at runtime (α/β promoted to pub const + Simulation fields)
- ALPHA and BETA constants threaded through step_inner/trade_step signatures

All 1147 unit tests pass; zero warnings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 13:13:52 +02:00

383 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<f64>>,
/// 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<SimulationTime>,
econ_sim_opt: Option<ResMut<EconSimResource>>,
econ_state_opt: Option<ResMut<EconStateResource>>,
) {
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<String, f64> = econ_sim
.sim
.shadow()
.intensity
.iter()
.map(|(k, v)| (k.clone(), *v))
.collect();
let corp_counts: BTreeMap<String, u32> = 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<String>,
}
/// 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<EconQueryBuffer>,
econ_state: Option<Res<EconStateResource>>,
mut snapshot_buf: ResMut<SnapshotBuffer>,
) {
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<EconNodeSnapshot> = 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
}
}
}