diff --git a/server/src/bridge/mod.rs b/server/src/bridge/mod.rs index 35340576a..4a8e79a3f 100644 --- a/server/src/bridge/mod.rs +++ b/server/src/bridge/mod.rs @@ -167,12 +167,19 @@ impl Plugin for BridgePlugin { .after(crate::simulation::movement::validate_movement), crate::simulation::monologue::trigger_monologue .after(crate::simulation::movement::validate_movement), - crate::simulation::monologue::process_sprint_anomaly_monologue + crate::simulation::monologue::trigger_recognition_monologue .after(crate::simulation::monologue::trigger_monologue), + crate::simulation::monologue::process_sprint_anomaly_monologue + .after(crate::simulation::monologue::trigger_recognition_monologue), + crate::simulation::dialogue::process_talk_interaction + .after(crate::simulation::input::process_player_input), + crate::simulation::dialogue::process_walk_away + .after(crate::simulation::input::process_player_input), crate::perception::observer::compute_observer_snapshot .after(crate::perception::observer::compute_visibility_geometry) .after(crate::simulation::interaction::compute_nearby_interactions) .after(crate::simulation::monologue::process_sprint_anomaly_monologue) + .after(crate::simulation::dialogue::process_talk_interaction) .before(crate::simulation::time::advance_tick), crate::perception::observation::emit_observation_events .after(crate::perception::observer::compute_observer_snapshot), diff --git a/server/src/bridge/types.rs b/server/src/bridge/types.rs index 6d3a13f09..a22a22fdc 100644 --- a/server/src/bridge/types.rs +++ b/server/src/bridge/types.rs @@ -15,7 +15,7 @@ pub use crate::simulation::time::{DayPhase, TickRate}; /// negotiation is unnecessary. Client should reject snapshots with version != /// PROTOCOL_VERSION. New fields use #[serde(default)] only during the migration /// period, then the default is removed once both sides are updated. -pub const PROTOCOL_VERSION: u8 = 7; +pub const PROTOCOL_VERSION: u8 = 8; /// The ONLY data structure crossing the client-server boundary (D-020) /// Contains all information visible to the observer at a given tick. @@ -26,6 +26,7 @@ pub const PROTOCOL_VERSION: u8 = 7; /// v5 adds: current_monologue (#414 internal monologue pipeline). /// v6 adds: player_stance (#449, D-053), player_inventory (#449, D-065). /// v7 adds: pending_recognitions (#423, D-060 cognitive delay). +/// v8 adds: dialogue_response (#305, D-028 dialogue pipeline). /// Future fields: ambient sound events, HUD state (D-020 expansion). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ObserverSnapshot { @@ -62,6 +63,11 @@ pub struct ObserverSnapshot { /// Empty when no recognitions are pending. #[serde(default)] pub pending_recognitions: Vec, + /// Dialogue response from Talk verb interaction (#305, D-028). + /// Present when the player talked to an NPC this tick and a line was selected. + /// Client shows speaker name + dialogue text in a dialogue box. + #[serde(default)] + pub dialogue_response: Option, } /// Game time data for client display (D-031) @@ -300,6 +306,10 @@ pub enum PlayerAction { UsePerceptionMode(String), Pause, Unpause, + /// Player walked away during active dialogue (WASD during conversation, D-064). + /// Client sends this when movement input is detected while dialogue box is visible. + /// Server records incomplete interaction in KG and clears dialogue state. + WalkAway, /// Set tick rate: Full (1.0), Half (0.5), or Paused (0.0) per D-052 SetTickRate(TickRate), /// Move one step up the stance ladder (toward Sprint) per D-053 @@ -434,6 +444,18 @@ pub struct MonologueEvent { pub duration_seconds: f32, } +/// Dialogue response event sent to the client for display (#305, D-028). +/// Contains the selected line and speaker identity. Client renders a dialogue box. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DialogueResponseEvent { + /// Dialogue line ID (for dedup and cooldown tracking) + pub line_id: String, + /// The dialogue text to display + pub text: String, + /// Wire-format entity identifier of the speaking NPC + pub speaker_entity_id: u64, +} + /// Snapshot buffer resource for staging outgoing ObserverSnapshots #[derive(Resource, Debug, Default)] pub struct SnapshotBuffer { diff --git a/server/src/content/spawn.rs b/server/src/content/spawn.rs index 7f805874f..072151692 100644 --- a/server/src/content/spawn.rs +++ b/server/src/content/spawn.rs @@ -32,6 +32,19 @@ use crate::simulation::interaction::Interactable; use crate::simulation::movement::TilePosition; use crate::simulation::time::DayPhase; +/// Stable content identifier from YAML (e.g., "kael-davan", "sera-venn"). +/// +/// Bridges authoring identity to ECS entities. Independent of StableId — +/// StableId is runtime entity tracking (KG references), ContentSlug is +/// authoring/content identity (which authored NPC template). Not all entities +/// have ContentSlugs (e.g., procedurally spawned NPCs, furniture). +/// +/// Used by #427 (walk-away KG recording) to record interaction memory +/// against a stable content identity rather than an Entity (which is +/// unstable across save/load). +#[derive(Component, Debug, Clone, PartialEq, Eq, Hash)] +pub struct ContentSlug(pub String); + /// Result of spawning content into the ECS world. #[derive(Debug, Default)] pub struct SpawnResult { @@ -184,7 +197,9 @@ fn spawn_npc(world: &mut World, profile: &types::NpcProfile, result: &mut SpawnR // Register in EntityRegistry for StableId mapping let stable_id = world.resource_mut::().register(entity); - world.entity_mut(entity).insert(StableEntityId(stable_id)); + world + .entity_mut(entity) + .insert((StableEntityId(stable_id), ContentSlug(profile.canonical_id.clone()))); result .npc_ids @@ -722,6 +737,26 @@ mod tests { assert!(!skills.combat_trained); } + #[test] + fn spawn_npc_attaches_content_slug() { + let mut world = create_test_world(); + let profile = create_test_profile(); + let mut result = SpawnResult::default(); + + spawn_npc(&mut world, &profile, &mut result); + + let stable_id = result.npc_ids["test-npc"]; + let entity = world + .resource::() + .to_entity(&stable_id) + .unwrap(); + + let slug = world + .get::(entity) + .expect("ContentSlug should be attached during spawn"); + assert_eq!(slug.0, "test-npc"); + } + #[test] fn spawn_npc_minimal_profile() { let mut world = create_test_world(); diff --git a/server/src/knowledge/events.rs b/server/src/knowledge/events.rs index e97e9bc58..48615e68f 100644 --- a/server/src/knowledge/events.rs +++ b/server/src/knowledge/events.rs @@ -29,6 +29,25 @@ pub enum KnowledgeEventType { }, /// Entity left observer's LOS (downgrades from Direct). LeftLOS { target: Entity }, + /// Observer walked away from an active interaction (D-064). + /// Records incompleteness in the target's known_attributes for future + /// dialogue/monologue consequences. + IncompleteInteraction { + target: Entity, + interaction_type: InteractionType, + }, +} + +/// Type of interaction for walk-away recording (D-064). +/// Differentiates casual conversation from confrontation — +/// future dialogue may react differently. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InteractionType { + /// Normal Talk conversation. + Talk, + /// Confrontation (D-063). Walking away from confrontation + /// carries heavier consequences than casual talk. + Confront, } /// Resource: queue of pending knowledge events. @@ -98,6 +117,24 @@ pub fn process_knowledge_events( tracing::error!("LeftLOS target {:?} not in EntityRegistry", target); } } + KnowledgeEventType::IncompleteInteraction { + target, + interaction_type, + } => { + if let Some(stable_id) = registry.to_stable(target) { + observer_kg.record_incomplete_interaction( + &stable_id, + interaction_type, + event.tick, + ); + tracing::debug!( + "Recorded incomplete {:?} interaction with {:?} at tick {}", + interaction_type, + stable_id, + event.tick, + ); + } + } } } } diff --git a/server/src/knowledge/graph.rs b/server/src/knowledge/graph.rs index bbdb16c87..081194709 100644 --- a/server/src/knowledge/graph.rs +++ b/server/src/knowledge/graph.rs @@ -145,6 +145,61 @@ impl KnowledgeGraph { } } + /// Record an incomplete interaction with an entity (D-064 walk-away). + /// + /// Appends to known_attributes["incomplete_interactions"] as a + /// comma-separated list of "tick:type" entries. Creates the entity + /// entry if it doesn't exist (at Suspects confidence). + pub fn record_incomplete_interaction( + &mut self, + target: &StableId, + interaction_type: super::events::InteractionType, + tick: u64, + ) { + let entry = self + .entities + .entry(*target) + .or_insert_with(|| EntityKnowledge { + last_known_position: None, + last_observed_tick: 0, + last_updated_tick: 0, + confidence: KnowledgeConfidence::Suspects, + source: KnowledgeSource::DirectObservation { tick }, + state: KnowledgeState::Active, + relationship: RelationshipState::Unknown, + known_attributes: BTreeMap::new(), + }); + + let type_str = match interaction_type { + super::events::InteractionType::Talk => "talk", + super::events::InteractionType::Confront => "confront", + }; + let record = format!("{}:{}", tick, type_str); + + entry + .known_attributes + .entry("incomplete_interactions".to_string()) + .and_modify(|v| { + v.push(','); + v.push_str(&record); + }) + .or_insert(record); + + entry.last_updated_tick = tick; + } + + /// Check if the observer has any incomplete interactions with an entity. + /// + /// Returns true if known_attributes["incomplete_interactions"] exists + /// and is non-empty. Used by dialogue/monologue systems to gate + /// post-conversation reactions (D-064 phase 3). + pub fn has_incomplete_interaction(&self, target: &StableId) -> bool { + self.entities + .get(target) + .and_then(|e| e.known_attributes.get("incomplete_interactions")) + .is_some_and(|v| !v.is_empty()) + } + /// Set relationship state for an entity. pub fn set_relationship(&mut self, target: &StableId, state: RelationshipState) { if let Some(entry) = self.entities.get_mut(target) { diff --git a/server/src/knowledge/mod.rs b/server/src/knowledge/mod.rs index 4683109b3..2f9f8ffba 100644 --- a/server/src/knowledge/mod.rs +++ b/server/src/knowledge/mod.rs @@ -12,7 +12,7 @@ pub mod graph; pub mod registry; pub mod types; -pub use events::{KnowledgeEvent, KnowledgeEventQueue, KnowledgeEventType}; +pub use events::{InteractionType, KnowledgeEvent, KnowledgeEventQueue, KnowledgeEventType}; pub use graph::KnowledgeGraph; pub use registry::{EntityRegistry, StableEntityId}; pub use types::*; diff --git a/server/src/simulation/dialogue.rs b/server/src/simulation/dialogue.rs new file mode 100644 index 000000000..7f7088f1f --- /dev/null +++ b/server/src/simulation/dialogue.rs @@ -0,0 +1,1314 @@ +//! 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, +} + +impl DialogueResponseBuffer { + /// Drain and return the dialogue response, leaving the buffer empty. + pub fn take(&mut self) -> Option { + self.response.take() + } +} + +// --------------------------------------------------------------------------- +// Mapping functions (D-028 Layer 1 + Layer 3) +// --------------------------------------------------------------------------- + +/// Map RelationshipState to the set of AccessTiers the player can access. +/// +/// Per sprint briefing: +/// - Unknown → Public only +/// - Known → Public + Peer +/// - Friendly → Public + Peer + Insider +/// - PersonOfInterest → Public + Peer + Authority (detective investigation context) +/// - Hostile → Hostile only +pub fn available_access_tiers(relationship: RelationshipState) -> Vec { + match relationship { + RelationshipState::Unknown => vec![AccessTier::Public], + RelationshipState::Known => vec![AccessTier::Public, AccessTier::Peer], + RelationshipState::Friendly => { + vec![AccessTier::Public, AccessTier::Peer, AccessTier::Insider] + } + RelationshipState::PersonOfInterest => { + vec![AccessTier::Public, AccessTier::Peer, AccessTier::Authority] + } + RelationshipState::Hostile => vec![AccessTier::Hostile], + } +} + +/// Map RelationshipState 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 { + use crate::simulation::time::DayPhase; + + let mut situations = vec![Situation::Routine]; // Always active baseline + + // Day phase → situation mapping + match day_phase { + DayPhase::Morning => situations.push(Situation::ShiftStart), + DayPhase::Afternoon => situations.push(Situation::Social), + DayPhase::Evening => { + situations.push(Situation::BarEvening); + situations.push(Situation::Social); + } + DayPhase::Night => situations.push(Situation::NightShift), + } + + // Relationship context + if relationship == RelationshipState::PersonOfInterest { + situations.push(Situation::Investigation); + } + + situations +} + +// --------------------------------------------------------------------------- +// Layer 4: Topic + Mood weighted selection +// --------------------------------------------------------------------------- + +/// Score a dialogue line by topic and mood match. +/// +/// Scoring: +/// - Base score: 1 (topic/mood-neutral lines always eligible) +/// - Mood match: +3 if NPC's CurrentMood is in line.mood +/// - Topic match: +2 per matching topic +/// +/// Returns 0 only for lines on cooldown (caller handles). +pub fn score_line(line: &IndexedDialogueLine, npc_mood: Option, active_topics: &[Topic]) -> u32 { + let mut score: u32 = 1; // Base score — no line is excluded by Layer 4 + + // Mood match + if let Some(mood) = npc_mood { + if line.mood.contains(&mood) { + score += 3; + } + } + + // Topic match + for topic in active_topics { + if line.topic.contains(topic) { + score += 2; + } + } + + score +} + +/// Select a dialogue line from Layer 1-3 filtered candidates using Layer 4 scoring. +/// +/// Performs weighted random selection: lines with higher topic/mood match scores +/// are more likely to be chosen. Lines on cooldown are excluded. +/// +/// Returns None if no eligible lines remain after cooldown filtering. +pub fn select_dialogue_line<'a>( + candidates: &[&'a IndexedDialogueLine], + npc_mood: Option, + active_topics: &[Topic], + cooldown: &DialogueCooldownTracker, + tick: u64, + rng: &mut impl Rng, +) -> Option<&'a IndexedDialogueLine> { + // Score and filter by cooldown + let scored: Vec<(&IndexedDialogueLine, u32)> = candidates + .iter() + .filter(|line| !cooldown.is_on_cooldown(&line.id, tick)) + .map(|line| (*line, score_line(line, npc_mood, active_topics))) + .collect(); + + if scored.is_empty() { + return None; + } + + // Weighted random selection + 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, + line_pool: Option>, + registry: Res, + mut rng: ResMut, + mut player_query: Query< + ( + Entity, + &KnowledgeGraph, + &TalkRequest, + &mut DialogueResponseBuffer, + &mut DialogueCooldownTracker, + ), + With, + >, + 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::(); + 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::(); + return; + } + + // Layer 4: Topic + mood weighted selection + let npc_mood = mood_opt.map(|m| m.0); + let active_topics: Vec = 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::(); +} + +// --------------------------------------------------------------------------- +// 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, + time: Res, + query: Query<(Entity, Option<&ActiveDialogue>, &WalkAwayRequest), With>, +) { + 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::(); + } else { + tracing::trace!("WalkAway with no active dialogue — ignored"); + } + + commands.entity(player_entity).remove::(); +} + +// --------------------------------------------------------------------------- +// 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::(); + world.insert_resource(SimRng::new(42)); + world.init_resource::(); + world + } + + fn build_test_line_pool() -> LinePoolIndex { + let mut index = LinePoolIndex::default(); + let lines = vec![ + IndexedDialogueLine { + id: "test_d_001".to_string(), + text: "Welcome to the terminal.".to_string(), + role: "dock-worker".to_string(), + access: vec![AccessTier::Public], + trust: TrustTier::Surface, + situation: vec![Situation::Routine, Situation::Social], + topic: vec![], + mood: vec![], + tags: vec![], + knowledge_grant: None, + }, + IndexedDialogueLine { + id: "test_d_002".to_string(), + text: "I've seen some strange cargo lately.".to_string(), + role: "dock-worker".to_string(), + access: vec![AccessTier::Peer], + trust: TrustTier::Surface, + situation: vec![Situation::Routine, Situation::Investigation], + topic: vec![Topic::Cargo], + mood: vec![Mood::Suspicious], + tags: vec![], + knowledge_grant: None, + }, + IndexedDialogueLine { + id: "test_d_003".to_string(), + text: "The night shifts have been quiet.".to_string(), + role: "dock-worker".to_string(), + access: vec![AccessTier::Public], + trust: TrustTier::Surface, + situation: vec![Situation::NightShift], + topic: vec![Topic::Routine], + mood: vec![Mood::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::().register(npc); + + // Spawn player with KG that doesn't know the NPC + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(5, 6, 0), + KnowledgeGraph::new(), + TalkRequest { target: npc }, + DialogueResponseBuffer::default(), + DialogueCooldownTracker::default(), + )) + .id(); + world.resource_mut::().register(player); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_talk_interaction); + schedule.run(&mut world); + world.flush(); + + // Should get a Public line (Unknown relationship → Public access only) + let buffer = world.get::(player).unwrap(); + assert!( + buffer.response.is_some(), + "should select a dialogue line for Unknown relationship" + ); + let response = buffer.response.as_ref().unwrap(); + // Only test_d_001 and test_d_003 are Public + match Routine situation + // But test_d_003 requires NightShift situation which isn't active by default + assert_eq!( + response.line_id, "test_d_001", + "should select the public routine line" + ); + } + + #[test] + fn process_talk_removes_talk_request() { + let mut world = setup_dialogue_world(); + let index = build_test_line_pool(); + world.insert_resource(LinePoolIndexResource(index)); + + let npc = world + .spawn(( + Npc, + TilePosition::new(5, 5, 0), + DialogueProfile { + location: "the-terminal".to_string(), + role: "dock-worker".to_string(), + }, + )) + .id(); + world.resource_mut::().register(npc); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(5, 6, 0), + KnowledgeGraph::new(), + TalkRequest { target: npc }, + DialogueResponseBuffer::default(), + DialogueCooldownTracker::default(), + )) + .id(); + world.resource_mut::().register(player); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_talk_interaction); + schedule.run(&mut world); + world.flush(); + + assert!( + world.get::(player).is_none(), + "TalkRequest should be consumed after processing" + ); + } + + #[test] + fn process_talk_known_relationship_gets_peer_lines() { + let mut world = setup_dialogue_world(); + let index = build_test_line_pool(); + world.insert_resource(LinePoolIndexResource(index)); + + let npc = world + .spawn(( + Npc, + TilePosition::new(5, 5, 0), + DialogueProfile { + location: "the-terminal".to_string(), + role: "dock-worker".to_string(), + }, + CurrentMood(Mood::Suspicious), + )) + .id(); + let npc_sid = world.resource_mut::().register(npc); + + // Player knows the NPC (Known relationship) + let mut kg = KnowledgeGraph::new(); + kg.observe_entity(npc_sid, TilePosition::new(5, 5, 0), 0); + kg.set_relationship(&npc_sid, RelationshipState::Known); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(5, 6, 0), + kg, + TalkRequest { target: npc }, + DialogueResponseBuffer::default(), + DialogueCooldownTracker::default(), + )) + .id(); + world.resource_mut::().register(player); + + // Run multiple times to verify peer lines are accessible + let mut seen_ids: Vec = Vec::new(); + for seed in 0..20 { + // Reset for each iteration + world.get_mut::(player).unwrap().response = None; + world + .entity_mut(player) + .insert(TalkRequest { target: npc }); + world.insert_resource(SimRng::new(seed)); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_talk_interaction); + schedule.run(&mut world); + world.flush(); + + if let Some(resp) = &world.get::(player).unwrap().response { + if !seen_ids.contains(&resp.line_id) { + seen_ids.push(resp.line_id.clone()); + } + } + } + + // Known relationship gives Public + Peer access, Routine situation + // Should see test_d_001 (public, routine) and test_d_002 (peer, routine) + assert!( + seen_ids.contains(&"test_d_001".to_string()), + "should access public line" + ); + assert!( + seen_ids.contains(&"test_d_002".to_string()), + "should access peer line with Known relationship" + ); + } + + #[test] + fn process_talk_no_dialogue_profile_is_noop() { + let mut world = setup_dialogue_world(); + let index = build_test_line_pool(); + world.insert_resource(LinePoolIndexResource(index)); + + // NPC without DialogueProfile + let npc = world + .spawn((Npc, TilePosition::new(5, 5, 0))) + .id(); + world.resource_mut::().register(npc); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(5, 6, 0), + KnowledgeGraph::new(), + TalkRequest { target: npc }, + DialogueResponseBuffer::default(), + DialogueCooldownTracker::default(), + )) + .id(); + world.resource_mut::().register(player); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_talk_interaction); + schedule.run(&mut world); + world.flush(); + + let buffer = world.get::(player).unwrap(); + assert!( + buffer.response.is_none(), + "NPC without DialogueProfile should produce no dialogue" + ); + } + + #[test] + fn cooldown_prevents_same_line_repeat() { + let mut world = setup_dialogue_world(); + + // Build index with only one line + let mut index = LinePoolIndex::default(); + let pool = IndexedDialoguePool { + location: "test".to_string(), + role: "worker".to_string(), + lines: vec![IndexedDialogueLine { + id: "only_line".to_string(), + text: "The only thing I can say.".to_string(), + role: "worker".to_string(), + access: vec![AccessTier::Public], + trust: TrustTier::Surface, + situation: vec![Situation::Routine], + topic: vec![], + mood: vec![], + tags: vec![], + knowledge_grant: None, + }], + }; + index + .dialogue + .insert(("test".to_string(), "worker".to_string()), pool); + world.insert_resource(LinePoolIndexResource(index)); + + let npc = world + .spawn(( + Npc, + TilePosition::new(5, 5, 0), + DialogueProfile { + location: "test".to_string(), + role: "worker".to_string(), + }, + )) + .id(); + world.resource_mut::().register(npc); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(5, 6, 0), + KnowledgeGraph::new(), + TalkRequest { target: npc }, + DialogueResponseBuffer::default(), + DialogueCooldownTracker::default(), + )) + .id(); + world.resource_mut::().register(player); + + // First talk — should succeed + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_talk_interaction); + schedule.run(&mut world); + world.flush(); + + assert!( + world + .get::(player) + .unwrap() + .response + .is_some(), + "first talk should select the line" + ); + + // Second talk — same tick, line on cooldown + world.get_mut::(player).unwrap().response = None; + world + .entity_mut(player) + .insert(TalkRequest { target: npc }); + + let mut schedule2 = bevy_ecs::schedule::Schedule::default(); + schedule2.add_systems(process_talk_interaction); + schedule2.run(&mut world); + world.flush(); + + assert!( + world + .get::(player) + .unwrap() + .response + .is_none(), + "second talk should fail — line on cooldown" + ); + } + + // -- Walk-away tests (D-064, #427) ---------------------------------------- + + #[test] + fn talk_sets_active_dialogue() { + let mut world = setup_dialogue_world(); + let index = build_test_line_pool(); + world.insert_resource(LinePoolIndexResource(index)); + + let npc = world + .spawn(( + Npc, + TilePosition::new(5, 5, 0), + DialogueProfile { + location: "the-terminal".to_string(), + role: "dock-worker".to_string(), + }, + CurrentMood(Mood::Comfortable), + )) + .id(); + world.resource_mut::().register(npc); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(5, 6, 0), + KnowledgeGraph::new(), + TalkRequest { target: npc }, + DialogueResponseBuffer::default(), + DialogueCooldownTracker::default(), + )) + .id(); + world.resource_mut::().register(player); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_talk_interaction); + schedule.run(&mut world); + world.flush(); + + let active = world + .get::(player) + .expect("ActiveDialogue should be set after successful dialogue"); + assert_eq!(active.target, npc); + assert_eq!( + active.interaction_type, + crate::knowledge::events::InteractionType::Talk + ); + assert_eq!(active.started_tick, 0); + } + + #[test] + fn walk_away_during_active_dialogue_emits_event() { + use crate::knowledge::KnowledgeEventQueue; + + let mut world = setup_dialogue_world(); + world.init_resource::(); + + let npc = world.spawn_empty().id(); + world.resource_mut::().register(npc); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(5, 6, 0), + KnowledgeGraph::new(), + ActiveDialogue { + target: npc, + interaction_type: crate::knowledge::events::InteractionType::Talk, + started_tick: 10, + }, + WalkAwayRequest, + )) + .id(); + world.resource_mut::().register(player); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_walk_away); + schedule.run(&mut world); + world.flush(); + + // ActiveDialogue and WalkAwayRequest should be removed + assert!( + world.get::(player).is_none(), + "ActiveDialogue should be cleared after walk-away" + ); + assert!( + world.get::(player).is_none(), + "WalkAwayRequest should be consumed" + ); + + // KnowledgeEventQueue should have one IncompleteInteraction event + let queue = world.resource::(); + assert_eq!(queue.len(), 1, "should emit exactly one knowledge event"); + } + + #[test] + fn walk_away_without_active_dialogue_is_noop() { + use crate::knowledge::KnowledgeEventQueue; + + let mut world = setup_dialogue_world(); + world.init_resource::(); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(5, 6, 0), + KnowledgeGraph::new(), + WalkAwayRequest, + )) + .id(); + world.resource_mut::().register(player); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_walk_away); + schedule.run(&mut world); + world.flush(); + + // WalkAwayRequest consumed but no event emitted + assert!( + world.get::(player).is_none(), + "WalkAwayRequest should be consumed even without dialogue" + ); + + let queue = world.resource::(); + assert!( + queue.is_empty(), + "no event should be emitted when not in dialogue" + ); + } + + #[test] + fn walk_away_records_in_knowledge_graph() { + // Full integration: walk-away → event → KG recording + use crate::knowledge::KnowledgeEventQueue; + + let mut world = setup_dialogue_world(); + world.init_resource::(); + + let npc = world.spawn_empty().id(); + let npc_sid = world.resource_mut::().register(npc); + + // Pre-populate player KG with knowledge of the NPC + let mut kg = KnowledgeGraph::new(); + kg.observe_entity(npc_sid, TilePosition::new(5, 5, 0), 0); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(5, 6, 0), + kg, + ActiveDialogue { + target: npc, + interaction_type: crate::knowledge::events::InteractionType::Talk, + started_tick: 5, + }, + WalkAwayRequest, + )) + .id(); + world.resource_mut::().register(player); + + // Step 1: process_walk_away emits the event + let mut schedule1 = bevy_ecs::schedule::Schedule::default(); + schedule1.add_systems(process_walk_away); + schedule1.run(&mut world); + world.flush(); + + // Step 2: process_knowledge_events applies it to the KG + let mut schedule2 = bevy_ecs::schedule::Schedule::default(); + schedule2.add_systems(crate::knowledge::events::process_knowledge_events); + schedule2.run(&mut world); + + // Verify the KG recorded the incomplete interaction + let player_kg = world.get::(player).unwrap(); + assert!( + player_kg.has_incomplete_interaction(&npc_sid), + "KG should record incomplete interaction after walk-away" + ); + } + + use rand::SeedableRng; +} diff --git a/server/src/simulation/input.rs b/server/src/simulation/input.rs index 12c0390bc..76996986b 100644 --- a/server/src/simulation/input.rs +++ b/server/src/simulation/input.rs @@ -99,8 +99,14 @@ pub fn process_player_input( let mut move_attempted = false; for input in inputs { - // Discard movement while paused (D-052). Pause/Unpause still processed. - if paused && input.action.is_movement() { + // Discard all gameplay actions while paused (D-052, R2-OQ-01). + // Only Pause/Unpause are processed — everything else is discarded. + if paused + && !matches!( + input.action, + PlayerAction::Pause | PlayerAction::Unpause + ) + { continue; } match input.action { @@ -182,14 +188,25 @@ pub fn process_player_input( Some("Place") => { handle_place(&mut commands, ®istry, &player_query, target_entity_id); } + Some("Talk") => { + handle_talk(&mut commands, ®istry, &player_query, target_entity_id); + } _ => { tracing::info!( - "Interact: target={:?}, verb={:?} — logged only, dialogue dispatch future scope (#415)", + "Interact: target={:?}, verb={:?} — logged only", target_entity_id, verb, ); } }, + PlayerAction::WalkAway => { + if let Ok((player_entity, _, _, _)) = player_query.single() { + commands + .entity(player_entity) + .insert(crate::simulation::dialogue::WalkAwayRequest); + tracing::debug!("WalkAway: marker set on player"); + } + } PlayerAction::UsePerceptionMode(ref mode) => { tracing::trace!("UsePerceptionMode({}) — no-op for Sprint 1", mode); } @@ -309,6 +326,47 @@ fn handle_take( ); } +/// Handle Talk verb: set TalkRequest marker on the player entity for the target NPC. +/// The actual dialogue pipeline runs in process_talk_interaction (dialogue.rs). +#[allow(clippy::type_complexity)] +fn handle_talk( + commands: &mut Commands, + registry: &EntityRegistry, + player_query: &Query< + ( + Entity, + &TilePosition, + Option<&mut Stance>, + Option<&mut PlayerMoveCooldown>, + ), + With, + >, + target_entity_id: Option, +) { + let Some(target_id) = target_entity_id else { + tracing::warn!("Talk verb without target_entity_id"); + return; + }; + + let Ok((player_entity, _, _, _)) = player_query.single() else { + return; + }; + + let target_stable = StableId(target_id); + let Some(target_entity) = registry.to_entity(&target_stable) else { + tracing::warn!(target_id, "Talk: target entity not in registry"); + return; + }; + + commands + .entity(player_entity) + .insert(crate::simulation::dialogue::TalkRequest { + target: target_entity, + }); + + tracing::debug!(target_id, "Talk: TalkRequest marker set on player"); +} + /// Handle Place verb: remove an item from inventory and place it on the ground /// at the player's current position. Removes CarriedBy + InventorySlot, adds /// TilePosition at the player's current tile. @@ -979,6 +1037,304 @@ mod tests { ); } + // === Pause Guard Tests (#461, #462, #463) === + // Prevent Bug #3 recurrence: player movement while paused. + // The pause guard at process_player_input discards movement inputs + // when SimulationTime.tick_rate == TickRate::Paused (D-052). + + #[test] + fn movement_discarded_while_paused() { + // #461: Movement input rejected while paused — prevents Bug #3 recurrence. + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(InputQueue::default()); + let mut time = SimulationTime::default(); + time.tick_rate = TickRate::Paused; + world.insert_resource(time); + world.init_resource::(); + + let player = world + .spawn((PlayerCharacter, TilePosition::new(5, 5, 0))) + .id(); + + world.resource_mut::().push(PlayerInput { + tick: 0, + action: PlayerAction::MoveNorth, + }); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_player_input); + schedule.run(&mut world); + + // Movement must be discarded — no MoveIntent created + assert!( + world.get::(player).is_none(), + "MoveNorth must be discarded while paused (Bug #3 guard)" + ); + } + + #[test] + fn unpause_accepted_while_paused() { + // #462: Unpause command is the one control action allowed while paused. + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(InputQueue::default()); + let mut time = SimulationTime::default(); + time.tick_rate = TickRate::Paused; + world.insert_resource(time); + world.init_resource::(); + + // Player entity required for process_player_input (even if no movement) + world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0))); + + world.resource_mut::().push(PlayerInput { + tick: 0, + action: PlayerAction::Unpause, + }); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_player_input); + schedule.run(&mut world); + + assert_eq!( + world.resource::().tick_rate, + TickRate::Full, + "Unpause must be accepted while paused" + ); + } + + #[test] + fn pause_unpause_roundtrip_with_movement() { + // #463: Full cycle — pause -> move (rejected) -> unpause -> move (accepted). + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(InputQueue::default()); + world.insert_resource(SimulationTime::default()); + world.init_resource::(); + + let player = world + .spawn((PlayerCharacter, TilePosition::new(5, 5, 0))) + .id(); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_player_input); + + // Step 1: Pause + world.resource_mut::().push(PlayerInput { + tick: 0, + action: PlayerAction::Pause, + }); + schedule.run(&mut world); + assert_eq!( + world.resource::().tick_rate, + TickRate::Paused, + "Step 1: game should be paused" + ); + + // Step 2: Move while paused — must be rejected + world.resource_mut::().push(PlayerInput { + tick: 0, + action: PlayerAction::MoveNorth, + }); + schedule.run(&mut world); + assert!( + world.get::(player).is_none(), + "Step 2: movement must be rejected while paused" + ); + + // Step 3: Unpause + world.resource_mut::().push(PlayerInput { + tick: 0, + action: PlayerAction::Unpause, + }); + schedule.run(&mut world); + assert_eq!( + world.resource::().tick_rate, + TickRate::Full, + "Step 3: game should be unpaused" + ); + + // Step 4: Move after unpause — must succeed + world.resource_mut::().push(PlayerInput { + tick: 0, + action: PlayerAction::MoveNorth, + }); + schedule.run(&mut world); + assert!( + world.get::(player).is_some(), + "Step 4: movement must succeed after unpause" + ); + } + + // === Remaining Pause Guard Tests (#468) === + // Edge cases: stance, interact, batch discard, and SetTickRate while paused. + + #[test] + fn stance_toggle_rejected_while_paused() { + // #468: Stance toggle rejected while paused. + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(InputQueue::default()); + let mut time = SimulationTime::default(); + time.tick_rate = TickRate::Paused; + world.insert_resource(time); + world.init_resource::(); + + world.spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + Stance::default(), // Walk + PlayerMoveCooldown::default(), + )); + + world.resource_mut::().push(PlayerInput { + tick: 0, + action: PlayerAction::ToggleStanceUp, + }); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_player_input); + schedule.run(&mut world); + + let mut query = world.query::<&Stance>(); + let stance = query.single(&world).unwrap(); + assert_eq!( + stance.0, + MovementStance::Walk, + "Stance toggle must be rejected while paused" + ); + } + + #[test] + fn interact_rejected_while_paused() { + // #468: Interact rejected while paused. + // This test verifies no panic and no side effects — interact is a no-op while paused. + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(InputQueue::default()); + let mut time = SimulationTime::default(); + time.tick_rate = TickRate::Paused; + world.insert_resource(time); + world.init_resource::(); + + let player = world + .spawn((PlayerCharacter, TilePosition::new(5, 5, 0))) + .id(); + world + .resource_mut::() + .register(player); + + // Spawn item on the ground + let item = world + .spawn((TilePosition::new(5, 4, 0), ItemName("Manifest Copy".into()))) + .id(); + let item_sid = world + .resource_mut::() + .register(item); + + world.resource_mut::().push(PlayerInput { + tick: 0, + action: PlayerAction::Interact { + target_entity_id: Some(item_sid.0), + verb: Some("Take".into()), + }, + }); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_player_input); + schedule.run(&mut world); + + // Item must remain on ground — Take rejected while paused + assert!( + world.get::(item).is_some(), + "Item must stay on ground — interact rejected while paused" + ); + assert!( + world.get::(item).is_none(), + "Item must not be picked up while paused" + ); + } + + #[test] + fn batch_discard_while_paused() { + // #468: All inputs in a batch discarded while paused (except Pause/Unpause). + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(InputQueue::default()); + let mut time = SimulationTime::default(); + time.tick_rate = TickRate::Paused; + world.insert_resource(time); + world.init_resource::(); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + Stance::default(), + PlayerMoveCooldown::default(), + )) + .id(); + + // Push a batch of mixed inputs — all should be discarded except Unpause + let queue = &mut world.resource_mut::(); + queue.push(PlayerInput { + tick: 0, + action: PlayerAction::MoveNorth, + }); + queue.push(PlayerInput { + tick: 0, + action: PlayerAction::ToggleStanceUp, + }); + queue.push(PlayerInput { + tick: 0, + action: PlayerAction::SetTickRate(TickRate::Half), + }); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_player_input); + schedule.run(&mut world); + + // All gameplay actions discarded + assert!( + world.get::(player).is_none(), + "Movement discarded in batch" + ); + let mut query = world.query::<&Stance>(); + let stance = query.single(&world).unwrap(); + assert_eq!( + stance.0, + MovementStance::Walk, + "Stance unchanged in batch" + ); + assert_eq!( + world.resource::().tick_rate, + TickRate::Paused, + "SetTickRate discarded in batch — still paused" + ); + } + + #[test] + fn set_tick_rate_rejected_while_paused() { + // #468 / R2-OQ-01: SetTickRate(Half) while paused is a bug — must be rejected. + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(InputQueue::default()); + let mut time = SimulationTime::default(); + time.tick_rate = TickRate::Paused; + world.insert_resource(time); + world.init_resource::(); + + world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0))); + + world.resource_mut::().push(PlayerInput { + tick: 0, + action: PlayerAction::SetTickRate(TickRate::Half), + }); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_player_input); + schedule.run(&mut world); + + assert_eq!( + world.resource::().tick_rate, + TickRate::Paused, + "SetTickRate must be rejected while paused (R2-OQ-01)" + ); + } + #[test] fn take_without_target_id_is_noop() { // Edge case: Take verb with no target_entity_id should not panic diff --git a/server/src/simulation/mod.rs b/server/src/simulation/mod.rs index 10bee8fec..d83bc4881 100644 --- a/server/src/simulation/mod.rs +++ b/server/src/simulation/mod.rs @@ -4,6 +4,7 @@ use bevy_app::prelude::*; use bevy_ecs::schedule::IntoScheduleConfigs; +pub mod dialogue; pub mod input; pub mod interaction; pub mod inventory;