From 50f5d6c22e81c4d9e6f76e2857878536ae4d03bc Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Tue, 3 Mar 2026 19:11:54 +0100 Subject: [PATCH] feat(simulation): add storyteller engagement tracking and activation pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Storyteller chain for Sprint 23 (#570, #571, #572, #579): - EngagementRecord component: per-NPC observation_time_ticks, conversation_count, monologue_trigger_count — incremented by perception, dialogue, and monologue systems - MovementHistoryBuffer resource: ring buffer of player positions over last 3000 ticks with npcs_copresent_in_window() query - Lifecycle rules: single activation per session, no concurrency, no cooldown, terminal resolution constants - activation_pass() system: gate check, proximity query, engagement scoring, unentangled-NPC routing, module selection, emits TriangleActivatedEvent on 10-tick cadence after contamination Co-Authored-By: Claude Opus 4.6 --- server/src/content/spawn.rs | 4 + server/src/perception/observation.rs | 7 + server/src/simulation/dialogue.rs | 7 + server/src/simulation/monologue.rs | 49 +- server/src/storyteller/mod.rs | 754 ++++++++++++++++++++++++++- 5 files changed, 812 insertions(+), 9 deletions(-) diff --git a/server/src/content/spawn.rs b/server/src/content/spawn.rs index bb9fd7bed..2873b1337 100644 --- a/server/src/content/spawn.rs +++ b/server/src/content/spawn.rs @@ -33,6 +33,7 @@ use crate::simulation::interaction::Interactable; use crate::simulation::movement::TilePosition; use crate::simulation::tier::ActiveSim; use crate::simulation::time::DayPhase; +use crate::storyteller::EngagementRecord; // #166 — Template-to-instance mapping use rand::Rng as _; @@ -133,6 +134,9 @@ fn spawn_npc(world: &mut World, profile: &types::NpcProfile, result: &mut SpawnR // Mood state — drives Layer 4 dialogue selection and monologue tone (#323) entity_commands.insert(npc::mood::MoodState::default()); + // Engagement metrics — storyteller activation scoring (#570, #162) + entity_commands.insert(EngagementRecord::default()); + // Axis 1: Want if let Some(want) = &profile.want { if let Some(kind) = parse_want_kind(&want.primary) { diff --git a/server/src/perception/observation.rs b/server/src/perception/observation.rs index e00c62262..69183666d 100644 --- a/server/src/perception/observation.rs +++ b/server/src/perception/observation.rs @@ -12,6 +12,7 @@ use crate::knowledge::{ use crate::perception::cognitive_delay::{CognitiveDelay, PendingRecognition, RecognitionTrigger}; use crate::simulation::movement::{PlayerCharacter, TilePosition}; use crate::simulation::time::SimulationTime; +use crate::storyteller::EngagementRecord; /// Emit knowledge events for entities entering/leaving the observer's LOS. /// @@ -29,6 +30,7 @@ pub fn emit_observation_events( mut event_queue: ResMut, entity_positions: Query<&TilePosition>, anomaly_markers: Query<(), With>, + mut engagement_query: Query<&mut EngagementRecord>, ) { let Some(snapshot) = &buffer.snapshot else { return; @@ -61,6 +63,11 @@ pub fn emit_observation_events( continue; }; + // Increment observation time for engagement tracking (#570) + if let Ok(mut record) = engagement_query.get_mut(entity) { + record.observation_time_ticks += 1; + } + // Get tile position for knowledge tracking let Ok(pos) = entity_positions.get(entity) else { continue; diff --git a/server/src/simulation/dialogue.rs b/server/src/simulation/dialogue.rs index 0f802f54d..f271e3444 100644 --- a/server/src/simulation/dialogue.rs +++ b/server/src/simulation/dialogue.rs @@ -22,6 +22,7 @@ use rand::Rng; use crate::bridge::types::{DialogueResponseEvent, MonologueEvent, RelationshipState}; use crate::simulation::conversation::{display_label_for_role, NpcColorIndex, NpcName}; +use crate::storyteller::EngagementRecord; use crate::content::line_pool::{ AccessTier, IndexedDialogueLine, Mood, Situation, Topic, TrustTier, }; @@ -449,6 +450,7 @@ pub fn process_talk_interaction( Option<&NpcColorIndex>, Option<&KnowledgeGraph>, )>, + mut engagement_query: Query<&mut EngagementRecord>, ) { let Some(line_pool) = line_pool else { return; @@ -583,6 +585,11 @@ pub fn process_talk_interaction( started_tick: time.tick, }); + // Engagement tracking (#570): increment conversation count for this NPC + if let Ok(mut record) = engagement_query.get_mut(target) { + record.conversation_count += 1; + } + // Trust progression (#324): successful talk warms the NPC trust_queue.push(TrustEvent::TalkCompleted { npc: target, diff --git a/server/src/simulation/monologue.rs b/server/src/simulation/monologue.rs index 5b0e1ef23..4f6d6b90e 100644 --- a/server/src/simulation/monologue.rs +++ b/server/src/simulation/monologue.rs @@ -16,10 +16,12 @@ use rand::Rng; use crate::bridge::types::MonologueEvent; use crate::content::ContentStoreResource; use crate::knowledge::{ContradictionDetectedQueue, EntityRegistry}; +use crate::perception::interpretation::ObservationTrigger; use crate::simulation::conversation::NpcName; use crate::simulation::movement::{PlayerCharacter, TilePosition}; use crate::simulation::rng::SimRng; use crate::simulation::time::SimulationTime; +use crate::storyteller::EngagementRecord; /// Minimum ticks between monologue lines (prevents spam). /// At 10 ticks/game-minute, 300 ticks = 30 game-minutes. @@ -512,19 +514,25 @@ pub fn trigger_event_monologue( ), With, >, + registry: Option>, + mut engagement_query: Query<&mut EngagementRecord>, ) { + // Drain post_conversation queue unconditionally — consumed this tick. + // Saved for NPC attribution (engagement tracking #570) and trigger detection. + let post_conv_npcs: Vec = post_conv_queue.drain(); + let Ok((player_pos, mut state, mut buffer, conv_buffer_opt)) = query.single_mut() else { - // Drain post_conversation queue even without a player - post_conv_queue.drain(); return; }; // Don't override existing monologue from higher-priority systems if buffer.event.is_some() { - post_conv_queue.drain(); return; } + // Save previous observation tick before update — needed for NPC attribution (#570) + let previous_observation_tick = state.last_observation_tick; + // Determine which trigger to fire (priority order) let trigger = if observation_queue .as_ref() @@ -540,15 +548,12 @@ pub fn trigger_event_monologue( Some("hear_sound") } else if conv_buffer_opt.map(|b| !b.events.is_empty()).unwrap_or(false) { Some("witness_interaction") - } else if !post_conv_queue.is_empty() { + } else if !post_conv_npcs.is_empty() { Some("post_conversation") } else { None }; - // Always drain post_conversation queue (consumed this tick) - post_conv_queue.drain(); - // Update observation tracking regardless of whether we fire if let Some(ref obs_queue) = observation_queue { if !obs_queue.is_empty() { @@ -588,6 +593,36 @@ pub fn trigger_event_monologue( id, time.tick ); + + // Engagement tracking (#570): attribute monologue_trigger_count to specific NPCs. + // Only NPC-context triggers are attributed — hear_sound/witness_interaction are not NPC-specific. + match trigger { + "observe_npc" => { + // Attribute to all NPCs whose NewEntity event triggered this monologue + if let (Some(ref obs_q), Some(ref reg)) = (&observation_queue, ®istry) { + for event in obs_q.iter() { + if event.tick > previous_observation_tick { + if let ObservationTrigger::NewEntity { entity: sid, .. } = &event.trigger { + if let Some(npc_entity) = reg.to_entity(sid) { + if let Ok(mut record) = engagement_query.get_mut(npc_entity) { + record.monologue_trigger_count += 1; + } + } + } + } + } + } + } + "post_conversation" => { + // Attribute to the NPC(s) whose conversation just ended + for npc in &post_conv_npcs { + if let Ok(mut record) = engagement_query.get_mut(*npc) { + record.monologue_trigger_count += 1; + } + } + } + _ => {} // hear_sound, witness_interaction: no NPC-specific attribution + } } /// Check if any NewEntity observation events exist that we haven't processed. diff --git a/server/src/storyteller/mod.rs b/server/src/storyteller/mod.rs index 4b980210f..b8dbbd56f 100644 --- a/server/src/storyteller/mod.rs +++ b/server/src/storyteller/mod.rs @@ -12,11 +12,54 @@ //! 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, TriangleState}; +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}; @@ -34,10 +77,68 @@ pub const CONTAMINATION_PRESSURE_DELTA: u8 = 10; /// 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. @@ -45,6 +146,56 @@ pub const CONFRONTATION_THRESHOLD: u8 = 75; #[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 // --------------------------------------------------------------------------- @@ -84,6 +235,129 @@ impl ContaminationEventQueue { } } +/// 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 // --------------------------------------------------------------------------- @@ -95,7 +369,21 @@ impl Plugin for StorytellerPlugin { fn build(&self, app: &mut App) { app.init_resource::() .init_resource::() - .add_systems(Update, tick_contamination_activation); + .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), + ); tracing::debug!("StorytellerPlugin initialized"); } @@ -153,6 +441,147 @@ pub fn tick_contamination_activation( ); } +/// 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; + let obs_score = record.observation_time_ticks as f32 * ENGAGEMENT_WEIGHT_OBSERVATION; + 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. +/// Fallback (unentangled NPC, D-025): highest-tension Simmering triangle. +/// 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 + if 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 copresent = history.npcs_copresent_in_window(npc_positions.into_iter(), COPRESENCE_THRESHOLD); + + // 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 + let triangle_id = simmering + .iter() + .find(|(e, _)| *e == tri_entity) + .map(|(_, s)| s.triangle_id) + .expect("target triangle must be in simmering snapshot"); + + // 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 // --------------------------------------------------------------------------- @@ -311,4 +740,325 @@ mod tests { 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}"); + } }