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:
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user