Files
settled-reach/server/src/storyteller/mod.rs
T
jpmschweitzerandClaude Opus 4.6 70de959ac3 fix(simulation): correct CONTAMINATION_DELAY_TICKS from 1800 to 300
The constant was supposed to represent 30 game-minutes but the formula
was wrong (30 × 10 tps × 60s = 1800). Correct derivation: 30 minutes ×
TICKS_PER_GAME_MINUTE (10) = 300. Now uses the canonical constant
directly. Also fixes stale assertion message in integration test.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 23:24:47 +01:00

315 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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).
/// 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 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<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) {
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);
}
}