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
+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.