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

161 lines
6.1 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.
//! 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.01.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.01.0).
///
/// Seeds from: political zone, hop distance, gate topology, currency zone.
/// Reference bands (D-174): core ~0.00.2, mid-reach ~0.30.6, frontier ~0.60.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
}
}
}