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:
@@ -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<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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user