//! 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), /// 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), /// 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, scheduled: Vec, } 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 = 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 { 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, economy: &Economy, factor: f64, ) { let commodity_ids: Vec = 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; } }