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:
Generated
+13
-1
@@ -570,6 +570,17 @@ version = "2.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc"
|
||||
|
||||
[[package]]
|
||||
name = "econ-sim"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"rand",
|
||||
"rand_chacha",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
@@ -1245,13 +1256,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "settled-reach-server"
|
||||
version = "0.1.32"
|
||||
version = "0.1.33"
|
||||
dependencies = [
|
||||
"bevy_app",
|
||||
"bevy_ecs",
|
||||
"bincode",
|
||||
"clap",
|
||||
"crossbeam-channel",
|
||||
"econ-sim",
|
||||
"pathfinding",
|
||||
"rand",
|
||||
"rand_chacha",
|
||||
|
||||
@@ -23,6 +23,8 @@ sysinfo = "0.35"
|
||||
serde_json = "1"
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
toml = "0.8"
|
||||
# Economics simulation — Leontief + tâtonnement + D-180 event port (#821)
|
||||
econ-sim = { path = "../tooling/econ-sim" }
|
||||
|
||||
[features]
|
||||
default = ["gauntlet"]
|
||||
|
||||
+133
-1
@@ -8,7 +8,11 @@
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
|
||||
use crate::bridge::types::{DebugCommandKind, DebugEnabled, DebugResponsePayload, SnapshotBuffer};
|
||||
use crate::bridge::types::{
|
||||
DebugCommandKind, DebugEnabled, DebugResponsePayload, EconDebugEffect, EconParamKind,
|
||||
SnapshotBuffer,
|
||||
};
|
||||
use crate::simulation::economy::{EconSimResource, EconStateResource};
|
||||
use crate::knowledge::EntityRegistry;
|
||||
use crate::npc::Npc;
|
||||
use crate::simulation::conversation::NpcName;
|
||||
@@ -69,6 +73,8 @@ pub fn handle_debug_commands(
|
||||
(Entity, &TilePosition, Option<&NpcName>),
|
||||
(With<Npc>, With<ActiveSim>, Without<PlayerCharacter>),
|
||||
>,
|
||||
mut econ_sim: Option<ResMut<EconSimResource>>,
|
||||
econ_state: Option<Res<EconStateResource>>,
|
||||
) {
|
||||
// Gate: debug must be enabled
|
||||
let enabled = debug_enabled.as_ref().is_some_and(|d| d.0);
|
||||
@@ -325,6 +331,132 @@ pub fn handle_debug_commands(
|
||||
}
|
||||
}
|
||||
}
|
||||
DebugCommandKind::InjectEconEvent {
|
||||
ref target,
|
||||
ref effect,
|
||||
magnitude,
|
||||
duration_ticks,
|
||||
} => {
|
||||
use econ_sim::events::{EconEvent, EconEventEffect, EconEventTarget, EconEventVisibility};
|
||||
if let Some(ref mut sim) = econ_sim {
|
||||
let econ_effect = match effect {
|
||||
EconDebugEffect::CapacityMultiplier => {
|
||||
EconEventEffect::CapacityMultiplier(magnitude)
|
||||
}
|
||||
EconDebugEffect::ProductivityMultiplier => {
|
||||
EconEventEffect::ProductivityMultiplier(magnitude)
|
||||
}
|
||||
EconDebugEffect::DemandShock => {
|
||||
EconEventEffect::DemandShock(magnitude)
|
||||
}
|
||||
EconDebugEffect::ExchangeShock => {
|
||||
EconEventEffect::ExchangeShock(magnitude)
|
||||
}
|
||||
};
|
||||
sim.sim.events.push(EconEvent {
|
||||
target: EconEventTarget::Node(target.clone()),
|
||||
effect: econ_effect,
|
||||
duration: duration_ticks,
|
||||
visibility: EconEventVisibility::Global,
|
||||
});
|
||||
DebugResponsePayload {
|
||||
command: format!("InjectEconEvent({}, {:?}, {}×{})", target, effect, magnitude, duration_ticks),
|
||||
text: format!(
|
||||
"Event injected: {:?} ×{} on node '{}' for {} ticks.\nTakes effect on next economy tick.",
|
||||
effect, magnitude, target, duration_ticks
|
||||
),
|
||||
success: true,
|
||||
}
|
||||
} else {
|
||||
DebugResponsePayload {
|
||||
command: "InjectEconEvent".to_string(),
|
||||
text: "Economy simulation not loaded.".to_string(),
|
||||
success: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
DebugCommandKind::SetEconParam { ref param, value } => {
|
||||
if let Some(ref mut sim) = econ_sim {
|
||||
match param {
|
||||
EconParamKind::TatonnementStep => {
|
||||
let old = sim.sim.alpha;
|
||||
sim.sim.alpha = value;
|
||||
DebugResponsePayload {
|
||||
command: format!("SetEconParam(TatonnementStep, {})", value),
|
||||
text: format!("α (tâtonnement step): {} → {}", old, value),
|
||||
success: true,
|
||||
}
|
||||
}
|
||||
EconParamKind::DampingFactor => {
|
||||
let old = sim.sim.beta;
|
||||
sim.sim.beta = value;
|
||||
DebugResponsePayload {
|
||||
command: format!("SetEconParam(DampingFactor, {})", value),
|
||||
text: format!("β (damping factor): {} → {}", old, value),
|
||||
success: true,
|
||||
}
|
||||
}
|
||||
EconParamKind::CorridorFriction { ref corridor_id } => {
|
||||
DebugResponsePayload {
|
||||
command: format!("SetEconParam(CorridorFriction({}))", corridor_id),
|
||||
text: "Per-corridor friction override not yet implemented (requires corridor friction model in trade.rs).".to_string(),
|
||||
success: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
DebugResponsePayload {
|
||||
command: "SetEconParam".to_string(),
|
||||
text: "Economy simulation not loaded.".to_string(),
|
||||
success: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
DebugCommandKind::GetEconState { ref system_id } => {
|
||||
if let Some(ref state) = econ_state {
|
||||
let signals: Vec<_> = state
|
||||
.signals
|
||||
.iter()
|
||||
.filter(|((sys, _), _)| sys == system_id)
|
||||
.collect();
|
||||
if signals.is_empty() {
|
||||
DebugResponsePayload {
|
||||
command: format!("GetEconState({})", system_id),
|
||||
text: format!("System '{}' not found in economy state.", system_id),
|
||||
success: false,
|
||||
}
|
||||
} else {
|
||||
let mut lines = vec![
|
||||
format!("=== Economy state for '{}' (econ_tick={}) ===", system_id, state.econ_tick),
|
||||
format!(" FX rate (Tractus/Mark): {:.4}", state.tractus_mark_rate),
|
||||
];
|
||||
for ((_, commodity_id), sig) in &signals {
|
||||
lines.push(format!(
|
||||
" {} | price={:.2} trend={:+.2} flow={:.1} corps={} stockpile_wks={:.1} prod_vs_base={:.3} coverage={:.2}",
|
||||
commodity_id,
|
||||
sig.price_current,
|
||||
sig.price_trend,
|
||||
sig.trade_flow_volume,
|
||||
sig.corporate_presence,
|
||||
sig.stockpile_weeks,
|
||||
sig.production_vs_baseline,
|
||||
sig.official_coverage_ratio,
|
||||
));
|
||||
}
|
||||
DebugResponsePayload {
|
||||
command: format!("GetEconState({})", system_id),
|
||||
text: lines.join("\n"),
|
||||
success: true,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
DebugResponsePayload {
|
||||
command: format!("GetEconState({})", system_id),
|
||||
text: "Economy simulation not loaded.".to_string(),
|
||||
success: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -320,6 +320,7 @@ mod tests {
|
||||
debug_response: None,
|
||||
current_ticker: None,
|
||||
settings_response: None,
|
||||
economy_snapshot: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -461,6 +462,7 @@ mod tests {
|
||||
debug_response: None,
|
||||
current_ticker: None,
|
||||
settings_response: None,
|
||||
economy_snapshot: None,
|
||||
};
|
||||
let text = format_snapshot_text(&snap);
|
||||
assert!(text.contains("Tick 0"));
|
||||
|
||||
@@ -17,7 +17,7 @@ pub use crate::simulation::time::{DayPhase, TickRate};
|
||||
/// negotiation is unnecessary. Client should reject snapshots with version !=
|
||||
/// PROTOCOL_VERSION. New fields use #[serde(default)] only during the migration
|
||||
/// period, then the default is removed once both sides are updated.
|
||||
pub const PROTOCOL_VERSION: u8 = 20;
|
||||
pub const PROTOCOL_VERSION: u8 = 21;
|
||||
|
||||
/// Handshake message sent as the very first framed message after connection (#555).
|
||||
/// Client reads this before entering the normal tick loop and validates
|
||||
@@ -81,6 +81,8 @@ pub struct StartupMessage {
|
||||
/// v18 adds: debug_response (#580, debug console server — command/response wire).
|
||||
/// v19 adds: character_archetype on StartupMessage (#587), current_ticker (#591).
|
||||
/// v20 adds: settings_response (#627, SQLite settings IPC).
|
||||
/// v21 adds: economy_snapshot (#822, D-181 7-signal snapshot per queried system),
|
||||
/// EconStateQuery PlayerAction variant (#822).
|
||||
/// Future fields: ambient sound events, HUD state (D-020 expansion).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ObserverSnapshot {
|
||||
@@ -216,6 +218,12 @@ pub struct ObserverSnapshot {
|
||||
/// Client reads to confirm setting changes or to populate the settings UI.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub settings_response: Option<crate::settings::types::SettingsResponseWire>,
|
||||
/// Economy snapshot (#822, D-181 7-signal snapshot).
|
||||
/// Present for exactly one tick after an `EconStateQuery` is processed.
|
||||
/// Contains all 7 D-181 signals for each commodity in the queried system.
|
||||
/// None during normal gameplay; client queries explicitly via `EconStateQuery`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub economy_snapshot: Option<EconomySnapshot>,
|
||||
}
|
||||
|
||||
/// A single news ticker headline crossing the wire boundary (#591).
|
||||
@@ -550,6 +558,12 @@ pub enum PlayerAction {
|
||||
DeleteSetting {
|
||||
key: String,
|
||||
},
|
||||
/// Query economy state for a named system (#822, D-181).
|
||||
/// Server responds with `ObserverSnapshot.economy_snapshot` for one tick.
|
||||
/// Absent when economy is not loaded or `system_id` is unknown.
|
||||
EconStateQuery {
|
||||
system_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl PlayerAction {
|
||||
@@ -595,6 +609,21 @@ pub enum DebugCommandKind {
|
||||
ListPopulation,
|
||||
/// Return `ContaminationActive` status and current tick.
|
||||
GetContaminationStatus,
|
||||
/// Inject a D-180 economic event into the running simulation (#823).
|
||||
/// The event fires at the next economy tick and lasts for `duration_ticks`.
|
||||
/// `target` is a system_id (node-level events only in v0.1).
|
||||
InjectEconEvent {
|
||||
target: String,
|
||||
effect: EconDebugEffect,
|
||||
magnitude: f64,
|
||||
duration_ticks: u32,
|
||||
},
|
||||
/// Mutate a simulation parameter at runtime (#823, D-178).
|
||||
/// Changes take effect on the next `Simulation::step()` call.
|
||||
SetEconParam { param: EconParamKind, value: f64 },
|
||||
/// Return all 7 D-181 signals for the named system (#823).
|
||||
/// Equivalent to `EconStateQuery` but via the debug console.
|
||||
GetEconState { system_id: String },
|
||||
}
|
||||
|
||||
/// Debug response payload included in `ObserverSnapshot` (#580).
|
||||
@@ -612,6 +641,72 @@ pub struct DebugResponsePayload {
|
||||
pub success: bool,
|
||||
}
|
||||
|
||||
/// Wire type for a single commodity's 7 D-181 signals at a node (#822).
|
||||
///
|
||||
/// Compact snapshot used in `EconomySnapshot.nodes`. Mirrors `EconNodeSignals`
|
||||
/// in `simulation::economy` but is Serializable for wire transmission.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EconNodeSnapshot {
|
||||
pub commodity_id: String,
|
||||
/// Signal 1: current market price in Tractus (Public).
|
||||
pub price_current: f64,
|
||||
/// Signal 2: price delta over last TREND_WINDOW economy ticks (Public).
|
||||
pub price_trend: f64,
|
||||
/// Signal 3: trade flow volume proxy (Observable).
|
||||
pub trade_flow_volume: f64,
|
||||
/// Signal 4: number of corporations at this node (Observable).
|
||||
pub corporate_presence: u32,
|
||||
/// Signal 5: stockpile in weeks at current demand rate (Semi-private).
|
||||
pub stockpile_weeks: f64,
|
||||
/// Signal 6: supply vs. baseline supply from first tick (Private).
|
||||
pub production_vs_baseline: f64,
|
||||
/// Signal 7: ratio of formal to total activity (Meta-signal).
|
||||
pub official_coverage_ratio: f64,
|
||||
}
|
||||
|
||||
/// Wire type for economy state snapshot (#822, D-181).
|
||||
///
|
||||
/// Returned in `ObserverSnapshot.economy_snapshot` for one tick after an
|
||||
/// `EconStateQuery` is processed. Contains signals for all commodities in the
|
||||
/// queried system. None when economy is not loaded or system_id is unknown.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EconomySnapshot {
|
||||
/// The system this snapshot covers.
|
||||
pub system_id: String,
|
||||
/// Economy tick at which this snapshot was produced.
|
||||
pub econ_tick: u64,
|
||||
/// Current Tractus/Mark exchange rate (1.0 = parity).
|
||||
pub tractus_mark_rate: f64,
|
||||
/// Signals for each commodity active in this system.
|
||||
pub nodes: Vec<EconNodeSnapshot>,
|
||||
}
|
||||
|
||||
/// Effect type for `InjectEconEvent` debug command (#823, D-180).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum EconDebugEffect {
|
||||
/// Multiply production capacity of the target node by `magnitude`.
|
||||
/// < 1.0 = capacity shock; > 1.0 = capacity boost.
|
||||
CapacityMultiplier,
|
||||
/// Multiply productivity of all operations at the target node by `magnitude`.
|
||||
ProductivityMultiplier,
|
||||
/// Add `magnitude` to demand for all commodities at the target node.
|
||||
DemandShock,
|
||||
/// Apply a one-time exchange rate shock of `magnitude` to the FX rate.
|
||||
ExchangeShock,
|
||||
}
|
||||
|
||||
/// Parameter selector for `SetEconParam` debug command (#823, D-178).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum EconParamKind {
|
||||
/// Tâtonnement step size (α, D-178 Layer 2). Default: 0.03.
|
||||
TatonnementStep,
|
||||
/// Trade flow damping factor (β, D-178). Default: 0.4.
|
||||
DampingFactor,
|
||||
/// Per-corridor friction override (not yet implemented in simulation).
|
||||
#[allow(dead_code)] // Used by future corridor friction model (#TODO)
|
||||
CorridorFriction { corridor_id: String },
|
||||
}
|
||||
|
||||
/// Whether the debug console is enabled (#580).
|
||||
///
|
||||
/// Set at server startup. Cannot be toggled mid-session via IPC.
|
||||
@@ -941,6 +1036,8 @@ pub struct SnapshotBuffer {
|
||||
pub pending_debug_response: Option<DebugResponsePayload>,
|
||||
/// Pending settings response, consumed once by `compute_observer_snapshot` (#627).
|
||||
pub pending_settings_response: Option<crate::settings::types::SettingsResponseWire>,
|
||||
/// Pending economy snapshot, consumed once by `compute_observer_snapshot` (#822).
|
||||
pub pending_economy_response: Option<EconomySnapshot>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -328,6 +328,7 @@ fn send_panic_error(app: &App, panic_msg: &str) {
|
||||
debug_response: None,
|
||||
current_ticker: None,
|
||||
settings_response: None,
|
||||
economy_snapshot: None,
|
||||
sim_errors: vec![SimError {
|
||||
kind: SimErrorKind::Panic,
|
||||
message: format!("Simulation panic: {}", panic_msg),
|
||||
|
||||
@@ -394,6 +394,9 @@ pub fn compute_observer_snapshot(
|
||||
None
|
||||
};
|
||||
|
||||
// Consume pending economy snapshot for this tick (#822).
|
||||
let economy_snapshot = buffer.pending_economy_response.take();
|
||||
|
||||
// Consume pending save/load result for this tick (#553).
|
||||
let save_result = buffer.pending_save_result.take();
|
||||
|
||||
@@ -489,6 +492,7 @@ pub fn compute_observer_snapshot(
|
||||
debug_response,
|
||||
current_ticker,
|
||||
settings_response,
|
||||
economy_snapshot,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
//! Economy simulation integration — runs econ-sim inside the server tick loop.
|
||||
//!
|
||||
//! Bridges the standalone `econ_sim` library into the Bevy ECS tick loop.
|
||||
//! The simulation advances one economy tick every `ECON_TICK_RATE` game ticks.
|
||||
//!
|
||||
//! ## Architecture
|
||||
//!
|
||||
//! - [`EconSimResource`] — holds the running `Simulation` + price history for trends.
|
||||
//! Loaded once at startup from `server/data/systems.db`. Never reloaded mid-session.
|
||||
//!
|
||||
//! - [`EconStateResource`] — all 7 D-181 signals per active (system_id, commodity_id).
|
||||
//! Updated every `ECON_TICK_RATE` game ticks by `tick_economy_simulation`.
|
||||
//! Queryable by the IPC bridge (#822) and debug commands (#823).
|
||||
//!
|
||||
//! - `tick_economy_simulation` — bevy System registered in `SimulationPlugin`.
|
||||
//! Advances one economy tick, then rebuilds `EconStateResource`.
|
||||
//!
|
||||
//! ## Rate (D-031)
|
||||
//!
|
||||
//! `ECON_TICK_RATE = 10` game ticks per economy tick.
|
||||
//! At 10 game ticks/game-minute (D-031), this means the economy advances once
|
||||
//! per game-minute — a reasonable granularity for macro-scale price dynamics.
|
||||
//!
|
||||
//! ## D-181 signals
|
||||
//!
|
||||
//! 1. `price_current` — current market price (Public)
|
||||
//! 2. `price_trend` — Δprice over the last `TREND_WINDOW` economy ticks (Public)
|
||||
//! 3. `trade_flow_volume` — supply volume proxy (Observable; Phase 3 will refine)
|
||||
//! 4. `corporate_presence` — corp count at this node (Observable)
|
||||
//! 5. `stockpile_weeks` — stockpile ÷ weekly demand rate (Semi-private)
|
||||
//! 6. `production_vs_baseline` — supply ÷ initial baseline supply (Private)
|
||||
//! 7. `official_coverage_ratio` — 1 − shadow_intensity (Meta-signal)
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
use econ_sim::Simulation;
|
||||
|
||||
use crate::bridge::types::{EconNodeSnapshot, EconomySnapshot, SnapshotBuffer};
|
||||
use crate::simulation::time::SimulationTime;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Game ticks between each economy tick (D-031: 10 ticks/game-minute).
|
||||
pub const ECON_TICK_RATE: u64 = 10;
|
||||
|
||||
/// Number of economy ticks to average for price trend signal 2 (D-181).
|
||||
const TREND_WINDOW: usize = 5;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resources
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The running economics simulation (loaded once at startup).
|
||||
///
|
||||
/// Never reinitialize mid-session — the economy state is continuous.
|
||||
#[derive(Resource)]
|
||||
pub struct EconSimResource {
|
||||
pub sim: Simulation,
|
||||
/// Price history for signal 2 (price_trend) computation.
|
||||
/// Ring-buffer keyed by (system_id, commodity_id) → last TREND_WINDOW prices.
|
||||
price_history: BTreeMap<(String, String), Vec<f64>>,
|
||||
/// Baseline supply from first economy tick for signal 6 (production_vs_baseline).
|
||||
baseline_supply: BTreeMap<(String, String), f64>,
|
||||
}
|
||||
|
||||
impl EconSimResource {
|
||||
pub fn new(sim: Simulation) -> Self {
|
||||
Self {
|
||||
sim,
|
||||
price_history: BTreeMap::new(),
|
||||
baseline_supply: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The 7 D-181 signals for a single active (system_id, commodity_id) pair.
|
||||
///
|
||||
/// Updated every `ECON_TICK_RATE` game ticks. All fields present when the
|
||||
/// node is active; queries for inactive nodes return nothing.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EconNodeSignals {
|
||||
pub system_id: String,
|
||||
pub commodity_id: String,
|
||||
/// Signal 1: current market price in Tractus (Public).
|
||||
pub price_current: f64,
|
||||
/// Signal 2: price delta over last `TREND_WINDOW` economy ticks (Public).
|
||||
/// Positive = price rising; negative = falling. Absolute delta, not percentage.
|
||||
pub price_trend: f64,
|
||||
/// Signal 3: trade flow volume proxy — supply volume this tick (Observable).
|
||||
/// Phase 2 proxy: actual inter-node flow tracking is Phase 3.
|
||||
pub trade_flow_volume: f64,
|
||||
/// Signal 4: number of corporations operating at this node (Observable).
|
||||
pub corporate_presence: u32,
|
||||
/// Signal 5: estimated stockpile in weeks at current demand rate (Semi-private).
|
||||
pub stockpile_weeks: f64,
|
||||
/// Signal 6: supply vs. baseline supply from first tick (Private).
|
||||
/// 1.0 = at baseline; < 1.0 = below baseline; > 1.0 = above.
|
||||
pub production_vs_baseline: f64,
|
||||
/// Signal 7: ratio of formal to total (formal + shadow) activity (Meta-signal).
|
||||
/// Derived from `shadow_intensity`: 1.0 = fully formal, 0.0 = fully shadow.
|
||||
pub official_coverage_ratio: f64,
|
||||
}
|
||||
|
||||
/// Current economy state — all 7 D-181 signals for all active nodes.
|
||||
///
|
||||
/// Updated every `ECON_TICK_RATE` game ticks. Queryable by the IPC bridge
|
||||
/// (#822) and debug command handler (#823). Absent when the economy DB is
|
||||
/// not loaded (graceful degradation).
|
||||
#[derive(Resource, Default)]
|
||||
pub struct EconStateResource {
|
||||
/// Economy tick at which this snapshot was produced.
|
||||
pub econ_tick: u64,
|
||||
/// Current Tractus/Mark exchange rate (1.0 = parity).
|
||||
pub tractus_mark_rate: f64,
|
||||
/// Signal map: (system_id, commodity_id) → 7-signal snapshot.
|
||||
pub signals: BTreeMap<(String, String), EconNodeSignals>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// System
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// System: advance the economy simulation one tick every `ECON_TICK_RATE` game ticks.
|
||||
///
|
||||
/// Runs after `time::advance_tick` (needs current game tick) and before
|
||||
/// `compute_observer_snapshot` (so signals are fresh for the snapshot).
|
||||
///
|
||||
/// No-op when the game tick is not divisible by `ECON_TICK_RATE`.
|
||||
/// Both `EconSimResource` and `EconStateResource` must be present (inserted
|
||||
/// at startup only when the economy DB loaded successfully).
|
||||
pub fn tick_economy_simulation(
|
||||
time: Res<SimulationTime>,
|
||||
econ_sim_opt: Option<ResMut<EconSimResource>>,
|
||||
econ_state_opt: Option<ResMut<EconStateResource>>,
|
||||
) {
|
||||
let (mut econ_sim, mut econ_state) = match (econ_sim_opt, econ_state_opt) {
|
||||
(Some(s), Some(st)) => (s, st),
|
||||
_ => return, // economy not loaded — no-op
|
||||
};
|
||||
|
||||
if time.tick % ECON_TICK_RATE != 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Step the simulation one economy tick
|
||||
econ_sim.sim.step();
|
||||
|
||||
let econ_tick = econ_sim.sim.tick();
|
||||
let fx_rate = econ_sim.sim.tractus_mark_rate();
|
||||
|
||||
// Rebuild signal map from updated node states
|
||||
rebuild_signals(&mut econ_sim, &mut econ_state, econ_tick, fx_rate);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Signal computation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn rebuild_signals(
|
||||
econ_sim: &mut EconSimResource,
|
||||
econ_state: &mut EconStateResource,
|
||||
econ_tick: u64,
|
||||
fx_rate: f64,
|
||||
) {
|
||||
econ_state.econ_tick = econ_tick;
|
||||
econ_state.tractus_mark_rate = fx_rate;
|
||||
econ_state.signals.clear();
|
||||
|
||||
// Collect shadow intensities and corp counts once (avoid repeated borrows)
|
||||
let shadow_intensities: BTreeMap<String, f64> = econ_sim
|
||||
.sim
|
||||
.shadow()
|
||||
.intensity
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), *v))
|
||||
.collect();
|
||||
|
||||
let corp_counts: BTreeMap<String, u32> = econ_sim
|
||||
.sim
|
||||
.economy()
|
||||
.presences_by_system
|
||||
.iter()
|
||||
.map(|(sys, corps)| (sys.clone(), corps.len() as u32))
|
||||
.collect();
|
||||
|
||||
// Snapshot current node states into the price_history and baseline_supply maps,
|
||||
// then build signals. We need to separate the borrow from the iteration.
|
||||
let node_snapshots: Vec<(String, Vec<(String, f64, f64)>)> = econ_sim
|
||||
.sim
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|(system_id, node)| {
|
||||
let commodities: Vec<(String, f64, f64)> = node
|
||||
.commodities
|
||||
.iter()
|
||||
.map(|(commodity_id, state)| {
|
||||
(commodity_id.clone(), state.price, state.supply)
|
||||
})
|
||||
.collect();
|
||||
(system_id.clone(), commodities)
|
||||
})
|
||||
.collect();
|
||||
|
||||
for (system_id, commodities) in &node_snapshots {
|
||||
let shadow_intensity = shadow_intensities.get(system_id).copied().unwrap_or(0.0);
|
||||
let corp_count = corp_counts.get(system_id).copied().unwrap_or(0);
|
||||
|
||||
for (commodity_id, price, supply) in commodities {
|
||||
let key = (system_id.clone(), commodity_id.clone());
|
||||
|
||||
// Signal 6 baseline: record first-tick supply
|
||||
econ_sim
|
||||
.baseline_supply
|
||||
.entry(key.clone())
|
||||
.or_insert(*supply);
|
||||
let baseline = *econ_sim.baseline_supply.get(&key).unwrap_or(supply);
|
||||
|
||||
// Signal 2: price trend via ring buffer
|
||||
let history = econ_sim.price_history.entry(key.clone()).or_default();
|
||||
history.push(*price);
|
||||
if history.len() > TREND_WINDOW {
|
||||
history.remove(0);
|
||||
}
|
||||
let price_trend = if history.len() >= 2 {
|
||||
price - history[0]
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Signal 5: stockpile in weeks (7 economy ticks per week approximation)
|
||||
let stockpile = econ_sim
|
||||
.sim
|
||||
.nodes
|
||||
.get(system_id)
|
||||
.and_then(|n| n.commodities.get(commodity_id))
|
||||
.map(|s| s.stockpile)
|
||||
.unwrap_or(0.0);
|
||||
let demand = econ_sim
|
||||
.sim
|
||||
.nodes
|
||||
.get(system_id)
|
||||
.and_then(|n| n.commodities.get(commodity_id))
|
||||
.map(|s| s.demand)
|
||||
.unwrap_or(0.0);
|
||||
let weekly_demand = demand * 7.0; // 7 econ ticks ≈ 1 week
|
||||
let stockpile_weeks = if weekly_demand > 1e-9 {
|
||||
stockpile / weekly_demand
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Signal 6: production vs baseline
|
||||
let production_vs_baseline = if baseline > 1e-9 {
|
||||
supply / baseline
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
|
||||
// Signal 7: official coverage ratio
|
||||
let official_coverage_ratio = 1.0 - shadow_intensity;
|
||||
|
||||
econ_state.signals.insert(
|
||||
key,
|
||||
EconNodeSignals {
|
||||
system_id: system_id.clone(),
|
||||
commodity_id: commodity_id.clone(),
|
||||
price_current: *price,
|
||||
price_trend,
|
||||
trade_flow_volume: *supply, // Phase 2 proxy
|
||||
corporate_presence: corp_count,
|
||||
stockpile_weeks,
|
||||
production_vs_baseline,
|
||||
official_coverage_ratio,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Startup helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Attempt to load the economy simulation.
|
||||
///
|
||||
/// Returns `Some((EconSimResource, EconStateResource))` on success, `None` on
|
||||
/// failure (with the error logged at warn level). The server inserts these as
|
||||
/// resources when present; the economy features degrade gracefully when absent.
|
||||
// ---------------------------------------------------------------------------
|
||||
// IPC query buffer (#822)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Pending system_id from an `EconStateQuery` PlayerAction.
|
||||
///
|
||||
/// Populated by `process_player_input`; consumed by `serve_econ_state_query`.
|
||||
/// `None` on ticks when no query was received.
|
||||
#[derive(Resource, Default)]
|
||||
pub struct EconQueryBuffer {
|
||||
pub pending: Option<String>,
|
||||
}
|
||||
|
||||
/// System: serve a pending `EconStateQuery` by building an `EconomySnapshot`
|
||||
/// and storing it in `SnapshotBuffer.pending_economy_response`.
|
||||
///
|
||||
/// Runs after `tick_economy_simulation` (signals must be fresh) and before
|
||||
/// `compute_observer_snapshot` (which consumes the response).
|
||||
/// No-op when `EconStateResource` is absent or no query is pending.
|
||||
pub fn serve_econ_state_query(
|
||||
mut query_buf: ResMut<EconQueryBuffer>,
|
||||
econ_state: Option<Res<EconStateResource>>,
|
||||
mut snapshot_buf: ResMut<SnapshotBuffer>,
|
||||
) {
|
||||
let system_id = match query_buf.pending.take() {
|
||||
Some(s) => s,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let econ_state = match econ_state {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
// Economy not loaded — no response (client receives None in snapshot)
|
||||
tracing::debug!(system_id = %system_id, "EconStateQuery: economy not loaded");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Collect signals for all commodities in the requested system
|
||||
let nodes: Vec<EconNodeSnapshot> = econ_state
|
||||
.signals
|
||||
.iter()
|
||||
.filter(|((sys, _), _)| sys == &system_id)
|
||||
.map(|((_, commodity_id), sig)| EconNodeSnapshot {
|
||||
commodity_id: commodity_id.clone(),
|
||||
price_current: sig.price_current,
|
||||
price_trend: sig.price_trend,
|
||||
trade_flow_volume: sig.trade_flow_volume,
|
||||
corporate_presence: sig.corporate_presence,
|
||||
stockpile_weeks: sig.stockpile_weeks,
|
||||
production_vs_baseline: sig.production_vs_baseline,
|
||||
official_coverage_ratio: sig.official_coverage_ratio,
|
||||
})
|
||||
.collect();
|
||||
|
||||
if nodes.is_empty() {
|
||||
tracing::debug!(system_id = %system_id, "EconStateQuery: system not found in economy state");
|
||||
return;
|
||||
}
|
||||
|
||||
snapshot_buf.pending_economy_response = Some(EconomySnapshot {
|
||||
system_id,
|
||||
econ_tick: econ_state.econ_tick,
|
||||
tractus_mark_rate: econ_state.tractus_mark_rate,
|
||||
nodes,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Startup helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn try_load_economy(run_seed: u64) -> Option<(EconSimResource, EconStateResource)> {
|
||||
match Simulation::load_auto(run_seed) {
|
||||
Ok(sim) => {
|
||||
tracing::info!(
|
||||
commodities = sim.economy().commodities.len(),
|
||||
active_nodes = sim.nodes.len(),
|
||||
"Economy simulation loaded"
|
||||
);
|
||||
Some((EconSimResource::new(sim), EconStateResource::default()))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
"Economy simulation not loaded — economics features disabled for this session"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
use crate::bridge::debug::DebugCommandBuffer;
|
||||
use crate::bridge::types::{FacingDirection, ObjectType, PlayerAction, PlayerInput};
|
||||
use crate::simulation::economy::EconQueryBuffer;
|
||||
use crate::knowledge::{EntityRegistry, StableId};
|
||||
use crate::perception::vision_cone::{facing_from_delta, Facing};
|
||||
use crate::settings::{SettingsCommand, SettingsCommandBuffer};
|
||||
@@ -102,6 +103,7 @@ pub fn process_player_input(
|
||||
mut save_load: Option<ResMut<SaveLoadPending>>,
|
||||
mut debug_cmd_buffer: Option<ResMut<DebugCommandBuffer>>,
|
||||
mut settings_cmd_buffer: Option<ResMut<SettingsCommandBuffer>>,
|
||||
mut econ_query_buf: Option<ResMut<EconQueryBuffer>>,
|
||||
door_states: Query<&DoorState>,
|
||||
object_types: Query<&ObjectType>,
|
||||
) {
|
||||
@@ -127,6 +129,7 @@ pub fn process_player_input(
|
||||
| PlayerAction::ChangeSetting { .. }
|
||||
| PlayerAction::RequestAllSettings
|
||||
| PlayerAction::DeleteSetting { .. }
|
||||
| PlayerAction::EconStateQuery { .. }
|
||||
)
|
||||
{
|
||||
continue;
|
||||
@@ -410,6 +413,13 @@ pub fn process_player_input(
|
||||
);
|
||||
}
|
||||
}
|
||||
PlayerAction::EconStateQuery { system_id } => {
|
||||
if let Some(ref mut buf) = econ_query_buf {
|
||||
buf.pending = Some(system_id);
|
||||
} else {
|
||||
tracing::debug!("EconStateQuery received but EconQueryBuffer not registered — economy not loaded");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ pub mod chunk_streaming;
|
||||
pub mod contraband;
|
||||
pub mod conversation;
|
||||
pub mod dialogue;
|
||||
pub mod economy;
|
||||
pub mod examine;
|
||||
pub mod follow;
|
||||
pub mod generator;
|
||||
@@ -166,6 +167,29 @@ impl Plugin for SimulationPlugin {
|
||||
// Initialize TickerPool with empty default; populated by ContentPlugin at Startup.
|
||||
app.init_resource::<ticker::TickerPool>();
|
||||
|
||||
// Economy simulation (#821, D-031) — loaded once at startup, no-op when DB absent.
|
||||
// Uses seed 0 for now; will be threaded through StartupMessage world seed (#TODO).
|
||||
if let Some((econ_sim, econ_state)) = economy::try_load_economy(0) {
|
||||
app.insert_resource(econ_sim)
|
||||
.insert_resource(econ_state);
|
||||
}
|
||||
// EconQueryBuffer: always registered so EconStateQuery PlayerActions are accepted
|
||||
// even when the economy DB is absent (queries just produce no response).
|
||||
app.init_resource::<economy::EconQueryBuffer>();
|
||||
// tick_economy_simulation + serve_econ_state_query use Option<ResMut<...>> — safe to
|
||||
// register unconditionally. They no-op when EconSimResource / EconStateResource absent.
|
||||
app.add_systems(
|
||||
Update,
|
||||
(
|
||||
economy::tick_economy_simulation
|
||||
.after(time::advance_tick)
|
||||
.before(crate::perception::observer::compute_observer_snapshot),
|
||||
economy::serve_econ_state_query
|
||||
.after(economy::tick_economy_simulation)
|
||||
.before(crate::perception::observer::compute_observer_snapshot),
|
||||
),
|
||||
);
|
||||
|
||||
tracing::debug!("SimulationPlugin initialized");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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