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:
+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)]
|
||||
|
||||
Reference in New Issue
Block a user