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:
@@ -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,
|
||||
|
||||
@@ -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, ®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.
|
||||
|
||||
Reference in New Issue
Block a user