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 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 16:34:47 +01:00
co-authored by Claude Opus 4.6
parent 42febf4059
commit b126e1830d
2 changed files with 458 additions and 6 deletions
+305 -6
View File
@@ -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).
/// 0100 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<ContaminationEvent>,
}
impl ContaminationEventQueue {
pub fn push(&mut self, event: ContaminationEvent) {
self.events.push(event);
}
pub fn drain(&mut self) -> Vec<ContaminationEvent> {
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::<ContaminationActive>()
.init_resource::<ContaminationEventQueue>()
.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<SimulationTime>,
mut contamination: ResMut<ContaminationActive>,
mut event_queue: ResMut<ContaminationEventQueue>,
mut triangles: Query<&mut TriangleState, With<ActiveSim>>,
) {
// 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::<SimulationTime>();
world.init_resource::<ContaminationActive>();
world.init_resource::<ContaminationEventQueue>();
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::<ContaminationActive>().0);
assert!(world.resource::<ContaminationEventQueue>().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::<SimulationTime>().tick = CONTAMINATION_DELAY_TICKS;
schedule.run(&mut world);
assert!(world.resource::<ContaminationActive>().0);
assert!(!world.resource::<ContaminationEventQueue>().is_empty());
let events = world.resource_mut::<ContaminationEventQueue>().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::<SimulationTime>().tick = CONTAMINATION_DELAY_TICKS;
schedule.run(&mut world);
// ActiveFork should have tension increased
let fork_state = world.get::<TriangleState>(fork_entity).unwrap();
assert_eq!(fork_state.tension, CONTAMINATION_PRESSURE_DELTA);
// PassiveTension should remain at 0
let passive_state = world.get::<TriangleState>(passive_entity).unwrap();
assert_eq!(passive_state.tension, 0);
// Event should report 1 affected (only the ActiveFork)
let events = world.resource_mut::<ContaminationEventQueue>().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::<SimulationTime>().tick = CONTAMINATION_DELAY_TICKS;
schedule.run(&mut world);
let tension_after_first = world.get::<TriangleState>(entity).unwrap().tension;
assert_eq!(tension_after_first, CONTAMINATION_PRESSURE_DELTA);
// Drain events
world.resource_mut::<ContaminationEventQueue>().drain();
// Second run — should NOT apply pressure again
world.resource_mut::<SimulationTime>().tick = CONTAMINATION_DELAY_TICKS + 100;
schedule.run(&mut world);
let tension_after_second = world.get::<TriangleState>(entity).unwrap().tension;
assert_eq!(
tension_after_second, tension_after_first,
"one-shot: tension should not increase on subsequent ticks"
);
assert!(
world.resource::<ContaminationEventQueue>().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::<SimulationTime>().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);
}
}