Files
settled-reach/tooling/econ-sim/src/events.rs
T
jpmschweitzerandClaude Opus 4.6 1498115aba fix(simulation): address PR #125 review — tick truncation, ordering, perf, protocol test
- 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>
2026-04-10 14:06:54 +02:00

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;
}
}