- Widen EventPort tick methods from u32 to u64 (prevents overflow) - Add is_identity() guard on hot-path String allocation in modifiers - Replace Vec::remove(0) with VecDeque::pop_front() in price history - Add .after(tick_economy_simulation) ordering for debug commands - Fix stale PROTOCOL_VERSION assertion (20 → 21) in serialization test - Add D-181 Phase 2 visibility scope comment on serve_econ_state_query - Eliminate double lookup in rebuild_signals via single-pass extraction - Track economy seed TODO with backlog ticket reference Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
388 lines
14 KiB
Rust
388 lines
14 KiB
Rust
//! 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: u64,
|
|
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: u64, 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: u64) {
|
|
// 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.
|
|
/// Guards String allocation: fast-path returns 1.0 when no demand events are active.
|
|
pub fn demand_for(&self, system_id: &str, commodity_id: &str) -> f64 {
|
|
if self.demand.is_empty() {
|
|
return 1.0;
|
|
}
|
|
*self
|
|
.demand
|
|
.get(&(system_id.to_string(), commodity_id.to_string()))
|
|
.unwrap_or(&1.0)
|
|
}
|
|
|
|
/// Combined productivity multiplier for `(system_id, commodity_id)`.
|
|
/// Guards String allocation: fast-path returns 1.0 when no productivity events are active.
|
|
pub fn productivity_for(&self, system_id: &str, commodity_id: &str) -> f64 {
|
|
if self.productivity.is_empty() {
|
|
return 1.0;
|
|
}
|
|
*self
|
|
.productivity
|
|
.get(&(system_id.to_string(), commodity_id.to_string()))
|
|
.unwrap_or(&1.0)
|
|
}
|
|
|
|
/// Combined capacity multiplier for `(system_id, commodity_id)`.
|
|
/// Guards String allocation: fast-path returns 1.0 when no capacity events are active.
|
|
pub fn capacity_for(&self, system_id: &str, commodity_id: &str) -> f64 {
|
|
if self.capacity.is_empty() {
|
|
return 1.0;
|
|
}
|
|
*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;
|
|
}
|
|
}
|