1870 lines
64 KiB
Rust
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"
|
|
);
|
|
}
|
|
}
|