Files
settled-reach/server/src/simulation/dialogue.rs
T
jpmschweitzerandClaude Sonnet 4.6 e86e53ec06 feat(engine): retire D-078 overheard conversation system (#848)
Per R-012: delete conversation.rs, both overheard content files, and
remove all 6 wire-up points (social_plugin, bridge/types, monologue,
voice/integration). Protocol version 22 → 23. Scope confirmed by
#842 audit — npc/ and content/global/ untouched. Surviving NPC
components (NpcName, NpcColorIndex, NpcConversation) migrated to
simulation/npc_components.rs for use by D-080 knowledge propagation.
Also applies pre-existing cargo fmt debt (names.rs and 4 others).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 14:08:26 +02:00

2786 lines
98 KiB
Rust

//! 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<String, u64>, // 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<DialogueResponseEvent>,
}
impl DialogueResponseBuffer {
/// Drain and return the dialogue response, leaving the buffer empty.
pub fn take(&mut self) -> Option<DialogueResponseEvent> {
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<AccessTier> {
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<Situation> {
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<Mood>,
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<Mood>,
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<Mood>,
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<Topic> = 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<SimulationTime>,
line_pool: Option<Res<LinePoolIndexResource>>,
registry: Res<EntityRegistry>,
content_registry: Res<ContentEntityRegistry>,
mut rng: ResMut<SimRng>,
mut event_queue: ResMut<crate::knowledge::KnowledgeEventQueue>,
mut trust_queue: ResMut<TrustEventQueue>,
mut player_query: Query<
(
Entity,
&KnowledgeGraph,
&TalkRequest,
&mut DialogueResponseBuffer,
&mut DialogueCooldownTracker,
Option<&ActiveDialogue>,
),
With<PlayerCharacter>,
>,
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::<TalkRequest>();
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::<TalkRequest>();
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::<TalkRequest>();
}
// ---------------------------------------------------------------------------
// 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<crate::knowledge::KnowledgeEventQueue>,
mut trust_queue: ResMut<TrustEventQueue>,
mut post_conv_queue: ResMut<crate::simulation::monologue::PostConversationQueue>,
time: Res<SimulationTime>,
query: Query<(Entity, Option<&ActiveDialogue>, &WalkAwayRequest), With<PlayerCharacter>>,
mut npc_mem_query: Query<Option<&mut InteractionMemory>>,
) {
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::<ActiveDialogue>();
} else {
tracing::trace!("WalkAway with no active dialogue — ignored");
}
commands.entity(player_entity).remove::<WalkAwayRequest>();
}
/// 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<SimulationTime>,
registry: Res<EntityRegistry>,
mut rng: ResMut<crate::simulation::rng::SimRng>,
mut trust_queue: ResMut<TrustEventQueue>,
mut query: Query<
(
Entity,
&ConfrontationDelivered,
&mut KnowledgeGraph,
&mut MonologueBuffer,
&mut MonologueState,
),
With<PlayerCharacter>,
>,
mut npc_mem_query: Query<Option<&mut InteractionMemory>>,
) {
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::<ConfrontationDelivered>();
}
// ---------------------------------------------------------------------------
// 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<SimulationTime>,
line_pool: Option<Res<LinePoolIndexResource>>,
registry: Res<EntityRegistry>,
mut rng: ResMut<SimRng>,
mut trust_queue: ResMut<TrustEventQueue>,
mut player_query: Query<
(
Entity,
&KnowledgeGraph,
&DialogueResponseRequest,
&mut DialogueResponseBuffer,
&mut DialogueCooldownTracker,
Option<&ActiveDialogue>,
),
With<PlayerCharacter>,
>,
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::<DialogueResponseRequest>();
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::<ActiveDialogue>();
}
}
}
// ---------------------------------------------------------------------------
// 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::<SimulationTime>();
world.insert_resource(SimRng::new(42));
world.init_resource::<EntityRegistry>();
world.init_resource::<ContentEntityRegistry>();
world.init_resource::<crate::knowledge::KnowledgeEventQueue>();
world.init_resource::<TrustEventQueue>();
world.init_resource::<crate::simulation::monologue::PostConversationQueue>();
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::<EntityRegistry>().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::<EntityRegistry>().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::<DialogueResponseBuffer>(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::<EntityRegistry>().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::<EntityRegistry>().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::<TalkRequest>(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::<EntityRegistry>().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::<EntityRegistry>().register(player);
// Run multiple times to verify peer lines are accessible
let mut seen_ids: Vec<String> = Vec::new();
for seed in 0..20 {
// Reset for each iteration
world
.get_mut::<DialogueResponseBuffer>(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::<DialogueResponseBuffer>(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::<EntityRegistry>().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::<EntityRegistry>().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::<DialogueResponseBuffer>(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::<EntityRegistry>().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::<EntityRegistry>().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::<DialogueResponseBuffer>(player)
.unwrap()
.response
.is_some(),
"first talk should select the line"
);
// Second talk — same tick, line on cooldown
world
.get_mut::<DialogueResponseBuffer>(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::<DialogueResponseBuffer>(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::<EntityRegistry>().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::<EntityRegistry>().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::<ActiveDialogue>(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::<KnowledgeEventQueue>();
let npc = world.spawn_empty().id();
world.resource_mut::<EntityRegistry>().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::<EntityRegistry>().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::<ActiveDialogue>(player).is_none(),
"ActiveDialogue should be cleared after walk-away"
);
assert!(
world.get::<WalkAwayRequest>(player).is_none(),
"WalkAwayRequest should be consumed"
);
// KnowledgeEventQueue should have one IncompleteInteraction event
let queue = world.resource::<KnowledgeEventQueue>();
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::<KnowledgeEventQueue>();
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(5, 6, 0),
KnowledgeGraph::new(),
WalkAwayRequest,
))
.id();
world.resource_mut::<EntityRegistry>().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::<WalkAwayRequest>(player).is_none(),
"WalkAwayRequest should be consumed even without dialogue"
);
let queue = world.resource::<KnowledgeEventQueue>();
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::<KnowledgeEventQueue>();
world.init_resource::<crate::knowledge::ContradictionDetectedQueue>();
let npc = world.spawn_empty().id();
let npc_sid = world.resource_mut::<EntityRegistry>().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::<EntityRegistry>().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::<KnowledgeGraph>(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::<KnowledgeEventQueue>();
let npc = world.spawn_empty().id();
world.resource_mut::<EntityRegistry>().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::<EntityRegistry>().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::<AnimationTier>(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::<KnowledgeEventQueue>();
world.resource_mut::<SimulationTime>().tick = 42;
let npc = world.spawn_empty().id();
world.resource_mut::<EntityRegistry>().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::<EntityRegistry>().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::<RoutineDeviation>(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::<KnowledgeEventQueue>();
let npc = world.spawn_empty().id();
world.resource_mut::<EntityRegistry>().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::<EntityRegistry>().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::<AnimationTier>(npc).is_none(),
"NPC should not get AnimationTier when no dialogue was active"
);
assert!(
world.get::<RoutineDeviation>(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::<EntityRegistry>().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::<EntityRegistry>().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::<DialogueResponseBuffer>(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::<DialogueResponseBuffer>(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::<EntityRegistry>().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::<EntityRegistry>().register(player);
let mut saw_secret = false;
for seed in 0u64..50 {
world
.get_mut::<DialogueResponseBuffer>(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::<DialogueResponseBuffer>(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<IndexedDialogueLine> = (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::<EntityRegistry>().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::<EntityRegistry>().register(player);
let mut seen_ids: Vec<String> = Vec::new();
for seed in 0u64..10 {
world
.get_mut::<DialogueResponseBuffer>(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::<DialogueResponseBuffer>(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::<EntityRegistry>();
world.insert_resource(SimRng::new(42));
world.init_resource::<TrustEventQueue>();
let npc = world
.spawn((crate::npc::Npc, TilePosition::new(5, 6, 0)))
.id();
let npc_sid = world.resource_mut::<EntityRegistry>().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::<crate::npc::AnimationTier>(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::<crate::npc::RoutineDeviation>(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::<EntityRegistry>();
world.insert_resource(SimRng::new(42));
world.init_resource::<TrustEventQueue>();
let npc = world
.spawn((crate::npc::Npc, TilePosition::new(5, 6, 0)))
.id();
let npc_sid = world.resource_mut::<EntityRegistry>().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::<KnowledgeGraph>(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::<EntityRegistry>();
world.insert_resource(SimRng::new(42));
world.init_resource::<TrustEventQueue>();
let npc = world
.spawn((crate::npc::Npc, TilePosition::new(5, 6, 0)))
.id();
let npc_sid = world.resource_mut::<EntityRegistry>().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::<MonologueBuffer>(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::<EntityRegistry>();
world.insert_resource(SimRng::new(42));
world.init_resource::<TrustEventQueue>();
let npc = world
.spawn((crate::npc::Npc, TilePosition::new(5, 6, 0)))
.id();
let npc_sid = world.resource_mut::<EntityRegistry>().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::<ConfrontationDelivered>(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::<EntityRegistry>().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::<EntityRegistry>().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::<DialogueResponseRequest>(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::<EntityRegistry>().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::<EntityRegistry>().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::<ActiveDialogue>(player).is_none(),
"ActiveDialogue should be cleared when no follow-up lines available"
);
assert!(
world.get::<DialogueResponseRequest>(player).is_none(),
"DialogueResponseRequest should be consumed"
);
// Buffer should remain empty
let buffer = world.get::<DialogueResponseBuffer>(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::<EntityRegistry>().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::<EntityRegistry>().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::<DialogueResponseRequest>(player).is_none(),
"DialogueResponseRequest consumed even with no profile"
);
let buffer = world.get::<DialogueResponseBuffer>(player).unwrap();
assert!(
buffer.response.is_none(),
"No response for NPC without DialogueProfile"
);
}
}