//! Storyteller module — Rimworld-style narrative pacing (#254). //! //! The storyteller is the game's hidden director. It manages event timing, //! pressure escalation, and the hubris wall (future). Its first concrete //! mechanic is **contamination activation**: after a configurable delay, //! the investigation's proximity begins pressuring smuggling-ring triangles. //! //! ## Contamination (#254) //! //! After `CONTAMINATION_DELAY_TICKS` (default 300 = 30 game-minutes at //! 10 ticks/game-minute per D-031), the storyteller: //! 1. Sets `ContaminationActive` resource to true (one-shot) //! 2. Applies a tension delta to all `ActiveFork` triangles //! 3. Emits a `ContaminationEvent` for downstream systems (monologue, etc.) use bevy_app::prelude::*; use bevy_ecs::prelude::*; use crate::content::template::{TriangleClassification, TriangleState}; use crate::simulation::tier::ActiveSim; use crate::simulation::time::{SimulationTime, TICKS_PER_GAME_MINUTE}; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- /// Tick at which contamination activates (30 game-minutes × 10 ticks/minute = 300). pub const CONTAMINATION_DELAY_TICKS: u64 = 30 * TICKS_PER_GAME_MINUTE; /// Tension delta applied to ActiveFork triangles when contamination fires. pub const CONTAMINATION_PRESSURE_DELTA: u8 = 10; /// Threshold above which confrontation mechanics trigger (Q-017 fallback). /// 0–100 scale. Not yet consumed — placeholder for future confrontation system. pub const CONFRONTATION_THRESHOLD: u8 = 75; // --------------------------------------------------------------------------- // Resources // --------------------------------------------------------------------------- /// Whether contamination has been activated by the storyteller. /// /// Once set to `true`, it stays true for the remainder of the session. /// Persisted in `SaveStateV1` to prevent double-firing on save/load. #[derive(Resource, Debug, Clone, Default)] pub struct ContaminationActive(pub bool); // --------------------------------------------------------------------------- // Events // --------------------------------------------------------------------------- /// Emitted once when contamination activates (#254). /// /// Downstream consumers (monologue system, NPC behavioral shifts) can /// listen for this to trigger one-shot reactions. #[derive(Debug, Clone)] pub struct ContaminationEvent { /// Tick at which contamination activated. pub tick: u64, /// Number of ActiveFork triangles that received pressure. pub triangles_affected: u32, } /// Resource queue for contamination events. /// /// Follows the same pattern as `TriangleCrisisEventQueue` — /// populated by the storyteller system, drained by consumers. #[derive(Resource, Default)] pub struct ContaminationEventQueue { pub events: Vec, } impl ContaminationEventQueue { pub fn push(&mut self, event: ContaminationEvent) { self.events.push(event); } pub fn drain(&mut self) -> Vec { std::mem::take(&mut self.events) } pub fn is_empty(&self) -> bool { self.events.is_empty() } } // --------------------------------------------------------------------------- // Plugin // --------------------------------------------------------------------------- /// Storyteller plugin — manages narrative pacing and event generation. pub struct StorytellerPlugin; impl Plugin for StorytellerPlugin { fn build(&self, app: &mut App) { app.init_resource::() .init_resource::() .add_systems(Update, tick_contamination_activation); tracing::debug!("StorytellerPlugin initialized"); } } // --------------------------------------------------------------------------- // Systems // --------------------------------------------------------------------------- /// System: activate contamination after the configured delay (#254). /// /// One-shot: checks every tick whether the delay has elapsed. When it fires: /// 1. Sets `ContaminationActive` to true /// 2. Increments `tension` on all `ActiveFork` triangles by `CONTAMINATION_PRESSURE_DELTA` /// 3. Emits a `ContaminationEvent` /// /// After activation, the system early-returns on subsequent ticks. pub fn tick_contamination_activation( time: Res, mut contamination: ResMut, mut event_queue: ResMut, mut triangles: Query<&mut TriangleState, With>, ) { // Already activated — nothing to do if contamination.0 { return; } // Not yet time if time.tick < CONTAMINATION_DELAY_TICKS { return; } // Activate contamination contamination.0 = true; let mut affected = 0u32; for mut state in &mut triangles { if state.classification == TriangleClassification::ActiveFork { state.tension = state.tension.saturating_add(CONTAMINATION_PRESSURE_DELTA); affected += 1; } } event_queue.push(ContaminationEvent { tick: time.tick, triangles_affected: affected, }); tracing::info!( "Contamination activated at tick {} — {} ActiveFork triangles pressured (+{})", time.tick, affected, CONTAMINATION_PRESSURE_DELTA, ); } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use crate::content::template::{ RoleId, TemplateId, TriangleClassification, TriangleId, TrianglePhase, TriangleState, }; use crate::knowledge::types::StableId; use std::collections::BTreeMap; fn make_triangle(classification: TriangleClassification) -> TriangleState { let mut role_assignments = BTreeMap::new(); role_assignments.insert(RoleId::new("a"), StableId(1)); role_assignments.insert(RoleId::new("b"), StableId(2)); role_assignments.insert(RoleId::new("c"), StableId(3)); TriangleState { triangle_id: TriangleId::from_seed_and_slug(0, "test"), role_assignments, tension: 0, phase: match classification { TriangleClassification::ActiveFork => TrianglePhase::Simmering, TriangleClassification::PassiveTension => TrianglePhase::Dormant, }, tension_rate: 1, template_id: TemplateId::from_seed_and_slug(0, "test"), classification, } } fn setup_world() -> (World, Schedule) { let mut world = World::new(); world.init_resource::(); world.init_resource::(); world.init_resource::(); let mut schedule = Schedule::default(); schedule.add_systems(tick_contamination_activation); (world, schedule) } #[test] fn no_activation_before_delay() { let (mut world, mut schedule) = setup_world(); // Spawn an ActiveFork triangle world.spawn((make_triangle(TriangleClassification::ActiveFork), ActiveSim)); // Run at tick 0 — should not activate schedule.run(&mut world); assert!(!world.resource::().0); assert!(world.resource::().is_empty()); } #[test] fn activates_at_delay_tick() { let (mut world, mut schedule) = setup_world(); world.spawn((make_triangle(TriangleClassification::ActiveFork), ActiveSim)); // Set tick to exactly the delay threshold world.resource_mut::().tick = CONTAMINATION_DELAY_TICKS; schedule.run(&mut world); assert!(world.resource::().0); assert!(!world.resource::().is_empty()); let events = world.resource_mut::().drain(); assert_eq!(events.len(), 1); assert_eq!(events[0].tick, CONTAMINATION_DELAY_TICKS); assert_eq!(events[0].triangles_affected, 1); } #[test] fn only_active_fork_triangles_pressured() { let (mut world, mut schedule) = setup_world(); // Spawn one ActiveFork and one PassiveTension let fork_entity = world .spawn((make_triangle(TriangleClassification::ActiveFork), ActiveSim)) .id(); let passive_entity = world .spawn(( make_triangle(TriangleClassification::PassiveTension), ActiveSim, )) .id(); world.resource_mut::().tick = CONTAMINATION_DELAY_TICKS; schedule.run(&mut world); // ActiveFork should have tension increased let fork_state = world.get::(fork_entity).unwrap(); assert_eq!(fork_state.tension, CONTAMINATION_PRESSURE_DELTA); // PassiveTension should remain at 0 let passive_state = world.get::(passive_entity).unwrap(); assert_eq!(passive_state.tension, 0); // Event should report 1 affected (only the ActiveFork) let events = world.resource_mut::().drain(); assert_eq!(events[0].triangles_affected, 1); } #[test] fn one_shot_does_not_fire_twice() { let (mut world, mut schedule) = setup_world(); let entity = world .spawn((make_triangle(TriangleClassification::ActiveFork), ActiveSim)) .id(); // First activation world.resource_mut::().tick = CONTAMINATION_DELAY_TICKS; schedule.run(&mut world); let tension_after_first = world.get::(entity).unwrap().tension; assert_eq!(tension_after_first, CONTAMINATION_PRESSURE_DELTA); // Drain events world.resource_mut::().drain(); // Second run — should NOT apply pressure again world.resource_mut::().tick = CONTAMINATION_DELAY_TICKS + 100; schedule.run(&mut world); let tension_after_second = world.get::(entity).unwrap().tension; assert_eq!( tension_after_second, tension_after_first, "one-shot: tension should not increase on subsequent ticks" ); assert!( world.resource::().is_empty(), "one-shot: no new events after first activation" ); } #[test] fn tension_saturates_at_max() { let (mut world, mut schedule) = setup_world(); // Start with tension near max let mut triangle = make_triangle(TriangleClassification::ActiveFork); triangle.tension = 250; world.spawn((triangle, ActiveSim)); world.resource_mut::().tick = CONTAMINATION_DELAY_TICKS; schedule.run(&mut world); // Should saturate at 255, not overflow let mut q = world.query::<&TriangleState>(); let state = q.single(&world).unwrap(); assert_eq!(state.tension, 255); } }