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>
166 lines
6.4 KiB
Rust
166 lines
6.4 KiB
Rust
//! Layer 2: Spatial price equilibrium via damped tâtonnement (D-178).
|
||
//!
|
||
//! Goods flow along direct gate links when price differentials exceed
|
||
//! transport costs. Multi-hop propagation occurs over multiple ticks as
|
||
//! direct-neighbor flows compound. β=0.4 dampens flows to prevent cobweb
|
||
//! oscillation.
|
||
//!
|
||
//! Currency zone friction (D-172): cross-zone (TRACTUS ↔ MARK) trade incurs
|
||
//! an additional 3% cost. Net cross-zone flow drives the floating exchange
|
||
//! rate adjustment (D-171).
|
||
//!
|
||
//! Gate links are bidirectional in the DB; `build_adjacency` builds the
|
||
//! full adjacency map directly from them.
|
||
|
||
use std::collections::BTreeMap;
|
||
|
||
use crate::currency::CurrencyState;
|
||
use crate::db::Economy;
|
||
use crate::model::NodeState;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Constants (D-178)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Transport cost per gate hop (midpoint of 5–12% range from D-178).
|
||
const GATE_COST_PER_HOP: f64 = 0.08;
|
||
|
||
/// Damping factor β (D-178): fraction of potential flow that actually moves
|
||
/// per tick. Prevents cobweb oscillation.
|
||
/// 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.
|
||
const MAX_EXPORT_FRACTION: f64 = 0.15;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Adjacency
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Build a direct-neighbor map from the gate link list.
|
||
///
|
||
/// DB stores links bidirectionally (A→B and B→A both present), so we
|
||
/// collect them as-is without adding reverse edges. The resulting map
|
||
/// covers all active market nodes that have at least one gate connection.
|
||
pub fn build_adjacency(economy: &Economy) -> BTreeMap<String, Vec<String>> {
|
||
let mut adj: BTreeMap<String, Vec<String>> = BTreeMap::new();
|
||
for link in &economy.gate_links {
|
||
adj.entry(link.from_system_id.clone())
|
||
.or_default()
|
||
.push(link.to_system_id.clone());
|
||
}
|
||
adj
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Trade step
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Apply one tick of inter-node trade flows along direct gate links.
|
||
///
|
||
/// For each directed gate link (A → B): if the price of a commodity in A,
|
||
/// after paying transport and currency costs, is still below the price in B,
|
||
/// goods flow from A to B. Cross-zone (TRACTUS ↔ MARK) links incur an
|
||
/// additional 3% conversion friction (D-172).
|
||
///
|
||
/// Net cross-zone flow is accumulated in `currency` to drive exchange rate
|
||
/// adjustment each tick (D-171).
|
||
///
|
||
/// All flows are computed from the pre-step state and applied atomically
|
||
/// to avoid order-dependent artifacts.
|
||
pub fn trade_step(
|
||
economy: &Economy,
|
||
nodes: &mut BTreeMap<String, NodeState>,
|
||
adjacency: &BTreeMap<String, Vec<String>>,
|
||
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)
|
||
let mut flows: Vec<(String, String, String, f64, f64)> = Vec::new();
|
||
|
||
for (from_id, neighbors) in adjacency {
|
||
let from_node = match nodes.get(from_id.as_str()) {
|
||
Some(n) => n,
|
||
None => continue,
|
||
};
|
||
let from_zone = economy
|
||
.systems
|
||
.get(from_id.as_str())
|
||
.map(|s| s.currency_zone.as_str())
|
||
.unwrap_or("TRACTUS_PRIMARY");
|
||
|
||
for to_id in neighbors {
|
||
let to_node = match nodes.get(to_id.as_str()) {
|
||
Some(n) => n,
|
||
None => continue,
|
||
};
|
||
let to_zone = economy
|
||
.systems
|
||
.get(to_id.as_str())
|
||
.map(|s| s.currency_zone.as_str())
|
||
.unwrap_or("TRACTUS_PRIMARY");
|
||
|
||
let gate_cost = 1.0 + GATE_COST_PER_HOP;
|
||
// zone_cost is a raw fraction (0.0 or 0.03); combine multiplicatively
|
||
let zone_cost = currency.zone_friction_factor(from_zone, to_zone);
|
||
let cost_factor = gate_cost * (1.0 + zone_cost);
|
||
|
||
// Sign: positive = Tractus zone exporting to Mark zone
|
||
let cross_zone_sign = if from_zone == "TRACTUS_PRIMARY" && to_zone == "MARK_PRIMARY" {
|
||
1.0_f64
|
||
} else if from_zone == "MARK_PRIMARY" && to_zone == "TRACTUS_PRIMARY" {
|
||
-1.0_f64
|
||
} else {
|
||
0.0_f64
|
||
};
|
||
|
||
for (commodity_id, from_state) in &from_node.commodities {
|
||
let to_state = match to_node.commodities.get(commodity_id) {
|
||
Some(s) => s,
|
||
None => continue,
|
||
};
|
||
|
||
// Only trade if profitable after full cost
|
||
let effective_price = from_state.price * cost_factor;
|
||
if effective_price >= to_state.price {
|
||
continue;
|
||
}
|
||
|
||
// Normalised price differential ∈ (0, 1) drives flow magnitude
|
||
let price_ratio = (to_state.price - effective_price) / to_state.price;
|
||
|
||
// 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;
|
||
|
||
if flow > 1e-6 {
|
||
flows.push((
|
||
from_id.clone(),
|
||
to_id.clone(),
|
||
commodity_id.clone(),
|
||
flow,
|
||
cross_zone_sign * flow,
|
||
));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Apply flows and accumulate cross-zone net flow for exchange rate
|
||
for (from_id, to_id, commodity_id, amount, cross_zone_contrib) in flows {
|
||
if let Some(from_node) = nodes.get_mut(&from_id) {
|
||
if let Some(state) = from_node.commodities.get_mut(&commodity_id) {
|
||
state.stockpile = (state.stockpile - amount).max(0.0);
|
||
}
|
||
}
|
||
if let Some(to_node) = nodes.get_mut(&to_id) {
|
||
if let Some(state) = to_node.commodities.get_mut(&commodity_id) {
|
||
state.stockpile += amount;
|
||
}
|
||
}
|
||
currency.net_cross_zone_flow += cross_zone_contrib;
|
||
}
|
||
}
|