From 273d29f26f137c9e438c136bd63a7982218f9e52 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 18 Feb 2026 12:58:14 +0100 Subject: [PATCH] =?UTF-8?q?feat(simulation):=20sprint=2010=20=E2=80=94=20h?= =?UTF-8?q?ub=20teleport,=20confrontation=20response,=20walk-away=20phase?= =?UTF-8?q?=202,=20gauntlet=20rooms=204-7,=20blocked=5Fentities=20debug=20?= =?UTF-8?q?field?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #491: PlayerAction::TeleportToHub — moves player to hub spawn, clears dialogue/monologue/interaction buffer, Gauntlet-only with log warning. #520: ConfrontationDelivered event — Tier 2 animation shift, relationship state decrement (D-033), monologue spike emission. #519: Walk-away Phase 2 — NPC animation shift + routine deviation on dialogue exit (D-064). #498: Four new Gauntlet rooms — Interaction Gallery, Fog Theater, Crowd Plaza, Dialogue Room with constants and wiring. #514: blocked_entities Vec on ObserverSnapshot, protocol v9. Co-Authored-By: Claude Opus 4.6 --- server/Cargo.lock | 2 +- server/src/bridge/mod.rs | 3 + server/src/bridge/text_renderer.rs | 24 + server/src/bridge/types.rs | 12 +- server/src/knowledge/types.rs | 17 + server/src/npc/mod.rs | 36 ++ server/src/perception/observation.rs | 6 +- server/src/perception/observer/mod.rs | 38 +- server/src/simulation/dialogue.rs | 418 +++++++++++++++++- server/src/simulation/input.rs | 283 +++++++++++- server/src/simulation/monologue.rs | 6 + server/src/test_world/constants.rs | 37 +- server/src/test_world/mod.rs | 94 +++- server/src/test_world/rooms/crowd_plaza.rs | 85 ++++ server/src/test_world/rooms/dialogue_room.rs | 87 ++++ server/src/test_world/rooms/fog_theater.rs | 66 +++ .../test_world/rooms/interaction_gallery.rs | 51 +++ server/src/test_world/rooms/mod.rs | 4 + 18 files changed, 1219 insertions(+), 50 deletions(-) create mode 100644 server/src/test_world/rooms/crowd_plaza.rs create mode 100644 server/src/test_world/rooms/dialogue_room.rs create mode 100644 server/src/test_world/rooms/fog_theater.rs create mode 100644 server/src/test_world/rooms/interaction_gallery.rs diff --git a/server/Cargo.lock b/server/Cargo.lock index 88dc1b8fd..ae9e05069 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -978,7 +978,7 @@ dependencies = [ [[package]] name = "settled-reach-server" -version = "0.1.0" +version = "0.1.9" dependencies = [ "bevy_app", "bevy_ecs", diff --git a/server/src/bridge/mod.rs b/server/src/bridge/mod.rs index 21d8aa8a5..3893ddfe2 100644 --- a/server/src/bridge/mod.rs +++ b/server/src/bridge/mod.rs @@ -178,11 +178,14 @@ impl Plugin for BridgePlugin { crate::simulation::dialogue::process_walk_away .after(crate::simulation::input::process_player_input) .after(crate::simulation::dialogue::process_talk_interaction), + crate::simulation::dialogue::process_confrontation_response + .after(crate::simulation::input::process_player_input), crate::perception::observer::compute_observer_snapshot .after(crate::perception::observer::compute_visibility_geometry) .after(crate::simulation::interaction::compute_nearby_interactions) .after(crate::simulation::monologue::process_sprint_anomaly_monologue) .after(crate::simulation::dialogue::process_talk_interaction) + .after(crate::simulation::dialogue::process_confrontation_response) .before(crate::simulation::time::advance_tick), crate::perception::observation::emit_observation_events .after(crate::perception::observer::compute_observer_snapshot), diff --git a/server/src/bridge/text_renderer.rs b/server/src/bridge/text_renderer.rs index d80d4bcd5..7e0d2b650 100644 --- a/server/src/bridge/text_renderer.rs +++ b/server/src/bridge/text_renderer.rs @@ -159,6 +159,12 @@ pub fn format_snapshot_text(snapshot: &ObserverSnapshot) -> String { .ok(); } + // Blocked entities (debug, #514) + if !snapshot.blocked_entities.is_empty() { + let ids: Vec = snapshot.blocked_entities.iter().map(|id| id.to_string()).collect(); + writeln!(out, "Blocked (LOS): {} [{}]", snapshot.blocked_entities.len(), ids.join(", ")).ok(); + } + writeln!(out, "===").ok(); out } @@ -281,6 +287,7 @@ mod tests { }), pending_recognitions: vec![], dialogue_response: None, + blocked_entities: vec![], } } @@ -402,10 +409,27 @@ mod tests { current_monologue: None, pending_recognitions: vec![], dialogue_response: None, + blocked_entities: vec![], }; let text = format_snapshot_text(&snap); assert!(text.contains("Tick 0")); assert!(text.contains("Player (-1,-1)")); assert!(text.contains("Tiles: 0 visible")); } + + #[test] + fn blocked_entities_rendered() { + let mut snap = make_snapshot(); + snap.blocked_entities = vec![42, 99, 1024]; + let text = format_snapshot_text(&snap); + assert!(text.contains("Blocked (LOS): 3")); + assert!(text.contains("[42, 99, 1024]")); + } + + #[test] + fn blocked_entities_empty_not_rendered() { + let snap = make_snapshot(); + let text = format_snapshot_text(&snap); + assert!(!text.contains("Blocked (LOS)")); + } } diff --git a/server/src/bridge/types.rs b/server/src/bridge/types.rs index a22a22fdc..094f7b1ac 100644 --- a/server/src/bridge/types.rs +++ b/server/src/bridge/types.rs @@ -15,7 +15,7 @@ pub use crate::simulation::time::{DayPhase, TickRate}; /// negotiation is unnecessary. Client should reject snapshots with version != /// PROTOCOL_VERSION. New fields use #[serde(default)] only during the migration /// period, then the default is removed once both sides are updated. -pub const PROTOCOL_VERSION: u8 = 8; +pub const PROTOCOL_VERSION: u8 = 9; /// The ONLY data structure crossing the client-server boundary (D-020) /// Contains all information visible to the observer at a given tick. @@ -27,6 +27,7 @@ pub const PROTOCOL_VERSION: u8 = 8; /// v6 adds: player_stance (#449, D-053), player_inventory (#449, D-065). /// v7 adds: pending_recognitions (#423, D-060 cognitive delay). /// v8 adds: dialogue_response (#305, D-028 dialogue pipeline). +/// v9 adds: blocked_entities (#514, debug field for LOS-blocked entities). /// Future fields: ambient sound events, HUD state (D-020 expansion). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ObserverSnapshot { @@ -68,6 +69,11 @@ pub struct ObserverSnapshot { /// Client shows speaker name + dialogue text in a dialogue box. #[serde(default)] pub dialogue_response: Option, + /// Debug field: entity IDs on the same z-level that are not visible due to + /// LOS obstruction or being outside the vision cone (#514). + /// Sorted ascending for deterministic output. Client can safely ignore. + #[serde(default)] + pub blocked_entities: Vec, } /// Game time data for client display (D-031) @@ -316,6 +322,10 @@ pub enum PlayerAction { ToggleStanceUp, /// Move one step down the stance ladder (toward Crouch) per D-053 ToggleStanceDown, + /// Teleport player to the Gauntlet hub spawn point (#491). + /// Clears dialogue, monologue, and interaction buffers. + /// Rejected with a log warning on non-Gauntlet maps. + TeleportToHub, } impl PlayerAction { diff --git a/server/src/knowledge/types.rs b/server/src/knowledge/types.rs index 2f3acde14..49d667405 100644 --- a/server/src/knowledge/types.rs +++ b/server/src/knowledge/types.rs @@ -129,6 +129,23 @@ pub enum RelationshipState { Hostile, } +impl RelationshipState { + /// Decrement relationship state toward more negative (D-063 confrontation response). + /// + /// Friendly → Known → PersonOfInterest → Hostile. + /// Unknown stays Unknown (can't confront a stranger meaningfully). + /// Hostile stays Hostile (already worst state). + pub fn decrement(self) -> Self { + match self { + Self::Friendly => Self::Known, + Self::Known => Self::PersonOfInterest, + Self::PersonOfInterest => Self::Hostile, + Self::Unknown => Self::Unknown, + Self::Hostile => Self::Hostile, + } + } +} + // --- Entity Knowledge --- /// What entity A knows about entity B. diff --git a/server/src/npc/mod.rs b/server/src/npc/mod.rs index 86ad505dd..abbac1bc6 100644 --- a/server/src/npc/mod.rs +++ b/server/src/npc/mod.rs @@ -35,6 +35,42 @@ impl Plugin for NpcPlugin { #[derive(Component, Debug)] pub struct Npc; +/// NPC animation tier (D-047). +/// +/// Tier 1 (clear): public daily activities — instantly readable. +/// Tier 2 (ambiguous): privately motivated behaviors — player sees the action +/// but cannot determine the intention. +/// +/// NPCs start at Tier 1. Confrontation (D-063) and other triggers shift to Tier 2. +#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)] +pub enum AnimationTier { + /// Clear, readable public activities (walking, working, talking). + #[default] + Tier1, + /// Ambiguous, privately motivated behaviors (pausing, lingering, looking around). + Tier2, +} + +/// Tracks why and when an NPC's routine deviated from normal (D-064 Phase 2). +/// +/// Inserted when a player action causes an NPC to break from their scheduled +/// behavior. Acts as a hook for the storyteller system and affects future +/// interactions (e.g., second-approach dialogue differences). +#[derive(Component, Debug, Clone)] +pub struct RoutineDeviation { + pub trigger: DeviationTrigger, + pub tick: u64, +} + +/// What caused an NPC's routine deviation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum DeviationTrigger { + /// Player walked away mid-dialogue (D-064 Phase 2). + WalkAway, + /// Player delivered a confrontation (D-063). + Confrontation, +} + // --------------------------------------------------------------------------- // Axis 1: Want (D-024) // --------------------------------------------------------------------------- diff --git a/server/src/perception/observation.rs b/server/src/perception/observation.rs index 9a5bf1dc3..fcfc776bb 100644 --- a/server/src/perception/observation.rs +++ b/server/src/perception/observation.rs @@ -142,10 +142,8 @@ pub fn emit_observation_events( let pending_ids: Vec = delay.pending().iter().map(|p| p.stable_id).collect(); for sid in pending_ids { - if !visible_stable_ids.contains(&sid.0) { - if delay.cancel(&sid).is_some() { - tracing::debug!("Cognitive delay cancelled: stable_id={} left LOS", sid.0); - } + if !visible_stable_ids.contains(&sid.0) && delay.cancel(&sid).is_some() { + tracing::debug!("Cognitive delay cancelled: stable_id={} left LOS", sid.0); } } } diff --git a/server/src/perception/observer/mod.rs b/server/src/perception/observer/mod.rs index 8e7d91571..842710c50 100644 --- a/server/src/perception/observer/mod.rs +++ b/server/src/perception/observer/mod.rs @@ -116,7 +116,7 @@ pub fn compute_observer_snapshot( }) .unwrap_or_default(); - let (mut entities, visible_ids) = + let (mut entities, visible_ids, blocked_entities) = filter_visible_entities(&geometry, ®istry, observer_kg, &all_entities); collect_remembered_entities( @@ -206,11 +206,13 @@ pub fn compute_observer_snapshot( current_monologue, pending_recognitions, dialogue_response, + blocked_entities, }); } /// Filter entities by visibility using precomputed geometry. -/// Returns (visible entities, set of visible wire IDs). +/// Returns (visible entities, set of visible wire IDs, blocked entity IDs). +/// Blocked entities are on the same z-level but not in visible_positions (#514). #[allow(clippy::type_complexity)] fn filter_visible_entities( geometry: &VisibilityGeometry, @@ -222,15 +224,31 @@ fn filter_visible_entities( Option<&PlayerCharacter>, Option<&crate::npc::Npc>, )>, -) -> (Vec, BTreeSet) { +) -> (Vec, BTreeSet, Vec) { let mut entities = Vec::new(); let mut visible_ids: BTreeSet = BTreeSet::new(); + let mut blocked_ids: BTreeSet = BTreeSet::new(); for (entity, pos, is_player, is_npc) in all_entities.iter() { if pos.z != geometry.observer_z { continue; } + + // Resolve wire ID early — needed for both visible and blocked paths + let wire_id = registry + .to_stable(entity) + .map(|sid| sid.0) + .unwrap_or_else(|| { + tracing::error!(?entity, "entity not in EntityRegistry"); + entity.to_bits() + }); + if !geometry.visible_positions.contains(&(pos.x, pos.y)) { + // Same z-level but not visible — blocked by LOS or outside vision cone. + // Exclude the player entity (always at origin, always visible). + if is_player.is_none() { + blocked_ids.insert(wire_id); + } continue; } @@ -257,16 +275,6 @@ fn filter_visible_entities( RelationshipState::Unknown }; - // Fallback to Entity::to_bits() is intentional for per-frame systems: - // panicking would crash the server every tick. The error log makes this - // loud enough to catch in testing while keeping the server alive. - let wire_id = registry - .to_stable(entity) - .map(|sid| sid.0) - .unwrap_or_else(|| { - tracing::error!(?entity, "entity visible but not in EntityRegistry"); - entity.to_bits() - }); visible_ids.insert(wire_id); entities.push(VisibleEntity { entity_id: wire_id, @@ -280,7 +288,9 @@ fn filter_visible_entities( }); } - (entities, visible_ids) + // BTreeSet iteration is sorted — deterministic output guaranteed + let blocked_vec: Vec = blocked_ids.into_iter().collect(); + (entities, visible_ids, blocked_vec) } /// Collect remembered entities from the knowledge graph — entities the observer diff --git a/server/src/simulation/dialogue.rs b/server/src/simulation/dialogue.rs index 1fda1b31e..f3cf583e7 100644 --- a/server/src/simulation/dialogue.rs +++ b/server/src/simulation/dialogue.rs @@ -18,12 +18,13 @@ use bevy_ecs::prelude::*; use rand::Rng; -use crate::bridge::types::{DialogueResponseEvent, RelationshipState}; +use crate::bridge::types::{DialogueResponseEvent, MonologueEvent, RelationshipState}; use crate::content::line_pool::{ AccessTier, IndexedDialogueLine, Mood, Situation, Topic, TrustTier, }; use crate::content::LinePoolIndexResource; use crate::knowledge::{EntityRegistry, KnowledgeGraph}; +use crate::simulation::monologue::{MonologueBuffer, MonologueState}; use crate::simulation::movement::PlayerCharacter; use crate::simulation::rng::SimRng; use crate::simulation::time::SimulationTime; @@ -117,6 +118,18 @@ pub struct ActiveDialogue { #[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. @@ -299,7 +312,7 @@ pub fn select_dialogue_line<'a>( /// line to DialogueResponseBuffer. /// /// System ordering: after process_player_input, before compute_observer_snapshot. -#[allow(clippy::type_complexity)] +#[allow(clippy::type_complexity, clippy::too_many_arguments)] pub fn process_talk_interaction( mut commands: Commands, time: Res, @@ -478,11 +491,13 @@ pub fn process_talk_interaction( // System: process_walk_away (D-064) // --------------------------------------------------------------------------- -/// Process walk-away requests during active dialogue. +/// 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. Emits IncompleteInteraction knowledge event (recorded in KG) +/// 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) /// 2. Clears ActiveDialogue state /// 3. Removes the WalkAwayRequest marker /// @@ -500,21 +515,38 @@ pub fn process_walk_away( }; if let Some(active_dialogue) = active_dialogue_opt { - // Emit IncompleteInteraction knowledge event + let target = active_dialogue.target; + + // Phase 2, Effect 1: Shift target NPC to Tier 2 animation (D-047) + commands + .entity(target) + .insert(crate::npc::AnimationTier::Tier2); + + // Phase 2, Effect 2: Record routine deviation on target NPC + commands + .entity(target) + .insert(crate::npc::RoutineDeviation { + trigger: crate::npc::DeviationTrigger::WalkAway, + tick: time.tick, + }); + + // Phase 3: Emit IncompleteInteraction knowledge event event_queue.push(crate::knowledge::KnowledgeEvent { observer: player_entity, tick: time.tick, event_type: crate::knowledge::KnowledgeEventType::IncompleteInteraction { - target: active_dialogue.target, + target, interaction_type: active_dialogue.interaction_type, }, }); tracing::debug!( - "Walk-away during {:?} dialogue at tick {} (started tick {})", + "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::(); @@ -525,6 +557,104 @@ pub fn process_walk_away( commands.entity(player_entity).remove::(); } +/// Hardcoded confrontation monologue lines (D-063). +/// Fired as a monologue spike when the player delivers a confrontation. +/// Future: move to content pools with trigger="confrontation_delivered". +const CONFRONTATION_LINES: &[(&str, &str)] = &[ + ( + "confront_01", + "That changed everything between us. No going back.", + ), + ( + "confront_02", + "The look on their face... they know I know.", + ), + ( + "confront_03", + "Cards on the table. Let's see what happens next.", + ), +]; + +/// Process confrontation world response (#520, D-063). +/// +/// Reads ConfrontationDelivered marker (set by input system), applies three +/// server-authoritative effects: +/// 1. Target NPC shifts to AnimationTier::Tier2 (D-047) +/// 2. Observer's KG relationship state decremented (D-033 color fade) +/// 3. Monologue spike: immediate monologue line bypassing cooldown +/// +/// System ordering: after process_player_input, before compute_observer_snapshot. +pub fn process_confrontation_response( + mut commands: Commands, + time: Res, + registry: Res, + mut rng: ResMut, + mut query: Query< + ( + Entity, + &ConfrontationDelivered, + &mut KnowledgeGraph, + &mut MonologueBuffer, + &mut MonologueState, + ), + With, + >, +) { + 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) + commands + .entity(target) + .insert(crate::npc::AnimationTier::Tier2); + + // Effect 2: Decrement observer's relationship with the target (D-033 color fade) + if let Some(target_sid) = registry.to_stable(target) { + let old_rel = observer_kg.relationship_with(&target_sid); + let new_rel = old_rel.decrement(); + if new_rel != old_rel { + observer_kg.set_relationship(&target_sid, new_rel); + tracing::info!( + target_id = target_sid.0, + ?old_rel, + ?new_rel, + "Confrontation: relationship decremented" + ); + } + } + + // Effect 3: Monologue spike — bypass cooldown, fire immediately + let idx = rng.rng.random_range(0..CONFRONTATION_LINES.len()); + let (id, text) = CONFRONTATION_LINES[idx]; + monologue_buf.set(MonologueEvent { + id: id.to_string(), + text: text.to_string(), + duration_seconds: 5.0, + }); + monologue_state.last_fired_tick = time.tick; + + tracing::info!( + tick = time.tick, + monologue_id = id, + "Confrontation delivered: Tier 2 anim + relationship decrement + monologue spike" + ); + + // Clean up marker + commands + .entity(player_entity) + .remove::(); +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -1362,5 +1492,279 @@ mod tests { ); } + // -- 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; + + // === 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)); + + 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" + ); + } + + #[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)); + + 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)); + + 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)); + + 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/input.rs b/server/src/simulation/input.rs index 3d8feb6d4..2efd9d9a5 100644 --- a/server/src/simulation/input.rs +++ b/server/src/simulation/input.rs @@ -75,7 +75,7 @@ impl InputQueue { /// Drains InputQueue for the current tick, converts PlayerActions to ECS components. /// Handles stance toggling (D-053), movement cooldown, and Take/Place verbs (#424). -#[allow(clippy::type_complexity)] +#[allow(clippy::type_complexity, clippy::too_many_arguments)] pub fn process_player_input( mut input_queue: ResMut, mut time: ResMut, @@ -104,8 +104,14 @@ pub fn process_player_input( for input in inputs { // Discard all gameplay actions while paused (D-052, R2-OQ-01). - // Only Pause/Unpause are processed — everything else is discarded. - if paused && !matches!(input.action, PlayerAction::Pause | PlayerAction::Unpause) { + // Only Pause/Unpause/TeleportToHub are processed — everything else is discarded. + // TeleportToHub is exempted because it's a Gauntlet QA action (#491). + if paused + && !matches!( + input.action, + PlayerAction::Pause | PlayerAction::Unpause | PlayerAction::TeleportToHub + ) + { continue; } match input.action { @@ -196,6 +202,15 @@ pub fn process_player_input( target_entity_id, ); } + Some("Confront") => { + handle_confront( + &mut commands, + ®istry, + &player_query, + &all_positions, + target_entity_id, + ); + } Some("Reset") => { handle_reset( &mut commands, @@ -222,6 +237,9 @@ pub fn process_player_input( tracing::debug!("WalkAway: marker set on player"); } } + PlayerAction::TeleportToHub => { + handle_teleport_to_hub(&mut player_query, &mut commands); + } PlayerAction::UsePerceptionMode(ref mode) => { tracing::trace!("UsePerceptionMode({}) — no-op for Sprint 1", mode); } @@ -400,6 +418,65 @@ fn handle_talk( tracing::debug!(target_id, "Talk: TalkRequest 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 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. @@ -512,6 +589,62 @@ fn handle_reset( ); } +/// 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 + commands + .entity(player_entity) + .remove::() + .remove::() + .remove::(); + + tracing::info!( + x = hub_spawn.x, + y = hub_spawn.y, + z = hub_spawn.z, + "TeleportToHub: player moved to hub spawn" + ); + } +} + #[cfg(test)] mod tests { use super::*; @@ -1690,4 +1823,148 @@ mod tests { 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 and TalkRequest. + 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 + 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, + }, + )) + .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" + ); + } + + #[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"); + } } diff --git a/server/src/simulation/monologue.rs b/server/src/simulation/monologue.rs index 2d5b02878..38276dd27 100644 --- a/server/src/simulation/monologue.rs +++ b/server/src/simulation/monologue.rs @@ -108,6 +108,12 @@ impl MonologueBuffer { pub fn take(&mut self) -> Option { self.event.take() } + + /// Set a monologue event, replacing any pending event. + /// Used by confrontation response (#520, D-063) to emit a monologue spike. + pub fn set(&mut self, event: MonologueEvent) { + self.event = Some(event); + } } /// Queued sprint anomaly for delayed "double-take" monologue (#428, D-055). diff --git a/server/src/test_world/constants.rs b/server/src/test_world/constants.rs index 4ed21935d..e70b13985 100644 --- a/server/src/test_world/constants.rs +++ b/server/src/test_world/constants.rs @@ -189,16 +189,19 @@ pub const INTERACTION_GALLERY_STABLE_IDS: (u64, u64) = (24, 28); pub const PAUSE_CHAMBER_STABLE_IDS: (u64, u64) = (29, 29); pub const DIALOGUE_ROOM_STABLE_IDS: (u64, u64) = (30, 33); pub const CROWD_PLAZA_STABLE_IDS: (u64, u64) = (34, 48); -pub const RESET_PLATE_STABLE_IDS: (u64, u64) = (49, 51); +pub const RESET_PLATE_STABLE_IDS: (u64, u64) = (49, 55); /// Number of actively-spawned entities in the current Gauntlet build. -/// Derived from StableId ranges of built rooms + player + reset plates. -/// Reserved (unbuilt) rooms do not contribute entities. +/// Derived from StableId ranges of all rooms + player + reset plates. pub const EXPECTED_ENTITY_COUNT: usize = 1 // player (StableId 0) + (HUB_STABLE_IDS.1 - HUB_STABLE_IDS.0 + 1) as usize + + (FOG_THEATER_STABLE_IDS.1 - FOG_THEATER_STABLE_IDS.0 + 1) as usize + (OCCLUSION_STABLE_IDS.1 - OCCLUSION_STABLE_IDS.0 + 1) as usize + (INVENTORY_STABLE_IDS.1 - INVENTORY_STABLE_IDS.0 + 1) as usize + + (INTERACTION_GALLERY_STABLE_IDS.1 - INTERACTION_GALLERY_STABLE_IDS.0 + 1) as usize + (PAUSE_CHAMBER_STABLE_IDS.1 - PAUSE_CHAMBER_STABLE_IDS.0 + 1) as usize + + (DIALOGUE_ROOM_STABLE_IDS.1 - DIALOGUE_ROOM_STABLE_IDS.0 + 1) as usize + + (CROWD_PLAZA_STABLE_IDS.1 - CROWD_PLAZA_STABLE_IDS.0 + 1) as usize + (RESET_PLATE_STABLE_IDS.1 - RESET_PLATE_STABLE_IDS.0 + 1) as usize; #[cfg(test)] @@ -233,6 +236,34 @@ mod tests { assert_eq!(room.name, "pause_chamber"); } + #[test] + fn room_at_finds_fog_theater() { + let pos = TilePosition { x: 56, y: 18, z: 0 }; + let room = room_at(&pos).expect("Fog Theater observer should be in a room"); + assert_eq!(room.name, "fog_theater"); + } + + #[test] + fn room_at_finds_interaction_gallery() { + let pos = TilePosition { x: 14, y: 92, z: 0 }; + let room = room_at(&pos).expect("Interaction Gallery observer should be in a room"); + assert_eq!(room.name, "interaction_gallery"); + } + + #[test] + fn room_at_finds_dialogue_room() { + let pos = TilePosition { x: 50, y: 114, z: 0 }; + let room = room_at(&pos).expect("Dialogue Room observer should be in a room"); + assert_eq!(room.name, "dialogue_room"); + } + + #[test] + fn room_at_finds_crowd_plaza() { + let pos = TilePosition { x: 96, y: 94, z: 0 }; + let room = room_at(&pos).expect("Crowd Plaza observer should be in a room"); + assert_eq!(room.name, "crowd_plaza"); + } + #[test] fn room_at_returns_none_for_corridor() { // Point inside corridor-E (between Hub and Occlusion) diff --git a/server/src/test_world/mod.rs b/server/src/test_world/mod.rs index ace9c13e2..004d34066 100644 --- a/server/src/test_world/mod.rs +++ b/server/src/test_world/mod.rs @@ -19,14 +19,14 @@ //! StableId ranges (from gestalt-round3.md): //! Player: 0 //! Hub signs: 1-4 -//! Fog Theater: 5-8 (reserved, not yet built) +//! Fog Theater: 5-8 //! Occlusion Corridor: 9-12 //! Inventory Warehouse: 13-23 -//! Interaction Gallery: 24-28 (reserved, not yet built) +//! Interaction Gallery: 24-28 //! Pause Chamber: 29 -//! Dialogue Room: 30-33 (reserved, not yet built) -//! Crowd Plaza: 34-48 (reserved, not yet built) -//! Reset plates: 49-51 (Occlusion, Inventory, Pause) +//! Dialogue Room: 30-33 +//! Crowd Plaza: 34-48 +//! Reset plates: 49-55 #[cfg(feature = "gauntlet")] pub mod constants; @@ -86,14 +86,22 @@ pub fn setup_gauntlet(app: &mut App) { // Carve room interiors (2-tile-thick walls → interior starts 2 tiles in) carve_room_interior(&mut walkability, 38, 46, 24, 24); // Hub + carve_room_interior(&mut walkability, 28, 2, 44, 32); // Fog Theater carve_room_interior(&mut walkability, 74, 48, 42, 22); // Occlusion Corridor carve_room_interior(&mut walkability, 2, 40, 30, 28); // Inventory Warehouse + carve_room_interior(&mut walkability, 2, 82, 24, 20); // Interaction Gallery carve_room_interior(&mut walkability, 42, 78, 16, 16); // Pause Chamber + carve_room_interior(&mut walkability, 36, 104, 28, 20); // Dialogue Room + carve_room_interior(&mut walkability, 80, 78, 32, 32); // Crowd Plaza // Carve corridors between hub and rooms + carve_corridor(&mut walkability, 48, 34, 6, 12); // corridor-N: Hub ↔ Fog Theater carve_corridor(&mut walkability, 62, 55, 12, 6); // corridor-E: Hub ↔ Occlusion carve_corridor(&mut walkability, 32, 55, 6, 6); // corridor-W: Hub ↔ Inventory carve_corridor(&mut walkability, 47, 70, 6, 8); // corridor-S: Hub ↔ Pause Chamber + carve_corridor(&mut walkability, 12, 68, 6, 14); // corridor-SW: Inventory ↔ Interaction Gallery + carve_corridor(&mut walkability, 48, 94, 6, 10); // corridor-S2: Pause ↔ Dialogue Room + carve_corridor(&mut walkability, 58, 84, 22, 6); // corridor-E2: Pause ↔ Crowd Plaza // Set up Occlusion Corridor walls (relative positions converted to absolute) // North wall segment: rel x=[14,22], y=[6,7] — blocks LOS to npc_hidden_wall @@ -142,8 +150,8 @@ pub fn setup_gauntlet(app: &mut App) { // --- Hub signs (StableId 1-4) --- rooms::hub::spawn_entities(app, &mut registry); - // --- Fog Theater (StableId 5-8) — reserved, not yet built --- - registry.reserve_up_to(9); + // --- Fog Theater (StableId 5-8) --- + rooms::fog_theater::spawn_entities(app, &mut registry); // --- Occlusion Corridor (StableId 9-12) --- rooms::occlusion_corridor::spawn_entities(app, &mut registry); @@ -151,23 +159,29 @@ pub fn setup_gauntlet(app: &mut App) { // --- Inventory Warehouse (StableId 13-23) --- rooms::inventory_warehouse::spawn_entities(app, &mut registry); - // --- Interaction Gallery (StableId 24-28) — reserved, not yet built --- - registry.reserve_up_to(29); + // --- Interaction Gallery (StableId 24-28) --- + rooms::interaction_gallery::spawn_entities(app, &mut registry); // --- Pause Chamber (StableId 29) --- rooms::pause_chamber::spawn_entities(app, &mut registry); - // --- Dialogue Room (StableId 30-33) — reserved, not yet built --- - // --- Crowd Plaza (StableId 34-48) — reserved, not yet built --- - registry.reserve_up_to(49); + // --- Dialogue Room (StableId 30-33) --- + rooms::dialogue_room::spawn_entities(app, &mut registry); - // --- Reset plates (StableId 49-51) --- + // --- Crowd Plaza (StableId 34-48) --- + rooms::crowd_plaza::spawn_entities(app, &mut registry); + + // --- Reset plates (StableId 49-55) --- // Spawned at corridor entrances per workshop-outcomes.md Section 8. // Each plate triggers reset of its associated room. let reset_plates: &[(&str, TilePosition)] = &[ ("occlusion_corridor", constants::OCCLUSION_CORRIDOR.reset_plate.expect("occlusion_corridor should have a reset_plate")), ("inventory_warehouse", constants::INVENTORY_WAREHOUSE.reset_plate.expect("inventory_warehouse should have a reset_plate")), ("pause_chamber", constants::PAUSE_CHAMBER.reset_plate.expect("pause_chamber should have a reset_plate")), + ("fog_theater", constants::FOG_THEATER.reset_plate.expect("fog_theater should have a reset_plate")), + ("interaction_gallery", constants::INTERACTION_GALLERY.reset_plate.expect("interaction_gallery should have a reset_plate")), + ("dialogue_room", constants::DIALOGUE_ROOM.reset_plate.expect("dialogue_room should have a reset_plate")), + ("crowd_plaza", constants::CROWD_PLAZA.reset_plate.expect("crowd_plaza should have a reset_plate")), ]; for &(room_name, pos) in reset_plates { let entity = app @@ -189,6 +203,15 @@ pub fn setup_gauntlet(app: &mut App) { // --- Populate RoomSnapshots for reset mechanism (#490) --- let mut snapshots = RoomSnapshots::default(); + // Fog Theater entities (StableId 5-8): NPCs only + for id in constants::FOG_THEATER_STABLE_IDS.0..=constants::FOG_THEATER_STABLE_IDS.1 { + if let Some(entity) = registry.to_entity(&StableId(id)) { + if let Some(pos) = app.world().get::(entity) { + snapshots.record("fog_theater", entity, *pos, false); + } + } + } + // Occlusion Corridor entities (StableId 9-12): NPCs only, no floor items for id in 9..=12 { if let Some(entity) = registry.to_entity(&StableId(id)) { @@ -208,6 +231,15 @@ pub fn setup_gauntlet(app: &mut App) { } } + // Interaction Gallery entities (StableId 24-28): objects only, no floor items + for id in constants::INTERACTION_GALLERY_STABLE_IDS.0..=constants::INTERACTION_GALLERY_STABLE_IDS.1 { + if let Some(entity) = registry.to_entity(&StableId(id)) { + if let Some(pos) = app.world().get::(entity) { + snapshots.record("interaction_gallery", entity, *pos, false); + } + } + } + // Pause Chamber entity (StableId 29): NPC only if let Some(entity) = registry.to_entity(&StableId(29)) { if let Some(pos) = app.world().get::(entity) { @@ -215,6 +247,24 @@ pub fn setup_gauntlet(app: &mut App) { } } + // Dialogue Room entities (StableId 30-33): NPCs only + for id in constants::DIALOGUE_ROOM_STABLE_IDS.0..=constants::DIALOGUE_ROOM_STABLE_IDS.1 { + if let Some(entity) = registry.to_entity(&StableId(id)) { + if let Some(pos) = app.world().get::(entity) { + snapshots.record("dialogue_room", entity, *pos, false); + } + } + } + + // Crowd Plaza entities (StableId 34-48): NPCs only + for id in constants::CROWD_PLAZA_STABLE_IDS.0..=constants::CROWD_PLAZA_STABLE_IDS.1 { + if let Some(entity) = registry.to_entity(&StableId(id)) { + if let Some(pos) = app.world().get::(entity) { + snapshots.record("crowd_plaza", entity, *pos, false); + } + } + } + app.insert_resource(snapshots); app.insert_resource(registry); } @@ -331,9 +381,9 @@ mod tests { assert!(registry.to_entity(&StableId(id)).is_some(), "Hub sign at StableId {}", id); } - // Fog Theater 5-8 reserved (no entities) + // Fog Theater at 5-8 for id in 5..=8 { - assert!(registry.to_entity(&StableId(id)).is_none(), "Fog Theater {} reserved", id); + assert!(registry.to_entity(&StableId(id)).is_some(), "Fog Theater at StableId {}", id); } // Occlusion Corridor at 9-12 @@ -346,14 +396,24 @@ mod tests { assert!(registry.to_entity(&StableId(id)).is_some(), "Inventory at StableId {}", id); } - // Interaction Gallery 24-28 reserved + // Interaction Gallery at 24-28 for id in 24..=28 { - assert!(registry.to_entity(&StableId(id)).is_none(), "Gallery {} reserved", id); + assert!(registry.to_entity(&StableId(id)).is_some(), "Gallery at StableId {}", id); } // Pause Chamber at 29 assert!(registry.to_entity(&StableId(29)).is_some(), "Pause Chamber at StableId 29"); + // Dialogue Room at 30-33 + for id in 30..=33 { + assert!(registry.to_entity(&StableId(id)).is_some(), "Dialogue Room at StableId {}", id); + } + + // Crowd Plaza at 34-48 + for id in 34..=48 { + assert!(registry.to_entity(&StableId(id)).is_some(), "Crowd Plaza at StableId {}", id); + } + // Reset plates at 49-51 for id in constants::RESET_PLATE_STABLE_IDS.0..=constants::RESET_PLATE_STABLE_IDS.1 { assert!( diff --git a/server/src/test_world/rooms/crowd_plaza.rs b/server/src/test_world/rooms/crowd_plaza.rs new file mode 100644 index 000000000..7c0fa712b --- /dev/null +++ b/server/src/test_world/rooms/crowd_plaza.rs @@ -0,0 +1,85 @@ +//! Crowd Plaza — Room 7 (32x32) +//! +//! Density stress test room. 15 NPCs in a 5x3 grid to test perception, +//! snapshot, and tick-budget performance under high entity density. +//! Validates D-026 tick budget holds with many visible entities. +//! +//! Observer position: (16, 16) relative = (96, 94) absolute, facing West. +//! +//! Entities (StableId 34-48): +//! crowd_npc_00..crowd_npc_14 — 15 NPCs in a 5-column x 3-row grid +//! Grid starts at rel (4, 4) = abs (84, 82), spacing: 5x cols, 8y rows. + +use bevy_app::prelude::*; + +use crate::knowledge::registry::{EntityRegistry, StableEntityId}; +use crate::npc::{Contentment, Npc, ToleranceThreshold, Want, WantKind}; +use crate::simulation::interaction::Interactable; +use crate::simulation::movement::TilePosition; +use crate::simulation::path_follow::MovementSpeed; + +/// Room origin (top-left corner including walls). +const ORIGIN_X: i32 = 80; +const ORIGIN_Y: i32 = 78; + +/// Grid layout constants. +const GRID_COLS: usize = 5; +const GRID_ROWS: usize = 3; +const GRID_START_X: i32 = 4; +const GRID_START_Y: i32 = 4; +const GRID_SPACING_X: i32 = 5; +const GRID_SPACING_Y: i32 = 8; + +/// WantKind cycle for variety across 15 NPCs. +const WANT_CYCLE: &[WantKind] = &[ + WantKind::Wealth, + WantKind::Safety, + WantKind::Knowledge, + WantKind::Connection, + WantKind::Power, + WantKind::Freedom, + WantKind::Justice, + WantKind::Revenge, + WantKind::Happiness, +]; + +/// Spawn Crowd Plaza entities in canonical order (StableId 34-48). +pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) { + let mut index = 0usize; + for row in 0..GRID_ROWS { + for col in 0..GRID_COLS { + let rx = GRID_START_X + col as i32 * GRID_SPACING_X; + let ry = GRID_START_Y + row as i32 * GRID_SPACING_Y; + let pos = TilePosition::new(ORIGIN_X + rx, ORIGIN_Y + ry, 0); + let want_kind = WANT_CYCLE[index % WANT_CYCLE.len()]; + + let entity = app + .world_mut() + .spawn(( + Npc, + Interactable, + pos, + Want { + primary: want_kind, + intensity: ((index % 8) + 2) as u8, // 2-9 range + description: format!("Crowd Plaza NPC #{:02}", index), + }, + Contentment { + level: (index as i16 * 7 - 50).clamp(-100, 100), + }, + ToleranceThreshold { + current_stress: (index as i16 * 5) % 80, + threshold: 40 + (index as i16 % 4) * 10, + }, + MovementSpeed::default(), + )) + .id(); + let sid = registry.register(entity); + app.world_mut() + .entity_mut(entity) + .insert(StableEntityId(sid)); + + index += 1; + } + } +} diff --git a/server/src/test_world/rooms/dialogue_room.rs b/server/src/test_world/rooms/dialogue_room.rs new file mode 100644 index 000000000..272b00385 --- /dev/null +++ b/server/src/test_world/rooms/dialogue_room.rs @@ -0,0 +1,87 @@ +//! Dialogue Room — Room 6 (28x20) +//! +//! Tests D-041 (knowledge graph), D-028 (dialogue filtering pipeline). +//! +//! Layout: Four NPCs with DialogueProfile components, enabling the full +//! dialogue selection pipeline (access tier, situation, trust, mood scoring). +//! NPCs have varying trust/contentment to test different dialogue branches. +//! +//! Observer position: (14, 10) relative = (50, 114) absolute, facing North. +//! +//! Entities (StableId 30-33): +//! npc_dialogue_a (44, 112) — Relaxed dock worker +//! npc_dialogue_b (50, 110) — Guarded technician +//! npc_dialogue_c (56, 112) — Stressed supervisor +//! npc_dialogue_d (50, 118) — Distant observer (range test) + +use bevy_app::prelude::*; + +use crate::knowledge::registry::{EntityRegistry, StableEntityId}; +use crate::npc::{Contentment, Npc, ToleranceThreshold, Want, WantKind}; +use crate::simulation::dialogue::{CurrentMood, DialogueProfile}; +use crate::simulation::interaction::Interactable; +use crate::simulation::movement::TilePosition; +use crate::simulation::path_follow::MovementSpeed; + +/// Room origin (top-left corner including walls). +const ORIGIN_X: i32 = 36; +const ORIGIN_Y: i32 = 104; + +/// NPC definitions: (name, rel_x, rel_y, want_kind, intensity, contentment, stress, threshold, location, role). +const NPCS: &[(&str, i32, i32, WantKind, u8, i16, i16, i16, &str, &str)] = &[ + ( + "npc_dialogue_a", 8, 8, WantKind::Connection, 4, 20, 10, 60, + "the-terminal", "dock-worker", + ), + ( + "npc_dialogue_b", 14, 6, WantKind::Safety, 6, 0, 25, 45, + "the-terminal", "technician", + ), + ( + "npc_dialogue_c", 20, 8, WantKind::Power, 7, -15, 40, 50, + "the-terminal", "supervisor", + ), + ( + "npc_dialogue_d", 14, 14, WantKind::Knowledge, 3, 10, 5, 70, + "the-terminal", "observer", + ), +]; + +/// Spawn Dialogue Room entities in canonical order (StableId 30-33). +pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) { + for &(name, rx, ry, want_kind, intensity, contentment, stress, threshold, location, role) in + NPCS + { + let pos = TilePosition::new(ORIGIN_X + rx, ORIGIN_Y + ry, 0); + let entity = app + .world_mut() + .spawn(( + Npc, + Interactable, + pos, + Want { + primary: want_kind, + intensity, + description: format!("Dialogue Room test NPC: {}", name), + }, + Contentment { + level: contentment, + }, + ToleranceThreshold { + current_stress: stress, + threshold, + }, + MovementSpeed::default(), + DialogueProfile { + location: location.to_string(), + role: role.to_string(), + }, + CurrentMood::default(), + )) + .id(); + let sid = registry.register(entity); + app.world_mut() + .entity_mut(entity) + .insert(StableEntityId(sid)); + } +} diff --git a/server/src/test_world/rooms/fog_theater.rs b/server/src/test_world/rooms/fog_theater.rs new file mode 100644 index 000000000..30d26d640 --- /dev/null +++ b/server/src/test_world/rooms/fog_theater.rs @@ -0,0 +1,66 @@ +//! Fog Theater — Room 1 (44x32) +//! +//! Tests D-059 (fog layers), D-060 (cognitive delay for fog recognition). +//! +//! Layout: Large open room with NPCs at varying distances from observer. +//! Tests visibility at clear, peripheral, deep-fog, and edge-of-range +//! distances. No internal walls — fog layers are distance-based, not +//! occlusion-based (that's the Occlusion Corridor's job). +//! +//! Observer position: (28, 16) relative = (56, 18) absolute, facing South. +//! +//! Entities (StableId 5-8): +//! npc_fog_clear (56, 22) — 4 tiles south, clear vision cone +//! npc_fog_peripheral (46, 18) — 10 tiles west, peripheral sector +//! npc_fog_deep (34, 10) — far NW corner, deep fog range +//! npc_fog_edge (68, 28) — far SE, edge-of-range test + +use bevy_app::prelude::*; + +use crate::knowledge::registry::{EntityRegistry, StableEntityId}; +use crate::npc::{Contentment, Npc, ToleranceThreshold, Want, WantKind}; +use crate::simulation::interaction::Interactable; +use crate::simulation::movement::TilePosition; +use crate::simulation::path_follow::MovementSpeed; + +/// Room origin (top-left corner including walls). +const ORIGIN_X: i32 = 28; +const ORIGIN_Y: i32 = 2; + +/// NPC definitions: (name, relative_x, relative_y, want_kind, want_intensity). +const NPCS: &[(&str, i32, i32, WantKind, u8)] = &[ + ("npc_fog_clear", 28, 20, WantKind::Safety, 5), + ("npc_fog_peripheral", 18, 16, WantKind::Knowledge, 6), + ("npc_fog_deep", 6, 8, WantKind::Freedom, 3), + ("npc_fog_edge", 40, 26, WantKind::Wealth, 4), +]; + +/// Spawn Fog Theater entities in canonical order (StableId 5-8). +pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) { + for &(name, rx, ry, want_kind, intensity) in NPCS { + let pos = TilePosition::new(ORIGIN_X + rx, ORIGIN_Y + ry, 0); + let entity = app + .world_mut() + .spawn(( + Npc, + Interactable, + pos, + Want { + primary: want_kind, + intensity, + description: format!("Fog Theater test NPC: {}", name), + }, + Contentment { level: 0 }, + ToleranceThreshold { + current_stress: 0, + threshold: 50, + }, + MovementSpeed::default(), + )) + .id(); + let sid = registry.register(entity); + app.world_mut() + .entity_mut(entity) + .insert(StableEntityId(sid)); + } +} diff --git a/server/src/test_world/rooms/interaction_gallery.rs b/server/src/test_world/rooms/interaction_gallery.rs new file mode 100644 index 000000000..0c3190485 --- /dev/null +++ b/server/src/test_world/rooms/interaction_gallery.rs @@ -0,0 +1,51 @@ +//! Interaction Gallery — Room 4 (24x20) +//! +//! Tests D-057 (entity interaction vertical list, verb generation per type). +//! +//! Layout: One entity of each ObjectType to test that each type generates +//! the correct interaction verb(s). Container is already tested in +//! Inventory Warehouse, so this room covers the remaining 5 types. +//! +//! Observer position: (12, 10) relative = (14, 92) absolute, facing East. +//! +//! Entities (StableId 24-28): +//! obj_notice (8, 88) — Readable (Read verb) +//! obj_terminal (8, 94) — Terminal (Use verb) +//! obj_hatch (20, 88) — Door (Open/Close verb) +//! obj_pickup (20, 94) — Pickup (Pick up verb) +//! obj_bench (14, 96) — Furniture (Sit verb) + +use bevy_app::prelude::*; + +use crate::bridge::types::ObjectType; +use crate::knowledge::registry::{EntityRegistry, StableEntityId}; +use crate::simulation::interaction::Interactable; +use crate::simulation::movement::TilePosition; + +/// Room origin (top-left corner including walls). +const ORIGIN_X: i32 = 2; +const ORIGIN_Y: i32 = 82; + +/// Object definitions: (name, relative_x, relative_y, object_type). +const OBJECTS: &[(&str, i32, i32, ObjectType)] = &[ + ("obj_notice", 6, 6, ObjectType::Readable), + ("obj_terminal", 6, 12, ObjectType::Terminal), + ("obj_hatch", 18, 6, ObjectType::Door), + ("obj_pickup", 18, 12, ObjectType::Pickup), + ("obj_bench", 12, 14, ObjectType::Furniture), +]; + +/// Spawn Interaction Gallery entities in canonical order (StableId 24-28). +pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) { + for &(_name, rx, ry, obj_type) in OBJECTS { + let pos = TilePosition::new(ORIGIN_X + rx, ORIGIN_Y + ry, 0); + let entity = app + .world_mut() + .spawn((Interactable, obj_type, pos)) + .id(); + let sid = registry.register(entity); + app.world_mut() + .entity_mut(entity) + .insert(StableEntityId(sid)); + } +} diff --git a/server/src/test_world/rooms/mod.rs b/server/src/test_world/rooms/mod.rs index 0d464b2d9..059387d66 100644 --- a/server/src/test_world/rooms/mod.rs +++ b/server/src/test_world/rooms/mod.rs @@ -3,7 +3,11 @@ //! Each room module exports a `spawn_entities()` function that creates //! entities in canonical order for deterministic StableId assignment. +pub mod crowd_plaza; +pub mod dialogue_room; +pub mod fog_theater; pub mod hub; +pub mod interaction_gallery; pub mod inventory_warehouse; pub mod occlusion_corridor; pub mod pause_chamber;