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:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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: ¤cy::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()
|
||||
}
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) -> ¤cy::ShadowEconomy {
|
||||
&self.shadow
|
||||
}
|
||||
}
|
||||
@@ -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((
|
||||
|
||||
Reference in New Issue
Block a user