Files
settled-reach/server/src/simulation/dialogue.rs
T

1870 lines
64 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 (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::content::line_pool::{
AccessTier, IndexedDialogueLine, Mood, Situation, Topic, TrustTier,
};
use crate::content::LinePoolIndexResource;
use crate::knowledge::{EntityRegistry, KnowledgeGraph};
use crate::simulation::monologue::{MonologueBuffer, MonologueState};
use crate::simulation::movement::PlayerCharacter;
use crate::simulation::rng::SimRng;
use crate::simulation::time::SimulationTime;
/// 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::Comfortable)
}
}
/// 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.
#[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 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
let total_weight: u32 = scored.iter().map(|(_, s)| s).sum();
if total_weight == 0 {
return None;
}
let mut roll = rng.random_range(0..total_weight);
for (line, weight) in &scored {
if roll < *weight {
return Some(line);
}
roll -= weight;
}
// Fallback (shouldn't reach here with valid weights)
Some(scored.last().unwrap().0)
}
// ---------------------------------------------------------------------------
// 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>,
mut rng: ResMut<SimRng>,
mut event_queue: ResMut<crate::knowledge::KnowledgeEventQueue>,
mut player_query: Query<
(
Entity,
&KnowledgeGraph,
&TalkRequest,
&mut DialogueResponseBuffer,
&mut DialogueCooldownTracker,
Option<&ActiveDialogue>,
),
With<PlayerCharacter>,
>,
npc_query: Query<(&DialogueProfile, Option<&CurrentMood>)>,
) {
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 and mood
let Ok((profile, mood_opt)) = npc_query.get(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);
// Layer 1: Access tiers from relationship
let access_tiers = available_access_tiers(relationship);
// Layer 2: Derive active situations from game state
let situations = derive_situations(time.day_phase(), relationship);
// Layer 3: Trust tier from relationship + confidence (D-075)
// 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 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.0.query_dialogue(
&profile.location,
&profile.role,
*access,
&situations,
trust,
);
for line in results {
// Deduplicate across access tiers (BTreeSet for deterministic iteration)
if seen_ids.insert(&line.id) {
candidates.push(line);
}
}
}
if candidates.is_empty() {
tracing::debug!(
"No dialogue lines available for {}/{} (access={:?}, situations={:?}, trust={:?})",
profile.location,
profile.role,
access_tiers,
situations,
trust,
);
commands.entity(player_entity).remove::<TalkRequest>();
return;
}
// Layer 4: Topic + mood weighted selection
let npc_mood = mood_opt.map(|m| m.0);
let active_topics: Vec<Topic> = Vec::new(); // v0.1: no topic context yet
// Prune old cooldown entries
cooldown.prune(time.tick);
let selected = select_dialogue_line(
&candidates,
npc_mood,
&active_topics,
&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;
};
response_buffer.response = Some(DialogueResponseEvent {
line_id: line.id.clone(),
text: line.text.clone(),
speaker_entity_id: speaker_stable.0,
});
cooldown.record(&line.id, time.tick);
// 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,
});
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>();
}
// ---------------------------------------------------------------------------
// 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>,
time: Res<SimulationTime>,
query: Query<(Entity, Option<&ActiveDialogue>, &WalkAwayRequest), With<PlayerCharacter>>,
) {
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,
});
// 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,
},
});
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.
/// Future: move to content pools with trigger="confrontation_delivered".
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 query: Query<
(
Entity,
&ConfrontationDelivered,
&mut KnowledgeGraph,
&mut MonologueBuffer,
&mut MonologueState,
),
With<PlayerCharacter>,
>,
) {
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,
},
));
// 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;
tracing::info!(
tick = time.tick,
monologue_id = id,
"Confrontation delivered: Tier 2 anim + relationship decrement + monologue spike"
);
// Clean up marker
commands
.entity(player_entity)
.remove::<ConfrontationDelivered>();
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::content::line_pool::{
AccessTier, IndexedDialogueLine, IndexedDialoguePool, LinePoolIndex, Mood, Situation,
Topic, TrustTier,
};
use crate::content::LinePoolIndexResource;
use crate::knowledge::graph::KnowledgeGraph;
use crate::knowledge::registry::EntityRegistry;
use crate::npc::Npc;
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::Worried]);
assert_eq!(score_line(&line, Some(Mood::Worried), &[]), 4); // 1 base + 3 mood
}
#[test]
fn score_mood_mismatch_stays_base() {
let line = make_line("moody", &[], &[Mood::Worried]);
assert_eq!(score_line(&line, Some(Mood::Fond), &[]), 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::Worried]);
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::Worried]);
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::Worried),
&[],
&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::<crate::knowledge::KnowledgeEventQueue>();
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::Comfortable],
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::Conflicted],
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::Comfortable),
))
.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::Comfortable),
))
.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>();
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;
// === 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));
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));
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));
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));
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"
);
}
}