From b126e1830d55aa271723b447f48b4fa893f46d5b Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 28 Feb 2026 16:34:47 +0100 Subject: [PATCH] feat(simulation): add contamination activation mechanic (#254) Timer-based storyteller system fires after 1800 ticks (30 game-min). Sets ContaminationActive resource, applies tension delta to all ActiveFork triangles, and emits ContaminationEvent for downstream monologue/behavioral hooks. Q-017 fallback constants in place. Co-Authored-By: Claude Opus 4.6 --- server/src/storyteller/mod.rs | 311 +++++++++++++++++++++++++++++++++- server/tests/contamination.rs | 153 +++++++++++++++++ 2 files changed, 458 insertions(+), 6 deletions(-) create mode 100644 server/tests/contamination.rs diff --git a/server/src/storyteller/mod.rs b/server/src/storyteller/mod.rs index 34a0fc9f7..dd997ed5a 100644 --- a/server/src/storyteller/mod.rs +++ b/server/src/storyteller/mod.rs @@ -1,15 +1,314 @@ -// Storyteller module - Rimworld-style storyteller system -// Event generation, pacing, hubris wall mechanics +//! 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 1800 = 30 game-minutes at +//! 10 tps), 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::*; -/// Storyteller plugin -/// Manages narrative pacing and event generation +use crate::content::template::{TriangleClassification, TriangleState}; +use crate::simulation::tier::ActiveSim; +use crate::simulation::time::SimulationTime; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/// Tick at which contamination activates (30 game-minutes × 10 tps × 60s = 1800). +pub const CONTAMINATION_DELAY_TICKS: u64 = 1800; + +/// 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 via save state (future — currently session-scoped). +#[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) { - // Stub implementation - will be populated in phase 2 + 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); + } +} diff --git a/server/tests/contamination.rs b/server/tests/contamination.rs new file mode 100644 index 000000000..4ff1b6649 --- /dev/null +++ b/server/tests/contamination.rs @@ -0,0 +1,153 @@ +//! Integration test: contamination activation mechanic (#254). +//! +//! Verifies that the storyteller activates contamination after the configured +//! delay and that all ActiveFork triangles receive pressure. + +use bevy_ecs::prelude::*; +use bevy_ecs::schedule::Schedule; +use std::collections::BTreeMap; + +use settled_reach_server::content::template::{ + RoleId, TemplateId, TriangleClassification, TriangleId, TrianglePhase, TriangleState, +}; +use settled_reach_server::knowledge::types::StableId; +use settled_reach_server::simulation::tier::ActiveSim; +use settled_reach_server::simulation::time::SimulationTime; +use settled_reach_server::storyteller::{ + tick_contamination_activation, ContaminationActive, ContaminationEventQueue, + CONTAMINATION_DELAY_TICKS, CONTAMINATION_PRESSURE_DELTA, +}; + +fn make_triangle( + slug: &str, + classification: TriangleClassification, + base_ids: [u64; 3], +) -> TriangleState { + let mut role_assignments = BTreeMap::new(); + role_assignments.insert(RoleId::new("role-a"), StableId(base_ids[0])); + role_assignments.insert(RoleId::new("role-b"), StableId(base_ids[1])); + role_assignments.insert(RoleId::new("role-c"), StableId(base_ids[2])); + + TriangleState { + triangle_id: TriangleId::from_seed_and_slug(0, slug), + 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, "authored"), + classification, + } +} + +/// Acceptance test: run 1801 ticks, assert ContaminationActive is set and +/// all ActiveFork TriangleState entities have tension > 0. +#[test] +fn contamination_activates_after_1801_ticks() { + let mut world = World::new(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + + // Spawn 3 ActiveFork + 2 PassiveTension triangles (matching real content) + let fork1 = world + .spawn(( + make_triangle("hub-power", TriangleClassification::ActiveFork, [1, 2, 3]), + ActiveSim, + )) + .id(); + let fork2 = world + .spawn(( + make_triangle( + "bar-tensions", + TriangleClassification::ActiveFork, + [4, 5, 6], + ), + ActiveSim, + )) + .id(); + let fork3 = world + .spawn(( + make_triangle( + "informant-question", + TriangleClassification::ActiveFork, + [7, 8, 9], + ), + ActiveSim, + )) + .id(); + let passive1 = world + .spawn(( + make_triangle( + "worried-knowledge", + TriangleClassification::PassiveTension, + [10, 11, 12], + ), + ActiveSim, + )) + .id(); + let passive2 = world + .spawn(( + make_triangle( + "worried-partner", + TriangleClassification::PassiveTension, + [13, 14, 15], + ), + ActiveSim, + )) + .id(); + + let mut schedule = Schedule::default(); + schedule.add_systems(tick_contamination_activation); + + // Simulate 1801 ticks + for tick in 0..=1800 { + world.resource_mut::().tick = tick; + schedule.run(&mut world); + } + + // Assert ContaminationActive is set + assert!( + world.resource::().0, + "ContaminationActive must be true after 1801 ticks" + ); + + // Assert all ActiveFork triangles have tension > 0 + for (label, entity) in [("hub-power", fork1), ("bar-tensions", fork2), ("informant-question", fork3)] { + let state = world + .get::(entity) + .unwrap_or_else(|| panic!("{} triangle entity should exist", label)); + assert!( + state.tension > 0, + "ActiveFork triangle '{}' should have tension > 0 after contamination, got {}", + label, + state.tension + ); + assert_eq!( + state.tension, CONTAMINATION_PRESSURE_DELTA, + "ActiveFork triangle '{}' tension should be exactly {} (contamination delta)", + label, + CONTAMINATION_PRESSURE_DELTA + ); + } + + // Assert PassiveTension triangles were NOT pressured + for (label, entity) in [("worried-knowledge", passive1), ("worried-partner", passive2)] { + let state = world + .get::(entity) + .unwrap_or_else(|| panic!("{} triangle entity should exist", label)); + assert_eq!( + state.tension, 0, + "PassiveTension triangle '{}' should have tension 0 (not affected by contamination)", + label + ); + } + + // Assert exactly one ContaminationEvent was emitted + let events = world.resource_mut::().drain(); + assert_eq!(events.len(), 1, "exactly one ContaminationEvent expected"); + assert_eq!(events[0].tick, CONTAMINATION_DELAY_TICKS); + assert_eq!(events[0].triangles_affected, 3); +}