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>
315 lines
11 KiB
Rust
315 lines
11 KiB
Rust
//! 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).
|
||
/// 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 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);
|
||
}
|
||
}
|