From 257d979aedf160a95ad8207faec050ac8c4916ad Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 12 Jun 2026 17:07:36 +0200 Subject: [PATCH] refactor(simulation): split the input.rs and dialogue.rs dispatchers (T-1062) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit input.rs 2,739 → 858 lines: per-domain action handlers moved to their owning modules (inventory, movement, stance, examine, follow, interaction, save_io, settings, bridge::debug, economy, vision_cone, bookmark, test_world reset + new teleport.rs); input.rs keeps the queue, the thin dispatch table, and pause/cooldown glue. All 9 type_complexity allows dissolved via one PlayerInputQuery alias. dialogue.rs → dialogue/ directory module: selection (631), response (1,473), confrontation (714), mod.rs (226, shared session components + re-exports — public paths preserved). Documented seam deviation: process_walk_away lives with confrontation (D-064/D-063 share the same world-response shape). Mechanical, zero behavior change: determinism + golden_suite byte-identical (independently re-verified); 1,504 lib tests unchanged — 23 input tests moved with their subjects, 53 dialogue tests redistributed, zero deleted. System scheduling registrations untouched (input_plugin.rs 0-line diff). Co-Authored-By: Claude Fable 5 --- .pql/changelog/ticket_history/2026-06.sql | 1 + .pql/changelog/tickets/2026-06.sql | 5 + server/src/bookmark/mod.rs | 253 ++ server/src/bridge/debug.rs | 11 + server/src/perception/vision_cone.rs | 28 + server/src/settings/mod.rs | 19 + server/src/simulation/dialogue.rs | 2785 ----------------- .../src/simulation/dialogue/confrontation.rs | 714 +++++ server/src/simulation/dialogue/mod.rs | 226 ++ server/src/simulation/dialogue/response.rs | 1473 +++++++++ server/src/simulation/dialogue/selection.rs | 631 ++++ server/src/simulation/economy.rs | 14 + server/src/simulation/examine.rs | 48 + server/src/simulation/follow.rs | 52 + server/src/simulation/input.rs | 2019 +----------- server/src/simulation/interaction.rs | 90 + server/src/simulation/inventory.rs | 396 +++ server/src/simulation/movement.rs | 171 + server/src/simulation/save_io.rs | 25 + server/src/simulation/stance.rs | 89 + server/src/test_world/mod.rs | 1 + server/src/test_world/reset.rs | 278 ++ server/src/test_world/teleport.rs | 225 ++ 23 files changed, 4819 insertions(+), 4735 deletions(-) delete mode 100644 server/src/simulation/dialogue.rs create mode 100644 server/src/simulation/dialogue/confrontation.rs create mode 100644 server/src/simulation/dialogue/mod.rs create mode 100644 server/src/simulation/dialogue/response.rs create mode 100644 server/src/simulation/dialogue/selection.rs create mode 100644 server/src/test_world/teleport.rs diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 7c89dcd6b..6efe35c93 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -1838,3 +1838,4 @@ The pre-push hook is the ONLY automatic correctness gate (its own comment, .conf Fix: run tests/run-godot in pre-push when CLIENT_CHANGED > 0 — or record manual-only explicitly and fix team-patterns.md. Related decision: Q-116 (gdlint blocking). Prerequisite worth checking: T-973 (pre-existing bare-run failures, under this initiative) so the new gate starts green. Closed: pre-push now runs tests/run-godot (blocking) when CLIENT_CHANGED > 0 — added after T-973 made the suite green and the lead independently re-verified (2536/2536). team-patterns.md gate description updated. ~100s added to client-touching pushes; e2e suites self-skip without the server binary.', NULL, '2026-06-12 14:24:14', '2026-06-12 14:24:14', '2026-06-12 14:24:14', NULL, '89425fbc550ecb2a7fdde019c4f23482', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBPTXR79W28XNN1TB7HKGBP8', 'status', 'backlog', 'in_progress', NULL, '2026-06-12 14:39:13', '2026-06-12 14:39:13', '2026-06-12 14:39:13', NULL, '3e30c4bc9bb82e75990d958bd287e086', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 86265c3e4..50308af66 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -2093,3 +2093,8 @@ The pre-push hook is the ONLY automatic correctness gate (its own comment, .conf Fix: run tests/run-godot in pre-push when CLIENT_CHANGED > 0 — or record manual-only explicitly and fix team-patterns.md. Related decision: Q-116 (gdlint blocking). Prerequisite worth checking: T-973 (pre-existing bare-run failures, under this initiative) so the new gate starts green. Closed: pre-push now runs tests/run-godot (blocking) when CLIENT_CHANGED > 0 — added after T-973 made the suite green and the lead independently re-verified (2536/2536). team-patterns.md gate description updated. ~100s added to client-touching pushes; e2e suites self-skip without the server binary.', 'done', 'high', NULL, 'client', NULL, '2026-06-12 10:40:59', '2026-06-12 14:24:14', NULL, '1764112ab2704e7782054150a9435eb4', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBPTXR79W28XNN1TB7HKGBP8', 'task', '06FBPPMZNNEV052DBYYY3A897C', 'Split the input.rs and dialogue.rs god-module dispatchers', '(description follows in first append) + +Filed 2026-06-12 from the fable-ous.md audit (S-03; citations adversarially verified). +simulation/input.rs: 2,739 lines, imports 9 domains (bookmark, bridge::debug, knowledge, settings, economy, interaction, inventory, movement, save_io), 9× type_complexity allows (:485-1120) + 1 too_many_arguments — the dispatch hub for every player verb. simulation/dialogue.rs: 2,785 lines, 3 allows. +Extract per-domain input handlers into their owning modules behind a thin dispatch table; split dialogue into talk-selection / confrontation / response-assembly. Mechanical, no behavior change; gate with the existing determinism.rs byte-identical goldens.', 'in_progress', 'medium', NULL, 'server', NULL, '2026-06-12 10:40:59', '2026-06-12 14:39:13', NULL, 'dfb062e89b351f48cb18a684ce5a9531', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); diff --git a/server/src/bookmark/mod.rs b/server/src/bookmark/mod.rs index 30aec9153..883f459c9 100644 --- a/server/src/bookmark/mod.rs +++ b/server/src/bookmark/mod.rs @@ -12,6 +12,7 @@ use std::collections::BTreeMap; use bevy_app::prelude::*; use bevy_ecs::prelude::*; +use bevy_ecs::system::SystemParam; use crate::bridge::types::{BookmarkCatalog, BookmarkWire, CareerKindWire, SnapshotBuffer}; use crate::knowledge::{resolve_culture, CultureResolver}; @@ -174,6 +175,102 @@ fn prime_initial_catalog( tracing::debug!("BookmarkPlugin: initial catalog staged for tick-0 snapshot"); } +/// Bundled SystemParam for bookmark-related input handling. +/// +/// Bevy's blanket `IntoSystem` impl covers functions up to 16 parameters. +/// Bundling the bookmark params keeps `process_player_input` at exactly 16. +#[derive(SystemParam)] +pub struct BookmarkInputParams<'w> { + pub registry: Option>, + pub selected: Option>, + pub snapshot_buf: Option>, + pub sim_error_buf: Option>, + pub culture: Option>, +} + +/// Handle RequestBookmarkCatalog: stage the catalog in `SnapshotBuffer` for +/// delivery in the next snapshot. +pub fn handle_request_bookmark_catalog(bookmark: &mut BookmarkInputParams) { + if let (Some(ref registry), Some(ref mut buf)) = + (&bookmark.registry, &mut bookmark.snapshot_buf) + { + let resolver = bookmark.culture.as_deref().map(|c| &c.0); + buf.pending_bookmark_catalog = Some(registry.build_catalog(resolver)); + tracing::debug!("RequestBookmarkCatalog: catalog staged"); + } else { + tracing::warn!("RequestBookmarkCatalog: BookmarkRegistry or SnapshotBuffer not available"); + } +} + +/// Handle ConfirmBookmark: validate id + starting location against the registry +/// and record the selection. Invalid or duplicate confirms emit a SimError. +pub fn handle_confirm_bookmark( + bookmark_id: String, + starting_location_id: String, + registry: &Option>, + selected_bookmark: &mut Option>, + sim_error_buf: &mut Option>, + tick: u64, +) { + use crate::bridge::types::{SimError, SimErrorKind}; + + let Some(ref registry) = registry else { + tracing::warn!("ConfirmBookmark: BookmarkRegistry not available"); + return; + }; + let Some(defn) = registry.get(&bookmark_id) else { + let msg = format!("ConfirmBookmark: unknown bookmark_id {:?}", bookmark_id); + tracing::warn!("{}", msg); + if let Some(ref mut buf) = sim_error_buf { + buf.push(SimError { + kind: SimErrorKind::ProtocolError, + message: msg, + tick, + }); + } + return; + }; + if !defn.allowed_locations.contains(&starting_location_id) { + let msg = format!( + "ConfirmBookmark: starting_location_id {:?} not in allowed_locations for {:?}", + starting_location_id, bookmark_id + ); + tracing::warn!("{}", msg); + if let Some(ref mut buf) = sim_error_buf { + buf.push(SimError { + kind: SimErrorKind::ProtocolError, + message: msg, + tick, + }); + } + return; + } + if let Some(ref mut sel) = selected_bookmark { + if sel.bookmark_id.is_some() { + let msg = format!( + "ConfirmBookmark: bookmark already confirmed ({}), ignoring retry", + sel.bookmark_id.as_deref().unwrap_or("?") + ); + tracing::warn!("{}", msg); + if let Some(ref mut buf) = sim_error_buf { + buf.push(SimError { + kind: SimErrorKind::ProtocolError, + message: msg, + tick, + }); + } + return; + } + sel.bookmark_id = Some(bookmark_id.clone()); + sel.starting_location_id = Some(starting_location_id.clone()); + tracing::info!( + bookmark_id, + starting_location_id, + "ConfirmBookmark: selection recorded" + ); + } +} + /// Register all v0.2 bookmarks. /// /// Hard-coded for now (single entry). Promote to TOML loading in Sprint 38+ @@ -264,4 +361,160 @@ mod tests { .allowed_locations .contains(&"Unknown System".to_string())); } + + // ----------------------------------------------------------------------- + // ConfirmBookmark input-handler tests (moved from input.rs, T-1062) + // ----------------------------------------------------------------------- + + use crate::bridge::types::{PlayerAction, PlayerInput, SimErrorBuffer}; + use crate::simulation::input::{process_player_input, InputQueue}; + use crate::simulation::movement::{PlayerCharacter, TilePosition}; + use crate::simulation::time::SimulationTime; + + fn make_bookmark_world() -> bevy_ecs::world::World { + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(InputQueue::default()); + world.insert_resource(SimulationTime::default()); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + + let mut registry = BookmarkRegistry::default(); + registry.insert(BookmarkDefinition { + id: BookmarkId("test_bookmark".to_string()), + title: "Test Bookmark".into(), + subtitle: String::new(), + flavor: String::new(), + default_location: "Loc A".into(), + allowed_locations: vec!["Loc A".into(), "Loc B".into()], + career: CareerKind::Tycoon, + starting_capital_tractus: 1_000, + available: true, + }); + world.insert_resource(registry); + + world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0))); + world + } + + #[test] + fn confirm_bookmark_unknown_id_emits_sim_error() { + let mut world = make_bookmark_world(); + world.resource_mut::().push(PlayerInput { + tick: 0, + action: PlayerAction::ConfirmBookmark { + bookmark_id: "no_such_bookmark".into(), + starting_location_id: "Loc A".into(), + }, + }); + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_player_input); + schedule.run(&mut world); + + let errors = world.resource_mut::().drain(); + assert_eq!( + errors.len(), + 1, + "expected one SimError for unknown bookmark" + ); + assert_eq!( + errors[0].kind, + crate::bridge::types::SimErrorKind::ProtocolError + ); + assert!(errors[0].message.contains("unknown bookmark_id")); + assert!( + world.resource::().bookmark_id.is_none(), + "SelectedBookmark must not be set on error" + ); + } + + #[test] + fn confirm_bookmark_invalid_location_emits_sim_error() { + let mut world = make_bookmark_world(); + world.resource_mut::().push(PlayerInput { + tick: 0, + action: PlayerAction::ConfirmBookmark { + bookmark_id: "test_bookmark".into(), + starting_location_id: "Not A Location".into(), + }, + }); + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_player_input); + schedule.run(&mut world); + + let errors = world.resource_mut::().drain(); + assert_eq!( + errors.len(), + 1, + "expected one SimError for invalid location" + ); + assert_eq!( + errors[0].kind, + crate::bridge::types::SimErrorKind::ProtocolError + ); + assert!(errors[0].message.contains("not in allowed_locations")); + } + + #[test] + fn confirm_bookmark_valid_inputs_populate_selected_bookmark() { + let mut world = make_bookmark_world(); + world.resource_mut::().push(PlayerInput { + tick: 0, + action: PlayerAction::ConfirmBookmark { + bookmark_id: "test_bookmark".into(), + starting_location_id: "Loc B".into(), + }, + }); + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_player_input); + schedule.run(&mut world); + + let errors = world.resource_mut::().drain(); + assert!( + errors.is_empty(), + "no errors expected for valid ConfirmBookmark" + ); + + let sel = world.resource::(); + assert_eq!(sel.bookmark_id.as_deref(), Some("test_bookmark")); + assert_eq!(sel.starting_location_id.as_deref(), Some("Loc B")); + } + + #[test] + fn confirm_bookmark_double_confirm_emits_sim_error() { + let mut world = make_bookmark_world(); + + // Both confirms at tick=0: processed in order within the same run. + // First succeeds; second hits the idempotency guard. + world.resource_mut::().push(PlayerInput { + tick: 0, + action: PlayerAction::ConfirmBookmark { + bookmark_id: "test_bookmark".into(), + starting_location_id: "Loc A".into(), + }, + }); + world.resource_mut::().push(PlayerInput { + tick: 0, + action: PlayerAction::ConfirmBookmark { + bookmark_id: "test_bookmark".into(), + starting_location_id: "Loc B".into(), + }, + }); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_player_input); + schedule.run(&mut world); + + let errors = world.resource_mut::().drain(); + assert_eq!(errors.len(), 1, "double-confirm must emit ProtocolError"); + assert_eq!( + errors[0].kind, + crate::bridge::types::SimErrorKind::ProtocolError + ); + assert!(errors[0].message.contains("already confirmed")); + + // Selection must remain the original, not overwritten + let sel = world.resource::(); + assert_eq!(sel.starting_location_id.as_deref(), Some("Loc A")); + } } diff --git a/server/src/bridge/debug.rs b/server/src/bridge/debug.rs index ad3cbb4ba..fb1c42751 100644 --- a/server/src/bridge/debug.rs +++ b/server/src/bridge/debug.rs @@ -49,6 +49,17 @@ impl DebugCommandBuffer { } } +/// Queue a debug command from player input. +/// `None` means the buffer was never registered (warn and drop). +pub fn queue_debug_command(buf: Option<&mut DebugCommandBuffer>, cmd: DebugCommandKind) { + match buf { + Some(buf) => buf.push(cmd), + None => { + tracing::warn!("DebugCommand received but DebugCommandBuffer not registered"); + } + } +} + // --------------------------------------------------------------------------- // System // --------------------------------------------------------------------------- diff --git a/server/src/perception/vision_cone.rs b/server/src/perception/vision_cone.rs index c93430670..a7ff173b1 100644 --- a/server/src/perception/vision_cone.rs +++ b/server/src/perception/vision_cone.rs @@ -78,6 +78,34 @@ pub fn facing_from_delta(dx: i32, dy: i32) -> FacingDirection { } } +/// Handle the SetFacing player action: parse the wire direction string and +/// set the player's Facing component. Unknown directions warn and no-op. +pub fn handle_set_facing( + player_query: &mut crate::simulation::input::PlayerInputQuery, + commands: &mut Commands, + facing: &str, +) { + let dir = match facing { + "North" => Some(FacingDirection::North), + "Northeast" => Some(FacingDirection::Northeast), + "East" => Some(FacingDirection::East), + "Southeast" => Some(FacingDirection::Southeast), + "South" => Some(FacingDirection::South), + "Southwest" => Some(FacingDirection::Southwest), + "West" => Some(FacingDirection::West), + "Northwest" => Some(FacingDirection::Northwest), + _ => { + tracing::warn!("SetFacing: unknown direction {:?}", facing); + None + } + }; + if let Some(dir) = dir { + if let Ok((entity, _, _, _)) = player_query.single_mut() { + commands.entity(entity).insert(Facing(dir)); + } + } +} + /// Classify a visible tile into a vision sector based on facing direction. /// Returns None if the tile falls outside the forward cone. fn classify_tile( diff --git a/server/src/settings/mod.rs b/server/src/settings/mod.rs index 5de1784d4..9e4a98c97 100644 --- a/server/src/settings/mod.rs +++ b/server/src/settings/mod.rs @@ -120,6 +120,25 @@ pub enum SettingsCommand { Delete { key: String }, } +/// Queue a settings command from player input. +/// `None` means the buffer was never registered (warn and drop). +pub fn queue_settings_command(buf: Option<&mut SettingsCommandBuffer>, cmd: SettingsCommand) { + let label = match cmd { + SettingsCommand::Change { .. } => "ChangeSetting", + SettingsCommand::RequestAll => "RequestAllSettings", + SettingsCommand::Delete { .. } => "DeleteSetting", + }; + match buf { + Some(buf) => buf.push(cmd), + None => { + tracing::warn!( + "{} received but SettingsCommandBuffer not registered", + label + ); + } + } +} + /// Process settings commands from the IPC buffer. /// Reads SettingsCommandBuffer, writes to SettingsStoreResource, /// and stages a response in SnapshotBuffer.pending_settings_response. diff --git a/server/src/simulation/dialogue.rs b/server/src/simulation/dialogue.rs deleted file mode 100644 index 3ebd46c8f..000000000 --- a/server/src/simulation/dialogue.rs +++ /dev/null @@ -1,2785 +0,0 @@ -//! Dialogue selection pipeline — D-028 four-layer filtering engine (#305). -//! -//! Full pipeline: Talk verb → access tier (from KG RelationshipState) -//! → situations (from game context) → trust tier (from KG) → topic+mood -//! weighted scoring → select line → DialogueResponseBuffer. -//! -//! Layers 1-3 (access, situation, trust) are delegated to -//! LinePoolIndex::query_dialogue. Layer 4 (topic + mood weighted selection) -//! is implemented here. -//! -//! Integration points: -//! - Reads LinePoolIndexResource (server/content/mod.rs) -//! - Reads KnowledgeGraph + EntityRegistry for access/trust derivation -//! - Reads DialogueProfile on NPCs for pool lookup coordinates -//! - Writes DialogueResponseBuffer for snapshot inclusion -//! - Uses SimRng for deterministic weighted random selection - -use std::collections::BTreeSet; - -use bevy_ecs::prelude::*; -use rand::Rng; - -use crate::bridge::types::{DialogueResponseEvent, MonologueEvent, RelationshipState}; -use crate::knowledge::content_registry::ContentEntityRegistry; -use crate::knowledge::events::{ProcessedEntityGrant, ProcessedFactGrant, ProcessedKnowledgeGrant}; -use crate::knowledge::types::{FactId, KnowledgeConfidence, KnowledgeSource, StableId}; -use crate::knowledge::{EntityRegistry, KnowledgeGraph}; -use crate::npc::interaction::{InteractionEvent, InteractionEventKind, InteractionMemory}; -use crate::npc::relationships::{TrustEvent, TrustEventQueue}; -use crate::simulation::knowledge_grant::KnowledgeGrant; -use crate::simulation::line_pool::LinePoolIndexResource; -use crate::simulation::line_pool::{ - AccessTier, IndexedDialogueLine, Mood, Situation, Topic, TrustTier, -}; -use crate::simulation::monologue::{MonologueBuffer, MonologueState}; -use crate::simulation::movement::PlayerCharacter; -use crate::simulation::npc_components::{display_label_for_role, NpcColorIndex, NpcName}; -use crate::simulation::rng::SimRng; -use crate::simulation::time::SimulationTime; -use crate::storyteller::EngagementRecord; - -/// Cooldown ticks before the same dialogue line can be selected again. -/// At 10 ticks/game-minute, 600 ticks = 1 game-hour. -const LINE_COOLDOWN_TICKS: u64 = 600; - -// --------------------------------------------------------------------------- -// Components -// --------------------------------------------------------------------------- - -/// Marker: player requested Talk interaction with a target NPC this tick. -/// -/// Set by process_player_input when verb == "Talk". Consumed and removed -/// by process_talk_interaction each tick. -#[derive(Component, Debug)] -pub struct TalkRequest { - pub target: Entity, -} - -/// NPC's dialogue pool coordinates for LinePoolIndex lookup. -/// -/// `location` maps to DialoguePool.location (e.g., "the-terminal"). -/// `role` maps to DialoguePool.role (e.g., "dock-worker"). -/// Attached during content spawn; NPCs without this cannot be talked to. -#[derive(Component, Debug, Clone)] -pub struct DialogueProfile { - pub location: String, - pub role: String, -} - -/// NPC's current mood for Layer 4 scoring. -/// -/// Computed from NPC axes (Tolerance, Contentment, recent events). -/// v0.1: set during spawn or defaults to Comfortable. -#[derive(Component, Debug, Clone)] -pub struct CurrentMood(pub Mood); - -impl Default for CurrentMood { - fn default() -> Self { - Self(Mood::Content) - } -} - -/// Per-player cooldown tracker for dialogue line variety (#338). -/// -/// Prevents the same line from being selected within LINE_COOLDOWN_TICKS. -/// Entries older than the cooldown window are pruned each query. -/// -/// Design note: this is player-global, not per-NPC. Line IDs are NPC-scoped -/// per D-035 (`{template}_{d|m|e}_{###}`), so cross-NPC collisions don't occur -/// in practice. If a future sprint introduces shared line IDs across roles, -/// the key should become `(StableId, line_id)` instead. -#[derive(Component, Debug, Default)] -pub struct DialogueCooldownTracker { - used: std::collections::BTreeMap, // line_id → tick_used (D-041) -} - -impl DialogueCooldownTracker { - /// Record that a line was used at the given tick. - pub fn record(&mut self, line_id: &str, tick: u64) { - self.used.insert(line_id.to_string(), tick); - } - - /// Check if a line is on cooldown at the given tick. - pub fn is_on_cooldown(&self, line_id: &str, tick: u64) -> bool { - self.used - .get(line_id) - .is_some_and(|used_tick| tick.saturating_sub(*used_tick) < LINE_COOLDOWN_TICKS) - } - - /// Prune entries older than the cooldown window. - pub fn prune(&mut self, tick: u64) { - self.used - .retain(|_, used_tick| tick.saturating_sub(*used_tick) < LINE_COOLDOWN_TICKS); - } -} - -/// Tracks an active dialogue session between the player and an NPC. -/// -/// Set by `process_talk_interaction` when a dialogue line is selected. -/// Cleared by `process_walk_away` (walk-away, D-064) or when dialogue -/// ends naturally (future: multi-line exchanges). -#[derive(Component, Debug)] -pub struct ActiveDialogue { - pub target: Entity, - pub interaction_type: crate::knowledge::events::InteractionType, - pub started_tick: u64, -} - -/// Marker: player submitted a dialogue response this tick (#539). -/// -/// Set by process_player_input when PlayerAction::DialogueResponse is received. -/// Consumed and removed by process_dialogue_response each tick. -#[derive(Component, Debug)] -pub struct DialogueResponseRequest { - pub target: Entity, - pub response_id: String, -} - -/// Marker: player walked away during active dialogue this tick (D-064). -/// -/// Set by process_player_input when PlayerAction::WalkAway is received. -/// Consumed by process_walk_away each tick. -#[derive(Component, Debug)] -pub struct WalkAwayRequest; - -/// Marker: player delivered a confrontation this tick (#520, D-063). -/// -/// Set by process_player_input when Interact{verb: "Confront"} is received. -/// Consumed by process_confrontation_response each tick. Triggers: -/// 1. Target NPC shifts to AnimationTier::Tier2 (D-047) -/// 2. Observer KG relationship state decremented (D-033 color fade) -/// 3. Monologue spike event emitted -#[derive(Component, Debug)] -pub struct ConfrontationDelivered { - pub target: Entity, -} - -/// Buffer holding the dialogue response for snapshot inclusion. -/// -/// Consumed once per snapshot via `take()`. Cleared at snapshot build time. -#[derive(Component, Debug, Default)] -pub struct DialogueResponseBuffer { - pub(crate) response: Option, -} - -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 + 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 { - 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 — score_line always returns >= 1 (base score), - // so total_weight > 0 is guaranteed when scored is non-empty. - let total_weight: u32 = scored.iter().map(|(_, s)| s).sum(); - - let mut roll = rng.random_range(0..total_weight); - for (line, weight) in &scored { - if roll < *weight { - return Some(line); - } - roll -= weight; - } - - unreachable!("weighted selection with total_weight > 0 must select a line") -} - -// --------------------------------------------------------------------------- -// Shared pipeline: Layers 1-4 -// --------------------------------------------------------------------------- - -/// Run the full D-028 four-layer dialogue pipeline and return a selected line. -/// -/// Shared by `process_talk_interaction` and `process_dialogue_response` to -/// avoid duplicating the L1-L4 query + scoring logic. Callers handle the -/// result differently (initial Talk sets ActiveDialogue; follow-up may clear it). -fn run_dialogue_pipeline<'a>( - line_pool: &'a crate::simulation::line_pool::LinePoolIndex, - location: &str, - role: &str, - relationship: RelationshipState, - confidence: crate::knowledge::types::KnowledgeConfidence, - day_phase: crate::simulation::time::DayPhase, - interaction_mem: Option<&InteractionMemory>, - npc_mood: Option, - cooldown: &DialogueCooldownTracker, - tick: u64, - rng: &mut impl Rng, -) -> Option<&'a IndexedDialogueLine> { - // Layer 1: Access tiers from relationship - let access_tiers = available_access_tiers(relationship); - - // Layer 2: Derive active situations from game state - let mut situations = derive_situations(day_phase, relationship); - - // Layer 2 extension: first_meeting / repeated_visit from InteractionMemory (#325, D-028) - if let Some(mem) = interaction_mem { - if mem.is_first_meeting() { - situations.push(Situation::FirstMeeting); - } else if mem.is_repeated_visit() { - situations.push(Situation::RepeatedVisit); - } - } - - // Layer 3: Trust tier from relationship + confidence (D-075) - let trust = relationship_to_trust(relationship, confidence); - - // Query Layers 1-3: collect candidates across all available access tiers - let mut candidates: Vec<&IndexedDialogueLine> = Vec::new(); - let mut seen_ids: BTreeSet<&str> = BTreeSet::new(); - - for access in &access_tiers { - let results = line_pool.query_dialogue(location, role, *access, &situations, trust); - for line in results { - if seen_ids.insert(&line.id) { - candidates.push(line); - } - } - } - - if candidates.is_empty() { - return None; - } - - // Layer 4: Topic + mood weighted selection - let active_topics: Vec = Vec::new(); // v0.1: no topic context yet - - select_dialogue_line(&candidates, npc_mood, &active_topics, cooldown, tick, rng) -} - -// --------------------------------------------------------------------------- -// System: process_talk_interaction -// --------------------------------------------------------------------------- - -/// Process Talk verb requests through the full D-028 four-layer pipeline. -/// -/// Reads TalkRequest marker (set by input system), looks up NPC dialogue pool, -/// queries through Layers 1-3, applies Layer 4 scoring, and writes the selected -/// line to DialogueResponseBuffer. -/// -/// System ordering: after process_player_input, before compute_observer_snapshot. -#[tracing::instrument(level = "debug", skip_all)] -#[allow(clippy::type_complexity, clippy::too_many_arguments)] -pub fn process_talk_interaction( - mut commands: Commands, - time: Res, - line_pool: Option>, - registry: Res, - content_registry: Res, - mut rng: ResMut, - mut event_queue: ResMut, - mut trust_queue: ResMut, - mut player_query: Query< - ( - Entity, - &KnowledgeGraph, - &TalkRequest, - &mut DialogueResponseBuffer, - &mut DialogueCooldownTracker, - Option<&ActiveDialogue>, - ), - With, - >, - mut npc_query: Query<( - &DialogueProfile, - Option<&CurrentMood>, - Option<&mut InteractionMemory>, - Option<&NpcName>, - Option<&NpcColorIndex>, - Option<&KnowledgeGraph>, - )>, - mut engagement_query: Query<&mut EngagementRecord>, -) { - let Some(line_pool) = line_pool else { - return; - }; - - let Ok(( - player_entity, - observer_kg, - talk_request, - mut response_buffer, - mut cooldown, - active_dialogue_opt, - )) = player_query.single_mut() - else { - return; - }; - - let target = talk_request.target; - - // Look up NPC dialogue profile, mood, interaction history, name, color, and KG (#325, D-079) - let Ok((profile, mood_opt, mut interaction_mem_opt, npc_name_opt, color_idx_opt, npc_kg_opt)) = - npc_query.get_mut(target) - else { - tracing::debug!( - "Talk target {:?} has no DialogueProfile — cannot select dialogue", - target - ); - commands.entity(player_entity).remove::(); - return; - }; - - // Resolve target's StableId for KG lookup - let target_stable = registry.to_stable(target); - let relationship = target_stable - .map(|sid| observer_kg.relationship_with(&sid)) - .unwrap_or(RelationshipState::Unknown); - - // Default to Suspects for unknown NPCs — no KG entry means no basis for - // deeper dialogue, which correctly yields Surface trust tier. - let confidence = target_stable - .and_then(|sid| observer_kg.confidence_of(&sid)) - .unwrap_or(crate::knowledge::types::KnowledgeConfidence::Suspects); - - let npc_mood = mood_opt.map(|m| m.0); - - // Prune old cooldown entries - cooldown.prune(time.tick); - - let selected = run_dialogue_pipeline( - &line_pool.0, - &profile.location, - &profile.role, - relationship, - confidence, - time.day_phase(), - interaction_mem_opt.as_deref(), - npc_mood, - &cooldown, - time.tick, - &mut rng.rng, - ); - - if let Some(line) = selected { - // Resolve wire ID for the speaker — skip if target not in registry - let Some(speaker_stable) = registry.to_stable(target) else { - tracing::warn!( - "Talk target {:?} not in EntityRegistry — cannot resolve wire ID, skipping dialogue", - target - ); - commands.entity(player_entity).remove::(); - return; - }; - - // Resolve speaker display name: use real name if player KG has "name" - // attribute for the target, otherwise fall back to role label. - let speaker_display_name = { - let known = observer_kg - .entity_knowledge(&speaker_stable) - .map(|e| e.known_attributes.contains_key("name")) - .unwrap_or(false); - if known { - npc_name_opt - .map(|n| n.0.clone()) - .unwrap_or_else(|| "Unknown".to_string()) - } else { - display_label_for_role(&profile.role) - } - }; - let speaker_color = color_idx_opt.map(|c| c.0).unwrap_or(0u8); - - response_buffer.response = Some(DialogueResponseEvent { - line_id: line.id.clone(), - text: line.text.clone(), - speaker_entity_id: speaker_stable.0, - speaker_color_index: speaker_color, - speaker_name: speaker_display_name, - }); - - cooldown.record(&line.id, time.tick); - - // Knowledge grant (D-079): fire at line selection time, server-authoritative. - if let Some(grant) = &line.knowledge_grant { - emit_knowledge_grant( - grant, - player_entity, - speaker_stable, - &content_registry, - npc_kg_opt, - time.tick, - &mut event_queue, - ); - } - - // Emit IncompleteInteraction if overwriting an existing dialogue session - if let Some(prev) = active_dialogue_opt { - event_queue.push(crate::knowledge::KnowledgeEvent { - observer: player_entity, - tick: time.tick, - event_type: crate::knowledge::KnowledgeEventType::IncompleteInteraction { - target: prev.target, - interaction_type: prev.interaction_type, - }, - }); - tracing::debug!( - "Overwriting active {:?} dialogue — emitted IncompleteInteraction", - prev.interaction_type, - ); - } - - // Track active dialogue for walk-away detection (D-064) - commands.entity(player_entity).insert(ActiveDialogue { - target, - interaction_type: crate::knowledge::events::InteractionType::Talk, - started_tick: time.tick, - }); - - // Engagement tracking (#570): increment conversation count for this NPC - if let Ok(mut record) = engagement_query.get_mut(target) { - record.conversation_count += 1; - } - - // Trust progression (#324): successful talk warms the NPC - trust_queue.push(TrustEvent::TalkCompleted { - npc: target, - player: player_entity, - }); - - // Interaction tracking (#325): record completed talk - if let Some(ref mut mem) = interaction_mem_opt { - mem.record_talk(time.tick); - } - - tracing::debug!( - "Dialogue selected: id={}, speaker={}, location={}, role={}", - line.id, - speaker_stable.0, - profile.location, - profile.role, - ); - } else { - tracing::debug!( - "All dialogue lines on cooldown for {}/{}", - profile.location, - profile.role, - ); - } - - // Remove the TalkRequest marker — processed this tick - commands.entity(player_entity).remove::(); -} - -// --------------------------------------------------------------------------- -// Knowledge grant helper (D-079) -// --------------------------------------------------------------------------- - -/// Emit a KnowledgeGranted event for a dialogue line's knowledge_grant field. -/// -/// Called at line selection time (server-authoritative, tick-stamped). -/// Source is always `ToldBy { source_id: speaker_stable, tick }`. -/// -/// Fact grants: dropped with tracing::warn! if the granting NPC's KG -/// does not contain the fact (D-079 runtime guardrail). -/// Entity grants: no guardrail — always emitted if entity_ref resolves. -#[allow(clippy::too_many_arguments)] -fn emit_knowledge_grant( - grant: &KnowledgeGrant, - player_entity: Entity, - speaker_stable: StableId, - content_registry: &ContentEntityRegistry, - npc_kg_opt: Option<&KnowledgeGraph>, - tick: u64, - event_queue: &mut crate::knowledge::KnowledgeEventQueue, -) { - let source = KnowledgeSource::ToldBy { - source_id: speaker_stable, - tick, - }; - - match grant { - KnowledgeGrant::Fact { - fact_id, - confidence, - } => { - let conf = match KnowledgeConfidence::try_from(confidence.as_str()) { - Ok(c) => c, - Err(e) => { - tracing::warn!("KnowledgeGrant confidence parse error: {}", e); - return; - } - }; - let fid = FactId(fact_id.clone()); - // Guardrail: NPC must know this fact to grant it (D-079). - let npc_knows = npc_kg_opt.map(|kg| kg.knows_fact(&fid)).unwrap_or(false); - if !npc_knows { - tracing::warn!( - "KnowledgeGrant dropped: NPC {:?} does not know fact '{}' — grant guardrail", - speaker_stable, - fact_id - ); - return; - } - event_queue.push(crate::knowledge::KnowledgeEvent { - observer: player_entity, - tick, - event_type: crate::knowledge::KnowledgeEventType::KnowledgeGranted { - grant: ProcessedKnowledgeGrant::Fact(ProcessedFactGrant { - fact_id: fid, - confidence: conf, - }), - source, - }, - }); - } - // Entity grants have no "NPC knows this entity" guardrail (unlike Fact - // grants above). This is intentional per D-079: entity grants introduce - // NEW knowledge about an entity the NPC is talking about — the NPC - // doesn't need to "know" the entity in their own KG to reference it - // in dialogue. The entity_ref resolves via ContentEntityRegistry, not KG. - KnowledgeGrant::Entity { - entity_ref, - attributes, - confidence, - } => { - let conf = match KnowledgeConfidence::try_from(confidence.as_str()) { - Ok(c) => c, - Err(e) => { - tracing::warn!("KnowledgeGrant confidence parse error: {}", e); - return; - } - }; - let Some(target_id) = content_registry.resolve(entity_ref) else { - tracing::warn!( - "KnowledgeGrant::Entity dropped: entity_ref '{}' not in ContentEntityRegistry", - entity_ref - ); - return; - }; - event_queue.push(crate::knowledge::KnowledgeEvent { - observer: player_entity, - tick, - event_type: crate::knowledge::KnowledgeEventType::KnowledgeGranted { - grant: ProcessedKnowledgeGrant::Entity(ProcessedEntityGrant { - target_id, - attributes: attributes.clone(), - confidence: conf, - }), - source, - }, - }); - } - } -} - -// --------------------------------------------------------------------------- -// System: process_walk_away (D-064) -// --------------------------------------------------------------------------- - -/// Process walk-away requests during active dialogue (D-064 Phases 2+3). -/// -/// When the player moves (WASD) during an active dialogue, the client sends -/// PlayerAction::WalkAway which sets WalkAwayRequest. This system: -/// 1. Target NPC shifts to AnimationTier::Tier2 (D-047 ambiguous animation) -/// 2. NPC routine deviation recorded (storyteller hook) -/// 3. Emits IncompleteInteraction knowledge event (recorded in KG) -/// 4. Clears ActiveDialogue state -/// 5. Removes the WalkAwayRequest marker -/// -/// If no ActiveDialogue is present, removes WalkAwayRequest silently (no-op). -/// -/// System ordering: after process_player_input, before compute_observer_snapshot. -pub fn process_walk_away( - mut commands: Commands, - mut event_queue: ResMut, - mut trust_queue: ResMut, - mut post_conv_queue: ResMut, - time: Res, - query: Query<(Entity, Option<&ActiveDialogue>, &WalkAwayRequest), With>, - mut npc_mem_query: Query>, -) { - let Ok((player_entity, active_dialogue_opt, _walk_away)) = query.single() else { - return; - }; - - if let Some(active_dialogue) = active_dialogue_opt { - let target = active_dialogue.target; - - // Phase 2, Effect 1: Shift target NPC to Tier 2 animation (D-047) - commands - .entity(target) - .insert(crate::npc::AnimationTier::Tier2); - - // Phase 2, Effect 2: Record routine deviation on target NPC - commands - .entity(target) - .insert(crate::npc::RoutineDeviation { - trigger: crate::npc::DeviationTrigger::WalkAway, - tick: time.tick, - expires_at_tick: time.tick + crate::npc::TELL_ESCALATION_DURATION_TICKS, - }); - - // Phase 3: Emit IncompleteInteraction knowledge event - event_queue.push(crate::knowledge::KnowledgeEvent { - observer: player_entity, - tick: time.tick, - event_type: crate::knowledge::KnowledgeEventType::IncompleteInteraction { - target, - interaction_type: active_dialogue.interaction_type, - }, - }); - - // Trust progression (#324): walk-away reduces NPC trust - trust_queue.push(TrustEvent::WalkAway { - npc: target, - player: player_entity, - }); - - // Interaction tracking (#325): record notable walk-away event - if let Ok(Some(mut mem)) = npc_mem_query.get_mut(target) { - mem.push_event(InteractionEvent { - tick: time.tick, - kind: InteractionEventKind::WalkAway, - }); - } - - // Post-conversation monologue trigger (#119, D-035) - post_conv_queue.push(target); - - tracing::debug!( - "Walk-away during {:?} dialogue at tick {} (started tick {}): \ - target {:?} → Tier2 animation + routine deviation", - active_dialogue.interaction_type, - time.tick, - active_dialogue.started_tick, - target, - ); - - commands.entity(player_entity).remove::(); - } else { - tracing::trace!("WalkAway with no active dialogue — ignored"); - } - - commands.entity(player_entity).remove::(); -} - -/// Hardcoded confrontation monologue lines (D-063). -/// Fired as a monologue spike when the player delivers a confrontation. -/// TODO: move to content pools with trigger="confrontation_delivered" (D-028/D-035). -const CONFRONTATION_LINES: &[(&str, &str)] = &[ - ( - "confront_01", - "That changed everything between us. No going back.", - ), - ("confront_02", "The look on their face... they know I know."), - ( - "confront_03", - "Cards on the table. Let's see what happens next.", - ), -]; - -/// Process confrontation world response (#520, D-063). -/// -/// Reads ConfrontationDelivered marker (set by input system), applies three -/// server-authoritative effects: -/// 1. Target NPC shifts to AnimationTier::Tier2 (D-047) -/// 2. Observer's KG relationship state decremented (D-033 color fade) -/// 3. Monologue spike: immediate monologue line bypassing cooldown -/// -/// System ordering: after process_player_input, before compute_observer_snapshot. -pub fn process_confrontation_response( - mut commands: Commands, - time: Res, - registry: Res, - mut rng: ResMut, - mut trust_queue: ResMut, - mut query: Query< - ( - Entity, - &ConfrontationDelivered, - &mut KnowledgeGraph, - &mut MonologueBuffer, - &mut MonologueState, - ), - With, - >, - mut npc_mem_query: Query>, -) { - let Ok((player_entity, confrontation, mut observer_kg, mut monologue_buf, mut monologue_state)) = - query.single_mut() - else { - return; - }; - - let target = confrontation.target; - - // Effect 1: Shift target NPC to Tier 2 animation (D-047) - // + record routine deviation (symmetric with walk-away path) - commands.entity(target).insert(( - crate::npc::AnimationTier::Tier2, - crate::npc::RoutineDeviation { - trigger: crate::npc::DeviationTrigger::Confrontation, - tick: time.tick, - expires_at_tick: time.tick + crate::npc::TELL_ESCALATION_DURATION_TICKS, - }, - )); - - // Effect 2: Decrement observer's relationship with the target (D-033 color fade) - if let Some(target_sid) = registry.to_stable(target) { - let old_rel = observer_kg.relationship_with(&target_sid); - let new_rel = old_rel.decrement(); - if new_rel != old_rel { - observer_kg.set_relationship(&target_sid, new_rel); - tracing::info!( - target_id = target_sid.0, - ?old_rel, - ?new_rel, - "Confrontation: relationship decremented" - ); - } - } - - // Effect 3: Monologue spike — bypass cooldown, fire immediately - let idx = rng.rng.random_range(0..CONFRONTATION_LINES.len()); - let (id, text) = CONFRONTATION_LINES[idx]; - monologue_buf.set(MonologueEvent { - id: id.to_string(), - text: text.to_string(), - duration_seconds: 5.0, - }); - monologue_state.last_fired_tick = time.tick; - - // Trust progression (#324): confrontation significantly reduces NPC trust - trust_queue.push(TrustEvent::ConfrontationDelivered { - npc: target, - player: player_entity, - }); - - // Interaction tracking (#325): record confrontation notable event - if let Ok(Some(mut mem)) = npc_mem_query.get_mut(target) { - mem.push_event(InteractionEvent { - tick: time.tick, - kind: InteractionEventKind::Confrontation, - }); - } - - tracing::info!( - tick = time.tick, - monologue_id = id, - "Confrontation delivered: Tier 2 anim + relationship decrement + monologue spike + trust penalty" - ); - - // Clean up marker - commands - .entity(player_entity) - .remove::(); -} - -// --------------------------------------------------------------------------- -// System: process_dialogue_response (#539) -// --------------------------------------------------------------------------- - -/// Process DialogueResponse actions through the full D-028 four-layer pipeline (#539). -/// -/// Called when the player picks a dialogue option. Re-runs the same pipeline as -/// process_talk_interaction to select a follow-up line. Clears ActiveDialogue if -/// no candidates remain after cooldown filtering (conversation ends naturally). -/// -/// The response_id is the line_id that was shown; it's already on cooldown from -/// process_talk_interaction, ensuring the follow-up is a different line. -/// -/// System ordering: after process_player_input, after process_talk_interaction, -/// before compute_observer_snapshot. -#[tracing::instrument(level = "debug", skip_all)] -#[allow(clippy::type_complexity, clippy::too_many_arguments)] -pub fn process_dialogue_response( - mut commands: Commands, - time: Res, - line_pool: Option>, - registry: Res, - mut rng: ResMut, - mut trust_queue: ResMut, - mut player_query: Query< - ( - Entity, - &KnowledgeGraph, - &DialogueResponseRequest, - &mut DialogueResponseBuffer, - &mut DialogueCooldownTracker, - Option<&ActiveDialogue>, - ), - With, - >, - mut npc_query: Query<( - &DialogueProfile, - Option<&CurrentMood>, - Option<&mut InteractionMemory>, - Option<&NpcName>, - Option<&NpcColorIndex>, - )>, -) { - let Ok(( - player_entity, - observer_kg, - response_req, - mut response_buffer, - mut cooldown, - active_dialogue_opt, - )) = player_query.single_mut() - else { - return; - }; - - let target = response_req.target; - let response_id = response_req.response_id.clone(); - - // Always remove the marker regardless of outcome — request is consumed this tick. - commands - .entity(player_entity) - .remove::(); - - let Some(line_pool) = line_pool else { return }; - - // Look up NPC dialogue profile, mood, interaction history, name, and color - let Ok((profile, mood_opt, mut interaction_mem_opt, npc_name_opt, color_idx_opt)) = - npc_query.get_mut(target) - else { - tracing::debug!( - "DialogueResponse target {:?} has no DialogueProfile — cannot select follow-up", - target - ); - return; - }; - - // Resolve target's StableId for KG lookup - let target_stable = registry.to_stable(target); - let relationship = target_stable - .map(|sid| observer_kg.relationship_with(&sid)) - .unwrap_or(RelationshipState::Unknown); - - let confidence = target_stable - .and_then(|sid| observer_kg.confidence_of(&sid)) - .unwrap_or(crate::knowledge::types::KnowledgeConfidence::Suspects); - - let npc_mood = mood_opt.map(|m| m.0); - - // Prune old cooldown entries - cooldown.prune(time.tick); - - let selected = run_dialogue_pipeline( - &line_pool.0, - &profile.location, - &profile.role, - relationship, - confidence, - time.day_phase(), - interaction_mem_opt.as_deref(), - npc_mood, - &cooldown, - time.tick, - &mut rng.rng, - ); - - if let Some(line) = selected { - let Some(speaker_stable) = registry.to_stable(target) else { - tracing::warn!( - "DialogueResponse target {:?} not in EntityRegistry — skipping follow-up", - target - ); - return; - }; - - let speaker_display_name = { - let known = observer_kg - .entity_knowledge(&speaker_stable) - .map(|e| e.known_attributes.contains_key("name")) - .unwrap_or(false); - if known { - npc_name_opt - .map(|n| n.0.clone()) - .unwrap_or_else(|| "Unknown".to_string()) - } else { - display_label_for_role(&profile.role) - } - }; - let speaker_color = color_idx_opt.map(|c| c.0).unwrap_or(0u8); - - response_buffer.response = Some(DialogueResponseEvent { - line_id: line.id.clone(), - text: line.text.clone(), - speaker_entity_id: speaker_stable.0, - speaker_color_index: speaker_color, - speaker_name: speaker_display_name, - }); - - cooldown.record(&line.id, time.tick); - - // Update ActiveDialogue with current tick — prevents stale started_tick - commands.entity(player_entity).insert(ActiveDialogue { - target, - interaction_type: crate::knowledge::events::InteractionType::Talk, - started_tick: time.tick, - }); - - // Trust progression: follow-up dialogue warms the NPC - trust_queue.push(TrustEvent::TalkCompleted { - npc: target, - player: player_entity, - }); - - // Interaction tracking: record follow-up as a talk event - if let Some(ref mut mem) = interaction_mem_opt { - mem.record_talk(time.tick); - } - - tracing::debug!( - "Follow-up selected: id={}, response_id={}, location={}, role={}", - line.id, - response_id, - profile.location, - profile.role, - ); - } else { - // No follow-up lines — conversation ends naturally (D-062: invisible locks) - tracing::debug!( - "No follow-up lines for response_id={} at {}/{} — ending conversation", - response_id, - profile.location, - profile.role, - ); - // Clear active dialogue state - if active_dialogue_opt.is_some() { - commands.entity(player_entity).remove::(); - } - } -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use super::*; - use crate::knowledge::graph::KnowledgeGraph; - use crate::knowledge::registry::EntityRegistry; - use crate::npc::Npc; - use crate::simulation::line_pool::LinePoolIndexResource; - use crate::simulation::line_pool::{ - AccessTier, IndexedDialogueLine, IndexedDialoguePool, LinePoolIndex, Mood, Situation, - Topic, TrustTier, - }; - use crate::simulation::movement::TilePosition; - use crate::simulation::rng::SimRng; - use crate::simulation::time::SimulationTime; - - // -- Mapping tests ------------------------------------------------------- - - #[test] - fn access_tiers_unknown_gets_public() { - let tiers = available_access_tiers(RelationshipState::Unknown); - assert_eq!(tiers, vec![AccessTier::Public]); - } - - #[test] - fn access_tiers_known_gets_public_and_peer() { - let tiers = available_access_tiers(RelationshipState::Known); - assert!(tiers.contains(&AccessTier::Public)); - assert!(tiers.contains(&AccessTier::Peer)); - } - - #[test] - fn access_tiers_friendly_includes_insider() { - let tiers = available_access_tiers(RelationshipState::Friendly); - assert!(tiers.contains(&AccessTier::Insider)); - } - - #[test] - fn access_tiers_poi_includes_authority() { - let tiers = available_access_tiers(RelationshipState::PersonOfInterest); - assert!(tiers.contains(&AccessTier::Authority)); - assert!(tiers.contains(&AccessTier::Peer)); - assert!(!tiers.contains(&AccessTier::Insider)); - } - - #[test] - fn access_tiers_hostile_only_hostile() { - let tiers = available_access_tiers(RelationshipState::Hostile); - assert_eq!(tiers, vec![AccessTier::Hostile]); - } - - // -- Trust tier tests (D-075: layered confidence gate) -------------------- - - #[test] - fn trust_friendly_knows_details_is_secret() { - use crate::knowledge::types::KnowledgeConfidence; - assert_eq!( - relationship_to_trust( - RelationshipState::Friendly, - KnowledgeConfidence::KnowsDetails - ), - TrustTier::Secret - ); - } - - #[test] - fn trust_friendly_direct_is_secret() { - use crate::knowledge::types::KnowledgeConfidence; - assert_eq!( - relationship_to_trust(RelationshipState::Friendly, KnowledgeConfidence::Direct), - TrustTier::Secret - ); - } - - #[test] - fn trust_friendly_knows_of_is_real() { - use crate::knowledge::types::KnowledgeConfidence; - assert_eq!( - relationship_to_trust(RelationshipState::Friendly, KnowledgeConfidence::KnowsOf), - TrustTier::Real - ); - } - - #[test] - fn trust_friendly_suspects_is_surface() { - use crate::knowledge::types::KnowledgeConfidence; - assert_eq!( - relationship_to_trust(RelationshipState::Friendly, KnowledgeConfidence::Suspects), - TrustTier::Surface - ); - } - - #[test] - fn trust_known_knows_of_is_real() { - use crate::knowledge::types::KnowledgeConfidence; - assert_eq!( - relationship_to_trust(RelationshipState::Known, KnowledgeConfidence::KnowsOf), - TrustTier::Real - ); - } - - #[test] - fn trust_known_suspects_is_surface() { - use crate::knowledge::types::KnowledgeConfidence; - assert_eq!( - relationship_to_trust(RelationshipState::Known, KnowledgeConfidence::Suspects), - TrustTier::Surface - ); - } - - #[test] - fn trust_unknown_is_always_surface() { - use crate::knowledge::types::KnowledgeConfidence; - assert_eq!( - relationship_to_trust(RelationshipState::Unknown, KnowledgeConfidence::Direct), - TrustTier::Surface - ); - } - - #[test] - fn trust_poi_is_always_surface() { - use crate::knowledge::types::KnowledgeConfidence; - // PersonOfInterest uses Authority access, not trust depth - assert_eq!( - relationship_to_trust( - RelationshipState::PersonOfInterest, - KnowledgeConfidence::KnowsDetails - ), - TrustTier::Surface - ); - } - - // -- Situation derivation tests ------------------------------------------ - - #[test] - fn situations_always_include_routine() { - use crate::simulation::time::DayPhase; - for phase in [ - DayPhase::Morning, - DayPhase::Afternoon, - DayPhase::Evening, - DayPhase::Night, - ] { - let sits = derive_situations(phase, RelationshipState::Unknown); - assert!( - sits.contains(&Situation::Routine), - "Routine must always be present for {:?}", - phase - ); - } - } - - #[test] - fn situations_morning_includes_shift_start() { - use crate::simulation::time::DayPhase; - let sits = derive_situations(DayPhase::Morning, RelationshipState::Unknown); - assert!(sits.contains(&Situation::ShiftStart)); - } - - #[test] - fn situations_evening_includes_bar_evening() { - use crate::simulation::time::DayPhase; - let sits = derive_situations(DayPhase::Evening, RelationshipState::Unknown); - assert!(sits.contains(&Situation::BarEvening)); - assert!(sits.contains(&Situation::Social)); - } - - #[test] - fn situations_poi_adds_investigation() { - use crate::simulation::time::DayPhase; - let sits = derive_situations(DayPhase::Morning, RelationshipState::PersonOfInterest); - assert!(sits.contains(&Situation::Investigation)); - } - - #[test] - fn situations_non_poi_no_investigation() { - use crate::simulation::time::DayPhase; - let sits = derive_situations(DayPhase::Morning, RelationshipState::Known); - assert!(!sits.contains(&Situation::Investigation)); - } - - // -- Layer 4 scoring tests ----------------------------------------------- - - fn make_line(id: &str, topics: &[Topic], moods: &[Mood]) -> IndexedDialogueLine { - IndexedDialogueLine { - id: id.to_string(), - text: format!("Text for {}", id), - role: "worker".to_string(), - access: vec![AccessTier::Public], - trust: TrustTier::Surface, - situation: vec![Situation::Routine], - topic: topics.to_vec(), - mood: moods.to_vec(), - tags: vec![], - knowledge_grant: None, - } - } - - #[test] - fn score_base_is_one_for_neutral_line() { - let line = make_line("neutral", &[], &[]); - assert_eq!(score_line(&line, None, &[]), 1); - } - - #[test] - fn score_mood_match_adds_three() { - let line = make_line("moody", &[], &[Mood::Anxious]); - assert_eq!(score_line(&line, Some(Mood::Anxious), &[]), 4); // 1 base + 3 mood - } - - #[test] - fn score_mood_mismatch_stays_base() { - let line = make_line("moody", &[], &[Mood::Anxious]); - assert_eq!(score_line(&line, Some(Mood::Warm), &[]), 1); - } - - #[test] - fn score_topic_match_adds_two_each() { - let line = make_line("topical", &[Topic::Cargo, Topic::Danger], &[]); - assert_eq!(score_line(&line, None, &[Topic::Cargo]), 3); // 1 + 2 - assert_eq!(score_line(&line, None, &[Topic::Cargo, Topic::Danger]), 5); // 1 + 2 + 2 - } - - #[test] - fn score_combined_mood_and_topic() { - let line = make_line("both", &[Topic::Cargo], &[Mood::Suspicious]); - assert_eq!( - score_line(&line, Some(Mood::Suspicious), &[Topic::Cargo]), - 6 // 1 + 3 + 2 - ); - } - - // -- Cooldown tracker tests ---------------------------------------------- - - #[test] - fn cooldown_tracks_used_lines() { - let mut tracker = DialogueCooldownTracker::default(); - tracker.record("line_001", 100); - assert!(tracker.is_on_cooldown("line_001", 100)); - assert!(tracker.is_on_cooldown("line_001", 100 + LINE_COOLDOWN_TICKS - 1)); - assert!(!tracker.is_on_cooldown("line_001", 100 + LINE_COOLDOWN_TICKS)); - } - - #[test] - fn cooldown_different_line_not_affected() { - let mut tracker = DialogueCooldownTracker::default(); - tracker.record("line_001", 100); - assert!(!tracker.is_on_cooldown("line_002", 100)); - } - - #[test] - fn cooldown_prune_removes_old_entries() { - let mut tracker = DialogueCooldownTracker::default(); - tracker.record("old", 0); - tracker.record("recent", LINE_COOLDOWN_TICKS); - tracker.prune(LINE_COOLDOWN_TICKS); - assert_eq!(tracker.used.len(), 1); - assert!(tracker.used.contains_key("recent")); - } - - // -- Selection tests ----------------------------------------------------- - - #[test] - fn select_returns_none_when_empty() { - let candidates: Vec<&IndexedDialogueLine> = vec![]; - let cooldown = DialogueCooldownTracker::default(); - let mut rng = rand_chacha::ChaCha20Rng::seed_from_u64(42); - - let result = select_dialogue_line(&candidates, None, &[], &cooldown, 0, &mut rng); - assert!(result.is_none()); - } - - #[test] - fn select_returns_none_when_all_on_cooldown() { - let line = make_line("only", &[], &[]); - let candidates = vec![&line]; - let mut cooldown = DialogueCooldownTracker::default(); - cooldown.record("only", 0); - let mut rng = rand_chacha::ChaCha20Rng::seed_from_u64(42); - - let result = select_dialogue_line(&candidates, None, &[], &cooldown, 0, &mut rng); - assert!(result.is_none()); - } - - #[test] - fn select_picks_from_candidates() { - let line_a = make_line("a", &[], &[]); - let line_b = make_line("b", &[], &[]); - let candidates = vec![&line_a, &line_b]; - let cooldown = DialogueCooldownTracker::default(); - let mut rng = rand_chacha::ChaCha20Rng::seed_from_u64(42); - - let result = select_dialogue_line(&candidates, None, &[], &cooldown, 0, &mut rng); - assert!(result.is_some()); - let id = &result.unwrap().id; - assert!(id == "a" || id == "b"); - } - - #[test] - fn select_deterministic_with_same_seed() { - let line_a = make_line("a", &[], &[]); - let line_b = make_line("b", &[Topic::Cargo], &[]); - let line_c = make_line("c", &[], &[Mood::Anxious]); - let candidates = vec![&line_a, &line_b, &line_c]; - let cooldown = DialogueCooldownTracker::default(); - - let mut rng1 = rand_chacha::ChaCha20Rng::seed_from_u64(42); - let mut rng2 = rand_chacha::ChaCha20Rng::seed_from_u64(42); - - let r1 = select_dialogue_line(&candidates, None, &[], &cooldown, 0, &mut rng1); - let r2 = select_dialogue_line(&candidates, None, &[], &cooldown, 0, &mut rng2); - assert_eq!(r1.unwrap().id, r2.unwrap().id); - } - - #[test] - fn select_favors_higher_scored_lines() { - // Line with matching mood gets +3, so should be selected more often - let neutral = make_line("neutral", &[], &[]); - let matched = make_line("matched", &[], &[Mood::Anxious]); - let candidates = vec![&neutral, &matched]; - let cooldown = DialogueCooldownTracker::default(); - - let mut match_count = 0; - for seed in 0..100 { - let mut rng = rand_chacha::ChaCha20Rng::seed_from_u64(seed); - if let Some(line) = select_dialogue_line( - &candidates, - Some(Mood::Anxious), - &[], - &cooldown, - 0, - &mut rng, - ) { - if line.id == "matched" { - match_count += 1; - } - } - } - // matched has score 4, neutral has score 1, so ~80% should be matched - assert!( - match_count > 60, - "matched line should be selected most of the time, got {}/100", - match_count - ); - } - - // -- System integration tests -------------------------------------------- - - fn setup_dialogue_world() -> World { - let mut world = World::new(); - world.init_resource::(); - world.insert_resource(SimRng::new(42)); - world.init_resource::(); - world.init_resource::(); - world.init_resource::(); - world.init_resource::(); - 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::Content], - tags: vec![], - knowledge_grant: None, - }, - IndexedDialogueLine { - id: "test_d_004".to_string(), - text: "There's something I need to tell you about the manifests.".to_string(), - role: "dock-worker".to_string(), - access: vec![AccessTier::Insider], - trust: TrustTier::Real, - situation: vec![Situation::Investigation], - topic: vec![Topic::Cargo, Topic::Investigation], - mood: vec![Mood::Suspicious], - tags: vec![], - knowledge_grant: None, - }, - ]; - - let pool = IndexedDialoguePool { - location: "the-terminal".to_string(), - role: "dock-worker".to_string(), - lines, - }; - index.dialogue.insert( - ("the-terminal".to_string(), "dock-worker".to_string()), - pool, - ); - index - } - - #[test] - fn process_talk_selects_line_for_unknown_relationship() { - let mut world = setup_dialogue_world(); - let index = build_test_line_pool(); - world.insert_resource(LinePoolIndexResource(index)); - - // Spawn NPC with DialogueProfile - let npc = world - .spawn(( - Npc, - TilePosition::new(5, 5, 0), - DialogueProfile { - location: "the-terminal".to_string(), - role: "dock-worker".to_string(), - }, - CurrentMood(Mood::Content), - )) - .id(); - world.resource_mut::().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::Content), - )) - .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::(); - 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" - ); - } - - // -- 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::(); - - 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(); - - let tier = world.get::(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::(); - world.resource_mut::().tick = 42; - - 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(); - - let deviation = world.get::(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::(); - - let npc = world.spawn_empty().id(); - world.resource_mut::().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::().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::(npc).is_none(), - "NPC should not get AnimationTier when no dialogue was active" - ); - assert!( - world.get::(npc).is_none(), - "NPC should not get RoutineDeviation when no dialogue was active" - ); - } - - use rand::SeedableRng; - - // -- Trust-gated gossip tests (#171, D-075) ---------------------------------- - - /// Build a pool with a Surface-tier Public line and a Secret-tier Insider line. - /// Used to verify that KnowledgeConfidence gates Secret access correctly. - fn build_trust_tier_pool() -> LinePoolIndex { - let mut index = LinePoolIndex::default(); - let pool = IndexedDialoguePool { - location: "the-terminal".to_string(), - role: "dock-worker".to_string(), - lines: vec![ - IndexedDialogueLine { - id: "trust_surface_001".to_string(), - text: "Just another day at the terminal.".to_string(), - role: "dock-worker".to_string(), - access: vec![AccessTier::Public], - trust: TrustTier::Surface, - situation: vec![Situation::Routine], - topic: vec![], - mood: vec![], - tags: vec![], - knowledge_grant: None, - }, - IndexedDialogueLine { - id: "trust_secret_001".to_string(), - text: "The manifests don't match. You didn't hear that from me.".to_string(), - role: "dock-worker".to_string(), - access: vec![AccessTier::Insider], // requires Friendly relationship - trust: TrustTier::Secret, // requires Friendly + KnowsDetails+ - situation: vec![Situation::Routine], - topic: vec![], - mood: vec![], - tags: vec![], - knowledge_grant: None, - }, - ], - }; - index.dialogue.insert( - ("the-terminal".to_string(), "dock-worker".to_string()), - pool, - ); - index - } - - #[test] - fn trust_gated_knows_details_can_get_secret_tier_line() { - // D-075: Friendly + KnowsDetails → Secret trust tier → secret lines available. - // Spec ref: #171, D-075 "Secret: Friendly + KnowsDetails+" - use crate::knowledge::types::KnowledgeConfidence; - - let mut world = setup_dialogue_world(); - world.insert_resource(LinePoolIndexResource(build_trust_tier_pool())); - - let npc = world - .spawn(( - Npc, - TilePosition::new(5, 5, 0), - DialogueProfile { - location: "the-terminal".to_string(), - role: "dock-worker".to_string(), - }, - )) - .id(); - let npc_sid = world.resource_mut::().register(npc); - - // observe_entity → Direct; observe_entity_leaving_los → KnowsDetails - let mut kg = KnowledgeGraph::new(); - kg.observe_entity(npc_sid, TilePosition::new(5, 5, 0), 0); - kg.observe_entity_leaving_los(&npc_sid, 1); - kg.set_relationship(&npc_sid, RelationshipState::Friendly); - - assert_eq!( - kg.confidence_of(&npc_sid), - Some(KnowledgeConfidence::KnowsDetails), - "precondition: KG must have KnowsDetails confidence" - ); - - let player = world - .spawn(( - PlayerCharacter, - TilePosition::new(5, 6, 0), - kg, - TalkRequest { target: npc }, - DialogueResponseBuffer::default(), - DialogueCooldownTracker::default(), - )) - .id(); - world.resource_mut::().register(player); - - // Run with multiple seeds — Secret-tier line must appear at least once - let mut saw_secret_line = false; - for seed in 0u64..50 { - world - .get_mut::(player) - .unwrap() - .response = None; - world.entity_mut(player).insert(TalkRequest { target: npc }); - // Reset cooldown so the pool is not exhausted between iterations - world - .entity_mut(player) - .insert(DialogueCooldownTracker::default()); - world.insert_resource(SimRng::new(seed)); - - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(process_talk_interaction); - schedule.run(&mut world); - world.flush(); - - if let Some(resp) = &world - .get::(player) - .unwrap() - .response - { - if resp.line_id == "trust_secret_001" { - saw_secret_line = true; - break; - } - } - } - - assert!( - saw_secret_line, - "Friendly + KnowsDetails player should be able to access Secret-tier lines (D-075 #171)" - ); - } - - #[test] - fn trust_gated_suspects_only_gets_surface_tier() { - // D-075: Friendly + Suspects → Surface trust tier → Secret lines invisible. - // Spec ref: #171, D-075 "Surface: any relationship + any confidence" - use crate::knowledge::types::KnowledgeConfidence; - - let mut world = setup_dialogue_world(); - world.insert_resource(LinePoolIndexResource(build_trust_tier_pool())); - - let npc = world - .spawn(( - Npc, - TilePosition::new(5, 5, 0), - DialogueProfile { - location: "the-terminal".to_string(), - role: "dock-worker".to_string(), - }, - )) - .id(); - let npc_sid = world.resource_mut::().register(npc); - - // Friendly relationship but Suspects confidence → Surface trust only - let mut kg = KnowledgeGraph::new(); - kg.observe_entity(npc_sid, TilePosition::new(5, 5, 0), 0); - kg.set_relationship(&npc_sid, RelationshipState::Friendly); - // Patch down to Suspects (observe_entity sets Direct — too high) - kg.entities.get_mut(&npc_sid).unwrap().confidence = KnowledgeConfidence::Suspects; - - assert_eq!( - kg.confidence_of(&npc_sid), - Some(KnowledgeConfidence::Suspects), - "precondition: KG must have Suspects confidence" - ); - - let player = world - .spawn(( - PlayerCharacter, - TilePosition::new(5, 6, 0), - kg, - TalkRequest { target: npc }, - DialogueResponseBuffer::default(), - DialogueCooldownTracker::default(), - )) - .id(); - world.resource_mut::().register(player); - - let mut saw_secret = false; - for seed in 0u64..50 { - world - .get_mut::(player) - .unwrap() - .response = None; - world.entity_mut(player).insert(TalkRequest { target: npc }); - world - .entity_mut(player) - .insert(DialogueCooldownTracker::default()); - world.insert_resource(SimRng::new(seed)); - - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(process_talk_interaction); - schedule.run(&mut world); - world.flush(); - - if let Some(resp) = &world - .get::(player) - .unwrap() - .response - { - if resp.line_id == "trust_secret_001" { - saw_secret = true; - break; - } - } - } - - assert!( - !saw_secret, - "Friendly + Suspects player must NOT access Secret-tier lines (D-075 #171)" - ); - } - - // -- Line variety regression test (#338, D-028) ------------------------------ - - /// Pool with 12 distinct Public/Surface/Routine lines for variety testing. - fn build_variety_pool() -> LinePoolIndex { - let mut index = LinePoolIndex::default(); - let lines: Vec = (1u32..=12) - .map(|n| IndexedDialogueLine { - id: format!("variety_{:03}", n), - text: format!("Line number {}.", n), - role: "dock-worker".to_string(), - access: vec![AccessTier::Public], - trust: TrustTier::Surface, - situation: vec![Situation::Routine], - topic: vec![], - mood: vec![], - tags: vec![], - knowledge_grant: None, - }) - .collect(); - let pool = IndexedDialoguePool { - location: "the-terminal".to_string(), - role: "dock-worker".to_string(), - lines, - }; - index.dialogue.insert( - ("the-terminal".to_string(), "dock-worker".to_string()), - pool, - ); - index - } - - #[test] - fn line_variety_no_repeats_within_cooldown_window() { - // #338, D-028: No line_id should repeat within LINE_COOLDOWN_TICKS. - // Regression: Talk 10 times at tick 0 (well within the 600-tick window). - // Each selected line must be distinct — cooldown tracker enforces this. - let mut world = setup_dialogue_world(); - world.insert_resource(LinePoolIndexResource(build_variety_pool())); - - let npc = world - .spawn(( - Npc, - TilePosition::new(5, 5, 0), - DialogueProfile { - location: "the-terminal".to_string(), - role: "dock-worker".to_string(), - }, - )) - .id(); - world.resource_mut::().register(npc); - - // Unknown player — Public access only; tick stays at 0 throughout - let player = world - .spawn(( - PlayerCharacter, - TilePosition::new(5, 6, 0), - KnowledgeGraph::new(), - TalkRequest { target: npc }, - DialogueResponseBuffer::default(), - DialogueCooldownTracker::default(), - )) - .id(); - world.resource_mut::().register(player); - - let mut seen_ids: Vec = Vec::new(); - - for seed in 0u64..10 { - world - .get_mut::(player) - .unwrap() - .response = None; - world.entity_mut(player).insert(TalkRequest { target: npc }); - // NOTE: SimulationTime is NOT advanced — all 10 talks happen within tick 0 - world.insert_resource(SimRng::new(seed)); - - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(process_talk_interaction); - schedule.run(&mut world); - world.flush(); - - if let Some(resp) = &world - .get::(player) - .unwrap() - .response - { - let id = resp.line_id.clone(); - assert!( - !seen_ids.contains(&id), - "Line '{}' was repeated within the {}-tick cooldown window (iteration {}). \ - Cooldown tracker must prevent repeats. (#338)", - id, - LINE_COOLDOWN_TICKS, - seed, - ); - seen_ids.push(id); - } - } - - assert_eq!( - seen_ids.len(), - 10, - "Should have selected 10 distinct lines across 10 consecutive Talks (#338)" - ); - } - - // === Confrontation Response Tests (#520, D-063) === - - #[test] - fn confrontation_shifts_npc_to_tier2() { - let mut world = bevy_ecs::world::World::new(); - world.insert_resource(SimulationTime::default()); - world.init_resource::(); - world.insert_resource(SimRng::new(42)); - world.init_resource::(); - - let npc = world - .spawn((crate::npc::Npc, TilePosition::new(5, 6, 0))) - .id(); - let npc_sid = world.resource_mut::().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::(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::(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::(); - world.insert_resource(SimRng::new(42)); - world.init_resource::(); - - let npc = world - .spawn((crate::npc::Npc, TilePosition::new(5, 6, 0))) - .id(); - let npc_sid = world.resource_mut::().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::(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::(); - world.insert_resource(SimRng::new(42)); - world.init_resource::(); - - let npc = world - .spawn((crate::npc::Npc, TilePosition::new(5, 6, 0))) - .id(); - let npc_sid = world.resource_mut::().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::(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::(); - world.insert_resource(SimRng::new(42)); - world.init_resource::(); - - let npc = world - .spawn((crate::npc::Npc, TilePosition::new(5, 6, 0))) - .id(); - let npc_sid = world.resource_mut::().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::(player).is_none(), - "Marker should be removed after processing" - ); - } - - // === DialogueResponse Tests (#539) === - - #[test] - fn process_dialogue_response_selects_follow_up_line() { - let mut world = setup_dialogue_world(); - let index = build_test_line_pool(); - world.insert_resource(LinePoolIndexResource(index)); - - let npc = world - .spawn(( - Npc, - TilePosition::new(5, 5, 0), - DialogueProfile { - location: "the-terminal".to_string(), - role: "dock-worker".to_string(), - }, - CurrentMood(Mood::Content), - )) - .id(); - world.resource_mut::().register(npc); - - let player = world - .spawn(( - PlayerCharacter, - TilePosition::new(5, 6, 0), - KnowledgeGraph::new(), - // Simulate that first line was already selected (on cooldown) - DialogueResponseRequest { - target: npc, - response_id: "test_d_001".to_string(), - }, - DialogueResponseBuffer::default(), - DialogueCooldownTracker::default(), - )) - .id(); - world.resource_mut::().register(player); - - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(process_dialogue_response); - schedule.run(&mut world); - world.flush(); - - // Should have consumed the marker - assert!( - world.get::(player).is_none(), - "DialogueResponseRequest should be consumed" - ); - } - - #[test] - fn process_dialogue_response_clears_active_dialogue_when_no_lines() { - let mut world = setup_dialogue_world(); - - // Build pool with only one line — it will be on cooldown - let mut index = LinePoolIndex::default(); - let pool = IndexedDialoguePool { - location: "test".to_string(), - role: "worker".to_string(), - lines: vec![IndexedDialogueLine { - id: "only_line".to_string(), - text: "Only thing I can say.".to_string(), - role: "worker".to_string(), - access: vec![AccessTier::Public], - trust: TrustTier::Surface, - situation: vec![Situation::Routine], - topic: vec![], - mood: vec![], - tags: vec![], - knowledge_grant: None, - }], - }; - index - .dialogue - .insert(("test".to_string(), "worker".to_string()), pool); - world.insert_resource(LinePoolIndexResource(index)); - - let npc = world - .spawn(( - Npc, - TilePosition::new(5, 5, 0), - DialogueProfile { - location: "test".to_string(), - role: "worker".to_string(), - }, - )) - .id(); - world.resource_mut::().register(npc); - - // Set the only line on cooldown — so no follow-up can be selected - let mut cooldown = DialogueCooldownTracker::default(); - cooldown.record("only_line", 0); - - let player = world - .spawn(( - PlayerCharacter, - TilePosition::new(5, 6, 0), - KnowledgeGraph::new(), - DialogueResponseRequest { - target: npc, - response_id: "only_line".to_string(), - }, - DialogueResponseBuffer::default(), - cooldown, - ActiveDialogue { - target: npc, - interaction_type: crate::knowledge::events::InteractionType::Talk, - started_tick: 0, - }, - )) - .id(); - world.resource_mut::().register(player); - - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(process_dialogue_response); - schedule.run(&mut world); - world.flush(); - - // No follow-up lines — ActiveDialogue should be cleared - assert!( - world.get::(player).is_none(), - "ActiveDialogue should be cleared when no follow-up lines available" - ); - assert!( - world.get::(player).is_none(), - "DialogueResponseRequest should be consumed" - ); - // Buffer should remain empty - let buffer = world.get::(player).unwrap(); - assert!( - buffer.response.is_none(), - "No response when all lines on cooldown" - ); - } - - #[test] - fn process_dialogue_response_no_profile_is_noop() { - let mut world = setup_dialogue_world(); - - // NPC without DialogueProfile - let npc = world.spawn((Npc, TilePosition::new(5, 5, 0))).id(); - world.resource_mut::().register(npc); - - let player = world - .spawn(( - PlayerCharacter, - TilePosition::new(5, 6, 0), - KnowledgeGraph::new(), - DialogueResponseRequest { - target: npc, - response_id: "some_line".to_string(), - }, - DialogueResponseBuffer::default(), - DialogueCooldownTracker::default(), - )) - .id(); - world.resource_mut::().register(player); - - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(process_dialogue_response); - schedule.run(&mut world); - world.flush(); - - assert!( - world.get::(player).is_none(), - "DialogueResponseRequest consumed even with no profile" - ); - let buffer = world.get::(player).unwrap(); - assert!( - buffer.response.is_none(), - "No response for NPC without DialogueProfile" - ); - } -} diff --git a/server/src/simulation/dialogue/confrontation.rs b/server/src/simulation/dialogue/confrontation.rs new file mode 100644 index 000000000..3040eb867 --- /dev/null +++ b/server/src/simulation/dialogue/confrontation.rs @@ -0,0 +1,714 @@ +//! Confrontation and walk-away — negative conversation-ending world +//! responses (D-063 #520, D-064 #427/#519). +//! +//! Both systems share the same effect shape: target NPC shifts to +//! `AnimationTier::Tier2`, a `RoutineDeviation` is recorded, NPC trust drops, +//! and the event lands in `InteractionMemory`. Walk-away lives here rather +//! than in response-assembly for that reason (T-1062 seam note). Also owns +//! the Confront input handler that stages `ConfrontationDelivered`. + +use bevy_ecs::prelude::*; +use rand::Rng; + +use crate::bridge::types::MonologueEvent; +use crate::knowledge::types::StableId; +use crate::knowledge::{EntityRegistry, KnowledgeGraph}; +use crate::npc::interaction::{InteractionEvent, InteractionEventKind, InteractionMemory}; +use crate::npc::relationships::{TrustEvent, TrustEventQueue}; +use crate::simulation::monologue::{MonologueBuffer, MonologueState}; +use crate::simulation::movement::PlayerCharacter; +use crate::simulation::time::SimulationTime; + +use super::{ActiveDialogue, ConfrontationDelivered, WalkAwayRequest}; + +// --------------------------------------------------------------------------- +// 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, + mut trust_queue: ResMut, + mut post_conv_queue: ResMut, + time: Res, + query: Query<(Entity, Option<&ActiveDialogue>, &WalkAwayRequest), With>, + mut npc_mem_query: Query>, +) { + let Ok((player_entity, active_dialogue_opt, _walk_away)) = query.single() else { + return; + }; + + if let Some(active_dialogue) = active_dialogue_opt { + let target = active_dialogue.target; + + // Phase 2, Effect 1: Shift target NPC to Tier 2 animation (D-047) + commands + .entity(target) + .insert(crate::npc::AnimationTier::Tier2); + + // Phase 2, Effect 2: Record routine deviation on target NPC + commands + .entity(target) + .insert(crate::npc::RoutineDeviation { + trigger: crate::npc::DeviationTrigger::WalkAway, + tick: time.tick, + expires_at_tick: time.tick + crate::npc::TELL_ESCALATION_DURATION_TICKS, + }); + + // Phase 3: Emit IncompleteInteraction knowledge event + event_queue.push(crate::knowledge::KnowledgeEvent { + observer: player_entity, + tick: time.tick, + event_type: crate::knowledge::KnowledgeEventType::IncompleteInteraction { + target, + interaction_type: active_dialogue.interaction_type, + }, + }); + + // Trust progression (#324): walk-away reduces NPC trust + trust_queue.push(TrustEvent::WalkAway { + npc: target, + player: player_entity, + }); + + // Interaction tracking (#325): record notable walk-away event + if let Ok(Some(mut mem)) = npc_mem_query.get_mut(target) { + mem.push_event(InteractionEvent { + tick: time.tick, + kind: InteractionEventKind::WalkAway, + }); + } + + // Post-conversation monologue trigger (#119, D-035) + post_conv_queue.push(target); + + tracing::debug!( + "Walk-away during {:?} dialogue at tick {} (started tick {}): \ + target {:?} → Tier2 animation + routine deviation", + active_dialogue.interaction_type, + time.tick, + active_dialogue.started_tick, + target, + ); + + commands.entity(player_entity).remove::(); + } else { + tracing::trace!("WalkAway with no active dialogue — ignored"); + } + + commands.entity(player_entity).remove::(); +} + +/// Hardcoded confrontation monologue lines (D-063). +/// Fired as a monologue spike when the player delivers a confrontation. +/// TODO: move to content pools with trigger="confrontation_delivered" (D-028/D-035). +const CONFRONTATION_LINES: &[(&str, &str)] = &[ + ( + "confront_01", + "That changed everything between us. No going back.", + ), + ("confront_02", "The look on their face... they know I know."), + ( + "confront_03", + "Cards on the table. Let's see what happens next.", + ), +]; + +/// Process confrontation world response (#520, D-063). +/// +/// Reads ConfrontationDelivered marker (set by input system), applies three +/// server-authoritative effects: +/// 1. Target NPC shifts to AnimationTier::Tier2 (D-047) +/// 2. Observer's KG relationship state decremented (D-033 color fade) +/// 3. Monologue spike: immediate monologue line bypassing cooldown +/// +/// System ordering: after process_player_input, before compute_observer_snapshot. +pub fn process_confrontation_response( + mut commands: Commands, + time: Res, + registry: Res, + mut rng: ResMut, + mut trust_queue: ResMut, + mut query: Query< + ( + Entity, + &ConfrontationDelivered, + &mut KnowledgeGraph, + &mut MonologueBuffer, + &mut MonologueState, + ), + With, + >, + mut npc_mem_query: Query>, +) { + let Ok((player_entity, confrontation, mut observer_kg, mut monologue_buf, mut monologue_state)) = + query.single_mut() + else { + return; + }; + + let target = confrontation.target; + + // Effect 1: Shift target NPC to Tier 2 animation (D-047) + // + record routine deviation (symmetric with walk-away path) + commands.entity(target).insert(( + crate::npc::AnimationTier::Tier2, + crate::npc::RoutineDeviation { + trigger: crate::npc::DeviationTrigger::Confrontation, + tick: time.tick, + expires_at_tick: time.tick + crate::npc::TELL_ESCALATION_DURATION_TICKS, + }, + )); + + // Effect 2: Decrement observer's relationship with the target (D-033 color fade) + if let Some(target_sid) = registry.to_stable(target) { + let old_rel = observer_kg.relationship_with(&target_sid); + let new_rel = old_rel.decrement(); + if new_rel != old_rel { + observer_kg.set_relationship(&target_sid, new_rel); + tracing::info!( + target_id = target_sid.0, + ?old_rel, + ?new_rel, + "Confrontation: relationship decremented" + ); + } + } + + // Effect 3: Monologue spike — bypass cooldown, fire immediately + let idx = rng.rng.random_range(0..CONFRONTATION_LINES.len()); + let (id, text) = CONFRONTATION_LINES[idx]; + monologue_buf.set(MonologueEvent { + id: id.to_string(), + text: text.to_string(), + duration_seconds: 5.0, + }); + monologue_state.last_fired_tick = time.tick; + + // Trust progression (#324): confrontation significantly reduces NPC trust + trust_queue.push(TrustEvent::ConfrontationDelivered { + npc: target, + player: player_entity, + }); + + // Interaction tracking (#325): record confrontation notable event + if let Ok(Some(mut mem)) = npc_mem_query.get_mut(target) { + mem.push_event(InteractionEvent { + tick: time.tick, + kind: InteractionEventKind::Confrontation, + }); + } + + tracing::info!( + tick = time.tick, + monologue_id = id, + "Confrontation delivered: Tier 2 anim + relationship decrement + monologue spike + trust penalty" + ); + + // Clean up marker + commands + .entity(player_entity) + .remove::(); +} + +/// Handle Confront verb: set ConfrontationDelivered marker on the player entity (#520, D-063). +/// The confrontation response system runs in process_confrontation_response. +/// Server-side range check: Confront requires CLOSE_RANGE (same as Talk). +pub fn handle_confront( + commands: &mut Commands, + registry: &EntityRegistry, + player_query: &crate::simulation::input::PlayerInputQuery, + all_positions: &Query<&crate::simulation::movement::TilePosition>, + target_entity_id: Option, +) { + let Some(target_id) = target_entity_id else { + tracing::warn!("Confront verb without target_entity_id"); + return; + }; + + let Ok((player_entity, player_pos, _, _)) = 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, "Confront: target entity not in registry"); + return; + }; + + // Server-side range check: reject Confront if target is beyond close range + if let Ok(target_pos) = all_positions.get(target_entity) { + let distance = player_pos + .manhattan_distance(target_pos) + .unwrap_or(u32::MAX); + if distance > crate::simulation::interaction::CLOSE_RANGE { + tracing::info!( + target_id, + distance, + "Confront: target out of range (max {})", + crate::simulation::interaction::CLOSE_RANGE, + ); + return; + } + } + + commands + .entity(player_entity) + .insert(ConfrontationDelivered { + target: target_entity, + }); + + tracing::debug!( + target_id, + "Confront: ConfrontationDelivered marker set on player" + ); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::bridge::types::RelationshipState; + use crate::simulation::dialogue::test_support::setup_dialogue_world; + use crate::simulation::movement::TilePosition; + use crate::simulation::rng::SimRng; + + #[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::(); + 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" + ); + } + + // -- 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::(); + + 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(); + + let tier = world.get::(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::(); + world.resource_mut::().tick = 42; + + 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(); + + let deviation = world.get::(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::(); + + let npc = world.spawn_empty().id(); + world.resource_mut::().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::().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::(npc).is_none(), + "NPC should not get AnimationTier when no dialogue was active" + ); + assert!( + world.get::(npc).is_none(), + "NPC should not get RoutineDeviation when no dialogue was active" + ); + } + + // === 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::(); + world.insert_resource(SimRng::new(42)); + world.init_resource::(); + + let npc = world + .spawn((crate::npc::Npc, TilePosition::new(5, 6, 0))) + .id(); + let npc_sid = world.resource_mut::().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::(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::(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::(); + world.insert_resource(SimRng::new(42)); + world.init_resource::(); + + let npc = world + .spawn((crate::npc::Npc, TilePosition::new(5, 6, 0))) + .id(); + let npc_sid = world.resource_mut::().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::(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::(); + world.insert_resource(SimRng::new(42)); + world.init_resource::(); + + let npc = world + .spawn((crate::npc::Npc, TilePosition::new(5, 6, 0))) + .id(); + let npc_sid = world.resource_mut::().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::(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::(); + world.insert_resource(SimRng::new(42)); + world.init_resource::(); + + let npc = world + .spawn((crate::npc::Npc, TilePosition::new(5, 6, 0))) + .id(); + let npc_sid = world.resource_mut::().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::(player).is_none(), + "Marker should be removed after processing" + ); + } +} diff --git a/server/src/simulation/dialogue/mod.rs b/server/src/simulation/dialogue/mod.rs new file mode 100644 index 000000000..8812aa282 --- /dev/null +++ b/server/src/simulation/dialogue/mod.rs @@ -0,0 +1,226 @@ +//! Dialogue system — D-028 four-layer pipeline (#305), split per T-1062. +//! +//! Full pipeline: Talk verb → access tier (from KG RelationshipState) +//! → situations (from game context) → trust tier (from KG) → topic+mood +//! weighted scoring → select line → DialogueResponseBuffer. +//! +//! Submodules (the dispatcher split, T-1062): +//! - [`selection`] — the four-layer filtering/scoring engine + line cooldown +//! - [`response`] — Talk/DialogueResponse assembly systems + knowledge grants +//! - [`confrontation`] — confrontation + walk-away world responses +//! +//! This module keeps the shared dialogue components (session markers and +//! buffers) and re-exports the public API so existing +//! `crate::simulation::dialogue::X` paths keep working. +//! +//! Integration points: +//! - Reads LinePoolIndexResource (server/content/mod.rs) +//! - Reads KnowledgeGraph + EntityRegistry for access/trust derivation +//! - Reads DialogueProfile on NPCs for pool lookup coordinates +//! - Writes DialogueResponseBuffer for snapshot inclusion +//! - Uses SimRng for deterministic weighted random selection + +pub mod confrontation; +pub mod response; +pub mod selection; + +pub use confrontation::{handle_confront, process_confrontation_response, process_walk_away}; +pub use response::{ + handle_dialogue_response, handle_talk, process_dialogue_response, process_talk_interaction, +}; +pub use selection::{ + available_access_tiers, derive_situations, relationship_to_trust, score_line, + select_dialogue_line, DialogueCooldownTracker, +}; + +use bevy_ecs::prelude::*; + +use crate::bridge::types::DialogueResponseEvent; +use crate::simulation::line_pool::Mood; + +// --------------------------------------------------------------------------- +// Components +// --------------------------------------------------------------------------- + +/// Marker: player requested Talk interaction with a target NPC this tick. +/// +/// Set by process_player_input when verb == "Talk". Consumed and removed +/// by process_talk_interaction each tick. +#[derive(Component, Debug)] +pub struct TalkRequest { + pub target: Entity, +} + +/// NPC's dialogue pool coordinates for LinePoolIndex lookup. +/// +/// `location` maps to DialoguePool.location (e.g., "the-terminal"). +/// `role` maps to DialoguePool.role (e.g., "dock-worker"). +/// Attached during content spawn; NPCs without this cannot be talked to. +#[derive(Component, Debug, Clone)] +pub struct DialogueProfile { + pub location: String, + pub role: String, +} + +/// NPC's current mood for Layer 4 scoring. +/// +/// Computed from NPC axes (Tolerance, Contentment, recent events). +/// v0.1: set during spawn or defaults to Comfortable. +#[derive(Component, Debug, Clone)] +pub struct CurrentMood(pub Mood); + +impl Default for CurrentMood { + fn default() -> Self { + Self(Mood::Content) + } +} + +/// Tracks an active dialogue session between the player and an NPC. +/// +/// Set by `process_talk_interaction` when a dialogue line is selected. +/// Cleared by `process_walk_away` (walk-away, D-064) or when dialogue +/// ends naturally (future: multi-line exchanges). +#[derive(Component, Debug)] +pub struct ActiveDialogue { + pub target: Entity, + pub interaction_type: crate::knowledge::events::InteractionType, + pub started_tick: u64, +} + +/// Marker: player submitted a dialogue response this tick (#539). +/// +/// Set by process_player_input when PlayerAction::DialogueResponse is received. +/// Consumed and removed by process_dialogue_response each tick. +#[derive(Component, Debug)] +pub struct DialogueResponseRequest { + pub target: Entity, + pub response_id: String, +} + +/// Marker: player walked away during active dialogue this tick (D-064). +/// +/// Set by process_player_input when PlayerAction::WalkAway is received. +/// Consumed by process_walk_away each tick. +#[derive(Component, Debug)] +pub struct WalkAwayRequest; + +/// Marker: player delivered a confrontation this tick (#520, D-063). +/// +/// Set by process_player_input when Interact{verb: "Confront"} is received. +/// Consumed by process_confrontation_response each tick. Triggers: +/// 1. Target NPC shifts to AnimationTier::Tier2 (D-047) +/// 2. Observer KG relationship state decremented (D-033 color fade) +/// 3. Monologue spike event emitted +#[derive(Component, Debug)] +pub struct ConfrontationDelivered { + pub target: Entity, +} + +/// Buffer holding the dialogue response for snapshot inclusion. +/// +/// Consumed once per snapshot via `take()`. Cleared at snapshot build time. +#[derive(Component, Debug, Default)] +pub struct DialogueResponseBuffer { + pub(crate) response: Option, +} + +impl DialogueResponseBuffer { + /// Drain and return the dialogue response, leaving the buffer empty. + pub fn take(&mut self) -> Option { + self.response.take() + } +} + +/// Shared world/pool builders for the dialogue submodule tests. +#[cfg(test)] +pub(crate) mod test_support { + use bevy_ecs::prelude::*; + + use crate::knowledge::content_registry::ContentEntityRegistry; + use crate::knowledge::registry::EntityRegistry; + use crate::npc::relationships::TrustEventQueue; + use crate::simulation::line_pool::Mood; + use crate::simulation::line_pool::{ + AccessTier, IndexedDialogueLine, IndexedDialoguePool, LinePoolIndex, Situation, Topic, + TrustTier, + }; + use crate::simulation::rng::SimRng; + use crate::simulation::time::SimulationTime; + + pub(crate) fn setup_dialogue_world() -> World { + let mut world = World::new(); + world.init_resource::(); + world.insert_resource(SimRng::new(42)); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world + } + + pub(crate) fn build_test_line_pool() -> LinePoolIndex { + let mut index = LinePoolIndex::default(); + let lines = vec![ + IndexedDialogueLine { + id: "test_d_001".to_string(), + text: "Welcome to the terminal.".to_string(), + role: "dock-worker".to_string(), + access: vec![AccessTier::Public], + trust: TrustTier::Surface, + situation: vec![Situation::Routine, Situation::Social], + topic: vec![], + mood: vec![], + tags: vec![], + knowledge_grant: None, + }, + IndexedDialogueLine { + id: "test_d_002".to_string(), + text: "I've seen some strange cargo lately.".to_string(), + role: "dock-worker".to_string(), + access: vec![AccessTier::Peer], + trust: TrustTier::Surface, + situation: vec![Situation::Routine, Situation::Investigation], + topic: vec![Topic::Cargo], + mood: vec![Mood::Suspicious], + tags: vec![], + knowledge_grant: None, + }, + IndexedDialogueLine { + id: "test_d_003".to_string(), + text: "The night shifts have been quiet.".to_string(), + role: "dock-worker".to_string(), + access: vec![AccessTier::Public], + trust: TrustTier::Surface, + situation: vec![Situation::NightShift], + topic: vec![Topic::Routine], + mood: vec![Mood::Content], + tags: vec![], + knowledge_grant: None, + }, + IndexedDialogueLine { + id: "test_d_004".to_string(), + text: "There's something I need to tell you about the manifests.".to_string(), + role: "dock-worker".to_string(), + access: vec![AccessTier::Insider], + trust: TrustTier::Real, + situation: vec![Situation::Investigation], + topic: vec![Topic::Cargo, Topic::Investigation], + mood: vec![Mood::Suspicious], + tags: vec![], + knowledge_grant: None, + }, + ]; + + let pool = IndexedDialoguePool { + location: "the-terminal".to_string(), + role: "dock-worker".to_string(), + lines, + }; + index.dialogue.insert( + ("the-terminal".to_string(), "dock-worker".to_string()), + pool, + ); + index + } +} diff --git a/server/src/simulation/dialogue/response.rs b/server/src/simulation/dialogue/response.rs new file mode 100644 index 000000000..dd01e1f0e --- /dev/null +++ b/server/src/simulation/dialogue/response.rs @@ -0,0 +1,1473 @@ +//! Response-assembly — dialogue line delivery systems (#305, #539). +//! +//! Runs the selection pipeline for Talk requests and dialogue-option +//! follow-ups, assembles the `DialogueResponseEvent` (speaker identity is +//! KG-filtered per D-010), fires knowledge grants (D-079), and maintains +//! `ActiveDialogue` session state. Also owns the input handlers that stage +//! `TalkRequest` / `DialogueResponseRequest` markers (T-1062). + +use bevy_ecs::prelude::*; + +use crate::bridge::types::{DialogueResponseEvent, RelationshipState}; +use crate::knowledge::content_registry::ContentEntityRegistry; +use crate::knowledge::events::{ProcessedEntityGrant, ProcessedFactGrant, ProcessedKnowledgeGrant}; +use crate::knowledge::types::{FactId, KnowledgeConfidence, KnowledgeSource, StableId}; +use crate::knowledge::{EntityRegistry, KnowledgeGraph}; +use crate::npc::interaction::InteractionMemory; +use crate::npc::relationships::{TrustEvent, TrustEventQueue}; +use crate::simulation::knowledge_grant::KnowledgeGrant; +use crate::simulation::line_pool::LinePoolIndexResource; +use crate::simulation::movement::PlayerCharacter; +use crate::simulation::npc_components::{display_label_for_role, NpcColorIndex, NpcName}; +use crate::simulation::rng::SimRng; +use crate::simulation::time::SimulationTime; +use crate::storyteller::EngagementRecord; + +use super::selection::run_dialogue_pipeline; +use super::{ + ActiveDialogue, CurrentMood, DialogueCooldownTracker, DialogueProfile, DialogueResponseBuffer, + DialogueResponseRequest, TalkRequest, +}; + +// --------------------------------------------------------------------------- +// 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, + line_pool: Option>, + registry: Res, + content_registry: Res, + mut rng: ResMut, + mut event_queue: ResMut, + mut trust_queue: ResMut, + mut player_query: Query< + ( + Entity, + &KnowledgeGraph, + &TalkRequest, + &mut DialogueResponseBuffer, + &mut DialogueCooldownTracker, + Option<&ActiveDialogue>, + ), + With, + >, + mut npc_query: Query<( + &DialogueProfile, + Option<&CurrentMood>, + Option<&mut InteractionMemory>, + Option<&NpcName>, + Option<&NpcColorIndex>, + Option<&KnowledgeGraph>, + )>, + mut engagement_query: Query<&mut EngagementRecord>, +) { + let Some(line_pool) = line_pool else { + return; + }; + + let Ok(( + player_entity, + observer_kg, + talk_request, + mut response_buffer, + mut cooldown, + active_dialogue_opt, + )) = player_query.single_mut() + else { + return; + }; + + let target = talk_request.target; + + // Look up NPC dialogue profile, mood, interaction history, name, color, and KG (#325, D-079) + let Ok((profile, mood_opt, mut interaction_mem_opt, npc_name_opt, color_idx_opt, npc_kg_opt)) = + npc_query.get_mut(target) + else { + tracing::debug!( + "Talk target {:?} has no DialogueProfile — cannot select dialogue", + target + ); + commands.entity(player_entity).remove::(); + return; + }; + + // Resolve target's StableId for KG lookup + let target_stable = registry.to_stable(target); + let relationship = target_stable + .map(|sid| observer_kg.relationship_with(&sid)) + .unwrap_or(RelationshipState::Unknown); + + // Default to Suspects for unknown NPCs — no KG entry means no basis for + // deeper dialogue, which correctly yields Surface trust tier. + let confidence = target_stable + .and_then(|sid| observer_kg.confidence_of(&sid)) + .unwrap_or(crate::knowledge::types::KnowledgeConfidence::Suspects); + + let npc_mood = mood_opt.map(|m| m.0); + + // Prune old cooldown entries + cooldown.prune(time.tick); + + let selected = run_dialogue_pipeline( + &line_pool.0, + &profile.location, + &profile.role, + relationship, + confidence, + time.day_phase(), + interaction_mem_opt.as_deref(), + npc_mood, + &cooldown, + time.tick, + &mut rng.rng, + ); + + if let Some(line) = selected { + // Resolve wire ID for the speaker — skip if target not in registry + let Some(speaker_stable) = registry.to_stable(target) else { + tracing::warn!( + "Talk target {:?} not in EntityRegistry — cannot resolve wire ID, skipping dialogue", + target + ); + commands.entity(player_entity).remove::(); + return; + }; + + // Resolve speaker display name: use real name if player KG has "name" + // attribute for the target, otherwise fall back to role label. + let speaker_display_name = { + let known = observer_kg + .entity_knowledge(&speaker_stable) + .map(|e| e.known_attributes.contains_key("name")) + .unwrap_or(false); + if known { + npc_name_opt + .map(|n| n.0.clone()) + .unwrap_or_else(|| "Unknown".to_string()) + } else { + display_label_for_role(&profile.role) + } + }; + let speaker_color = color_idx_opt.map(|c| c.0).unwrap_or(0u8); + + response_buffer.response = Some(DialogueResponseEvent { + line_id: line.id.clone(), + text: line.text.clone(), + speaker_entity_id: speaker_stable.0, + speaker_color_index: speaker_color, + speaker_name: speaker_display_name, + }); + + cooldown.record(&line.id, time.tick); + + // Knowledge grant (D-079): fire at line selection time, server-authoritative. + if let Some(grant) = &line.knowledge_grant { + emit_knowledge_grant( + grant, + player_entity, + speaker_stable, + &content_registry, + npc_kg_opt, + time.tick, + &mut event_queue, + ); + } + + // Emit IncompleteInteraction if overwriting an existing dialogue session + if let Some(prev) = active_dialogue_opt { + event_queue.push(crate::knowledge::KnowledgeEvent { + observer: player_entity, + tick: time.tick, + event_type: crate::knowledge::KnowledgeEventType::IncompleteInteraction { + target: prev.target, + interaction_type: prev.interaction_type, + }, + }); + tracing::debug!( + "Overwriting active {:?} dialogue — emitted IncompleteInteraction", + prev.interaction_type, + ); + } + + // Track active dialogue for walk-away detection (D-064) + commands.entity(player_entity).insert(ActiveDialogue { + target, + interaction_type: crate::knowledge::events::InteractionType::Talk, + started_tick: time.tick, + }); + + // Engagement tracking (#570): increment conversation count for this NPC + if let Ok(mut record) = engagement_query.get_mut(target) { + record.conversation_count += 1; + } + + // Trust progression (#324): successful talk warms the NPC + trust_queue.push(TrustEvent::TalkCompleted { + npc: target, + player: player_entity, + }); + + // Interaction tracking (#325): record completed talk + if let Some(ref mut mem) = interaction_mem_opt { + mem.record_talk(time.tick); + } + + tracing::debug!( + "Dialogue selected: id={}, speaker={}, location={}, role={}", + line.id, + speaker_stable.0, + profile.location, + profile.role, + ); + } else { + tracing::debug!( + "All dialogue lines on cooldown for {}/{}", + profile.location, + profile.role, + ); + } + + // Remove the TalkRequest marker — processed this tick + commands.entity(player_entity).remove::(); +} + +// --------------------------------------------------------------------------- +// Knowledge grant helper (D-079) +// --------------------------------------------------------------------------- + +/// Emit a KnowledgeGranted event for a dialogue line's knowledge_grant field. +/// +/// Called at line selection time (server-authoritative, tick-stamped). +/// Source is always `ToldBy { source_id: speaker_stable, tick }`. +/// +/// Fact grants: dropped with tracing::warn! if the granting NPC's KG +/// does not contain the fact (D-079 runtime guardrail). +/// Entity grants: no guardrail — always emitted if entity_ref resolves. +#[allow(clippy::too_many_arguments)] +fn emit_knowledge_grant( + grant: &KnowledgeGrant, + player_entity: Entity, + speaker_stable: StableId, + content_registry: &ContentEntityRegistry, + npc_kg_opt: Option<&KnowledgeGraph>, + tick: u64, + event_queue: &mut crate::knowledge::KnowledgeEventQueue, +) { + let source = KnowledgeSource::ToldBy { + source_id: speaker_stable, + tick, + }; + + match grant { + KnowledgeGrant::Fact { + fact_id, + confidence, + } => { + let conf = match KnowledgeConfidence::try_from(confidence.as_str()) { + Ok(c) => c, + Err(e) => { + tracing::warn!("KnowledgeGrant confidence parse error: {}", e); + return; + } + }; + let fid = FactId(fact_id.clone()); + // Guardrail: NPC must know this fact to grant it (D-079). + let npc_knows = npc_kg_opt.map(|kg| kg.knows_fact(&fid)).unwrap_or(false); + if !npc_knows { + tracing::warn!( + "KnowledgeGrant dropped: NPC {:?} does not know fact '{}' — grant guardrail", + speaker_stable, + fact_id + ); + return; + } + event_queue.push(crate::knowledge::KnowledgeEvent { + observer: player_entity, + tick, + event_type: crate::knowledge::KnowledgeEventType::KnowledgeGranted { + grant: ProcessedKnowledgeGrant::Fact(ProcessedFactGrant { + fact_id: fid, + confidence: conf, + }), + source, + }, + }); + } + // Entity grants have no "NPC knows this entity" guardrail (unlike Fact + // grants above). This is intentional per D-079: entity grants introduce + // NEW knowledge about an entity the NPC is talking about — the NPC + // doesn't need to "know" the entity in their own KG to reference it + // in dialogue. The entity_ref resolves via ContentEntityRegistry, not KG. + KnowledgeGrant::Entity { + entity_ref, + attributes, + confidence, + } => { + let conf = match KnowledgeConfidence::try_from(confidence.as_str()) { + Ok(c) => c, + Err(e) => { + tracing::warn!("KnowledgeGrant confidence parse error: {}", e); + return; + } + }; + let Some(target_id) = content_registry.resolve(entity_ref) else { + tracing::warn!( + "KnowledgeGrant::Entity dropped: entity_ref '{}' not in ContentEntityRegistry", + entity_ref + ); + return; + }; + event_queue.push(crate::knowledge::KnowledgeEvent { + observer: player_entity, + tick, + event_type: crate::knowledge::KnowledgeEventType::KnowledgeGranted { + grant: ProcessedKnowledgeGrant::Entity(ProcessedEntityGrant { + target_id, + attributes: attributes.clone(), + confidence: conf, + }), + source, + }, + }); + } + } +} + +// --------------------------------------------------------------------------- +// System: process_dialogue_response (#539) +// --------------------------------------------------------------------------- + +/// Process DialogueResponse actions through the full D-028 four-layer pipeline (#539). +/// +/// Called when the player picks a dialogue option. Re-runs the same pipeline as +/// process_talk_interaction to select a follow-up line. Clears ActiveDialogue if +/// no candidates remain after cooldown filtering (conversation ends naturally). +/// +/// The response_id is the line_id that was shown; it's already on cooldown from +/// process_talk_interaction, ensuring the follow-up is a different line. +/// +/// System ordering: after process_player_input, after process_talk_interaction, +/// before compute_observer_snapshot. +#[tracing::instrument(level = "debug", skip_all)] +#[allow(clippy::type_complexity, clippy::too_many_arguments)] +pub fn process_dialogue_response( + mut commands: Commands, + time: Res, + line_pool: Option>, + registry: Res, + mut rng: ResMut, + mut trust_queue: ResMut, + mut player_query: Query< + ( + Entity, + &KnowledgeGraph, + &DialogueResponseRequest, + &mut DialogueResponseBuffer, + &mut DialogueCooldownTracker, + Option<&ActiveDialogue>, + ), + With, + >, + mut npc_query: Query<( + &DialogueProfile, + Option<&CurrentMood>, + Option<&mut InteractionMemory>, + Option<&NpcName>, + Option<&NpcColorIndex>, + )>, +) { + let Ok(( + player_entity, + observer_kg, + response_req, + mut response_buffer, + mut cooldown, + active_dialogue_opt, + )) = player_query.single_mut() + else { + return; + }; + + let target = response_req.target; + let response_id = response_req.response_id.clone(); + + // Always remove the marker regardless of outcome — request is consumed this tick. + commands + .entity(player_entity) + .remove::(); + + let Some(line_pool) = line_pool else { return }; + + // Look up NPC dialogue profile, mood, interaction history, name, and color + let Ok((profile, mood_opt, mut interaction_mem_opt, npc_name_opt, color_idx_opt)) = + npc_query.get_mut(target) + else { + tracing::debug!( + "DialogueResponse target {:?} has no DialogueProfile — cannot select follow-up", + target + ); + return; + }; + + // Resolve target's StableId for KG lookup + let target_stable = registry.to_stable(target); + let relationship = target_stable + .map(|sid| observer_kg.relationship_with(&sid)) + .unwrap_or(RelationshipState::Unknown); + + let confidence = target_stable + .and_then(|sid| observer_kg.confidence_of(&sid)) + .unwrap_or(crate::knowledge::types::KnowledgeConfidence::Suspects); + + let npc_mood = mood_opt.map(|m| m.0); + + // Prune old cooldown entries + cooldown.prune(time.tick); + + let selected = run_dialogue_pipeline( + &line_pool.0, + &profile.location, + &profile.role, + relationship, + confidence, + time.day_phase(), + interaction_mem_opt.as_deref(), + npc_mood, + &cooldown, + time.tick, + &mut rng.rng, + ); + + if let Some(line) = selected { + let Some(speaker_stable) = registry.to_stable(target) else { + tracing::warn!( + "DialogueResponse target {:?} not in EntityRegistry — skipping follow-up", + target + ); + return; + }; + + let speaker_display_name = { + let known = observer_kg + .entity_knowledge(&speaker_stable) + .map(|e| e.known_attributes.contains_key("name")) + .unwrap_or(false); + if known { + npc_name_opt + .map(|n| n.0.clone()) + .unwrap_or_else(|| "Unknown".to_string()) + } else { + display_label_for_role(&profile.role) + } + }; + let speaker_color = color_idx_opt.map(|c| c.0).unwrap_or(0u8); + + response_buffer.response = Some(DialogueResponseEvent { + line_id: line.id.clone(), + text: line.text.clone(), + speaker_entity_id: speaker_stable.0, + speaker_color_index: speaker_color, + speaker_name: speaker_display_name, + }); + + cooldown.record(&line.id, time.tick); + + // Update ActiveDialogue with current tick — prevents stale started_tick + commands.entity(player_entity).insert(ActiveDialogue { + target, + interaction_type: crate::knowledge::events::InteractionType::Talk, + started_tick: time.tick, + }); + + // Trust progression: follow-up dialogue warms the NPC + trust_queue.push(TrustEvent::TalkCompleted { + npc: target, + player: player_entity, + }); + + // Interaction tracking: record follow-up as a talk event + if let Some(ref mut mem) = interaction_mem_opt { + mem.record_talk(time.tick); + } + + tracing::debug!( + "Follow-up selected: id={}, response_id={}, location={}, role={}", + line.id, + response_id, + profile.location, + profile.role, + ); + } else { + // No follow-up lines — conversation ends naturally (D-062: invisible locks) + tracing::debug!( + "No follow-up lines for response_id={} at {}/{} — ending conversation", + response_id, + profile.location, + profile.role, + ); + // Clear active dialogue state + if active_dialogue_opt.is_some() { + commands.entity(player_entity).remove::(); + } + } +} + +// --------------------------------------------------------------------------- +// Input handlers (dispatched from process_player_input, T-1062) +// --------------------------------------------------------------------------- + +/// Handle Talk verb: set TalkRequest marker on the player entity for the target NPC. +/// The actual dialogue pipeline runs in process_talk_interaction. +/// Server-side range check: Talk requires CLOSE_RANGE (same as interaction system). +pub fn handle_talk( + commands: &mut Commands, + registry: &EntityRegistry, + player_query: &crate::simulation::input::PlayerInputQuery, + all_positions: &Query<&crate::simulation::movement::TilePosition>, + 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_pos, _, _)) = 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; + }; + + // Server-side range check: reject Talk if target is beyond close range + if let Ok(target_pos) = all_positions.get(target_entity) { + let distance = player_pos + .manhattan_distance(target_pos) + .unwrap_or(u32::MAX); + if distance > crate::simulation::interaction::CLOSE_RANGE { + tracing::info!( + target_id, + distance, + "Talk: target out of range (max {})", + crate::simulation::interaction::CLOSE_RANGE, + ); + return; + } + } + + commands.entity(player_entity).insert(TalkRequest { + target: target_entity, + }); + + tracing::debug!(target_id, "Talk: TalkRequest marker set on player"); +} + +/// Handle DialogueResponse action: set DialogueResponseRequest marker (#539). +/// The follow-up dialogue pipeline runs in process_dialogue_response. +/// +/// Range check: same CLOSE_RANGE as Talk/Confront (D-010 info boundary). The player +/// must still be near the NPC to continue a conversation — walking away mid-dialogue +/// should not allow remote follow-ups. +pub fn handle_dialogue_response( + commands: &mut Commands, + registry: &EntityRegistry, + player_query: &crate::simulation::input::PlayerInputQuery, + all_positions: &Query<&crate::simulation::movement::TilePosition>, + target_entity_id: u64, + response_id: &str, +) { + let Ok((player_entity, player_pos, _, _)) = player_query.single() else { + return; + }; + + let target_stable = StableId(target_entity_id); + let Some(target_entity) = registry.to_entity(&target_stable) else { + tracing::warn!( + target_entity_id, + "DialogueResponse: target entity not in registry" + ); + return; + }; + + // Server-side range check: reject DialogueResponse if target moved out of range + if let Ok(target_pos) = all_positions.get(target_entity) { + let distance = player_pos + .manhattan_distance(target_pos) + .unwrap_or(u32::MAX); + if distance > crate::simulation::interaction::CLOSE_RANGE { + tracing::info!( + target_entity_id, + distance, + "DialogueResponse: target out of range (max {})", + crate::simulation::interaction::CLOSE_RANGE, + ); + return; + } + } + + commands + .entity(player_entity) + .insert(DialogueResponseRequest { + target: target_entity, + response_id: response_id.to_string(), + }); + + tracing::debug!( + target_entity_id, + response_id, + "DialogueResponse: marker set on player" + ); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::npc::Npc; + use crate::simulation::dialogue::selection::LINE_COOLDOWN_TICKS; + use crate::simulation::dialogue::test_support::{build_test_line_pool, setup_dialogue_world}; + use crate::simulation::line_pool::{ + AccessTier, IndexedDialogueLine, IndexedDialoguePool, LinePoolIndex, Mood, Situation, + TrustTier, + }; + use crate::simulation::movement::TilePosition; + + #[test] + fn process_talk_selects_line_for_unknown_relationship() { + let mut world = setup_dialogue_world(); + let index = build_test_line_pool(); + world.insert_resource(LinePoolIndexResource(index)); + + // Spawn NPC with DialogueProfile + let npc = world + .spawn(( + Npc, + TilePosition::new(5, 5, 0), + DialogueProfile { + location: "the-terminal".to_string(), + role: "dock-worker".to_string(), + }, + CurrentMood(Mood::Content), + )) + .id(); + world.resource_mut::().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" + ); + } + // process_talk_interaction sets ActiveDialogue (D-064 walk-away precondition) + + #[test] + fn talk_sets_active_dialogue() { + let mut world = setup_dialogue_world(); + let index = build_test_line_pool(); + world.insert_resource(LinePoolIndexResource(index)); + + let npc = world + .spawn(( + Npc, + TilePosition::new(5, 5, 0), + DialogueProfile { + location: "the-terminal".to_string(), + role: "dock-worker".to_string(), + }, + CurrentMood(Mood::Content), + )) + .id(); + world.resource_mut::().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); + } + + // -- Trust-gated gossip tests (#171, D-075) ---------------------------------- + + /// Build a pool with a Surface-tier Public line and a Secret-tier Insider line. + /// Used to verify that KnowledgeConfidence gates Secret access correctly. + fn build_trust_tier_pool() -> LinePoolIndex { + let mut index = LinePoolIndex::default(); + let pool = IndexedDialoguePool { + location: "the-terminal".to_string(), + role: "dock-worker".to_string(), + lines: vec![ + IndexedDialogueLine { + id: "trust_surface_001".to_string(), + text: "Just another day at the terminal.".to_string(), + role: "dock-worker".to_string(), + access: vec![AccessTier::Public], + trust: TrustTier::Surface, + situation: vec![Situation::Routine], + topic: vec![], + mood: vec![], + tags: vec![], + knowledge_grant: None, + }, + IndexedDialogueLine { + id: "trust_secret_001".to_string(), + text: "The manifests don't match. You didn't hear that from me.".to_string(), + role: "dock-worker".to_string(), + access: vec![AccessTier::Insider], // requires Friendly relationship + trust: TrustTier::Secret, // requires Friendly + KnowsDetails+ + situation: vec![Situation::Routine], + topic: vec![], + mood: vec![], + tags: vec![], + knowledge_grant: None, + }, + ], + }; + index.dialogue.insert( + ("the-terminal".to_string(), "dock-worker".to_string()), + pool, + ); + index + } + + #[test] + fn trust_gated_knows_details_can_get_secret_tier_line() { + // D-075: Friendly + KnowsDetails → Secret trust tier → secret lines available. + // Spec ref: #171, D-075 "Secret: Friendly + KnowsDetails+" + use crate::knowledge::types::KnowledgeConfidence; + + let mut world = setup_dialogue_world(); + world.insert_resource(LinePoolIndexResource(build_trust_tier_pool())); + + let npc = world + .spawn(( + Npc, + TilePosition::new(5, 5, 0), + DialogueProfile { + location: "the-terminal".to_string(), + role: "dock-worker".to_string(), + }, + )) + .id(); + let npc_sid = world.resource_mut::().register(npc); + + // observe_entity → Direct; observe_entity_leaving_los → KnowsDetails + let mut kg = KnowledgeGraph::new(); + kg.observe_entity(npc_sid, TilePosition::new(5, 5, 0), 0); + kg.observe_entity_leaving_los(&npc_sid, 1); + kg.set_relationship(&npc_sid, RelationshipState::Friendly); + + assert_eq!( + kg.confidence_of(&npc_sid), + Some(KnowledgeConfidence::KnowsDetails), + "precondition: KG must have KnowsDetails confidence" + ); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(5, 6, 0), + kg, + TalkRequest { target: npc }, + DialogueResponseBuffer::default(), + DialogueCooldownTracker::default(), + )) + .id(); + world.resource_mut::().register(player); + + // Run with multiple seeds — Secret-tier line must appear at least once + let mut saw_secret_line = false; + for seed in 0u64..50 { + world + .get_mut::(player) + .unwrap() + .response = None; + world.entity_mut(player).insert(TalkRequest { target: npc }); + // Reset cooldown so the pool is not exhausted between iterations + world + .entity_mut(player) + .insert(DialogueCooldownTracker::default()); + world.insert_resource(SimRng::new(seed)); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_talk_interaction); + schedule.run(&mut world); + world.flush(); + + if let Some(resp) = &world + .get::(player) + .unwrap() + .response + { + if resp.line_id == "trust_secret_001" { + saw_secret_line = true; + break; + } + } + } + + assert!( + saw_secret_line, + "Friendly + KnowsDetails player should be able to access Secret-tier lines (D-075 #171)" + ); + } + + #[test] + fn trust_gated_suspects_only_gets_surface_tier() { + // D-075: Friendly + Suspects → Surface trust tier → Secret lines invisible. + // Spec ref: #171, D-075 "Surface: any relationship + any confidence" + use crate::knowledge::types::KnowledgeConfidence; + + let mut world = setup_dialogue_world(); + world.insert_resource(LinePoolIndexResource(build_trust_tier_pool())); + + let npc = world + .spawn(( + Npc, + TilePosition::new(5, 5, 0), + DialogueProfile { + location: "the-terminal".to_string(), + role: "dock-worker".to_string(), + }, + )) + .id(); + let npc_sid = world.resource_mut::().register(npc); + + // Friendly relationship but Suspects confidence → Surface trust only + let mut kg = KnowledgeGraph::new(); + kg.observe_entity(npc_sid, TilePosition::new(5, 5, 0), 0); + kg.set_relationship(&npc_sid, RelationshipState::Friendly); + // Patch down to Suspects (observe_entity sets Direct — too high) + kg.entities.get_mut(&npc_sid).unwrap().confidence = KnowledgeConfidence::Suspects; + + assert_eq!( + kg.confidence_of(&npc_sid), + Some(KnowledgeConfidence::Suspects), + "precondition: KG must have Suspects confidence" + ); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(5, 6, 0), + kg, + TalkRequest { target: npc }, + DialogueResponseBuffer::default(), + DialogueCooldownTracker::default(), + )) + .id(); + world.resource_mut::().register(player); + + let mut saw_secret = false; + for seed in 0u64..50 { + world + .get_mut::(player) + .unwrap() + .response = None; + world.entity_mut(player).insert(TalkRequest { target: npc }); + world + .entity_mut(player) + .insert(DialogueCooldownTracker::default()); + world.insert_resource(SimRng::new(seed)); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_talk_interaction); + schedule.run(&mut world); + world.flush(); + + if let Some(resp) = &world + .get::(player) + .unwrap() + .response + { + if resp.line_id == "trust_secret_001" { + saw_secret = true; + break; + } + } + } + + assert!( + !saw_secret, + "Friendly + Suspects player must NOT access Secret-tier lines (D-075 #171)" + ); + } + + // -- Line variety regression test (#338, D-028) ------------------------------ + + /// Pool with 12 distinct Public/Surface/Routine lines for variety testing. + fn build_variety_pool() -> LinePoolIndex { + let mut index = LinePoolIndex::default(); + let lines: Vec = (1u32..=12) + .map(|n| IndexedDialogueLine { + id: format!("variety_{:03}", n), + text: format!("Line number {}.", n), + role: "dock-worker".to_string(), + access: vec![AccessTier::Public], + trust: TrustTier::Surface, + situation: vec![Situation::Routine], + topic: vec![], + mood: vec![], + tags: vec![], + knowledge_grant: None, + }) + .collect(); + let pool = IndexedDialoguePool { + location: "the-terminal".to_string(), + role: "dock-worker".to_string(), + lines, + }; + index.dialogue.insert( + ("the-terminal".to_string(), "dock-worker".to_string()), + pool, + ); + index + } + + #[test] + fn line_variety_no_repeats_within_cooldown_window() { + // #338, D-028: No line_id should repeat within LINE_COOLDOWN_TICKS. + // Regression: Talk 10 times at tick 0 (well within the 600-tick window). + // Each selected line must be distinct — cooldown tracker enforces this. + let mut world = setup_dialogue_world(); + world.insert_resource(LinePoolIndexResource(build_variety_pool())); + + let npc = world + .spawn(( + Npc, + TilePosition::new(5, 5, 0), + DialogueProfile { + location: "the-terminal".to_string(), + role: "dock-worker".to_string(), + }, + )) + .id(); + world.resource_mut::().register(npc); + + // Unknown player — Public access only; tick stays at 0 throughout + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(5, 6, 0), + KnowledgeGraph::new(), + TalkRequest { target: npc }, + DialogueResponseBuffer::default(), + DialogueCooldownTracker::default(), + )) + .id(); + world.resource_mut::().register(player); + + let mut seen_ids: Vec = Vec::new(); + + for seed in 0u64..10 { + world + .get_mut::(player) + .unwrap() + .response = None; + world.entity_mut(player).insert(TalkRequest { target: npc }); + // NOTE: SimulationTime is NOT advanced — all 10 talks happen within tick 0 + world.insert_resource(SimRng::new(seed)); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_talk_interaction); + schedule.run(&mut world); + world.flush(); + + if let Some(resp) = &world + .get::(player) + .unwrap() + .response + { + let id = resp.line_id.clone(); + assert!( + !seen_ids.contains(&id), + "Line '{}' was repeated within the {}-tick cooldown window (iteration {}). \ + Cooldown tracker must prevent repeats. (#338)", + id, + LINE_COOLDOWN_TICKS, + seed, + ); + seen_ids.push(id); + } + } + + assert_eq!( + seen_ids.len(), + 10, + "Should have selected 10 distinct lines across 10 consecutive Talks (#338)" + ); + } + + // === DialogueResponse Tests (#539) === + + #[test] + fn process_dialogue_response_selects_follow_up_line() { + let mut world = setup_dialogue_world(); + let index = build_test_line_pool(); + world.insert_resource(LinePoolIndexResource(index)); + + let npc = world + .spawn(( + Npc, + TilePosition::new(5, 5, 0), + DialogueProfile { + location: "the-terminal".to_string(), + role: "dock-worker".to_string(), + }, + CurrentMood(Mood::Content), + )) + .id(); + world.resource_mut::().register(npc); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(5, 6, 0), + KnowledgeGraph::new(), + // Simulate that first line was already selected (on cooldown) + DialogueResponseRequest { + target: npc, + response_id: "test_d_001".to_string(), + }, + DialogueResponseBuffer::default(), + DialogueCooldownTracker::default(), + )) + .id(); + world.resource_mut::().register(player); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_dialogue_response); + schedule.run(&mut world); + world.flush(); + + // Should have consumed the marker + assert!( + world.get::(player).is_none(), + "DialogueResponseRequest should be consumed" + ); + } + + #[test] + fn process_dialogue_response_clears_active_dialogue_when_no_lines() { + let mut world = setup_dialogue_world(); + + // Build pool with only one line — it will be on cooldown + let mut index = LinePoolIndex::default(); + let pool = IndexedDialoguePool { + location: "test".to_string(), + role: "worker".to_string(), + lines: vec![IndexedDialogueLine { + id: "only_line".to_string(), + text: "Only thing I can say.".to_string(), + role: "worker".to_string(), + access: vec![AccessTier::Public], + trust: TrustTier::Surface, + situation: vec![Situation::Routine], + topic: vec![], + mood: vec![], + tags: vec![], + knowledge_grant: None, + }], + }; + index + .dialogue + .insert(("test".to_string(), "worker".to_string()), pool); + world.insert_resource(LinePoolIndexResource(index)); + + let npc = world + .spawn(( + Npc, + TilePosition::new(5, 5, 0), + DialogueProfile { + location: "test".to_string(), + role: "worker".to_string(), + }, + )) + .id(); + world.resource_mut::().register(npc); + + // Set the only line on cooldown — so no follow-up can be selected + let mut cooldown = DialogueCooldownTracker::default(); + cooldown.record("only_line", 0); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(5, 6, 0), + KnowledgeGraph::new(), + DialogueResponseRequest { + target: npc, + response_id: "only_line".to_string(), + }, + DialogueResponseBuffer::default(), + cooldown, + ActiveDialogue { + target: npc, + interaction_type: crate::knowledge::events::InteractionType::Talk, + started_tick: 0, + }, + )) + .id(); + world.resource_mut::().register(player); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_dialogue_response); + schedule.run(&mut world); + world.flush(); + + // No follow-up lines — ActiveDialogue should be cleared + assert!( + world.get::(player).is_none(), + "ActiveDialogue should be cleared when no follow-up lines available" + ); + assert!( + world.get::(player).is_none(), + "DialogueResponseRequest should be consumed" + ); + // Buffer should remain empty + let buffer = world.get::(player).unwrap(); + assert!( + buffer.response.is_none(), + "No response when all lines on cooldown" + ); + } + + #[test] + fn process_dialogue_response_no_profile_is_noop() { + let mut world = setup_dialogue_world(); + + // NPC without DialogueProfile + let npc = world.spawn((Npc, TilePosition::new(5, 5, 0))).id(); + world.resource_mut::().register(npc); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(5, 6, 0), + KnowledgeGraph::new(), + DialogueResponseRequest { + target: npc, + response_id: "some_line".to_string(), + }, + DialogueResponseBuffer::default(), + DialogueCooldownTracker::default(), + )) + .id(); + world.resource_mut::().register(player); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_dialogue_response); + schedule.run(&mut world); + world.flush(); + + assert!( + world.get::(player).is_none(), + "DialogueResponseRequest consumed even with no profile" + ); + let buffer = world.get::(player).unwrap(); + assert!( + buffer.response.is_none(), + "No response for NPC without DialogueProfile" + ); + } +} diff --git a/server/src/simulation/dialogue/selection.rs b/server/src/simulation/dialogue/selection.rs new file mode 100644 index 000000000..63cfa4743 --- /dev/null +++ b/server/src/simulation/dialogue/selection.rs @@ -0,0 +1,631 @@ +//! Talk-selection — the D-028 four-layer dialogue filtering engine (#305). +//! +//! Layers 1-3 (access, situation, trust) are delegated to +//! `LinePoolIndex::query_dialogue`. Layer 4 (topic + mood weighted selection) +//! is implemented here, together with the per-line cooldown tracker (#338) +//! and the shared `run_dialogue_pipeline` entry point used by the +//! response-assembly systems. + +use std::collections::BTreeSet; + +use bevy_ecs::prelude::*; +use rand::Rng; + +use crate::bridge::types::RelationshipState; +use crate::npc::interaction::InteractionMemory; +use crate::simulation::line_pool::{ + AccessTier, IndexedDialogueLine, Mood, Situation, Topic, TrustTier, +}; + +/// Cooldown ticks before the same dialogue line can be selected again. +/// At 10 ticks/game-minute, 600 ticks = 1 game-hour. +pub(crate) const LINE_COOLDOWN_TICKS: u64 = 600; + +/// Per-player cooldown tracker for dialogue line variety (#338). +/// +/// Prevents the same line from being selected within LINE_COOLDOWN_TICKS. +/// Entries older than the cooldown window are pruned each query. +/// +/// Design note: this is player-global, not per-NPC. Line IDs are NPC-scoped +/// per D-035 (`{template}_{d|m|e}_{###}`), so cross-NPC collisions don't occur +/// in practice. If a future sprint introduces shared line IDs across roles, +/// the key should become `(StableId, line_id)` instead. +#[derive(Component, Debug, Default)] +pub struct DialogueCooldownTracker { + used: std::collections::BTreeMap, // 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); + } +} + +// --------------------------------------------------------------------------- +// 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 + 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 { + 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 — score_line always returns >= 1 (base score), + // so total_weight > 0 is guaranteed when scored is non-empty. + let total_weight: u32 = scored.iter().map(|(_, s)| s).sum(); + + let mut roll = rng.random_range(0..total_weight); + for (line, weight) in &scored { + if roll < *weight { + return Some(line); + } + roll -= weight; + } + + unreachable!("weighted selection with total_weight > 0 must select a line") +} + +// --------------------------------------------------------------------------- +// Shared pipeline: Layers 1-4 +// --------------------------------------------------------------------------- + +/// Run the full D-028 four-layer dialogue pipeline and return a selected line. +/// +/// Shared by `process_talk_interaction` and `process_dialogue_response` to +/// avoid duplicating the L1-L4 query + scoring logic. Callers handle the +/// result differently (initial Talk sets ActiveDialogue; follow-up may clear it). +pub(crate) fn run_dialogue_pipeline<'a>( + line_pool: &'a crate::simulation::line_pool::LinePoolIndex, + location: &str, + role: &str, + relationship: RelationshipState, + confidence: crate::knowledge::types::KnowledgeConfidence, + day_phase: crate::simulation::time::DayPhase, + interaction_mem: Option<&InteractionMemory>, + npc_mood: Option, + cooldown: &DialogueCooldownTracker, + tick: u64, + rng: &mut impl Rng, +) -> Option<&'a IndexedDialogueLine> { + // Layer 1: Access tiers from relationship + let access_tiers = available_access_tiers(relationship); + + // Layer 2: Derive active situations from game state + let mut situations = derive_situations(day_phase, relationship); + + // Layer 2 extension: first_meeting / repeated_visit from InteractionMemory (#325, D-028) + if let Some(mem) = interaction_mem { + if mem.is_first_meeting() { + situations.push(Situation::FirstMeeting); + } else if mem.is_repeated_visit() { + situations.push(Situation::RepeatedVisit); + } + } + + // Layer 3: Trust tier from relationship + confidence (D-075) + let trust = relationship_to_trust(relationship, confidence); + + // Query Layers 1-3: collect candidates across all available access tiers + let mut candidates: Vec<&IndexedDialogueLine> = Vec::new(); + let mut seen_ids: BTreeSet<&str> = BTreeSet::new(); + + for access in &access_tiers { + let results = line_pool.query_dialogue(location, role, *access, &situations, trust); + for line in results { + if seen_ids.insert(&line.id) { + candidates.push(line); + } + } + } + + if candidates.is_empty() { + return None; + } + + // Layer 4: Topic + mood weighted selection + let active_topics: Vec = Vec::new(); // v0.1: no topic context yet + + select_dialogue_line(&candidates, npc_mood, &active_topics, cooldown, tick, rng) +} + +#[cfg(test)] +mod tests { + use super::*; + use rand::SeedableRng; + + // -- Mapping tests ------------------------------------------------------- + + #[test] + fn access_tiers_unknown_gets_public() { + let tiers = available_access_tiers(RelationshipState::Unknown); + assert_eq!(tiers, vec![AccessTier::Public]); + } + + #[test] + fn access_tiers_known_gets_public_and_peer() { + let tiers = available_access_tiers(RelationshipState::Known); + assert!(tiers.contains(&AccessTier::Public)); + assert!(tiers.contains(&AccessTier::Peer)); + } + + #[test] + fn access_tiers_friendly_includes_insider() { + let tiers = available_access_tiers(RelationshipState::Friendly); + assert!(tiers.contains(&AccessTier::Insider)); + } + + #[test] + fn access_tiers_poi_includes_authority() { + let tiers = available_access_tiers(RelationshipState::PersonOfInterest); + assert!(tiers.contains(&AccessTier::Authority)); + assert!(tiers.contains(&AccessTier::Peer)); + assert!(!tiers.contains(&AccessTier::Insider)); + } + + #[test] + fn access_tiers_hostile_only_hostile() { + let tiers = available_access_tiers(RelationshipState::Hostile); + assert_eq!(tiers, vec![AccessTier::Hostile]); + } + + // -- Trust tier tests (D-075: layered confidence gate) -------------------- + + #[test] + fn trust_friendly_knows_details_is_secret() { + use crate::knowledge::types::KnowledgeConfidence; + assert_eq!( + relationship_to_trust( + RelationshipState::Friendly, + KnowledgeConfidence::KnowsDetails + ), + TrustTier::Secret + ); + } + + #[test] + fn trust_friendly_direct_is_secret() { + use crate::knowledge::types::KnowledgeConfidence; + assert_eq!( + relationship_to_trust(RelationshipState::Friendly, KnowledgeConfidence::Direct), + TrustTier::Secret + ); + } + + #[test] + fn trust_friendly_knows_of_is_real() { + use crate::knowledge::types::KnowledgeConfidence; + assert_eq!( + relationship_to_trust(RelationshipState::Friendly, KnowledgeConfidence::KnowsOf), + TrustTier::Real + ); + } + + #[test] + fn trust_friendly_suspects_is_surface() { + use crate::knowledge::types::KnowledgeConfidence; + assert_eq!( + relationship_to_trust(RelationshipState::Friendly, KnowledgeConfidence::Suspects), + TrustTier::Surface + ); + } + + #[test] + fn trust_known_knows_of_is_real() { + use crate::knowledge::types::KnowledgeConfidence; + assert_eq!( + relationship_to_trust(RelationshipState::Known, KnowledgeConfidence::KnowsOf), + TrustTier::Real + ); + } + + #[test] + fn trust_known_suspects_is_surface() { + use crate::knowledge::types::KnowledgeConfidence; + assert_eq!( + relationship_to_trust(RelationshipState::Known, KnowledgeConfidence::Suspects), + TrustTier::Surface + ); + } + + #[test] + fn trust_unknown_is_always_surface() { + use crate::knowledge::types::KnowledgeConfidence; + assert_eq!( + relationship_to_trust(RelationshipState::Unknown, KnowledgeConfidence::Direct), + TrustTier::Surface + ); + } + + #[test] + fn trust_poi_is_always_surface() { + use crate::knowledge::types::KnowledgeConfidence; + // PersonOfInterest uses Authority access, not trust depth + assert_eq!( + relationship_to_trust( + RelationshipState::PersonOfInterest, + KnowledgeConfidence::KnowsDetails + ), + TrustTier::Surface + ); + } + + // -- Situation derivation tests ------------------------------------------ + + #[test] + fn situations_always_include_routine() { + use crate::simulation::time::DayPhase; + for phase in [ + DayPhase::Morning, + DayPhase::Afternoon, + DayPhase::Evening, + DayPhase::Night, + ] { + let sits = derive_situations(phase, RelationshipState::Unknown); + assert!( + sits.contains(&Situation::Routine), + "Routine must always be present for {:?}", + phase + ); + } + } + + #[test] + fn situations_morning_includes_shift_start() { + use crate::simulation::time::DayPhase; + let sits = derive_situations(DayPhase::Morning, RelationshipState::Unknown); + assert!(sits.contains(&Situation::ShiftStart)); + } + + #[test] + fn situations_evening_includes_bar_evening() { + use crate::simulation::time::DayPhase; + let sits = derive_situations(DayPhase::Evening, RelationshipState::Unknown); + assert!(sits.contains(&Situation::BarEvening)); + assert!(sits.contains(&Situation::Social)); + } + + #[test] + fn situations_poi_adds_investigation() { + use crate::simulation::time::DayPhase; + let sits = derive_situations(DayPhase::Morning, RelationshipState::PersonOfInterest); + assert!(sits.contains(&Situation::Investigation)); + } + + #[test] + fn situations_non_poi_no_investigation() { + use crate::simulation::time::DayPhase; + let sits = derive_situations(DayPhase::Morning, RelationshipState::Known); + assert!(!sits.contains(&Situation::Investigation)); + } + + // -- Layer 4 scoring tests ----------------------------------------------- + + fn make_line(id: &str, topics: &[Topic], moods: &[Mood]) -> IndexedDialogueLine { + IndexedDialogueLine { + id: id.to_string(), + text: format!("Text for {}", id), + role: "worker".to_string(), + access: vec![AccessTier::Public], + trust: TrustTier::Surface, + situation: vec![Situation::Routine], + topic: topics.to_vec(), + mood: moods.to_vec(), + tags: vec![], + knowledge_grant: None, + } + } + + #[test] + fn score_base_is_one_for_neutral_line() { + let line = make_line("neutral", &[], &[]); + assert_eq!(score_line(&line, None, &[]), 1); + } + + #[test] + fn score_mood_match_adds_three() { + let line = make_line("moody", &[], &[Mood::Anxious]); + assert_eq!(score_line(&line, Some(Mood::Anxious), &[]), 4); // 1 base + 3 mood + } + + #[test] + fn score_mood_mismatch_stays_base() { + let line = make_line("moody", &[], &[Mood::Anxious]); + assert_eq!(score_line(&line, Some(Mood::Warm), &[]), 1); + } + + #[test] + fn score_topic_match_adds_two_each() { + let line = make_line("topical", &[Topic::Cargo, Topic::Danger], &[]); + assert_eq!(score_line(&line, None, &[Topic::Cargo]), 3); // 1 + 2 + assert_eq!(score_line(&line, None, &[Topic::Cargo, Topic::Danger]), 5); // 1 + 2 + 2 + } + + #[test] + fn score_combined_mood_and_topic() { + let line = make_line("both", &[Topic::Cargo], &[Mood::Suspicious]); + assert_eq!( + score_line(&line, Some(Mood::Suspicious), &[Topic::Cargo]), + 6 // 1 + 3 + 2 + ); + } + + // -- Cooldown tracker tests ---------------------------------------------- + + #[test] + fn cooldown_tracks_used_lines() { + let mut tracker = DialogueCooldownTracker::default(); + tracker.record("line_001", 100); + assert!(tracker.is_on_cooldown("line_001", 100)); + assert!(tracker.is_on_cooldown("line_001", 100 + LINE_COOLDOWN_TICKS - 1)); + assert!(!tracker.is_on_cooldown("line_001", 100 + LINE_COOLDOWN_TICKS)); + } + + #[test] + fn cooldown_different_line_not_affected() { + let mut tracker = DialogueCooldownTracker::default(); + tracker.record("line_001", 100); + assert!(!tracker.is_on_cooldown("line_002", 100)); + } + + #[test] + fn cooldown_prune_removes_old_entries() { + let mut tracker = DialogueCooldownTracker::default(); + tracker.record("old", 0); + tracker.record("recent", LINE_COOLDOWN_TICKS); + tracker.prune(LINE_COOLDOWN_TICKS); + assert_eq!(tracker.used.len(), 1); + assert!(tracker.used.contains_key("recent")); + } + + // -- Selection tests ----------------------------------------------------- + + #[test] + fn select_returns_none_when_empty() { + let candidates: Vec<&IndexedDialogueLine> = vec![]; + let cooldown = DialogueCooldownTracker::default(); + let mut rng = rand_chacha::ChaCha20Rng::seed_from_u64(42); + + let result = select_dialogue_line(&candidates, None, &[], &cooldown, 0, &mut rng); + assert!(result.is_none()); + } + + #[test] + fn select_returns_none_when_all_on_cooldown() { + let line = make_line("only", &[], &[]); + let candidates = vec![&line]; + let mut cooldown = DialogueCooldownTracker::default(); + cooldown.record("only", 0); + let mut rng = rand_chacha::ChaCha20Rng::seed_from_u64(42); + + let result = select_dialogue_line(&candidates, None, &[], &cooldown, 0, &mut rng); + assert!(result.is_none()); + } + + #[test] + fn select_picks_from_candidates() { + let line_a = make_line("a", &[], &[]); + let line_b = make_line("b", &[], &[]); + let candidates = vec![&line_a, &line_b]; + let cooldown = DialogueCooldownTracker::default(); + let mut rng = rand_chacha::ChaCha20Rng::seed_from_u64(42); + + let result = select_dialogue_line(&candidates, None, &[], &cooldown, 0, &mut rng); + assert!(result.is_some()); + let id = &result.unwrap().id; + assert!(id == "a" || id == "b"); + } + + #[test] + fn select_deterministic_with_same_seed() { + let line_a = make_line("a", &[], &[]); + let line_b = make_line("b", &[Topic::Cargo], &[]); + let line_c = make_line("c", &[], &[Mood::Anxious]); + let candidates = vec![&line_a, &line_b, &line_c]; + let cooldown = DialogueCooldownTracker::default(); + + let mut rng1 = rand_chacha::ChaCha20Rng::seed_from_u64(42); + let mut rng2 = rand_chacha::ChaCha20Rng::seed_from_u64(42); + + let r1 = select_dialogue_line(&candidates, None, &[], &cooldown, 0, &mut rng1); + let r2 = select_dialogue_line(&candidates, None, &[], &cooldown, 0, &mut rng2); + assert_eq!(r1.unwrap().id, r2.unwrap().id); + } + + #[test] + fn select_favors_higher_scored_lines() { + // Line with matching mood gets +3, so should be selected more often + let neutral = make_line("neutral", &[], &[]); + let matched = make_line("matched", &[], &[Mood::Anxious]); + let candidates = vec![&neutral, &matched]; + let cooldown = DialogueCooldownTracker::default(); + + let mut match_count = 0; + for seed in 0..100 { + let mut rng = rand_chacha::ChaCha20Rng::seed_from_u64(seed); + if let Some(line) = select_dialogue_line( + &candidates, + Some(Mood::Anxious), + &[], + &cooldown, + 0, + &mut rng, + ) { + if line.id == "matched" { + match_count += 1; + } + } + } + // matched has score 4, neutral has score 1, so ~80% should be matched + assert!( + match_count > 60, + "matched line should be selected most of the time, got {}/100", + match_count + ); + } +} diff --git a/server/src/simulation/economy.rs b/server/src/simulation/economy.rs index 9480bfc6e..57e15e33e 100644 --- a/server/src/simulation/economy.rs +++ b/server/src/simulation/economy.rs @@ -285,6 +285,20 @@ pub struct EconQueryBuffer { pub pending: Option, } +/// Queue an `EconStateQuery` from player input. +/// `None` means the economy is not loaded (debug-log and drop — the query +/// buffer is only registered when the economy plugin is active). +pub fn queue_econ_state_query(buf: Option<&mut EconQueryBuffer>, system_id: String) { + match buf { + Some(buf) => buf.pending = Some(system_id), + None => { + tracing::debug!( + "EconStateQuery received but EconQueryBuffer not registered — economy not loaded" + ); + } + } +} + /// System: serve a pending `EconStateQuery` by building an `EconomySnapshot` /// and storing it in `SnapshotBuffer.pending_economy_response`. /// diff --git a/server/src/simulation/examine.rs b/server/src/simulation/examine.rs index e07c7b1ee..0277d3b54 100644 --- a/server/src/simulation/examine.rs +++ b/server/src/simulation/examine.rs @@ -285,6 +285,54 @@ pub fn process_examine_interaction( commands.entity(player_entity).remove::(); } +/// Handle Examine verb: insert ExamineRequest marker on the player entity (#242). +/// Examine is available at close range (same as Talk). Range check here matches +/// the server-side guard in process_examine_interaction. +pub fn handle_examine( + commands: &mut Commands, + registry: &EntityRegistry, + player_query: &crate::simulation::input::PlayerInputQuery, + all_positions: &Query<&TilePosition>, + target_entity_id: Option, +) { + let Some(target_id) = target_entity_id else { + tracing::warn!("Examine verb without target_entity_id"); + return; + }; + + let Ok((player_entity, player_pos, _, _)) = player_query.single() else { + return; + }; + + let target_stable = crate::knowledge::StableId(target_id); + let Some(target_entity) = registry.to_entity(&target_stable) else { + tracing::warn!(target_id, "Examine: target entity not in registry"); + return; + }; + + // Server-side range check: reject Examine if target is beyond close range + if let Ok(target_pos) = all_positions.get(target_entity) { + let distance = player_pos + .manhattan_distance(target_pos) + .unwrap_or(u32::MAX); + if distance > CLOSE_RANGE { + tracing::info!( + target_id, + distance, + "Examine: target out of range (max {})", + CLOSE_RANGE, + ); + return; + } + } + + commands.entity(player_entity).insert(ExamineRequest { + target: target_entity, + }); + + tracing::debug!(target_id, "Examine: ExamineRequest marker set on player"); +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- diff --git a/server/src/simulation/follow.rs b/server/src/simulation/follow.rs index d3464ee78..37f79b6c0 100644 --- a/server/src/simulation/follow.rs +++ b/server/src/simulation/follow.rs @@ -239,6 +239,58 @@ pub fn update_follow_state( } } +/// Handle Follow verb: designate an NPC as follow target (#241). +/// Sets FollowTarget on the player entity. Replaces any existing follow target. +/// Server-side range check: Follow requires CLOSE_RANGE (same as Talk). +pub fn handle_follow( + commands: &mut Commands, + registry: &crate::knowledge::EntityRegistry, + player_query: &crate::simulation::input::PlayerInputQuery, + all_positions: &Query<&TilePosition>, + target_entity_id: Option, + current_tick: u64, +) { + let Some(target_id) = target_entity_id else { + tracing::warn!("Follow verb without target_entity_id"); + return; + }; + + let Ok((player_entity, player_pos, _, _)) = player_query.single() else { + return; + }; + + let target_stable = crate::knowledge::StableId(target_id); + let Some(target_entity) = registry.to_entity(&target_stable) else { + tracing::warn!(target_id, "Follow: target entity not in registry"); + return; + }; + + // Server-side range check: reject Follow if target is beyond close range + if let Ok(target_pos) = all_positions.get(target_entity) { + let distance = player_pos + .manhattan_distance(target_pos) + .unwrap_or(u32::MAX); + if distance > crate::simulation::interaction::CLOSE_RANGE { + tracing::info!( + target_id, + distance, + "Follow: target out of range (max {})", + crate::simulation::interaction::CLOSE_RANGE, + ); + return; + } + } + + commands.entity(player_entity).insert(FollowTarget { + target: target_entity, + started_tick: current_tick, + proximity_ticks: 0, + los_lost_ticks: 0, + }); + + tracing::debug!(target_id, "Follow: FollowTarget set on player"); +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- diff --git a/server/src/simulation/input.rs b/server/src/simulation/input.rs index 74f4cd721..50dfa7afa 100644 --- a/server/src/simulation/input.rs +++ b/server/src/simulation/input.rs @@ -1,34 +1,53 @@ // Input processing system // Timestamped player input events for deterministic simulation (D-010 principle 4) // PlayerInput: semantic actions (MoveNorth, Interact, UsePerceptionMode, ToggleStance) +// +// This module owns the queue and the dispatch table only. Per-domain handler +// bodies live in their owning modules (T-1062): movement, inventory, dialogue, +// examine, follow, interaction, stance, save_io, settings, economy, bookmark, +// bridge::debug, perception::vision_cone, and test_world (reset, teleport). -use crate::bookmark::{BookmarkRegistry, SelectedBookmark}; -use crate::bridge::debug::DebugCommandBuffer; -use crate::bridge::types::{ - FacingDirection, ObjectType, PlayerAction, PlayerInput, SimError, SimErrorKind, SnapshotBuffer, +use crate::bookmark::{ + handle_confirm_bookmark, handle_request_bookmark_catalog, BookmarkInputParams, }; -use crate::knowledge::CultureResolverResource; -use crate::knowledge::{EntityRegistry, StableId}; -use crate::perception::vision_cone::{facing_from_delta, Facing}; -use crate::settings::{SettingsCommand, SettingsCommandBuffer}; -use crate::simulation::economy::EconQueryBuffer; -use crate::simulation::interaction::{DoorInteractRequest, DoorState, TerminalInteractRequest}; -use crate::simulation::inventory::{ - find_next_slot, occupied_slots_for, CarriedBy, InventorySlot, ItemName, MAX_INVENTORY_SLOTS, -}; -use crate::simulation::movement::{MoveIntent, PlayerCharacter, TilePosition}; -use crate::simulation::save_io::{SaveLoadCommand, SaveLoadPending}; -use crate::simulation::stance::{PlayerMoveCooldown, Stance}; +use crate::bridge::debug::{queue_debug_command, DebugCommandBuffer}; +use crate::bridge::types::{ObjectType, PlayerAction, PlayerInput}; +use crate::knowledge::EntityRegistry; +use crate::perception::vision_cone::handle_set_facing; +use crate::settings::{queue_settings_command, SettingsCommand, SettingsCommandBuffer}; +use crate::simulation::dialogue::{handle_confront, handle_dialogue_response, handle_talk}; +use crate::simulation::economy::{queue_econ_state_query, EconQueryBuffer}; +use crate::simulation::examine::handle_examine; +use crate::simulation::follow::handle_follow; +use crate::simulation::interaction::{handle_door_interact, handle_terminal_interact, DoorState}; +use crate::simulation::inventory::{handle_place, handle_take, CarriedBy, InventorySlot, ItemName}; +use crate::simulation::movement::{apply_move, PlayerCharacter, TilePosition}; +use crate::simulation::save_io::{queue_save_load, SaveLoadCommand, SaveLoadPending}; +use crate::simulation::stance::{handle_toggle_stance, PlayerMoveCooldown, Stance}; use crate::simulation::time::{SimulationTime, TickRate}; -use crate::test_world::reset::{RoomResetTrigger, RoomSnapshots}; +use crate::test_world::reset::{handle_reset, RoomResetTrigger, RoomSnapshots}; +use crate::test_world::teleport::handle_teleport_to_hub; use bevy_ecs::prelude::*; -use bevy_ecs::system::SystemParam; use std::collections::VecDeque; /// Maximum number of inputs the queue will hold before dropping oldest. /// Prevents unbounded memory growth from input flooding. pub const INPUT_QUEUE_CAPACITY: usize = 1000; +/// Player query shared by the dispatch and the per-domain input handlers. +/// One alias instead of nine per-handler `clippy::type_complexity` allows (T-1062). +pub type PlayerInputQuery<'w, 's> = Query< + 'w, + 's, + ( + Entity, + &'static TilePosition, + Option<&'static mut Stance>, + Option<&'static mut PlayerMoveCooldown>, + ), + With, +>; + /// Queue of pending player inputs, ordered by tick #[derive(Resource, Debug, Default)] pub struct InputQueue { @@ -83,37 +102,16 @@ impl InputQueue { } } -/// Bundled SystemParam for bookmark-related input handling. -/// -/// Bevy's blanket `IntoSystem` impl covers functions up to 16 parameters. -/// Bundling the 4 bookmark params keeps `process_player_input` at exactly 16. -#[derive(SystemParam)] -pub struct BookmarkInputParams<'w> { - pub registry: Option>, - pub selected: Option>, - pub snapshot_buf: Option>, - pub sim_error_buf: Option>, - pub culture: Option>, -} - /// Drains InputQueue for the current tick, converts PlayerActions to ECS components. /// Handles stance toggling (D-053), movement cooldown, Take/Place verbs (#424), /// and save/load commands (#553). -#[allow(clippy::type_complexity, clippy::too_many_arguments)] +#[allow(clippy::too_many_arguments)] pub fn process_player_input( mut input_queue: ResMut, mut time: ResMut, mut commands: Commands, registry: Res, - mut player_query: Query< - ( - Entity, - &TilePosition, - Option<&mut Stance>, - Option<&mut PlayerMoveCooldown>, - ), - With, - >, + mut player_query: PlayerInputQuery, inventory_items: Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>, all_positions: Query<&TilePosition>, reset_triggers: Query<&RoomResetTrigger>, @@ -189,22 +187,10 @@ pub fn process_player_input( apply_move(&mut player_query, &mut commands, -1, 1); } PlayerAction::ToggleStanceUp => { - if let Ok((_, _, Some(mut stance), _)) = player_query.single_mut() { - let new_stance = stance.0.step_up(); - if new_stance != stance.0 { - tracing::debug!("Stance up: {:?} -> {:?}", stance.0, new_stance); - stance.0 = new_stance; - } - } + handle_toggle_stance(&mut player_query, true); } PlayerAction::ToggleStanceDown => { - if let Ok((_, _, Some(mut stance), _)) = player_query.single_mut() { - let new_stance = stance.0.step_down(); - if new_stance != stance.0 { - tracing::debug!("Stance down: {:?} -> {:?}", stance.0, new_stance); - stance.0 = new_stance; - } - } + handle_toggle_stance(&mut player_query, false); } PlayerAction::Pause => { time.tick_rate = TickRate::Paused; @@ -331,25 +317,7 @@ pub fn process_player_input( } } PlayerAction::SetFacing { ref facing } => { - let dir = match facing.as_str() { - "North" => Some(FacingDirection::North), - "Northeast" => Some(FacingDirection::Northeast), - "East" => Some(FacingDirection::East), - "Southeast" => Some(FacingDirection::Southeast), - "South" => Some(FacingDirection::South), - "Southwest" => Some(FacingDirection::Southwest), - "West" => Some(FacingDirection::West), - "Northwest" => Some(FacingDirection::Northwest), - _ => { - tracing::warn!("SetFacing: unknown direction {:?}", facing); - None - } - }; - if let Some(dir) = dir { - if let Ok((entity, _, _, _)) = player_query.single_mut() { - commands.entity(entity).insert(Facing(dir)); - } - } + handle_set_facing(&mut player_query, &mut commands, facing); } PlayerAction::TeleportToHub => { handle_teleport_to_hub(&mut player_query, &mut commands); @@ -371,88 +339,47 @@ pub fn process_player_input( tracing::trace!("UsePerceptionMode({}) — no-op for Sprint 1", mode); } PlayerAction::SaveGame { ref path } => { - if let Some(ref mut sl) = save_load { - if sl.pending.is_some() { - tracing::warn!( - "SaveGame overwrites already-pending save/load command (dropped)" - ); - } - sl.pending = Some(SaveLoadCommand::Save { + queue_save_load( + save_load.as_deref_mut(), + SaveLoadCommand::Save { path: std::path::PathBuf::from(path), - }); - tracing::info!("SaveGame queued: {:?}", path); - } else { - tracing::warn!("SaveGame received but SaveLoadPending resource not registered"); - } + }, + ); } PlayerAction::LoadGame { ref path } => { - if let Some(ref mut sl) = save_load { - if sl.pending.is_some() { - tracing::warn!( - "LoadGame overwrites already-pending save/load command (dropped)" - ); - } - sl.pending = Some(SaveLoadCommand::Load { + queue_save_load( + save_load.as_deref_mut(), + SaveLoadCommand::Load { path: std::path::PathBuf::from(path), - }); - tracing::info!("LoadGame queued: {:?}", path); - } else { - tracing::warn!("LoadGame received but SaveLoadPending resource not registered"); - } + }, + ); } PlayerAction::DebugCommand(cmd) => { - if let Some(ref mut buf) = debug_cmd_buffer { - buf.push(cmd); - } else { - tracing::warn!("DebugCommand received but DebugCommandBuffer not registered"); - } + queue_debug_command(debug_cmd_buffer.as_deref_mut(), cmd); } PlayerAction::ChangeSetting { key, value } => { - if let Some(ref mut buf) = settings_cmd_buffer { - buf.push(SettingsCommand::Change { key, value }); - } else { - tracing::warn!( - "ChangeSetting received but SettingsCommandBuffer not registered" - ); - } + queue_settings_command( + settings_cmd_buffer.as_deref_mut(), + SettingsCommand::Change { key, value }, + ); } PlayerAction::RequestAllSettings => { - if let Some(ref mut buf) = settings_cmd_buffer { - buf.push(SettingsCommand::RequestAll); - } else { - tracing::warn!( - "RequestAllSettings received but SettingsCommandBuffer not registered" - ); - } + queue_settings_command( + settings_cmd_buffer.as_deref_mut(), + SettingsCommand::RequestAll, + ); } PlayerAction::DeleteSetting { key } => { - if let Some(ref mut buf) = settings_cmd_buffer { - buf.push(SettingsCommand::Delete { key }); - } else { - tracing::warn!( - "DeleteSetting received but SettingsCommandBuffer not registered" - ); - } + queue_settings_command( + settings_cmd_buffer.as_deref_mut(), + SettingsCommand::Delete { key }, + ); } PlayerAction::EconStateQuery { system_id } => { - if let Some(ref mut buf) = econ_query_buf { - buf.pending = Some(system_id); - } else { - tracing::debug!("EconStateQuery received but EconQueryBuffer not registered — economy not loaded"); - } + queue_econ_state_query(econ_query_buf.as_deref_mut(), system_id); } PlayerAction::RequestBookmarkCatalog => { - if let (Some(ref registry), Some(ref mut buf)) = - (&bookmark.registry, &mut bookmark.snapshot_buf) - { - let resolver = bookmark.culture.as_deref().map(|c| &c.0); - buf.pending_bookmark_catalog = Some(registry.build_catalog(resolver)); - tracing::debug!("RequestBookmarkCatalog: catalog staged"); - } else { - tracing::warn!( - "RequestBookmarkCatalog: BookmarkRegistry or SnapshotBuffer not available" - ); - } + handle_request_bookmark_catalog(&mut bookmark); } PlayerAction::ConfirmBookmark { bookmark_id, @@ -478,764 +405,11 @@ pub fn process_player_input( } } -/// Apply a movement action with stance-based cooldown enforcement. -/// If the player has a Stance and PlayerMoveCooldown, movement is throttled -/// according to the stance's ticks_per_move. Without these components, -/// movement is unrestricted (backward compatibility). -#[allow(clippy::type_complexity)] -fn apply_move( - player_query: &mut Query< - ( - Entity, - &TilePosition, - Option<&mut Stance>, - Option<&mut PlayerMoveCooldown>, - ), - With, - >, - commands: &mut Commands, - dx: i32, - dy: i32, -) { - let (entity, pos, stance_opt, cooldown_opt) = player_query - .single_mut() - .expect("PlayerCharacter entity must exist when processing input"); - - let stance = stance_opt.map(|s| s.0).unwrap_or_default(); - - // Check cooldown if present - if let Some(mut cooldown) = cooldown_opt { - if !cooldown.try_move(stance) { - tracing::trace!("Movement throttled by stance {:?} cooldown", stance); - return; - } - } - - commands.entity(entity).insert(MoveIntent { - target: TilePosition::new(pos.x + dx, pos.y + dy, pos.z), - }); - // Update facing direction based on movement (D-015 vision cone) - commands - .entity(entity) - .insert(Facing(facing_from_delta(dx, dy))); -} - -/// Handle Take verb: pick up an item entity and add it to the player's inventory. -/// Removes TilePosition (item is no longer on the ground — info boundary enforcement), -/// adds CarriedBy + InventorySlot components. -#[allow(clippy::type_complexity)] -fn handle_take( - commands: &mut Commands, - registry: &EntityRegistry, - player_query: &Query< - ( - Entity, - &TilePosition, - Option<&mut Stance>, - Option<&mut PlayerMoveCooldown>, - ), - With, - >, - inventory_items: &Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>, - target_entity_id: Option, -) { - let Some(target_id) = target_entity_id else { - tracing::warn!("Take verb without target_entity_id"); - return; - }; - - let Ok((player_entity, _, _, _)) = player_query.single() else { - return; - }; - - let Some(player_sid) = registry.to_stable(player_entity) else { - tracing::error!("Player entity not in EntityRegistry"); - return; - }; - - // Resolve wire ID to ECS entity - let target_stable = StableId(target_id); - let Some(target_entity) = registry.to_entity(&target_stable) else { - tracing::warn!(target_id, "Take: target entity not in registry"); - return; - }; - - // Check inventory capacity - let occupied = occupied_slots_for(player_sid, inventory_items); - let Some(slot) = find_next_slot(&occupied) else { - tracing::info!( - "Inventory full ({} slots), cannot take item", - MAX_INVENTORY_SLOTS - ); - return; - }; - - // Remove TilePosition (item leaves the ground), add CarriedBy + InventorySlot - commands - .entity(target_entity) - .remove::() - .insert((CarriedBy(player_sid), InventorySlot(slot))); - - tracing::info!( - target_id, - slot, - "Take: item picked up and added to inventory slot", - ); -} - -/// 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). -/// Server-side range check: Talk requires CLOSE_RANGE (same as interaction system). -#[allow(clippy::type_complexity)] -fn handle_talk( - commands: &mut Commands, - registry: &EntityRegistry, - player_query: &Query< - ( - Entity, - &TilePosition, - Option<&mut Stance>, - Option<&mut PlayerMoveCooldown>, - ), - With, - >, - all_positions: &Query<&TilePosition>, - 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_pos, _, _)) = 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; - }; - - // Server-side range check: reject Talk if target is beyond close range - if let Ok(target_pos) = all_positions.get(target_entity) { - let distance = player_pos - .manhattan_distance(target_pos) - .unwrap_or(u32::MAX); - if distance > crate::simulation::interaction::CLOSE_RANGE { - tracing::info!( - target_id, - distance, - "Talk: target out of range (max {})", - crate::simulation::interaction::CLOSE_RANGE, - ); - return; - } - } - - commands - .entity(player_entity) - .insert(crate::simulation::dialogue::TalkRequest { - target: target_entity, - }); - - tracing::debug!(target_id, "Talk: TalkRequest marker set on player"); -} - -/// Handle Examine verb: insert ExamineRequest marker on the player entity (#242). -/// Examine is available at close range (same as Talk). Range check here matches -/// the server-side guard in process_examine_interaction. -#[allow(clippy::type_complexity)] -fn handle_examine( - commands: &mut Commands, - registry: &EntityRegistry, - player_query: &Query< - ( - Entity, - &TilePosition, - Option<&mut crate::simulation::stance::Stance>, - Option<&mut crate::simulation::stance::PlayerMoveCooldown>, - ), - With, - >, - all_positions: &Query<&TilePosition>, - target_entity_id: Option, -) { - let Some(target_id) = target_entity_id else { - tracing::warn!("Examine verb without target_entity_id"); - return; - }; - - let Ok((player_entity, player_pos, _, _)) = 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, "Examine: target entity not in registry"); - return; - }; - - // Server-side range check: reject Examine if target is beyond close range - if let Ok(target_pos) = all_positions.get(target_entity) { - let distance = player_pos - .manhattan_distance(target_pos) - .unwrap_or(u32::MAX); - if distance > crate::simulation::interaction::CLOSE_RANGE { - tracing::info!( - target_id, - distance, - "Examine: target out of range (max {})", - crate::simulation::interaction::CLOSE_RANGE, - ); - return; - } - } - - commands - .entity(player_entity) - .insert(crate::simulation::examine::ExamineRequest { - target: target_entity, - }); - - tracing::debug!(target_id, "Examine: ExamineRequest marker set on player"); -} - -/// Handle DialogueResponse action: set DialogueResponseRequest marker (#539). -/// The follow-up dialogue pipeline runs in process_dialogue_response (dialogue.rs). -/// -/// Range check: same CLOSE_RANGE as Talk/Confront (D-010 info boundary). The player -/// must still be near the NPC to continue a conversation — walking away mid-dialogue -/// should not allow remote follow-ups. -#[allow(clippy::type_complexity)] -fn handle_dialogue_response( - commands: &mut Commands, - registry: &EntityRegistry, - player_query: &Query< - ( - Entity, - &TilePosition, - Option<&mut Stance>, - Option<&mut PlayerMoveCooldown>, - ), - With, - >, - all_positions: &Query<&TilePosition>, - target_entity_id: u64, - response_id: &str, -) { - let Ok((player_entity, player_pos, _, _)) = player_query.single() else { - return; - }; - - let target_stable = StableId(target_entity_id); - let Some(target_entity) = registry.to_entity(&target_stable) else { - tracing::warn!( - target_entity_id, - "DialogueResponse: target entity not in registry" - ); - return; - }; - - // Server-side range check: reject DialogueResponse if target moved out of range - if let Ok(target_pos) = all_positions.get(target_entity) { - let distance = player_pos - .manhattan_distance(target_pos) - .unwrap_or(u32::MAX); - if distance > crate::simulation::interaction::CLOSE_RANGE { - tracing::info!( - target_entity_id, - distance, - "DialogueResponse: target out of range (max {})", - crate::simulation::interaction::CLOSE_RANGE, - ); - return; - } - } - - commands - .entity(player_entity) - .insert(crate::simulation::dialogue::DialogueResponseRequest { - target: target_entity, - response_id: response_id.to_string(), - }); - - tracing::debug!( - target_entity_id, - response_id, - "DialogueResponse: marker set on player" - ); -} - -/// Handle Confront verb: set ConfrontationDelivered marker on the player entity (#520, D-063). -/// The confrontation response system runs in process_confrontation_response (dialogue.rs). -/// Server-side range check: Confront requires CLOSE_RANGE (same as Talk). -#[allow(clippy::type_complexity)] -fn handle_confront( - commands: &mut Commands, - registry: &EntityRegistry, - player_query: &Query< - ( - Entity, - &TilePosition, - Option<&mut Stance>, - Option<&mut PlayerMoveCooldown>, - ), - With, - >, - all_positions: &Query<&TilePosition>, - target_entity_id: Option, -) { - let Some(target_id) = target_entity_id else { - tracing::warn!("Confront verb without target_entity_id"); - return; - }; - - let Ok((player_entity, player_pos, _, _)) = 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, "Confront: target entity not in registry"); - return; - }; - - // Server-side range check: reject Confront if target is beyond close range - if let Ok(target_pos) = all_positions.get(target_entity) { - let distance = player_pos - .manhattan_distance(target_pos) - .unwrap_or(u32::MAX); - if distance > crate::simulation::interaction::CLOSE_RANGE { - tracing::info!( - target_id, - distance, - "Confront: target out of range (max {})", - crate::simulation::interaction::CLOSE_RANGE, - ); - return; - } - } - - commands - .entity(player_entity) - .insert(crate::simulation::dialogue::ConfrontationDelivered { - target: target_entity, - }); - - tracing::debug!( - target_id, - "Confront: ConfrontationDelivered marker set on player" - ); -} - -/// Handle Follow verb: designate an NPC as follow target (#241). -/// Sets FollowTarget on the player entity. Replaces any existing follow target. -/// Server-side range check: Follow requires CLOSE_RANGE (same as Talk). -#[allow(clippy::type_complexity)] -fn handle_follow( - commands: &mut Commands, - registry: &EntityRegistry, - player_query: &Query< - ( - Entity, - &TilePosition, - Option<&mut Stance>, - Option<&mut PlayerMoveCooldown>, - ), - With, - >, - all_positions: &Query<&TilePosition>, - target_entity_id: Option, - current_tick: u64, -) { - let Some(target_id) = target_entity_id else { - tracing::warn!("Follow verb without target_entity_id"); - return; - }; - - let Ok((player_entity, player_pos, _, _)) = 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, "Follow: target entity not in registry"); - return; - }; - - // Server-side range check: reject Follow if target is beyond close range - if let Ok(target_pos) = all_positions.get(target_entity) { - let distance = player_pos - .manhattan_distance(target_pos) - .unwrap_or(u32::MAX); - if distance > crate::simulation::interaction::CLOSE_RANGE { - tracing::info!( - target_id, - distance, - "Follow: target out of range (max {})", - crate::simulation::interaction::CLOSE_RANGE, - ); - return; - } - } - - commands - .entity(player_entity) - .insert(crate::simulation::follow::FollowTarget { - target: target_entity, - started_tick: current_tick, - proximity_ticks: 0, - los_lost_ticks: 0, - }); - - tracing::debug!(target_id, "Follow: FollowTarget 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. -#[allow(clippy::type_complexity)] -fn handle_place( - 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!("Place verb without target_entity_id"); - return; - }; - - let Ok((_, player_pos, _, _)) = 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, "Place: target entity not in registry"); - return; - }; - - let place_pos = *player_pos; - - // Remove inventory components, place item at player's tile - commands - .entity(target_entity) - .remove::() - .remove::() - .insert(place_pos); - - tracing::info!( - target_id, - x = place_pos.x, - y = place_pos.y, - z = place_pos.z, - "Place: item dropped at player position", - ); -} - -/// Handle Reset verb: restore a room's entities to their initial positions. -/// Target entity must have a RoomResetTrigger component. Respects debounce. -fn handle_reset( - commands: &mut Commands, - registry: &EntityRegistry, - reset_triggers: &Query<&RoomResetTrigger>, - room_snapshots: &mut Option>, - target_entity_id: Option, - current_tick: u64, -) { - let Some(target_id) = target_entity_id else { - tracing::warn!("Reset verb without target_entity_id"); - return; - }; - - let Some(snapshots) = room_snapshots.as_mut() else { - tracing::warn!("Reset verb but RoomSnapshots resource not available"); - return; - }; - - let target_stable = StableId(target_id); - let Some(target_entity) = registry.to_entity(&target_stable) else { - tracing::warn!(target_id, "Reset: target entity not in registry"); - return; - }; - - let Ok(trigger) = reset_triggers.get(target_entity) else { - tracing::warn!(target_id, "Reset: target is not a reset trigger"); - return; - }; - - let Some(changes) = snapshots.plan_reset(&trigger.room_name, current_tick) else { - tracing::info!( - room = trigger.room_name.as_str(), - "Reset: debounced or unknown room" - ); - return; - }; - - let mut restored = 0; - for (entity, position, is_floor_item) in changes { - if is_floor_item { - commands - .entity(entity) - .remove::() - .remove::() - .insert(position); - } else { - commands.entity(entity).insert(position); - } - restored += 1; - } - - tracing::info!( - room = trigger.room_name.as_str(), - restored, - current_tick, - "Room reset executed via Reset verb" - ); -} - -/// Handle door Open/Close: insert `DoorInteractRequest` on the player entity (#246). -/// -/// The actual walkability toggle is done by `process_door_interaction` which -/// reads the request and modifies `WalkabilityMap`. The split keeps system -/// ordering explicit and avoids mutable resource conflicts in one system. -fn handle_door_interact( - commands: &mut Commands, - registry: &EntityRegistry, - player_query: &Query< - ( - Entity, - &TilePosition, - Option<&mut Stance>, - Option<&mut PlayerMoveCooldown>, - ), - With, - >, - door_states: &Query<&DoorState>, - target_entity_id: Option, -) { - let Some(target_id) = target_entity_id else { - tracing::warn!("Door verb without target_entity_id"); - return; - }; - - let target_stable = StableId(target_id); - let Some(target_entity) = registry.to_entity(&target_stable) else { - tracing::warn!(target_id, "Door interact: target entity not in registry"); - return; - }; - - // Verify target has DoorState before inserting request - if door_states.get(target_entity).is_err() { - tracing::warn!(target_id, "Door verb on entity without DoorState — ignored"); - return; - } - - let Ok((player_entity, _, _, _)) = player_query.single() else { - return; - }; - - commands.entity(player_entity).insert(DoorInteractRequest { - door_entity: target_entity, - }); - - tracing::debug!( - target_id, - "Door interact: DoorInteractRequest inserted on player" - ); -} - -/// Handle Terminal Use: insert `TerminalInteractRequest` on the player entity (#246). -fn handle_terminal_interact( - commands: &mut Commands, - registry: &EntityRegistry, - player_query: &Query< - ( - Entity, - &TilePosition, - Option<&mut Stance>, - Option<&mut PlayerMoveCooldown>, - ), - With, - >, - object_types: &Query<&ObjectType>, - target_entity_id: Option, -) { - let Some(target_id) = target_entity_id else { - tracing::warn!("Use verb without target_entity_id"); - return; - }; - - let target_stable = StableId(target_id); - let Some(target_entity) = registry.to_entity(&target_stable) else { - tracing::warn!( - target_id, - "Terminal interact: target entity not in registry" - ); - return; - }; - - // Verify target is a Terminal - match object_types.get(target_entity) { - Ok(ObjectType::Terminal) => {} - _ => { - tracing::warn!(target_id, "Use verb on non-Terminal entity — ignored"); - return; - } - } - - let Ok((player_entity, _, _, _)) = player_query.single() else { - return; - }; - - commands - .entity(player_entity) - .insert(TerminalInteractRequest { - terminal_entity: target_entity, - }); - - tracing::debug!( - target_id, - "Terminal interact: TerminalInteractRequest inserted on player" - ); -} - -/// Handle TeleportToHub: move player to hub spawn, clear interaction state (#491). -/// -/// Gauntlet-only action. On non-Gauntlet maps (feature disabled), logs a warning -/// and returns. On Gauntlet maps, moves the player to HUB.spawn and removes -/// dialogue, monologue, and interaction markers to prevent stale state. -/// -/// Does NOT affect: room state, inventory, game time, knowledge graph. -#[allow(clippy::type_complexity)] -fn handle_teleport_to_hub( - player_query: &mut Query< - ( - Entity, - &TilePosition, - Option<&mut Stance>, - Option<&mut PlayerMoveCooldown>, - ), - With, - >, - commands: &mut Commands, -) { - #[cfg(not(feature = "gauntlet"))] - { - tracing::warn!("TeleportToHub rejected: not a Gauntlet map"); - return; - } - - #[cfg(feature = "gauntlet")] - { - let Ok((player_entity, _, _, _)) = player_query.single() else { - return; - }; - - let hub_spawn = crate::test_world::constants::HUB.spawn; - - // Move player to hub spawn - commands.entity(player_entity).insert(hub_spawn); - - // Clear any pending movement - commands.entity(player_entity).remove::(); - - // Clear dialogue/interaction markers (including mid-confrontation state) - commands - .entity(player_entity) - .remove::() - .remove::() - .remove::() - .remove::() - .remove::(); - - tracing::info!( - x = hub_spawn.x, - y = hub_spawn.y, - z = hub_spawn.z, - "TeleportToHub: player moved to hub spawn" - ); - } -} - -fn handle_confirm_bookmark( - bookmark_id: String, - starting_location_id: String, - registry: &Option>, - selected_bookmark: &mut Option>, - sim_error_buf: &mut Option>, - tick: u64, -) { - let Some(ref registry) = registry else { - tracing::warn!("ConfirmBookmark: BookmarkRegistry not available"); - return; - }; - let Some(defn) = registry.get(&bookmark_id) else { - let msg = format!("ConfirmBookmark: unknown bookmark_id {:?}", bookmark_id); - tracing::warn!("{}", msg); - if let Some(ref mut buf) = sim_error_buf { - buf.push(SimError { - kind: SimErrorKind::ProtocolError, - message: msg, - tick, - }); - } - return; - }; - if !defn.allowed_locations.contains(&starting_location_id) { - let msg = format!( - "ConfirmBookmark: starting_location_id {:?} not in allowed_locations for {:?}", - starting_location_id, bookmark_id - ); - tracing::warn!("{}", msg); - if let Some(ref mut buf) = sim_error_buf { - buf.push(SimError { - kind: SimErrorKind::ProtocolError, - message: msg, - tick, - }); - } - return; - } - if let Some(ref mut sel) = selected_bookmark { - if sel.bookmark_id.is_some() { - let msg = format!( - "ConfirmBookmark: bookmark already confirmed ({}), ignoring retry", - sel.bookmark_id.as_deref().unwrap_or("?") - ); - tracing::warn!("{}", msg); - if let Some(ref mut buf) = sim_error_buf { - buf.push(SimError { - kind: SimErrorKind::ProtocolError, - message: msg, - tick, - }); - } - return; - } - sel.bookmark_id = Some(bookmark_id.clone()); - sel.starting_location_id = Some(starting_location_id.clone()); - tracing::info!( - bookmark_id, - starting_location_id, - "ConfirmBookmark: selection recorded" - ); - } -} - #[cfg(test)] mod tests { use super::*; + use crate::bridge::types::MovementStance; + use crate::simulation::movement::MoveIntent; #[test] fn drain_returns_inputs_up_to_tick() { @@ -1388,470 +562,6 @@ mod tests { assert!(world.get::(player).is_none()); } - use crate::bridge::types::MovementStance; - - #[test] - fn toggle_stance_up_changes_stance() { - let mut world = bevy_ecs::world::World::new(); - world.insert_resource(InputQueue::default()); - world.insert_resource(SimulationTime::default()); - world.init_resource::(); - - world.spawn(( - PlayerCharacter, - TilePosition::new(5, 5, 0), - Stance::default(), - 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::Sprint); - } - - #[test] - fn toggle_stance_down_changes_stance() { - let mut world = bevy_ecs::world::World::new(); - world.insert_resource(InputQueue::default()); - world.insert_resource(SimulationTime::default()); - world.init_resource::(); - - world.spawn(( - PlayerCharacter, - TilePosition::new(5, 5, 0), - Stance::default(), - PlayerMoveCooldown::default(), - )); - - world.resource_mut::().push(PlayerInput { - tick: 0, - action: PlayerAction::ToggleStanceDown, - }); - - 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::Careful); - } - - #[test] - fn walk_stance_throttles_movement_to_every_2_ticks() { - 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), - Stance::default(), // Walk - PlayerMoveCooldown::default(), - )) - .id(); - - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(process_player_input); - - // Tick 0: move north — should succeed (first move) - world.resource_mut::().push(PlayerInput { - tick: 0, - action: PlayerAction::MoveNorth, - }); - schedule.run(&mut world); - assert!( - world.get::(player).is_some(), - "first move should succeed" - ); - - // Remove MoveIntent (simulating validate_movement consuming it) - world.entity_mut(player).remove::(); - - // Tick 0 again: move north — should be throttled (cooldown) - world.resource_mut::().push(PlayerInput { - tick: 0, - action: PlayerAction::MoveNorth, - }); - schedule.run(&mut world); - assert!( - world.get::(player).is_none(), - "second move should be throttled" - ); - - // Tick 0 again: move north — should succeed (cooldown elapsed) - world.resource_mut::().push(PlayerInput { - tick: 0, - action: PlayerAction::MoveNorth, - }); - schedule.run(&mut world); - assert!( - world.get::(player).is_some(), - "third move should succeed after cooldown" - ); - } - - #[test] - fn sprint_stance_allows_every_tick() { - 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), - Stance(MovementStance::Sprint), - PlayerMoveCooldown::default(), - )) - .id(); - - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(process_player_input); - - // First move - world.resource_mut::().push(PlayerInput { - tick: 0, - action: PlayerAction::MoveNorth, - }); - schedule.run(&mut world); - assert!(world.get::(player).is_some()); - world.entity_mut(player).remove::(); - - // Second move — sprint allows every tick - world.resource_mut::().push(PlayerInput { - tick: 0, - action: PlayerAction::MoveNorth, - }); - schedule.run(&mut world); - assert!( - world.get::(player).is_some(), - "sprint should allow every tick" - ); - } - - #[test] - fn no_stance_component_moves_unrestricted() { - // Backward compatibility: entities without Stance/Cooldown move freely - 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); - - world.resource_mut::().push(PlayerInput { - tick: 0, - action: PlayerAction::MoveNorth, - }); - schedule.run(&mut world); - assert!(world.get::(player).is_some()); - world.entity_mut(player).remove::(); - - // Second move immediately — no throttle without components - world.resource_mut::().push(PlayerInput { - tick: 0, - action: PlayerAction::MoveNorth, - }); - schedule.run(&mut world); - assert!(world.get::(player).is_some()); - } - - #[test] - fn take_verb_picks_up_item() { - let mut world = bevy_ecs::world::World::new(); - world.insert_resource(InputQueue::default()); - world.insert_resource(SimulationTime::default()); - world.init_resource::(); - - // Spawn player and register - let player = world - .spawn((PlayerCharacter, TilePosition::new(5, 5, 0))) - .id(); - let player_sid = world - .resource_mut::() - .register(player); - - // Spawn item near player - let item = world - .spawn((TilePosition::new(5, 4, 0), ItemName("Manifest Copy".into()))) - .id(); - let item_sid = world - .resource_mut::() - .register(item); - - // Issue Take verb - 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 should have CarriedBy + InventorySlot, no TilePosition - assert!( - world.get::(item).is_none(), - "item should leave the ground" - ); - let carried = world - .get::(item) - .expect("item should have CarriedBy"); - assert_eq!(carried.0, player_sid); - let slot = world - .get::(item) - .expect("item should have slot"); - assert_eq!(slot.0, 0, "first item goes to slot 0"); - } - - #[test] - fn place_verb_drops_item() { - 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 player_sid = world - .resource_mut::() - .register(player); - - // Spawn item already in inventory (no TilePosition) - let item = world - .spawn(( - CarriedBy(player_sid), - ItemName("Manifest Copy".into()), - InventorySlot(0), - )) - .id(); - let item_sid = world - .resource_mut::() - .register(item); - - // Issue Place verb - world.resource_mut::().push(PlayerInput { - tick: 0, - action: PlayerAction::Interact { - target_entity_id: Some(item_sid.0), - verb: Some("Place".into()), - }, - }); - - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(process_player_input); - schedule.run(&mut world); - - // Item should have TilePosition at player's location, no CarriedBy/InventorySlot - let pos = world - .get::(item) - .expect("item should be on ground"); - assert_eq!( - *pos, - TilePosition::new(5, 5, 0), - "placed at player position" - ); - assert!(world.get::(item).is_none(), "CarriedBy removed"); - assert!( - world.get::(item).is_none(), - "InventorySlot removed" - ); - } - - #[test] - fn take_verb_assigns_sequential_slots() { - 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 player_sid = world - .resource_mut::() - .register(player); - - // Item already in slot 0 - world.spawn(( - CarriedBy(player_sid), - ItemName("Manifest".into()), - InventorySlot(0), - )); - - // New item on the ground - let item2 = world - .spawn((TilePosition::new(5, 4, 0), ItemName("Token".into()))) - .id(); - let item2_sid = world - .resource_mut::() - .register(item2); - - world.resource_mut::().push(PlayerInput { - tick: 0, - action: PlayerAction::Interact { - target_entity_id: Some(item2_sid.0), - verb: Some("Take".into()), - }, - }); - - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(process_player_input); - schedule.run(&mut world); - - let slot = world - .get::(item2) - .expect("item should have slot"); - assert_eq!(slot.0, 1, "second item goes to slot 1"); - } - - #[test] - fn take_verb_full_inventory_rejected() { - 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 player_sid = world - .resource_mut::() - .register(player); - - // Fill all 9 slots - for slot in 0..MAX_INVENTORY_SLOTS { - world.spawn(( - CarriedBy(player_sid), - ItemName(format!("Item {}", slot)), - InventorySlot(slot), - )); - } - - // Try to take another item - let item = world - .spawn((TilePosition::new(5, 4, 0), ItemName("Overflow".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 should still be on the ground - assert!( - world.get::(item).is_some(), - "item stays on ground" - ); - assert!( - world.get::(item).is_none(), - "no CarriedBy when full" - ); - } - - #[test] - fn take_then_place_roundtrip() { - // D-065: full cycle — item on ground → Take → carried → Place → ground again - 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 player_sid = world - .resource_mut::() - .register(player); - - let item = world - .spawn((TilePosition::new(5, 4, 0), ItemName("Manifest Copy".into()))) - .id(); - let item_sid = world - .resource_mut::() - .register(item); - - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(process_player_input); - - // Step 1: Take - world.resource_mut::().push(PlayerInput { - tick: 0, - action: PlayerAction::Interact { - target_entity_id: Some(item_sid.0), - verb: Some("Take".into()), - }, - }); - schedule.run(&mut world); - - assert!( - world.get::(item).is_none(), - "item off ground after Take" - ); - assert_eq!(world.get::(item).unwrap().0, player_sid); - assert_eq!(world.get::(item).unwrap().0, 0); - - // Step 2: Place - world.resource_mut::().push(PlayerInput { - tick: 1, - action: PlayerAction::Interact { - target_entity_id: Some(item_sid.0), - verb: Some("Place".into()), - }, - }); - world.resource_mut::().tick = 1; - schedule.run(&mut world); - - let pos = world - .get::(item) - .expect("item back on ground after Place"); - assert_eq!( - *pos, - TilePosition::new(5, 5, 0), - "placed at player position" - ); - assert!( - world.get::(item).is_none(), - "CarriedBy removed after Place" - ); - assert!( - world.get::(item).is_none(), - "InventorySlot removed after Place" - ); - } - // === Pause Guard Tests (#461, #462, #463) === // Prevent Bug #3 recurrence: player movement while paused. // The pause guard at process_player_input discards movement inputs @@ -2145,595 +855,4 @@ mod tests { "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 - let mut world = bevy_ecs::world::World::new(); - world.insert_resource(InputQueue::default()); - world.insert_resource(SimulationTime::default()); - world.init_resource::(); - - world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0))); - - world.resource_mut::().push(PlayerInput { - tick: 0, - action: PlayerAction::Interact { - target_entity_id: None, - verb: Some("Take".into()), - }, - }); - - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(process_player_input); - schedule.run(&mut world); // should not panic - } - - #[test] - fn place_without_target_id_is_noop() { - // Edge case: Place verb with no target_entity_id should not panic - let mut world = bevy_ecs::world::World::new(); - world.insert_resource(InputQueue::default()); - world.insert_resource(SimulationTime::default()); - world.init_resource::(); - - world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0))); - - world.resource_mut::().push(PlayerInput { - tick: 0, - action: PlayerAction::Interact { - target_entity_id: None, - verb: Some("Place".into()), - }, - }); - - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(process_player_input); - schedule.run(&mut world); // should not panic - } - - // === Room Reset Tests (#490) === - - #[test] - fn reset_verb_restores_floor_item() { - // #490: Take a floor item, then Reset verb restores it to original position. - let mut world = bevy_ecs::world::World::new(); - world.insert_resource(InputQueue::default()); - world.insert_resource(SimulationTime::default()); - world.init_resource::(); - - // Player - let player = world - .spawn((PlayerCharacter, TilePosition::new(5, 5, 0))) - .id(); - let player_sid = world - .resource_mut::() - .register(player); - - // Floor item at (5, 4) - let item = world - .spawn((TilePosition::new(5, 4, 0), ItemName("Keycard".into()))) - .id(); - let item_sid = world - .resource_mut::() - .register(item); - - // Reset plate entity - let plate = world - .spawn(( - crate::simulation::interaction::Interactable, - RoomResetTrigger { - room_name: "test_room".to_string(), - }, - TilePosition::new(5, 3, 0), - )) - .id(); - let plate_sid = world - .resource_mut::() - .register(plate); - - // Record snapshot: item is a floor item at its original position - let mut snapshots = RoomSnapshots::default(); - snapshots.record("test_room", item, TilePosition::new(5, 4, 0), true); - world.insert_resource(snapshots); - - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(process_player_input); - - // Step 1: Take the item - world.resource_mut::().push(PlayerInput { - tick: 0, - action: PlayerAction::Interact { - target_entity_id: Some(item_sid.0), - verb: Some("Take".into()), - }, - }); - schedule.run(&mut world); - - assert!( - world.get::(item).is_none(), - "Item should be picked up" - ); - assert_eq!(world.get::(item).unwrap().0, player_sid); - - // Step 2: Reset via verb - world.resource_mut::().push(PlayerInput { - tick: 1, - action: PlayerAction::Interact { - target_entity_id: Some(plate_sid.0), - verb: Some("Reset".into()), - }, - }); - world.resource_mut::().tick = 1; - schedule.run(&mut world); - - // Item should be back on the ground at original position - let pos = world - .get::(item) - .expect("Item should be restored to ground"); - assert_eq!( - *pos, - TilePosition::new(5, 4, 0), - "Item at original position" - ); - assert!( - world.get::(item).is_none(), - "CarriedBy removed after reset" - ); - assert!( - world.get::(item).is_none(), - "InventorySlot removed after reset" - ); - } - - #[test] - fn reset_verb_debounces() { - // #490: Reset debounce prevents rapid-fire resets. - 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(); - - // NPC entity - let npc = world.spawn(TilePosition::new(10, 10, 0)).id(); - world - .resource_mut::() - .register(npc); - - // Reset plate - let plate = world - .spawn(( - crate::simulation::interaction::Interactable, - RoomResetTrigger { - room_name: "test_room".to_string(), - }, - TilePosition::new(5, 3, 0), - )) - .id(); - let plate_sid = world - .resource_mut::() - .register(plate); - - let mut snapshots = RoomSnapshots::default(); - snapshots.record("test_room", npc, TilePosition::new(10, 10, 0), false); - world.insert_resource(snapshots); - - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(process_player_input); - - // First reset at tick 0 — should succeed - world.resource_mut::().push(PlayerInput { - tick: 0, - action: PlayerAction::Interact { - target_entity_id: Some(plate_sid.0), - verb: Some("Reset".into()), - }, - }); - schedule.run(&mut world); - - // Move NPC to verify debounce blocks second reset - *world.get_mut::(npc).unwrap() = TilePosition::new(20, 20, 0); - - // Second reset at tick 5 — should be debounced (< 10 ticks) - world.resource_mut::().push(PlayerInput { - tick: 5, - action: PlayerAction::Interact { - target_entity_id: Some(plate_sid.0), - verb: Some("Reset".into()), - }, - }); - world.resource_mut::().tick = 5; - schedule.run(&mut world); - - // NPC should still be at moved position (reset was debounced) - assert_eq!( - world.get::(npc).unwrap().x, - 20, - "NPC not reset — debounced" - ); - - // Third reset at tick 10 — should succeed - world.resource_mut::().push(PlayerInput { - tick: 10, - action: PlayerAction::Interact { - target_entity_id: Some(plate_sid.0), - verb: Some("Reset".into()), - }, - }); - world.resource_mut::().tick = 10; - schedule.run(&mut world); - - // NPC should be back at original position - assert_eq!( - world.get::(npc).unwrap().x, - 10, - "NPC reset after debounce elapsed" - ); - } - - #[test] - fn reset_verb_without_snapshots_is_noop() { - // Reset verb when no RoomSnapshots resource exists should not panic. - let mut world = bevy_ecs::world::World::new(); - world.insert_resource(InputQueue::default()); - world.insert_resource(SimulationTime::default()); - world.init_resource::(); - - world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0))); - - let plate = world - .spawn(( - crate::simulation::interaction::Interactable, - RoomResetTrigger { - room_name: "test_room".to_string(), - }, - TilePosition::new(5, 3, 0), - )) - .id(); - let plate_sid = world - .resource_mut::() - .register(plate); - - // No RoomSnapshots resource inserted — should be gracefully handled - world.resource_mut::().push(PlayerInput { - tick: 0, - action: PlayerAction::Interact { - target_entity_id: Some(plate_sid.0), - verb: Some("Reset".into()), - }, - }); - - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(process_player_input); - schedule.run(&mut world); // should not panic - } - - // === TeleportToHub Tests (#491) === - - #[cfg(feature = "gauntlet")] - #[test] - fn teleport_to_hub_moves_player() { - // #491: TeleportToHub moves player to hub spawn position. - let mut world = bevy_ecs::world::World::new(); - world.insert_resource(InputQueue::default()); - world.insert_resource(SimulationTime::default()); - world.init_resource::(); - - // Spawn player at a non-hub position - let player = world - .spawn((PlayerCharacter, TilePosition::new(84, 58, 0))) - .id(); - - world.resource_mut::().push(PlayerInput { - tick: 0, - action: PlayerAction::TeleportToHub, - }); - - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(process_player_input); - schedule.run(&mut world); - - let pos = world - .get::(player) - .expect("player has position"); - let hub_spawn = crate::test_world::constants::HUB.spawn; - assert_eq!(pos.x, hub_spawn.x, "player x at hub spawn"); - assert_eq!(pos.y, hub_spawn.y, "player y at hub spawn"); - assert_eq!(pos.z, hub_spawn.z, "player z at hub spawn"); - } - - #[cfg(feature = "gauntlet")] - #[test] - fn teleport_to_hub_clears_dialogue_markers() { - // #491: TeleportToHub removes ActiveDialogue, TalkRequest, - // WalkAwayRequest, and ConfrontationDelivered. - let mut world = bevy_ecs::world::World::new(); - world.insert_resource(InputQueue::default()); - world.insert_resource(SimulationTime::default()); - world.init_resource::(); - - // Spawn a fake NPC target - let npc = world.spawn(TilePosition::new(10, 10, 0)).id(); - - // Spawn player with active dialogue state + mid-confrontation marker - let player = world - .spawn(( - PlayerCharacter, - TilePosition::new(84, 58, 0), - crate::simulation::dialogue::TalkRequest { target: npc }, - crate::simulation::dialogue::ActiveDialogue { - target: npc, - interaction_type: crate::knowledge::events::InteractionType::Talk, - started_tick: 0, - }, - crate::simulation::dialogue::WalkAwayRequest, - crate::simulation::dialogue::ConfrontationDelivered { target: npc }, - )) - .id(); - - world.resource_mut::().push(PlayerInput { - tick: 0, - action: PlayerAction::TeleportToHub, - }); - - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(process_player_input); - schedule.run(&mut world); - - assert!( - world - .get::(player) - .is_none(), - "TalkRequest cleared after teleport" - ); - assert!( - world - .get::(player) - .is_none(), - "ActiveDialogue cleared after teleport" - ); - assert!( - world - .get::(player) - .is_none(), - "WalkAwayRequest cleared after teleport" - ); - assert!( - world - .get::(player) - .is_none(), - "ConfrontationDelivered cleared after teleport" - ); - } - - #[cfg(feature = "gauntlet")] - #[test] - fn teleport_to_hub_clears_move_intent() { - // #491: TeleportToHub removes any pending MoveIntent. - 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(84, 58, 0), - MoveIntent { - target: TilePosition::new(85, 58, 0), - }, - )) - .id(); - - world.resource_mut::().push(PlayerInput { - tick: 0, - action: PlayerAction::TeleportToHub, - }); - - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(process_player_input); - schedule.run(&mut world); - - assert!( - world.get::(player).is_none(), - "MoveIntent cleared after teleport" - ); - } - - #[cfg(feature = "gauntlet")] - #[test] - fn teleport_to_hub_allowed_while_paused() { - // #491: TeleportToHub is a QA action — allowed even when 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(84, 58, 0))) - .id(); - - world.resource_mut::().push(PlayerInput { - tick: 0, - action: PlayerAction::TeleportToHub, - }); - - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(process_player_input); - schedule.run(&mut world); - - let pos = world - .get::(player) - .expect("player has position"); - let hub_spawn = crate::test_world::constants::HUB.spawn; - assert_eq!(pos.x, hub_spawn.x, "teleport works while paused"); - } - - fn make_bookmark_world() -> bevy_ecs::world::World { - use crate::bookmark::types::{BookmarkDefinition, BookmarkId, CareerKind}; - use crate::bookmark::{BookmarkRegistry, SelectedBookmark}; - use crate::bridge::types::SimErrorBuffer; - - let mut world = bevy_ecs::world::World::new(); - world.insert_resource(InputQueue::default()); - world.insert_resource(SimulationTime::default()); - world.init_resource::(); - world.init_resource::(); - world.init_resource::(); - - let mut registry = BookmarkRegistry::default(); - registry.insert(BookmarkDefinition { - id: BookmarkId("test_bookmark".to_string()), - title: "Test Bookmark".into(), - subtitle: String::new(), - flavor: String::new(), - default_location: "Loc A".into(), - allowed_locations: vec!["Loc A".into(), "Loc B".into()], - career: CareerKind::Tycoon, - starting_capital_tractus: 1_000, - available: true, - }); - world.insert_resource(registry); - - world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0))); - world - } - - #[test] - fn confirm_bookmark_unknown_id_emits_sim_error() { - use crate::bridge::types::SimErrorBuffer; - - let mut world = make_bookmark_world(); - world.resource_mut::().push(PlayerInput { - tick: 0, - action: PlayerAction::ConfirmBookmark { - bookmark_id: "no_such_bookmark".into(), - starting_location_id: "Loc A".into(), - }, - }); - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(process_player_input); - schedule.run(&mut world); - - let errors = world.resource_mut::().drain(); - assert_eq!( - errors.len(), - 1, - "expected one SimError for unknown bookmark" - ); - assert_eq!( - errors[0].kind, - crate::bridge::types::SimErrorKind::ProtocolError - ); - assert!(errors[0].message.contains("unknown bookmark_id")); - assert!( - world - .resource::() - .bookmark_id - .is_none(), - "SelectedBookmark must not be set on error" - ); - } - - #[test] - fn confirm_bookmark_invalid_location_emits_sim_error() { - use crate::bridge::types::SimErrorBuffer; - - let mut world = make_bookmark_world(); - world.resource_mut::().push(PlayerInput { - tick: 0, - action: PlayerAction::ConfirmBookmark { - bookmark_id: "test_bookmark".into(), - starting_location_id: "Not A Location".into(), - }, - }); - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(process_player_input); - schedule.run(&mut world); - - let errors = world.resource_mut::().drain(); - assert_eq!( - errors.len(), - 1, - "expected one SimError for invalid location" - ); - assert_eq!( - errors[0].kind, - crate::bridge::types::SimErrorKind::ProtocolError - ); - assert!(errors[0].message.contains("not in allowed_locations")); - } - - #[test] - fn confirm_bookmark_valid_inputs_populate_selected_bookmark() { - use crate::bridge::types::SimErrorBuffer; - - let mut world = make_bookmark_world(); - world.resource_mut::().push(PlayerInput { - tick: 0, - action: PlayerAction::ConfirmBookmark { - bookmark_id: "test_bookmark".into(), - starting_location_id: "Loc B".into(), - }, - }); - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(process_player_input); - schedule.run(&mut world); - - let errors = world.resource_mut::().drain(); - assert!( - errors.is_empty(), - "no errors expected for valid ConfirmBookmark" - ); - - let sel = world.resource::(); - assert_eq!(sel.bookmark_id.as_deref(), Some("test_bookmark")); - assert_eq!(sel.starting_location_id.as_deref(), Some("Loc B")); - } - - #[test] - fn confirm_bookmark_double_confirm_emits_sim_error() { - use crate::bridge::types::SimErrorBuffer; - - let mut world = make_bookmark_world(); - - // Both confirms at tick=0: processed in order within the same run. - // First succeeds; second hits the idempotency guard. - world.resource_mut::().push(PlayerInput { - tick: 0, - action: PlayerAction::ConfirmBookmark { - bookmark_id: "test_bookmark".into(), - starting_location_id: "Loc A".into(), - }, - }); - world.resource_mut::().push(PlayerInput { - tick: 0, - action: PlayerAction::ConfirmBookmark { - bookmark_id: "test_bookmark".into(), - starting_location_id: "Loc B".into(), - }, - }); - - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(process_player_input); - schedule.run(&mut world); - - let errors = world.resource_mut::().drain(); - assert_eq!(errors.len(), 1, "double-confirm must emit ProtocolError"); - assert_eq!( - errors[0].kind, - crate::bridge::types::SimErrorKind::ProtocolError - ); - assert!(errors[0].message.contains("already confirmed")); - - // Selection must remain the original, not overwritten - let sel = world.resource::(); - assert_eq!(sel.starting_location_id.as_deref(), Some("Loc A")); - } } diff --git a/server/src/simulation/interaction.rs b/server/src/simulation/interaction.rs index 7b897013a..b9734097e 100644 --- a/server/src/simulation/interaction.rs +++ b/server/src/simulation/interaction.rs @@ -481,6 +481,96 @@ pub fn process_terminal_interaction( ); } +/// Handle door Open/Close: insert `DoorInteractRequest` on the player entity (#246). +/// +/// The actual walkability toggle is done by `process_door_interaction` which +/// reads the request and modifies `WalkabilityMap`. The split keeps system +/// ordering explicit and avoids mutable resource conflicts in one system. +pub fn handle_door_interact( + commands: &mut Commands, + registry: &EntityRegistry, + player_query: &crate::simulation::input::PlayerInputQuery, + door_states: &Query<&DoorState>, + target_entity_id: Option, +) { + let Some(target_id) = target_entity_id else { + tracing::warn!("Door verb without target_entity_id"); + return; + }; + + let target_stable = StableId(target_id); + let Some(target_entity) = registry.to_entity(&target_stable) else { + tracing::warn!(target_id, "Door interact: target entity not in registry"); + return; + }; + + // Verify target has DoorState before inserting request + if door_states.get(target_entity).is_err() { + tracing::warn!(target_id, "Door verb on entity without DoorState — ignored"); + return; + } + + let Ok((player_entity, _, _, _)) = player_query.single() else { + return; + }; + + commands.entity(player_entity).insert(DoorInteractRequest { + door_entity: target_entity, + }); + + tracing::debug!( + target_id, + "Door interact: DoorInteractRequest inserted on player" + ); +} + +/// Handle Terminal Use: insert `TerminalInteractRequest` on the player entity (#246). +pub fn handle_terminal_interact( + commands: &mut Commands, + registry: &EntityRegistry, + player_query: &crate::simulation::input::PlayerInputQuery, + object_types: &Query<&ObjectType>, + target_entity_id: Option, +) { + let Some(target_id) = target_entity_id else { + tracing::warn!("Use verb without target_entity_id"); + return; + }; + + let target_stable = StableId(target_id); + let Some(target_entity) = registry.to_entity(&target_stable) else { + tracing::warn!( + target_id, + "Terminal interact: target entity not in registry" + ); + return; + }; + + // Verify target is a Terminal + match object_types.get(target_entity) { + Ok(ObjectType::Terminal) => {} + _ => { + tracing::warn!(target_id, "Use verb on non-Terminal entity — ignored"); + return; + } + } + + let Ok((player_entity, _, _, _)) = player_query.single() else { + return; + }; + + commands + .entity(player_entity) + .insert(TerminalInteractRequest { + terminal_entity: target_entity, + }); + + tracing::debug!( + target_id, + "Terminal interact: TerminalInteractRequest inserted on player" + ); +} + #[cfg(test)] mod tests { use super::*; diff --git a/server/src/simulation/inventory.rs b/server/src/simulation/inventory.rs index 01b68b34d..0667fda25 100644 --- a/server/src/simulation/inventory.rs +++ b/server/src/simulation/inventory.rs @@ -82,6 +82,102 @@ pub fn occupied_slots_for( .collect() } +/// Handle Take verb: pick up an item entity and add it to the player's inventory. +/// Removes TilePosition (item is no longer on the ground — info boundary enforcement), +/// adds CarriedBy + InventorySlot components. +pub fn handle_take( + commands: &mut Commands, + registry: &crate::knowledge::EntityRegistry, + player_query: &crate::simulation::input::PlayerInputQuery, + inventory_items: &Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>, + target_entity_id: Option, +) { + let Some(target_id) = target_entity_id else { + tracing::warn!("Take verb without target_entity_id"); + return; + }; + + let Ok((player_entity, _, _, _)) = player_query.single() else { + return; + }; + + let Some(player_sid) = registry.to_stable(player_entity) else { + tracing::error!("Player entity not in EntityRegistry"); + return; + }; + + // Resolve wire ID to ECS entity + let target_stable = StableId(target_id); + let Some(target_entity) = registry.to_entity(&target_stable) else { + tracing::warn!(target_id, "Take: target entity not in registry"); + return; + }; + + // Check inventory capacity + let occupied = occupied_slots_for(player_sid, inventory_items); + let Some(slot) = find_next_slot(&occupied) else { + tracing::info!( + "Inventory full ({} slots), cannot take item", + MAX_INVENTORY_SLOTS + ); + return; + }; + + // Remove TilePosition (item leaves the ground), add CarriedBy + InventorySlot + commands + .entity(target_entity) + .remove::() + .insert((CarriedBy(player_sid), InventorySlot(slot))); + + tracing::info!( + target_id, + slot, + "Take: item picked up and added to inventory slot", + ); +} + +/// 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. +pub fn handle_place( + commands: &mut Commands, + registry: &crate::knowledge::EntityRegistry, + player_query: &crate::simulation::input::PlayerInputQuery, + target_entity_id: Option, +) { + let Some(target_id) = target_entity_id else { + tracing::warn!("Place verb without target_entity_id"); + return; + }; + + let Ok((_, player_pos, _, _)) = 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, "Place: target entity not in registry"); + return; + }; + + let place_pos = *player_pos; + + // Remove inventory components, place item at player's tile + commands + .entity(target_entity) + .remove::() + .remove::() + .insert(place_pos); + + tracing::info!( + target_id, + x = place_pos.x, + y = place_pos.y, + z = place_pos.z, + "Place: item dropped at player position", + ); +} + #[cfg(test)] mod tests { use super::*; @@ -263,4 +359,304 @@ mod tests { fn max_slots_is_nine() { assert_eq!(MAX_INVENTORY_SLOTS, 9); } + + // ----------------------------------------------------------------------- + // Take/Place verb tests (#424, D-065; moved from input.rs, T-1062) + // ----------------------------------------------------------------------- + + use crate::bridge::types::{PlayerAction, PlayerInput}; + use crate::simulation::input::{process_player_input, InputQueue}; + use crate::simulation::movement::{PlayerCharacter, TilePosition}; + use crate::simulation::time::SimulationTime; + + fn setup_verb_world() -> World { + let mut world = World::new(); + world.insert_resource(InputQueue::default()); + world.insert_resource(SimulationTime::default()); + world.init_resource::(); + world + } + + #[test] + fn take_verb_picks_up_item() { + let mut world = setup_verb_world(); + + // Spawn player and register + let player = world + .spawn((PlayerCharacter, TilePosition::new(5, 5, 0))) + .id(); + let player_sid = world.resource_mut::().register(player); + + // Spawn item near player + let item = world + .spawn((TilePosition::new(5, 4, 0), ItemName("Manifest Copy".into()))) + .id(); + let item_sid = world.resource_mut::().register(item); + + // Issue Take verb + 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 should have CarriedBy + InventorySlot, no TilePosition + assert!( + world.get::(item).is_none(), + "item should leave the ground" + ); + let carried = world + .get::(item) + .expect("item should have CarriedBy"); + assert_eq!(carried.0, player_sid); + let slot = world + .get::(item) + .expect("item should have slot"); + assert_eq!(slot.0, 0, "first item goes to slot 0"); + } + + #[test] + fn place_verb_drops_item() { + let mut world = setup_verb_world(); + + let player = world + .spawn((PlayerCharacter, TilePosition::new(5, 5, 0))) + .id(); + let player_sid = world.resource_mut::().register(player); + + // Spawn item already in inventory (no TilePosition) + let item = world + .spawn(( + CarriedBy(player_sid), + ItemName("Manifest Copy".into()), + InventorySlot(0), + )) + .id(); + let item_sid = world.resource_mut::().register(item); + + // Issue Place verb + world.resource_mut::().push(PlayerInput { + tick: 0, + action: PlayerAction::Interact { + target_entity_id: Some(item_sid.0), + verb: Some("Place".into()), + }, + }); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_player_input); + schedule.run(&mut world); + + // Item should have TilePosition at player's location, no CarriedBy/InventorySlot + let pos = world + .get::(item) + .expect("item should be on ground"); + assert_eq!( + *pos, + TilePosition::new(5, 5, 0), + "placed at player position" + ); + assert!(world.get::(item).is_none(), "CarriedBy removed"); + assert!( + world.get::(item).is_none(), + "InventorySlot removed" + ); + } + + #[test] + fn take_verb_assigns_sequential_slots() { + let mut world = setup_verb_world(); + + let player = world + .spawn((PlayerCharacter, TilePosition::new(5, 5, 0))) + .id(); + let player_sid = world.resource_mut::().register(player); + + // Item already in slot 0 + world.spawn(( + CarriedBy(player_sid), + ItemName("Manifest".into()), + InventorySlot(0), + )); + + // New item on the ground + let item2 = world + .spawn((TilePosition::new(5, 4, 0), ItemName("Token".into()))) + .id(); + let item2_sid = world.resource_mut::().register(item2); + + world.resource_mut::().push(PlayerInput { + tick: 0, + action: PlayerAction::Interact { + target_entity_id: Some(item2_sid.0), + verb: Some("Take".into()), + }, + }); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_player_input); + schedule.run(&mut world); + + let slot = world + .get::(item2) + .expect("item should have slot"); + assert_eq!(slot.0, 1, "second item goes to slot 1"); + } + + #[test] + fn take_verb_full_inventory_rejected() { + let mut world = setup_verb_world(); + + let player = world + .spawn((PlayerCharacter, TilePosition::new(5, 5, 0))) + .id(); + let player_sid = world.resource_mut::().register(player); + + // Fill all 9 slots + for slot in 0..MAX_INVENTORY_SLOTS { + world.spawn(( + CarriedBy(player_sid), + ItemName(format!("Item {}", slot)), + InventorySlot(slot), + )); + } + + // Try to take another item + let item = world + .spawn((TilePosition::new(5, 4, 0), ItemName("Overflow".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 should still be on the ground + assert!( + world.get::(item).is_some(), + "item stays on ground" + ); + assert!( + world.get::(item).is_none(), + "no CarriedBy when full" + ); + } + + #[test] + fn take_then_place_roundtrip() { + // D-065: full cycle — item on ground → Take → carried → Place → ground again + let mut world = setup_verb_world(); + + let player = world + .spawn((PlayerCharacter, TilePosition::new(5, 5, 0))) + .id(); + let player_sid = world.resource_mut::().register(player); + + let item = world + .spawn((TilePosition::new(5, 4, 0), ItemName("Manifest Copy".into()))) + .id(); + let item_sid = world.resource_mut::().register(item); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_player_input); + + // Step 1: Take + world.resource_mut::().push(PlayerInput { + tick: 0, + action: PlayerAction::Interact { + target_entity_id: Some(item_sid.0), + verb: Some("Take".into()), + }, + }); + schedule.run(&mut world); + + assert!( + world.get::(item).is_none(), + "item off ground after Take" + ); + assert_eq!(world.get::(item).unwrap().0, player_sid); + assert_eq!(world.get::(item).unwrap().0, 0); + + // Step 2: Place + world.resource_mut::().push(PlayerInput { + tick: 1, + action: PlayerAction::Interact { + target_entity_id: Some(item_sid.0), + verb: Some("Place".into()), + }, + }); + world.resource_mut::().tick = 1; + schedule.run(&mut world); + + let pos = world + .get::(item) + .expect("item back on ground after Place"); + assert_eq!( + *pos, + TilePosition::new(5, 5, 0), + "placed at player position" + ); + assert!( + world.get::(item).is_none(), + "CarriedBy removed after Place" + ); + assert!( + world.get::(item).is_none(), + "InventorySlot removed after Place" + ); + } + + #[test] + fn take_without_target_id_is_noop() { + // Edge case: Take verb with no target_entity_id should not panic + let mut world = setup_verb_world(); + + world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0))); + + world.resource_mut::().push(PlayerInput { + tick: 0, + action: PlayerAction::Interact { + target_entity_id: None, + verb: Some("Take".into()), + }, + }); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_player_input); + schedule.run(&mut world); // should not panic + } + + #[test] + fn place_without_target_id_is_noop() { + // Edge case: Place verb with no target_entity_id should not panic + let mut world = setup_verb_world(); + + world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0))); + + world.resource_mut::().push(PlayerInput { + tick: 0, + action: PlayerAction::Interact { + target_entity_id: None, + verb: Some("Place".into()), + }, + }); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_player_input); + schedule.run(&mut world); // should not panic + } } diff --git a/server/src/simulation/movement.rs b/server/src/simulation/movement.rs index ae5460cc0..b0a296026 100644 --- a/server/src/simulation/movement.rs +++ b/server/src/simulation/movement.rs @@ -11,6 +11,7 @@ use std::collections::BTreeMap; use crate::bridge::types::MovementStance; use crate::knowledge::types::SoundRange; +use crate::perception::vision_cone::{facing_from_delta, Facing}; use crate::simulation::sound::{SoundEvent, SoundEventEmitter, SoundEventKind}; use crate::simulation::stance::Stance; @@ -448,6 +449,39 @@ pub fn validate_movement( } } +/// Apply a movement action with stance-based cooldown enforcement. +/// If the player has a Stance and PlayerMoveCooldown, movement is throttled +/// according to the stance's ticks_per_move. Without these components, +/// movement is unrestricted (backward compatibility). +pub fn apply_move( + player_query: &mut crate::simulation::input::PlayerInputQuery, + commands: &mut Commands, + dx: i32, + dy: i32, +) { + let (entity, pos, stance_opt, cooldown_opt) = player_query + .single_mut() + .expect("PlayerCharacter entity must exist when processing input"); + + let stance = stance_opt.map(|s| s.0).unwrap_or_default(); + + // Check cooldown if present + if let Some(mut cooldown) = cooldown_opt { + if !cooldown.try_move(stance) { + tracing::trace!("Movement throttled by stance {:?} cooldown", stance); + return; + } + } + + commands.entity(entity).insert(MoveIntent { + target: TilePosition::new(pos.x + dx, pos.y + dy, pos.z), + }); + // Update facing direction based on movement (D-015 vision cone) + commands + .entity(entity) + .insert(Facing(facing_from_delta(dx, dy))); +} + #[cfg(test)] mod tests { use super::*; @@ -1283,4 +1317,141 @@ mod tests { let emitter = world.get::(entity).unwrap(); assert!((emitter.pending[0].intensity - 0.15).abs() < f32::EPSILON); } + + // ----------------------------------------------------------------------- + // apply_move stance-cooldown throttle tests (D-053; moved from input.rs, T-1062) + // ----------------------------------------------------------------------- + + use crate::bridge::types::{PlayerAction, PlayerInput}; + use crate::simulation::input::{process_player_input, InputQueue}; + use crate::simulation::stance::PlayerMoveCooldown; + use crate::simulation::time::SimulationTime; + + #[test] + fn walk_stance_throttles_movement_to_every_2_ticks() { + 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), + Stance::default(), // Walk + PlayerMoveCooldown::default(), + )) + .id(); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_player_input); + + // Tick 0: move north — should succeed (first move) + world.resource_mut::().push(PlayerInput { + tick: 0, + action: PlayerAction::MoveNorth, + }); + schedule.run(&mut world); + assert!( + world.get::(player).is_some(), + "first move should succeed" + ); + + // Remove MoveIntent (simulating validate_movement consuming it) + world.entity_mut(player).remove::(); + + // Tick 0 again: move north — should be throttled (cooldown) + world.resource_mut::().push(PlayerInput { + tick: 0, + action: PlayerAction::MoveNorth, + }); + schedule.run(&mut world); + assert!( + world.get::(player).is_none(), + "second move should be throttled" + ); + + // Tick 0 again: move north — should succeed (cooldown elapsed) + world.resource_mut::().push(PlayerInput { + tick: 0, + action: PlayerAction::MoveNorth, + }); + schedule.run(&mut world); + assert!( + world.get::(player).is_some(), + "third move should succeed after cooldown" + ); + } + + #[test] + fn sprint_stance_allows_every_tick() { + 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), + Stance(MovementStance::Sprint), + PlayerMoveCooldown::default(), + )) + .id(); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_player_input); + + // First move + world.resource_mut::().push(PlayerInput { + tick: 0, + action: PlayerAction::MoveNorth, + }); + schedule.run(&mut world); + assert!(world.get::(player).is_some()); + world.entity_mut(player).remove::(); + + // Second move — sprint allows every tick + world.resource_mut::().push(PlayerInput { + tick: 0, + action: PlayerAction::MoveNorth, + }); + schedule.run(&mut world); + assert!( + world.get::(player).is_some(), + "sprint should allow every tick" + ); + } + + #[test] + fn no_stance_component_moves_unrestricted() { + // Backward compatibility: entities without Stance/Cooldown move freely + 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); + + world.resource_mut::().push(PlayerInput { + tick: 0, + action: PlayerAction::MoveNorth, + }); + schedule.run(&mut world); + assert!(world.get::(player).is_some()); + world.entity_mut(player).remove::(); + + // Second move immediately — no throttle without components + world.resource_mut::().push(PlayerInput { + tick: 0, + action: PlayerAction::MoveNorth, + }); + schedule.run(&mut world); + assert!(world.get::(player).is_some()); + } } diff --git a/server/src/simulation/save_io.rs b/server/src/simulation/save_io.rs index 92d020303..000a39125 100644 --- a/server/src/simulation/save_io.rs +++ b/server/src/simulation/save_io.rs @@ -68,6 +68,31 @@ pub struct SaveLoadPending { pub pending: Option, } +/// Queue a save/load command from player input (#553). +/// At most one command can be pending; a newer command overwrites it. +/// `None` means the resource was never registered (warn and drop). +pub fn queue_save_load(pending: Option<&mut SaveLoadPending>, cmd: SaveLoadCommand) { + let (label, path) = match &cmd { + SaveLoadCommand::Save { path } => ("SaveGame", path), + SaveLoadCommand::Load { path } => ("LoadGame", path), + }; + let Some(sl) = pending else { + tracing::warn!( + "{} received but SaveLoadPending resource not registered", + label + ); + return; + }; + if sl.pending.is_some() { + tracing::warn!( + "{} overwrites already-pending save/load command (dropped)", + label + ); + } + tracing::info!("{} queued: {:?}", label, path); + sl.pending = Some(cmd); +} + /// Extract world state into `SaveStateV1` and write MessagePack bytes to `path` (#553). /// /// Queries all NPC entities, the player knowledge graph, global relationship graph, diff --git a/server/src/simulation/stance.rs b/server/src/simulation/stance.rs index 0109f405b..1f49f8c48 100644 --- a/server/src/simulation/stance.rs +++ b/server/src/simulation/stance.rs @@ -96,6 +96,30 @@ impl PlayerMoveCooldown { } } +/// Step the player's stance one level up or down the ladder (D-053). +/// No-op when the player has no Stance component or is already at the end. +pub fn handle_toggle_stance( + player_query: &mut crate::simulation::input::PlayerInputQuery, + up: bool, +) { + if let Ok((_, _, Some(mut stance), _)) = player_query.single_mut() { + let new_stance = if up { + stance.0.step_up() + } else { + stance.0.step_down() + }; + if new_stance != stance.0 { + tracing::debug!( + "Stance {}: {:?} -> {:?}", + if up { "up" } else { "down" }, + stance.0, + new_stance + ); + stance.0 = new_stance; + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -253,4 +277,69 @@ mod tests { let stance = world.get::(entity).unwrap(); assert_eq!(stance.0, MovementStance::Walk); } + + // ----------------------------------------------------------------------- + // handle_toggle_stance ladder tests (moved from input.rs, T-1062) + // ----------------------------------------------------------------------- + + use crate::bridge::types::{PlayerAction, PlayerInput}; + use crate::simulation::input::{process_player_input, InputQueue}; + use crate::simulation::movement::{PlayerCharacter, TilePosition}; + use crate::simulation::time::SimulationTime; + + #[test] + fn toggle_stance_up_changes_stance() { + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(InputQueue::default()); + world.insert_resource(SimulationTime::default()); + world.init_resource::(); + + world.spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + Stance::default(), + 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::Sprint); + } + + #[test] + fn toggle_stance_down_changes_stance() { + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(InputQueue::default()); + world.insert_resource(SimulationTime::default()); + world.init_resource::(); + + world.spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + Stance::default(), + PlayerMoveCooldown::default(), + )); + + world.resource_mut::().push(PlayerInput { + tick: 0, + action: PlayerAction::ToggleStanceDown, + }); + + 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::Careful); + } } diff --git a/server/src/test_world/mod.rs b/server/src/test_world/mod.rs index c19831ed1..e687a03f9 100644 --- a/server/src/test_world/mod.rs +++ b/server/src/test_world/mod.rs @@ -45,6 +45,7 @@ pub mod invariants; pub mod reset; #[cfg(feature = "gauntlet")] pub mod rooms; +pub mod teleport; #[cfg(feature = "gauntlet")] use bevy_app::prelude::*; diff --git a/server/src/test_world/reset.rs b/server/src/test_world/reset.rs index 802e50a0a..5ecca27fc 100644 --- a/server/src/test_world/reset.rs +++ b/server/src/test_world/reset.rs @@ -115,6 +115,67 @@ impl RoomSnapshots { } } +/// Handle Reset verb: restore a room's entities to their initial positions. +/// Target entity must have a RoomResetTrigger component. Respects debounce. +pub fn handle_reset( + commands: &mut Commands, + registry: &crate::knowledge::EntityRegistry, + reset_triggers: &Query<&RoomResetTrigger>, + room_snapshots: &mut Option>, + target_entity_id: Option, + current_tick: u64, +) { + let Some(target_id) = target_entity_id else { + tracing::warn!("Reset verb without target_entity_id"); + return; + }; + + let Some(snapshots) = room_snapshots.as_mut() else { + tracing::warn!("Reset verb but RoomSnapshots resource not available"); + return; + }; + + let target_stable = crate::knowledge::StableId(target_id); + let Some(target_entity) = registry.to_entity(&target_stable) else { + tracing::warn!(target_id, "Reset: target entity not in registry"); + return; + }; + + let Ok(trigger) = reset_triggers.get(target_entity) else { + tracing::warn!(target_id, "Reset: target is not a reset trigger"); + return; + }; + + let Some(changes) = snapshots.plan_reset(&trigger.room_name, current_tick) else { + tracing::info!( + room = trigger.room_name.as_str(), + "Reset: debounced or unknown room" + ); + return; + }; + + let mut restored = 0; + for (entity, position, is_floor_item) in changes { + if is_floor_item { + commands + .entity(entity) + .remove::() + .remove::() + .insert(position); + } else { + commands.entity(entity).insert(position); + } + restored += 1; + } + + tracing::info!( + room = trigger.room_name.as_str(), + restored, + current_tick, + "Room reset executed via Reset verb" + ); +} + #[cfg(test)] mod tests { use super::*; @@ -208,4 +269,221 @@ mod tests { assert!(snapshots.can_reset("room", 110)); assert!(snapshots.can_reset("room", 200)); } + + // ----------------------------------------------------------------------- + // Reset verb tests (#490; moved from input.rs, T-1062) + // ----------------------------------------------------------------------- + + use crate::bridge::types::{PlayerAction, PlayerInput}; + use crate::knowledge::EntityRegistry; + use crate::simulation::input::{process_player_input, InputQueue}; + use crate::simulation::inventory::{CarriedBy, InventorySlot, ItemName}; + use crate::simulation::movement::PlayerCharacter; + use crate::simulation::time::SimulationTime; + + #[test] + fn reset_verb_restores_floor_item() { + // #490: Take a floor item, then Reset verb restores it to original position. + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(InputQueue::default()); + world.insert_resource(SimulationTime::default()); + world.init_resource::(); + + // Player + let player = world + .spawn((PlayerCharacter, TilePosition::new(5, 5, 0))) + .id(); + let player_sid = world.resource_mut::().register(player); + + // Floor item at (5, 4) + let item = world + .spawn((TilePosition::new(5, 4, 0), ItemName("Keycard".into()))) + .id(); + let item_sid = world.resource_mut::().register(item); + + // Reset plate entity + let plate = world + .spawn(( + crate::simulation::interaction::Interactable, + RoomResetTrigger { + room_name: "test_room".to_string(), + }, + TilePosition::new(5, 3, 0), + )) + .id(); + let plate_sid = world.resource_mut::().register(plate); + + // Record snapshot: item is a floor item at its original position + let mut snapshots = RoomSnapshots::default(); + snapshots.record("test_room", item, TilePosition::new(5, 4, 0), true); + world.insert_resource(snapshots); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_player_input); + + // Step 1: Take the item + world.resource_mut::().push(PlayerInput { + tick: 0, + action: PlayerAction::Interact { + target_entity_id: Some(item_sid.0), + verb: Some("Take".into()), + }, + }); + schedule.run(&mut world); + + assert!( + world.get::(item).is_none(), + "Item should be picked up" + ); + assert_eq!(world.get::(item).unwrap().0, player_sid); + + // Step 2: Reset via verb + world.resource_mut::().push(PlayerInput { + tick: 1, + action: PlayerAction::Interact { + target_entity_id: Some(plate_sid.0), + verb: Some("Reset".into()), + }, + }); + world.resource_mut::().tick = 1; + schedule.run(&mut world); + + // Item should be back on the ground at original position + let pos = world + .get::(item) + .expect("Item should be restored to ground"); + assert_eq!( + *pos, + TilePosition::new(5, 4, 0), + "Item at original position" + ); + assert!( + world.get::(item).is_none(), + "CarriedBy removed after reset" + ); + assert!( + world.get::(item).is_none(), + "InventorySlot removed after reset" + ); + } + + #[test] + fn reset_verb_debounces() { + // #490: Reset debounce prevents rapid-fire resets. + 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(); + + // NPC entity + let npc = world.spawn(TilePosition::new(10, 10, 0)).id(); + world.resource_mut::().register(npc); + + // Reset plate + let plate = world + .spawn(( + crate::simulation::interaction::Interactable, + RoomResetTrigger { + room_name: "test_room".to_string(), + }, + TilePosition::new(5, 3, 0), + )) + .id(); + let plate_sid = world.resource_mut::().register(plate); + + let mut snapshots = RoomSnapshots::default(); + snapshots.record("test_room", npc, TilePosition::new(10, 10, 0), false); + world.insert_resource(snapshots); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_player_input); + + // First reset at tick 0 — should succeed + world.resource_mut::().push(PlayerInput { + tick: 0, + action: PlayerAction::Interact { + target_entity_id: Some(plate_sid.0), + verb: Some("Reset".into()), + }, + }); + schedule.run(&mut world); + + // Move NPC to verify debounce blocks second reset + *world.get_mut::(npc).unwrap() = TilePosition::new(20, 20, 0); + + // Second reset at tick 5 — should be debounced (< 10 ticks) + world.resource_mut::().push(PlayerInput { + tick: 5, + action: PlayerAction::Interact { + target_entity_id: Some(plate_sid.0), + verb: Some("Reset".into()), + }, + }); + world.resource_mut::().tick = 5; + schedule.run(&mut world); + + // NPC should still be at moved position (reset was debounced) + assert_eq!( + world.get::(npc).unwrap().x, + 20, + "NPC not reset — debounced" + ); + + // Third reset at tick 10 — should succeed + world.resource_mut::().push(PlayerInput { + tick: 10, + action: PlayerAction::Interact { + target_entity_id: Some(plate_sid.0), + verb: Some("Reset".into()), + }, + }); + world.resource_mut::().tick = 10; + schedule.run(&mut world); + + // NPC should be back at original position + assert_eq!( + world.get::(npc).unwrap().x, + 10, + "NPC reset after debounce elapsed" + ); + } + + #[test] + fn reset_verb_without_snapshots_is_noop() { + // Reset verb when no RoomSnapshots resource exists should not panic. + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(InputQueue::default()); + world.insert_resource(SimulationTime::default()); + world.init_resource::(); + + world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0))); + + let plate = world + .spawn(( + crate::simulation::interaction::Interactable, + RoomResetTrigger { + room_name: "test_room".to_string(), + }, + TilePosition::new(5, 3, 0), + )) + .id(); + let plate_sid = world.resource_mut::().register(plate); + + // No RoomSnapshots resource inserted — should be gracefully handled + world.resource_mut::().push(PlayerInput { + tick: 0, + action: PlayerAction::Interact { + target_entity_id: Some(plate_sid.0), + verb: Some("Reset".into()), + }, + }); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_player_input); + schedule.run(&mut world); // should not panic + } } diff --git a/server/src/test_world/teleport.rs b/server/src/test_world/teleport.rs new file mode 100644 index 000000000..0d7b84251 --- /dev/null +++ b/server/src/test_world/teleport.rs @@ -0,0 +1,225 @@ +//! TeleportToHub QA action (#491) — Gauntlet-only return-to-hub verb. +//! +//! Moved out of simulation::input with the dispatcher split (T-1062): the +//! action only exists on the Gauntlet test world, so it lives with it. + +use bevy_ecs::prelude::*; + +use crate::simulation::input::PlayerInputQuery; + +/// Handle TeleportToHub: move player to hub spawn, clear interaction state (#491). +/// +/// Gauntlet-only action. On non-Gauntlet maps (feature disabled), logs a warning +/// and returns. On Gauntlet maps, moves the player to HUB.spawn and removes +/// dialogue, monologue, and interaction markers to prevent stale state. +/// +/// Does NOT affect: room state, inventory, game time, knowledge graph. +pub fn handle_teleport_to_hub(player_query: &mut PlayerInputQuery, commands: &mut Commands) { + #[cfg(not(feature = "gauntlet"))] + { + tracing::warn!("TeleportToHub rejected: not a Gauntlet map"); + return; + } + + #[cfg(feature = "gauntlet")] + { + let Ok((player_entity, _, _, _)) = player_query.single() else { + return; + }; + + let hub_spawn = crate::test_world::constants::HUB.spawn; + + // Move player to hub spawn + commands.entity(player_entity).insert(hub_spawn); + + // Clear any pending movement + commands + .entity(player_entity) + .remove::(); + + // Clear dialogue/interaction markers (including mid-confrontation state) + commands + .entity(player_entity) + .remove::() + .remove::() + .remove::() + .remove::() + .remove::(); + + tracing::info!( + x = hub_spawn.x, + y = hub_spawn.y, + z = hub_spawn.z, + "TeleportToHub: player moved to hub spawn" + ); + } +} + +// --------------------------------------------------------------------------- +// Tests (#491; moved from input.rs, T-1062) +// --------------------------------------------------------------------------- + +#[cfg(all(test, feature = "gauntlet"))] +mod tests { + use crate::bridge::types::{PlayerAction, PlayerInput}; + use crate::simulation::input::{process_player_input, InputQueue}; + use crate::simulation::movement::{MoveIntent, PlayerCharacter, TilePosition}; + use crate::simulation::time::{SimulationTime, TickRate}; + + #[test] + fn teleport_to_hub_moves_player() { + // #491: TeleportToHub moves player to hub spawn position. + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(InputQueue::default()); + world.insert_resource(SimulationTime::default()); + world.init_resource::(); + + // Spawn player at a non-hub position + let player = world + .spawn((PlayerCharacter, TilePosition::new(84, 58, 0))) + .id(); + + world.resource_mut::().push(PlayerInput { + tick: 0, + action: PlayerAction::TeleportToHub, + }); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_player_input); + schedule.run(&mut world); + + let pos = world + .get::(player) + .expect("player has position"); + let hub_spawn = crate::test_world::constants::HUB.spawn; + assert_eq!(pos.x, hub_spawn.x, "player x at hub spawn"); + assert_eq!(pos.y, hub_spawn.y, "player y at hub spawn"); + assert_eq!(pos.z, hub_spawn.z, "player z at hub spawn"); + } + + #[test] + fn teleport_to_hub_clears_dialogue_markers() { + // #491: TeleportToHub removes ActiveDialogue, TalkRequest, + // WalkAwayRequest, and ConfrontationDelivered. + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(InputQueue::default()); + world.insert_resource(SimulationTime::default()); + world.init_resource::(); + + // Spawn a fake NPC target + let npc = world.spawn(TilePosition::new(10, 10, 0)).id(); + + // Spawn player with active dialogue state + mid-confrontation marker + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(84, 58, 0), + crate::simulation::dialogue::TalkRequest { target: npc }, + crate::simulation::dialogue::ActiveDialogue { + target: npc, + interaction_type: crate::knowledge::events::InteractionType::Talk, + started_tick: 0, + }, + crate::simulation::dialogue::WalkAwayRequest, + crate::simulation::dialogue::ConfrontationDelivered { target: npc }, + )) + .id(); + + world.resource_mut::().push(PlayerInput { + tick: 0, + action: PlayerAction::TeleportToHub, + }); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_player_input); + schedule.run(&mut world); + + assert!( + world + .get::(player) + .is_none(), + "TalkRequest cleared after teleport" + ); + assert!( + world + .get::(player) + .is_none(), + "ActiveDialogue cleared after teleport" + ); + assert!( + world + .get::(player) + .is_none(), + "WalkAwayRequest cleared after teleport" + ); + assert!( + world + .get::(player) + .is_none(), + "ConfrontationDelivered cleared after teleport" + ); + } + + #[test] + fn teleport_to_hub_clears_move_intent() { + // #491: TeleportToHub removes any pending MoveIntent. + 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(84, 58, 0), + MoveIntent { + target: TilePosition::new(85, 58, 0), + }, + )) + .id(); + + world.resource_mut::().push(PlayerInput { + tick: 0, + action: PlayerAction::TeleportToHub, + }); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_player_input); + schedule.run(&mut world); + + assert!( + world.get::(player).is_none(), + "MoveIntent cleared after teleport" + ); + } + + #[test] + fn teleport_to_hub_allowed_while_paused() { + // #491: TeleportToHub is a QA action — allowed even when 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(84, 58, 0))) + .id(); + + world.resource_mut::().push(PlayerInput { + tick: 0, + action: PlayerAction::TeleportToHub, + }); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_player_input); + schedule.run(&mut world); + + let pos = world + .get::(player) + .expect("player has position"); + let hub_spawn = crate::test_world::constants::HUB.spawn; + assert_eq!(pos.x, hub_spawn.x, "teleport works while paused"); + } +}