feat(simulation): implement economics integration sprint — #810 #821 #822 #823

Implements the full D-180/D-181 economics pipeline:

**#810 — Event input port (D-180)**
- Add EconEvent struct with Target/Effect/Duration/Visibility variants
- Implement EventPort as typed input queue for external disruptions
- Apply events in simulation step; D-179 Test 3 now uses real shock injection

**#821 — Integrate econ-sim into server tick loop**
- Extract econ-sim as library crate (lib.rs + sim.rs, Cargo.toml [lib] section)
- Add Simulation stateful runner; step() advances one economy tick
- Add EconSimResource, EconStateResource (7 D-181 signals), tick_economy_simulation
- Economy loads once at startup; graceful no-op when systems.db absent
- Server advances economy 1 tick per 10 game ticks (D-031)

**#822 — Expose economy state over IPC bridge**
- Protocol version 20 → 21
- Add EconomySnapshot, EconNodeSnapshot wire types
- Add EconStateQuery PlayerAction variant; response in economy_snapshot field
- Add EconQueryBuffer resource + serve_econ_state_query system

**#823 — Economics debug commands**
- Add InjectEconEvent, SetEconParam, GetEconState to DebugCommandKind
- Add EconDebugEffect, EconParamKind enums
- SetEconParam mutates α/β at runtime (α/β promoted to pub const + Simulation fields)
- ALPHA and BETA constants threaded through step_inner/trade_step signatures

All 1147 unit tests pass; zero warnings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-10 13:13:52 +02:00
co-authored by Claude Sonnet 4.6
parent 9d9ea96be1
commit 5f4139bc9e
19 changed files with 1471 additions and 122 deletions
+133 -1
View File
@@ -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,
}
}
}
}
};
+2
View File
@@ -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"));
+98 -1
View File
@@ -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)]