Files
settled-reach/tooling/econ-sim/src/trade.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

166 lines
6.4 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.
//! 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 512% 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;
}
}