From 273d29f26f137c9e438c136bd63a7982218f9e52 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 18 Feb 2026 12:58:14 +0100 Subject: [PATCH 1/4] =?UTF-8?q?feat(simulation):=20sprint=2010=20=E2=80=94?= =?UTF-8?q?=20hub=20teleport,=20confrontation=20response,=20walk-away=20ph?= =?UTF-8?q?ase=202,=20gauntlet=20rooms=204-7,=20blocked=5Fentities=20debug?= =?UTF-8?q?=20field?= 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; From 6c62e2228fa558ea0d29638f9319ba1a6e82a336 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 18 Feb 2026 12:58:27 +0100 Subject: [PATCH 2/4] =?UTF-8?q?test(simulation):=20sprint=2010=20=E2=80=94?= =?UTF-8?q?=20replay=20loading,=20content=20scaling,=20serialization=20v9,?= =?UTF-8?q?=20observer=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #483: Replay loading in test-client — JSONL file loading, tick-scheduled PlayerInput sending, 13 unit tests, 3 sample replay files. #500: Content scaling test — baseline + extra NPC comparative, tick budget assertion (D-026), determinism check across content packs. #514: Serialization tests for protocol v9 — blocked_entities roundtrip, backward compat (v5→v9, v8→v9), regenerated msgpack fixtures. Observer perception tests for confrontation + walk-away mechanics. Co-Authored-By: Claude Opus 4.6 --- .../msgpack/snapshot_boundary_tick_0.msgpack | Bin 241 -> 259 bytes .../snapshot_boundary_tick_127.msgpack | Bin 241 -> 259 bytes .../snapshot_boundary_tick_2b31m1.msgpack | Bin 245 -> 263 bytes .../snapshot_boundary_tick_2b32.msgpack | Bin 249 -> 267 bytes .../snapshot_boundary_tick_32767.msgpack | Bin 243 -> 261 bytes .../fixtures/msgpack/snapshot_empty.msgpack | Bin 241 -> 259 bytes .../msgpack/snapshot_multi_entity.msgpack | Bin 646 -> 664 bytes .../fixtures/msgpack/snapshot_one_npc.msgpack | Bin 339 -> 357 bytes .../fixtures/msgpack/snapshot_player.msgpack | Bin 342 -> 360 bytes .../fixtures/msgpack/snapshot_v2_full.msgpack | Bin 488 -> 506 bytes server/src/perception/observer/tests.rs | 210 ++++++++++++++ server/tests/bridge_ipc.rs | 1 + server/tests/bridge_tcp.rs | 1 + server/tests/content_scaling.rs | 257 ++++++++++++++++++ server/tests/gen_fixtures.rs | 2 + server/tests/golden/proof_room_tick_10.json | 5 +- server/tests/serialization.rs | 93 ++++++- tests/replays/gauntlet_hub_teleport.jsonl | 5 + tests/replays/gauntlet_idle.jsonl | 5 + tests/replays/gauntlet_walk_north.jsonl | 5 + tooling/test-client/Cargo.lock | 45 ++- tooling/test-client/Cargo.toml | 3 + tooling/test-client/src/replay.rs | 153 +++++++++++ 23 files changed, 782 insertions(+), 3 deletions(-) create mode 100644 server/tests/content_scaling.rs create mode 100644 tests/replays/gauntlet_hub_teleport.jsonl create mode 100644 tests/replays/gauntlet_idle.jsonl create mode 100644 tests/replays/gauntlet_walk_north.jsonl diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack index cafdb40685aface7dc81461666d1be3015b7aa93..3700cbd9c29a8d4ad3b455b2fe77fe1c3077aa25 100644 GIT binary patch delta 37 scmey!*v!P$ySyy5s5mn}k8>i|bD<4MIr+)isVVWPc_o=8nW@DS02+%AF#rGn delta 18 ZcmZo>`pC%Dv%D;|s5mn}k7FX&a{xmS2Soq? diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack index e6c1d093d515e73ff554c3944f737b849d1023fd..8f350a9506d8f002f67a02d51b3d72ea3cebc25e 100644 GIT binary patch delta 37 scmey!*v!P$ySyy5s5mn}k8>i|bD<4MIr+)isVVWPc_o=8nW@DS02+%AF#rGn delta 18 ZcmZo>`pC%Dv%D;|s5mn}k7FX&a{xmS2Soq? diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack index 561968e8d11e12083d0acb78715cbe098a4d5dab..829fc4dabb266abdcd641c306f2cf934b256b992 100644 GIT binary patch delta 37 scmey$*v`b&ySyy5s5mn}k8>i|YoQHEIr+)isVVWPc_o=8nW@DS02})cJpcdz delta 18 ZcmZo?`pU@Fv%D;|s5mn}k7FX&YXC!>2T=e3 diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack index 44153c26af800bf698b90008c427bebfb9f06f07..8723189c6b95e0586a8884f66f41a6948755c022 100644 GIT binary patch delta 37 scmey#*v-V%ySyy5s5mn}k8>i|d!Y?UIr+)isVVWPc_o=8nW@DS03B-&NdN!< delta 18 ZcmeBX`pL-Ev%D;|s5mn}k7FX&djLcz2VDRF diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack index 1374de60b9a254a7e904438e0adfc27657c3e5c8..25a2615afb7c4683aba49befe4e3724fa2657cc3 100644 GIT binary patch delta 37 scmey&*viD!ySyy5s5mn}k8>i|OQ8)(Ir+)isVVWPc_o=8nW@DS02@OOHvj+t delta 18 ZcmZo=`pn4Hv%D;|s5mn}k7FX&O8`Sw2TK3| diff --git a/client/tests/fixtures/msgpack/snapshot_empty.msgpack b/client/tests/fixtures/msgpack/snapshot_empty.msgpack index cafdb40685aface7dc81461666d1be3015b7aa93..3700cbd9c29a8d4ad3b455b2fe77fe1c3077aa25 100644 GIT binary patch delta 37 scmey!*v!P$ySyy5s5mn}k8>i|bD<4MIr+)isVVWPc_o=8nW@DS02+%AF#rGn delta 18 ZcmZo>`pC%Dv%D;|s5mn}k7FX&a{xmS2Soq? diff --git a/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack b/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack index bea3ffa615e3abd022fee22fc8e54f278f982316..c17c621184e0a2f3e4933e52e57dc044f3142c6c 100644 GIT binary patch delta 38 tcmZo;ox#f0ySyy5s5mn}k8>ke4U^D@q@4WZ?9`O_)Vz|+lFZcN2>=ix4w(P| delta 19 acmbQi+Q!P&v%D;|s5mn}k7FZO4HE!F>jtd= diff --git a/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack b/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack index b3d6a619cbcc8127723465ab334ea2c0d7ce4326..89d9285580e4004faff9338800b1c756ddda73ff 100644 GIT binary patch delta 38 tcmcc2^puIKcX?TAQE_H|9_L0bPe!2)Njdq+*{Lb+#5PeuSv@&^q7 diff --git a/client/tests/fixtures/msgpack/snapshot_player.msgpack b/client/tests/fixtures/msgpack/snapshot_player.msgpack index 3f9a7316ea5d3db7467a015b669540f1f042d264..29bb08964bfd1e9dd82705516b2d2c7cadb1173e 100644 GIT binary patch delta 38 tcmcb{^n!`2cX?TAQE_H|9_L0bA4Z`KNjdq+*{Lb+#5A4ULAWCs=i diff --git a/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack b/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack index f92affd5020ce74f66342b17f0d3985466ac862c..dea8f77dd6130ff7aef5a80fd4eb44d9927a2192 100644 GIT binary patch delta 38 ucmaFC{EL~ZcX?TAQE_H|9_L1`dyGOGl5+Bsvr|*zQ}aqPOEOc7CjbC5+z+|{ delta 19 acmeyx{DPUQXL(s_QE_H|9>+$mdyD{6G6&88 diff --git a/server/src/perception/observer/tests.rs b/server/src/perception/observer/tests.rs index d2e579d01..73316530f 100644 --- a/server/src/perception/observer/tests.rs +++ b/server/src/perception/observer/tests.rs @@ -2030,3 +2030,213 @@ fn no_cognitive_delay_component_means_empty_pending_recognitions() { "no CognitiveDelay component should produce empty pending_recognitions" ); } + +// ----------------------------------------------------------------------- +// blocked_entities debug field tests (#514) +// ----------------------------------------------------------------------- + +#[test] +fn blocked_entities_empty_when_all_visible() { + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + let npc = world + .spawn((crate::npc::Npc, TilePosition::new(16, 14, 0))) + .id(); + registry.register(npc); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + )) + .id(); + registry.register(player); + world.insert_resource(registry); + + run_observer_pipeline(&mut world); + + let buffer = world.resource::(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + assert!( + snapshot.blocked_entities.is_empty(), + "no blocked entities when NPC is in LOS" + ); +} + +#[test] +fn npc_behind_wall_appears_in_blocked_entities() { + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + // Wall between player and NPC + world + .resource_mut::() + .set_walkable(&TilePosition::new(16, 14, 0), false); + + // NPC behind the wall + let npc = world + .spawn((crate::npc::Npc, TilePosition::new(16, 12, 0))) + .id(); + let npc_sid = registry.register(npc); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + )) + .id(); + registry.register(player); + world.insert_resource(registry); + + run_observer_pipeline(&mut world); + + let buffer = world.resource::(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + assert!( + snapshot.blocked_entities.contains(&npc_sid.0), + "NPC behind wall should appear in blocked_entities" + ); + // Not in visible entities + let npc_visible = snapshot + .entities + .iter() + .any(|e| e.entity_id == npc_sid.0); + assert!(!npc_visible, "NPC should not be in visible entities"); +} + +#[test] +fn npc_behind_player_appears_in_blocked_entities() { + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + // NPC far behind player (south, outside vision cone) + let npc = world + .spawn((crate::npc::Npc, TilePosition::new(16, 26, 0))) + .id(); + let npc_sid = registry.register(npc); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + )) + .id(); + registry.register(player); + world.insert_resource(registry); + + run_observer_pipeline(&mut world); + + let buffer = world.resource::(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + assert!( + snapshot.blocked_entities.contains(&npc_sid.0), + "NPC in blind spot should appear in blocked_entities" + ); +} + +#[test] +fn different_z_level_not_in_blocked_entities() { + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + // NPC on a different z-level + let npc = world + .spawn((crate::npc::Npc, TilePosition::new(16, 14, 1))) + .id(); + let npc_sid = registry.register(npc); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing::default(), + KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + )) + .id(); + registry.register(player); + world.insert_resource(registry); + + run_observer_pipeline(&mut world); + + let buffer = world.resource::(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + assert!( + !snapshot.blocked_entities.contains(&npc_sid.0), + "NPC on different z-level should NOT be in blocked_entities" + ); +} + +#[test] +fn blocked_entities_sorted_ascending() { + // Multiple blocked NPCs should appear in ascending entity_id order + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + // Wall blocks north + world + .resource_mut::() + .set_walkable(&TilePosition::new(16, 14, 0), false); + + // Two NPCs behind wall + one behind player + let npc_a = world + .spawn((crate::npc::Npc, TilePosition::new(16, 12, 0))) + .id(); + let npc_a_sid = registry.register(npc_a); + + let npc_b = world + .spawn((crate::npc::Npc, TilePosition::new(16, 10, 0))) + .id(); + let npc_b_sid = registry.register(npc_b); + + let npc_c = world + .spawn((crate::npc::Npc, TilePosition::new(16, 26, 0))) + .id(); + let npc_c_sid = registry.register(npc_c); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + )) + .id(); + registry.register(player); + world.insert_resource(registry); + + run_observer_pipeline(&mut world); + + let buffer = world.resource::(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + + assert!(snapshot.blocked_entities.len() >= 3); + // Must be sorted ascending (BTreeSet guarantee) + for i in 1..snapshot.blocked_entities.len() { + assert!( + snapshot.blocked_entities[i - 1] < snapshot.blocked_entities[i], + "blocked_entities not sorted: {:?}", + snapshot.blocked_entities + ); + } + // All three NPCs should be present + assert!(snapshot.blocked_entities.contains(&npc_a_sid.0)); + assert!(snapshot.blocked_entities.contains(&npc_b_sid.0)); + assert!(snapshot.blocked_entities.contains(&npc_c_sid.0)); +} diff --git a/server/tests/bridge_ipc.rs b/server/tests/bridge_ipc.rs index af41dab66..cab7ed4e8 100644 --- a/server/tests/bridge_ipc.rs +++ b/server/tests/bridge_ipc.rs @@ -59,6 +59,7 @@ fn snapshot_roundtrip_over_unix_socket() { current_monologue: None, pending_recognitions: vec![], dialogue_response: None, + blocked_entities: vec![], }; bridge diff --git a/server/tests/bridge_tcp.rs b/server/tests/bridge_tcp.rs index 93b8d884d..9a7063f86 100644 --- a/server/tests/bridge_tcp.rs +++ b/server/tests/bridge_tcp.rs @@ -45,6 +45,7 @@ fn snapshot_roundtrip_over_tcp() { current_monologue: None, pending_recognitions: vec![], dialogue_response: None, + blocked_entities: vec![], }; bridge diff --git a/server/tests/content_scaling.rs b/server/tests/content_scaling.rs new file mode 100644 index 000000000..451db7003 --- /dev/null +++ b/server/tests/content_scaling.rs @@ -0,0 +1,257 @@ +//! Content scaling test (#500, D-026). +//! +//! Verifies that adding extra NPCs doesn't degrade tick timing beyond +//! acceptable bounds. Runs the Gauntlet baseline, then adds additional +//! NPCs and compares: +//! 1. Tick timing stays within D-026 budget (100ms) +//! 2. Baseline entities still behave identically (deterministic) +//! +//! Run with: cargo test --test content_scaling -- --nocapture + +use bevy_app::prelude::*; +use std::time::Instant; + +use settled_reach_server::bridge::types::*; +use settled_reach_server::bridge::BridgePlugin; +use settled_reach_server::knowledge::registry::{EntityRegistry, StableEntityId}; +use settled_reach_server::knowledge::KnowledgePlugin; +use settled_reach_server::npc::{Contentment, Npc, NpcPlugin, ToleranceThreshold, Want, WantKind}; +use settled_reach_server::simulation::interaction::Interactable; +use settled_reach_server::simulation::movement::TilePosition; +use settled_reach_server::simulation::path_follow::MovementSpeed; +use settled_reach_server::simulation::SimulationPlugin; + +/// Number of ticks to run for timing measurements. +const TIMING_TICKS: usize = 50; + +/// D-026 budget: 100ms per tick maximum. +const MAX_TICK_MS: f64 = 100.0; + +/// Extra NPC counts for scaling tiers. +const EXTRA_NPC_COUNTS: &[usize] = &[0, 15, 50]; + +/// Set up a Gauntlet world and return the app. +fn setup_baseline() -> App { + let mut app = App::new(); + app.add_plugins(SimulationPlugin); + app.add_plugins(BridgePlugin); + app.add_plugins(KnowledgePlugin); + app.add_plugins(NpcPlugin); + + #[cfg(feature = "gauntlet")] + settled_reach_server::test_world::setup_gauntlet(&mut app); + + app +} + +/// Spawn N extra NPCs spread across the Gauntlet hub area. +/// NPCs are placed in a grid starting at (40, 48) to stay within walkable space. +fn spawn_extra_npcs(app: &mut App, count: usize) { + // Remove registry from world so we can mutate it while also spawning entities. + let mut registry = app + .world_mut() + .remove_resource::() + .expect("EntityRegistry should exist after setup_gauntlet"); + let cols = 10; + + for i in 0..count { + let x = 40 + (i % cols) as i32; + let y = 48 + (i / cols) as i32; + let pos = TilePosition::new(x, y, 0); + + let entity = app + .world_mut() + .spawn(( + Npc, + Interactable, + pos, + Want { + primary: WantKind::Safety, + intensity: 5, + description: format!("extra_npc_{}", i), + }, + 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)); + } + + app.insert_resource(registry); +} + +/// Tick the app N times and return average milliseconds per tick. +fn measure_tick_timing(app: &mut App, ticks: usize) -> f64 { + // Warm-up tick (first tick has startup overhead) + app.update(); + + let start = Instant::now(); + for _ in 0..ticks { + app.update(); + } + let elapsed = start.elapsed(); + elapsed.as_secs_f64() * 1000.0 / ticks as f64 +} + +/// Collect snapshot entity IDs from the VisibilityGeometry and entity count. +fn count_entities(app: &App) -> usize { + let registry = app.world().resource::(); + registry.len() as usize +} + +/// Baseline tick timing: Gauntlet with default entities stays within D-026 budget. +#[test] +#[cfg(feature = "gauntlet")] +fn baseline_tick_timing_within_budget() { + let mut app = setup_baseline(); + let entity_count = count_entities(&app); + let avg_ms = measure_tick_timing(&mut app, TIMING_TICKS); + + eprintln!( + "Baseline: {} entities, avg {:.3}ms/tick over {} ticks", + entity_count, avg_ms, TIMING_TICKS + ); + + assert!( + avg_ms < MAX_TICK_MS, + "Baseline tick timing ({:.3}ms) exceeds D-026 budget ({}ms)", + avg_ms, + MAX_TICK_MS + ); +} + +/// Scaling test: adding NPCs keeps tick timing within D-026 budget. +/// Tests 0 (baseline), 15, and 50 extra NPCs. +#[test] +#[cfg(feature = "gauntlet")] +fn scaling_tick_timing_within_budget() { + let mut results: Vec<(usize, usize, f64)> = Vec::new(); + + for &extra_count in EXTRA_NPC_COUNTS { + let mut app = setup_baseline(); + if extra_count > 0 { + spawn_extra_npcs(&mut app, extra_count); + } + let total_entities = count_entities(&app); + let avg_ms = measure_tick_timing(&mut app, TIMING_TICKS); + results.push((extra_count, total_entities, avg_ms)); + } + + eprintln!("\n=== Content Scaling Results (D-026: {}ms budget) ===", MAX_TICK_MS); + eprintln!("{:<12} {:<10} {:<15}", "Extra NPCs", "Total", "Avg ms/tick"); + eprintln!("{:-<37}", ""); + for &(extra, total, avg_ms) in &results { + let status = if avg_ms < MAX_TICK_MS { "OK" } else { "OVER" }; + eprintln!("{:<12} {:<10} {:<15.3} {}", extra, total, avg_ms, status); + } + + // Assert all tiers stay within budget + for &(extra, _total, avg_ms) in &results { + assert!( + avg_ms < MAX_TICK_MS, + "Tick timing with +{} NPCs ({:.3}ms) exceeds D-026 budget ({}ms)", + extra, + avg_ms, + MAX_TICK_MS + ); + } + + // Assert scaling is reasonable: +50 NPCs shouldn't more than 5x the baseline + if results.len() >= 2 { + let baseline_ms = results[0].2; + let max_extra_ms = results.last().unwrap().2; + let scaling_factor = max_extra_ms / baseline_ms; + eprintln!( + "\nScaling factor (baseline → +{} NPCs): {:.2}x", + results.last().unwrap().0, + scaling_factor + ); + assert!( + scaling_factor < 5.0, + "Scaling factor {:.2}x exceeds 5x threshold — possible O(n^2) regression", + scaling_factor + ); + } +} + +/// Determinism test: baseline entities produce identical snapshots regardless +/// of extra NPCs being present. The original Gauntlet entities (StableId 0-51) +/// should have the same positions and visibility after the same number of ticks. +#[test] +#[cfg(feature = "gauntlet")] +fn extra_npcs_dont_affect_baseline_behavior() { + // Run baseline + let mut baseline_app = setup_baseline(); + for _ in 0..10 { + baseline_app.update(); + } + let baseline_buffer = baseline_app + .world() + .resource::() + .snapshot + .clone(); + + // Run with extra NPCs + let mut scaled_app = setup_baseline(); + spawn_extra_npcs(&mut scaled_app, 15); + for _ in 0..10 { + scaled_app.update(); + } + let scaled_buffer = scaled_app + .world() + .resource::() + .snapshot + .clone(); + + let baseline_snap = baseline_buffer.expect("baseline should produce a snapshot"); + let scaled_snap = scaled_buffer.expect("scaled should produce a snapshot"); + + // Same tick + assert_eq!(baseline_snap.tick, scaled_snap.tick, "tick count should match"); + + // Same game time + assert_eq!( + baseline_snap.game_time.time_of_day, scaled_snap.game_time.time_of_day, + "game time should match" + ); + + // Player position should be identical + let baseline_player = baseline_snap.entities.iter().find(|e| e.kind == EntityKind::Player); + let scaled_player = scaled_snap.entities.iter().find(|e| e.kind == EntityKind::Player); + assert!(baseline_player.is_some(), "baseline should have player"); + assert!(scaled_player.is_some(), "scaled should have player"); + + let bp = baseline_player.unwrap(); + let sp = scaled_player.unwrap(); + assert_eq!(bp.x, sp.x, "player x should match"); + assert_eq!(bp.y, sp.y, "player y should match"); + + // Original entities (entity_id <= 51) visible in baseline should still be + // visible in scaled run. Extra NPCs may add to the visible set, but + // shouldn't remove baseline visibility. + let baseline_original_ids: Vec = baseline_snap + .entities + .iter() + .filter(|e| e.entity_id <= 51) + .map(|e| e.entity_id) + .collect(); + + let scaled_original_ids: Vec = scaled_snap + .entities + .iter() + .filter(|e| e.entity_id <= 51) + .map(|e| e.entity_id) + .collect(); + + assert_eq!( + baseline_original_ids, scaled_original_ids, + "Original Gauntlet entities (id<=51) should be identical in both runs" + ); +} diff --git a/server/tests/gen_fixtures.rs b/server/tests/gen_fixtures.rs index 7becb342b..5eaf8fb5f 100644 --- a/server/tests/gen_fixtures.rs +++ b/server/tests/gen_fixtures.rs @@ -35,6 +35,7 @@ fn fixture_snapshot(tick: u64, entities: Vec) -> ObserverSnapshot current_monologue: None, pending_recognitions: vec![], dialogue_response: None, + blocked_entities: vec![], } } @@ -204,6 +205,7 @@ fn generate_msgpack_fixtures() { current_monologue: None, pending_recognitions: vec![], dialogue_response: None, + blocked_entities: vec![], }; write_fixture( "snapshot_v2_full", diff --git a/server/tests/golden/proof_room_tick_10.json b/server/tests/golden/proof_room_tick_10.json index d6db3f02b..aa692bcee 100644 --- a/server/tests/golden/proof_room_tick_10.json +++ b/server/tests/golden/proof_room_tick_10.json @@ -1,4 +1,7 @@ { + "blocked_entities": [ + 2 + ], "current_monologue": null, "dialogue_response": null, "entities": [ @@ -62,7 +65,7 @@ "player_inventory": [], "player_stance": "Sprint", "tick": 8, - "version": 8, + "version": 9, "visible_tiles": [ { "tile_kind": "Wall", diff --git a/server/tests/serialization.rs b/server/tests/serialization.rs index 2a094c5d4..766422c7c 100644 --- a/server/tests/serialization.rs +++ b/server/tests/serialization.rs @@ -24,6 +24,7 @@ fn test_snapshot(tick: u64, entities: Vec) -> ObserverSnapshot { current_monologue: None, pending_recognitions: vec![], dialogue_response: None, + blocked_entities: vec![], } } @@ -250,6 +251,7 @@ fn snapshot_v2_fields_roundtrip() { current_monologue: None, pending_recognitions: vec![], dialogue_response: None, + blocked_entities: vec![], }; let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); @@ -304,7 +306,7 @@ fn protocol_version_constant_matches_snapshot() { let snapshot = test_snapshot(0, vec![]); assert_eq!(snapshot.version, PROTOCOL_VERSION); assert_eq!( - PROTOCOL_VERSION, 8, + PROTOCOL_VERSION, 9, "bump this assertion when protocol version changes" ); } @@ -342,6 +344,7 @@ fn all_facing_direction_variants_roundtrip() { current_monologue: None, pending_recognitions: vec![], dialogue_response: None, + blocked_entities: vec![], }; let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize"); @@ -469,6 +472,10 @@ fn v5_payload_deserializes_into_v6_struct() { decoded.pending_recognitions.is_empty(), "missing pending_recognitions should default to empty" ); + assert!( + decoded.blocked_entities.is_empty(), + "missing blocked_entities should default to empty" + ); } /// Full 9-slot inventory roundtrip (D-065: 3x3 grid = 9 slots universal) @@ -1095,6 +1102,90 @@ fn gdscript_generated_fixtures_deserialize() { eprintln!("Verified {} GDScript-generated fixtures", count); } +/// blocked_entities Vec round-trips through MessagePack (#514). +/// Guards the debug field survives serialization. +#[test] +fn blocked_entities_roundtrip() { + let mut snapshot = test_snapshot(0, vec![]); + snapshot.blocked_entities = vec![42, 99, 1024]; + + let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); + let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize"); + + assert_eq!( + decoded.blocked_entities, + vec![42, 99, 1024], + "blocked_entities should survive roundtrip" + ); +} + +/// Empty blocked_entities round-trips correctly (#514). +#[test] +fn blocked_entities_empty_roundtrip() { + let snapshot = test_snapshot(0, vec![]); + assert!(snapshot.blocked_entities.is_empty()); + + let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); + let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize"); + + assert!( + decoded.blocked_entities.is_empty(), + "empty blocked_entities should survive roundtrip" + ); +} + +/// v8 payloads (without blocked_entities) must deserialize into the v9 struct +/// via #[serde(default)]. Guards backwards compat during migration (#514). +#[test] +fn v8_payload_deserializes_into_v9_struct() { + #[derive(serde::Serialize)] + struct ObserverSnapshotV8 { + version: u8, + tick: u64, + game_time: GameTime, + player_facing: FacingDirection, + player_stance: MovementStance, + player_inventory: Vec, + entities: Vec, + visible_tiles: Vec, + nearby_interactions: Vec, + current_monologue: Option, + pending_recognitions: Vec, + dialogue_response: Option, + } + + let v8 = ObserverSnapshotV8 { + version: 8, + tick: 100, + game_time: GameTime { + day: 0, + time_of_day: 0, + day_phase: DayPhase::Morning, + tick_rate: TickRate::Full, + }, + player_facing: FacingDirection::North, + player_stance: MovementStance::Walk, + player_inventory: vec![], + entities: vec![], + visible_tiles: vec![], + nearby_interactions: vec![], + current_monologue: None, + pending_recognitions: vec![], + dialogue_response: None, + }; + + let bytes = rmp_serde::to_vec_named(&v8).expect("serialize v8"); + let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes) + .expect("v8 payload should deserialize into v9 struct via serde(default)"); + + assert_eq!(decoded.version, 8, "version field preserved from v8"); + assert_eq!(decoded.tick, 100); + assert!( + decoded.blocked_entities.is_empty(), + "missing blocked_entities should default to empty" + ); +} + /// NearbyInteraction.object_type round-trips through MessagePack (#422). /// Verifies object_type=Some(Container) survives the wire. #[test] diff --git a/tests/replays/gauntlet_hub_teleport.jsonl b/tests/replays/gauntlet_hub_teleport.jsonl new file mode 100644 index 000000000..6b0491825 --- /dev/null +++ b/tests/replays/gauntlet_hub_teleport.jsonl @@ -0,0 +1,5 @@ +[{"tick":0,"action":"MoveNorth"}] +[{"tick":1,"action":"MoveNorth"}] +[{"tick":2,"action":"MoveEast"}] +[{"tick":3,"action":"TeleportToHub"}] +[{"tick":4,"action":"MoveNorth"}] diff --git a/tests/replays/gauntlet_idle.jsonl b/tests/replays/gauntlet_idle.jsonl new file mode 100644 index 000000000..5cbe1a286 --- /dev/null +++ b/tests/replays/gauntlet_idle.jsonl @@ -0,0 +1,5 @@ +[] +[] +[] +[] +[] diff --git a/tests/replays/gauntlet_walk_north.jsonl b/tests/replays/gauntlet_walk_north.jsonl new file mode 100644 index 000000000..92231c1a3 --- /dev/null +++ b/tests/replays/gauntlet_walk_north.jsonl @@ -0,0 +1,5 @@ +[{"tick":0,"action":"MoveNorth"}] +[{"tick":1,"action":"MoveNorth"}] +[{"tick":2,"action":"MoveNorth"}] +[{"tick":3,"action":"MoveNorth"}] +[{"tick":4,"action":"MoveNorth"}] diff --git a/tooling/test-client/Cargo.lock b/tooling/test-client/Cargo.lock index 95813535b..821826966 100644 --- a/tooling/test-client/Cargo.lock +++ b/tooling/test-client/Cargo.lock @@ -556,6 +556,16 @@ dependencies = [ "typeid", ] +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + [[package]] name = "event-listener" version = "5.4.1" @@ -799,6 +809,12 @@ version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + [[package]] name = "log" version = "0.4.29" @@ -1067,6 +1083,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -1143,7 +1172,7 @@ dependencies = [ [[package]] name = "settled-reach-server" -version = "0.1.0" +version = "0.1.9" dependencies = [ "bevy_app", "bevy_ecs", @@ -1168,6 +1197,7 @@ dependencies = [ "serde", "serde_json", "settled-reach-server", + "tempfile", ] [[package]] @@ -1241,6 +1271,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" +dependencies = [ + "fastrand", + "getrandom 0.4.1", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "thiserror" version = "2.0.18" diff --git a/tooling/test-client/Cargo.toml b/tooling/test-client/Cargo.toml index 5e59ee4ab..a96939aa5 100644 --- a/tooling/test-client/Cargo.toml +++ b/tooling/test-client/Cargo.toml @@ -14,3 +14,6 @@ clap = { version = "4", features = ["derive"] } serde_json = "1" serde = { version = "1", features = ["derive"] } rmp-serde = "1" + +[dev-dependencies] +tempfile = "3" diff --git a/tooling/test-client/src/replay.rs b/tooling/test-client/src/replay.rs index 45c01b4f1..2a1776b82 100644 --- a/tooling/test-client/src/replay.rs +++ b/tooling/test-client/src/replay.rs @@ -6,6 +6,10 @@ use settled_reach_server::bridge::types::PlayerInput; use std::path::Path; /// Load a JSONL replay file. Returns one Vec per tick. +/// +/// Format: one JSON array per line. Each array contains PlayerInput objects +/// for that tick. Blank lines are skipped. Returns Err with line number on +/// parse failure. pub fn load_replay(path: &Path) -> Result>, String> { let content = std::fs::read_to_string(path) .map_err(|e| format!("failed to read replay file {}: {}", path.display(), e))?; @@ -22,3 +26,152 @@ pub fn load_replay(path: &Path) -> Result>, String> { } Ok(ticks) } + +#[cfg(test)] +mod tests { + use super::*; + use settled_reach_server::bridge::types::PlayerAction; + use std::io::Write; + + fn write_temp_file(content: &str) -> tempfile::NamedTempFile { + let mut f = tempfile::NamedTempFile::new().unwrap(); + f.write_all(content.as_bytes()).unwrap(); + f.flush().unwrap(); + f + } + + #[test] + fn load_single_tick_single_action() { + let f = write_temp_file(r#"[{"tick":0,"action":"MoveNorth"}]"#); + let ticks = load_replay(f.path()).unwrap(); + assert_eq!(ticks.len(), 1); + assert_eq!(ticks[0].len(), 1); + assert!(ticks[0][0].action.is_movement()); + } + + #[test] + fn load_multiple_ticks() { + let content = r#"[{"tick":0,"action":"MoveNorth"}] +[{"tick":1,"action":"MoveEast"}] +[{"tick":2,"action":"MoveSouth"}]"#; + let f = write_temp_file(content); + let ticks = load_replay(f.path()).unwrap(); + assert_eq!(ticks.len(), 3); + } + + #[test] + fn load_multiple_actions_per_tick() { + let content = r#"[{"tick":0,"action":"MoveNorth"},{"tick":0,"action":"Pause"}]"#; + let f = write_temp_file(content); + let ticks = load_replay(f.path()).unwrap(); + assert_eq!(ticks.len(), 1); + assert_eq!(ticks[0].len(), 2); + } + + #[test] + fn load_empty_array_idle_tick() { + let content = "[]"; + let f = write_temp_file(content); + let ticks = load_replay(f.path()).unwrap(); + assert_eq!(ticks.len(), 1); + assert!(ticks[0].is_empty()); + } + + #[test] + fn blank_lines_skipped() { + let content = r#"[{"tick":0,"action":"MoveNorth"}] + +[{"tick":2,"action":"MoveSouth"}] + +"#; + let f = write_temp_file(content); + let ticks = load_replay(f.path()).unwrap(); + assert_eq!(ticks.len(), 2, "blank lines should be skipped, not counted as ticks"); + } + + #[test] + fn empty_file_returns_empty_vec() { + let f = write_temp_file(""); + let ticks = load_replay(f.path()).unwrap(); + assert!(ticks.is_empty()); + } + + #[test] + fn whitespace_only_file_returns_empty_vec() { + let f = write_temp_file(" \n \n\n "); + let ticks = load_replay(f.path()).unwrap(); + assert!(ticks.is_empty()); + } + + #[test] + fn invalid_json_reports_line_number() { + let content = r#"[{"tick":0,"action":"MoveNorth"}] +not valid json +[{"tick":2,"action":"MoveSouth"}]"#; + let f = write_temp_file(content); + let err = load_replay(f.path()).unwrap_err(); + assert!(err.contains("replay line 2"), "error should reference line 2, got: {}", err); + } + + #[test] + fn missing_file_returns_error() { + let err = load_replay(Path::new("/nonexistent/replay.jsonl")).unwrap_err(); + assert!(err.contains("failed to read replay file"), "got: {}", err); + } + + #[test] + fn interact_action_parses() { + let content = + r#"[{"tick":0,"action":{"Interact":{"target_entity_id":42,"verb":"Talk"}}}]"#; + let f = write_temp_file(content); + let ticks = load_replay(f.path()).unwrap(); + assert_eq!(ticks.len(), 1); + match &ticks[0][0].action { + PlayerAction::Interact { + target_entity_id, + verb, + } => { + assert_eq!(*target_entity_id, Some(42)); + assert_eq!(verb.as_deref(), Some("Talk")); + } + other => panic!("expected Interact, got {:?}", other), + } + } + + #[test] + fn teleport_to_hub_parses() { + let content = r#"[{"tick":0,"action":"TeleportToHub"}]"#; + let f = write_temp_file(content); + let ticks = load_replay(f.path()).unwrap(); + assert_eq!(ticks.len(), 1); + assert!(matches!(ticks[0][0].action, PlayerAction::TeleportToHub)); + } + + #[test] + fn walk_away_parses() { + let content = r#"[{"tick":0,"action":"WalkAway"}]"#; + let f = write_temp_file(content); + let ticks = load_replay(f.path()).unwrap(); + assert!(matches!(ticks[0][0].action, PlayerAction::WalkAway)); + } + + #[test] + fn mixed_replay_scenario() { + // Simulates a realistic Gauntlet replay: move, idle, interact, move, teleport + let content = r#"[{"tick":0,"action":"MoveNorth"}] +[{"tick":1,"action":"MoveNorth"}] +[] +[{"tick":3,"action":{"Interact":{"target_entity_id":100,"verb":"Talk"}}}] +[{"tick":4,"action":"MoveEast"}] +[{"tick":5,"action":"TeleportToHub"}]"#; + let f = write_temp_file(content); + let ticks = load_replay(f.path()).unwrap(); + assert_eq!(ticks.len(), 6); + assert_eq!(ticks[0].len(), 1); // MoveNorth + assert_eq!(ticks[1].len(), 1); // MoveNorth + assert_eq!(ticks[2].len(), 0); // Idle + assert_eq!(ticks[3].len(), 1); // Interact + assert_eq!(ticks[4].len(), 1); // MoveEast + assert_eq!(ticks[5].len(), 1); // TeleportToHub + } +} From d92a2e5f50392422b5e0447e4a1965f76230e915 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 18 Feb 2026 17:50:22 +0100 Subject: [PATCH 3/4] =?UTF-8?q?fix(simulation):=20address=20PR=20#37=20rev?= =?UTF-8?q?iew=20=E2=80=94=20doc=20corrections,=20race=20fix,=20marker=20c?= =?UTF-8?q?leanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hoshe review (4 items): - types.rs: doc comment "Current: 6" → "Current: 9" - dialogue.rs: walk-away doc duplicated numbering (items 4-5 were 2-3) - test_world/mod.rs: comment "Reset plates at 49-51" → "49-55" - content_scaling.rs: magic number 51 → constants::RESET_PLATE_STABLE_IDS.1 Tyre review (3 items): - knowledge/types.rs: guard comments on decrement() floor at Hostile - input.rs: TeleportToHub now clears ConfrontationDelivered marker - content_scaling.rs: same magic number fix (covered above) Additional: - content_runtime.rs: barrier-based shutdown handshake fixes TCP RST race condition under parallel test execution - dialogue_room.rs: clippy type_complexity allow on NPCS tuple array Co-Authored-By: Claude Opus 4.6 --- server/src/bridge/types.rs | 2 +- server/src/knowledge/types.rs | 6 +++--- server/src/simulation/dialogue.rs | 4 ++-- server/src/simulation/input.rs | 5 +++-- server/src/test_world/mod.rs | 2 +- server/src/test_world/rooms/dialogue_room.rs | 1 + server/tests/content_runtime.rs | 12 ++++++++++++ server/tests/content_scaling.rs | 11 ++++++----- 8 files changed, 29 insertions(+), 14 deletions(-) diff --git a/server/src/bridge/types.rs b/server/src/bridge/types.rs index 094f7b1ac..83da7832e 100644 --- a/server/src/bridge/types.rs +++ b/server/src/bridge/types.rs @@ -31,7 +31,7 @@ pub const PROTOCOL_VERSION: u8 = 9; /// Future fields: ambient sound events, HUD state (D-020 expansion). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ObserverSnapshot { - /// Protocol version for forward compatibility. Current: 6. + /// Protocol version for forward compatibility. Current: 9. pub version: u8, /// Simulation tick when this snapshot was produced pub tick: u64, diff --git a/server/src/knowledge/types.rs b/server/src/knowledge/types.rs index 49d667405..08a1430ad 100644 --- a/server/src/knowledge/types.rs +++ b/server/src/knowledge/types.rs @@ -134,14 +134,14 @@ impl RelationshipState { /// /// Friendly → Known → PersonOfInterest → Hostile. /// Unknown stays Unknown (can't confront a stranger meaningfully). - /// Hostile stays Hostile (already worst state). + /// Hostile stays Hostile — floor, does not wrap or panic. 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, + Self::Unknown => Self::Unknown, // no-op: can't confront a stranger + Self::Hostile => Self::Hostile, // floor: already worst state } } } diff --git a/server/src/simulation/dialogue.rs b/server/src/simulation/dialogue.rs index f3cf583e7..d8df9b1b3 100644 --- a/server/src/simulation/dialogue.rs +++ b/server/src/simulation/dialogue.rs @@ -498,8 +498,8 @@ pub fn process_talk_interaction( /// 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 +/// 4. Clears ActiveDialogue state +/// 5. Removes the WalkAwayRequest marker /// /// If no ActiveDialogue is present, removes WalkAwayRequest silently (no-op). /// diff --git a/server/src/simulation/input.rs b/server/src/simulation/input.rs index 2efd9d9a5..8586b5b9c 100644 --- a/server/src/simulation/input.rs +++ b/server/src/simulation/input.rs @@ -629,12 +629,13 @@ fn handle_teleport_to_hub( // Clear any pending movement commands.entity(player_entity).remove::(); - // Clear dialogue/interaction markers + // Clear dialogue/interaction markers (including mid-confrontation state) commands .entity(player_entity) .remove::() .remove::() - .remove::(); + .remove::() + .remove::(); tracing::info!( x = hub_spawn.x, diff --git a/server/src/test_world/mod.rs b/server/src/test_world/mod.rs index 004d34066..3df2ee9d4 100644 --- a/server/src/test_world/mod.rs +++ b/server/src/test_world/mod.rs @@ -414,7 +414,7 @@ mod tests { assert!(registry.to_entity(&StableId(id)).is_some(), "Crowd Plaza at StableId {}", id); } - // Reset plates at 49-51 + // Reset plates at 49-55 for id in constants::RESET_PLATE_STABLE_IDS.0..=constants::RESET_PLATE_STABLE_IDS.1 { assert!( registry.to_entity(&StableId(id)).is_some(), diff --git a/server/src/test_world/rooms/dialogue_room.rs b/server/src/test_world/rooms/dialogue_room.rs index 272b00385..412bf1002 100644 --- a/server/src/test_world/rooms/dialogue_room.rs +++ b/server/src/test_world/rooms/dialogue_room.rs @@ -28,6 +28,7 @@ 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). +#[allow(clippy::type_complexity)] const NPCS: &[(&str, i32, i32, WantKind, u8, i16, i16, i16, &str, &str)] = &[ ( "npc_dialogue_a", 8, 8, WantKind::Connection, 4, 20, 10, 60, diff --git a/server/tests/content_runtime.rs b/server/tests/content_runtime.rs index 743755400..da603bf85 100644 --- a/server/tests/content_runtime.rs +++ b/server/tests/content_runtime.rs @@ -8,6 +8,7 @@ use bevy_app::prelude::*; use std::net::{TcpListener, TcpStream}; use std::path::PathBuf; +use std::sync::{Arc, Barrier}; use std::thread; use std::time::Duration; @@ -51,6 +52,11 @@ fn content_runtime_boot_tick_10_snapshot() { let listener = TcpListener::bind("127.0.0.1:0").expect("bind listener"); let server_addr = listener.local_addr().expect("get local addr"); + // Barrier keeps the server thread alive until the client has finished + // reading all snapshots, preventing a TCP RST race under parallel execution. + let barrier = Arc::new(Barrier::new(2)); + let server_barrier = barrier.clone(); + // Server thread: full plugin stack with real content let server_handle = thread::spawn(move || { let bridge = TcpBridge::accept_on(listener).expect("accept connection"); @@ -96,6 +102,9 @@ fn content_runtime_boot_tick_10_snapshot() { for _ in 0..10 { app.update(); } + + // Wait for client to finish reading before dropping the TCP socket + server_barrier.wait(); }); // Client: connect with read timeout and receive 10 snapshots @@ -129,6 +138,9 @@ fn content_runtime_boot_tick_10_snapshot() { drop(reader); drop(writer); + // Signal server thread that client is done reading + barrier.wait(); + // Server thread must not have panicked server_handle .join() diff --git a/server/tests/content_scaling.rs b/server/tests/content_scaling.rs index 451db7003..bdc3249a4 100644 --- a/server/tests/content_scaling.rs +++ b/server/tests/content_scaling.rs @@ -233,20 +233,21 @@ fn extra_npcs_dont_affect_baseline_behavior() { assert_eq!(bp.x, sp.x, "player x should match"); assert_eq!(bp.y, sp.y, "player y should match"); - // Original entities (entity_id <= 51) visible in baseline should still be - // visible in scaled run. Extra NPCs may add to the visible set, but - // shouldn't remove baseline visibility. + // Original entities (entity_id <= max Gauntlet StableId) visible in baseline + // should still be visible in scaled run. Extra NPCs may add to the visible + // set, but shouldn't remove baseline visibility. + let max_baseline_id = settled_reach_server::test_world::constants::RESET_PLATE_STABLE_IDS.1; let baseline_original_ids: Vec = baseline_snap .entities .iter() - .filter(|e| e.entity_id <= 51) + .filter(|e| e.entity_id <= max_baseline_id) .map(|e| e.entity_id) .collect(); let scaled_original_ids: Vec = scaled_snap .entities .iter() - .filter(|e| e.entity_id <= 51) + .filter(|e| e.entity_id <= max_baseline_id) .map(|e| e.entity_id) .collect(); From 52eb8bfc81d7542ae5e392d4f3e637053848712f Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 18 Feb 2026 18:22:32 +0100 Subject: [PATCH 4/4] =?UTF-8?q?fix(simulation):=20address=20PR=20#37=20re-?= =?UTF-8?q?review=20=E2=80=94=20stale=20strings,=20test=20coverage,=20conf?= =?UTF-8?q?rontation=20symmetry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hoshe re-review (3 items): - content_scaling.rs:185: doc "StableId 0-51" → references constant - content_scaling.rs:256: assertion message "id<=51" → "id <= max_baseline_id" - input.rs: teleport test now asserts WalkAwayRequest + ConfrontationDelivered are cleared (was only checking TalkRequest + ActiveDialogue) Tyre re-review (2 items): - input.rs: same teleport test coverage (overlaps Hoshe #3) - dialogue.rs: process_confrontation_response now inserts RoutineDeviation with DeviationTrigger::Confrontation — symmetric with walk-away path. Test updated to verify deviation is recorded. Co-Authored-By: Claude Opus 4.6 --- server/src/simulation/dialogue.rs | 20 +++++++++++++++++--- server/src/simulation/input.rs | 19 +++++++++++++++++-- server/tests/content_scaling.rs | 7 ++++--- 3 files changed, 38 insertions(+), 8 deletions(-) diff --git a/server/src/simulation/dialogue.rs b/server/src/simulation/dialogue.rs index d8df9b1b3..ff0f129cb 100644 --- a/server/src/simulation/dialogue.rs +++ b/server/src/simulation/dialogue.rs @@ -614,9 +614,14 @@ pub fn process_confrontation_response( let target = confrontation.target; // Effect 1: Shift target NPC to Tier 2 animation (D-047) - commands - .entity(target) - .insert(crate::npc::AnimationTier::Tier2); + // + record routine deviation (symmetric with walk-away path) + commands.entity(target).insert(( + crate::npc::AnimationTier::Tier2, + crate::npc::RoutineDeviation { + trigger: crate::npc::DeviationTrigger::Confrontation, + tick: time.tick, + }, + )); // Effect 2: Decrement observer's relationship with the target (D-033 color fade) if let Some(target_sid) = registry.to_stable(target) { @@ -1651,6 +1656,15 @@ mod tests { 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] diff --git a/server/src/simulation/input.rs b/server/src/simulation/input.rs index 8586b5b9c..9c8e2ab2b 100644 --- a/server/src/simulation/input.rs +++ b/server/src/simulation/input.rs @@ -1860,7 +1860,8 @@ mod tests { #[cfg(feature = "gauntlet")] #[test] fn teleport_to_hub_clears_dialogue_markers() { - // #491: TeleportToHub removes ActiveDialogue and TalkRequest. + // #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()); @@ -1869,7 +1870,7 @@ mod tests { // Spawn a fake NPC target let npc = world.spawn(TilePosition::new(10, 10, 0)).id(); - // Spawn player with active dialogue state + // Spawn player with active dialogue state + mid-confrontation marker let player = world .spawn(( PlayerCharacter, @@ -1880,6 +1881,8 @@ mod tests { interaction_type: crate::knowledge::events::InteractionType::Talk, started_tick: 0, }, + crate::simulation::dialogue::WalkAwayRequest, + crate::simulation::dialogue::ConfrontationDelivered { target: npc }, )) .id(); @@ -1904,6 +1907,18 @@ mod tests { .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")] diff --git a/server/tests/content_scaling.rs b/server/tests/content_scaling.rs index bdc3249a4..3ca5bfa5b 100644 --- a/server/tests/content_scaling.rs +++ b/server/tests/content_scaling.rs @@ -182,8 +182,9 @@ fn scaling_tick_timing_within_budget() { } /// Determinism test: baseline entities produce identical snapshots regardless -/// of extra NPCs being present. The original Gauntlet entities (StableId 0-51) -/// should have the same positions and visibility after the same number of ticks. +/// of extra NPCs being present. The original Gauntlet entities (StableId 0 +/// through RESET_PLATE_STABLE_IDS.1) should have the same positions and +/// visibility after the same number of ticks. #[test] #[cfg(feature = "gauntlet")] fn extra_npcs_dont_affect_baseline_behavior() { @@ -253,6 +254,6 @@ fn extra_npcs_dont_affect_baseline_behavior() { assert_eq!( baseline_original_ids, scaled_original_ids, - "Original Gauntlet entities (id<=51) should be identical in both runs" + "Original Gauntlet entities (id <= max_baseline_id) should be identical in both runs" ); }