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>
161 lines
6.1 KiB
Rust
161 lines
6.1 KiB
Rust
//! Currency zones, exchange rates, and shadow economy seeding (D-171, D-172, D-174).
|
||
//!
|
||
//! Three currencies (D-171):
|
||
//! Tractus — Reach-wide standard, numeraire for all simulation pricing.
|
||
//! Mark — Compact of Westphalia, ~3% conversion friction on cross-zone trade.
|
||
//! Sol — Earth legacy, modeled as shadow commodity (not a numeraire).
|
||
//!
|
||
//! Exchange rate: floating Tractus/Mark rate driven by net cross-zone trade balance.
|
||
//! Initialized at 1.0 (parity). Adjusted each tick by net flow signal × α_fx.
|
||
//!
|
||
//! Shadow economy (D-174): per-node intensity (0.0–1.0) seeded from political
|
||
//! zone, hop distance, gate topology, and currency zone.
|
||
|
||
use std::collections::BTreeMap;
|
||
|
||
use rand::SeedableRng;
|
||
use rand_chacha::ChaCha8Rng;
|
||
|
||
use crate::db::Economy;
|
||
use crate::prng::{derive_seed, standard_normal};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Constants
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Cross-zone conversion friction (D-172): applied when trade crosses
|
||
/// TRACTUS_PRIMARY ↔ MARK_PRIMARY boundaries.
|
||
pub const ZONE_FRICTION: f64 = 0.03;
|
||
|
||
/// Exchange rate adjustment rate per tick: how strongly net cross-zone
|
||
/// flow imbalance moves the Tractus/Mark rate.
|
||
const ALPHA_FX: f64 = 0.002;
|
||
|
||
/// Exchange rate bounds (D-171): hard clamp to prevent runaway divergence.
|
||
const FX_RATE_MIN: f64 = 0.5;
|
||
const FX_RATE_MAX: f64 = 2.0;
|
||
|
||
/// Maximum shadow economy intensity for dead-end topology bonus.
|
||
const DEAD_END_SHADOW_BONUS: f64 = 0.10;
|
||
|
||
/// Shadow economy noise standard deviation (log-normal jitter per node).
|
||
const SHADOW_NOISE_SIGMA: f64 = 0.08;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Shadow economy
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Per-node shadow economy intensity (0.0–1.0).
|
||
///
|
||
/// Seeds from: political zone, hop distance, gate topology, currency zone.
|
||
/// Reference bands (D-174): core ~0.0–0.2, mid-reach ~0.3–0.6, frontier ~0.6–0.9.
|
||
pub struct ShadowEconomy {
|
||
/// system_id → shadow intensity [0.0, 1.0]
|
||
pub intensity: BTreeMap<String, f64>,
|
||
}
|
||
|
||
pub fn seed_shadow_economy(economy: &Economy, run_seed: u64) -> ShadowEconomy {
|
||
let mut intensity = BTreeMap::new();
|
||
|
||
for (system_id, sys) in &economy.systems {
|
||
// Base from hop distance: clamp to [0.0, 0.6] range
|
||
let hop_base = (sys.hop_distance as f64 / 15.0).clamp(0.0, 0.6);
|
||
|
||
// Political zone modifier
|
||
let zone_mod = match sys.political_zone.as_deref() {
|
||
Some("institutional_core") => -0.25,
|
||
Some("earth_sphere") | Some("diplomatic_periphery") => -0.15,
|
||
Some("commercial_mid_reach") | Some("commercial_periphery") => 0.0,
|
||
Some("research_periphery") => 0.05,
|
||
Some("contested_frontier") | Some("deep_reach_isolate") => 0.15,
|
||
_ => 0.0,
|
||
};
|
||
|
||
// Gate topology: dead-end systems are harder to police
|
||
let topology_mod = if sys.gate_topology.as_deref() == Some("dead_end") {
|
||
DEAD_END_SHADOW_BONUS
|
||
} else {
|
||
0.0
|
||
};
|
||
|
||
// Currency zone: Compact friction drives principled shadow economy
|
||
let currency_mod = if sys.currency_zone == "MARK_PRIMARY" {
|
||
0.20
|
||
} else {
|
||
0.0
|
||
};
|
||
|
||
let base = (hop_base + zone_mod + topology_mod + currency_mod).clamp(0.0, 0.95);
|
||
|
||
// Per-node PRNG jitter (Box-Muller)
|
||
let node_seed = derive_seed(run_seed, system_id);
|
||
let mut rng = ChaCha8Rng::seed_from_u64(node_seed);
|
||
let noise = standard_normal(&mut rng) * SHADOW_NOISE_SIGMA;
|
||
|
||
let final_intensity = (base + noise).clamp(0.0, 1.0);
|
||
intensity.insert(system_id.clone(), final_intensity);
|
||
}
|
||
|
||
ShadowEconomy { intensity }
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Exchange rate
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Mutable exchange rate state updated each tick.
|
||
#[derive(Debug, Clone)]
|
||
pub struct CurrencyState {
|
||
/// Tractus/Mark rate: how many Marks 1 Tractus buys.
|
||
/// 1.0 = parity. >1.0 = Tractus stronger (Mark depreciated).
|
||
pub tractus_mark_rate: f64,
|
||
/// Net cross-zone Tractus→Mark commodity flow accumulated this tick.
|
||
/// Positive = Tractus zone exporting to Mark zone (Mark zone demand >).
|
||
pub net_cross_zone_flow: f64,
|
||
}
|
||
|
||
impl CurrencyState {
|
||
pub fn new() -> Self {
|
||
CurrencyState {
|
||
tractus_mark_rate: 1.0,
|
||
net_cross_zone_flow: 0.0,
|
||
}
|
||
}
|
||
|
||
/// Adjust exchange rate from net cross-zone trade imbalance.
|
||
///
|
||
/// If Tractus zone exports more than it imports from the Mark zone,
|
||
/// demand for Tractus rises → Tractus appreciates (rate increases).
|
||
pub fn update_rate(&mut self) {
|
||
// Positive net flow (Tractus→Mark) → Tractus stronger → rate rises
|
||
let adjustment = ALPHA_FX * self.net_cross_zone_flow;
|
||
self.tractus_mark_rate =
|
||
(self.tractus_mark_rate + adjustment).clamp(FX_RATE_MIN, FX_RATE_MAX);
|
||
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.
|
||
/// Sol (GJ 0, MIXED) neither adds nor removes friction.
|
||
pub fn zone_friction_factor(&self, from_zone: &str, to_zone: &str) -> f64 {
|
||
let cross_zone = (from_zone == "TRACTUS_PRIMARY" && to_zone == "MARK_PRIMARY")
|
||
|| (from_zone == "MARK_PRIMARY" && to_zone == "TRACTUS_PRIMARY");
|
||
if cross_zone {
|
||
ZONE_FRICTION
|
||
} else {
|
||
0.0
|
||
}
|
||
}
|
||
}
|