From 5f4139bc9e2349c8d3b6428be1f03e6e48a18597 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 10 Apr 2026 13:13:52 +0200 Subject: [PATCH] =?UTF-8?q?feat(simulation):=20implement=20economics=20int?= =?UTF-8?q?egration=20sprint=20=E2=80=94=20#810=20#821=20#822=20#823?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/Cargo.lock | 14 +- server/Cargo.toml | 2 + server/src/bridge/debug.rs | 134 ++++++++- server/src/bridge/text_renderer.rs | 2 + server/src/bridge/types.rs | 99 ++++++- server/src/main.rs | 1 + server/src/perception/observer/mod.rs | 4 + server/src/simulation/economy.rs | 382 ++++++++++++++++++++++++++ server/src/simulation/input.rs | 10 + server/src/simulation/mod.rs | 24 ++ tooling/econ-sim/Cargo.toml | 4 + tooling/econ-sim/src/agents.rs | 58 +--- tooling/econ-sim/src/currency.rs | 10 + tooling/econ-sim/src/events.rs | 375 +++++++++++++++++++++++++ tooling/econ-sim/src/lib.rs | 30 ++ tooling/econ-sim/src/main.rs | 171 +++++++++--- tooling/econ-sim/src/model.rs | 111 ++++++-- tooling/econ-sim/src/sim.rs | 156 +++++++++++ tooling/econ-sim/src/trade.rs | 6 +- 19 files changed, 1471 insertions(+), 122 deletions(-) create mode 100644 server/src/simulation/economy.rs create mode 100644 tooling/econ-sim/src/events.rs create mode 100644 tooling/econ-sim/src/lib.rs create mode 100644 tooling/econ-sim/src/sim.rs diff --git a/server/Cargo.lock b/server/Cargo.lock index 3239169cc..3adb44117 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -570,6 +570,17 @@ version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" +[[package]] +name = "econ-sim" +version = "0.1.0" +dependencies = [ + "clap", + "rand", + "rand_chacha", + "rusqlite", + "serde", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1245,13 +1256,14 @@ dependencies = [ [[package]] name = "settled-reach-server" -version = "0.1.32" +version = "0.1.33" dependencies = [ "bevy_app", "bevy_ecs", "bincode", "clap", "crossbeam-channel", + "econ-sim", "pathfinding", "rand", "rand_chacha", diff --git a/server/Cargo.toml b/server/Cargo.toml index c4f1ee190..3278e3201 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -23,6 +23,8 @@ sysinfo = "0.35" serde_json = "1" rusqlite = { version = "0.32", features = ["bundled"] } toml = "0.8" +# Economics simulation — Leontief + tâtonnement + D-180 event port (#821) +econ-sim = { path = "../tooling/econ-sim" } [features] default = ["gauntlet"] diff --git a/server/src/bridge/debug.rs b/server/src/bridge/debug.rs index 2a78f23aa..b7b881c82 100644 --- a/server/src/bridge/debug.rs +++ b/server/src/bridge/debug.rs @@ -8,7 +8,11 @@ use bevy_ecs::prelude::*; -use crate::bridge::types::{DebugCommandKind, DebugEnabled, DebugResponsePayload, SnapshotBuffer}; +use crate::bridge::types::{ + DebugCommandKind, DebugEnabled, DebugResponsePayload, EconDebugEffect, EconParamKind, + SnapshotBuffer, +}; +use crate::simulation::economy::{EconSimResource, EconStateResource}; use crate::knowledge::EntityRegistry; use crate::npc::Npc; use crate::simulation::conversation::NpcName; @@ -69,6 +73,8 @@ pub fn handle_debug_commands( (Entity, &TilePosition, Option<&NpcName>), (With, With, Without), >, + mut econ_sim: Option>, + econ_state: Option>, ) { // Gate: debug must be enabled let enabled = debug_enabled.as_ref().is_some_and(|d| d.0); @@ -325,6 +331,132 @@ pub fn handle_debug_commands( } } } + DebugCommandKind::InjectEconEvent { + ref target, + ref effect, + magnitude, + duration_ticks, + } => { + use econ_sim::events::{EconEvent, EconEventEffect, EconEventTarget, EconEventVisibility}; + if let Some(ref mut sim) = econ_sim { + let econ_effect = match effect { + EconDebugEffect::CapacityMultiplier => { + EconEventEffect::CapacityMultiplier(magnitude) + } + EconDebugEffect::ProductivityMultiplier => { + EconEventEffect::ProductivityMultiplier(magnitude) + } + EconDebugEffect::DemandShock => { + EconEventEffect::DemandShock(magnitude) + } + EconDebugEffect::ExchangeShock => { + EconEventEffect::ExchangeShock(magnitude) + } + }; + sim.sim.events.push(EconEvent { + target: EconEventTarget::Node(target.clone()), + effect: econ_effect, + duration: duration_ticks, + visibility: EconEventVisibility::Global, + }); + DebugResponsePayload { + command: format!("InjectEconEvent({}, {:?}, {}×{})", target, effect, magnitude, duration_ticks), + text: format!( + "Event injected: {:?} ×{} on node '{}' for {} ticks.\nTakes effect on next economy tick.", + effect, magnitude, target, duration_ticks + ), + success: true, + } + } else { + DebugResponsePayload { + command: "InjectEconEvent".to_string(), + text: "Economy simulation not loaded.".to_string(), + success: false, + } + } + } + DebugCommandKind::SetEconParam { ref param, value } => { + if let Some(ref mut sim) = econ_sim { + match param { + EconParamKind::TatonnementStep => { + let old = sim.sim.alpha; + sim.sim.alpha = value; + DebugResponsePayload { + command: format!("SetEconParam(TatonnementStep, {})", value), + text: format!("α (tâtonnement step): {} → {}", old, value), + success: true, + } + } + EconParamKind::DampingFactor => { + let old = sim.sim.beta; + sim.sim.beta = value; + DebugResponsePayload { + command: format!("SetEconParam(DampingFactor, {})", value), + text: format!("β (damping factor): {} → {}", old, value), + success: true, + } + } + EconParamKind::CorridorFriction { ref corridor_id } => { + DebugResponsePayload { + command: format!("SetEconParam(CorridorFriction({}))", corridor_id), + text: "Per-corridor friction override not yet implemented (requires corridor friction model in trade.rs).".to_string(), + success: false, + } + } + } + } else { + DebugResponsePayload { + command: "SetEconParam".to_string(), + text: "Economy simulation not loaded.".to_string(), + success: false, + } + } + } + DebugCommandKind::GetEconState { ref system_id } => { + if let Some(ref state) = econ_state { + let signals: Vec<_> = state + .signals + .iter() + .filter(|((sys, _), _)| sys == system_id) + .collect(); + if signals.is_empty() { + DebugResponsePayload { + command: format!("GetEconState({})", system_id), + text: format!("System '{}' not found in economy state.", system_id), + success: false, + } + } else { + let mut lines = vec![ + format!("=== Economy state for '{}' (econ_tick={}) ===", system_id, state.econ_tick), + format!(" FX rate (Tractus/Mark): {:.4}", state.tractus_mark_rate), + ]; + for ((_, commodity_id), sig) in &signals { + lines.push(format!( + " {} | price={:.2} trend={:+.2} flow={:.1} corps={} stockpile_wks={:.1} prod_vs_base={:.3} coverage={:.2}", + commodity_id, + sig.price_current, + sig.price_trend, + sig.trade_flow_volume, + sig.corporate_presence, + sig.stockpile_weeks, + sig.production_vs_baseline, + sig.official_coverage_ratio, + )); + } + DebugResponsePayload { + command: format!("GetEconState({})", system_id), + text: lines.join("\n"), + success: true, + } + } + } else { + DebugResponsePayload { + command: format!("GetEconState({})", system_id), + text: "Economy simulation not loaded.".to_string(), + success: false, + } + } + } } }; diff --git a/server/src/bridge/text_renderer.rs b/server/src/bridge/text_renderer.rs index b527aa1ba..50b98c623 100644 --- a/server/src/bridge/text_renderer.rs +++ b/server/src/bridge/text_renderer.rs @@ -320,6 +320,7 @@ mod tests { debug_response: None, current_ticker: None, settings_response: None, + economy_snapshot: None, } } @@ -461,6 +462,7 @@ mod tests { debug_response: None, current_ticker: None, settings_response: None, + economy_snapshot: None, }; let text = format_snapshot_text(&snap); assert!(text.contains("Tick 0")); diff --git a/server/src/bridge/types.rs b/server/src/bridge/types.rs index 21bbfda36..af45e09d2 100644 --- a/server/src/bridge/types.rs +++ b/server/src/bridge/types.rs @@ -17,7 +17,7 @@ pub use crate::simulation::time::{DayPhase, TickRate}; /// negotiation is unnecessary. Client should reject snapshots with version != /// PROTOCOL_VERSION. New fields use #[serde(default)] only during the migration /// period, then the default is removed once both sides are updated. -pub const PROTOCOL_VERSION: u8 = 20; +pub const PROTOCOL_VERSION: u8 = 21; /// Handshake message sent as the very first framed message after connection (#555). /// Client reads this before entering the normal tick loop and validates @@ -81,6 +81,8 @@ pub struct StartupMessage { /// v18 adds: debug_response (#580, debug console server — command/response wire). /// v19 adds: character_archetype on StartupMessage (#587), current_ticker (#591). /// v20 adds: settings_response (#627, SQLite settings IPC). +/// v21 adds: economy_snapshot (#822, D-181 7-signal snapshot per queried system), +/// EconStateQuery PlayerAction variant (#822). /// Future fields: ambient sound events, HUD state (D-020 expansion). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ObserverSnapshot { @@ -216,6 +218,12 @@ pub struct ObserverSnapshot { /// Client reads to confirm setting changes or to populate the settings UI. #[serde(default, skip_serializing_if = "Option::is_none")] pub settings_response: Option, + /// Economy snapshot (#822, D-181 7-signal snapshot). + /// Present for exactly one tick after an `EconStateQuery` is processed. + /// Contains all 7 D-181 signals for each commodity in the queried system. + /// None during normal gameplay; client queries explicitly via `EconStateQuery`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub economy_snapshot: Option, } /// A single news ticker headline crossing the wire boundary (#591). @@ -550,6 +558,12 @@ pub enum PlayerAction { DeleteSetting { key: String, }, + /// Query economy state for a named system (#822, D-181). + /// Server responds with `ObserverSnapshot.economy_snapshot` for one tick. + /// Absent when economy is not loaded or `system_id` is unknown. + EconStateQuery { + system_id: String, + }, } impl PlayerAction { @@ -595,6 +609,21 @@ pub enum DebugCommandKind { ListPopulation, /// Return `ContaminationActive` status and current tick. GetContaminationStatus, + /// Inject a D-180 economic event into the running simulation (#823). + /// The event fires at the next economy tick and lasts for `duration_ticks`. + /// `target` is a system_id (node-level events only in v0.1). + InjectEconEvent { + target: String, + effect: EconDebugEffect, + magnitude: f64, + duration_ticks: u32, + }, + /// Mutate a simulation parameter at runtime (#823, D-178). + /// Changes take effect on the next `Simulation::step()` call. + SetEconParam { param: EconParamKind, value: f64 }, + /// Return all 7 D-181 signals for the named system (#823). + /// Equivalent to `EconStateQuery` but via the debug console. + GetEconState { system_id: String }, } /// Debug response payload included in `ObserverSnapshot` (#580). @@ -612,6 +641,72 @@ pub struct DebugResponsePayload { pub success: bool, } +/// Wire type for a single commodity's 7 D-181 signals at a node (#822). +/// +/// Compact snapshot used in `EconomySnapshot.nodes`. Mirrors `EconNodeSignals` +/// in `simulation::economy` but is Serializable for wire transmission. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EconNodeSnapshot { + 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). + pub price_trend: f64, + /// Signal 3: trade flow volume proxy (Observable). + pub trade_flow_volume: f64, + /// Signal 4: number of corporations at this node (Observable). + pub corporate_presence: u32, + /// Signal 5: stockpile in weeks at current demand rate (Semi-private). + pub stockpile_weeks: f64, + /// Signal 6: supply vs. baseline supply from first tick (Private). + pub production_vs_baseline: f64, + /// Signal 7: ratio of formal to total activity (Meta-signal). + pub official_coverage_ratio: f64, +} + +/// Wire type for economy state snapshot (#822, D-181). +/// +/// Returned in `ObserverSnapshot.economy_snapshot` for one tick after an +/// `EconStateQuery` is processed. Contains signals for all commodities in the +/// queried system. None when economy is not loaded or system_id is unknown. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EconomySnapshot { + /// The system this snapshot covers. + pub system_id: String, + /// 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, + /// Signals for each commodity active in this system. + pub nodes: Vec, +} + +/// Effect type for `InjectEconEvent` debug command (#823, D-180). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum EconDebugEffect { + /// Multiply production capacity of the target node by `magnitude`. + /// < 1.0 = capacity shock; > 1.0 = capacity boost. + CapacityMultiplier, + /// Multiply productivity of all operations at the target node by `magnitude`. + ProductivityMultiplier, + /// Add `magnitude` to demand for all commodities at the target node. + DemandShock, + /// Apply a one-time exchange rate shock of `magnitude` to the FX rate. + ExchangeShock, +} + +/// Parameter selector for `SetEconParam` debug command (#823, D-178). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum EconParamKind { + /// Tâtonnement step size (α, D-178 Layer 2). Default: 0.03. + TatonnementStep, + /// Trade flow damping factor (β, D-178). Default: 0.4. + DampingFactor, + /// Per-corridor friction override (not yet implemented in simulation). + #[allow(dead_code)] // Used by future corridor friction model (#TODO) + CorridorFriction { corridor_id: String }, +} + /// Whether the debug console is enabled (#580). /// /// Set at server startup. Cannot be toggled mid-session via IPC. @@ -941,6 +1036,8 @@ pub struct SnapshotBuffer { pub pending_debug_response: Option, /// Pending settings response, consumed once by `compute_observer_snapshot` (#627). pub pending_settings_response: Option, + /// Pending economy snapshot, consumed once by `compute_observer_snapshot` (#822). + pub pending_economy_response: Option, } #[cfg(test)] diff --git a/server/src/main.rs b/server/src/main.rs index 720ff8312..f60749df4 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -328,6 +328,7 @@ fn send_panic_error(app: &App, panic_msg: &str) { debug_response: None, current_ticker: None, settings_response: None, + economy_snapshot: None, sim_errors: vec![SimError { kind: SimErrorKind::Panic, message: format!("Simulation panic: {}", panic_msg), diff --git a/server/src/perception/observer/mod.rs b/server/src/perception/observer/mod.rs index 28ea21ab3..eff81176b 100644 --- a/server/src/perception/observer/mod.rs +++ b/server/src/perception/observer/mod.rs @@ -394,6 +394,9 @@ pub fn compute_observer_snapshot( None }; + // Consume pending economy snapshot for this tick (#822). + let economy_snapshot = buffer.pending_economy_response.take(); + // Consume pending save/load result for this tick (#553). let save_result = buffer.pending_save_result.take(); @@ -489,6 +492,7 @@ pub fn compute_observer_snapshot( debug_response, current_ticker, settings_response, + economy_snapshot, }); } diff --git a/server/src/simulation/economy.rs b/server/src/simulation/economy.rs new file mode 100644 index 000000000..c3107b6c3 --- /dev/null +++ b/server/src/simulation/economy.rs @@ -0,0 +1,382 @@ +//! 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 + } + } +} diff --git a/server/src/simulation/input.rs b/server/src/simulation/input.rs index e6657c213..acfc01fdb 100644 --- a/server/src/simulation/input.rs +++ b/server/src/simulation/input.rs @@ -4,6 +4,7 @@ use crate::bridge::debug::DebugCommandBuffer; use crate::bridge::types::{FacingDirection, ObjectType, PlayerAction, PlayerInput}; +use crate::simulation::economy::EconQueryBuffer; use crate::knowledge::{EntityRegistry, StableId}; use crate::perception::vision_cone::{facing_from_delta, Facing}; use crate::settings::{SettingsCommand, SettingsCommandBuffer}; @@ -102,6 +103,7 @@ pub fn process_player_input( mut save_load: Option>, mut debug_cmd_buffer: Option>, mut settings_cmd_buffer: Option>, + mut econ_query_buf: Option>, door_states: Query<&DoorState>, object_types: Query<&ObjectType>, ) { @@ -127,6 +129,7 @@ pub fn process_player_input( | PlayerAction::ChangeSetting { .. } | PlayerAction::RequestAllSettings | PlayerAction::DeleteSetting { .. } + | PlayerAction::EconStateQuery { .. } ) { continue; @@ -410,6 +413,13 @@ pub fn process_player_input( ); } } + PlayerAction::EconStateQuery { system_id } => { + if let Some(ref mut buf) = econ_query_buf { + buf.pending = Some(system_id); + } else { + tracing::debug!("EconStateQuery received but EconQueryBuffer not registered — economy not loaded"); + } + } } } diff --git a/server/src/simulation/mod.rs b/server/src/simulation/mod.rs index 81740c95b..c6f7d9ed0 100644 --- a/server/src/simulation/mod.rs +++ b/server/src/simulation/mod.rs @@ -8,6 +8,7 @@ pub mod chunk_streaming; pub mod contraband; pub mod conversation; pub mod dialogue; +pub mod economy; pub mod examine; pub mod follow; pub mod generator; @@ -166,6 +167,29 @@ impl Plugin for SimulationPlugin { // Initialize TickerPool with empty default; populated by ContentPlugin at Startup. app.init_resource::(); + // Economy simulation (#821, D-031) — loaded once at startup, no-op when DB absent. + // Uses seed 0 for now; will be threaded through StartupMessage world seed (#TODO). + if let Some((econ_sim, econ_state)) = economy::try_load_economy(0) { + app.insert_resource(econ_sim) + .insert_resource(econ_state); + } + // EconQueryBuffer: always registered so EconStateQuery PlayerActions are accepted + // even when the economy DB is absent (queries just produce no response). + app.init_resource::(); + // tick_economy_simulation + serve_econ_state_query use Option> — safe to + // register unconditionally. They no-op when EconSimResource / EconStateResource absent. + app.add_systems( + Update, + ( + economy::tick_economy_simulation + .after(time::advance_tick) + .before(crate::perception::observer::compute_observer_snapshot), + economy::serve_econ_state_query + .after(economy::tick_economy_simulation) + .before(crate::perception::observer::compute_observer_snapshot), + ), + ); + tracing::debug!("SimulationPlugin initialized"); } } diff --git a/tooling/econ-sim/Cargo.toml b/tooling/econ-sim/Cargo.toml index 67cb169d5..2e7da6ee8 100644 --- a/tooling/econ-sim/Cargo.toml +++ b/tooling/econ-sim/Cargo.toml @@ -4,6 +4,10 @@ version = "0.1.0" edition = "2021" description = "Settled Reach economics simulation — Layer 1 Leontief production + price adjustment" +[lib] +name = "econ_sim" +path = "src/lib.rs" + [[bin]] name = "econ-sim" path = "src/main.rs" diff --git a/tooling/econ-sim/src/agents.rs b/tooling/econ-sim/src/agents.rs index e6d95bae1..fffea5c74 100644 --- a/tooling/econ-sim/src/agents.rs +++ b/tooling/econ-sim/src/agents.rs @@ -15,64 +15,12 @@ //! Parameters apply to per-corp production in each simulation tick. //! Trade-layer archetype effects (corp-level bid/ask) are deferred to a //! future sprint when the event port (D-180) and IPC bridge are in place. +//! +//! The D-180 event port stubs previously in this file have been replaced by +//! the full implementation in `events.rs`. use std::collections::BTreeMap; -// --------------------------------------------------------------------------- -// EconEvent — D-180 event port stub (#809) -// --------------------------------------------------------------------------- - -/// Scope of nodes affected by an EconEvent. -#[derive(Debug, Clone)] -#[allow(dead_code)] -pub enum EventTarget { - Node(String), - NodeSet(Vec), - Corridor(String), - TradeRoute { from: String, to: String }, - Currency(String), - Commodity(String), -} - -/// Economic effect applied at the target. -#[derive(Debug, Clone)] -#[allow(dead_code)] -pub enum EventEffect { - ProductivityMultiplier(f64), - CapacityMultiplier(f64), - DemandShock(f64), - ExchangeShock(f64), -} - -/// Who can observe this event. -#[derive(Debug, Clone)] -#[allow(dead_code)] -pub enum EventVisibility { - Global, - Proximate(u32), // hops - Disclosed(Vec), // specific node IDs - Hidden, -} - -/// Economic event for injection into the simulation (D-180). -/// -/// No-op handler until the IPC bridge is in place. -#[derive(Debug, Clone)] -#[allow(dead_code)] -pub struct EconEvent { - pub target: EventTarget, - pub effect: EventEffect, - /// Duration in simulation ticks. 0 = instantaneous. - pub duration: u32, - pub visibility: EventVisibility, -} - -/// No-op event handler. Called from the tick loop once D-180 IPC is wired. -#[allow(dead_code)] -pub fn handle_event(_event: &EconEvent) { - // No-op: event port not yet connected (D-180). -} - // --------------------------------------------------------------------------- // Archetype enum // --------------------------------------------------------------------------- diff --git a/tooling/econ-sim/src/currency.rs b/tooling/econ-sim/src/currency.rs index 9c83bd36e..fac45e245 100644 --- a/tooling/econ-sim/src/currency.rs +++ b/tooling/econ-sim/src/currency.rs @@ -134,6 +134,16 @@ impl CurrencyState { self.net_cross_zone_flow = 0.0; // reset accumulator for next tick } + /// Apply an additive exchange rate delta from an `ExchangeShock` event (D-180). + /// + /// The result is clamped to the hard bounds `[FX_RATE_MIN, FX_RATE_MAX]`. + pub fn apply_exchange_shock(&mut self, delta: f64) { + if delta != 0.0 { + self.tractus_mark_rate = + (self.tractus_mark_rate + delta).clamp(FX_RATE_MIN, FX_RATE_MAX); + } + } + /// Transport cost factor from `from_zone` to `to_zone`. /// /// Cross-zone (TRACTUS ↔ MARK) incurs an additional 3% friction. diff --git a/tooling/econ-sim/src/events.rs b/tooling/econ-sim/src/events.rs new file mode 100644 index 000000000..43d7f4864 --- /dev/null +++ b/tooling/econ-sim/src/events.rs @@ -0,0 +1,375 @@ +//! D-180: Event input port for the economics simulation. +//! +//! External disruptions enter the simulation through `EconEvent` structs +//! pushed into an `EventPort`. Active events are applied each tick via +//! `compute_modifiers()`, which builds combined multiplier maps consumed +//! by the simulation step. +//! +//! ## Lifecycle +//! +//! ```text +//! port.activate_scheduled(tick) // inject any events due this tick +//! let mods = port.compute_modifiers(&economy) +//! step_inner(..., &mods) // apply production/demand/capacity mods +//! currency.apply_exchange_shock(mods.exchange_shock) +//! port.advance_remaining() // decrement and expire finished events +//! ``` +//! +//! ## Visibility modes (D-180) +//! +//! Phase 2 exercises `Global` and `Proximate` only. +//! `Hidden` is implemented but not exercised until the player inspect verb +//! exists (Phase 3). +//! +//! ## Economics is a receiver, not an emitter (D-180) +//! +//! The economics layer accepts events; it does NOT generate them. +//! Drama comes from the storyteller, political, or disaster layers. + +use std::collections::BTreeMap; + +use crate::db::Economy; + +// --------------------------------------------------------------------------- +// Event types (D-180) +// --------------------------------------------------------------------------- + +/// The scope of nodes affected by an economic event. +#[derive(Debug, Clone)] +pub enum EconEventTarget { + /// A single market node (system_id). + Node(String), + /// An explicit set of market nodes. + // Used by debug command handler (#823) and storyteller layer (#821+): + #[allow(dead_code)] + NodeSet(Vec), + /// All systems in a named cultural corridor. + // Used by storyteller / disaster layer (#821+): + #[allow(dead_code)] + Corridor(String), + /// Both endpoints of a gate link (directed: from → to). + // Used by trade disruption events (#821+): + #[allow(dead_code)] + TradeRoute { from: String, to: String }, + /// All systems in a currency zone (`"TRACTUS_PRIMARY"`, `"MARK_PRIMARY"`, `"MIXED"`). + // Used by currency-zone events (#821+): + #[allow(dead_code)] + Currency(String), + /// A specific commodity at all active nodes. + // Used by supply chain disruption events (#821+): + #[allow(dead_code)] + Commodity(String), +} + +/// The economic effect applied at the targeted nodes. +#[derive(Debug, Clone)] +pub enum EconEventEffect { + /// Multiply per-corp productivity (`prod.for_tier()`) by this factor. + /// `< 1.0` = disruption; `> 1.0` = boom. + // Used by #823 (debug commands) and storyteller events (#821+): + #[allow(dead_code)] + ProductivityMultiplier(f64), + /// Multiply production capacity (`BASELINE_CAPACITY`) by this factor. + /// `< 1.0` = capacity constraint; `> 1.0` = expanded capacity. + CapacityMultiplier(f64), + /// Multiply consumer demand by this factor at affected nodes. + /// `> 1.0` = demand spike; `< 1.0` = demand collapse. + // Used by #823 (debug commands) and storyteller events (#821+): + #[allow(dead_code)] + DemandShock(f64), + /// Additive delta applied to the Tractus/Mark exchange rate each tick. + /// Positive = Tractus strengthens (Mark weakens). + // Used by #823 (debug commands) and currency events (#821+): + #[allow(dead_code)] + ExchangeShock(f64), +} + +/// Who can observe this event (D-180 visibility modes). +#[derive(Debug, Clone)] +pub enum EconEventVisibility { + /// All actors know immediately. + Global, + /// Visible to nodes within N gate hops of the target. + // Used by Proximate event propagation (#821+): + #[allow(dead_code)] + Proximate(u32), + /// Only the named system IDs are informed. + // Used by intel/corporate disclosure events (#821+): + #[allow(dead_code)] + Disclosed(Vec), + /// Creates observable price effects but no knowledge flag. + /// No actor knows the cause. Phase 3 only — requires player inspect verb. + // Used by hidden disruption events (Phase 3, #831+): + #[allow(dead_code)] + Hidden, +} + +/// A typed economic disruption event (D-180). +/// +/// Push into an `EventPort` via `push()` (immediate) or `push_at()` (scheduled). +#[derive(Debug, Clone)] +pub struct EconEvent { + pub target: EconEventTarget, + pub effect: EconEventEffect, + /// Duration in simulation ticks (clamped to ≥ 1 on push). + pub duration: u32, + /// Who can observe this event. Used by the information boundary system (#822+). + #[allow(dead_code)] + pub visibility: EconEventVisibility, +} + +// --------------------------------------------------------------------------- +// EventPort +// --------------------------------------------------------------------------- + +struct ActiveEvent { + event: EconEvent, + /// Ticks remaining before this event expires. + remaining_ticks: u32, +} + +struct ScheduledEvent { + /// The simulation tick at which to activate this event. + inject_at_tick: u32, + event: EconEvent, +} + +/// The event input port — a typed queue of active and scheduled disruptions. +/// +/// **Usage in the tick loop:** +/// 1. Call `activate_scheduled(tick)` at the START of each tick. +/// 2. Call `compute_modifiers(&economy)` to get this tick's modifier maps. +/// 3. Pass the modifiers to `step_inner`. +/// 4. Call `advance_remaining()` at the END of each tick. +#[derive(Default)] +pub struct EventPort { + active: Vec, + scheduled: Vec, +} + +impl EventPort { + pub fn new() -> Self { + Self::default() + } + + /// Inject an event that starts at the current tick. + pub fn push(&mut self, event: EconEvent) { + let remaining = event.duration.max(1); + self.active.push(ActiveEvent { + event, + remaining_ticks: remaining, + }); + } + + /// Schedule an event to be injected at a specific simulation tick. + /// + /// The event becomes active at the START of `inject_at_tick`, before + /// `compute_modifiers` is called for that tick. + pub fn push_at(&mut self, inject_at_tick: u32, event: EconEvent) { + self.scheduled.push(ScheduledEvent { + inject_at_tick, + event, + }); + } + + /// Activate any events scheduled for `current_tick`. + /// + /// Call at the START of each tick, before `compute_modifiers`. + pub fn activate_scheduled(&mut self, current_tick: u32) { + // Stable Rust: partition scheduled list manually (no drain_filter). + let mut still_pending = Vec::new(); + let mut to_activate = Vec::new(); + for se in self.scheduled.drain(..) { + if se.inject_at_tick <= current_tick { + to_activate.push(se.event); + } else { + still_pending.push(se); + } + } + self.scheduled = still_pending; + for event in to_activate { + self.push(event); + } + } + + /// Decrement remaining ticks and remove events that have expired. + /// + /// Call at the END of each tick, after effects have been applied. + pub fn advance_remaining(&mut self) { + for ae in &mut self.active { + ae.remaining_ticks = ae.remaining_ticks.saturating_sub(1); + } + self.active.retain(|ae| ae.remaining_ticks > 0); + } + + /// True when no events are active or scheduled. + // Used by tick loop optimization in #821: + #[allow(dead_code)] + pub fn is_empty(&self) -> bool { + self.active.is_empty() && self.scheduled.is_empty() + } + + /// Compute combined modifier maps from all currently active events. + /// + /// Multiple overlapping events compound multiplicatively for `f64` effects. + /// Exchange shocks accumulate additively. + pub fn compute_modifiers(&self, economy: &Economy) -> EventModifiers { + let mut mods = EventModifiers::default(); + + for ae in &self.active { + let commodity_filter: Option = match &ae.event.target { + EconEventTarget::Commodity(cid) => Some(cid.clone()), + _ => None, + }; + let affected_nodes = resolve_target_nodes(economy, &ae.event.target); + + match ae.event.effect { + EconEventEffect::DemandShock(f) => { + for node_id in &affected_nodes { + apply_multiplier( + &mut mods.demand, + node_id, + &commodity_filter, + economy, + f, + ); + } + } + EconEventEffect::ProductivityMultiplier(f) => { + for node_id in &affected_nodes { + apply_multiplier( + &mut mods.productivity, + node_id, + &commodity_filter, + economy, + f, + ); + } + } + EconEventEffect::CapacityMultiplier(f) => { + for node_id in &affected_nodes { + apply_multiplier( + &mut mods.capacity, + node_id, + &commodity_filter, + economy, + f, + ); + } + } + EconEventEffect::ExchangeShock(delta) => { + mods.exchange_shock += delta; + } + } + } + + mods + } +} + +// --------------------------------------------------------------------------- +// EventModifiers +// --------------------------------------------------------------------------- + +/// Combined per-tick modifiers from all currently active events. +/// +/// Missing entries default to `1.0` (multiplicative identity) via the `_for` methods. +#[derive(Debug, Default)] +pub struct EventModifiers { + /// `(system_id, commodity_id)` → combined demand multiplier (product of all active shocks). + pub demand: BTreeMap<(String, String), f64>, + /// `(system_id, commodity_id)` → combined productivity multiplier. + pub productivity: BTreeMap<(String, String), f64>, + /// `(system_id, commodity_id)` → combined capacity multiplier. + pub capacity: BTreeMap<(String, String), f64>, + /// Additive delta applied to the Tractus/Mark exchange rate this tick. + pub exchange_shock: f64, +} + +impl EventModifiers { + /// Combined demand multiplier for `(system_id, commodity_id)`. + /// Returns `1.0` if no active demand shock targets this pair. + pub fn demand_for(&self, system_id: &str, commodity_id: &str) -> f64 { + *self + .demand + .get(&(system_id.to_string(), commodity_id.to_string())) + .unwrap_or(&1.0) + } + + /// Combined productivity multiplier for `(system_id, commodity_id)`. + pub fn productivity_for(&self, system_id: &str, commodity_id: &str) -> f64 { + *self + .productivity + .get(&(system_id.to_string(), commodity_id.to_string())) + .unwrap_or(&1.0) + } + + /// Combined capacity multiplier for `(system_id, commodity_id)`. + pub fn capacity_for(&self, system_id: &str, commodity_id: &str) -> f64 { + *self + .capacity + .get(&(system_id.to_string(), commodity_id.to_string())) + .unwrap_or(&1.0) + } + + /// True when no events are affecting this tick (all maps empty, no exchange shock). + // Used by tick loop fast path in #821: + #[allow(dead_code)] + pub fn is_identity(&self) -> bool { + self.demand.is_empty() + && self.productivity.is_empty() + && self.capacity.is_empty() + && self.exchange_shock == 0.0 + } +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/// Resolve which system IDs are affected by the given event target. +fn resolve_target_nodes(economy: &Economy, target: &EconEventTarget) -> Vec { + match target { + EconEventTarget::Node(id) => vec![id.clone()], + EconEventTarget::NodeSet(ids) => ids.clone(), + EconEventTarget::Corridor(corridor) => economy + .systems + .values() + .filter(|s| s.cultural_corridor.as_deref() == Some(corridor.as_str())) + .map(|s| s.system_id.clone()) + .collect(), + EconEventTarget::TradeRoute { from, to } => vec![from.clone(), to.clone()], + EconEventTarget::Currency(zone) => economy + .systems + .values() + .filter(|s| &s.currency_zone == zone) + .map(|s| s.system_id.clone()) + .collect(), + // Commodity target: effect applies to this commodity at all active nodes. + // The commodity filter is applied during apply_multiplier. + EconEventTarget::Commodity(_) => economy.systems.keys().cloned().collect(), + } +} + +/// Apply a multiplier to all `(node, commodity)` pairs matching the filter. +/// +/// If `commodity_filter` is `None`, applies to ALL commodities at `node_id`. +/// Multiple events compound multiplicatively. +fn apply_multiplier( + map: &mut BTreeMap<(String, String), f64>, + node_id: &str, + commodity_filter: &Option, + economy: &Economy, + factor: f64, +) { + let commodity_ids: Vec = match commodity_filter { + Some(cid) => vec![cid.clone()], + None => economy.commodities.iter().map(|c| c.id.clone()).collect(), + }; + for cid in commodity_ids { + let entry = map + .entry((node_id.to_string(), cid)) + .or_insert(1.0); + *entry *= factor; + } +} diff --git a/tooling/econ-sim/src/lib.rs b/tooling/econ-sim/src/lib.rs new file mode 100644 index 000000000..f259600d5 --- /dev/null +++ b/tooling/econ-sim/src/lib.rs @@ -0,0 +1,30 @@ +//! econ_sim — Settled Reach economics simulation library. +//! +//! Exposes the Layer 1+2+3 tâtonnement simulation as a reusable library crate. +//! The standalone `econ-sim` binary uses the same modules independently. +//! +//! ## Entry points +//! +//! - [`Simulation`] — stateful per-tick runner for server integration (#821). +//! Initialize once, call `step()` each economy tick. +//! +//! - [`model::run_with_events`] — batch runner (runs N ticks, returns TickRecords). +//! Used by the standalone binary and stability checks. +//! +//! ## Key decisions +//! +//! - D-178: Economic model architecture (Leontief + tâtonnement + agents) +//! - D-180: Event input port (`EconEvent`, `EventPort`) +//! - D-181: 7-signal vocabulary per active node + +pub mod agents; +pub mod currency; +pub mod db; +pub mod events; +pub mod model; +pub mod prng; +pub mod seed; +pub mod trade; + +mod sim; +pub use sim::Simulation; diff --git a/tooling/econ-sim/src/main.rs b/tooling/econ-sim/src/main.rs index 12c703e58..c77a4a98b 100644 --- a/tooling/econ-sim/src/main.rs +++ b/tooling/econ-sim/src/main.rs @@ -21,6 +21,7 @@ use clap::Parser; mod agents; mod currency; mod db; +mod events; mod model; mod output; mod prng; @@ -260,11 +261,11 @@ fn run_stability_checks( } // ----------------------------------------------------------------- - // Test 3: no-explosion check (price bounds over 1000-tick run) - // Note: this is NOT a D-179 shock injection test. Full shock-response - // testing (inject → cascade → recovery) requires D-180 event port. + // Test 3: D-179 shock response — inject supply shock, verify cascade + // and recovery within 200 ticks (D-179 Test 3, D-180 event port). // ----------------------------------------------------------------- - let (test3_pass, test3_note) = run_no_explosion_check(economy, &records); + let (test3_pass, test3_note) = + run_shock_response_test(economy, productivity, shadow, adjacency); // ----------------------------------------------------------------- // Test 4: cross-zone balance (skip if no MARK_PRIMARY systems) @@ -307,7 +308,7 @@ fn run_stability_checks( .unwrap_or_default() ); eprintln!( - "Test 3 (no-explosion check — price bounds over 1000 ticks): {} {}", + "Test 3 (shock response — D-180 CapacityMult event, recovery ≤200 ticks): {} {}", sym(test3_pass), test3_note ); @@ -327,60 +328,148 @@ fn run_stability_checks( } } -/// Verify no price explosions or negative prices in the 1000-tick run. +/// D-179 Test 3: shock response — inject supply disruption, verify cascade and recovery. /// -/// This is NOT a D-179 shock injection test. D-179 Test 3 requires deliberate -/// shock injection via the D-180 event port, which is not yet implemented. -/// This check validates the weaker property: the model does not produce -/// unbounded prices (>20× base) or negative prices over 1000 ticks. -fn run_no_explosion_check( +/// Protocol: +/// 1. Run WARMUP_TICKS with no events to establish a stable price baseline. +/// 2. At tick WARMUP_TICKS, inject a `CapacityMultiplier(0.1)` event on the +/// most active node for SHOCK_DURATION ticks (90% capacity reduction). +/// 3. Continue for RECOVERY_WINDOW ticks after the shock expires. +/// 4. Verify: no price explosion (>20× base) at any tick. +/// 5. Verify: all prices at end of recovery ≤ ±5% of the pre-shock baseline. +/// +/// A `CapacityMultiplier(0.1)` supply disruption is severe enough to deplete +/// stockpiles and propagate price signals to neighboring nodes (cascade), +/// while remaining recoverable within the 200-tick window (recovery). +fn run_shock_response_test( economy: &db::Economy, - records_1000: &[model::TickRecord], + productivity: &std::collections::BTreeMap<(String, String), seed::Productivity>, + shadow: ¤cy::ShadowEconomy, + adjacency: &std::collections::BTreeMap>, ) -> (bool, String) { - const PRICE_EXPLOSION_LIMIT: f64 = 20.0; // 20× base_price + use std::collections::BTreeMap; - // Check: no price > 20× base at any tick - let mut explosion_detected = false; - let mut explosion_worst = String::new(); - for r in records_1000 { + const WARMUP_TICKS: u32 = 100; + const SHOCK_DURATION: u32 = 50; + const RECOVERY_WINDOW: u32 = 200; + const RECOVERY_THRESHOLD: f64 = 0.05; // ±5% of pre-shock baseline + + // Pick the first active node (has corp presence) as the shock target + let shock_node = economy + .presences_by_system + .keys() + .next() + .cloned() + .or_else(|| { + economy + .systems + .values() + .find(|s| s.population > 0) + .map(|s| s.system_id.clone()) + }); + + let shock_node = match shock_node { + Some(n) => n, + None => return (true, "SKIP — no active nodes for shock test".to_string()), + }; + + // Schedule: inject 90% capacity disruption at tick WARMUP_TICKS + let mut port = events::EventPort::new(); + port.push_at( + WARMUP_TICKS, + events::EconEvent { + target: events::EconEventTarget::Node(shock_node.clone()), + effect: events::EconEventEffect::CapacityMultiplier(0.1), + duration: SHOCK_DURATION, + visibility: events::EconEventVisibility::Global, + }, + ); + + let total_ticks = WARMUP_TICKS + SHOCK_DURATION + RECOVERY_WINDOW; + let records = model::run_with_events( + economy, + productivity, + shadow, + adjacency, + total_ticks, + &mut port, + ); + + // Index records by (node_id, commodity_id, tick) for lookups + let baseline: BTreeMap<(String, String), f64> = records + .iter() + .filter(|r| r.tick == WARMUP_TICKS - 1) + .map(|r| ((r.node_id.clone(), r.commodity_id.clone()), r.price)) + .collect(); + + // Check 1: no price explosions or negatives at any tick + const PRICE_EXPLOSION_LIMIT: f64 = 20.0; + for r in &records { let base = economy .commodity_map .get(&r.commodity_id) .map_or(1.0, |c| c.base_price); if r.price > base * PRICE_EXPLOSION_LIMIT { - explosion_detected = true; - explosion_worst = format!( - "{}/{} price={:.1} base={:.1} ({:.0}×)", - r.node_id, - r.commodity_id, - r.price, - base, - r.price / base + return ( + false, + format!( + "price explosion at tick {}: {}/{} price={:.1} ({:.0}×base)", + r.tick, + r.node_id, + r.commodity_id, + r.price, + r.price / base + ), + ); + } + if r.price < 0.0 { + return ( + false, + format!( + "negative price at tick {}: {}/{} price={:.4}", + r.tick, r.node_id, r.commodity_id, r.price + ), ); } } - if explosion_detected { - return (false, format!("price explosion: {}", explosion_worst)); - } + // Check 2: prices at end of recovery window are within ±5% of pre-shock baseline + let recovery_end_tick = total_ticks - 1; + let recovery_prices: BTreeMap<(String, String), f64> = records + .iter() + .filter(|r| r.tick == recovery_end_tick) + .map(|r| ((r.node_id.clone(), r.commodity_id.clone()), r.price)) + .collect(); - // Check: no negative prices (should be clamped by model, verify here) - if let Some(r) = records_1000.iter().find(|r| r.price < 0.0) { - return ( - false, - format!( - "{}/{} price went negative: {}", - r.node_id, r.commodity_id, r.price - ), - ); + let mut worst_dev: f64 = 0.0; + let mut worst_key = String::new(); + + for ((node_id, commodity_id), &baseline_price) in &baseline { + if baseline_price < 1e-9 { + continue; + } + let key = (node_id.clone(), commodity_id.clone()); + if let Some(&recovery_price) = recovery_prices.get(&key) { + let dev = (recovery_price - baseline_price).abs() / baseline_price; + if dev > worst_dev { + worst_dev = dev; + worst_key = format!("{node_id}/{commodity_id}"); + } + } } + let pass = worst_dev <= RECOVERY_THRESHOLD; ( - true, + pass, format!( - "no explosions (>{:.0}× base), no negatives across {} records", - PRICE_EXPLOSION_LIMIT, - records_1000.len() + "CapacityMult(0.1)×{SHOCK_DURATION}t on {shock_node} at t={WARMUP_TICKS}, \ + max_dev={:.1}% at t={recovery_end_tick} (threshold ±5%){}", + worst_dev * 100.0, + if !worst_key.is_empty() { + format!(" worst: {worst_key}") + } else { + String::new() + } ), ) } diff --git a/tooling/econ-sim/src/model.rs b/tooling/econ-sim/src/model.rs index cfc5cd7fe..af599b5f1 100644 --- a/tooling/econ-sim/src/model.rs +++ b/tooling/econ-sim/src/model.rs @@ -1,19 +1,23 @@ //! Layer 1: Leontief production + consumption + price adjustment. //! Layer 2: Spatial price equilibrium via damped tâtonnement (D-178). +//! Layer 3: Corporate behavioral agents (D-178) — added in #809. //! //! Each system with economic activity (corp presence or population > 0) //! is an active market node. Goods flow along gate links when price //! differentials exceed transport costs (α=0.03, β=0.4). //! -//! Layer 3 (corporate behavioral agents) is added in #809. +//! Event port (D-180) — added in #810: +//! External disruptions enter via `EventPort` passed to `run_with_events`. +//! `run()` is the no-event fast path (delegates to `run_with_events`). //! -//! Reference: D-178 (Economic Model Architecture) +//! Reference: D-178 (Economic Model Architecture), D-180 (Event Input Port) use std::collections::BTreeMap; use crate::agents; use crate::currency::{CurrencyState, ShadowEconomy}; use crate::db::Economy; +use crate::events::{EventModifiers, EventPort}; use crate::seed::Productivity; use crate::trade; @@ -22,7 +26,8 @@ use crate::trade; // --------------------------------------------------------------------------- /// Price adjustment rate per tick (α=0.03, D-178 Layer 2). -const ALPHA: f64 = 0.03; +/// Exposed as pub so `Simulation` can default to it and `SetEconParam` can reset to it (#823). +pub const ALPHA: f64 = 0.03; /// Baseline production capacity per corp per tick (units/tick). const BASELINE_CAPACITY: f64 = 10.0; @@ -80,11 +85,10 @@ pub struct TickRecord { // Simulation // --------------------------------------------------------------------------- -/// Run the Layer 1+2 simulation for `ticks` ticks. +/// Run the Layer 1+2+3 simulation for `ticks` ticks (no external events). /// -/// Layer 1: Leontief production + consumption + stockpile update. -/// Layer 2: Damped tâtonnement trade flows along gate links (D-178). -/// Currency zone friction and exchange rate adjustment (D-171, D-172). +/// Fast path: delegates to `run_with_events` with an empty `EventPort`. +/// Use `run_with_events` when event injection is required (D-180 tests, debug). /// /// Returns a flat list of TickRecords (one per active node×commodity×tick). pub fn run( @@ -93,6 +97,36 @@ pub fn run( shadow: &ShadowEconomy, adjacency: &BTreeMap>, ticks: u32, +) -> Vec { + let mut port = EventPort::new(); + run_with_events(economy, productivity, shadow, adjacency, ticks, &mut port) +} + +/// Run the Layer 1+2+3 simulation with D-180 event injection. +/// +/// Layer 1: Leontief production + consumption + stockpile update. +/// Layer 2: Damped tâtonnement trade flows along gate links (D-178). +/// Currency zone friction and exchange rate adjustment (D-171, D-172). +/// Layer 3: Corporate behavioral archetypes (D-178). +/// Events: external disruptions applied each tick (D-180). +/// +/// Tick loop invariant: +/// 1. `events.activate_scheduled(tick)` — inject events due this tick. +/// 2. `events.compute_modifiers()` → modifier maps for this tick. +/// 3. `step_inner` — production + demand + price adjustment with modifiers. +/// 4. `currency.apply_exchange_shock` — apply any exchange shock from events. +/// 5. `trade_step` — inter-node trade flows. +/// 6. `currency.update_rate` — FX adjustment from net cross-zone flow. +/// 7. `events.advance_remaining` — decrement and expire finished events. +/// +/// Returns a flat list of TickRecords (one per active node×commodity×tick). +pub fn run_with_events( + economy: &Economy, + productivity: &BTreeMap<(String, String), Productivity>, + shadow: &ShadowEconomy, + adjacency: &BTreeMap>, + ticks: u32, + events: &mut EventPort, ) -> Vec { let archetypes = agents::build_archetype_map(economy.corp_archetype_data.clone()); let mut nodes = init_nodes(economy); @@ -100,10 +134,18 @@ pub fn run( let mut records = Vec::new(); for tick in 0..ticks { - step(economy, productivity, shadow, &archetypes, &mut nodes); - trade::trade_step(economy, &mut nodes, adjacency, &mut currency); + // Activate any events scheduled for this tick (D-180) + events.activate_scheduled(tick); + + let mods = events.compute_modifiers(economy); + step_inner(economy, productivity, shadow, &archetypes, &mut nodes, &mods, ALPHA); + currency.apply_exchange_shock(mods.exchange_shock); + trade::trade_step(economy, &mut nodes, adjacency, &mut currency, trade::BETA); currency.update_rate(); + // Expire events that have completed their duration + events.advance_remaining(); + let fx_rate = currency.tractus_mark_rate; for node in nodes.values() { let node_shadow = shadow @@ -133,7 +175,11 @@ pub fn run( // Initialization // --------------------------------------------------------------------------- -fn init_nodes(economy: &Economy) -> BTreeMap { +/// Initialize node states for all active systems (corp presence or population > 0). +/// +/// Public for use by [`crate::sim::Simulation`] and external callers that need +/// a stateful simulation runner rather than the batch `run_with_events` API. +pub fn init_nodes(economy: &Economy) -> BTreeMap { let mut nodes: BTreeMap = BTreeMap::new(); // Activate nodes that have corp presence or non-zero population @@ -199,12 +245,20 @@ fn base_population_demand(population: i64, tier: &str) -> f64 { /// At 100% intensity, shadow goods meet up to this fraction of demand. const SHADOW_DEMAND_COVERAGE: f64 = 0.30; -fn step( +/// Single simulation tick: Layer 1 production + demand + price adjustment. +/// +/// `event_mods` carries per-(node, commodity) multipliers from active D-180 events. +/// Pass `&EventModifiers::default()` when no events are active. +/// +/// Public for use by [`crate::sim::Simulation`] and external stateful runners. +pub fn step_inner( economy: &Economy, productivity: &BTreeMap<(String, String), Productivity>, shadow: &ShadowEconomy, archetypes: &BTreeMap, nodes: &mut BTreeMap, + event_mods: &EventModifiers, + alpha: f64, ) { // Process each active node independently (Layer 1: no inter-system trade) let system_ids: Vec = nodes.keys().cloned().collect(); @@ -247,8 +301,10 @@ fn step( .map(|a| a.params()) .unwrap_or_else(|| agents::Archetype::Producer.params()); - // Effective baseline = BASELINE_CAPACITY scaled by archetype - let effective_capacity = BASELINE_CAPACITY * arch_params.production_scale; + // D-180: capacity multiplier from active events (1.0 if no event) + let cap_mult = event_mods.capacity_for(system_id.as_str(), &primary_op); + // Effective baseline = BASELINE_CAPACITY scaled by archetype and event + let effective_capacity = BASELINE_CAPACITY * arch_params.production_scale * cap_mult; // Determine the tier of the primary_operation commodity let tier = economy @@ -259,7 +315,10 @@ fn step( if tier == "raw" { // Raw materials: direct extraction — no chain inputs required (D-177). - let gross_output = effective_capacity * prod.extraction_rate; + // D-180: productivity multiplier from active events (1.0 if no event) + let prod_mult_event = + event_mods.productivity_for(system_id.as_str(), &primary_op); + let gross_output = effective_capacity * prod.extraction_rate * prod_mult_event; // Monopolist withholds a fraction of output let net_output = gross_output * (1.0 - arch_params.supply_withheld); if let Some(state) = node.commodities.get_mut(&primary_op) { @@ -289,10 +348,15 @@ fn step( } } - // Apply productivity multiplier + // Apply productivity multipliers (seeded + event) let prod_mult = prod.for_tier(&chain_output_tier(economy, chain)); - let gross_output = - effective_capacity * chain.output_quantity * capacity_fraction * prod_mult; + let prod_mult_event = event_mods + .productivity_for(system_id.as_str(), &chain.output_commodity_id); + let gross_output = effective_capacity + * chain.output_quantity + * capacity_fraction + * prod_mult + * prod_mult_event; let net_output = gross_output * (1.0 - arch_params.supply_withheld); // Consume inputs (Leontief: fixed-coefficient deduction) @@ -319,7 +383,7 @@ fn step( .commodity_map .get(&primary_op) .map_or(1.0, |c| c.base_price); - let nudge = base_price * arch_params.price_premium * ALPHA; + let nudge = base_price * arch_params.price_premium * alpha; state.price = (state.price + nudge).clamp(base_price * 0.05, base_price * 20.0); } } @@ -331,6 +395,8 @@ fn step( // // Shadow economy (D-174): shadow goods satisfy a fraction of formal demand, // reducing formal-sector stockpile consumption proportionally. + // + // D-180: DemandShock events multiply demand further (or compress it). let shadow_intensity = shadow.intensity.get(system_id).copied().unwrap_or(0.0); let shadow_coverage = shadow_intensity * SHADOW_DEMAND_COVERAGE; @@ -338,7 +404,7 @@ fn step( let base_demand = base_population_demand(system_info.population, &commodity.tier); // D-186/D-188: reduce fusion_fuel utility demand if gate energy is connected - let raw_demand = if commodity.id == "fusion_fuel" + let gate_reduced = if commodity.id == "fusion_fuel" && system_info.gate_energy_connected && commodity.tier != "raw" { @@ -347,8 +413,11 @@ fn step( base_demand }; + // D-180: demand shock multiplier from active events (1.0 if no event) + let demand_mult = event_mods.demand_for(system_id.as_str(), &commodity.id); + // Shadow economy reduces formal-sector consumption (some demand met off-books) - let demand = raw_demand * (1.0 - shadow_coverage); + let demand = gate_reduced * demand_mult * (1.0 - shadow_coverage); if let Some(state) = node.commodities.get_mut(&commodity.id) { state.demand = demand; @@ -383,7 +452,7 @@ fn step( }; state.price = - (state.price * (1.0 - ALPHA * excess)).clamp(base_price * 0.05, base_price * 20.0); + (state.price * (1.0 - alpha * excess)).clamp(base_price * 0.05, base_price * 20.0); } } } diff --git a/tooling/econ-sim/src/sim.rs b/tooling/econ-sim/src/sim.rs new file mode 100644 index 000000000..5cf07847a --- /dev/null +++ b/tooling/econ-sim/src/sim.rs @@ -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>, + archetypes: BTreeMap, + pub nodes: BTreeMap, + 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 { + 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 { + 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) -> ¤cy::ShadowEconomy { + &self.shadow + } +} diff --git a/tooling/econ-sim/src/trade.rs b/tooling/econ-sim/src/trade.rs index 15b738f1e..a7ca6aa5a 100644 --- a/tooling/econ-sim/src/trade.rs +++ b/tooling/econ-sim/src/trade.rs @@ -27,7 +27,8 @@ const GATE_COST_PER_HOP: f64 = 0.08; /// Damping factor β (D-178): fraction of potential flow that actually moves /// per tick. Prevents cobweb oscillation. -const BETA: f64 = 0.4; +/// Exposed as pub so `Simulation` can default to it and `SetEconParam` can reset to it (#823). +pub const BETA: f64 = 0.4; /// Maximum fraction of a node's stockpile exported per tick via a single link. /// Limits shock propagation speed. @@ -73,6 +74,7 @@ pub fn trade_step( nodes: &mut BTreeMap, adjacency: &BTreeMap>, currency: &mut CurrencyState, + beta: f64, ) { // Collect pending flows before mutating (snapshot prices/stockpiles first) // (from_system, to_system, commodity_id, amount, cross_zone_tractus_to_mark) @@ -131,7 +133,7 @@ pub fn trade_step( // Damped flow capped at MAX_EXPORT_FRACTION of exporter's stockpile let max_export = from_state.stockpile * MAX_EXPORT_FRACTION; - let flow = BETA * price_ratio * max_export; + let flow = beta * price_ratio * max_export; if flow > 1e-6 { flows.push((