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>
This commit is contained in:
2026-04-10 13:13:52 +02:00
co-authored by Claude Sonnet 4.6
parent 9d9ea96be1
commit 5f4139bc9e
19 changed files with 1471 additions and 122 deletions
+4
View File
@@ -4,6 +4,10 @@ version = "0.1.0"
edition = "2021"
description = "Settled Reach economics simulation — Layer 1 Leontief production + price adjustment"
[lib]
name = "econ_sim"
path = "src/lib.rs"
[[bin]]
name = "econ-sim"
path = "src/main.rs"
+3 -55
View File
@@ -15,64 +15,12 @@
//! Parameters apply to per-corp production in each simulation tick.
//! Trade-layer archetype effects (corp-level bid/ask) are deferred to a
//! future sprint when the event port (D-180) and IPC bridge are in place.
//!
//! The D-180 event port stubs previously in this file have been replaced by
//! the full implementation in `events.rs`.
use std::collections::BTreeMap;
// ---------------------------------------------------------------------------
// EconEvent — D-180 event port stub (#809)
// ---------------------------------------------------------------------------
/// Scope of nodes affected by an EconEvent.
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub enum EventTarget {
Node(String),
NodeSet(Vec<String>),
Corridor(String),
TradeRoute { from: String, to: String },
Currency(String),
Commodity(String),
}
/// Economic effect applied at the target.
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub enum EventEffect {
ProductivityMultiplier(f64),
CapacityMultiplier(f64),
DemandShock(f64),
ExchangeShock(f64),
}
/// Who can observe this event.
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub enum EventVisibility {
Global,
Proximate(u32), // hops
Disclosed(Vec<String>), // specific node IDs
Hidden,
}
/// Economic event for injection into the simulation (D-180).
///
/// No-op handler until the IPC bridge is in place.
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct EconEvent {
pub target: EventTarget,
pub effect: EventEffect,
/// Duration in simulation ticks. 0 = instantaneous.
pub duration: u32,
pub visibility: EventVisibility,
}
/// No-op event handler. Called from the tick loop once D-180 IPC is wired.
#[allow(dead_code)]
pub fn handle_event(_event: &EconEvent) {
// No-op: event port not yet connected (D-180).
}
// ---------------------------------------------------------------------------
// Archetype enum
// ---------------------------------------------------------------------------
+10
View File
@@ -134,6 +134,16 @@ impl CurrencyState {
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.
+375
View File
@@ -0,0 +1,375 @@
//! D-180: Event input port for the economics simulation.
//!
//! External disruptions enter the simulation through `EconEvent` structs
//! pushed into an `EventPort`. Active events are applied each tick via
//! `compute_modifiers()`, which builds combined multiplier maps consumed
//! by the simulation step.
//!
//! ## Lifecycle
//!
//! ```text
//! port.activate_scheduled(tick) // inject any events due this tick
//! let mods = port.compute_modifiers(&economy)
//! step_inner(..., &mods) // apply production/demand/capacity mods
//! currency.apply_exchange_shock(mods.exchange_shock)
//! port.advance_remaining() // decrement and expire finished events
//! ```
//!
//! ## Visibility modes (D-180)
//!
//! Phase 2 exercises `Global` and `Proximate` only.
//! `Hidden` is implemented but not exercised until the player inspect verb
//! exists (Phase 3).
//!
//! ## Economics is a receiver, not an emitter (D-180)
//!
//! The economics layer accepts events; it does NOT generate them.
//! Drama comes from the storyteller, political, or disaster layers.
use std::collections::BTreeMap;
use crate::db::Economy;
// ---------------------------------------------------------------------------
// Event types (D-180)
// ---------------------------------------------------------------------------
/// The scope of nodes affected by an economic event.
#[derive(Debug, Clone)]
pub enum EconEventTarget {
/// A single market node (system_id).
Node(String),
/// An explicit set of market nodes.
// Used by debug command handler (#823) and storyteller layer (#821+):
#[allow(dead_code)]
NodeSet(Vec<String>),
/// All systems in a named cultural corridor.
// Used by storyteller / disaster layer (#821+):
#[allow(dead_code)]
Corridor(String),
/// Both endpoints of a gate link (directed: from → to).
// Used by trade disruption events (#821+):
#[allow(dead_code)]
TradeRoute { from: String, to: String },
/// All systems in a currency zone (`"TRACTUS_PRIMARY"`, `"MARK_PRIMARY"`, `"MIXED"`).
// Used by currency-zone events (#821+):
#[allow(dead_code)]
Currency(String),
/// A specific commodity at all active nodes.
// Used by supply chain disruption events (#821+):
#[allow(dead_code)]
Commodity(String),
}
/// The economic effect applied at the targeted nodes.
#[derive(Debug, Clone)]
pub enum EconEventEffect {
/// Multiply per-corp productivity (`prod.for_tier()`) by this factor.
/// `< 1.0` = disruption; `> 1.0` = boom.
// Used by #823 (debug commands) and storyteller events (#821+):
#[allow(dead_code)]
ProductivityMultiplier(f64),
/// Multiply production capacity (`BASELINE_CAPACITY`) by this factor.
/// `< 1.0` = capacity constraint; `> 1.0` = expanded capacity.
CapacityMultiplier(f64),
/// Multiply consumer demand by this factor at affected nodes.
/// `> 1.0` = demand spike; `< 1.0` = demand collapse.
// Used by #823 (debug commands) and storyteller events (#821+):
#[allow(dead_code)]
DemandShock(f64),
/// Additive delta applied to the Tractus/Mark exchange rate each tick.
/// Positive = Tractus strengthens (Mark weakens).
// Used by #823 (debug commands) and currency events (#821+):
#[allow(dead_code)]
ExchangeShock(f64),
}
/// Who can observe this event (D-180 visibility modes).
#[derive(Debug, Clone)]
pub enum EconEventVisibility {
/// All actors know immediately.
Global,
/// Visible to nodes within N gate hops of the target.
// Used by Proximate event propagation (#821+):
#[allow(dead_code)]
Proximate(u32),
/// Only the named system IDs are informed.
// Used by intel/corporate disclosure events (#821+):
#[allow(dead_code)]
Disclosed(Vec<String>),
/// Creates observable price effects but no knowledge flag.
/// No actor knows the cause. Phase 3 only — requires player inspect verb.
// Used by hidden disruption events (Phase 3, #831+):
#[allow(dead_code)]
Hidden,
}
/// A typed economic disruption event (D-180).
///
/// Push into an `EventPort` via `push()` (immediate) or `push_at()` (scheduled).
#[derive(Debug, Clone)]
pub struct EconEvent {
pub target: EconEventTarget,
pub effect: EconEventEffect,
/// Duration in simulation ticks (clamped to ≥ 1 on push).
pub duration: u32,
/// Who can observe this event. Used by the information boundary system (#822+).
#[allow(dead_code)]
pub visibility: EconEventVisibility,
}
// ---------------------------------------------------------------------------
// EventPort
// ---------------------------------------------------------------------------
struct ActiveEvent {
event: EconEvent,
/// Ticks remaining before this event expires.
remaining_ticks: u32,
}
struct ScheduledEvent {
/// The simulation tick at which to activate this event.
inject_at_tick: u32,
event: EconEvent,
}
/// The event input port — a typed queue of active and scheduled disruptions.
///
/// **Usage in the tick loop:**
/// 1. Call `activate_scheduled(tick)` at the START of each tick.
/// 2. Call `compute_modifiers(&economy)` to get this tick's modifier maps.
/// 3. Pass the modifiers to `step_inner`.
/// 4. Call `advance_remaining()` at the END of each tick.
#[derive(Default)]
pub struct EventPort {
active: Vec<ActiveEvent>,
scheduled: Vec<ScheduledEvent>,
}
impl EventPort {
pub fn new() -> Self {
Self::default()
}
/// Inject an event that starts at the current tick.
pub fn push(&mut self, event: EconEvent) {
let remaining = event.duration.max(1);
self.active.push(ActiveEvent {
event,
remaining_ticks: remaining,
});
}
/// Schedule an event to be injected at a specific simulation tick.
///
/// The event becomes active at the START of `inject_at_tick`, before
/// `compute_modifiers` is called for that tick.
pub fn push_at(&mut self, inject_at_tick: u32, event: EconEvent) {
self.scheduled.push(ScheduledEvent {
inject_at_tick,
event,
});
}
/// Activate any events scheduled for `current_tick`.
///
/// Call at the START of each tick, before `compute_modifiers`.
pub fn activate_scheduled(&mut self, current_tick: u32) {
// Stable Rust: partition scheduled list manually (no drain_filter).
let mut still_pending = Vec::new();
let mut to_activate = Vec::new();
for se in self.scheduled.drain(..) {
if se.inject_at_tick <= current_tick {
to_activate.push(se.event);
} else {
still_pending.push(se);
}
}
self.scheduled = still_pending;
for event in to_activate {
self.push(event);
}
}
/// Decrement remaining ticks and remove events that have expired.
///
/// Call at the END of each tick, after effects have been applied.
pub fn advance_remaining(&mut self) {
for ae in &mut self.active {
ae.remaining_ticks = ae.remaining_ticks.saturating_sub(1);
}
self.active.retain(|ae| ae.remaining_ticks > 0);
}
/// True when no events are active or scheduled.
// Used by tick loop optimization in #821:
#[allow(dead_code)]
pub fn is_empty(&self) -> bool {
self.active.is_empty() && self.scheduled.is_empty()
}
/// Compute combined modifier maps from all currently active events.
///
/// Multiple overlapping events compound multiplicatively for `f64` effects.
/// Exchange shocks accumulate additively.
pub fn compute_modifiers(&self, economy: &Economy) -> EventModifiers {
let mut mods = EventModifiers::default();
for ae in &self.active {
let commodity_filter: Option<String> = match &ae.event.target {
EconEventTarget::Commodity(cid) => Some(cid.clone()),
_ => None,
};
let affected_nodes = resolve_target_nodes(economy, &ae.event.target);
match ae.event.effect {
EconEventEffect::DemandShock(f) => {
for node_id in &affected_nodes {
apply_multiplier(
&mut mods.demand,
node_id,
&commodity_filter,
economy,
f,
);
}
}
EconEventEffect::ProductivityMultiplier(f) => {
for node_id in &affected_nodes {
apply_multiplier(
&mut mods.productivity,
node_id,
&commodity_filter,
economy,
f,
);
}
}
EconEventEffect::CapacityMultiplier(f) => {
for node_id in &affected_nodes {
apply_multiplier(
&mut mods.capacity,
node_id,
&commodity_filter,
economy,
f,
);
}
}
EconEventEffect::ExchangeShock(delta) => {
mods.exchange_shock += delta;
}
}
}
mods
}
}
// ---------------------------------------------------------------------------
// EventModifiers
// ---------------------------------------------------------------------------
/// Combined per-tick modifiers from all currently active events.
///
/// Missing entries default to `1.0` (multiplicative identity) via the `_for` methods.
#[derive(Debug, Default)]
pub struct EventModifiers {
/// `(system_id, commodity_id)` → combined demand multiplier (product of all active shocks).
pub demand: BTreeMap<(String, String), f64>,
/// `(system_id, commodity_id)` → combined productivity multiplier.
pub productivity: BTreeMap<(String, String), f64>,
/// `(system_id, commodity_id)` → combined capacity multiplier.
pub capacity: BTreeMap<(String, String), f64>,
/// Additive delta applied to the Tractus/Mark exchange rate this tick.
pub exchange_shock: f64,
}
impl EventModifiers {
/// Combined demand multiplier for `(system_id, commodity_id)`.
/// Returns `1.0` if no active demand shock targets this pair.
pub fn demand_for(&self, system_id: &str, commodity_id: &str) -> f64 {
*self
.demand
.get(&(system_id.to_string(), commodity_id.to_string()))
.unwrap_or(&1.0)
}
/// Combined productivity multiplier for `(system_id, commodity_id)`.
pub fn productivity_for(&self, system_id: &str, commodity_id: &str) -> f64 {
*self
.productivity
.get(&(system_id.to_string(), commodity_id.to_string()))
.unwrap_or(&1.0)
}
/// Combined capacity multiplier for `(system_id, commodity_id)`.
pub fn capacity_for(&self, system_id: &str, commodity_id: &str) -> f64 {
*self
.capacity
.get(&(system_id.to_string(), commodity_id.to_string()))
.unwrap_or(&1.0)
}
/// True when no events are affecting this tick (all maps empty, no exchange shock).
// Used by tick loop fast path in #821:
#[allow(dead_code)]
pub fn is_identity(&self) -> bool {
self.demand.is_empty()
&& self.productivity.is_empty()
&& self.capacity.is_empty()
&& self.exchange_shock == 0.0
}
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/// Resolve which system IDs are affected by the given event target.
fn resolve_target_nodes(economy: &Economy, target: &EconEventTarget) -> Vec<String> {
match target {
EconEventTarget::Node(id) => vec![id.clone()],
EconEventTarget::NodeSet(ids) => ids.clone(),
EconEventTarget::Corridor(corridor) => economy
.systems
.values()
.filter(|s| s.cultural_corridor.as_deref() == Some(corridor.as_str()))
.map(|s| s.system_id.clone())
.collect(),
EconEventTarget::TradeRoute { from, to } => vec![from.clone(), to.clone()],
EconEventTarget::Currency(zone) => economy
.systems
.values()
.filter(|s| &s.currency_zone == zone)
.map(|s| s.system_id.clone())
.collect(),
// Commodity target: effect applies to this commodity at all active nodes.
// The commodity filter is applied during apply_multiplier.
EconEventTarget::Commodity(_) => economy.systems.keys().cloned().collect(),
}
}
/// Apply a multiplier to all `(node, commodity)` pairs matching the filter.
///
/// If `commodity_filter` is `None`, applies to ALL commodities at `node_id`.
/// Multiple events compound multiplicatively.
fn apply_multiplier(
map: &mut BTreeMap<(String, String), f64>,
node_id: &str,
commodity_filter: &Option<String>,
economy: &Economy,
factor: f64,
) {
let commodity_ids: Vec<String> = match commodity_filter {
Some(cid) => vec![cid.clone()],
None => economy.commodities.iter().map(|c| c.id.clone()).collect(),
};
for cid in commodity_ids {
let entry = map
.entry((node_id.to_string(), cid))
.or_insert(1.0);
*entry *= factor;
}
}
+30
View File
@@ -0,0 +1,30 @@
//! econ_sim — Settled Reach economics simulation library.
//!
//! Exposes the Layer 1+2+3 tâtonnement simulation as a reusable library crate.
//! The standalone `econ-sim` binary uses the same modules independently.
//!
//! ## Entry points
//!
//! - [`Simulation`] — stateful per-tick runner for server integration (#821).
//! Initialize once, call `step()` each economy tick.
//!
//! - [`model::run_with_events`] — batch runner (runs N ticks, returns TickRecords).
//! Used by the standalone binary and stability checks.
//!
//! ## Key decisions
//!
//! - D-178: Economic model architecture (Leontief + tâtonnement + agents)
//! - D-180: Event input port (`EconEvent`, `EventPort`)
//! - D-181: 7-signal vocabulary per active node
pub mod agents;
pub mod currency;
pub mod db;
pub mod events;
pub mod model;
pub mod prng;
pub mod seed;
pub mod trade;
mod sim;
pub use sim::Simulation;
+130 -41
View File
@@ -21,6 +21,7 @@ use clap::Parser;
mod agents;
mod currency;
mod db;
mod events;
mod model;
mod output;
mod prng;
@@ -260,11 +261,11 @@ fn run_stability_checks(
}
// -----------------------------------------------------------------
// Test 3: no-explosion check (price bounds over 1000-tick run)
// Note: this is NOT a D-179 shock injection test. Full shock-response
// testing (inject → cascade → recovery) requires D-180 event port.
// Test 3: D-179 shock response — inject supply shock, verify cascade
// and recovery within 200 ticks (D-179 Test 3, D-180 event port).
// -----------------------------------------------------------------
let (test3_pass, test3_note) = run_no_explosion_check(economy, &records);
let (test3_pass, test3_note) =
run_shock_response_test(economy, productivity, shadow, adjacency);
// -----------------------------------------------------------------
// Test 4: cross-zone balance (skip if no MARK_PRIMARY systems)
@@ -307,7 +308,7 @@ fn run_stability_checks(
.unwrap_or_default()
);
eprintln!(
"Test 3 (no-explosion check — price bounds over 1000 ticks): {} {}",
"Test 3 (shock response — D-180 CapacityMult event, recovery ≤200 ticks): {} {}",
sym(test3_pass),
test3_note
);
@@ -327,60 +328,148 @@ fn run_stability_checks(
}
}
/// Verify no price explosions or negative prices in the 1000-tick run.
/// D-179 Test 3: shock response — inject supply disruption, verify cascade and recovery.
///
/// This is NOT a D-179 shock injection test. D-179 Test 3 requires deliberate
/// shock injection via the D-180 event port, which is not yet implemented.
/// This check validates the weaker property: the model does not produce
/// unbounded prices (>20× base) or negative prices over 1000 ticks.
fn run_no_explosion_check(
/// Protocol:
/// 1. Run WARMUP_TICKS with no events to establish a stable price baseline.
/// 2. At tick WARMUP_TICKS, inject a `CapacityMultiplier(0.1)` event on the
/// most active node for SHOCK_DURATION ticks (90% capacity reduction).
/// 3. Continue for RECOVERY_WINDOW ticks after the shock expires.
/// 4. Verify: no price explosion (>20× base) at any tick.
/// 5. Verify: all prices at end of recovery ≤ ±5% of the pre-shock baseline.
///
/// A `CapacityMultiplier(0.1)` supply disruption is severe enough to deplete
/// stockpiles and propagate price signals to neighboring nodes (cascade),
/// while remaining recoverable within the 200-tick window (recovery).
fn run_shock_response_test(
economy: &db::Economy,
records_1000: &[model::TickRecord],
productivity: &std::collections::BTreeMap<(String, String), seed::Productivity>,
shadow: &currency::ShadowEconomy,
adjacency: &std::collections::BTreeMap<String, Vec<String>>,
) -> (bool, String) {
const PRICE_EXPLOSION_LIMIT: f64 = 20.0; // 20× base_price
use std::collections::BTreeMap;
// Check: no price > 20× base at any tick
let mut explosion_detected = false;
let mut explosion_worst = String::new();
for r in records_1000 {
const WARMUP_TICKS: u32 = 100;
const SHOCK_DURATION: u32 = 50;
const RECOVERY_WINDOW: u32 = 200;
const RECOVERY_THRESHOLD: f64 = 0.05; // ±5% of pre-shock baseline
// Pick the first active node (has corp presence) as the shock target
let shock_node = economy
.presences_by_system
.keys()
.next()
.cloned()
.or_else(|| {
economy
.systems
.values()
.find(|s| s.population > 0)
.map(|s| s.system_id.clone())
});
let shock_node = match shock_node {
Some(n) => n,
None => return (true, "SKIP — no active nodes for shock test".to_string()),
};
// Schedule: inject 90% capacity disruption at tick WARMUP_TICKS
let mut port = events::EventPort::new();
port.push_at(
WARMUP_TICKS,
events::EconEvent {
target: events::EconEventTarget::Node(shock_node.clone()),
effect: events::EconEventEffect::CapacityMultiplier(0.1),
duration: SHOCK_DURATION,
visibility: events::EconEventVisibility::Global,
},
);
let total_ticks = WARMUP_TICKS + SHOCK_DURATION + RECOVERY_WINDOW;
let records = model::run_with_events(
economy,
productivity,
shadow,
adjacency,
total_ticks,
&mut port,
);
// Index records by (node_id, commodity_id, tick) for lookups
let baseline: BTreeMap<(String, String), f64> = records
.iter()
.filter(|r| r.tick == WARMUP_TICKS - 1)
.map(|r| ((r.node_id.clone(), r.commodity_id.clone()), r.price))
.collect();
// Check 1: no price explosions or negatives at any tick
const PRICE_EXPLOSION_LIMIT: f64 = 20.0;
for r in &records {
let base = economy
.commodity_map
.get(&r.commodity_id)
.map_or(1.0, |c| c.base_price);
if r.price > base * PRICE_EXPLOSION_LIMIT {
explosion_detected = true;
explosion_worst = format!(
"{}/{} price={:.1} base={:.1} ({:.0}×)",
r.node_id,
r.commodity_id,
r.price,
base,
r.price / base
return (
false,
format!(
"price explosion at tick {}: {}/{} price={:.1} ({:.0}×base)",
r.tick,
r.node_id,
r.commodity_id,
r.price,
r.price / base
),
);
}
if r.price < 0.0 {
return (
false,
format!(
"negative price at tick {}: {}/{} price={:.4}",
r.tick, r.node_id, r.commodity_id, r.price
),
);
}
}
if explosion_detected {
return (false, format!("price explosion: {}", explosion_worst));
}
// Check 2: prices at end of recovery window are within ±5% of pre-shock baseline
let recovery_end_tick = total_ticks - 1;
let recovery_prices: BTreeMap<(String, String), f64> = records
.iter()
.filter(|r| r.tick == recovery_end_tick)
.map(|r| ((r.node_id.clone(), r.commodity_id.clone()), r.price))
.collect();
// Check: no negative prices (should be clamped by model, verify here)
if let Some(r) = records_1000.iter().find(|r| r.price < 0.0) {
return (
false,
format!(
"{}/{} price went negative: {}",
r.node_id, r.commodity_id, r.price
),
);
let mut worst_dev: f64 = 0.0;
let mut worst_key = String::new();
for ((node_id, commodity_id), &baseline_price) in &baseline {
if baseline_price < 1e-9 {
continue;
}
let key = (node_id.clone(), commodity_id.clone());
if let Some(&recovery_price) = recovery_prices.get(&key) {
let dev = (recovery_price - baseline_price).abs() / baseline_price;
if dev > worst_dev {
worst_dev = dev;
worst_key = format!("{node_id}/{commodity_id}");
}
}
}
let pass = worst_dev <= RECOVERY_THRESHOLD;
(
true,
pass,
format!(
"no explosions (>{:.0}× base), no negatives across {} records",
PRICE_EXPLOSION_LIMIT,
records_1000.len()
"CapacityMult(0.1)×{SHOCK_DURATION}t on {shock_node} at t={WARMUP_TICKS}, \
max_dev={:.1}% at t={recovery_end_tick} (threshold ±5%){}",
worst_dev * 100.0,
if !worst_key.is_empty() {
format!(" worst: {worst_key}")
} else {
String::new()
}
),
)
}
+90 -21
View File
@@ -1,19 +1,23 @@
//! Layer 1: Leontief production + consumption + price adjustment.
//! Layer 2: Spatial price equilibrium via damped tâtonnement (D-178).
//! Layer 3: Corporate behavioral agents (D-178) — added in #809.
//!
//! Each system with economic activity (corp presence or population > 0)
//! is an active market node. Goods flow along gate links when price
//! differentials exceed transport costs (α=0.03, β=0.4).
//!
//! Layer 3 (corporate behavioral agents) is added in #809.
//! Event port (D-180) — added in #810:
//! External disruptions enter via `EventPort` passed to `run_with_events`.
//! `run()` is the no-event fast path (delegates to `run_with_events`).
//!
//! Reference: D-178 (Economic Model Architecture)
//! Reference: D-178 (Economic Model Architecture), D-180 (Event Input Port)
use std::collections::BTreeMap;
use crate::agents;
use crate::currency::{CurrencyState, ShadowEconomy};
use crate::db::Economy;
use crate::events::{EventModifiers, EventPort};
use crate::seed::Productivity;
use crate::trade;
@@ -22,7 +26,8 @@ use crate::trade;
// ---------------------------------------------------------------------------
/// Price adjustment rate per tick (α=0.03, D-178 Layer 2).
const ALPHA: f64 = 0.03;
/// Exposed as pub so `Simulation` can default to it and `SetEconParam` can reset to it (#823).
pub const ALPHA: f64 = 0.03;
/// Baseline production capacity per corp per tick (units/tick).
const BASELINE_CAPACITY: f64 = 10.0;
@@ -80,11 +85,10 @@ pub struct TickRecord {
// Simulation
// ---------------------------------------------------------------------------
/// Run the Layer 1+2 simulation for `ticks` ticks.
/// Run the Layer 1+2+3 simulation for `ticks` ticks (no external events).
///
/// Layer 1: Leontief production + consumption + stockpile update.
/// Layer 2: Damped tâtonnement trade flows along gate links (D-178).
/// Currency zone friction and exchange rate adjustment (D-171, D-172).
/// Fast path: delegates to `run_with_events` with an empty `EventPort`.
/// Use `run_with_events` when event injection is required (D-180 tests, debug).
///
/// Returns a flat list of TickRecords (one per active node×commodity×tick).
pub fn run(
@@ -93,6 +97,36 @@ pub fn run(
shadow: &ShadowEconomy,
adjacency: &BTreeMap<String, Vec<String>>,
ticks: u32,
) -> Vec<TickRecord> {
let mut port = EventPort::new();
run_with_events(economy, productivity, shadow, adjacency, ticks, &mut port)
}
/// Run the Layer 1+2+3 simulation with D-180 event injection.
///
/// Layer 1: Leontief production + consumption + stockpile update.
/// Layer 2: Damped tâtonnement trade flows along gate links (D-178).
/// Currency zone friction and exchange rate adjustment (D-171, D-172).
/// Layer 3: Corporate behavioral archetypes (D-178).
/// Events: external disruptions applied each tick (D-180).
///
/// Tick loop invariant:
/// 1. `events.activate_scheduled(tick)` — inject events due this tick.
/// 2. `events.compute_modifiers()` → modifier maps for this tick.
/// 3. `step_inner` — production + demand + price adjustment with modifiers.
/// 4. `currency.apply_exchange_shock` — apply any exchange shock from events.
/// 5. `trade_step` — inter-node trade flows.
/// 6. `currency.update_rate` — FX adjustment from net cross-zone flow.
/// 7. `events.advance_remaining` — decrement and expire finished events.
///
/// Returns a flat list of TickRecords (one per active node×commodity×tick).
pub fn run_with_events(
economy: &Economy,
productivity: &BTreeMap<(String, String), Productivity>,
shadow: &ShadowEconomy,
adjacency: &BTreeMap<String, Vec<String>>,
ticks: u32,
events: &mut EventPort,
) -> Vec<TickRecord> {
let archetypes = agents::build_archetype_map(economy.corp_archetype_data.clone());
let mut nodes = init_nodes(economy);
@@ -100,10 +134,18 @@ pub fn run(
let mut records = Vec::new();
for tick in 0..ticks {
step(economy, productivity, shadow, &archetypes, &mut nodes);
trade::trade_step(economy, &mut nodes, adjacency, &mut currency);
// Activate any events scheduled for this tick (D-180)
events.activate_scheduled(tick);
let mods = events.compute_modifiers(economy);
step_inner(economy, productivity, shadow, &archetypes, &mut nodes, &mods, ALPHA);
currency.apply_exchange_shock(mods.exchange_shock);
trade::trade_step(economy, &mut nodes, adjacency, &mut currency, trade::BETA);
currency.update_rate();
// Expire events that have completed their duration
events.advance_remaining();
let fx_rate = currency.tractus_mark_rate;
for node in nodes.values() {
let node_shadow = shadow
@@ -133,7 +175,11 @@ pub fn run(
// Initialization
// ---------------------------------------------------------------------------
fn init_nodes(economy: &Economy) -> BTreeMap<String, NodeState> {
/// Initialize node states for all active systems (corp presence or population > 0).
///
/// Public for use by [`crate::sim::Simulation`] and external callers that need
/// a stateful simulation runner rather than the batch `run_with_events` API.
pub fn init_nodes(economy: &Economy) -> BTreeMap<String, NodeState> {
let mut nodes: BTreeMap<String, NodeState> = BTreeMap::new();
// Activate nodes that have corp presence or non-zero population
@@ -199,12 +245,20 @@ fn base_population_demand(population: i64, tier: &str) -> f64 {
/// At 100% intensity, shadow goods meet up to this fraction of demand.
const SHADOW_DEMAND_COVERAGE: f64 = 0.30;
fn step(
/// Single simulation tick: Layer 1 production + demand + price adjustment.
///
/// `event_mods` carries per-(node, commodity) multipliers from active D-180 events.
/// Pass `&EventModifiers::default()` when no events are active.
///
/// Public for use by [`crate::sim::Simulation`] and external stateful runners.
pub fn step_inner(
economy: &Economy,
productivity: &BTreeMap<(String, String), Productivity>,
shadow: &ShadowEconomy,
archetypes: &BTreeMap<String, agents::Archetype>,
nodes: &mut BTreeMap<String, NodeState>,
event_mods: &EventModifiers,
alpha: f64,
) {
// Process each active node independently (Layer 1: no inter-system trade)
let system_ids: Vec<String> = nodes.keys().cloned().collect();
@@ -247,8 +301,10 @@ fn step(
.map(|a| a.params())
.unwrap_or_else(|| agents::Archetype::Producer.params());
// Effective baseline = BASELINE_CAPACITY scaled by archetype
let effective_capacity = BASELINE_CAPACITY * arch_params.production_scale;
// D-180: capacity multiplier from active events (1.0 if no event)
let cap_mult = event_mods.capacity_for(system_id.as_str(), &primary_op);
// Effective baseline = BASELINE_CAPACITY scaled by archetype and event
let effective_capacity = BASELINE_CAPACITY * arch_params.production_scale * cap_mult;
// Determine the tier of the primary_operation commodity
let tier = economy
@@ -259,7 +315,10 @@ fn step(
if tier == "raw" {
// Raw materials: direct extraction — no chain inputs required (D-177).
let gross_output = effective_capacity * prod.extraction_rate;
// D-180: productivity multiplier from active events (1.0 if no event)
let prod_mult_event =
event_mods.productivity_for(system_id.as_str(), &primary_op);
let gross_output = effective_capacity * prod.extraction_rate * prod_mult_event;
// Monopolist withholds a fraction of output
let net_output = gross_output * (1.0 - arch_params.supply_withheld);
if let Some(state) = node.commodities.get_mut(&primary_op) {
@@ -289,10 +348,15 @@ fn step(
}
}
// Apply productivity multiplier
// Apply productivity multipliers (seeded + event)
let prod_mult = prod.for_tier(&chain_output_tier(economy, chain));
let gross_output =
effective_capacity * chain.output_quantity * capacity_fraction * prod_mult;
let prod_mult_event = event_mods
.productivity_for(system_id.as_str(), &chain.output_commodity_id);
let gross_output = effective_capacity
* chain.output_quantity
* capacity_fraction
* prod_mult
* prod_mult_event;
let net_output = gross_output * (1.0 - arch_params.supply_withheld);
// Consume inputs (Leontief: fixed-coefficient deduction)
@@ -319,7 +383,7 @@ fn step(
.commodity_map
.get(&primary_op)
.map_or(1.0, |c| c.base_price);
let nudge = base_price * arch_params.price_premium * ALPHA;
let nudge = base_price * arch_params.price_premium * alpha;
state.price = (state.price + nudge).clamp(base_price * 0.05, base_price * 20.0);
}
}
@@ -331,6 +395,8 @@ fn step(
//
// Shadow economy (D-174): shadow goods satisfy a fraction of formal demand,
// reducing formal-sector stockpile consumption proportionally.
//
// D-180: DemandShock events multiply demand further (or compress it).
let shadow_intensity = shadow.intensity.get(system_id).copied().unwrap_or(0.0);
let shadow_coverage = shadow_intensity * SHADOW_DEMAND_COVERAGE;
@@ -338,7 +404,7 @@ fn step(
let base_demand = base_population_demand(system_info.population, &commodity.tier);
// D-186/D-188: reduce fusion_fuel utility demand if gate energy is connected
let raw_demand = if commodity.id == "fusion_fuel"
let gate_reduced = if commodity.id == "fusion_fuel"
&& system_info.gate_energy_connected
&& commodity.tier != "raw"
{
@@ -347,8 +413,11 @@ fn step(
base_demand
};
// D-180: demand shock multiplier from active events (1.0 if no event)
let demand_mult = event_mods.demand_for(system_id.as_str(), &commodity.id);
// Shadow economy reduces formal-sector consumption (some demand met off-books)
let demand = raw_demand * (1.0 - shadow_coverage);
let demand = gate_reduced * demand_mult * (1.0 - shadow_coverage);
if let Some(state) = node.commodities.get_mut(&commodity.id) {
state.demand = demand;
@@ -383,7 +452,7 @@ fn step(
};
state.price =
(state.price * (1.0 - ALPHA * excess)).clamp(base_price * 0.05, base_price * 20.0);
(state.price * (1.0 - alpha * excess)).clamp(base_price * 0.05, base_price * 20.0);
}
}
}
+156
View File
@@ -0,0 +1,156 @@
//! Stateful simulation runner for server integration (#821).
//!
//! [`Simulation`] wraps all simulation state (economy data, node states,
//! currency, events) and exposes a per-tick `step()` method. This is the
//! entry point for the game server's economy system, which advances one
//! economy tick per ECON_TICK_RATE game ticks (D-031).
//!
//! The batch `model::run_with_events` is retained for the CLI binary and
//! stability checks. Both share the same underlying `model::step_inner`.
use std::collections::BTreeMap;
use std::path::Path;
use crate::{agents, currency, db, events, model, seed, trade};
/// Stateful Settled Reach economics simulation.
///
/// Initialize with [`Simulation::load`] once at server startup.
/// Call [`Simulation::step`] once per economy tick.
pub struct Simulation {
pub economy: db::Economy,
productivity: BTreeMap<(String, String), seed::Productivity>,
shadow: currency::ShadowEconomy,
adjacency: BTreeMap<String, Vec<String>>,
archetypes: BTreeMap<String, agents::Archetype>,
pub nodes: BTreeMap<String, model::NodeState>,
currency_state: currency::CurrencyState,
/// The event input port (D-180). Push events here; they are consumed
/// on the next `step()` call.
pub events: events::EventPort,
/// Number of economy ticks processed so far.
tick: u64,
/// Tâtonnement step size (α). Runtime-tunable via SetEconParam (#823).
/// Default: `model::ALPHA` (0.03).
pub alpha: f64,
/// Trade flow damping factor (β). Runtime-tunable via SetEconParam (#823).
/// Default: `trade::BETA` (0.4).
pub beta: f64,
}
impl Simulation {
/// Load economy data from `db_path` and initialize the simulation.
///
/// `run_seed` is the per-run PRNG seed for productivity seeding (D-176).
/// This is typically the game's world seed from `StartupMessage`.
///
/// The DB is opened once and the loaded data stored in memory.
/// Do NOT call this per tick.
pub fn load(db_path: &Path, run_seed: u64) -> Result<Self, String> {
let db_pathbuf = db_path.to_path_buf();
if !db_path.exists() {
return Err(format!("economy DB not found: {}", db_path.display()));
}
let conn = db::open_db(&db_pathbuf);
let economy = db::load_economy(&conn);
let productivity = seed::seed_all_productivity(&economy, run_seed);
let shadow = currency::seed_shadow_economy(&economy, run_seed);
let adjacency = trade::build_adjacency(&economy);
let archetypes = agents::build_archetype_map(economy.corp_archetype_data.clone());
let nodes = model::init_nodes(&economy);
Ok(Simulation {
economy,
productivity,
shadow,
adjacency,
archetypes,
nodes,
currency_state: currency::CurrencyState::new(),
events: events::EventPort::new(),
tick: 0,
alpha: model::ALPHA,
beta: trade::BETA,
})
}
/// Try to load from the auto-detected DB path (same search as the CLI binary).
///
/// Searches up from CWD for `server/data/systems.db`.
pub fn load_auto(run_seed: u64) -> Result<Self, String> {
let mut dir = std::env::current_dir().map_err(|e| e.to_string())?;
loop {
let candidate = dir.join("server").join("data").join("systems.db");
if candidate.exists() {
return Self::load(&candidate, run_seed);
}
if !dir.pop() {
break;
}
}
// Also check adjacent `data/` directory (when running from within server/)
let candidate = std::path::PathBuf::from("data").join("systems.db");
if candidate.exists() {
return Self::load(&candidate, run_seed);
}
Err("cannot find server/data/systems.db — pass path explicitly or run from project root".to_string())
}
/// Advance the simulation by one economy tick.
///
/// Applies active events, runs the Layer 1+2+3 step, and advances the
/// event port. Call once per economy tick (every ECON_TICK_RATE game ticks).
pub fn step(&mut self) {
// Activate any events scheduled for this tick (D-180)
let tick32 = self.tick as u32;
self.events.activate_scheduled(tick32);
let mods = self.events.compute_modifiers(&self.economy);
model::step_inner(
&self.economy,
&self.productivity,
&self.shadow,
&self.archetypes,
&mut self.nodes,
&mods,
self.alpha,
);
self.currency_state.apply_exchange_shock(mods.exchange_shock);
trade::trade_step(
&self.economy,
&mut self.nodes,
&self.adjacency,
&mut self.currency_state,
self.beta,
);
self.currency_state.update_rate();
// Expire finished events
self.events.advance_remaining();
self.tick += 1;
}
/// Number of economy ticks processed so far.
pub fn tick(&self) -> u64 {
self.tick
}
/// Current Tractus/Mark exchange rate.
pub fn tractus_mark_rate(&self) -> f64 {
self.currency_state.tractus_mark_rate
}
/// Read-only access to the loaded economy data.
pub fn economy(&self) -> &db::Economy {
&self.economy
}
/// Read-only access to the per-node shadow economy intensities.
pub fn shadow(&self) -> &currency::ShadowEconomy {
&self.shadow
}
}
+4 -2
View File
@@ -27,7 +27,8 @@ const GATE_COST_PER_HOP: f64 = 0.08;
/// Damping factor β (D-178): fraction of potential flow that actually moves
/// per tick. Prevents cobweb oscillation.
const BETA: f64 = 0.4;
/// 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.
@@ -73,6 +74,7 @@ pub fn trade_step(
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)
@@ -131,7 +133,7 @@ pub fn trade_step(
// 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;
let flow = beta * price_ratio * max_export;
if flow > 1e-6 {
flows.push((