Files
settled-reach/server/src/storyteller/mod.rs
T
jpmschweitzerandClaude Opus 4.6 ac68ec6eef fix(simulation): address PR #81 review — critical and high-priority issues
Critical fixes:
- storyteller: replace .expect() with guard + log in activation_pass (Hoshe #1)
- content/loader: validate inverted tile_bounds before iteration (Hoshe #C)
- save_io: persist ActivationState on save/load (Tyre #7)

High-priority fixes:
- storyteller: f64 intermediate for observation_time_ticks scoring (Hoshe #A)
- storyteller: deduplicate copresent entities before scoring (Hoshe #B)
- storyteller: skip activation_pass at tick 0 (Hoshe #G)
- storyteller: explicit .before(advance_tick) ordering (Tyre #9)
- save_state: insert EngagementRecord on NPC deserialize (Tyre #10)
- save_io: reset TriangleActivatedQueue + MovementHistoryBuffer on load (Tyre #8)
- debug: validate teleport target walkability (Hoshe #E)
- debug: reject SkipToContamination when tick past delay (Hoshe #F)
- debug: DebugEnabled defaults to cfg!(debug_assertions) (Hoshe #3)
- debug: log when response overwritten (Hoshe #2)
- content/loader: error on tile dimension mismatch (Hoshe #5)
- content/types: DistrictMeta.description optional (Hoshe #D)
- types: version docs updated to v18 (Tyre #1, #2, #3)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 23:40:59 +01:00

1085 lines
40 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.)
//!
//! ## Activation Lifecycle (#572)
//!
//! After contamination, the activation pass (#162, #579) runs on a 10-tick
//! cadence, scoring NPCs by engagement and activating the best-fit triangle.
//!
//! **v0.1 lifecycle rules (single-activation model):**
//!
//! 1. **One activation per session.** The vertical slice is a ~30-minute
//! experience. Once a triangle is activated, the storyteller does not
//! activate additional triangles. `ActivationState::Activated` is the
//! terminal state for the storyteller within a single playthrough.
//!
//! 2. **No concurrent activations.** At most one triangle can be in the
//! `TrianglePhase::Active` state at any time. This is trivially enforced
//! by rule 1 for v0.1 — future versions (v0.3+) will need a concurrency
//! limit and priority queue.
//!
//! 3. **No cooldown.** Since only one activation fires, there is no cooldown
//! period between activations. Future multi-activation will need
//! `ACTIVATION_COOLDOWN_TICKS` — stub the constant now at 0.
//!
//! 4. **Resolution is terminal.** When a triangle reaches `TrianglePhase::Resolved`
//! (player completes investigation or consequences fire), it stays resolved.
//! The storyteller does not re-activate resolved triangles. For v0.1 this
//! is moot (one-shot), but the activation pass must still check for it.
//!
//! 5. **Activation cadence.** The activation pass polls every
//! `ACTIVATION_CADENCE_TICKS` (10 ticks = 1 game-second). This is cheap
//! because the pass no-ops if `ActivationState` is already `Activated`.
//!
//! **Future extensions (v0.3+):**
//! - `MAX_CONCURRENT_ACTIVATIONS` > 1
//! - `ACTIVATION_COOLDOWN_TICKS` > 0 between successive activations
//! - `SelectionStrategy::WeightedRandom` alongside `HighestScore`
//! - Hubris wall gating (activation blocked above a threshold)
use std::collections::VecDeque;
use bevy_app::prelude::*;
use bevy_ecs::prelude::*;
use rand::Rng;
use crate::content::template::{TriangleClassification, TriangleId, TrianglePhase, TriangleState};
use crate::knowledge::EntityRegistry;
use crate::npc::Npc;
use crate::simulation::movement::{PlayerCharacter, TilePosition};
use crate::simulation::rng::SimRng;
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;
// --- Activation lifecycle constants (#572) ----------------------------------
/// How often the activation pass runs, in ticks.
/// 10 ticks = 1 game-second (D-031). Cheap — no-ops if already activated.
pub const ACTIVATION_CADENCE_TICKS: u64 = 10;
/// Maximum concurrent triangle activations. v0.1 = 1 (single-activation).
pub const MAX_CONCURRENT_ACTIVATIONS: u32 = 1;
/// Cooldown between successive activations, in ticks. v0.1 = 0 (one-shot).
/// Future multi-activation (v0.3+) will set this to e.g. 600 (1 game-minute).
pub const ACTIVATION_COOLDOWN_TICKS: u64 = 0;
/// Engagement window for NPC co-presence scoring (#162 step 2).
/// 3000 ticks = 5 game-minutes = ~5 real minutes at 10 tps.
pub const ENGAGEMENT_WINDOW_TICKS: u64 = 3000;
// --- Engagement scoring weights (#162 step 3) -------------------------------
/// Weight per conversation (capped at `CONVERSATION_CAP`).
pub const ENGAGEMENT_WEIGHT_CONVERSATION: f32 = 2.0;
/// Maximum conversations counted toward engagement score.
pub const CONVERSATION_CAP: u32 = 5;
/// Weight per tick of observation time.
/// 200 ticks (~20 real seconds) = 1 point.
pub const ENGAGEMENT_WEIGHT_OBSERVATION: f32 = 0.005;
/// Weight per monologue trigger — richest signal.
pub const ENGAGEMENT_WEIGHT_MONOLOGUE: f32 = 8.0;
/// Proximity threshold (Chebyshev tiles) for co-presence detection in activation_pass.
/// Matches NaturalVision forward_range (D-015, D-017): 20 tiles.
pub const COPRESENCE_THRESHOLD: i32 = 20;
// ---------------------------------------------------------------------------
// Resources
// ---------------------------------------------------------------------------
/// Per-NPC engagement metrics for storyteller activation scoring (#570, #162).
///
/// Tracks cumulative engagement signals the player has generated with a specific NPC.
/// Used by `activation_pass()` (#579) to score NPCs and select the best-fit triangle.
///
/// All three fields are additive counters — never overwritten, only incremented.
///
/// **Write sites:**
/// - `observation_time_ticks`: `perception::observation::emit_observation_events` — +1 per tick in LOS
/// - `conversation_count`: `simulation::dialogue::process_talk_interaction` — +1 on Talk start
/// - `monologue_trigger_count`:`simulation::monologue::trigger_event_monologue` — +1 on NPC-context fire
#[derive(Component, Debug, Clone, Default)]
pub struct EngagementRecord {
/// Cumulative ticks this NPC was in the player's direct LOS.
pub observation_time_ticks: u64,
/// Number of player-initiated conversation starts with this NPC.
pub conversation_count: u32,
/// Number of monologue triggers fired in this NPC's context
/// (observe_npc or post_conversation referencing this entity).
pub monologue_trigger_count: u32,
}
/// 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);
/// Storyteller activation state (#572).
///
/// Tracks how many triangles have been activated this session. For v0.1 this
/// is a simple boolean gate — once `activated_count >= MAX_CONCURRENT_ACTIVATIONS`,
/// the activation pass no-ops. Persisted in `SaveStateV1`.
#[derive(Resource, Debug, Clone)]
pub struct ActivationState {
/// Number of triangles activated this session.
pub activated_count: u32,
/// Tick at which the most recent activation occurred. `None` if no activation yet.
pub last_activation_tick: Option<u64>,
}
impl Default for ActivationState {
fn default() -> Self {
Self {
activated_count: 0,
last_activation_tick: None,
}
}
}
impl ActivationState {
/// Whether the activation pass should attempt to activate a new triangle.
///
/// For v0.1 this returns `false` after the first activation. Future versions
/// will also check cooldown elapsed since `last_activation_tick`.
pub fn can_activate(&self, _current_tick: u64) -> bool {
if self.activated_count >= MAX_CONCURRENT_ACTIVATIONS {
return false;
}
// v0.1: cooldown is 0, so no cooldown check needed.
// Future: check (current_tick - last_activation_tick) >= ACTIVATION_COOLDOWN_TICKS
if ACTIVATION_COOLDOWN_TICKS > 0 {
if let Some(last) = self.last_activation_tick {
if _current_tick.saturating_sub(last) < ACTIVATION_COOLDOWN_TICKS {
return false;
}
}
}
true
}
/// Record that a triangle was activated at the given tick.
pub fn record_activation(&mut self, tick: u64) {
self.activated_count += 1;
self.last_activation_tick = Some(tick);
}
}
// ---------------------------------------------------------------------------
// 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()
}
}
/// Emitted when the activation pass selects and activates a triangle (#162, #572).
///
/// Downstream consumers:
/// - Tell system (D-024 axis 9): escalate tell behavior frequency
/// - Routine scheduler: activated triangle NPCs may deviate from routine
/// - Monologue system (D-016): unlock `triangle_activated` context lines
/// - FRIEND arc manager (D-034): advance relationship phase if applicable
#[derive(Debug, Clone)]
pub struct TriangleActivatedEvent {
/// Which triangle was activated.
pub triangle_id: TriangleId,
/// Tick at which activation occurred.
pub tick: u64,
/// The NPC entity that scored highest (activation anchor).
pub anchor_entity: Entity,
/// Engagement score of the anchor NPC.
pub anchor_score: f32,
}
/// Resource queue for triangle activation events.
///
/// Same drain pattern as `ContaminationEventQueue`. Populated by
/// `activation_pass()` (#579), consumed by downstream systems.
#[derive(Resource, Default)]
pub struct TriangleActivatedQueue {
pub events: Vec<TriangleActivatedEvent>,
}
impl TriangleActivatedQueue {
pub fn push(&mut self, event: TriangleActivatedEvent) {
self.events.push(event);
}
pub fn drain(&mut self) -> Vec<TriangleActivatedEvent> {
std::mem::take(&mut self.events)
}
pub fn is_empty(&self) -> bool {
self.events.is_empty()
}
}
// ---------------------------------------------------------------------------
// Movement history (#571)
// ---------------------------------------------------------------------------
/// Ring buffer of recent player tile positions for storyteller proximity scoring.
///
/// Retains the player's path over the last `ENGAGEMENT_WINDOW_TICKS` ticks.
/// Provides `npcs_copresent_in_window` to identify which NPCs have shared
/// space with the player recently, feeding the activation pass (#162, #579).
#[derive(Resource, Debug, Clone)]
pub struct MovementHistoryBuffer {
positions: VecDeque<TilePosition>,
}
impl Default for MovementHistoryBuffer {
fn default() -> Self {
Self {
positions: VecDeque::with_capacity(ENGAGEMENT_WINDOW_TICKS as usize),
}
}
}
impl MovementHistoryBuffer {
/// Append the player's current position for this tick.
///
/// Maintains a maximum of `ENGAGEMENT_WINDOW_TICKS` entries by evicting
/// the oldest position when the buffer is full.
pub fn append(&mut self, pos: TilePosition) {
if self.positions.len() >= ENGAGEMENT_WINDOW_TICKS as usize {
self.positions.pop_front();
}
self.positions.push_back(pos);
}
/// Returns entities from `npc_positions` whose position is within
/// `threshold` tiles (Chebyshev distance) of any recorded player position
/// in the buffer on the same z-level.
///
/// Used by the activation pass (#579) to identify which NPCs the player
/// was co-present with during the engagement window.
pub fn npcs_copresent_in_window(
&self,
npc_positions: impl Iterator<Item = (Entity, TilePosition)>,
threshold: i32,
) -> Vec<Entity> {
npc_positions
.filter(|(_, npc_pos)| {
self.positions.iter().any(|player_pos| {
player_pos.z == npc_pos.z
&& (player_pos.x - npc_pos.x).abs() <= threshold
&& (player_pos.y - npc_pos.y).abs() <= threshold
})
})
.map(|(entity, _)| entity)
.collect()
}
/// Number of recorded positions in the buffer.
pub fn len(&self) -> usize {
self.positions.len()
}
/// True if the buffer has no recorded positions.
pub fn is_empty(&self) -> bool {
self.positions.is_empty()
}
}
/// System: append the player's current position to the movement history buffer.
///
/// Runs each tick after `validate_movement`. Maintains the sliding
/// `ENGAGEMENT_WINDOW_TICKS` window consumed by the activation pass (#162).
pub fn append_player_history(
mut history: ResMut<MovementHistoryBuffer>,
player_query: Query<&TilePosition, With<PlayerCharacter>>,
) {
if let Ok(pos) = player_query.single() {
history.append(*pos);
}
}
// ---------------------------------------------------------------------------
// 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>()
.init_resource::<ActivationState>()
.init_resource::<TriangleActivatedQueue>()
.init_resource::<MovementHistoryBuffer>()
.add_systems(Update, tick_contamination_activation)
.add_systems(
Update,
append_player_history
.after(crate::simulation::movement::validate_movement),
)
.add_systems(
Update,
activation_pass
.after(append_player_history)
.after(tick_contamination_activation)
.before(crate::simulation::time::advance_tick),
);
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,
);
}
/// Helper: compute engagement score from an EngagementRecord.
///
/// Formula: capped conversations × weight + observation ticks × weight + monologue triggers × weight.
/// Implements #162 step 3 scoring.
fn compute_engagement_score(record: &EngagementRecord) -> f32 {
let conv_score = record.conversation_count.min(CONVERSATION_CAP) as f32
* ENGAGEMENT_WEIGHT_CONVERSATION;
// Use f64 intermediate to avoid precision loss beyond ~16.8M ticks (2^24),
// where f32 can no longer distinguish adjacent integers.
let obs_score = (record.observation_time_ticks as f64 * ENGAGEMENT_WEIGHT_OBSERVATION as f64) as f32;
let mono_score = record.monologue_trigger_count as f32 * ENGAGEMENT_WEIGHT_MONOLOGUE;
conv_score + obs_score + mono_score
}
/// System: storyteller activation pass — select and activate the best-fit triangle.
///
/// Implements #162 steps 16. Runs on a 10-tick cadence after contamination fires.
///
/// Steps:
/// 1. Gate: contamination active + activation limit not reached + cadence check.
/// 2. Co-presence query: find NPCs near any recorded player position (MovementHistoryBuffer).
/// 3. Engagement scoring: score each co-present NPC via EngagementRecord.
/// 4+5. Triangle routing: find Simmering triangle containing highest-scoring co-present NPC.
/// v0.1: NPCs not assigned to any triangle are excluded (D-025 reference link routing deferred to v0.3+).
/// Holds (no activation) if no triangle-assigned NPC is co-present.
/// 6. Activation event: emit TriangleActivatedEvent, record in ActivationState.
#[allow(clippy::too_many_arguments)]
pub fn activation_pass(
time: Res<SimulationTime>,
contamination: Res<ContaminationActive>,
mut activation_state: ResMut<ActivationState>,
history: Res<MovementHistoryBuffer>,
registry: Res<EntityRegistry>,
mut rng: ResMut<SimRng>,
mut triangles: Query<(Entity, &mut TriangleState), With<ActiveSim>>,
npcs: Query<(Entity, &TilePosition, Option<&EngagementRecord>), (With<Npc>, With<ActiveSim>)>,
mut event_queue: ResMut<TriangleActivatedQueue>,
) {
// Gate 1: contamination must be active
if !contamination.0 {
return;
}
// Gate 2: activation limit
if !activation_state.can_activate(time.tick) {
return;
}
// Gate 3: poll cadence — only run every ACTIVATION_CADENCE_TICKS.
// Guard: at tick 0 all engagement scores are 0.0 and selection is random
// (e.g. contamination pre-set in a save file). Skip tick 0.
if time.tick == 0 || time.tick % ACTIVATION_CADENCE_TICKS != 0 {
return;
}
// Step 2: co-presence query
let npc_positions: Vec<(Entity, TilePosition)> =
npcs.iter().map(|(e, pos, _)| (e, *pos)).collect();
let mut copresent = history.npcs_copresent_in_window(npc_positions.into_iter(), COPRESENCE_THRESHOLD);
// Deduplicate: the co-presence query can return the same entity more than once
// if multiple player positions fall near the same NPC. Without dedup, the NPC
// gets added to candidates twice, doubling its RNG weight.
copresent.sort_unstable();
copresent.dedup();
// Snapshot simmering triangles for read (avoids double-borrow during activation write)
let simmering: Vec<(Entity, TriangleState)> = triangles
.iter()
.filter(|(_, s)| s.phase == TrianglePhase::Simmering)
.map(|(e, s)| (e, s.clone()))
.collect();
if simmering.is_empty() {
tracing::warn!("activation_pass: no Simmering triangles — holding");
return;
}
// Steps 3+4: score co-present NPCs that belong to a Simmering triangle.
// Unentangled NPCs (not in any triangle) are excluded; v0.1 skips D-025 routing.
let mut candidates: Vec<(Entity, Entity, f32)> = Vec::new(); // (npc_entity, tri_entity, score)
for &npc_entity in &copresent {
let Some(stable_id) = registry.to_stable(npc_entity) else {
continue;
};
let score = npcs
.get(npc_entity)
.ok()
.and_then(|(_, _, r)| r)
.map(compute_engagement_score)
.unwrap_or(0.0);
for (tri_entity, tri_state) in &simmering {
if tri_state
.role_assignments
.values()
.any(|sid| *sid == stable_id)
{
candidates.push((npc_entity, *tri_entity, score));
break; // each NPC is assigned to at most one triangle per pass
}
}
}
if candidates.is_empty() {
tracing::warn!(
"activation_pass: no co-present NPC is assigned to a Simmering triangle at tick {} — holding",
time.tick
);
return;
}
// Step 5: select best candidate; SimRng tie-break among equal top scores (D-010)
candidates.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal));
let top_score = candidates[0].2;
let top_count = candidates
.iter()
.take_while(|(_, _, s)| (*s - top_score).abs() <= f32::EPSILON * top_score.abs().max(1.0))
.count();
let selected_idx = if top_count > 1 {
rng.rng.random_range(0..top_count)
} else {
0
};
let (anchor_entity, tri_entity, anchor_score) = candidates[selected_idx];
// Retrieve triangle_id for the event.
// Guard: the entity was in the simmering snapshot we collected above, but in
// theory it could have been despawned between snapshot and lookup (unlikely but
// a panic in the gameplay loop is unacceptable).
let Some(triangle_id) = simmering
.iter()
.find(|(e, _)| *e == tri_entity)
.map(|(_, s)| s.triangle_id)
else {
tracing::error!(
"activation_pass: target triangle entity {:?} missing from simmering snapshot — skipping activation at tick {}",
tri_entity, time.tick
);
return;
};
// Step 6: set triangle phase to Active
if let Ok((_, mut tri_state)) = triangles.get_mut(tri_entity) {
tri_state.phase = TrianglePhase::Active;
}
// Step 7: record activation and emit event
activation_state.record_activation(time.tick);
event_queue.push(TriangleActivatedEvent {
triangle_id,
tick: time.tick,
anchor_entity,
anchor_score,
});
tracing::info!(
"activation_pass: activated triangle {} at tick {} (anchor score: {:.2})",
triangle_id.0,
time.tick,
anchor_score,
);
}
// ---------------------------------------------------------------------------
// 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);
}
// --- ActivationState lifecycle tests (#572) ---
#[test]
fn activation_state_allows_first_activation() {
let state = ActivationState::default();
assert!(state.can_activate(500));
}
#[test]
fn activation_state_blocks_after_max() {
let mut state = ActivationState::default();
state.record_activation(500);
// v0.1: MAX_CONCURRENT_ACTIVATIONS = 1, so second is blocked
assert!(!state.can_activate(600));
}
#[test]
fn activation_state_records_tick() {
let mut state = ActivationState::default();
assert_eq!(state.activated_count, 0);
assert!(state.last_activation_tick.is_none());
state.record_activation(1000);
assert_eq!(state.activated_count, 1);
assert_eq!(state.last_activation_tick, Some(1000));
}
#[test]
fn triangle_activated_queue_drain() {
let mut queue = TriangleActivatedQueue::default();
assert!(queue.is_empty());
let mut world = World::new();
let entity = world.spawn_empty().id();
queue.push(TriangleActivatedEvent {
triangle_id: TriangleId::from_seed_and_slug(0, "test"),
tick: 500,
anchor_entity: entity,
anchor_score: 12.5,
});
assert!(!queue.is_empty());
let events = queue.drain();
assert_eq!(events.len(), 1);
assert!(queue.is_empty());
}
// --- MovementHistoryBuffer tests (#571) ---
#[test]
fn movement_history_append_and_len() {
let mut buf = MovementHistoryBuffer::default();
assert!(buf.is_empty());
buf.append(TilePosition::new(1, 2, 0));
buf.append(TilePosition::new(3, 4, 0));
assert_eq!(buf.len(), 2);
}
#[test]
fn movement_history_evicts_oldest_at_capacity() {
let mut buf = MovementHistoryBuffer::default();
// Fill to capacity
for i in 0..ENGAGEMENT_WINDOW_TICKS as i32 {
buf.append(TilePosition::new(i, 0, 0));
}
assert_eq!(buf.len(), ENGAGEMENT_WINDOW_TICKS as usize);
// One more — should evict x=0
buf.append(TilePosition::new(9999, 0, 0));
assert_eq!(
buf.len(),
ENGAGEMENT_WINDOW_TICKS as usize,
"buffer must not grow beyond ENGAGEMENT_WINDOW_TICKS"
);
// The oldest entry (x=0) should be gone; newest (x=9999) should be present
let mut world = World::new();
let entity_old = world.spawn_empty().id();
let entity_new = world.spawn_empty().id();
let copresent = buf.npcs_copresent_in_window(
[(entity_old, TilePosition::new(0, 0, 0))].into_iter(),
0,
);
assert!(
copresent.is_empty(),
"NPC at evicted position x=0 should not be copresent"
);
let copresent_new = buf.npcs_copresent_in_window(
[(entity_new, TilePosition::new(9999, 0, 0))].into_iter(),
0,
);
assert_eq!(copresent_new.len(), 1, "NPC at newest position should be copresent");
}
#[test]
fn npcs_copresent_finds_nearby_npc() {
let mut buf = MovementHistoryBuffer::default();
buf.append(TilePosition::new(10, 10, 0));
buf.append(TilePosition::new(11, 10, 0));
let mut world = World::new();
let nearby = world.spawn_empty().id();
let distant = world.spawn_empty().id();
// NPC 1 tile from a history position — within threshold 2
let result = buf.npcs_copresent_in_window(
[
(nearby, TilePosition::new(10, 11, 0)),
(distant, TilePosition::new(50, 50, 0)),
]
.into_iter(),
2,
);
assert!(result.contains(&nearby), "nearby NPC should be copresent");
assert!(!result.contains(&distant), "distant NPC should not be copresent");
}
#[test]
fn npcs_copresent_different_z_not_included() {
let mut buf = MovementHistoryBuffer::default();
buf.append(TilePosition::new(10, 10, 0));
let mut world = World::new();
let npc = world.spawn_empty().id();
// Same x/y but different z
let result = buf.npcs_copresent_in_window(
[(npc, TilePosition::new(10, 10, 1))].into_iter(),
0,
);
assert!(
result.is_empty(),
"NPC on different z-level must not be copresent"
);
}
#[test]
fn append_player_history_system_appends_position() {
let mut world = World::new();
world.init_resource::<MovementHistoryBuffer>();
world.spawn((PlayerCharacter, TilePosition::new(5, 7, 0)));
let mut schedule = Schedule::default();
schedule.add_systems(append_player_history);
schedule.run(&mut world);
let buf = world.resource::<MovementHistoryBuffer>();
assert_eq!(buf.len(), 1);
}
#[test]
fn append_player_history_system_no_player_no_panic() {
// Should silently no-op if no player entity is present
let mut world = World::new();
world.init_resource::<MovementHistoryBuffer>();
let mut schedule = Schedule::default();
schedule.add_systems(append_player_history);
schedule.run(&mut world);
assert!(world.resource::<MovementHistoryBuffer>().is_empty());
}
// --- activation_pass tests (#579) ---
fn setup_activation_world() -> (World, Schedule) {
let mut world = World::new();
world.init_resource::<SimulationTime>();
world.insert_resource(ContaminationActive(true));
world.init_resource::<ActivationState>();
world.init_resource::<MovementHistoryBuffer>();
world.init_resource::<EntityRegistry>();
world.init_resource::<TriangleActivatedQueue>();
world.insert_resource(crate::simulation::rng::SimRng::new(42));
let mut schedule = Schedule::default();
schedule.add_systems(activation_pass);
(world, schedule)
}
#[test]
fn activation_pass_blocked_before_contamination() {
let (mut world, mut schedule) = setup_activation_world();
world.insert_resource(ContaminationActive(false));
world.resource_mut::<SimulationTime>().tick = ACTIVATION_CADENCE_TICKS;
schedule.run(&mut world);
assert!(world.resource::<TriangleActivatedQueue>().is_empty());
assert_eq!(world.resource::<ActivationState>().activated_count, 0);
}
#[test]
fn activation_pass_blocked_off_cadence() {
let (mut world, mut schedule) = setup_activation_world();
// Off-cadence tick (not divisible by ACTIVATION_CADENCE_TICKS)
world.resource_mut::<SimulationTime>().tick = ACTIVATION_CADENCE_TICKS + 3;
schedule.run(&mut world);
assert!(world.resource::<TriangleActivatedQueue>().is_empty());
}
#[test]
fn activation_pass_blocked_after_max_activations() {
let (mut world, mut schedule) = setup_activation_world();
world.resource_mut::<SimulationTime>().tick = ACTIVATION_CADENCE_TICKS;
// Record activation up to the limit
world.resource_mut::<ActivationState>().record_activation(0);
schedule.run(&mut world);
// Should not fire again
assert!(world.resource::<TriangleActivatedQueue>().is_empty());
}
#[test]
fn activation_pass_holds_when_no_triangle_npc_copresent() {
// No NPCs present at all — no candidates, should hold (not activate a random triangle)
let (mut world, mut schedule) = setup_activation_world();
world.resource_mut::<SimulationTime>().tick = ACTIVATION_CADENCE_TICKS;
// Spawn a Simmering triangle (but no NPCs)
world.spawn((make_triangle(TriangleClassification::ActiveFork), ActiveSim));
schedule.run(&mut world);
// Should NOT activate — no co-present NPC is assigned to any triangle
assert!(
world.resource::<TriangleActivatedQueue>().is_empty(),
"activation should hold when no triangle-assigned NPC is copresent"
);
assert_eq!(world.resource::<ActivationState>().activated_count, 0);
}
#[test]
fn activation_pass_activates_triangle_via_copresent_npc() {
let (mut world, mut schedule) = setup_activation_world();
let tick = ACTIVATION_CADENCE_TICKS;
world.resource_mut::<SimulationTime>().tick = tick;
// Register NPC entity in EntityRegistry to get a StableId
let npc_entity = world.spawn((crate::npc::Npc, ActiveSim, TilePosition::new(5, 5, 0))).id();
let stable_id = world.resource_mut::<EntityRegistry>().register(npc_entity);
// Spawn a Simmering triangle with the NPC in a role
let mut tri = make_triangle(TriangleClassification::ActiveFork);
tri.role_assignments.insert(RoleId::new("a"), stable_id);
let tri_entity = world.spawn((tri, ActiveSim)).id();
// Put the player nearby in the history buffer
world.resource_mut::<MovementHistoryBuffer>().append(TilePosition::new(5, 5, 0));
schedule.run(&mut world);
let events = world.resource_mut::<TriangleActivatedQueue>().drain();
assert_eq!(events.len(), 1);
let tri_state = world.get::<TriangleState>(tri_entity).unwrap();
assert_eq!(tri_state.phase, TrianglePhase::Active, "triangle containing copresent NPC should be Active");
}
#[test]
fn activation_pass_does_not_fire_twice() {
// Verify the one-shot guard: a real first activation must happen, then confirm
// the second pass is blocked by ActivationState (not vacuously by empty candidates).
let (mut world, mut schedule) = setup_activation_world();
// Register an NPC and assign it to a triangle role so candidates are non-empty
let npc = world.spawn((crate::npc::Npc, ActiveSim, TilePosition::new(1, 1, 0))).id();
let stable_id = world.resource_mut::<EntityRegistry>().register(npc);
let mut tri = make_triangle(TriangleClassification::ActiveFork);
tri.role_assignments.insert(RoleId::new("a"), stable_id);
world.spawn((tri, ActiveSim));
// Put the player nearby so the NPC is copresent
world.resource_mut::<MovementHistoryBuffer>().append(TilePosition::new(1, 1, 0));
// First run — should activate
world.resource_mut::<SimulationTime>().tick = ACTIVATION_CADENCE_TICKS;
schedule.run(&mut world);
let first_events = world.resource_mut::<TriangleActivatedQueue>().drain();
assert_eq!(first_events.len(), 1, "first pass must activate the triangle");
assert_eq!(
world.resource::<ActivationState>().activated_count, 1,
"ActivationState must record the first activation"
);
// Second run — ActivationState blocks it (v0.1 one-shot)
world.resource_mut::<SimulationTime>().tick = ACTIVATION_CADENCE_TICKS * 2;
schedule.run(&mut world);
assert!(
world.resource::<TriangleActivatedQueue>().is_empty(),
"activation must not fire a second time (v0.1 one-shot)"
);
}
#[test]
fn compute_engagement_score_formula() {
let record = EngagementRecord {
observation_time_ticks: 200,
conversation_count: 3,
monologue_trigger_count: 1,
};
let score = compute_engagement_score(&record);
// 3 * 2.0 + 200 * 0.005 + 1 * 8.0 = 6.0 + 1.0 + 8.0 = 15.0
assert!((score - 15.0_f32).abs() < 0.001, "score={score}");
}
#[test]
fn compute_engagement_score_caps_conversations() {
let record = EngagementRecord {
observation_time_ticks: 0,
conversation_count: 100, // way above CONVERSATION_CAP = 5
monologue_trigger_count: 0,
};
let score = compute_engagement_score(&record);
// capped at 5 * 2.0 = 10.0
assert!((score - 10.0_f32).abs() < 0.001, "score={score}");
}
}