//! 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). /// 0–100 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, } 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, } impl ContaminationEventQueue { pub fn push(&mut self, event: ContaminationEvent) { self.events.push(event); } pub fn drain(&mut self) -> Vec { 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, } impl TriangleActivatedQueue { pub fn push(&mut self, event: TriangleActivatedEvent) { self.events.push(event); } pub fn drain(&mut self) -> Vec { 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, } 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, threshold: i32, ) -> Vec { 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, player_query: Query<&TilePosition, With>, ) { 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::() .init_resource::() .init_resource::() .init_resource::() .init_resource::() .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, mut contamination: ResMut, mut event_queue: ResMut, mut triangles: Query<&mut TriangleState, With>, ) { // 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 1–6. 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, contamination: Res, mut activation_state: ResMut, history: Res, registry: Res, mut rng: ResMut, mut triangles: Query<(Entity, &mut TriangleState), With>, npcs: Query<(Entity, &TilePosition, Option<&EngagementRecord>), (With, With)>, mut event_queue: ResMut, ) { // 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::(); world.init_resource::(); world.init_resource::(); 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::().0); assert!(world.resource::().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::().tick = CONTAMINATION_DELAY_TICKS; schedule.run(&mut world); assert!(world.resource::().0); assert!(!world.resource::().is_empty()); let events = world.resource_mut::().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::().tick = CONTAMINATION_DELAY_TICKS; schedule.run(&mut world); // ActiveFork should have tension increased let fork_state = world.get::(fork_entity).unwrap(); assert_eq!(fork_state.tension, CONTAMINATION_PRESSURE_DELTA); // PassiveTension should remain at 0 let passive_state = world.get::(passive_entity).unwrap(); assert_eq!(passive_state.tension, 0); // Event should report 1 affected (only the ActiveFork) let events = world.resource_mut::().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::().tick = CONTAMINATION_DELAY_TICKS; schedule.run(&mut world); let tension_after_first = world.get::(entity).unwrap().tension; assert_eq!(tension_after_first, CONTAMINATION_PRESSURE_DELTA); // Drain events world.resource_mut::().drain(); // Second run — should NOT apply pressure again world.resource_mut::().tick = CONTAMINATION_DELAY_TICKS + 100; schedule.run(&mut world); let tension_after_second = world.get::(entity).unwrap().tension; assert_eq!( tension_after_second, tension_after_first, "one-shot: tension should not increase on subsequent ticks" ); assert!( world.resource::().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::().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::(); 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::(); 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::(); let mut schedule = Schedule::default(); schedule.add_systems(append_player_history); schedule.run(&mut world); assert!(world.resource::().is_empty()); } // --- activation_pass tests (#579) --- fn setup_activation_world() -> (World, Schedule) { let mut world = World::new(); world.init_resource::(); world.insert_resource(ContaminationActive(true)); world.init_resource::(); world.init_resource::(); world.init_resource::(); world.init_resource::(); 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::().tick = ACTIVATION_CADENCE_TICKS; schedule.run(&mut world); assert!(world.resource::().is_empty()); assert_eq!(world.resource::().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::().tick = ACTIVATION_CADENCE_TICKS + 3; schedule.run(&mut world); assert!(world.resource::().is_empty()); } #[test] fn activation_pass_blocked_after_max_activations() { let (mut world, mut schedule) = setup_activation_world(); world.resource_mut::().tick = ACTIVATION_CADENCE_TICKS; // Record activation up to the limit world.resource_mut::().record_activation(0); schedule.run(&mut world); // Should not fire again assert!(world.resource::().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::().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::().is_empty(), "activation should hold when no triangle-assigned NPC is copresent" ); assert_eq!(world.resource::().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::().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::().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::().append(TilePosition::new(5, 5, 0)); schedule.run(&mut world); let events = world.resource_mut::().drain(); assert_eq!(events.len(), 1); let tri_state = world.get::(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::().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::().append(TilePosition::new(1, 1, 0)); // First run — should activate world.resource_mut::().tick = ACTIVATION_CADENCE_TICKS; schedule.run(&mut world); let first_events = world.resource_mut::().drain(); assert_eq!(first_events.len(), 1, "first pass must activate the triangle"); assert_eq!( world.resource::().activated_count, 1, "ActivationState must record the first activation" ); // Second run — ActivationState blocks it (v0.1 one-shot) world.resource_mut::().tick = ACTIVATION_CADENCE_TICKS * 2; schedule.run(&mut world); assert!( world.resource::().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}"); } }