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);
}
}
+153
View File
@@ -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::<SimulationTime>();
world.init_resource::<ContaminationActive>();
world.init_resource::<ContaminationEventQueue>();
// 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::<SimulationTime>().tick = tick;
schedule.run(&mut world);
}
// Assert ContaminationActive is set
assert!(
world.resource::<ContaminationActive>().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::<TriangleState>(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::<TriangleState>(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::<ContaminationEventQueue>().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);
}