Implement full dialogue selection pipeline (D-028): 4-layer filtering engine with access tier, situation derivation, trust tier, and weighted topic+mood scoring via SimRng. Add ContentSlug component for stable content identity across save/load. Add walk-away KG recording with IncompleteInteraction events per D-064 three-phase consequences. Bump protocol to v8 with DialogueResponseEvent. Fixes #305, #427, #452. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1315 lines
45 KiB
Rust
1315 lines
45 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 bevy_ecs::prelude::*;
|
|
use rand::Rng;
|
|
|
|
use crate::bridge::types::{DialogueResponseEvent, RelationshipState};
|
|
use crate::content::line_pool::{AccessTier, IndexedDialogueLine, Mood, Situation, Topic, TrustTier};
|
|
use crate::content::LinePoolIndexResource;
|
|
use crate::knowledge::{EntityRegistry, KnowledgeGraph};
|
|
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: Vec<(String, u64)>, // (line_id, tick_used)
|
|
}
|
|
|
|
impl DialogueCooldownTracker {
|
|
/// Record that a line was used at the given tick.
|
|
pub fn record(&mut self, line_id: &str, tick: u64) {
|
|
self.used.push((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
|
|
.iter()
|
|
.any(|(id, used_tick)| id == line_id && 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;
|
|
|
|
/// 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 to the player's effective TrustTier.
|
|
///
|
|
/// v0.1 mapping:
|
|
/// - Friendly → Real (relationship depth unlocks deeper trust)
|
|
/// - All others → Surface
|
|
pub fn relationship_to_trust(relationship: RelationshipState) -> TrustTier {
|
|
match relationship {
|
|
RelationshipState::Friendly => 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.
|
|
#[allow(clippy::type_complexity)]
|
|
pub fn process_talk_interaction(
|
|
mut commands: Commands,
|
|
time: Res<SimulationTime>,
|
|
line_pool: Option<Res<LinePoolIndexResource>>,
|
|
registry: Res<EntityRegistry>,
|
|
mut rng: ResMut<SimRng>,
|
|
mut player_query: Query<
|
|
(
|
|
Entity,
|
|
&KnowledgeGraph,
|
|
&TalkRequest,
|
|
&mut DialogueResponseBuffer,
|
|
&mut DialogueCooldownTracker,
|
|
),
|
|
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)) =
|
|
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
|
|
let trust = relationship_to_trust(relationship);
|
|
|
|
// Query Layers 1-3: collect candidates across all available access tiers
|
|
let mut candidates: Vec<&IndexedDialogueLine> = Vec::new();
|
|
let mut seen_ids: Vec<&str> = Vec::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
|
|
if !seen_ids.contains(&line.id.as_str()) {
|
|
seen_ids.push(&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
|
|
let speaker_wire_id = registry.to_stable(target).map(|s| s.0).unwrap_or(0);
|
|
|
|
response_buffer.response = Some(DialogueResponseEvent {
|
|
line_id: line.id.clone(),
|
|
text: line.text.clone(),
|
|
speaker_entity_id: speaker_wire_id,
|
|
});
|
|
|
|
cooldown.record(&line.id, time.tick);
|
|
|
|
// 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_wire_id,
|
|
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.
|
|
///
|
|
/// When the player moves (WASD) during an active dialogue, the client sends
|
|
/// PlayerAction::WalkAway which sets WalkAwayRequest. This system:
|
|
/// 1. Emits IncompleteInteraction knowledge event (recorded in KG)
|
|
/// 2. Clears ActiveDialogue state
|
|
/// 3. 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 {
|
|
// Emit IncompleteInteraction knowledge event
|
|
event_queue.push(crate::knowledge::KnowledgeEvent {
|
|
observer: player_entity,
|
|
tick: time.tick,
|
|
event_type: crate::knowledge::KnowledgeEventType::IncompleteInteraction {
|
|
target: active_dialogue.target,
|
|
interaction_type: active_dialogue.interaction_type,
|
|
},
|
|
});
|
|
|
|
tracing::debug!(
|
|
"Walk-away during {:?} dialogue at tick {} (started tick {})",
|
|
active_dialogue.interaction_type,
|
|
time.tick,
|
|
active_dialogue.started_tick,
|
|
);
|
|
|
|
commands.entity(player_entity).remove::<ActiveDialogue>();
|
|
} else {
|
|
tracing::trace!("WalkAway with no active dialogue — ignored");
|
|
}
|
|
|
|
commands.entity(player_entity).remove::<WalkAwayRequest>();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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]);
|
|
}
|
|
|
|
#[test]
|
|
fn trust_friendly_is_real() {
|
|
assert_eq!(
|
|
relationship_to_trust(RelationshipState::Friendly),
|
|
TrustTier::Real
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn trust_others_are_surface() {
|
|
assert_eq!(
|
|
relationship_to_trust(RelationshipState::Unknown),
|
|
TrustTier::Surface
|
|
);
|
|
assert_eq!(
|
|
relationship_to_trust(RelationshipState::Known),
|
|
TrustTier::Surface
|
|
);
|
|
assert_eq!(
|
|
relationship_to_trust(RelationshipState::PersonOfInterest),
|
|
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_eq!(tracker.used[0].0, "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
|
|
}
|
|
|
|
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"
|
|
);
|
|
}
|
|
|
|
use rand::SeedableRng;
|
|
}
|