//! Dialogue selection pipeline — D-028 four-layer filtering engine (#305). //! //! Full pipeline: Talk verb → access tier (from KG RelationshipState) //! → situations (from game context) → trust tier (from KG) → topic+mood //! weighted scoring → select line → DialogueResponseBuffer. //! //! Layers 1-3 (access, situation, trust) are delegated to //! LinePoolIndex::query_dialogue. Layer 4 (topic + mood weighted selection) //! is implemented here. //! //! Integration points: //! - Reads LinePoolIndexResource (server/content/mod.rs) //! - Reads KnowledgeGraph + EntityRegistry for access/trust derivation //! - Reads DialogueProfile on NPCs for pool lookup coordinates //! - Writes DialogueResponseBuffer for snapshot inclusion //! - Uses SimRng for deterministic weighted random selection use std::collections::BTreeSet; use bevy_ecs::prelude::*; use rand::Rng; use crate::bridge::types::{DialogueResponseEvent, MonologueEvent, RelationshipState}; use crate::knowledge::content_registry::ContentEntityRegistry; use crate::knowledge::events::{ProcessedEntityGrant, ProcessedFactGrant, ProcessedKnowledgeGrant}; use crate::knowledge::types::{FactId, KnowledgeConfidence, KnowledgeSource, StableId}; use crate::knowledge::{EntityRegistry, KnowledgeGraph}; use crate::npc::interaction::{InteractionEvent, InteractionEventKind, InteractionMemory}; use crate::npc::relationships::{TrustEvent, TrustEventQueue}; use crate::simulation::knowledge_grant::KnowledgeGrant; use crate::simulation::line_pool::LinePoolIndexResource; use crate::simulation::line_pool::{ AccessTier, IndexedDialogueLine, Mood, Situation, Topic, TrustTier, }; use crate::simulation::monologue::{MonologueBuffer, MonologueState}; use crate::simulation::movement::PlayerCharacter; use crate::simulation::npc_components::{display_label_for_role, NpcColorIndex, NpcName}; use crate::simulation::rng::SimRng; use crate::simulation::time::SimulationTime; use crate::storyteller::EngagementRecord; /// Cooldown ticks before the same dialogue line can be selected again. /// At 10 ticks/game-minute, 600 ticks = 1 game-hour. const LINE_COOLDOWN_TICKS: u64 = 600; // --------------------------------------------------------------------------- // Components // --------------------------------------------------------------------------- /// Marker: player requested Talk interaction with a target NPC this tick. /// /// Set by process_player_input when verb == "Talk". Consumed and removed /// by process_talk_interaction each tick. #[derive(Component, Debug)] pub struct TalkRequest { pub target: Entity, } /// NPC's dialogue pool coordinates for LinePoolIndex lookup. /// /// `location` maps to DialoguePool.location (e.g., "the-terminal"). /// `role` maps to DialoguePool.role (e.g., "dock-worker"). /// Attached during content spawn; NPCs without this cannot be talked to. #[derive(Component, Debug, Clone)] pub struct DialogueProfile { pub location: String, pub role: String, } /// NPC's current mood for Layer 4 scoring. /// /// Computed from NPC axes (Tolerance, Contentment, recent events). /// v0.1: set during spawn or defaults to Comfortable. #[derive(Component, Debug, Clone)] pub struct CurrentMood(pub Mood); impl Default for CurrentMood { fn default() -> Self { Self(Mood::Content) } } /// Per-player cooldown tracker for dialogue line variety (#338). /// /// Prevents the same line from being selected within LINE_COOLDOWN_TICKS. /// Entries older than the cooldown window are pruned each query. /// /// Design note: this is player-global, not per-NPC. Line IDs are NPC-scoped /// per D-035 (`{template}_{d|m|e}_{###}`), so cross-NPC collisions don't occur /// in practice. If a future sprint introduces shared line IDs across roles, /// the key should become `(StableId, line_id)` instead. #[derive(Component, Debug, Default)] pub struct DialogueCooldownTracker { used: std::collections::BTreeMap, // line_id → tick_used (D-041) } impl DialogueCooldownTracker { /// Record that a line was used at the given tick. pub fn record(&mut self, line_id: &str, tick: u64) { self.used.insert(line_id.to_string(), tick); } /// Check if a line is on cooldown at the given tick. pub fn is_on_cooldown(&self, line_id: &str, tick: u64) -> bool { self.used .get(line_id) .is_some_and(|used_tick| tick.saturating_sub(*used_tick) < LINE_COOLDOWN_TICKS) } /// Prune entries older than the cooldown window. pub fn prune(&mut self, tick: u64) { self.used .retain(|_, used_tick| tick.saturating_sub(*used_tick) < LINE_COOLDOWN_TICKS); } } /// Tracks an active dialogue session between the player and an NPC. /// /// Set by `process_talk_interaction` when a dialogue line is selected. /// Cleared by `process_walk_away` (walk-away, D-064) or when dialogue /// ends naturally (future: multi-line exchanges). #[derive(Component, Debug)] pub struct ActiveDialogue { pub target: Entity, pub interaction_type: crate::knowledge::events::InteractionType, pub started_tick: u64, } /// Marker: player submitted a dialogue response this tick (#539). /// /// Set by process_player_input when PlayerAction::DialogueResponse is received. /// Consumed and removed by process_dialogue_response each tick. #[derive(Component, Debug)] pub struct DialogueResponseRequest { pub target: Entity, pub response_id: String, } /// Marker: player walked away during active dialogue this tick (D-064). /// /// Set by process_player_input when PlayerAction::WalkAway is received. /// Consumed by process_walk_away each tick. #[derive(Component, Debug)] pub struct WalkAwayRequest; /// Marker: player delivered a confrontation this tick (#520, D-063). /// /// Set by process_player_input when Interact{verb: "Confront"} is received. /// Consumed by process_confrontation_response each tick. Triggers: /// 1. Target NPC shifts to AnimationTier::Tier2 (D-047) /// 2. Observer KG relationship state decremented (D-033 color fade) /// 3. Monologue spike event emitted #[derive(Component, Debug)] pub struct ConfrontationDelivered { pub target: Entity, } /// Buffer holding the dialogue response for snapshot inclusion. /// /// Consumed once per snapshot via `take()`. Cleared at snapshot build time. #[derive(Component, Debug, Default)] pub struct DialogueResponseBuffer { pub(crate) response: Option, } impl DialogueResponseBuffer { /// Drain and return the dialogue response, leaving the buffer empty. pub fn take(&mut self) -> Option { self.response.take() } } // --------------------------------------------------------------------------- // Mapping functions (D-028 Layer 1 + Layer 3) // --------------------------------------------------------------------------- /// Map RelationshipState to the set of AccessTiers the player can access. /// /// Per sprint briefing: /// - Unknown → Public only /// - Known → Public + Peer /// - Friendly → Public + Peer + Insider /// - PersonOfInterest → Public + Peer + Authority (detective investigation context) /// - Hostile → Hostile only pub fn available_access_tiers(relationship: RelationshipState) -> Vec { match relationship { RelationshipState::Unknown => vec![AccessTier::Public], RelationshipState::Known => vec![AccessTier::Public, AccessTier::Peer], RelationshipState::Friendly => { vec![AccessTier::Public, AccessTier::Peer, AccessTier::Insider] } RelationshipState::PersonOfInterest => { vec![AccessTier::Public, AccessTier::Peer, AccessTier::Authority] } RelationshipState::Hostile => vec![AccessTier::Hostile], } } /// Map RelationshipState + KnowledgeConfidence to the player's effective TrustTier. /// /// D-075 layered gate: trust requires BOTH relationship depth AND knowledge depth. /// - Secret: Friendly + KnowsDetails+ (deep rapport + actionable knowledge) /// - Real: (Friendly or Known) + KnowsOf+ (rapport + substantive knowledge) /// - Surface: everything else (baseline, always available) /// /// Map relationship + knowledge confidence to trust tier (D-075). /// /// Trust tier gates which dialogue lines are available. The layered gate /// requires BOTH sufficient relationship AND sufficient KG confidence: /// Surface: any relationship, any confidence (baseline) /// Real: (Friendly|Known) + KnowsOf+ (rapport + substantive knowledge) /// Secret: Friendly + KnowsDetails+ (deep rapport + actionable knowledge) /// /// KnowledgeConfidence ordering is load-bearing here — the >= comparison /// relies on the derive(PartialOrd) order: Suspects < KnowsOf < KnowsDetails < Direct. /// /// Unknown NPCs (no KG entry) default to Suspects, yielding Surface tier. /// This is correct: you can't have deep dialogue with someone you know nothing about. pub fn relationship_to_trust( relationship: RelationshipState, confidence: crate::knowledge::types::KnowledgeConfidence, ) -> TrustTier { use crate::knowledge::types::KnowledgeConfidence; match relationship { RelationshipState::Friendly if confidence >= KnowledgeConfidence::KnowsDetails => { TrustTier::Secret } RelationshipState::Friendly | RelationshipState::Known if confidence >= KnowledgeConfidence::KnowsOf => { TrustTier::Real } _ => TrustTier::Surface, } } // --------------------------------------------------------------------------- // Situation derivation (D-028 Layer 2) // --------------------------------------------------------------------------- /// Derive active Situation tags from game state. /// /// Maps DayPhase + relationship context to 1-3 active situations. /// Not hardcoded per sprint briefing — uses a mapping table. pub fn derive_situations( day_phase: crate::simulation::time::DayPhase, relationship: RelationshipState, ) -> Vec { use crate::simulation::time::DayPhase; let mut situations = vec![Situation::Routine]; // Always active baseline // Day phase → situation mapping match day_phase { DayPhase::Morning => situations.push(Situation::ShiftStart), DayPhase::Afternoon => situations.push(Situation::Social), DayPhase::Evening => { situations.push(Situation::BarEvening); situations.push(Situation::Social); } DayPhase::Night => situations.push(Situation::NightShift), } // Relationship context if relationship == RelationshipState::PersonOfInterest { situations.push(Situation::Investigation); } situations } // --------------------------------------------------------------------------- // Layer 4: Topic + Mood weighted selection // --------------------------------------------------------------------------- /// Score a dialogue line by topic and mood match. /// /// Scoring: /// - Base score: 1 (topic/mood-neutral lines always eligible) /// - Mood match: +3 if NPC's CurrentMood is in line.mood /// - Topic match: +2 per matching topic /// /// Returns 0 only for lines on cooldown (caller handles). pub fn score_line( line: &IndexedDialogueLine, npc_mood: Option, active_topics: &[Topic], ) -> u32 { let mut score: u32 = 1; // Base score — no line is excluded by Layer 4 // Mood match if let Some(mood) = npc_mood { if line.mood.contains(&mood) { score += 3; } } // Topic match for topic in active_topics { if line.topic.contains(topic) { score += 2; } } score } /// Select a dialogue line from Layer 1-3 filtered candidates using Layer 4 scoring. /// /// Performs weighted random selection: lines with higher topic/mood match scores /// are more likely to be chosen. Lines on cooldown are excluded. /// /// Returns None if no eligible lines remain after cooldown filtering. pub fn select_dialogue_line<'a>( candidates: &[&'a IndexedDialogueLine], npc_mood: Option, active_topics: &[Topic], cooldown: &DialogueCooldownTracker, tick: u64, rng: &mut impl Rng, ) -> Option<&'a IndexedDialogueLine> { // Score and filter by cooldown let scored: Vec<(&IndexedDialogueLine, u32)> = candidates .iter() .filter(|line| !cooldown.is_on_cooldown(&line.id, tick)) .map(|line| (*line, score_line(line, npc_mood, active_topics))) .collect(); if scored.is_empty() { return None; } // Weighted random selection — score_line always returns >= 1 (base score), // so total_weight > 0 is guaranteed when scored is non-empty. let total_weight: u32 = scored.iter().map(|(_, s)| s).sum(); let mut roll = rng.random_range(0..total_weight); for (line, weight) in &scored { if roll < *weight { return Some(line); } roll -= weight; } unreachable!("weighted selection with total_weight > 0 must select a line") } // --------------------------------------------------------------------------- // Shared pipeline: Layers 1-4 // --------------------------------------------------------------------------- /// Run the full D-028 four-layer dialogue pipeline and return a selected line. /// /// Shared by `process_talk_interaction` and `process_dialogue_response` to /// avoid duplicating the L1-L4 query + scoring logic. Callers handle the /// result differently (initial Talk sets ActiveDialogue; follow-up may clear it). fn run_dialogue_pipeline<'a>( line_pool: &'a crate::simulation::line_pool::LinePoolIndex, location: &str, role: &str, relationship: RelationshipState, confidence: crate::knowledge::types::KnowledgeConfidence, day_phase: crate::simulation::time::DayPhase, interaction_mem: Option<&InteractionMemory>, npc_mood: Option, cooldown: &DialogueCooldownTracker, tick: u64, rng: &mut impl Rng, ) -> Option<&'a IndexedDialogueLine> { // Layer 1: Access tiers from relationship let access_tiers = available_access_tiers(relationship); // Layer 2: Derive active situations from game state let mut situations = derive_situations(day_phase, relationship); // Layer 2 extension: first_meeting / repeated_visit from InteractionMemory (#325, D-028) if let Some(mem) = interaction_mem { if mem.is_first_meeting() { situations.push(Situation::FirstMeeting); } else if mem.is_repeated_visit() { situations.push(Situation::RepeatedVisit); } } // Layer 3: Trust tier from relationship + confidence (D-075) let trust = relationship_to_trust(relationship, confidence); // Query Layers 1-3: collect candidates across all available access tiers let mut candidates: Vec<&IndexedDialogueLine> = Vec::new(); let mut seen_ids: BTreeSet<&str> = BTreeSet::new(); for access in &access_tiers { let results = line_pool.query_dialogue(location, role, *access, &situations, trust); for line in results { if seen_ids.insert(&line.id) { candidates.push(line); } } } if candidates.is_empty() { return None; } // Layer 4: Topic + mood weighted selection let active_topics: Vec = Vec::new(); // v0.1: no topic context yet select_dialogue_line(&candidates, npc_mood, &active_topics, cooldown, tick, rng) } // --------------------------------------------------------------------------- // System: process_talk_interaction // --------------------------------------------------------------------------- /// Process Talk verb requests through the full D-028 four-layer pipeline. /// /// Reads TalkRequest marker (set by input system), looks up NPC dialogue pool, /// queries through Layers 1-3, applies Layer 4 scoring, and writes the selected /// line to DialogueResponseBuffer. /// /// System ordering: after process_player_input, before compute_observer_snapshot. #[tracing::instrument(level = "debug", skip_all)] #[allow(clippy::type_complexity, clippy::too_many_arguments)] pub fn process_talk_interaction( mut commands: Commands, time: Res, line_pool: Option>, registry: Res, content_registry: Res, mut rng: ResMut, mut event_queue: ResMut, mut trust_queue: ResMut, mut player_query: Query< ( Entity, &KnowledgeGraph, &TalkRequest, &mut DialogueResponseBuffer, &mut DialogueCooldownTracker, Option<&ActiveDialogue>, ), With, >, mut npc_query: Query<( &DialogueProfile, Option<&CurrentMood>, Option<&mut InteractionMemory>, Option<&NpcName>, Option<&NpcColorIndex>, Option<&KnowledgeGraph>, )>, mut engagement_query: Query<&mut EngagementRecord>, ) { let Some(line_pool) = line_pool else { return; }; let Ok(( player_entity, observer_kg, talk_request, mut response_buffer, mut cooldown, active_dialogue_opt, )) = player_query.single_mut() else { return; }; let target = talk_request.target; // Look up NPC dialogue profile, mood, interaction history, name, color, and KG (#325, D-079) let Ok((profile, mood_opt, mut interaction_mem_opt, npc_name_opt, color_idx_opt, npc_kg_opt)) = npc_query.get_mut(target) else { tracing::debug!( "Talk target {:?} has no DialogueProfile — cannot select dialogue", target ); commands.entity(player_entity).remove::(); return; }; // Resolve target's StableId for KG lookup let target_stable = registry.to_stable(target); let relationship = target_stable .map(|sid| observer_kg.relationship_with(&sid)) .unwrap_or(RelationshipState::Unknown); // Default to Suspects for unknown NPCs — no KG entry means no basis for // deeper dialogue, which correctly yields Surface trust tier. let confidence = target_stable .and_then(|sid| observer_kg.confidence_of(&sid)) .unwrap_or(crate::knowledge::types::KnowledgeConfidence::Suspects); let npc_mood = mood_opt.map(|m| m.0); // Prune old cooldown entries cooldown.prune(time.tick); let selected = run_dialogue_pipeline( &line_pool.0, &profile.location, &profile.role, relationship, confidence, time.day_phase(), interaction_mem_opt.as_deref(), npc_mood, &cooldown, time.tick, &mut rng.rng, ); if let Some(line) = selected { // Resolve wire ID for the speaker — skip if target not in registry let Some(speaker_stable) = registry.to_stable(target) else { tracing::warn!( "Talk target {:?} not in EntityRegistry — cannot resolve wire ID, skipping dialogue", target ); commands.entity(player_entity).remove::(); return; }; // Resolve speaker display name: use real name if player KG has "name" // attribute for the target, otherwise fall back to role label. let speaker_display_name = { let known = observer_kg .entity_knowledge(&speaker_stable) .map(|e| e.known_attributes.contains_key("name")) .unwrap_or(false); if known { npc_name_opt .map(|n| n.0.clone()) .unwrap_or_else(|| "Unknown".to_string()) } else { display_label_for_role(&profile.role) } }; let speaker_color = color_idx_opt.map(|c| c.0).unwrap_or(0u8); response_buffer.response = Some(DialogueResponseEvent { line_id: line.id.clone(), text: line.text.clone(), speaker_entity_id: speaker_stable.0, speaker_color_index: speaker_color, speaker_name: speaker_display_name, }); cooldown.record(&line.id, time.tick); // Knowledge grant (D-079): fire at line selection time, server-authoritative. if let Some(grant) = &line.knowledge_grant { emit_knowledge_grant( grant, player_entity, speaker_stable, &content_registry, npc_kg_opt, time.tick, &mut event_queue, ); } // Emit IncompleteInteraction if overwriting an existing dialogue session if let Some(prev) = active_dialogue_opt { event_queue.push(crate::knowledge::KnowledgeEvent { observer: player_entity, tick: time.tick, event_type: crate::knowledge::KnowledgeEventType::IncompleteInteraction { target: prev.target, interaction_type: prev.interaction_type, }, }); tracing::debug!( "Overwriting active {:?} dialogue — emitted IncompleteInteraction", prev.interaction_type, ); } // Track active dialogue for walk-away detection (D-064) commands.entity(player_entity).insert(ActiveDialogue { target, interaction_type: crate::knowledge::events::InteractionType::Talk, 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, player: player_entity, }); // Interaction tracking (#325): record completed talk if let Some(ref mut mem) = interaction_mem_opt { mem.record_talk(time.tick); } tracing::debug!( "Dialogue selected: id={}, speaker={}, location={}, role={}", line.id, speaker_stable.0, profile.location, profile.role, ); } else { tracing::debug!( "All dialogue lines on cooldown for {}/{}", profile.location, profile.role, ); } // Remove the TalkRequest marker — processed this tick commands.entity(player_entity).remove::(); } // --------------------------------------------------------------------------- // Knowledge grant helper (D-079) // --------------------------------------------------------------------------- /// Emit a KnowledgeGranted event for a dialogue line's knowledge_grant field. /// /// Called at line selection time (server-authoritative, tick-stamped). /// Source is always `ToldBy { source_id: speaker_stable, tick }`. /// /// Fact grants: dropped with tracing::warn! if the granting NPC's KG /// does not contain the fact (D-079 runtime guardrail). /// Entity grants: no guardrail — always emitted if entity_ref resolves. #[allow(clippy::too_many_arguments)] fn emit_knowledge_grant( grant: &KnowledgeGrant, player_entity: Entity, speaker_stable: StableId, content_registry: &ContentEntityRegistry, npc_kg_opt: Option<&KnowledgeGraph>, tick: u64, event_queue: &mut crate::knowledge::KnowledgeEventQueue, ) { let source = KnowledgeSource::ToldBy { source_id: speaker_stable, tick, }; match grant { KnowledgeGrant::Fact { fact_id, confidence, } => { let conf = match KnowledgeConfidence::try_from(confidence.as_str()) { Ok(c) => c, Err(e) => { tracing::warn!("KnowledgeGrant confidence parse error: {}", e); return; } }; let fid = FactId(fact_id.clone()); // Guardrail: NPC must know this fact to grant it (D-079). let npc_knows = npc_kg_opt.map(|kg| kg.knows_fact(&fid)).unwrap_or(false); if !npc_knows { tracing::warn!( "KnowledgeGrant dropped: NPC {:?} does not know fact '{}' — grant guardrail", speaker_stable, fact_id ); return; } event_queue.push(crate::knowledge::KnowledgeEvent { observer: player_entity, tick, event_type: crate::knowledge::KnowledgeEventType::KnowledgeGranted { grant: ProcessedKnowledgeGrant::Fact(ProcessedFactGrant { fact_id: fid, confidence: conf, }), source, }, }); } // Entity grants have no "NPC knows this entity" guardrail (unlike Fact // grants above). This is intentional per D-079: entity grants introduce // NEW knowledge about an entity the NPC is talking about — the NPC // doesn't need to "know" the entity in their own KG to reference it // in dialogue. The entity_ref resolves via ContentEntityRegistry, not KG. KnowledgeGrant::Entity { entity_ref, attributes, confidence, } => { let conf = match KnowledgeConfidence::try_from(confidence.as_str()) { Ok(c) => c, Err(e) => { tracing::warn!("KnowledgeGrant confidence parse error: {}", e); return; } }; let Some(target_id) = content_registry.resolve(entity_ref) else { tracing::warn!( "KnowledgeGrant::Entity dropped: entity_ref '{}' not in ContentEntityRegistry", entity_ref ); return; }; event_queue.push(crate::knowledge::KnowledgeEvent { observer: player_entity, tick, event_type: crate::knowledge::KnowledgeEventType::KnowledgeGranted { grant: ProcessedKnowledgeGrant::Entity(ProcessedEntityGrant { target_id, attributes: attributes.clone(), confidence: conf, }), source, }, }); } } } // --------------------------------------------------------------------------- // System: process_walk_away (D-064) // --------------------------------------------------------------------------- /// Process walk-away requests during active dialogue (D-064 Phases 2+3). /// /// When the player moves (WASD) during an active dialogue, the client sends /// PlayerAction::WalkAway which sets WalkAwayRequest. This system: /// 1. Target NPC shifts to AnimationTier::Tier2 (D-047 ambiguous animation) /// 2. NPC routine deviation recorded (storyteller hook) /// 3. Emits IncompleteInteraction knowledge event (recorded in KG) /// 4. Clears ActiveDialogue state /// 5. Removes the WalkAwayRequest marker /// /// If no ActiveDialogue is present, removes WalkAwayRequest silently (no-op). /// /// System ordering: after process_player_input, before compute_observer_snapshot. pub fn process_walk_away( mut commands: Commands, mut event_queue: ResMut, mut trust_queue: ResMut, mut post_conv_queue: ResMut, time: Res, query: Query<(Entity, Option<&ActiveDialogue>, &WalkAwayRequest), With>, mut npc_mem_query: Query>, ) { let Ok((player_entity, active_dialogue_opt, _walk_away)) = query.single() else { return; }; if let Some(active_dialogue) = active_dialogue_opt { let target = active_dialogue.target; // Phase 2, Effect 1: Shift target NPC to Tier 2 animation (D-047) commands .entity(target) .insert(crate::npc::AnimationTier::Tier2); // Phase 2, Effect 2: Record routine deviation on target NPC commands .entity(target) .insert(crate::npc::RoutineDeviation { trigger: crate::npc::DeviationTrigger::WalkAway, tick: time.tick, expires_at_tick: time.tick + crate::npc::TELL_ESCALATION_DURATION_TICKS, }); // Phase 3: Emit IncompleteInteraction knowledge event event_queue.push(crate::knowledge::KnowledgeEvent { observer: player_entity, tick: time.tick, event_type: crate::knowledge::KnowledgeEventType::IncompleteInteraction { target, interaction_type: active_dialogue.interaction_type, }, }); // Trust progression (#324): walk-away reduces NPC trust trust_queue.push(TrustEvent::WalkAway { npc: target, player: player_entity, }); // Interaction tracking (#325): record notable walk-away event if let Ok(Some(mut mem)) = npc_mem_query.get_mut(target) { mem.push_event(InteractionEvent { tick: time.tick, kind: InteractionEventKind::WalkAway, }); } // Post-conversation monologue trigger (#119, D-035) post_conv_queue.push(target); tracing::debug!( "Walk-away during {:?} dialogue at tick {} (started tick {}): \ target {:?} → Tier2 animation + routine deviation", active_dialogue.interaction_type, time.tick, active_dialogue.started_tick, target, ); commands.entity(player_entity).remove::(); } else { tracing::trace!("WalkAway with no active dialogue — ignored"); } commands.entity(player_entity).remove::(); } /// Hardcoded confrontation monologue lines (D-063). /// Fired as a monologue spike when the player delivers a confrontation. /// TODO: move to content pools with trigger="confrontation_delivered" (D-028/D-035). const CONFRONTATION_LINES: &[(&str, &str)] = &[ ( "confront_01", "That changed everything between us. No going back.", ), ("confront_02", "The look on their face... they know I know."), ( "confront_03", "Cards on the table. Let's see what happens next.", ), ]; /// Process confrontation world response (#520, D-063). /// /// Reads ConfrontationDelivered marker (set by input system), applies three /// server-authoritative effects: /// 1. Target NPC shifts to AnimationTier::Tier2 (D-047) /// 2. Observer's KG relationship state decremented (D-033 color fade) /// 3. Monologue spike: immediate monologue line bypassing cooldown /// /// System ordering: after process_player_input, before compute_observer_snapshot. pub fn process_confrontation_response( mut commands: Commands, time: Res, registry: Res, mut rng: ResMut, mut trust_queue: ResMut, mut query: Query< ( Entity, &ConfrontationDelivered, &mut KnowledgeGraph, &mut MonologueBuffer, &mut MonologueState, ), With, >, mut npc_mem_query: Query>, ) { let Ok((player_entity, confrontation, mut observer_kg, mut monologue_buf, mut monologue_state)) = query.single_mut() else { return; }; let target = confrontation.target; // Effect 1: Shift target NPC to Tier 2 animation (D-047) // + record routine deviation (symmetric with walk-away path) commands.entity(target).insert(( crate::npc::AnimationTier::Tier2, crate::npc::RoutineDeviation { trigger: crate::npc::DeviationTrigger::Confrontation, tick: time.tick, expires_at_tick: time.tick + crate::npc::TELL_ESCALATION_DURATION_TICKS, }, )); // Effect 2: Decrement observer's relationship with the target (D-033 color fade) if let Some(target_sid) = registry.to_stable(target) { let old_rel = observer_kg.relationship_with(&target_sid); let new_rel = old_rel.decrement(); if new_rel != old_rel { observer_kg.set_relationship(&target_sid, new_rel); tracing::info!( target_id = target_sid.0, ?old_rel, ?new_rel, "Confrontation: relationship decremented" ); } } // Effect 3: Monologue spike — bypass cooldown, fire immediately let idx = rng.rng.random_range(0..CONFRONTATION_LINES.len()); let (id, text) = CONFRONTATION_LINES[idx]; monologue_buf.set(MonologueEvent { id: id.to_string(), text: text.to_string(), duration_seconds: 5.0, }); monologue_state.last_fired_tick = time.tick; // Trust progression (#324): confrontation significantly reduces NPC trust trust_queue.push(TrustEvent::ConfrontationDelivered { npc: target, player: player_entity, }); // Interaction tracking (#325): record confrontation notable event if let Ok(Some(mut mem)) = npc_mem_query.get_mut(target) { mem.push_event(InteractionEvent { tick: time.tick, kind: InteractionEventKind::Confrontation, }); } tracing::info!( tick = time.tick, monologue_id = id, "Confrontation delivered: Tier 2 anim + relationship decrement + monologue spike + trust penalty" ); // Clean up marker commands .entity(player_entity) .remove::(); } // --------------------------------------------------------------------------- // System: process_dialogue_response (#539) // --------------------------------------------------------------------------- /// Process DialogueResponse actions through the full D-028 four-layer pipeline (#539). /// /// Called when the player picks a dialogue option. Re-runs the same pipeline as /// process_talk_interaction to select a follow-up line. Clears ActiveDialogue if /// no candidates remain after cooldown filtering (conversation ends naturally). /// /// The response_id is the line_id that was shown; it's already on cooldown from /// process_talk_interaction, ensuring the follow-up is a different line. /// /// System ordering: after process_player_input, after process_talk_interaction, /// before compute_observer_snapshot. #[tracing::instrument(level = "debug", skip_all)] #[allow(clippy::type_complexity, clippy::too_many_arguments)] pub fn process_dialogue_response( mut commands: Commands, time: Res, line_pool: Option>, registry: Res, mut rng: ResMut, mut trust_queue: ResMut, mut player_query: Query< ( Entity, &KnowledgeGraph, &DialogueResponseRequest, &mut DialogueResponseBuffer, &mut DialogueCooldownTracker, Option<&ActiveDialogue>, ), With, >, mut npc_query: Query<( &DialogueProfile, Option<&CurrentMood>, Option<&mut InteractionMemory>, Option<&NpcName>, Option<&NpcColorIndex>, )>, ) { let Ok(( player_entity, observer_kg, response_req, mut response_buffer, mut cooldown, active_dialogue_opt, )) = player_query.single_mut() else { return; }; let target = response_req.target; let response_id = response_req.response_id.clone(); // Always remove the marker regardless of outcome — request is consumed this tick. commands .entity(player_entity) .remove::(); let Some(line_pool) = line_pool else { return }; // Look up NPC dialogue profile, mood, interaction history, name, and color let Ok((profile, mood_opt, mut interaction_mem_opt, npc_name_opt, color_idx_opt)) = npc_query.get_mut(target) else { tracing::debug!( "DialogueResponse target {:?} has no DialogueProfile — cannot select follow-up", target ); return; }; // Resolve target's StableId for KG lookup let target_stable = registry.to_stable(target); let relationship = target_stable .map(|sid| observer_kg.relationship_with(&sid)) .unwrap_or(RelationshipState::Unknown); let confidence = target_stable .and_then(|sid| observer_kg.confidence_of(&sid)) .unwrap_or(crate::knowledge::types::KnowledgeConfidence::Suspects); let npc_mood = mood_opt.map(|m| m.0); // Prune old cooldown entries cooldown.prune(time.tick); let selected = run_dialogue_pipeline( &line_pool.0, &profile.location, &profile.role, relationship, confidence, time.day_phase(), interaction_mem_opt.as_deref(), npc_mood, &cooldown, time.tick, &mut rng.rng, ); if let Some(line) = selected { let Some(speaker_stable) = registry.to_stable(target) else { tracing::warn!( "DialogueResponse target {:?} not in EntityRegistry — skipping follow-up", target ); return; }; let speaker_display_name = { let known = observer_kg .entity_knowledge(&speaker_stable) .map(|e| e.known_attributes.contains_key("name")) .unwrap_or(false); if known { npc_name_opt .map(|n| n.0.clone()) .unwrap_or_else(|| "Unknown".to_string()) } else { display_label_for_role(&profile.role) } }; let speaker_color = color_idx_opt.map(|c| c.0).unwrap_or(0u8); response_buffer.response = Some(DialogueResponseEvent { line_id: line.id.clone(), text: line.text.clone(), speaker_entity_id: speaker_stable.0, speaker_color_index: speaker_color, speaker_name: speaker_display_name, }); cooldown.record(&line.id, time.tick); // Update ActiveDialogue with current tick — prevents stale started_tick commands.entity(player_entity).insert(ActiveDialogue { target, interaction_type: crate::knowledge::events::InteractionType::Talk, started_tick: time.tick, }); // Trust progression: follow-up dialogue warms the NPC trust_queue.push(TrustEvent::TalkCompleted { npc: target, player: player_entity, }); // Interaction tracking: record follow-up as a talk event if let Some(ref mut mem) = interaction_mem_opt { mem.record_talk(time.tick); } tracing::debug!( "Follow-up selected: id={}, response_id={}, location={}, role={}", line.id, response_id, profile.location, profile.role, ); } else { // No follow-up lines — conversation ends naturally (D-062: invisible locks) tracing::debug!( "No follow-up lines for response_id={} at {}/{} — ending conversation", response_id, profile.location, profile.role, ); // Clear active dialogue state if active_dialogue_opt.is_some() { commands.entity(player_entity).remove::(); } } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use crate::knowledge::graph::KnowledgeGraph; use crate::knowledge::registry::EntityRegistry; use crate::npc::Npc; use crate::simulation::line_pool::LinePoolIndexResource; use crate::simulation::line_pool::{ AccessTier, IndexedDialogueLine, IndexedDialoguePool, LinePoolIndex, Mood, Situation, Topic, TrustTier, }; use crate::simulation::movement::TilePosition; use crate::simulation::rng::SimRng; use crate::simulation::time::SimulationTime; // -- Mapping tests ------------------------------------------------------- #[test] fn access_tiers_unknown_gets_public() { let tiers = available_access_tiers(RelationshipState::Unknown); assert_eq!(tiers, vec![AccessTier::Public]); } #[test] fn access_tiers_known_gets_public_and_peer() { let tiers = available_access_tiers(RelationshipState::Known); assert!(tiers.contains(&AccessTier::Public)); assert!(tiers.contains(&AccessTier::Peer)); } #[test] fn access_tiers_friendly_includes_insider() { let tiers = available_access_tiers(RelationshipState::Friendly); assert!(tiers.contains(&AccessTier::Insider)); } #[test] fn access_tiers_poi_includes_authority() { let tiers = available_access_tiers(RelationshipState::PersonOfInterest); assert!(tiers.contains(&AccessTier::Authority)); assert!(tiers.contains(&AccessTier::Peer)); assert!(!tiers.contains(&AccessTier::Insider)); } #[test] fn access_tiers_hostile_only_hostile() { let tiers = available_access_tiers(RelationshipState::Hostile); assert_eq!(tiers, vec![AccessTier::Hostile]); } // -- Trust tier tests (D-075: layered confidence gate) -------------------- #[test] fn trust_friendly_knows_details_is_secret() { use crate::knowledge::types::KnowledgeConfidence; assert_eq!( relationship_to_trust( RelationshipState::Friendly, KnowledgeConfidence::KnowsDetails ), TrustTier::Secret ); } #[test] fn trust_friendly_direct_is_secret() { use crate::knowledge::types::KnowledgeConfidence; assert_eq!( relationship_to_trust(RelationshipState::Friendly, KnowledgeConfidence::Direct), TrustTier::Secret ); } #[test] fn trust_friendly_knows_of_is_real() { use crate::knowledge::types::KnowledgeConfidence; assert_eq!( relationship_to_trust(RelationshipState::Friendly, KnowledgeConfidence::KnowsOf), TrustTier::Real ); } #[test] fn trust_friendly_suspects_is_surface() { use crate::knowledge::types::KnowledgeConfidence; assert_eq!( relationship_to_trust(RelationshipState::Friendly, KnowledgeConfidence::Suspects), TrustTier::Surface ); } #[test] fn trust_known_knows_of_is_real() { use crate::knowledge::types::KnowledgeConfidence; assert_eq!( relationship_to_trust(RelationshipState::Known, KnowledgeConfidence::KnowsOf), TrustTier::Real ); } #[test] fn trust_known_suspects_is_surface() { use crate::knowledge::types::KnowledgeConfidence; assert_eq!( relationship_to_trust(RelationshipState::Known, KnowledgeConfidence::Suspects), TrustTier::Surface ); } #[test] fn trust_unknown_is_always_surface() { use crate::knowledge::types::KnowledgeConfidence; assert_eq!( relationship_to_trust(RelationshipState::Unknown, KnowledgeConfidence::Direct), TrustTier::Surface ); } #[test] fn trust_poi_is_always_surface() { use crate::knowledge::types::KnowledgeConfidence; // PersonOfInterest uses Authority access, not trust depth assert_eq!( relationship_to_trust( RelationshipState::PersonOfInterest, KnowledgeConfidence::KnowsDetails ), TrustTier::Surface ); } // -- Situation derivation tests ------------------------------------------ #[test] fn situations_always_include_routine() { use crate::simulation::time::DayPhase; for phase in [ DayPhase::Morning, DayPhase::Afternoon, DayPhase::Evening, DayPhase::Night, ] { let sits = derive_situations(phase, RelationshipState::Unknown); assert!( sits.contains(&Situation::Routine), "Routine must always be present for {:?}", phase ); } } #[test] fn situations_morning_includes_shift_start() { use crate::simulation::time::DayPhase; let sits = derive_situations(DayPhase::Morning, RelationshipState::Unknown); assert!(sits.contains(&Situation::ShiftStart)); } #[test] fn situations_evening_includes_bar_evening() { use crate::simulation::time::DayPhase; let sits = derive_situations(DayPhase::Evening, RelationshipState::Unknown); assert!(sits.contains(&Situation::BarEvening)); assert!(sits.contains(&Situation::Social)); } #[test] fn situations_poi_adds_investigation() { use crate::simulation::time::DayPhase; let sits = derive_situations(DayPhase::Morning, RelationshipState::PersonOfInterest); assert!(sits.contains(&Situation::Investigation)); } #[test] fn situations_non_poi_no_investigation() { use crate::simulation::time::DayPhase; let sits = derive_situations(DayPhase::Morning, RelationshipState::Known); assert!(!sits.contains(&Situation::Investigation)); } // -- Layer 4 scoring tests ----------------------------------------------- fn make_line(id: &str, topics: &[Topic], moods: &[Mood]) -> IndexedDialogueLine { IndexedDialogueLine { id: id.to_string(), text: format!("Text for {}", id), role: "worker".to_string(), access: vec![AccessTier::Public], trust: TrustTier::Surface, situation: vec![Situation::Routine], topic: topics.to_vec(), mood: moods.to_vec(), tags: vec![], knowledge_grant: None, } } #[test] fn score_base_is_one_for_neutral_line() { let line = make_line("neutral", &[], &[]); assert_eq!(score_line(&line, None, &[]), 1); } #[test] fn score_mood_match_adds_three() { let line = make_line("moody", &[], &[Mood::Anxious]); assert_eq!(score_line(&line, Some(Mood::Anxious), &[]), 4); // 1 base + 3 mood } #[test] fn score_mood_mismatch_stays_base() { let line = make_line("moody", &[], &[Mood::Anxious]); assert_eq!(score_line(&line, Some(Mood::Warm), &[]), 1); } #[test] fn score_topic_match_adds_two_each() { let line = make_line("topical", &[Topic::Cargo, Topic::Danger], &[]); assert_eq!(score_line(&line, None, &[Topic::Cargo]), 3); // 1 + 2 assert_eq!(score_line(&line, None, &[Topic::Cargo, Topic::Danger]), 5); // 1 + 2 + 2 } #[test] fn score_combined_mood_and_topic() { let line = make_line("both", &[Topic::Cargo], &[Mood::Suspicious]); assert_eq!( score_line(&line, Some(Mood::Suspicious), &[Topic::Cargo]), 6 // 1 + 3 + 2 ); } // -- Cooldown tracker tests ---------------------------------------------- #[test] fn cooldown_tracks_used_lines() { let mut tracker = DialogueCooldownTracker::default(); tracker.record("line_001", 100); assert!(tracker.is_on_cooldown("line_001", 100)); assert!(tracker.is_on_cooldown("line_001", 100 + LINE_COOLDOWN_TICKS - 1)); assert!(!tracker.is_on_cooldown("line_001", 100 + LINE_COOLDOWN_TICKS)); } #[test] fn cooldown_different_line_not_affected() { let mut tracker = DialogueCooldownTracker::default(); tracker.record("line_001", 100); assert!(!tracker.is_on_cooldown("line_002", 100)); } #[test] fn cooldown_prune_removes_old_entries() { let mut tracker = DialogueCooldownTracker::default(); tracker.record("old", 0); tracker.record("recent", LINE_COOLDOWN_TICKS); tracker.prune(LINE_COOLDOWN_TICKS); assert_eq!(tracker.used.len(), 1); assert!(tracker.used.contains_key("recent")); } // -- Selection tests ----------------------------------------------------- #[test] fn select_returns_none_when_empty() { let candidates: Vec<&IndexedDialogueLine> = vec![]; let cooldown = DialogueCooldownTracker::default(); let mut rng = rand_chacha::ChaCha20Rng::seed_from_u64(42); let result = select_dialogue_line(&candidates, None, &[], &cooldown, 0, &mut rng); assert!(result.is_none()); } #[test] fn select_returns_none_when_all_on_cooldown() { let line = make_line("only", &[], &[]); let candidates = vec![&line]; let mut cooldown = DialogueCooldownTracker::default(); cooldown.record("only", 0); let mut rng = rand_chacha::ChaCha20Rng::seed_from_u64(42); let result = select_dialogue_line(&candidates, None, &[], &cooldown, 0, &mut rng); assert!(result.is_none()); } #[test] fn select_picks_from_candidates() { let line_a = make_line("a", &[], &[]); let line_b = make_line("b", &[], &[]); let candidates = vec![&line_a, &line_b]; let cooldown = DialogueCooldownTracker::default(); let mut rng = rand_chacha::ChaCha20Rng::seed_from_u64(42); let result = select_dialogue_line(&candidates, None, &[], &cooldown, 0, &mut rng); assert!(result.is_some()); let id = &result.unwrap().id; assert!(id == "a" || id == "b"); } #[test] fn select_deterministic_with_same_seed() { let line_a = make_line("a", &[], &[]); let line_b = make_line("b", &[Topic::Cargo], &[]); let line_c = make_line("c", &[], &[Mood::Anxious]); let candidates = vec![&line_a, &line_b, &line_c]; let cooldown = DialogueCooldownTracker::default(); let mut rng1 = rand_chacha::ChaCha20Rng::seed_from_u64(42); let mut rng2 = rand_chacha::ChaCha20Rng::seed_from_u64(42); let r1 = select_dialogue_line(&candidates, None, &[], &cooldown, 0, &mut rng1); let r2 = select_dialogue_line(&candidates, None, &[], &cooldown, 0, &mut rng2); assert_eq!(r1.unwrap().id, r2.unwrap().id); } #[test] fn select_favors_higher_scored_lines() { // Line with matching mood gets +3, so should be selected more often let neutral = make_line("neutral", &[], &[]); let matched = make_line("matched", &[], &[Mood::Anxious]); let candidates = vec![&neutral, &matched]; let cooldown = DialogueCooldownTracker::default(); let mut match_count = 0; for seed in 0..100 { let mut rng = rand_chacha::ChaCha20Rng::seed_from_u64(seed); if let Some(line) = select_dialogue_line( &candidates, Some(Mood::Anxious), &[], &cooldown, 0, &mut rng, ) { if line.id == "matched" { match_count += 1; } } } // matched has score 4, neutral has score 1, so ~80% should be matched assert!( match_count > 60, "matched line should be selected most of the time, got {}/100", match_count ); } // -- System integration tests -------------------------------------------- fn setup_dialogue_world() -> World { let mut world = World::new(); world.init_resource::(); world.insert_resource(SimRng::new(42)); world.init_resource::(); world.init_resource::(); world.init_resource::(); world.init_resource::(); world.init_resource::(); world } fn build_test_line_pool() -> LinePoolIndex { let mut index = LinePoolIndex::default(); let lines = vec![ IndexedDialogueLine { id: "test_d_001".to_string(), text: "Welcome to the terminal.".to_string(), role: "dock-worker".to_string(), access: vec![AccessTier::Public], trust: TrustTier::Surface, situation: vec![Situation::Routine, Situation::Social], topic: vec![], mood: vec![], tags: vec![], knowledge_grant: None, }, IndexedDialogueLine { id: "test_d_002".to_string(), text: "I've seen some strange cargo lately.".to_string(), role: "dock-worker".to_string(), access: vec![AccessTier::Peer], trust: TrustTier::Surface, situation: vec![Situation::Routine, Situation::Investigation], topic: vec![Topic::Cargo], mood: vec![Mood::Suspicious], tags: vec![], knowledge_grant: None, }, IndexedDialogueLine { id: "test_d_003".to_string(), text: "The night shifts have been quiet.".to_string(), role: "dock-worker".to_string(), access: vec![AccessTier::Public], trust: TrustTier::Surface, situation: vec![Situation::NightShift], topic: vec![Topic::Routine], mood: vec![Mood::Content], tags: vec![], knowledge_grant: None, }, IndexedDialogueLine { id: "test_d_004".to_string(), text: "There's something I need to tell you about the manifests.".to_string(), role: "dock-worker".to_string(), access: vec![AccessTier::Insider], trust: TrustTier::Real, situation: vec![Situation::Investigation], topic: vec![Topic::Cargo, Topic::Investigation], mood: vec![Mood::Suspicious], tags: vec![], knowledge_grant: None, }, ]; let pool = IndexedDialoguePool { location: "the-terminal".to_string(), role: "dock-worker".to_string(), lines, }; index.dialogue.insert( ("the-terminal".to_string(), "dock-worker".to_string()), pool, ); index } #[test] fn process_talk_selects_line_for_unknown_relationship() { let mut world = setup_dialogue_world(); let index = build_test_line_pool(); world.insert_resource(LinePoolIndexResource(index)); // Spawn NPC with DialogueProfile let npc = world .spawn(( Npc, TilePosition::new(5, 5, 0), DialogueProfile { location: "the-terminal".to_string(), role: "dock-worker".to_string(), }, CurrentMood(Mood::Content), )) .id(); world.resource_mut::().register(npc); // Spawn player with KG that doesn't know the NPC let player = world .spawn(( PlayerCharacter, TilePosition::new(5, 6, 0), KnowledgeGraph::new(), TalkRequest { target: npc }, DialogueResponseBuffer::default(), DialogueCooldownTracker::default(), )) .id(); world.resource_mut::().register(player); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_talk_interaction); schedule.run(&mut world); world.flush(); // Should get a Public line (Unknown relationship → Public access only) let buffer = world.get::(player).unwrap(); assert!( buffer.response.is_some(), "should select a dialogue line for Unknown relationship" ); let response = buffer.response.as_ref().unwrap(); // Only test_d_001 and test_d_003 are Public + match Routine situation // But test_d_003 requires NightShift situation which isn't active by default assert_eq!( response.line_id, "test_d_001", "should select the public routine line" ); } #[test] fn process_talk_removes_talk_request() { let mut world = setup_dialogue_world(); let index = build_test_line_pool(); world.insert_resource(LinePoolIndexResource(index)); let npc = world .spawn(( Npc, TilePosition::new(5, 5, 0), DialogueProfile { location: "the-terminal".to_string(), role: "dock-worker".to_string(), }, )) .id(); world.resource_mut::().register(npc); let player = world .spawn(( PlayerCharacter, TilePosition::new(5, 6, 0), KnowledgeGraph::new(), TalkRequest { target: npc }, DialogueResponseBuffer::default(), DialogueCooldownTracker::default(), )) .id(); world.resource_mut::().register(player); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_talk_interaction); schedule.run(&mut world); world.flush(); assert!( world.get::(player).is_none(), "TalkRequest should be consumed after processing" ); } #[test] fn process_talk_known_relationship_gets_peer_lines() { let mut world = setup_dialogue_world(); let index = build_test_line_pool(); world.insert_resource(LinePoolIndexResource(index)); let npc = world .spawn(( Npc, TilePosition::new(5, 5, 0), DialogueProfile { location: "the-terminal".to_string(), role: "dock-worker".to_string(), }, CurrentMood(Mood::Suspicious), )) .id(); let npc_sid = world.resource_mut::().register(npc); // Player knows the NPC (Known relationship) let mut kg = KnowledgeGraph::new(); kg.observe_entity(npc_sid, TilePosition::new(5, 5, 0), 0); kg.set_relationship(&npc_sid, RelationshipState::Known); let player = world .spawn(( PlayerCharacter, TilePosition::new(5, 6, 0), kg, TalkRequest { target: npc }, DialogueResponseBuffer::default(), DialogueCooldownTracker::default(), )) .id(); world.resource_mut::().register(player); // Run multiple times to verify peer lines are accessible let mut seen_ids: Vec = Vec::new(); for seed in 0..20 { // Reset for each iteration world .get_mut::(player) .unwrap() .response = None; world.entity_mut(player).insert(TalkRequest { target: npc }); world.insert_resource(SimRng::new(seed)); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_talk_interaction); schedule.run(&mut world); world.flush(); if let Some(resp) = &world .get::(player) .unwrap() .response { if !seen_ids.contains(&resp.line_id) { seen_ids.push(resp.line_id.clone()); } } } // Known relationship gives Public + Peer access, Routine situation // Should see test_d_001 (public, routine) and test_d_002 (peer, routine) assert!( seen_ids.contains(&"test_d_001".to_string()), "should access public line" ); assert!( seen_ids.contains(&"test_d_002".to_string()), "should access peer line with Known relationship" ); } #[test] fn process_talk_no_dialogue_profile_is_noop() { let mut world = setup_dialogue_world(); let index = build_test_line_pool(); world.insert_resource(LinePoolIndexResource(index)); // NPC without DialogueProfile let npc = world.spawn((Npc, TilePosition::new(5, 5, 0))).id(); world.resource_mut::().register(npc); let player = world .spawn(( PlayerCharacter, TilePosition::new(5, 6, 0), KnowledgeGraph::new(), TalkRequest { target: npc }, DialogueResponseBuffer::default(), DialogueCooldownTracker::default(), )) .id(); world.resource_mut::().register(player); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_talk_interaction); schedule.run(&mut world); world.flush(); let buffer = world.get::(player).unwrap(); assert!( buffer.response.is_none(), "NPC without DialogueProfile should produce no dialogue" ); } #[test] fn cooldown_prevents_same_line_repeat() { let mut world = setup_dialogue_world(); // Build index with only one line let mut index = LinePoolIndex::default(); let pool = IndexedDialoguePool { location: "test".to_string(), role: "worker".to_string(), lines: vec![IndexedDialogueLine { id: "only_line".to_string(), text: "The only thing I can say.".to_string(), role: "worker".to_string(), access: vec![AccessTier::Public], trust: TrustTier::Surface, situation: vec![Situation::Routine], topic: vec![], mood: vec![], tags: vec![], knowledge_grant: None, }], }; index .dialogue .insert(("test".to_string(), "worker".to_string()), pool); world.insert_resource(LinePoolIndexResource(index)); let npc = world .spawn(( Npc, TilePosition::new(5, 5, 0), DialogueProfile { location: "test".to_string(), role: "worker".to_string(), }, )) .id(); world.resource_mut::().register(npc); let player = world .spawn(( PlayerCharacter, TilePosition::new(5, 6, 0), KnowledgeGraph::new(), TalkRequest { target: npc }, DialogueResponseBuffer::default(), DialogueCooldownTracker::default(), )) .id(); world.resource_mut::().register(player); // First talk — should succeed let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_talk_interaction); schedule.run(&mut world); world.flush(); assert!( world .get::(player) .unwrap() .response .is_some(), "first talk should select the line" ); // Second talk — same tick, line on cooldown world .get_mut::(player) .unwrap() .response = None; world.entity_mut(player).insert(TalkRequest { target: npc }); let mut schedule2 = bevy_ecs::schedule::Schedule::default(); schedule2.add_systems(process_talk_interaction); schedule2.run(&mut world); world.flush(); assert!( world .get::(player) .unwrap() .response .is_none(), "second talk should fail — line on cooldown" ); } // -- Walk-away tests (D-064, #427) ---------------------------------------- #[test] fn talk_sets_active_dialogue() { let mut world = setup_dialogue_world(); let index = build_test_line_pool(); world.insert_resource(LinePoolIndexResource(index)); let npc = world .spawn(( Npc, TilePosition::new(5, 5, 0), DialogueProfile { location: "the-terminal".to_string(), role: "dock-worker".to_string(), }, CurrentMood(Mood::Content), )) .id(); world.resource_mut::().register(npc); let player = world .spawn(( PlayerCharacter, TilePosition::new(5, 6, 0), KnowledgeGraph::new(), TalkRequest { target: npc }, DialogueResponseBuffer::default(), DialogueCooldownTracker::default(), )) .id(); world.resource_mut::().register(player); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_talk_interaction); schedule.run(&mut world); world.flush(); let active = world .get::(player) .expect("ActiveDialogue should be set after successful dialogue"); assert_eq!(active.target, npc); assert_eq!( active.interaction_type, crate::knowledge::events::InteractionType::Talk ); assert_eq!(active.started_tick, 0); } #[test] fn walk_away_during_active_dialogue_emits_event() { use crate::knowledge::KnowledgeEventQueue; let mut world = setup_dialogue_world(); world.init_resource::(); let npc = world.spawn_empty().id(); world.resource_mut::().register(npc); let player = world .spawn(( PlayerCharacter, TilePosition::new(5, 6, 0), KnowledgeGraph::new(), ActiveDialogue { target: npc, interaction_type: crate::knowledge::events::InteractionType::Talk, started_tick: 10, }, WalkAwayRequest, )) .id(); world.resource_mut::().register(player); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_walk_away); schedule.run(&mut world); world.flush(); // ActiveDialogue and WalkAwayRequest should be removed assert!( world.get::(player).is_none(), "ActiveDialogue should be cleared after walk-away" ); assert!( world.get::(player).is_none(), "WalkAwayRequest should be consumed" ); // KnowledgeEventQueue should have one IncompleteInteraction event let queue = world.resource::(); assert_eq!(queue.len(), 1, "should emit exactly one knowledge event"); } #[test] fn walk_away_without_active_dialogue_is_noop() { use crate::knowledge::KnowledgeEventQueue; let mut world = setup_dialogue_world(); world.init_resource::(); let player = world .spawn(( PlayerCharacter, TilePosition::new(5, 6, 0), KnowledgeGraph::new(), WalkAwayRequest, )) .id(); world.resource_mut::().register(player); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_walk_away); schedule.run(&mut world); world.flush(); // WalkAwayRequest consumed but no event emitted assert!( world.get::(player).is_none(), "WalkAwayRequest should be consumed even without dialogue" ); let queue = world.resource::(); assert!( queue.is_empty(), "no event should be emitted when not in dialogue" ); } #[test] fn walk_away_records_in_knowledge_graph() { // Full integration: walk-away → event → KG recording use crate::knowledge::KnowledgeEventQueue; let mut world = setup_dialogue_world(); world.init_resource::(); world.init_resource::(); let npc = world.spawn_empty().id(); let npc_sid = world.resource_mut::().register(npc); // Pre-populate player KG with knowledge of the NPC let mut kg = KnowledgeGraph::new(); kg.observe_entity(npc_sid, TilePosition::new(5, 5, 0), 0); let player = world .spawn(( PlayerCharacter, TilePosition::new(5, 6, 0), kg, ActiveDialogue { target: npc, interaction_type: crate::knowledge::events::InteractionType::Talk, started_tick: 5, }, WalkAwayRequest, )) .id(); world.resource_mut::().register(player); // Step 1: process_walk_away emits the event let mut schedule1 = bevy_ecs::schedule::Schedule::default(); schedule1.add_systems(process_walk_away); schedule1.run(&mut world); world.flush(); // Step 2: process_knowledge_events applies it to the KG let mut schedule2 = bevy_ecs::schedule::Schedule::default(); schedule2.add_systems(crate::knowledge::events::process_knowledge_events); schedule2.run(&mut world); // Verify the KG recorded the incomplete interaction let player_kg = world.get::(player).unwrap(); assert!( player_kg.has_incomplete_interaction(&npc_sid), "KG should record incomplete interaction after walk-away" ); } // -- Walk-away Phase 2 tests (D-064, #519) --------------------------------- #[test] fn walk_away_shifts_npc_to_tier2_animation() { use crate::knowledge::KnowledgeEventQueue; use crate::npc::AnimationTier; let mut world = setup_dialogue_world(); world.init_resource::(); let npc = world.spawn_empty().id(); world.resource_mut::().register(npc); let player = world .spawn(( PlayerCharacter, TilePosition::new(5, 6, 0), KnowledgeGraph::new(), ActiveDialogue { target: npc, interaction_type: crate::knowledge::events::InteractionType::Talk, started_tick: 10, }, WalkAwayRequest, )) .id(); world.resource_mut::().register(player); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_walk_away); schedule.run(&mut world); world.flush(); let tier = world.get::(npc).unwrap(); assert_eq!( *tier, AnimationTier::Tier2, "Walk-away should shift NPC to Tier 2 animation" ); } #[test] fn walk_away_records_routine_deviation() { use crate::knowledge::KnowledgeEventQueue; use crate::npc::{DeviationTrigger, RoutineDeviation}; let mut world = setup_dialogue_world(); world.init_resource::(); world.resource_mut::().tick = 42; let npc = world.spawn_empty().id(); world.resource_mut::().register(npc); let player = world .spawn(( PlayerCharacter, TilePosition::new(5, 6, 0), KnowledgeGraph::new(), ActiveDialogue { target: npc, interaction_type: crate::knowledge::events::InteractionType::Talk, started_tick: 10, }, WalkAwayRequest, )) .id(); world.resource_mut::().register(player); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_walk_away); schedule.run(&mut world); world.flush(); let deviation = world.get::(npc).unwrap(); assert_eq!( deviation.trigger, DeviationTrigger::WalkAway, "Deviation trigger should be WalkAway" ); assert_eq!( deviation.tick, 42, "Deviation should record the walk-away tick" ); } #[test] fn walk_away_without_dialogue_does_not_affect_npcs() { use crate::knowledge::KnowledgeEventQueue; use crate::npc::{AnimationTier, RoutineDeviation}; let mut world = setup_dialogue_world(); world.init_resource::(); let npc = world.spawn_empty().id(); world.resource_mut::().register(npc); // Player with WalkAwayRequest but NO ActiveDialogue let player = world .spawn(( PlayerCharacter, TilePosition::new(5, 6, 0), KnowledgeGraph::new(), WalkAwayRequest, )) .id(); world.resource_mut::().register(player); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_walk_away); schedule.run(&mut world); world.flush(); assert!( world.get::(npc).is_none(), "NPC should not get AnimationTier when no dialogue was active" ); assert!( world.get::(npc).is_none(), "NPC should not get RoutineDeviation when no dialogue was active" ); } use rand::SeedableRng; // -- Trust-gated gossip tests (#171, D-075) ---------------------------------- /// Build a pool with a Surface-tier Public line and a Secret-tier Insider line. /// Used to verify that KnowledgeConfidence gates Secret access correctly. fn build_trust_tier_pool() -> LinePoolIndex { let mut index = LinePoolIndex::default(); let pool = IndexedDialoguePool { location: "the-terminal".to_string(), role: "dock-worker".to_string(), lines: vec![ IndexedDialogueLine { id: "trust_surface_001".to_string(), text: "Just another day at the terminal.".to_string(), role: "dock-worker".to_string(), access: vec![AccessTier::Public], trust: TrustTier::Surface, situation: vec![Situation::Routine], topic: vec![], mood: vec![], tags: vec![], knowledge_grant: None, }, IndexedDialogueLine { id: "trust_secret_001".to_string(), text: "The manifests don't match. You didn't hear that from me.".to_string(), role: "dock-worker".to_string(), access: vec![AccessTier::Insider], // requires Friendly relationship trust: TrustTier::Secret, // requires Friendly + KnowsDetails+ situation: vec![Situation::Routine], topic: vec![], mood: vec![], tags: vec![], knowledge_grant: None, }, ], }; index.dialogue.insert( ("the-terminal".to_string(), "dock-worker".to_string()), pool, ); index } #[test] fn trust_gated_knows_details_can_get_secret_tier_line() { // D-075: Friendly + KnowsDetails → Secret trust tier → secret lines available. // Spec ref: #171, D-075 "Secret: Friendly + KnowsDetails+" use crate::knowledge::types::KnowledgeConfidence; let mut world = setup_dialogue_world(); world.insert_resource(LinePoolIndexResource(build_trust_tier_pool())); let npc = world .spawn(( Npc, TilePosition::new(5, 5, 0), DialogueProfile { location: "the-terminal".to_string(), role: "dock-worker".to_string(), }, )) .id(); let npc_sid = world.resource_mut::().register(npc); // observe_entity → Direct; observe_entity_leaving_los → KnowsDetails let mut kg = KnowledgeGraph::new(); kg.observe_entity(npc_sid, TilePosition::new(5, 5, 0), 0); kg.observe_entity_leaving_los(&npc_sid, 1); kg.set_relationship(&npc_sid, RelationshipState::Friendly); assert_eq!( kg.confidence_of(&npc_sid), Some(KnowledgeConfidence::KnowsDetails), "precondition: KG must have KnowsDetails confidence" ); let player = world .spawn(( PlayerCharacter, TilePosition::new(5, 6, 0), kg, TalkRequest { target: npc }, DialogueResponseBuffer::default(), DialogueCooldownTracker::default(), )) .id(); world.resource_mut::().register(player); // Run with multiple seeds — Secret-tier line must appear at least once let mut saw_secret_line = false; for seed in 0u64..50 { world .get_mut::(player) .unwrap() .response = None; world.entity_mut(player).insert(TalkRequest { target: npc }); // Reset cooldown so the pool is not exhausted between iterations world .entity_mut(player) .insert(DialogueCooldownTracker::default()); world.insert_resource(SimRng::new(seed)); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_talk_interaction); schedule.run(&mut world); world.flush(); if let Some(resp) = &world .get::(player) .unwrap() .response { if resp.line_id == "trust_secret_001" { saw_secret_line = true; break; } } } assert!( saw_secret_line, "Friendly + KnowsDetails player should be able to access Secret-tier lines (D-075 #171)" ); } #[test] fn trust_gated_suspects_only_gets_surface_tier() { // D-075: Friendly + Suspects → Surface trust tier → Secret lines invisible. // Spec ref: #171, D-075 "Surface: any relationship + any confidence" use crate::knowledge::types::KnowledgeConfidence; let mut world = setup_dialogue_world(); world.insert_resource(LinePoolIndexResource(build_trust_tier_pool())); let npc = world .spawn(( Npc, TilePosition::new(5, 5, 0), DialogueProfile { location: "the-terminal".to_string(), role: "dock-worker".to_string(), }, )) .id(); let npc_sid = world.resource_mut::().register(npc); // Friendly relationship but Suspects confidence → Surface trust only let mut kg = KnowledgeGraph::new(); kg.observe_entity(npc_sid, TilePosition::new(5, 5, 0), 0); kg.set_relationship(&npc_sid, RelationshipState::Friendly); // Patch down to Suspects (observe_entity sets Direct — too high) kg.entities.get_mut(&npc_sid).unwrap().confidence = KnowledgeConfidence::Suspects; assert_eq!( kg.confidence_of(&npc_sid), Some(KnowledgeConfidence::Suspects), "precondition: KG must have Suspects confidence" ); let player = world .spawn(( PlayerCharacter, TilePosition::new(5, 6, 0), kg, TalkRequest { target: npc }, DialogueResponseBuffer::default(), DialogueCooldownTracker::default(), )) .id(); world.resource_mut::().register(player); let mut saw_secret = false; for seed in 0u64..50 { world .get_mut::(player) .unwrap() .response = None; world.entity_mut(player).insert(TalkRequest { target: npc }); world .entity_mut(player) .insert(DialogueCooldownTracker::default()); world.insert_resource(SimRng::new(seed)); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_talk_interaction); schedule.run(&mut world); world.flush(); if let Some(resp) = &world .get::(player) .unwrap() .response { if resp.line_id == "trust_secret_001" { saw_secret = true; break; } } } assert!( !saw_secret, "Friendly + Suspects player must NOT access Secret-tier lines (D-075 #171)" ); } // -- Line variety regression test (#338, D-028) ------------------------------ /// Pool with 12 distinct Public/Surface/Routine lines for variety testing. fn build_variety_pool() -> LinePoolIndex { let mut index = LinePoolIndex::default(); let lines: Vec = (1u32..=12) .map(|n| IndexedDialogueLine { id: format!("variety_{:03}", n), text: format!("Line number {}.", n), role: "dock-worker".to_string(), access: vec![AccessTier::Public], trust: TrustTier::Surface, situation: vec![Situation::Routine], topic: vec![], mood: vec![], tags: vec![], knowledge_grant: None, }) .collect(); let pool = IndexedDialoguePool { location: "the-terminal".to_string(), role: "dock-worker".to_string(), lines, }; index.dialogue.insert( ("the-terminal".to_string(), "dock-worker".to_string()), pool, ); index } #[test] fn line_variety_no_repeats_within_cooldown_window() { // #338, D-028: No line_id should repeat within LINE_COOLDOWN_TICKS. // Regression: Talk 10 times at tick 0 (well within the 600-tick window). // Each selected line must be distinct — cooldown tracker enforces this. let mut world = setup_dialogue_world(); world.insert_resource(LinePoolIndexResource(build_variety_pool())); let npc = world .spawn(( Npc, TilePosition::new(5, 5, 0), DialogueProfile { location: "the-terminal".to_string(), role: "dock-worker".to_string(), }, )) .id(); world.resource_mut::().register(npc); // Unknown player — Public access only; tick stays at 0 throughout let player = world .spawn(( PlayerCharacter, TilePosition::new(5, 6, 0), KnowledgeGraph::new(), TalkRequest { target: npc }, DialogueResponseBuffer::default(), DialogueCooldownTracker::default(), )) .id(); world.resource_mut::().register(player); let mut seen_ids: Vec = Vec::new(); for seed in 0u64..10 { world .get_mut::(player) .unwrap() .response = None; world.entity_mut(player).insert(TalkRequest { target: npc }); // NOTE: SimulationTime is NOT advanced — all 10 talks happen within tick 0 world.insert_resource(SimRng::new(seed)); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_talk_interaction); schedule.run(&mut world); world.flush(); if let Some(resp) = &world .get::(player) .unwrap() .response { let id = resp.line_id.clone(); assert!( !seen_ids.contains(&id), "Line '{}' was repeated within the {}-tick cooldown window (iteration {}). \ Cooldown tracker must prevent repeats. (#338)", id, LINE_COOLDOWN_TICKS, seed, ); seen_ids.push(id); } } assert_eq!( seen_ids.len(), 10, "Should have selected 10 distinct lines across 10 consecutive Talks (#338)" ); } // === Confrontation Response Tests (#520, D-063) === #[test] fn confrontation_shifts_npc_to_tier2() { let mut world = bevy_ecs::world::World::new(); world.insert_resource(SimulationTime::default()); world.init_resource::(); world.insert_resource(SimRng::new(42)); world.init_resource::(); let npc = world .spawn((crate::npc::Npc, TilePosition::new(5, 6, 0))) .id(); let npc_sid = world.resource_mut::().register(npc); let mut kg = KnowledgeGraph::new(); kg.observe_entity(npc_sid, TilePosition::new(5, 6, 0), 0); kg.set_relationship(&npc_sid, RelationshipState::Known); world.spawn(( PlayerCharacter, TilePosition::new(5, 5, 0), kg, ConfrontationDelivered { target: npc }, MonologueBuffer::default(), MonologueState::default(), )); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_confrontation_response); schedule.run(&mut world); world.flush(); let tier = world.get::(npc); assert_eq!( tier, Some(&crate::npc::AnimationTier::Tier2), "NPC should shift to Tier2 after confrontation" ); // RoutineDeviation should be recorded (symmetric with walk-away) let deviation = world.get::(npc); assert!( deviation.is_some(), "NPC should get RoutineDeviation after confrontation" ); assert_eq!( deviation.unwrap().trigger, crate::npc::DeviationTrigger::Confrontation, "Deviation trigger should be Confrontation" ); } #[test] fn confrontation_decrements_relationship() { let mut world = bevy_ecs::world::World::new(); world.insert_resource(SimulationTime::default()); world.init_resource::(); world.insert_resource(SimRng::new(42)); world.init_resource::(); let npc = world .spawn((crate::npc::Npc, TilePosition::new(5, 6, 0))) .id(); let npc_sid = world.resource_mut::().register(npc); let mut kg = KnowledgeGraph::new(); kg.observe_entity(npc_sid, TilePosition::new(5, 6, 0), 0); kg.set_relationship(&npc_sid, RelationshipState::Known); let player = world .spawn(( PlayerCharacter, TilePosition::new(5, 5, 0), kg, ConfrontationDelivered { target: npc }, MonologueBuffer::default(), MonologueState::default(), )) .id(); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_confrontation_response); schedule.run(&mut world); let player_kg = world.get::(player).unwrap(); assert_eq!( player_kg.relationship_with(&npc_sid), RelationshipState::PersonOfInterest, "Known → PersonOfInterest after confrontation" ); } #[test] fn confrontation_emits_monologue_spike() { let mut world = bevy_ecs::world::World::new(); world.insert_resource(SimulationTime::default()); world.init_resource::(); world.insert_resource(SimRng::new(42)); world.init_resource::(); let npc = world .spawn((crate::npc::Npc, TilePosition::new(5, 6, 0))) .id(); let npc_sid = world.resource_mut::().register(npc); let mut kg = KnowledgeGraph::new(); kg.observe_entity(npc_sid, TilePosition::new(5, 6, 0), 0); let player = world .spawn(( PlayerCharacter, TilePosition::new(5, 5, 0), kg, ConfrontationDelivered { target: npc }, MonologueBuffer::default(), MonologueState::default(), )) .id(); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_confrontation_response); schedule.run(&mut world); let mut buffer = world.get_mut::(player).unwrap(); let event = buffer.take(); assert!(event.is_some(), "Monologue spike should be emitted"); assert!( event.unwrap().id.starts_with("confront_"), "Should be a confrontation monologue line" ); } #[test] fn confrontation_clears_marker() { let mut world = bevy_ecs::world::World::new(); world.insert_resource(SimulationTime::default()); world.init_resource::(); world.insert_resource(SimRng::new(42)); world.init_resource::(); let npc = world .spawn((crate::npc::Npc, TilePosition::new(5, 6, 0))) .id(); let npc_sid = world.resource_mut::().register(npc); let mut kg = KnowledgeGraph::new(); kg.observe_entity(npc_sid, TilePosition::new(5, 6, 0), 0); let player = world .spawn(( PlayerCharacter, TilePosition::new(5, 5, 0), kg, ConfrontationDelivered { target: npc }, MonologueBuffer::default(), MonologueState::default(), )) .id(); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_confrontation_response); schedule.run(&mut world); world.flush(); assert!( world.get::(player).is_none(), "Marker should be removed after processing" ); } // === DialogueResponse Tests (#539) === #[test] fn process_dialogue_response_selects_follow_up_line() { let mut world = setup_dialogue_world(); let index = build_test_line_pool(); world.insert_resource(LinePoolIndexResource(index)); let npc = world .spawn(( Npc, TilePosition::new(5, 5, 0), DialogueProfile { location: "the-terminal".to_string(), role: "dock-worker".to_string(), }, CurrentMood(Mood::Content), )) .id(); world.resource_mut::().register(npc); let player = world .spawn(( PlayerCharacter, TilePosition::new(5, 6, 0), KnowledgeGraph::new(), // Simulate that first line was already selected (on cooldown) DialogueResponseRequest { target: npc, response_id: "test_d_001".to_string(), }, DialogueResponseBuffer::default(), DialogueCooldownTracker::default(), )) .id(); world.resource_mut::().register(player); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_dialogue_response); schedule.run(&mut world); world.flush(); // Should have consumed the marker assert!( world.get::(player).is_none(), "DialogueResponseRequest should be consumed" ); } #[test] fn process_dialogue_response_clears_active_dialogue_when_no_lines() { let mut world = setup_dialogue_world(); // Build pool with only one line — it will be on cooldown let mut index = LinePoolIndex::default(); let pool = IndexedDialoguePool { location: "test".to_string(), role: "worker".to_string(), lines: vec![IndexedDialogueLine { id: "only_line".to_string(), text: "Only thing I can say.".to_string(), role: "worker".to_string(), access: vec![AccessTier::Public], trust: TrustTier::Surface, situation: vec![Situation::Routine], topic: vec![], mood: vec![], tags: vec![], knowledge_grant: None, }], }; index .dialogue .insert(("test".to_string(), "worker".to_string()), pool); world.insert_resource(LinePoolIndexResource(index)); let npc = world .spawn(( Npc, TilePosition::new(5, 5, 0), DialogueProfile { location: "test".to_string(), role: "worker".to_string(), }, )) .id(); world.resource_mut::().register(npc); // Set the only line on cooldown — so no follow-up can be selected let mut cooldown = DialogueCooldownTracker::default(); cooldown.record("only_line", 0); let player = world .spawn(( PlayerCharacter, TilePosition::new(5, 6, 0), KnowledgeGraph::new(), DialogueResponseRequest { target: npc, response_id: "only_line".to_string(), }, DialogueResponseBuffer::default(), cooldown, ActiveDialogue { target: npc, interaction_type: crate::knowledge::events::InteractionType::Talk, started_tick: 0, }, )) .id(); world.resource_mut::().register(player); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_dialogue_response); schedule.run(&mut world); world.flush(); // No follow-up lines — ActiveDialogue should be cleared assert!( world.get::(player).is_none(), "ActiveDialogue should be cleared when no follow-up lines available" ); assert!( world.get::(player).is_none(), "DialogueResponseRequest should be consumed" ); // Buffer should remain empty let buffer = world.get::(player).unwrap(); assert!( buffer.response.is_none(), "No response when all lines on cooldown" ); } #[test] fn process_dialogue_response_no_profile_is_noop() { let mut world = setup_dialogue_world(); // NPC without DialogueProfile let npc = world.spawn((Npc, TilePosition::new(5, 5, 0))).id(); world.resource_mut::().register(npc); let player = world .spawn(( PlayerCharacter, TilePosition::new(5, 6, 0), KnowledgeGraph::new(), DialogueResponseRequest { target: npc, response_id: "some_line".to_string(), }, DialogueResponseBuffer::default(), DialogueCooldownTracker::default(), )) .id(); world.resource_mut::().register(player); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_dialogue_response); schedule.run(&mut world); world.flush(); assert!( world.get::(player).is_none(), "DialogueResponseRequest consumed even with no profile" ); let buffer = world.get::(player).unwrap(); assert!( buffer.response.is_none(), "No response for NPC without DialogueProfile" ); } }