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>
This commit is contained in:
2026-04-10 13:13:52 +02:00
co-authored by Claude Sonnet 4.6
parent 9d9ea96be1
commit 5f4139bc9e
19 changed files with 1471 additions and 122 deletions
+156
View File
@@ -0,0 +1,156 @@
//! Stateful simulation runner for server integration (#821).
//!
//! [`Simulation`] wraps all simulation state (economy data, node states,
//! currency, events) and exposes a per-tick `step()` method. This is the
//! entry point for the game server's economy system, which advances one
//! economy tick per ECON_TICK_RATE game ticks (D-031).
//!
//! The batch `model::run_with_events` is retained for the CLI binary and
//! stability checks. Both share the same underlying `model::step_inner`.
use std::collections::BTreeMap;
use std::path::Path;
use crate::{agents, currency, db, events, model, seed, trade};
/// Stateful Settled Reach economics simulation.
///
/// Initialize with [`Simulation::load`] once at server startup.
/// Call [`Simulation::step`] once per economy tick.
pub struct Simulation {
pub economy: db::Economy,
productivity: BTreeMap<(String, String), seed::Productivity>,
shadow: currency::ShadowEconomy,
adjacency: BTreeMap<String, Vec<String>>,
archetypes: BTreeMap<String, agents::Archetype>,
pub nodes: BTreeMap<String, model::NodeState>,
currency_state: currency::CurrencyState,
/// The event input port (D-180). Push events here; they are consumed
/// on the next `step()` call.
pub events: events::EventPort,
/// Number of economy ticks processed so far.
tick: u64,
/// Tâtonnement step size (α). Runtime-tunable via SetEconParam (#823).
/// Default: `model::ALPHA` (0.03).
pub alpha: f64,
/// Trade flow damping factor (β). Runtime-tunable via SetEconParam (#823).
/// Default: `trade::BETA` (0.4).
pub beta: f64,
}
impl Simulation {
/// Load economy data from `db_path` and initialize the simulation.
///
/// `run_seed` is the per-run PRNG seed for productivity seeding (D-176).
/// This is typically the game's world seed from `StartupMessage`.
///
/// The DB is opened once and the loaded data stored in memory.
/// Do NOT call this per tick.
pub fn load(db_path: &Path, run_seed: u64) -> Result<Self, String> {
let db_pathbuf = db_path.to_path_buf();
if !db_path.exists() {
return Err(format!("economy DB not found: {}", db_path.display()));
}
let conn = db::open_db(&db_pathbuf);
let economy = db::load_economy(&conn);
let productivity = seed::seed_all_productivity(&economy, run_seed);
let shadow = currency::seed_shadow_economy(&economy, run_seed);
let adjacency = trade::build_adjacency(&economy);
let archetypes = agents::build_archetype_map(economy.corp_archetype_data.clone());
let nodes = model::init_nodes(&economy);
Ok(Simulation {
economy,
productivity,
shadow,
adjacency,
archetypes,
nodes,
currency_state: currency::CurrencyState::new(),
events: events::EventPort::new(),
tick: 0,
alpha: model::ALPHA,
beta: trade::BETA,
})
}
/// Try to load from the auto-detected DB path (same search as the CLI binary).
///
/// Searches up from CWD for `server/data/systems.db`.
pub fn load_auto(run_seed: u64) -> Result<Self, String> {
let mut dir = std::env::current_dir().map_err(|e| e.to_string())?;
loop {
let candidate = dir.join("server").join("data").join("systems.db");
if candidate.exists() {
return Self::load(&candidate, run_seed);
}
if !dir.pop() {
break;
}
}
// Also check adjacent `data/` directory (when running from within server/)
let candidate = std::path::PathBuf::from("data").join("systems.db");
if candidate.exists() {
return Self::load(&candidate, run_seed);
}
Err("cannot find server/data/systems.db — pass path explicitly or run from project root".to_string())
}
/// Advance the simulation by one economy tick.
///
/// Applies active events, runs the Layer 1+2+3 step, and advances the
/// event port. Call once per economy tick (every ECON_TICK_RATE game ticks).
pub fn step(&mut self) {
// Activate any events scheduled for this tick (D-180)
let tick32 = self.tick as u32;
self.events.activate_scheduled(tick32);
let mods = self.events.compute_modifiers(&self.economy);
model::step_inner(
&self.economy,
&self.productivity,
&self.shadow,
&self.archetypes,
&mut self.nodes,
&mods,
self.alpha,
);
self.currency_state.apply_exchange_shock(mods.exchange_shock);
trade::trade_step(
&self.economy,
&mut self.nodes,
&self.adjacency,
&mut self.currency_state,
self.beta,
);
self.currency_state.update_rate();
// Expire finished events
self.events.advance_remaining();
self.tick += 1;
}
/// Number of economy ticks processed so far.
pub fn tick(&self) -> u64 {
self.tick
}
/// Current Tractus/Mark exchange rate.
pub fn tractus_mark_rate(&self) -> f64 {
self.currency_state.tractus_mark_rate
}
/// Read-only access to the loaded economy data.
pub fn economy(&self) -> &db::Economy {
&self.economy
}
/// Read-only access to the per-node shadow economy intensities.
pub fn shadow(&self) -> &currency::ShadowEconomy {
&self.shadow
}
}