//! 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> { let mut adj: BTreeMap> = 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, 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) 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; } }