feat(simulation): add storyteller engagement tracking and activation pass

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 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 19:11:54 +01:00
co-authored by Claude Opus 4.6
parent 7fbfc5ae65
commit 50f5d6c22e
5 changed files with 812 additions and 9 deletions
+4
View File
@@ -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) {
+7
View File
@@ -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<KnowledgeEventQueue>,
entity_positions: Query<&TilePosition>,
anomaly_markers: Query<(), With<crate::perception::anomaly::AnomalyMarker>>,
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;
+7
View File
@@ -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,
+42 -7
View File
@@ -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<PlayerCharacter>,
>,
registry: Option<Res<EntityRegistry>>,
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<Entity> = 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, &registry) {
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.
+752 -2
View File
@@ -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;
/// 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.
@@ -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<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
// ---------------------------------------------------------------------------
@@ -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<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
// ---------------------------------------------------------------------------
@@ -95,7 +369,21 @@ impl Plugin for StorytellerPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<ContaminationActive>()
.init_resource::<ContaminationEventQueue>()
.add_systems(Update, tick_contamination_activation);
.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),
);
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 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.
/// 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<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
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::<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}");
}
}