From 98f4cedc03104f23bccacf85d6dd61d967acb476 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 14 Feb 2026 15:38:59 +0100 Subject: [PATCH 1/8] =?UTF-8?q?feat(simulation):=20Sprint=206=20Touch=20?= =?UTF-8?q?=E2=80=94=20stance,=20tile=20presence,=20verbs,=20protocol=20v6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the core Sprint 6: Touch systems across 5 tickets: - #449 ObserverSnapshot v6: add player_stance (MovementStance) and player_inventory (Vec) wire fields with serde defaults for backward compatibility. Bump PROTOCOL_VERSION 5→6. - #417 Stance system: Sprint/Walk/Careful/Crouch movement stance with tick-based speed (1/2/3/4 ticks per move), monologue rate multipliers, and PlayerMoveCooldown component. ToggleStanceUp/Down player actions. - #420 TilePresence: posture-layer collision system allowing same-tile occupancy for different layers (Standing/Prone/Seated/Fixture). Layer-based collision in validate_movement. - #421 ObjectType component: Readable/Container/Terminal/Door/Pickup/ Furniture types with Phase 1 verb sets computed from type + proximity. - #422 Phase 2 verb filter: KG-gated observer-side verb processing — POI priority flips, Confront injection at KnowsDetails+, contradiction marking, archetype-specific label relabeling (Smuggler/Detective). 217 unit tests + 17 integration tests passing. All MessagePack fixtures regenerated for v6 wire format. Co-Authored-By: Claude Opus 4.6 --- .../fixtures/msgpack/snapshot_empty.msgpack | Bin 163 -> 200 bytes .../msgpack/snapshot_multi_entity.msgpack | Bin 568 -> 605 bytes .../fixtures/msgpack/snapshot_one_npc.msgpack | Bin 261 -> 298 bytes .../fixtures/msgpack/snapshot_player.msgpack | Bin 264 -> 301 bytes .../fixtures/msgpack/snapshot_v2_full.msgpack | Bin 410 -> 447 bytes server/src/bridge/types.rs | 177 +++++- server/src/perception/observer/mod.rs | 122 +++- server/src/perception/observer/tests.rs | 54 +- server/src/simulation/input.rs | 272 +++++++- server/src/simulation/interaction.rs | 586 ++++++++++++++++-- server/src/simulation/mod.rs | 1 + server/src/simulation/movement.rs | 327 +++++++++- server/src/simulation/stance.rs | 162 +++++ server/tests/bridge_ipc.rs | 2 + server/tests/bridge_tcp.rs | 2 + server/tests/game_loop.rs | 2 +- server/tests/gen_fixtures.rs | 4 + server/tests/serialization.rs | 209 ++++++- 18 files changed, 1825 insertions(+), 95 deletions(-) create mode 100644 server/src/simulation/stance.rs diff --git a/client/tests/fixtures/msgpack/snapshot_empty.msgpack b/client/tests/fixtures/msgpack/snapshot_empty.msgpack index 3868af268ba0eac25feb9519c81665f49b682dbe..d767797550e5ce81504247fe790df1f21f2191c9 100644 GIT binary patch delta 59 zcmZ3?c!H6uYk65}QE_H|9@|8&2-UR(If<32Me)TYiFwJXOTrU#vNu45GV{t(^GfoI KDkn@#as&XxbQom- delta 21 ccmX@XxR{ZvV|iI>QE_H|9_vJ|h>7iv09hLc{r~^~ diff --git a/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack b/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack index 51037146adfec667b501f4e097490e318bf963db..94055d595600d579ea722cb6d8e591f5949eaf08 100644 GIT binary patch delta 60 zcmdnNa+igxYk65}QE_H|9@|8&DAlzEIf<32Me)TYiFwJXOTrU#vNu45GV{t(^GfoI LDkp4Axxxql>E;QE_H|9_vJ|sEwUh7y)Lw2<89) diff --git a/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack b/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack index 70801445860ed42f1219d52adedd2d86923c307e..698b978527db7a83ea9658bf4d125aa56ce03c20 100644 GIT binary patch delta 59 zcmZo=TE)cGwY)5~s5mn}k8L7XgzDOYoW#o1qWI#H#JuFxCE)WO8nvAis`s5mn}k98tf#KiVx099@XF8}}l diff --git a/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack b/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack index cb6219705d93254571afe04284456bdf4521ca7f..2b830526bfc74b1e6abaafffc307270e1fbfac22 100644 GIT binary patch delta 60 zcmbQmyq}q?Yk65}QE_H|9@|8&1l6?#If<32Me)TYiFwJXOTrU#vNu45GV{t(^GfoI LDkp5rGGGJ%+iDnh delta 22 dcmdnbJd2sDV|iI>QE_H|9_vJ|gpK_Mi~wMc2oL}O diff --git a/server/src/bridge/types.rs b/server/src/bridge/types.rs index 9fd31e2cf..3251c94c1 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 = 5; +pub const PROTOCOL_VERSION: u8 = 6; /// The ONLY data structure crossing the client-server boundary (D-020) /// Contains all information visible to the observer at a given tick. @@ -24,10 +24,11 @@ pub const PROTOCOL_VERSION: u8 = 5; /// v3 adds: relationship (D-033 entity color), observation (Visible/Remembered). /// v4 adds: nearby_interactions (D-060, #404 proximity + verbs[]). /// v5 adds: current_monologue (#414 internal monologue pipeline). +/// v6 adds: player_stance (#449, D-053), player_inventory (#449, D-065). /// Future fields: ambient sound events, HUD state (D-020 expansion). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ObserverSnapshot { - /// Protocol version for forward compatibility. Current: 4. + /// Protocol version for forward compatibility. Current: 6. pub version: u8, /// Simulation tick when this snapshot was produced pub tick: u64, @@ -35,6 +36,15 @@ pub struct ObserverSnapshot { pub game_time: GameTime, /// Player character's facing direction for vision cone (D-015) pub player_facing: FacingDirection, + /// Player's current movement stance for HUD display (#449, D-053). + /// Defaults to Walk when stance component is absent. + #[serde(default)] + pub player_stance: MovementStance, + /// Items in the player's inventory (#449, D-065). + /// Visible only to this observer per D-010 info boundary. + /// Empty when no CarriedBy component is present. + #[serde(default)] + pub player_inventory: Vec, /// All entities visible to the observer (filtered by LOS + vision cone) pub entities: Vec, /// Tiles visible to the observer for fog rendering @@ -62,6 +72,81 @@ pub struct GameTime { pub tick_rate: TickRate, } +/// Player movement stance for tick-based movement speed (#449, D-053). +/// Sprint/Walk/Careful/Crouch affect movement ticks, monologue rate, and +/// interaction buffer availability. Wire format for ObserverSnapshot. +/// v0.1 scope: Sprint/Walk/Careful/Crouch only (Prone deferred). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)] +pub enum MovementStance { + /// 1 tile/tick, monologue at 40%, interaction buffer suppressed (D-055) + Sprint, + /// 1 tile/2 ticks, monologue at 100% (default) + #[default] + Walk, + /// 1 tile/3 ticks, monologue at 150% + Careful, + /// 1 tile/4 ticks, uses Prone/Seated posture layer (D-054) + Crouch, +} + +impl MovementStance { + /// Move one step up the stance ladder (toward Sprint). + /// Returns self if already at the top. + pub fn step_up(self) -> Self { + match self { + Self::Crouch => Self::Careful, + Self::Careful => Self::Walk, + Self::Walk => Self::Sprint, + Self::Sprint => Self::Sprint, + } + } + + /// Move one step down the stance ladder (toward Crouch). + /// Returns self if already at the bottom. + pub fn step_down(self) -> Self { + match self { + Self::Sprint => Self::Walk, + Self::Walk => Self::Careful, + Self::Careful => Self::Crouch, + Self::Crouch => Self::Crouch, + } + } + + /// Ticks per movement step for this stance. + pub fn ticks_per_move(self) -> u32 { + match self { + Self::Sprint => 1, + Self::Walk => 2, + Self::Careful => 3, + Self::Crouch => 4, + } + } + + /// Monologue rate multiplier as a percentage (100 = baseline). + /// Sprint suppresses to 40%, Careful enhances to 150% (D-053). + pub fn monologue_rate_percent(self) -> u32 { + match self { + Self::Sprint => 40, + Self::Walk => 100, + Self::Careful => 150, + Self::Crouch => 100, + } + } +} + +/// An item in the player's inventory, crossing the wire boundary (#449, D-065). +/// Only items carried by the observer are included (D-010 info boundary). +/// Slot positions map to a 3x3 grid (0-8), 9 slots universal. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InventoryItem { + /// Wire-format entity identifier for the item + pub item_id: u64, + /// Display name for inventory UI + pub name: String, + /// Inventory slot index (0-8 for 3x3 grid) + pub slot: u8, +} + /// 8-directional facing direction, matching movement system. /// Used for vision cone computation (D-015) and snapshot wire format. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)] @@ -142,6 +227,43 @@ pub enum EntityKind { Terrain, } +/// Object type for D-057 Phase 1 verb computation (#421). +/// +/// Determines the maximum possible verb set for an interactable world object. +/// NPCs don't use ObjectType — they have their own verb logic (Talk/ExamineNpc). +/// Phase 2 (#422) filters these verbs by the observer's knowledge graph. +/// +/// Defined here in bridge::types because it appears on the wire in +/// NearbyInteraction.object_type for Phase 2 context. +/// VerbDef and verb_set() remain in simulation::interaction. +#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum ObjectType { + /// Documents, manifests, notices — can be read + Readable, + /// Crates, lockers, cargo containers — can be opened and searched + Container, + /// Access terminals, comms panels — can be used + Terminal, + /// Doors, hatches, bulkheads — can be opened/closed + Door, + /// Small items that can be picked up (physical inventory, D-065) + Pickup, + /// Chairs, benches, consoles — can be sat at + Furniture, +} + +/// Character archetype for Phase 2 verb filtering (#422) and monologue pool +/// selection. Determines how the character perceives and labels interactions. +/// v0.1: Smuggler and Detective (the two playable characters). +#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)] +pub enum CharacterArchetype { + /// Smuggler character — sees Move/Stash on containers, physical manipulation verbs + Smuggler, + /// Detective character — sees Scan/Flag on containers, investigation verbs + #[default] + Detective, +} + /// Semantic player actions, not raw key events (D-020) /// Timestamped for deterministic processing #[derive(Debug, Clone, Serialize, Deserialize)] @@ -174,6 +296,10 @@ pub enum PlayerAction { Unpause, /// Set tick rate: Full (1.0), Half (0.5), or Paused (0.0) per D-052 SetTickRate(TickRate), + /// Move one step up the stance ladder (toward Sprint) per D-053 + ToggleStanceUp, + /// Move one step down the stance ladder (toward Crouch) per D-053 + ToggleStanceDown, } /// Available interaction verbs for a nearby entity (D-060, #404) @@ -188,6 +314,15 @@ pub struct NearbyInteraction { pub distance: u32, /// Available verbs sorted by priority (index 0 = highest priority) pub verbs: Vec, + /// Object type for Phase 2 verb filter context (#422). + /// None for NPCs and untyped objects. Enables archetype-specific + /// label remapping (smuggler/detective see different labels for same verb). + #[serde(default)] + pub object_type: Option, + /// Whether the observer has contradicted knowledge about this entity (#422). + /// Client may render a contradiction indicator (e.g., amber warning icon). + #[serde(default)] + pub contradicted: bool, } /// A single available verb on a nearby entity @@ -203,14 +338,48 @@ pub struct VerbOption { pub available: bool, } -/// Verb types for the interaction system (D-060) +/// Verb types for the interaction system (D-057, D-060) /// Only active verbs appear in verbs[]. Passive (Look, Overhear) and /// reactive (Monologue) verbs fire independently. +/// +/// Phase 1 verbs (simulation, no KG): derived from ObjectType component (#421). +/// Phase 2 verbs (observer, reads KG): filtered/augmented by #422. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum VerbKind { - ExamineObject, + // --- NPC verbs --- + /// Observe an NPC (available at mid + close range) ExamineNpc, + /// Talk to an NPC (close range only) Talk, + + // --- Object verbs (D-057, per ObjectType) --- + /// Generic observation — common to all object types + Observe, + /// Readable objects (manifests, logs, notices) + Read, + /// Container / Door — open it + Open, + /// Door — close it + Close, + /// Container — deeper search (distinct from Open) + Search, + /// Terminal — access logs, comms + Use, + /// Pickup items — physical inventory (D-065) + Take, + /// Furniture — sit/use + Sit, + + // --- Phase 2 verbs (observer, KG-gated, #422) --- + /// Confront an NPC about known facts/contradictions. + /// Phase 2 only: injected when observer has KnowsDetails+ confidence. + /// Close range only. Opens confrontation dialogue (D-063). + Confront, + + // --- Legacy fallback --- + /// Untyped object examination (entities without ObjectType component). + /// Prefer ObjectType-derived verbs for new content. + ExamineObject, } /// Internal monologue event sent to the client for display (#414). diff --git a/server/src/perception/observer/mod.rs b/server/src/perception/observer/mod.rs index 6de16c552..b96b2e3a0 100644 --- a/server/src/perception/observer/mod.rs +++ b/server/src/perception/observer/mod.rs @@ -10,12 +10,14 @@ use bevy_ecs::prelude::*; use std::collections::HashSet; use crate::bridge::types::*; +use crate::knowledge::types::KnowledgeState; use crate::knowledge::{EntityRegistry, KnowledgeGraph, StableId}; use crate::perception::query::{ActivePerceptionMode, VisibilityGeometry}; use crate::perception::vision_cone::Facing; use crate::simulation::interaction::NearbyInteractionBuffer; use crate::simulation::monologue::MonologueBuffer; use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap}; +use crate::simulation::stance::Stance; use crate::simulation::time::SimulationTime; /// Compute visibility geometry using the active perception mode. @@ -50,7 +52,7 @@ pub fn compute_observer_snapshot( geometry: Res, registry: Res, mut observer_query: Query< - (&TilePosition, Option<&Facing>, &KnowledgeGraph, &mut NearbyInteractionBuffer, &mut MonologueBuffer), + (&TilePosition, Option<&Facing>, &KnowledgeGraph, &mut NearbyInteractionBuffer, &mut MonologueBuffer, Option<&Stance>, Option<&CharacterArchetype>), With, >, all_entities: Query<( @@ -61,7 +63,7 @@ pub fn compute_observer_snapshot( )>, mut buffer: ResMut, ) { - let Ok((_observer_pos, facing_opt, observer_kg, mut interaction_buffer, mut monologue_buffer)) = + let Ok((_observer_pos, facing_opt, observer_kg, mut interaction_buffer, mut monologue_buffer, stance_opt, archetype_opt)) = observer_query.single_mut() else { return; @@ -71,6 +73,8 @@ pub fn compute_observer_snapshot( .map(|f| f.0) .unwrap_or(FacingDirection::default()); + let archetype = archetype_opt.copied().unwrap_or_default(); + let (mut entities, visible_ids) = filter_visible_entities(&geometry, ®istry, observer_kg, &all_entities); @@ -90,9 +94,9 @@ pub fn compute_observer_snapshot( tick_rate: time.tick_rate, }; - // Take interactions and adjust POI verb priority (D-060) + // Take interactions and apply Phase 2 verb filter (D-057, #422) let mut nearby_interactions = interaction_buffer.take(); - apply_poi_verb_priority(&mut nearby_interactions, observer_kg); + apply_phase2_verb_filter(&mut nearby_interactions, observer_kg, archetype); tracing::trace!( "compute_observer_snapshot: tick={}, visible={}, remembered={}, tiles={}", @@ -109,6 +113,8 @@ pub fn compute_observer_snapshot( tick: time.tick, game_time, player_facing: facing, + player_stance: stance_opt.map(|s| s.0).unwrap_or_default(), + player_inventory: Vec::new(), entities, visible_tiles: geometry.visible_tiles.clone(), nearby_interactions, @@ -250,26 +256,110 @@ fn collect_remembered_entities( } } -/// Adjust verb priority for PersonOfInterest NPCs (D-060). -/// Moves ExamineNpc to priority 1 and Talk to priority 2 when the observer -/// knows the entity as POI. Called after interaction buffer is taken. -fn apply_poi_verb_priority( +/// Phase 2 verb filter: KG-gated observer-side verb processing (#422, D-057). +/// +/// Runs after Phase 1 (simulation-level verb computation) and applies: +/// 1. POI priority flips (D-060) — ExamineNpc above Talk for POI entities +/// 2. Confront injection — adds Confront verb for NPCs when KnowsDetails+ +/// 3. Contradiction marking — sets contradicted flag when entity knowledge is Contradicted +/// 4. Archetype label relabeling — smuggler/detective see different labels for same verb +/// +/// Phase boundary: Phase 1 (interaction.rs) determines verb availability from +/// ObjectType + proximity. Phase 2 (here) reads the observer's KnowledgeGraph +/// to filter, augment, and relabel. This separation keeps D-010 principle 1 +/// (info boundary) clean — simulation doesn't know what the observer knows. +fn apply_phase2_verb_filter( interactions: &mut [NearbyInteraction], observer_kg: &KnowledgeGraph, + archetype: CharacterArchetype, ) { for interaction in interactions.iter_mut() { let stable_id = StableId(interaction.entity_id); - let relationship = observer_kg.relationship_with(&stable_id); - if relationship == RelationshipState::PersonOfInterest { - for verb in &mut interaction.verbs { - match verb.kind { - VerbKind::ExamineNpc => verb.priority = 1, - VerbKind::Talk => verb.priority = 2, - _ => {} + let knowledge = observer_kg.entity_knowledge(&stable_id); + + // --- Contradiction marking --- + // If observer's knowledge of this entity is Contradicted, mark the + // interaction. Client renders a visual indicator (D-041). + if let Some(k) = knowledge { + if k.state == KnowledgeState::Contradicted { + interaction.contradicted = true; + } + } + + // --- NPC-specific Phase 2 --- + if interaction.entity_type == EntityKind::Npc { + let relationship = observer_kg.relationship_with(&stable_id); + + // POI priority flip (D-060): Observe first, Talk second + if relationship == RelationshipState::PersonOfInterest { + for verb in &mut interaction.verbs { + match verb.kind { + VerbKind::ExamineNpc => verb.priority = 1, + VerbKind::Talk => verb.priority = 2, + _ => {} + } + } + } + + // Confront injection: available when observer has KnowsDetails+ + // on this NPC and is at close range (distance ≤ 2). + if interaction.distance <= 2 { + let has_details = knowledge + .map(|k| k.confidence >= KnowledgeConfidence::KnowsDetails) + .unwrap_or(false); + + if has_details { + // Priority 3 = after Talk/ExamineNpc in normal case, + // after ExamineNpc/Talk in POI case. Always the escalation option. + interaction.verbs.push(VerbOption { + kind: VerbKind::Confront, + label: "Confront".into(), + priority: 3, + available: true, + }); } } - interaction.verbs.sort_by_key(|v| (v.priority, v.kind as u8)); } + + // --- Archetype label relabeling --- + // Phase 2 swaps verb labels based on character archetype. + // The VerbKind stays the same (same handler), only the display label changes. + // This implements D-057: "Character differentiation via Phase 2 observer + // filter, not separate verb systems." + for verb in &mut interaction.verbs { + if let Some(label) = archetype_verb_label(archetype, interaction.object_type, verb.kind) { + verb.label = label.into(); + } + } + + // Re-sort after priority changes and verb additions + interaction.verbs.sort_by_key(|v| (v.priority, v.kind as u8)); + } +} + +/// Archetype-specific verb label overrides (#422, D-057). +/// +/// Returns a replacement label for the given (archetype, object_type, verb_kind) +/// combination, or None to keep the Phase 1 default label. +/// +/// v0.1: Container verbs differ by archetype. Other object types keep defaults. +/// Add match arms here for future archetype-specific labels. +fn archetype_verb_label( + archetype: CharacterArchetype, + object_type: Option, + kind: VerbKind, +) -> Option<&'static str> { + match (archetype, object_type, kind) { + // Smuggler: Container verbs — physical manipulation vocabulary + (CharacterArchetype::Smuggler, Some(ObjectType::Container), VerbKind::Open) => Some("Move"), + (CharacterArchetype::Smuggler, Some(ObjectType::Container), VerbKind::Search) => Some("Stash"), + + // Detective: Container verbs — investigation vocabulary + (CharacterArchetype::Detective, Some(ObjectType::Container), VerbKind::Open) => Some("Scan"), + (CharacterArchetype::Detective, Some(ObjectType::Container), VerbKind::Search) => Some("Flag"), + + // All other combinations: keep Phase 1 default label + _ => None, } } diff --git a/server/src/perception/observer/tests.rs b/server/src/perception/observer/tests.rs index dd70ac97e..14ceb5967 100644 --- a/server/src/perception/observer/tests.rs +++ b/server/src/perception/observer/tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::knowledge::types::KnowledgeState; use crate::knowledge::{EntityRegistry, KnowledgeGraph}; use crate::perception::query::{ActivePerceptionMode, VisibilityGeometry}; use crate::perception::vision_cone::Facing; @@ -56,7 +57,7 @@ fn player_always_visible_in_snapshot() { let buffer = world.resource::(); let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist"); - assert_eq!(snapshot.version, 5); + assert_eq!(snapshot.version, 6); assert_eq!(snapshot.entities.len(), 1); assert!(matches!(snapshot.entities[0].kind, EntityKind::Player)); assert_eq!(snapshot.entities[0].observation, EntityVisibility::Visible); @@ -632,3 +633,54 @@ fn poi_interaction_gets_observe_first_priority() { assert_eq!(interaction.verbs[1].kind, VerbKind::Talk); assert_eq!(interaction.verbs[1].priority, 2); } + +// ----------------------------------------------------------------------- +// v6 field tests (Hoshe QA, Sprint 6 — #449) +// ----------------------------------------------------------------------- + +#[test] +fn snapshot_v6_fields_default_through_pipeline() { + // Until #417 (stance) and #424 (inventory) wire up the components, + // the observer system should produce Walk stance and empty inventory. + let mut world = setup_world(32, 32); + world.spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing::default(), + KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + )); + + run_observer_pipeline(&mut world); + + let buffer = world.resource::(); + let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist"); + + assert_eq!(snapshot.version, 6, "should be protocol v6"); + assert_eq!(snapshot.player_stance, MovementStance::Walk, "default stance is Walk"); + assert!(snapshot.player_inventory.is_empty(), "default inventory is empty"); +} + +#[test] +fn snapshot_v6_version_is_protocol_version() { + let mut world = setup_world(32, 32); + world.spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing::default(), + KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + )); + + run_observer_pipeline(&mut world); + + let buffer = world.resource::(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + assert_eq!( + snapshot.version, + crate::bridge::types::PROTOCOL_VERSION, + "snapshot version must match PROTOCOL_VERSION constant" + ); +} diff --git a/server/src/simulation/input.rs b/server/src/simulation/input.rs index be0670ac7..f4a5a1a4c 100644 --- a/server/src/simulation/input.rs +++ b/server/src/simulation/input.rs @@ -1,10 +1,11 @@ // Input processing system // Timestamped player input events for deterministic simulation (D-010 principle 4) -// PlayerInput: semantic actions (MoveNorth, Interact, UsePerceptionMode) +// PlayerInput: semantic actions (MoveNorth, Interact, UsePerceptionMode, ToggleStance) use crate::bridge::types::{PlayerAction, PlayerInput}; use crate::perception::vision_cone::{facing_from_delta, Facing}; use crate::simulation::movement::{MoveIntent, PlayerCharacter, TilePosition}; +use crate::simulation::stance::{PlayerMoveCooldown, Stance}; use crate::simulation::time::{SimulationTime, TickRate}; use bevy_ecs::prelude::*; use std::collections::VecDeque; @@ -55,25 +56,75 @@ impl InputQueue { } /// Drains InputQueue for the current tick, converts PlayerActions to ECS components. +/// Handles stance toggling (D-053) and movement cooldown based on current stance. +#[allow(clippy::type_complexity)] pub fn process_player_input( mut input_queue: ResMut, mut time: ResMut, mut commands: Commands, - player_query: Query<(Entity, &TilePosition), With>, + mut player_query: Query< + (Entity, &TilePosition, Option<&mut Stance>, Option<&mut PlayerMoveCooldown>), + With, + >, ) { let current_tick = time.tick; let inputs = input_queue.drain_for_tick(current_tick); + // Track whether any movement was attempted this tick (for cooldown tick advance) + let mut move_attempted = false; + for input in inputs { match input.action { - PlayerAction::MoveNorth => apply_move(&player_query, &mut commands, 0, -1), - PlayerAction::MoveSouth => apply_move(&player_query, &mut commands, 0, 1), - PlayerAction::MoveEast => apply_move(&player_query, &mut commands, 1, 0), - PlayerAction::MoveWest => apply_move(&player_query, &mut commands, -1, 0), - PlayerAction::MoveNortheast => apply_move(&player_query, &mut commands, 1, -1), - PlayerAction::MoveNorthwest => apply_move(&player_query, &mut commands, -1, -1), - PlayerAction::MoveSoutheast => apply_move(&player_query, &mut commands, 1, 1), - PlayerAction::MoveSouthwest => apply_move(&player_query, &mut commands, -1, 1), + PlayerAction::MoveNorth => { + move_attempted = true; + apply_move(&mut player_query, &mut commands, 0, -1); + } + PlayerAction::MoveSouth => { + move_attempted = true; + apply_move(&mut player_query, &mut commands, 0, 1); + } + PlayerAction::MoveEast => { + move_attempted = true; + apply_move(&mut player_query, &mut commands, 1, 0); + } + PlayerAction::MoveWest => { + move_attempted = true; + apply_move(&mut player_query, &mut commands, -1, 0); + } + PlayerAction::MoveNortheast => { + move_attempted = true; + apply_move(&mut player_query, &mut commands, 1, -1); + } + PlayerAction::MoveNorthwest => { + move_attempted = true; + apply_move(&mut player_query, &mut commands, -1, -1); + } + PlayerAction::MoveSoutheast => { + move_attempted = true; + apply_move(&mut player_query, &mut commands, 1, 1); + } + PlayerAction::MoveSouthwest => { + move_attempted = true; + apply_move(&mut player_query, &mut commands, -1, 1); + } + PlayerAction::ToggleStanceUp => { + if let Ok((_, _, Some(mut stance), _)) = player_query.single_mut() { + let new_stance = stance.0.step_up(); + if new_stance != stance.0 { + tracing::debug!("Stance up: {:?} -> {:?}", stance.0, new_stance); + stance.0 = new_stance; + } + } + } + PlayerAction::ToggleStanceDown => { + if let Ok((_, _, Some(mut stance), _)) = player_query.single_mut() { + let new_stance = stance.0.step_down(); + if new_stance != stance.0 { + tracing::debug!("Stance down: {:?} -> {:?}", stance.0, new_stance); + stance.0 = new_stance; + } + } + } PlayerAction::Pause => { time.tick_rate = TickRate::Paused; tracing::debug!("Simulation paused by player input"); @@ -98,17 +149,43 @@ pub fn process_player_input( } } } + + // If no movement was attempted this tick, still advance cooldown counter + if !move_attempted { + if let Ok((_, _, _, Some(mut cooldown))) = player_query.single_mut() { + cooldown.tick(); + } + } } +/// Apply a movement action with stance-based cooldown enforcement. +/// If the player has a Stance and PlayerMoveCooldown, movement is throttled +/// according to the stance's ticks_per_move. Without these components, +/// movement is unrestricted (backward compatibility). +#[allow(clippy::type_complexity)] fn apply_move( - player_query: &Query<(Entity, &TilePosition), With>, + player_query: &mut Query< + (Entity, &TilePosition, Option<&mut Stance>, Option<&mut PlayerMoveCooldown>), + With, + >, commands: &mut Commands, dx: i32, dy: i32, ) { - let (entity, pos) = player_query - .single() + let (entity, pos, stance_opt, cooldown_opt) = player_query + .single_mut() .expect("PlayerCharacter entity must exist when processing input"); + + let stance = stance_opt.map(|s| s.0).unwrap_or_default(); + + // Check cooldown if present + if let Some(mut cooldown) = cooldown_opt { + if !cooldown.try_move(stance) { + tracing::trace!("Movement throttled by stance {:?} cooldown", stance); + return; + } + } + commands.entity(entity).insert(MoveIntent { target: TilePosition::new(pos.x + dx, pos.y + dy, pos.z), }); @@ -261,4 +338,173 @@ mod tests { // No MoveIntent should be created (input for future tick) assert!(world.get::(player).is_none()); } + + use crate::bridge::types::MovementStance; + + #[test] + fn toggle_stance_up_changes_stance() { + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(InputQueue::default()); + world.insert_resource(SimulationTime::default()); + + world.spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + Stance::default(), + PlayerMoveCooldown::default(), + )); + + world.resource_mut::().push(PlayerInput { + tick: 0, + action: PlayerAction::ToggleStanceUp, + }); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_player_input); + schedule.run(&mut world); + + let mut query = world.query::<&Stance>(); + let stance = query.single(&world).unwrap(); + assert_eq!(stance.0, MovementStance::Sprint); + } + + #[test] + fn toggle_stance_down_changes_stance() { + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(InputQueue::default()); + world.insert_resource(SimulationTime::default()); + + world.spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + Stance::default(), + PlayerMoveCooldown::default(), + )); + + world.resource_mut::().push(PlayerInput { + tick: 0, + action: PlayerAction::ToggleStanceDown, + }); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_player_input); + schedule.run(&mut world); + + let mut query = world.query::<&Stance>(); + let stance = query.single(&world).unwrap(); + assert_eq!(stance.0, MovementStance::Careful); + } + + #[test] + fn walk_stance_throttles_movement_to_every_2_ticks() { + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(InputQueue::default()); + world.insert_resource(SimulationTime::default()); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + Stance::default(), // Walk + PlayerMoveCooldown::default(), + )) + .id(); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_player_input); + + // Tick 0: move north — should succeed (first move) + world.resource_mut::().push(PlayerInput { + tick: 0, + action: PlayerAction::MoveNorth, + }); + schedule.run(&mut world); + assert!(world.get::(player).is_some(), "first move should succeed"); + + // Remove MoveIntent (simulating validate_movement consuming it) + world.entity_mut(player).remove::(); + + // Tick 0 again: move north — should be throttled (cooldown) + world.resource_mut::().push(PlayerInput { + tick: 0, + action: PlayerAction::MoveNorth, + }); + schedule.run(&mut world); + assert!(world.get::(player).is_none(), "second move should be throttled"); + + // Tick 0 again: move north — should succeed (cooldown elapsed) + world.resource_mut::().push(PlayerInput { + tick: 0, + action: PlayerAction::MoveNorth, + }); + schedule.run(&mut world); + assert!(world.get::(player).is_some(), "third move should succeed after cooldown"); + } + + #[test] + fn sprint_stance_allows_every_tick() { + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(InputQueue::default()); + world.insert_resource(SimulationTime::default()); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + Stance(MovementStance::Sprint), + PlayerMoveCooldown::default(), + )) + .id(); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_player_input); + + // First move + world.resource_mut::().push(PlayerInput { + tick: 0, + action: PlayerAction::MoveNorth, + }); + schedule.run(&mut world); + assert!(world.get::(player).is_some()); + world.entity_mut(player).remove::(); + + // Second move — sprint allows every tick + world.resource_mut::().push(PlayerInput { + tick: 0, + action: PlayerAction::MoveNorth, + }); + schedule.run(&mut world); + assert!(world.get::(player).is_some(), "sprint should allow every tick"); + } + + #[test] + fn no_stance_component_moves_unrestricted() { + // Backward compatibility: entities without Stance/Cooldown move freely + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(InputQueue::default()); + world.insert_resource(SimulationTime::default()); + + let player = world + .spawn((PlayerCharacter, TilePosition::new(5, 5, 0))) + .id(); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_player_input); + + world.resource_mut::().push(PlayerInput { + tick: 0, + action: PlayerAction::MoveNorth, + }); + schedule.run(&mut world); + assert!(world.get::(player).is_some()); + world.entity_mut(player).remove::(); + + // Second move immediately — no throttle without components + world.resource_mut::().push(PlayerInput { + tick: 0, + action: PlayerAction::MoveNorth, + }); + schedule.run(&mut world); + assert!(world.get::(player).is_some()); + } } diff --git a/server/src/simulation/interaction.rs b/server/src/simulation/interaction.rs index cb5d2935c..de26c42ad 100644 --- a/server/src/simulation/interaction.rs +++ b/server/src/simulation/interaction.rs @@ -1,13 +1,18 @@ // Interaction system — proximity detection + multi-verb InteractionOptions // Implements #404: server-side verb computation for context-sensitive [E] key +// Extended by #421: ObjectType component + verb sets per type (D-057) // Spec: docs/design/interaction-verbs-v0.1.md // D-060: actions[] renamed to verbs[] across all surfaces // // Phase boundary: this system determines verb AVAILABILITY based on proximity // and entity type only. Verb PRIORITY adjustment (e.g. POI flipping Observe // above Talk) is a perception concern handled by the observer system. +// Phase 2 filtering (KG-gated verbs) handled by #422. use bevy_ecs::prelude::*; + +// Re-export ObjectType for backward compatibility — definition moved to bridge::types (#422). +pub use crate::bridge::types::ObjectType; use crate::bridge::types::{EntityKind, NearbyInteraction, VerbKind, VerbOption}; use crate::knowledge::EntityRegistry; use crate::npc::Npc; @@ -22,12 +27,70 @@ pub(crate) const MID_RANGE: u32 = 5; #[derive(Component, Debug, Clone)] pub struct Interactable; +// ObjectType enum is defined in bridge::types (wire type for NearbyInteraction). +// VerbDef and verb_set() impl remain here in the simulation layer. + +/// A verb definition in an ObjectType's Phase 1 verb set. +#[derive(Debug, Clone, Copy)] +pub struct VerbDef { + pub kind: VerbKind, + pub label: &'static str, + pub priority: u8, + /// Whether this verb requires close range (true) or works at mid range (false) + pub close_only: bool, +} + +impl ObjectType { + /// Phase 1 verb set: maximum possible verbs for this object type (D-057). + /// No KG dependency — this is simulation-level verb computation. + /// Phase 2 (#422) will filter these by the observer's knowledge. + /// + /// Each verb has a default priority and range requirement: + /// - Primary verbs (priority 1-2): the main actions for this type + /// - Observe (priority 3): always available, works at mid range + pub fn verb_set(&self) -> &'static [VerbDef] { + match self { + Self::Readable => &[ + VerbDef { kind: VerbKind::Read, label: "Read", priority: 1, close_only: true }, + VerbDef { kind: VerbKind::Observe, label: "Observe", priority: 2, close_only: false }, + ], + Self::Container => &[ + VerbDef { kind: VerbKind::Open, label: "Open", priority: 1, close_only: true }, + VerbDef { kind: VerbKind::Search, label: "Search", priority: 2, close_only: true }, + VerbDef { kind: VerbKind::Observe, label: "Observe", priority: 3, close_only: false }, + ], + Self::Terminal => &[ + VerbDef { kind: VerbKind::Use, label: "Use", priority: 1, close_only: true }, + VerbDef { kind: VerbKind::Observe, label: "Observe", priority: 2, close_only: false }, + ], + Self::Door => &[ + VerbDef { kind: VerbKind::Open, label: "Open", priority: 1, close_only: true }, + VerbDef { kind: VerbKind::Close, label: "Close", priority: 2, close_only: true }, + VerbDef { kind: VerbKind::Observe, label: "Observe", priority: 3, close_only: false }, + ], + Self::Pickup => &[ + VerbDef { kind: VerbKind::Take, label: "Take", priority: 1, close_only: true }, + VerbDef { kind: VerbKind::Observe, label: "Observe", priority: 2, close_only: false }, + ], + Self::Furniture => &[ + VerbDef { kind: VerbKind::Sit, label: "Sit", priority: 1, close_only: true }, + VerbDef { kind: VerbKind::Observe, label: "Observe", priority: 2, close_only: false }, + ], + } + } +} + /// Compute nearby interactions for the player character. /// For each entity in range, determines available verbs sorted by priority. /// Results are written to the NearbyInteractionBuffer for inclusion in ObserverSnapshot. /// +/// Verb source priority: +/// 1. NPCs: Talk + ExamineNpc (hardcoded, NPC-specific) +/// 2. ObjectType: type-specific verb set from verb_set() (D-057 Phase 1) +/// 3. Untyped objects: ExamineObject fallback (legacy/simple objects) +/// /// NOTE: Determines verb availability and default priority only. Relationship-based -/// priority adjustment (e.g. POI → Observe first) is applied by the observer +/// priority adjustment (e.g. POI -> Observe first) is applied by the observer /// system after taking the buffer. This keeps the simulation phase free of /// knowledge graph dependencies (D-010 phase boundary). #[allow(clippy::type_complexity)] @@ -38,7 +101,7 @@ pub fn compute_nearby_interactions( >, registry: Res, interactables: Query< - (Entity, &TilePosition, Option<&Npc>), + (Entity, &TilePosition, Option<&Npc>, Option<&ObjectType>), (With, Without), >, ) { @@ -47,7 +110,7 @@ pub fn compute_nearby_interactions( }; buffer.interactions.clear(); - for (entity, pos, is_npc) in interactables.iter() { + for (entity, pos, is_npc, object_type) in interactables.iter() { let Some(distance) = player_pos.manhattan_distance(pos) else { continue; // Different z-level }; @@ -65,44 +128,55 @@ pub fn compute_nearby_interactions( let is_close = distance <= CLOSE_RANGE; let mut verbs = Vec::new(); - match entity_type { - EntityKind::Npc => { - if is_close { - // Default priority: Talk first, Observe second. - // Observer adjusts priority for POI entities. - verbs.push(VerbOption { - kind: VerbKind::Talk, - label: "Talk".into(), - priority: 1, - available: true, - }); - verbs.push(VerbOption { - kind: VerbKind::ExamineNpc, - label: "Observe".into(), - priority: 2, - available: true, - }); - } else { - // Mid range: only Examine NPC (Talk requires close range) - verbs.push(VerbOption { - kind: VerbKind::ExamineNpc, - label: "Observe".into(), - priority: 1, - available: true, - }); - } + if is_npc.is_some() { + // NPC verb logic — unchanged from #404 + if is_close { + // Default priority: Talk first, Observe second. + // Observer adjusts priority for POI entities. + verbs.push(VerbOption { + kind: VerbKind::Talk, + label: "Talk".into(), + priority: 1, + available: true, + }); + verbs.push(VerbOption { + kind: VerbKind::ExamineNpc, + label: "Observe".into(), + priority: 2, + available: true, + }); + } else { + // Mid range: only Examine NPC (Talk requires close range) + verbs.push(VerbOption { + kind: VerbKind::ExamineNpc, + label: "Observe".into(), + priority: 1, + available: true, + }); } - EntityKind::Object | EntityKind::Terrain => { - if is_close { - verbs.push(VerbOption { - kind: VerbKind::ExamineObject, - label: "Examine".into(), - priority: 1, - available: true, - }); + } else if let Some(obj_type) = object_type { + // D-057 Phase 1: ObjectType-driven verb set (#421) + for def in obj_type.verb_set() { + if def.close_only && !is_close { + continue; // Skip close-only verbs when at mid range } + verbs.push(VerbOption { + kind: def.kind, + label: def.label.into(), + priority: def.priority, + available: true, + }); + } + } else { + // Legacy fallback: untyped object (no ObjectType component) + if is_close { + verbs.push(VerbOption { + kind: VerbKind::ExamineObject, + label: "Examine".into(), + priority: 1, + available: true, + }); } - EntityKind::Player => {} // No self-interaction } if verbs.is_empty() { @@ -128,6 +202,8 @@ pub fn compute_nearby_interactions( entity_type, distance, verbs, + object_type: object_type.copied(), + contradicted: false, // Phase 2 sets this from KG }); } @@ -185,6 +261,10 @@ mod tests { query.single(world).unwrap() } + // ----------------------------------------------------------------------- + // NPC verb tests (unchanged from #404) + // ----------------------------------------------------------------------- + #[test] fn npc_in_close_range_gets_talk_and_observe() { let mut world = setup_world(); @@ -235,8 +315,186 @@ mod tests { assert!(buffer.interactions.is_empty()); } + // ----------------------------------------------------------------------- + // ObjectType verb tests (#421) + // ----------------------------------------------------------------------- + #[test] - fn object_in_close_range_gets_examine() { + fn readable_close_range_gets_read_and_observe() { + let mut world = setup_world(); + spawn_player(&mut world, 5, 5); + world.spawn(( + TilePosition::new(5, 6, 0), + Interactable, + ObjectType::Readable, + )); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_nearby_interactions); + schedule.run(&mut world); + + let buffer = read_buffer(&mut world); + assert_eq!(buffer.interactions.len(), 1); + assert_eq!(buffer.interactions[0].verbs.len(), 2); + assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::Read); + assert_eq!(buffer.interactions[0].verbs[0].label, "Read"); + assert_eq!(buffer.interactions[0].verbs[1].kind, VerbKind::Observe); + assert_eq!(buffer.interactions[0].verbs[1].label, "Observe"); + } + + #[test] + fn readable_mid_range_gets_observe_only() { + let mut world = setup_world(); + spawn_player(&mut world, 5, 5); + world.spawn(( + TilePosition::new(5, 9, 0), + Interactable, + ObjectType::Readable, + )); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_nearby_interactions); + schedule.run(&mut world); + + let buffer = read_buffer(&mut world); + assert_eq!(buffer.interactions.len(), 1); + assert_eq!(buffer.interactions[0].verbs.len(), 1); + assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::Observe); + } + + #[test] + fn container_close_range_gets_open_search_observe() { + let mut world = setup_world(); + spawn_player(&mut world, 5, 5); + world.spawn(( + TilePosition::new(5, 6, 0), + Interactable, + ObjectType::Container, + )); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_nearby_interactions); + schedule.run(&mut world); + + let buffer = read_buffer(&mut world); + assert_eq!(buffer.interactions.len(), 1); + assert_eq!(buffer.interactions[0].verbs.len(), 3); + assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::Open); + assert_eq!(buffer.interactions[0].verbs[1].kind, VerbKind::Search); + assert_eq!(buffer.interactions[0].verbs[2].kind, VerbKind::Observe); + } + + #[test] + fn terminal_close_range_gets_use_and_observe() { + let mut world = setup_world(); + spawn_player(&mut world, 5, 5); + world.spawn(( + TilePosition::new(5, 6, 0), + Interactable, + ObjectType::Terminal, + )); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_nearby_interactions); + schedule.run(&mut world); + + let buffer = read_buffer(&mut world); + assert_eq!(buffer.interactions.len(), 1); + assert_eq!(buffer.interactions[0].verbs.len(), 2); + assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::Use); + assert_eq!(buffer.interactions[0].verbs[1].kind, VerbKind::Observe); + } + + #[test] + fn door_close_range_gets_open_close_observe() { + let mut world = setup_world(); + spawn_player(&mut world, 5, 5); + world.spawn(( + TilePosition::new(5, 6, 0), + Interactable, + ObjectType::Door, + )); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_nearby_interactions); + schedule.run(&mut world); + + let buffer = read_buffer(&mut world); + assert_eq!(buffer.interactions.len(), 1); + assert_eq!(buffer.interactions[0].verbs.len(), 3); + assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::Open); + assert_eq!(buffer.interactions[0].verbs[1].kind, VerbKind::Close); + assert_eq!(buffer.interactions[0].verbs[2].kind, VerbKind::Observe); + } + + #[test] + fn pickup_close_range_gets_take_and_observe() { + let mut world = setup_world(); + spawn_player(&mut world, 5, 5); + world.spawn(( + TilePosition::new(5, 6, 0), + Interactable, + ObjectType::Pickup, + )); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_nearby_interactions); + schedule.run(&mut world); + + let buffer = read_buffer(&mut world); + assert_eq!(buffer.interactions.len(), 1); + assert_eq!(buffer.interactions[0].verbs.len(), 2); + assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::Take); + assert_eq!(buffer.interactions[0].verbs[1].kind, VerbKind::Observe); + } + + #[test] + fn furniture_close_range_gets_sit_and_observe() { + let mut world = setup_world(); + spawn_player(&mut world, 5, 5); + world.spawn(( + TilePosition::new(5, 6, 0), + Interactable, + ObjectType::Furniture, + )); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_nearby_interactions); + schedule.run(&mut world); + + let buffer = read_buffer(&mut world); + assert_eq!(buffer.interactions.len(), 1); + assert_eq!(buffer.interactions[0].verbs.len(), 2); + assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::Sit); + assert_eq!(buffer.interactions[0].verbs[1].kind, VerbKind::Observe); + } + + #[test] + fn container_mid_range_gets_observe_only() { + let mut world = setup_world(); + spawn_player(&mut world, 5, 5); + world.spawn(( + TilePosition::new(5, 9, 0), + Interactable, + ObjectType::Container, + )); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_nearby_interactions); + schedule.run(&mut world); + + let buffer = read_buffer(&mut world); + assert_eq!(buffer.interactions.len(), 1); + assert_eq!(buffer.interactions[0].verbs.len(), 1); + assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::Observe); + } + + // ----------------------------------------------------------------------- + // Legacy untyped object fallback + // ----------------------------------------------------------------------- + + #[test] + fn untyped_object_in_close_range_gets_examine() { let mut world = setup_world(); spawn_player(&mut world, 5, 5); world.spawn((TilePosition::new(5, 6, 0), Interactable)); @@ -251,6 +509,25 @@ mod tests { assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::ExamineObject); } + #[test] + fn untyped_object_mid_range_no_verbs() { + let mut world = setup_world(); + spawn_player(&mut world, 5, 5); + world.spawn((TilePosition::new(5, 9, 0), Interactable)); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_nearby_interactions); + schedule.run(&mut world); + + let buffer = read_buffer(&mut world); + // Untyped objects have no mid-range verbs + assert!(buffer.interactions.is_empty()); + } + + // ----------------------------------------------------------------------- + // General interaction tests + // ----------------------------------------------------------------------- + #[test] fn different_z_level_no_interactions() { let mut world = setup_world(); @@ -310,4 +587,235 @@ mod tests { assert_eq!(buffer.interactions.len(), 2); assert_eq!(buffer.interactions[0].distance, buffer.interactions[1].distance); } + + #[test] + fn mixed_npcs_and_objects_all_detected() { + let mut world = setup_world(); + spawn_player(&mut world, 5, 5); + // NPC nearby + world.spawn((Npc, TilePosition::new(5, 6, 0), Interactable)); + // Typed object nearby + world.spawn(( + TilePosition::new(6, 5, 0), + Interactable, + ObjectType::Terminal, + )); + // Untyped object nearby + world.spawn((TilePosition::new(4, 5, 0), Interactable)); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_nearby_interactions); + schedule.run(&mut world); + + let buffer = read_buffer(&mut world); + assert_eq!(buffer.interactions.len(), 3); + + // All should be at distance 1 + for interaction in &buffer.interactions { + assert_eq!(interaction.distance, 1); + } + } + + // ----------------------------------------------------------------------- + // ObjectType::verb_set() unit tests + // ----------------------------------------------------------------------- + + #[test] + fn verb_set_readable_has_read_and_observe() { + let verbs = ObjectType::Readable.verb_set(); + assert_eq!(verbs.len(), 2); + assert_eq!(verbs[0].kind, VerbKind::Read); + assert!(verbs[0].close_only); + assert_eq!(verbs[1].kind, VerbKind::Observe); + assert!(!verbs[1].close_only); + } + + #[test] + fn verb_set_container_has_three_verbs() { + let verbs = ObjectType::Container.verb_set(); + assert_eq!(verbs.len(), 3); + assert_eq!(verbs[0].kind, VerbKind::Open); + assert_eq!(verbs[1].kind, VerbKind::Search); + assert_eq!(verbs[2].kind, VerbKind::Observe); + } + + #[test] + fn all_object_types_have_observe() { + let types = [ + ObjectType::Readable, + ObjectType::Container, + ObjectType::Terminal, + ObjectType::Door, + ObjectType::Pickup, + ObjectType::Furniture, + ]; + + for obj_type in types { + let verbs = obj_type.verb_set(); + let has_observe = verbs.iter().any(|v| v.kind == VerbKind::Observe); + assert!(has_observe, "{:?} should have Observe verb", obj_type); + } + } + + #[test] + fn all_object_types_observe_is_mid_range() { + let types = [ + ObjectType::Readable, + ObjectType::Container, + ObjectType::Terminal, + ObjectType::Door, + ObjectType::Pickup, + ObjectType::Furniture, + ]; + + for obj_type in types { + let verbs = obj_type.verb_set(); + let observe = verbs.iter().find(|v| v.kind == VerbKind::Observe).unwrap(); + assert!( + !observe.close_only, + "{:?}'s Observe should be available at mid range", + obj_type + ); + } + } + + // ----------------------------------------------------------------------- + // Additional QA coverage (Hoshe, Sprint 6) + // ----------------------------------------------------------------------- + + /// All ObjectType variants at mid range should produce Observe only. + /// Covers gap: only Readable + Container had explicit mid-range tests. + #[test] + fn all_object_types_mid_range_observe_only() { + let types = [ + ObjectType::Readable, + ObjectType::Container, + ObjectType::Terminal, + ObjectType::Door, + ObjectType::Pickup, + ObjectType::Furniture, + ]; + + for obj_type in types { + let mut world = setup_world(); + spawn_player(&mut world, 5, 5); + // Distance 4 = mid range (> CLOSE_RANGE=2, <= MID_RANGE=5) + world.spawn(( + TilePosition::new(5, 9, 0), + Interactable, + obj_type, + )); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_nearby_interactions); + schedule.run(&mut world); + + let buffer = read_buffer(&mut world); + assert_eq!( + buffer.interactions.len(), 1, + "{:?} at mid range should produce 1 interaction", obj_type + ); + assert_eq!( + buffer.interactions[0].verbs.len(), 1, + "{:?} at mid range should have exactly 1 verb (Observe)", obj_type + ); + assert_eq!( + buffer.interactions[0].verbs[0].kind, VerbKind::Observe, + "{:?} at mid range verb should be Observe", obj_type + ); + } + } + + /// ObjectType entity beyond MID_RANGE produces no interactions. + #[test] + fn object_type_out_of_range_no_interactions() { + let mut world = setup_world(); + spawn_player(&mut world, 5, 5); + // Distance 6 = beyond MID_RANGE=5 + world.spawn(( + TilePosition::new(5, 11, 0), + Interactable, + ObjectType::Container, + )); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_nearby_interactions); + schedule.run(&mut world); + + let buffer = read_buffer(&mut world); + assert!(buffer.interactions.is_empty()); + } + + /// Entity with both Npc and ObjectType: NPC verbs take priority. + /// ObjectType verbs should NOT appear — NPCs have their own verb logic. + #[test] + fn npc_with_object_type_uses_npc_verbs() { + let mut world = setup_world(); + spawn_player(&mut world, 5, 5); + world.spawn(( + Npc, + TilePosition::new(5, 6, 0), + Interactable, + ObjectType::Terminal, // Should be ignored + )); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_nearby_interactions); + schedule.run(&mut world); + + let buffer = read_buffer(&mut world); + assert_eq!(buffer.interactions.len(), 1); + // Should get NPC verbs (Talk + ExamineNpc), NOT Terminal verbs (Use + Observe) + assert_eq!(buffer.interactions[0].verbs.len(), 2); + assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::Talk); + assert_eq!(buffer.interactions[0].verbs[1].kind, VerbKind::ExamineNpc); + } + + /// All ObjectType primary verbs are close_only (except Observe). + /// Ensures no accidental mid-range primary actions. + #[test] + fn all_primary_verbs_are_close_only() { + let types = [ + ObjectType::Readable, + ObjectType::Container, + ObjectType::Terminal, + ObjectType::Door, + ObjectType::Pickup, + ObjectType::Furniture, + ]; + + for obj_type in types { + for def in obj_type.verb_set() { + if def.kind == VerbKind::Observe { + assert!(!def.close_only, "{:?} Observe should be mid-range", obj_type); + } else { + assert!( + def.close_only, + "{:?} {:?} should be close-only", obj_type, def.kind + ); + } + } + } + } + + /// D-057: max 4 verbs per entity. Verify no ObjectType exceeds this. + #[test] + fn verb_set_max_four_verbs() { + let types = [ + ObjectType::Readable, + ObjectType::Container, + ObjectType::Terminal, + ObjectType::Door, + ObjectType::Pickup, + ObjectType::Furniture, + ]; + + for obj_type in types { + let verbs = obj_type.verb_set(); + assert!( + verbs.len() <= 4, + "{:?} has {} verbs, D-057 max is 4", obj_type, verbs.len() + ); + } + } } diff --git a/server/src/simulation/mod.rs b/server/src/simulation/mod.rs index c78854d78..b16cebe25 100644 --- a/server/src/simulation/mod.rs +++ b/server/src/simulation/mod.rs @@ -11,6 +11,7 @@ pub mod movement; pub mod path_follow; pub mod pathfinding; pub mod rng; +pub mod stance; pub mod tier; pub mod time; diff --git a/server/src/simulation/movement.rs b/server/src/simulation/movement.rs index d8943774b..368121cbf 100644 --- a/server/src/simulation/movement.rs +++ b/server/src/simulation/movement.rs @@ -1,5 +1,6 @@ // Tile-based movement and collision system // Implements Sprint 1 ticket #236: walkability map and movement validation +// Extended by #420: TilePresence posture layers for same-tile occupancy (D-054) // Chunk-based storage per D-012: supports chunk load/unload for future borderless generation // Y-down convention: North = y-1, South = y+1 @@ -14,6 +15,27 @@ pub const CHUNK_SIZE: i32 = 32; #[derive(Component, Debug)] pub struct PlayerCharacter; +/// Posture layer for same-tile occupancy (D-054, #420). +/// +/// Multiple entities can share a tile if they occupy different posture layers. +/// Two entities in the same layer on the same tile is a collision. +/// +/// Examples: a Standing character can walk past a Seated NPC at a console, +/// a Fixture (terminal) shares a tile with someone Seated at it. +#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)] +pub enum TilePresence { + /// Upright position — walking, standing, sprinting. Default for all entities. + #[default] + Standing, + /// Low position — crouching or prone on the ground. + Prone, + /// Seated at furniture, console, or vehicle. + Seated, + /// Immovable world fixture — terminals, furniture, consoles. + /// Occupies its layer permanently. + Fixture, +} + /// Tile position component for grid-based movement. /// Discrete integer coordinates used in simulation; converted to f32 /// at the bridge boundary for VisibleEntity wire format. @@ -226,48 +248,59 @@ pub struct MoveIntent { } /// System to validate and execute movement intents. -/// Checks walkability map AND entity-entity collision before allowing moves. -/// Processes all intents in a single pass: first collect occupied tiles from +/// Checks walkability map AND layer-based entity collision before allowing moves. +/// +/// Same-tile occupancy (D-054, #420): multiple entities can share a tile if they +/// occupy different posture layers (TilePresence). Two entities in the same layer +/// on the same tile is a collision. Entities without TilePresence default to Standing. +/// +/// Processes all intents in a single pass: first collect occupied layer slots from /// entities without intents, then resolve movers in order — first valid claim -/// to a tile wins. +/// to a layer slot wins. /// Always removes MoveIntent component after processing. pub fn validate_movement( mut commands: Commands, walkability: Option>, - mut movers: Query<(Entity, &MoveIntent, &mut TilePosition)>, - stationary: Query<(Entity, &TilePosition), Without>, + mut movers: Query<(Entity, &MoveIntent, &mut TilePosition, Option<&TilePresence>)>, + stationary: Query<(Entity, &TilePosition, Option<&TilePresence>), Without>, ) { let Some(map) = walkability else { tracing::warn!("No WalkabilityMap loaded — rejecting all move intents"); - for (entity, _, _) in movers.iter() { + for (entity, _, _, _) in movers.iter() { commands.entity(entity).remove::(); } return; }; - // Collect tiles occupied by stationary entities (no MoveIntent) - let mut occupied: HashMap = HashMap::new(); - for (entity, pos) in stationary.iter() { - occupied.insert(*pos, entity); + // Collect layer slots occupied by stationary entities (no MoveIntent). + // Key: (position, layer) — two entities can share a tile if different layers. + let mut occupied: HashMap<(TilePosition, TilePresence), Entity> = HashMap::new(); + for (entity, pos, presence) in stationary.iter() { + let layer = presence.copied().unwrap_or_default(); + occupied.insert((*pos, layer), entity); } - for (entity, intent, mut position) in movers.iter_mut() { + for (entity, intent, mut position, presence) in movers.iter_mut() { let target = &intent.target; + let layer = presence.copied().unwrap_or_default(); + let slot = (*target, layer); + if !map.can_move_to(target) { tracing::trace!("Entity {:?} blocked by terrain at {:?}", entity, target); - } else if occupied.contains_key(target) { - tracing::trace!("Entity {:?} blocked by entity at {:?}", entity, target); + } else if occupied.contains_key(&slot) { + tracing::trace!( + "Entity {:?} blocked by entity at {:?} (layer {:?})", + entity, target, layer + ); } else { tracing::trace!( - "Entity {:?} moving from {:?} to {:?}", - entity, - *position, - target + "Entity {:?} moving from {:?} to {:?} (layer {:?})", + entity, *position, target, layer ); - // Free old tile, claim new tile - occupied.remove(&*position); + // Free old layer slot, claim new one + occupied.remove(&(*position, layer)); *position = *target; - occupied.insert(*target, entity); + occupied.insert(slot, entity); } commands.entity(entity).remove::(); } @@ -570,4 +603,258 @@ mod tests { ); assert!(world.get::(entity).is_none()); } + + // ----------------------------------------------------------------------- + // TilePresence / same-tile occupancy tests (D-054, #420) + // ----------------------------------------------------------------------- + + #[test] + fn tile_presence_default_is_standing() { + assert_eq!(TilePresence::default(), TilePresence::Standing); + } + + #[test] + fn same_layer_same_tile_blocks_movement() { + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(WalkabilityMap::new(10, 10, 1)); + + // Stationary entity at target, Standing layer + world.spawn((TilePosition::new(5, 4, 0), TilePresence::Standing)); + + // Mover also Standing — should be blocked + let mover = world + .spawn(( + TilePosition::new(5, 5, 0), + TilePresence::Standing, + MoveIntent { + target: TilePosition::new(5, 4, 0), + }, + )) + .id(); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(validate_movement); + schedule.run(&mut world); + + assert_eq!( + *world.get::(mover).unwrap(), + TilePosition::new(5, 5, 0), + "same-layer collision should block movement" + ); + } + + #[test] + fn different_layer_same_tile_allows_movement() { + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(WalkabilityMap::new(10, 10, 1)); + + // Fixture at target tile + world.spawn((TilePosition::new(5, 4, 0), TilePresence::Fixture)); + + // Standing mover — different layer, should pass + let mover = world + .spawn(( + TilePosition::new(5, 5, 0), + TilePresence::Standing, + MoveIntent { + target: TilePosition::new(5, 4, 0), + }, + )) + .id(); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(validate_movement); + schedule.run(&mut world); + + assert_eq!( + *world.get::(mover).unwrap(), + TilePosition::new(5, 4, 0), + "different layers should share a tile" + ); + } + + #[test] + fn seated_and_fixture_share_tile() { + // Common case: NPC seated at a terminal (Fixture) + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(WalkabilityMap::new(10, 10, 1)); + + // Terminal fixture at tile + world.spawn((TilePosition::new(5, 4, 0), TilePresence::Fixture)); + + // Seated NPC moves to same tile + let npc = world + .spawn(( + TilePosition::new(5, 5, 0), + TilePresence::Seated, + MoveIntent { + target: TilePosition::new(5, 4, 0), + }, + )) + .id(); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(validate_movement); + schedule.run(&mut world); + + assert_eq!( + *world.get::(npc).unwrap(), + TilePosition::new(5, 4, 0), + "Seated NPC should share tile with Fixture" + ); + } + + #[test] + fn prone_and_standing_share_tile() { + // Eavesdrop scenario: prone entity next to standing entity + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(WalkabilityMap::new(10, 10, 1)); + + // Standing NPC at tile + world.spawn((TilePosition::new(5, 4, 0), TilePresence::Standing)); + + // Prone entity moves in — different layer + let prone = world + .spawn(( + TilePosition::new(5, 5, 0), + TilePresence::Prone, + MoveIntent { + target: TilePosition::new(5, 4, 0), + }, + )) + .id(); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(validate_movement); + schedule.run(&mut world); + + assert_eq!( + *world.get::(prone).unwrap(), + TilePosition::new(5, 4, 0), + "Prone should share tile with Standing" + ); + } + + #[test] + fn entity_without_tile_presence_defaults_to_standing() { + // Backwards compat: entities spawned without TilePresence should + // still collide with Standing entities (default layer). + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(WalkabilityMap::new(10, 10, 1)); + + // Stationary entity WITHOUT TilePresence component + world.spawn(TilePosition::new(5, 4, 0)); + + // Mover also WITHOUT TilePresence — both default to Standing + let mover = world + .spawn(( + TilePosition::new(5, 5, 0), + MoveIntent { + target: TilePosition::new(5, 4, 0), + }, + )) + .id(); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(validate_movement); + schedule.run(&mut world); + + assert_eq!( + *world.get::(mover).unwrap(), + TilePosition::new(5, 5, 0), + "entities without TilePresence should default to Standing and collide" + ); + } + + #[test] + fn entity_without_presence_blocked_by_standing() { + // Entity without TilePresence blocked by explicit Standing entity + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(WalkabilityMap::new(10, 10, 1)); + + // Stationary with explicit Standing + world.spawn((TilePosition::new(5, 4, 0), TilePresence::Standing)); + + // Mover without TilePresence (defaults to Standing) + let mover = world + .spawn(( + TilePosition::new(5, 5, 0), + MoveIntent { + target: TilePosition::new(5, 4, 0), + }, + )) + .id(); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(validate_movement); + schedule.run(&mut world); + + assert_eq!( + *world.get::(mover).unwrap(), + TilePosition::new(5, 5, 0), + "no-presence entity should collide with Standing" + ); + } + + #[test] + fn three_layers_on_same_tile() { + // Maximum plausible scenario: Standing + Seated + Fixture on one tile + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(WalkabilityMap::new(10, 10, 1)); + + // Fixture already at tile + world.spawn((TilePosition::new(5, 4, 0), TilePresence::Fixture)); + // Seated already at tile + world.spawn((TilePosition::new(5, 4, 0), TilePresence::Seated)); + + // Standing mover enters — third layer + let mover = world + .spawn(( + TilePosition::new(5, 5, 0), + TilePresence::Standing, + MoveIntent { + target: TilePosition::new(5, 4, 0), + }, + )) + .id(); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(validate_movement); + schedule.run(&mut world); + + assert_eq!( + *world.get::(mover).unwrap(), + TilePosition::new(5, 4, 0), + "three different layers should coexist on one tile" + ); + } + + #[test] + fn two_fixtures_same_tile_blocked() { + // Edge case: two fixtures can't stack on the same tile + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(WalkabilityMap::new(10, 10, 1)); + + world.spawn((TilePosition::new(5, 4, 0), TilePresence::Fixture)); + + let mover = world + .spawn(( + TilePosition::new(5, 5, 0), + TilePresence::Fixture, + MoveIntent { + target: TilePosition::new(5, 4, 0), + }, + )) + .id(); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(validate_movement); + schedule.run(&mut world); + + assert_eq!( + *world.get::(mover).unwrap(), + TilePosition::new(5, 5, 0), + "two Fixtures on same tile should collide" + ); + } } diff --git a/server/src/simulation/stance.rs b/server/src/simulation/stance.rs new file mode 100644 index 000000000..79994cec1 --- /dev/null +++ b/server/src/simulation/stance.rs @@ -0,0 +1,162 @@ +// Stance system — D-053 movement stances with tick-based speed +// +// MovementStance (Sprint/Walk/Careful/Crouch) affects: +// - Movement speed (ticks per step): Sprint=1, Walk=2, Careful=3, Crouch=4 +// - Monologue rate: Sprint=40%, Walk=100%, Careful=150%, Crouch=100% +// - Interaction buffer: Sprint suppresses (D-055, wired in #419) +// +// The stance ladder is toggled via PlayerAction::ToggleStanceUp/Down. +// This module provides the ECS component and movement cooldown. + +use bevy_ecs::prelude::*; + +use crate::bridge::types::MovementStance; + +/// ECS component tracking an entity's current movement stance. +/// Attached to PlayerCharacter (and potentially NPCs in future). +#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)] +pub struct Stance(pub MovementStance); + +impl Default for Stance { + fn default() -> Self { + Stance(MovementStance::Walk) + } +} + +/// Tracks ticks since last movement step for stance-based speed enforcement. +/// The player's movement is throttled server-side based on their current stance. +#[derive(Component, Debug, Clone)] +pub struct PlayerMoveCooldown { + pub ticks_since_last_move: u32, +} + +impl Default for PlayerMoveCooldown { + fn default() -> Self { + Self { + // Start at max so first move is immediate + ticks_since_last_move: u32::MAX, + } + } +} + +impl PlayerMoveCooldown { + /// Check if the player can move this tick given their stance. + /// Returns true and resets the counter if movement is allowed. + pub fn try_move(&mut self, stance: MovementStance) -> bool { + self.ticks_since_last_move = self.ticks_since_last_move.saturating_add(1); + if self.ticks_since_last_move >= stance.ticks_per_move() { + self.ticks_since_last_move = 0; + true + } else { + false + } + } + + /// Advance the cooldown counter without attempting a move. + /// Call this each tick when no move input is present to keep the counter progressing. + pub fn tick(&mut self) { + self.ticks_since_last_move = self.ticks_since_last_move.saturating_add(1); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stance_default_is_walk() { + assert_eq!(Stance::default().0, MovementStance::Walk); + } + + #[test] + fn stance_ladder_step_up() { + assert_eq!(MovementStance::Crouch.step_up(), MovementStance::Careful); + assert_eq!(MovementStance::Careful.step_up(), MovementStance::Walk); + assert_eq!(MovementStance::Walk.step_up(), MovementStance::Sprint); + assert_eq!(MovementStance::Sprint.step_up(), MovementStance::Sprint); + } + + #[test] + fn stance_ladder_step_down() { + assert_eq!(MovementStance::Sprint.step_down(), MovementStance::Walk); + assert_eq!(MovementStance::Walk.step_down(), MovementStance::Careful); + assert_eq!(MovementStance::Careful.step_down(), MovementStance::Crouch); + assert_eq!(MovementStance::Crouch.step_down(), MovementStance::Crouch); + } + + #[test] + fn ticks_per_move_values() { + assert_eq!(MovementStance::Sprint.ticks_per_move(), 1); + assert_eq!(MovementStance::Walk.ticks_per_move(), 2); + assert_eq!(MovementStance::Careful.ticks_per_move(), 3); + assert_eq!(MovementStance::Crouch.ticks_per_move(), 4); + } + + #[test] + fn monologue_rate_values() { + assert_eq!(MovementStance::Sprint.monologue_rate_percent(), 40); + assert_eq!(MovementStance::Walk.monologue_rate_percent(), 100); + assert_eq!(MovementStance::Careful.monologue_rate_percent(), 150); + assert_eq!(MovementStance::Crouch.monologue_rate_percent(), 100); + } + + #[test] + fn cooldown_first_move_immediate() { + let mut cd = PlayerMoveCooldown::default(); + // First move should always succeed (counter starts at MAX) + assert!(cd.try_move(MovementStance::Walk)); + } + + #[test] + fn cooldown_sprint_every_tick() { + let mut cd = PlayerMoveCooldown::default(); + assert!(cd.try_move(MovementStance::Sprint)); // tick 1 + assert!(cd.try_move(MovementStance::Sprint)); // tick 2 + assert!(cd.try_move(MovementStance::Sprint)); // tick 3 + } + + #[test] + fn cooldown_walk_every_two_ticks() { + let mut cd = PlayerMoveCooldown::default(); + assert!(cd.try_move(MovementStance::Walk)); // tick 1: allowed (first) + assert!(!cd.try_move(MovementStance::Walk)); // tick 2: cooldown + assert!(cd.try_move(MovementStance::Walk)); // tick 3: allowed + assert!(!cd.try_move(MovementStance::Walk)); // tick 4: cooldown + assert!(cd.try_move(MovementStance::Walk)); // tick 5: allowed + } + + #[test] + fn cooldown_careful_every_three_ticks() { + let mut cd = PlayerMoveCooldown::default(); + assert!(cd.try_move(MovementStance::Careful)); // tick 1: allowed (first) + assert!(!cd.try_move(MovementStance::Careful)); // tick 2: cd + assert!(!cd.try_move(MovementStance::Careful)); // tick 3: cd + assert!(cd.try_move(MovementStance::Careful)); // tick 4: allowed + } + + #[test] + fn cooldown_crouch_every_four_ticks() { + let mut cd = PlayerMoveCooldown::default(); + assert!(cd.try_move(MovementStance::Crouch)); // tick 1: allowed (first) + assert!(!cd.try_move(MovementStance::Crouch)); // tick 2: cd + assert!(!cd.try_move(MovementStance::Crouch)); // tick 3: cd + assert!(!cd.try_move(MovementStance::Crouch)); // tick 4: cd + assert!(cd.try_move(MovementStance::Crouch)); // tick 5: allowed + } + + #[test] + fn cooldown_tick_advances_counter() { + let mut cd = PlayerMoveCooldown::default(); + assert!(cd.try_move(MovementStance::Walk)); // move + cd.tick(); // no move, but counter advances + assert!(cd.try_move(MovementStance::Walk)); // allowed after tick + try_move = 2 + } + + #[test] + fn cooldown_stance_switch_mid_cooldown() { + let mut cd = PlayerMoveCooldown::default(); + assert!(cd.try_move(MovementStance::Crouch)); // move at crouch speed + // Switch to sprint mid-cooldown + assert!(cd.try_move(MovementStance::Sprint)); // sprint allows every tick + } +} diff --git a/server/tests/bridge_ipc.rs b/server/tests/bridge_ipc.rs index ab88147f8..69e52ddc5 100644 --- a/server/tests/bridge_ipc.rs +++ b/server/tests/bridge_ipc.rs @@ -42,6 +42,8 @@ fn snapshot_roundtrip_over_unix_socket() { tick_rate: TickRate::Full, }, player_facing: FacingDirection::North, + player_stance: MovementStance::default(), + player_inventory: vec![], entities: vec![VisibleEntity { entity_id: 100, x: 10.5, diff --git a/server/tests/bridge_tcp.rs b/server/tests/bridge_tcp.rs index 3373445b9..3e980b3d7 100644 --- a/server/tests/bridge_tcp.rs +++ b/server/tests/bridge_tcp.rs @@ -28,6 +28,8 @@ fn snapshot_roundtrip_over_tcp() { tick_rate: TickRate::Full, }, player_facing: FacingDirection::North, + player_stance: MovementStance::default(), + player_inventory: vec![], entities: vec![VisibleEntity { entity_id: 100, x: 10.5, diff --git a/server/tests/game_loop.rs b/server/tests/game_loop.rs index 721aefc6a..58df823a2 100644 --- a/server/tests/game_loop.rs +++ b/server/tests/game_loop.rs @@ -66,7 +66,7 @@ fn player_moves_north_through_full_pipeline() { rmp_serde::from_slice(&response).expect("deserialize snapshot"); // Snapshot captures state at end of tick 0 (before advance_tick increments to 1) - assert_eq!(snapshot.version, 5); + assert_eq!(snapshot.version, 6); assert_eq!(snapshot.tick, 0); assert_eq!(snapshot.entities.len(), 1); diff --git a/server/tests/gen_fixtures.rs b/server/tests/gen_fixtures.rs index 862e2a8b8..af7b8c352 100644 --- a/server/tests/gen_fixtures.rs +++ b/server/tests/gen_fixtures.rs @@ -27,6 +27,8 @@ fn fixture_snapshot(tick: u64, entities: Vec) -> ObserverSnapshot tick_rate: TickRate::Full, }, player_facing: FacingDirection::North, + player_stance: MovementStance::default(), + player_inventory: vec![], entities, visible_tiles: vec![], nearby_interactions: vec![], @@ -161,6 +163,8 @@ fn generate_msgpack_fixtures() { tick_rate: TickRate::Full, }, player_facing: FacingDirection::Southeast, + player_stance: MovementStance::default(), + player_inventory: vec![], entities: vec![VisibleEntity { entity_id: 1, x: 10.5, diff --git a/server/tests/serialization.rs b/server/tests/serialization.rs index 06063c043..70acfe4c7 100644 --- a/server/tests/serialization.rs +++ b/server/tests/serialization.rs @@ -16,6 +16,8 @@ fn test_snapshot(tick: u64, entities: Vec) -> ObserverSnapshot { tick_rate: TickRate::Full, }, player_facing: FacingDirection::North, + player_stance: MovementStance::default(), + player_inventory: vec![], entities, visible_tiles: vec![], nearby_interactions: vec![], @@ -190,6 +192,8 @@ fn snapshot_v2_fields_roundtrip() { tick_rate: TickRate::Paused, }, player_facing: FacingDirection::Southeast, + player_stance: MovementStance::default(), + player_inventory: vec![], entities: vec![VisibleEntity { entity_id: 1, x: 5.5, @@ -261,7 +265,7 @@ fn entity_to_bits_roundtrip() { fn protocol_version_constant_matches_snapshot() { let snapshot = test_snapshot(0, vec![]); assert_eq!(snapshot.version, PROTOCOL_VERSION); - assert_eq!(PROTOCOL_VERSION, 5, "bump this assertion when protocol version changes"); + assert_eq!(PROTOCOL_VERSION, 6, "bump this assertion when protocol version changes"); } /// All FacingDirection variants round-trip @@ -289,6 +293,8 @@ fn all_facing_direction_variants_roundtrip() { tick_rate: TickRate::Full, }, player_facing: dir, + player_stance: MovementStance::default(), + player_inventory: vec![], entities: vec![], visible_tiles: vec![], nearby_interactions: vec![], @@ -299,3 +305,204 @@ fn all_facing_direction_variants_roundtrip() { assert_eq!(decoded.player_facing, dir); } } + +/// v6 fields: all MovementStance variants round-trip (#449, D-053) +#[test] +fn all_movement_stance_variants_roundtrip() { + let stances = [ + MovementStance::Sprint, + MovementStance::Walk, + MovementStance::Careful, + MovementStance::Crouch, + ]; + + for stance in stances { + let snapshot = test_snapshot(0, vec![]); + let mut snapshot = snapshot; + snapshot.player_stance = stance; + let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); + let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize"); + assert_eq!(decoded.player_stance, stance); + } +} + +/// v6 fields: player_inventory with items round-trips (#449, D-065) +#[test] +fn snapshot_v6_inventory_roundtrip() { + let mut snapshot = test_snapshot(0, vec![]); + snapshot.player_stance = MovementStance::Careful; + snapshot.player_inventory = vec![ + InventoryItem { + item_id: 100, + name: "Manifest Copy".into(), + slot: 0, + }, + InventoryItem { + item_id: 101, + name: "Access Token".into(), + slot: 1, + }, + InventoryItem { + item_id: 102, + name: "Comm Log".into(), + slot: 2, + }, + ]; + + let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); + let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize"); + + assert_eq!(decoded.player_stance, MovementStance::Careful); + assert_eq!(decoded.player_inventory.len(), 3); + assert_eq!(decoded.player_inventory[0].item_id, 100); + assert_eq!(decoded.player_inventory[0].name, "Manifest Copy"); + assert_eq!(decoded.player_inventory[0].slot, 0); + assert_eq!(decoded.player_inventory[2].name, "Comm Log"); + assert_eq!(decoded.player_inventory[2].slot, 2); +} + +/// v6 fields: default stance is Walk, default inventory is empty (#449) +#[test] +fn snapshot_v6_defaults() { + let snapshot = test_snapshot(0, vec![]); + assert_eq!(snapshot.player_stance, MovementStance::Walk); + assert!(snapshot.player_inventory.is_empty()); +} + +/// v5 payloads (without player_stance/player_inventory) must deserialize into +/// the v6 struct via #[serde(default)]. Guards backwards compat during migration. +#[test] +fn v5_payload_deserializes_into_v6_struct() { + // Local v5 struct: ObserverSnapshot without player_stance and player_inventory + #[derive(serde::Serialize)] + struct ObserverSnapshotV5 { + version: u8, + tick: u64, + game_time: GameTime, + player_facing: FacingDirection, + entities: Vec, + visible_tiles: Vec, + nearby_interactions: Vec, + current_monologue: Option, + } + + let v5 = ObserverSnapshotV5 { + version: 5, + tick: 42, + game_time: GameTime { + day: 0, + time_of_day: 0, + day_phase: DayPhase::Morning, + tick_rate: TickRate::Full, + }, + player_facing: FacingDirection::North, + entities: vec![], + visible_tiles: vec![], + nearby_interactions: vec![], + current_monologue: None, + }; + + let bytes = rmp_serde::to_vec_named(&v5).expect("serialize v5"); + let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes) + .expect("v5 payload should deserialize into v6 struct via serde(default)"); + + // New fields should get their defaults + assert_eq!(decoded.version, 5, "version field preserved from v5"); + assert_eq!(decoded.tick, 42); + assert_eq!(decoded.player_stance, MovementStance::Walk, "missing stance should default to Walk"); + assert!(decoded.player_inventory.is_empty(), "missing inventory should default to empty"); + assert!(decoded.current_monologue.is_none(), "missing monologue should default to None"); +} + +/// Full 9-slot inventory roundtrip (D-065: 3x3 grid = 9 slots universal) +#[test] +fn snapshot_v6_full_inventory_roundtrip() { + let items: Vec = (0..9).map(|i| InventoryItem { + item_id: 100 + i as u64, + name: format!("Item {}", i), + slot: i, + }).collect(); + + let mut snapshot = test_snapshot(0, vec![]); + snapshot.player_inventory = items; + + let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); + let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize"); + + assert_eq!(decoded.player_inventory.len(), 9); + for (i, item) in decoded.player_inventory.iter().enumerate() { + assert_eq!(item.slot, i as u8, "slot {} should match index", i); + assert_eq!(item.item_id, 100 + i as u64); + } + // Slot 8 is max valid (0-indexed, 3x3 grid) + assert_eq!(decoded.player_inventory[8].slot, 8); +} + +/// All VerbKind variants must survive MessagePack round-trip (#421, D-057). +/// Guards against serde mapping breakage when new verbs are added. +#[test] +fn all_verb_kind_variants_roundtrip() { + let all_verbs = [ + (VerbKind::ExamineNpc, "Observe"), + (VerbKind::Talk, "Talk"), + (VerbKind::Observe, "Observe"), + (VerbKind::Read, "Read"), + (VerbKind::Open, "Open"), + (VerbKind::Close, "Close"), + (VerbKind::Search, "Search"), + (VerbKind::Use, "Use"), + (VerbKind::Take, "Take"), + (VerbKind::Sit, "Sit"), + (VerbKind::Confront, "Confront"), + (VerbKind::ExamineObject, "Examine"), + ]; + + for (kind, label) in all_verbs { + let mut snapshot = test_snapshot(0, vec![]); + snapshot.nearby_interactions = vec![NearbyInteraction { + entity_id: 1, + entity_type: EntityKind::Object, + distance: 1, + verbs: vec![VerbOption { + kind, + label: label.into(), + priority: 1, + available: true, + }], + object_type: None, + contradicted: false, + }]; + + let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); + let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize"); + + assert_eq!(decoded.nearby_interactions.len(), 1); + assert_eq!( + decoded.nearby_interactions[0].verbs[0].kind, kind, + "VerbKind::{:?} did not roundtrip", kind + ); + } +} + +/// ObjectType enum round-trips through MessagePack (#421). +/// While not on the wire in ObserverSnapshot, ObjectType has Serialize/Deserialize +/// for future save/load and must round-trip cleanly. +#[test] +fn all_object_type_variants_roundtrip() { + use settled_reach_server::simulation::interaction::ObjectType; + + let types = [ + ObjectType::Readable, + ObjectType::Container, + ObjectType::Terminal, + ObjectType::Door, + ObjectType::Pickup, + ObjectType::Furniture, + ]; + + for obj_type in types { + let bytes = rmp_serde::to_vec_named(&obj_type).expect("serialize"); + let decoded: ObjectType = rmp_serde::from_slice(&bytes).expect("deserialize"); + assert_eq!(decoded, obj_type, "ObjectType::{:?} roundtrip failed", obj_type); + } +} From 32422ac4bcc2e362180bacd17af278add6f3799e Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 14 Feb 2026 15:39:28 +0100 Subject: [PATCH 2/8] chore(meta): update changelog Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bdd2e0026..109ca12e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,22 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] +### Added +- ObserverSnapshot v6 wire protocol (#449) — player_stance (MovementStance) and player_inventory (Vec\) fields with serde defaults for backward compatibility +- Stance system (#417) — Sprint/Walk/Careful/Crouch movement stance with tick-based speed (1/2/3/4 ticks per move), monologue rate multipliers (40%/100%/150%/100%), PlayerMoveCooldown component, ToggleStanceUp/Down player actions +- TilePresence posture layers (#420) — Standing/Prone/Seated/Fixture occupancy layers enabling same-tile coexistence (e.g. seated NPC + standing player), layer-based collision in validate_movement +- ObjectType component (#421) — Readable/Container/Terminal/Door/Pickup/Furniture types with Phase 1 verb sets computed from type + proximity range +- Phase 2 verb filter (#422) — KG-gated observer-side verb processing: POI priority flips (D-060), Confront injection at KnowsDetails+ confidence, contradiction marking, archetype-specific label relabeling (Smuggler sees Move/Stash, Detective sees Scan/Flag on containers) +- CharacterArchetype component — Smuggler/Detective archetype for Phase 2 verb label differentiation (D-057) +- VerbKind::Confront — Phase 2 only verb injected when observer has KnowsDetails+ on an NPC at close range + +### Changed +- Protocol version bumped from 5 to 6 (stance, inventory, ObjectType, verb system fields) +- MessagePack fixtures regenerated for protocol v6 +- Input processing queries expanded for stance and cooldown components with backward-compatible Option wrapping +- Observer pipeline queries expanded for Stance and CharacterArchetype components +- NearbyInteraction carries object_type and contradicted fields for Phase 2 context + ### Added - D-066: Dual-scale grid — 0.5m simulation tiles for stealth granularity, 1m visual tiles for proportional art (2x retina factor). All world geometry 2x2 sim tile minimum so cover/LOS maps 1:1 with visuals. Amends OQ-01. - Sprint CLI (`db/connectors/sprint`) — unified sprint lifecycle management with 5 subcommands: status, start, stop, start-work, prepare. Auto-detects sprint from DB state and team from git branch. Guards prevent activating unplanned sprints. From 6ef7029707f2bb60f6bfdc36e84996b91aafc6d3 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 14 Feb 2026 15:44:37 +0100 Subject: [PATCH 3/8] =?UTF-8?q?feat(simulation):=20smuggler=20inventory=20?= =?UTF-8?q?=E2=80=94=20CarriedBy,=20Take/Place=20verbs=20(#424)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement physical inventory system per D-065: - CarriedBy(StableId) component marks items as carried by an entity - ItemName and InventorySlot components for display and slot assignment - Take verb: removes TilePosition, adds CarriedBy + InventorySlot - Place verb: removes CarriedBy + InventorySlot, adds TilePosition - Observer snapshot populates player_inventory from carried items query - Info boundary: carried items lack TilePosition, naturally excluded from all spatial queries (visibility, interactions) for other entities - 9-slot capacity (3x3 grid per OQ-24), auto-assigns first free slot - 8 new tests covering take, place, sequential slots, full rejection Co-Authored-By: Claude Opus 4.6 --- server/src/perception/observer/mod.rs | 20 +- server/src/simulation/input.rs | 326 +++++++++++++++++++++++++- server/src/simulation/inventory.rs | 272 +++++++++++++++++++++ server/src/simulation/mod.rs | 1 + 4 files changed, 609 insertions(+), 10 deletions(-) create mode 100644 server/src/simulation/inventory.rs diff --git a/server/src/perception/observer/mod.rs b/server/src/perception/observer/mod.rs index b96b2e3a0..f3f6c8e38 100644 --- a/server/src/perception/observer/mod.rs +++ b/server/src/perception/observer/mod.rs @@ -15,6 +15,7 @@ use crate::knowledge::{EntityRegistry, KnowledgeGraph, StableId}; use crate::perception::query::{ActivePerceptionMode, VisibilityGeometry}; use crate::perception::vision_cone::Facing; use crate::simulation::interaction::NearbyInteractionBuffer; +use crate::simulation::inventory::{CarriedBy, InventorySlot, ItemName}; use crate::simulation::monologue::MonologueBuffer; use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap}; use crate::simulation::stance::Stance; @@ -52,7 +53,7 @@ pub fn compute_observer_snapshot( geometry: Res, registry: Res, mut observer_query: Query< - (&TilePosition, Option<&Facing>, &KnowledgeGraph, &mut NearbyInteractionBuffer, &mut MonologueBuffer, Option<&Stance>, Option<&CharacterArchetype>), + (Entity, &TilePosition, Option<&Facing>, &KnowledgeGraph, &mut NearbyInteractionBuffer, &mut MonologueBuffer, Option<&Stance>, Option<&CharacterArchetype>), With, >, all_entities: Query<( @@ -61,9 +62,10 @@ pub fn compute_observer_snapshot( Option<&PlayerCharacter>, Option<&crate::npc::Npc>, )>, + inventory_items: Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>, mut buffer: ResMut, ) { - let Ok((_observer_pos, facing_opt, observer_kg, mut interaction_buffer, mut monologue_buffer, stance_opt, archetype_opt)) = + let Ok((observer_entity, _observer_pos, facing_opt, observer_kg, mut interaction_buffer, mut monologue_buffer, stance_opt, archetype_opt)) = observer_query.single_mut() else { return; @@ -75,6 +77,18 @@ pub fn compute_observer_snapshot( let archetype = archetype_opt.copied().unwrap_or_default(); + // Collect player inventory (D-065 info boundary: only own items) + let player_inventory = registry + .to_stable(observer_entity) + .map(|player_sid| { + crate::simulation::inventory::collect_inventory_for( + player_sid, + &inventory_items, + ®istry, + ) + }) + .unwrap_or_default(); + let (mut entities, visible_ids) = filter_visible_entities(&geometry, ®istry, observer_kg, &all_entities); @@ -114,7 +128,7 @@ pub fn compute_observer_snapshot( game_time, player_facing: facing, player_stance: stance_opt.map(|s| s.0).unwrap_or_default(), - player_inventory: Vec::new(), + player_inventory, entities, visible_tiles: geometry.visible_tiles.clone(), nearby_interactions, diff --git a/server/src/simulation/input.rs b/server/src/simulation/input.rs index f4a5a1a4c..b0a8cd6cb 100644 --- a/server/src/simulation/input.rs +++ b/server/src/simulation/input.rs @@ -3,7 +3,11 @@ // PlayerInput: semantic actions (MoveNorth, Interact, UsePerceptionMode, ToggleStance) use crate::bridge::types::{PlayerAction, PlayerInput}; +use crate::knowledge::{EntityRegistry, StableId}; use crate::perception::vision_cone::{facing_from_delta, Facing}; +use crate::simulation::inventory::{ + find_next_slot, occupied_slots_for, CarriedBy, InventorySlot, ItemName, MAX_INVENTORY_SLOTS, +}; use crate::simulation::movement::{MoveIntent, PlayerCharacter, TilePosition}; use crate::simulation::stance::{PlayerMoveCooldown, Stance}; use crate::simulation::time::{SimulationTime, TickRate}; @@ -56,16 +60,18 @@ impl InputQueue { } /// Drains InputQueue for the current tick, converts PlayerActions to ECS components. -/// Handles stance toggling (D-053) and movement cooldown based on current stance. +/// Handles stance toggling (D-053), movement cooldown, and Take/Place verbs (#424). #[allow(clippy::type_complexity)] pub fn process_player_input( mut input_queue: ResMut, mut time: ResMut, mut commands: Commands, + registry: Res, mut player_query: Query< (Entity, &TilePosition, Option<&mut Stance>, Option<&mut PlayerMoveCooldown>), With, >, + inventory_items: Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>, ) { let current_tick = time.tick; let inputs = input_queue.drain_for_tick(current_tick); @@ -137,12 +143,33 @@ pub fn process_player_input( time.tick_rate = rate; tracing::debug!("Tick rate set to {:?} by player input", rate); } - PlayerAction::Interact { target_entity_id, verb } => { - tracing::info!( - "Interact: target={:?}, verb={:?} — logged only, dialogue dispatch future scope (#415)", - target_entity_id, - verb, - ); + PlayerAction::Interact { target_entity_id, ref verb } => { + match verb.as_deref() { + Some("Take") => { + handle_take( + &mut commands, + ®istry, + &player_query, + &inventory_items, + target_entity_id, + ); + } + Some("Place") => { + handle_place( + &mut commands, + ®istry, + &player_query, + target_entity_id, + ); + } + _ => { + tracing::info!( + "Interact: target={:?}, verb={:?} — logged only, dialogue dispatch future scope (#415)", + target_entity_id, + verb, + ); + } + } } PlayerAction::UsePerceptionMode(ref mode) => { tracing::trace!("UsePerceptionMode({}) — no-op for Sprint 1", mode); @@ -195,6 +222,105 @@ fn apply_move( .insert(Facing(facing_from_delta(dx, dy))); } +/// Handle Take verb: pick up an item entity and add it to the player's inventory. +/// Removes TilePosition (item is no longer on the ground — info boundary enforcement), +/// adds CarriedBy + InventorySlot components. +#[allow(clippy::type_complexity)] +fn handle_take( + commands: &mut Commands, + registry: &EntityRegistry, + player_query: &Query< + (Entity, &TilePosition, Option<&mut Stance>, Option<&mut PlayerMoveCooldown>), + With, + >, + inventory_items: &Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>, + target_entity_id: Option, +) { + let Some(target_id) = target_entity_id else { + tracing::warn!("Take verb without target_entity_id"); + return; + }; + + let Ok((player_entity, _, _, _)) = player_query.single() else { + return; + }; + + let Some(player_sid) = registry.to_stable(player_entity) else { + tracing::error!("Player entity not in EntityRegistry"); + return; + }; + + // Resolve wire ID to ECS entity + let target_stable = StableId(target_id); + let Some(target_entity) = registry.to_entity(&target_stable) else { + tracing::warn!(target_id, "Take: target entity not in registry"); + return; + }; + + // Check inventory capacity + let occupied = occupied_slots_for(player_sid, inventory_items); + let Some(slot) = find_next_slot(&occupied) else { + tracing::info!("Inventory full ({} slots), cannot take item", MAX_INVENTORY_SLOTS); + return; + }; + + // Remove TilePosition (item leaves the ground), add CarriedBy + InventorySlot + commands.entity(target_entity) + .remove::() + .insert((CarriedBy(player_sid), InventorySlot(slot))); + + tracing::info!( + target_id, + slot, + "Take: item picked up and added to inventory slot", + ); +} + +/// Handle Place verb: remove an item from inventory and place it on the ground +/// at the player's current position. Removes CarriedBy + InventorySlot, adds +/// TilePosition at the player's current tile. +#[allow(clippy::type_complexity)] +fn handle_place( + commands: &mut Commands, + registry: &EntityRegistry, + player_query: &Query< + (Entity, &TilePosition, Option<&mut Stance>, Option<&mut PlayerMoveCooldown>), + With, + >, + target_entity_id: Option, +) { + let Some(target_id) = target_entity_id else { + tracing::warn!("Place verb without target_entity_id"); + return; + }; + + let Ok((_, player_pos, _, _)) = player_query.single() else { + return; + }; + + let target_stable = StableId(target_id); + let Some(target_entity) = registry.to_entity(&target_stable) else { + tracing::warn!(target_id, "Place: target entity not in registry"); + return; + }; + + let place_pos = *player_pos; + + // Remove inventory components, place item at player's tile + commands.entity(target_entity) + .remove::() + .remove::() + .insert(place_pos); + + tracing::info!( + target_id, + x = place_pos.x, + y = place_pos.y, + z = place_pos.z, + "Place: item dropped at player position", + ); +} + #[cfg(test)] mod tests { use super::*; @@ -245,6 +371,7 @@ mod tests { let mut world = bevy_ecs::world::World::new(); world.insert_resource(InputQueue::default()); world.insert_resource(SimulationTime::default()); + world.init_resource::(); let player = world .spawn((PlayerCharacter, TilePosition::new(5, 5, 0))) @@ -268,6 +395,7 @@ mod tests { let mut world = bevy_ecs::world::World::new(); world.insert_resource(InputQueue::default()); world.insert_resource(SimulationTime::default()); + world.init_resource::(); world.resource_mut::().push(PlayerInput { tick: 0, @@ -286,6 +414,7 @@ mod tests { let mut world = bevy_ecs::world::World::new(); world.insert_resource(InputQueue::default()); world.insert_resource(SimulationTime::default()); + world.init_resource::(); world.resource_mut::().push(PlayerInput { tick: 0, @@ -305,6 +434,7 @@ mod tests { let mut world = bevy_ecs::world::World::new(); world.insert_resource(InputQueue::default()); world.insert_resource(SimulationTime::default()); + world.init_resource::(); world.resource_mut::().push(PlayerInput { tick: 0, @@ -321,6 +451,7 @@ mod tests { let mut world = bevy_ecs::world::World::new(); world.insert_resource(InputQueue::default()); world.insert_resource(SimulationTime::default()); + world.init_resource::(); let player = world .spawn((PlayerCharacter, TilePosition::new(5, 5, 0))) @@ -346,6 +477,7 @@ mod tests { let mut world = bevy_ecs::world::World::new(); world.insert_resource(InputQueue::default()); world.insert_resource(SimulationTime::default()); + world.init_resource::(); world.spawn(( PlayerCharacter, @@ -373,6 +505,7 @@ mod tests { let mut world = bevy_ecs::world::World::new(); world.insert_resource(InputQueue::default()); world.insert_resource(SimulationTime::default()); + world.init_resource::(); world.spawn(( PlayerCharacter, @@ -400,6 +533,7 @@ mod tests { 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(( @@ -446,6 +580,7 @@ mod tests { 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(( @@ -483,6 +618,7 @@ mod tests { let mut world = bevy_ecs::world::World::new(); world.insert_resource(InputQueue::default()); world.insert_resource(SimulationTime::default()); + world.init_resource::(); let player = world .spawn((PlayerCharacter, TilePosition::new(5, 5, 0))) @@ -507,4 +643,180 @@ mod tests { schedule.run(&mut world); assert!(world.get::(player).is_some()); } + + #[test] + fn take_verb_picks_up_item() { + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(InputQueue::default()); + world.insert_resource(SimulationTime::default()); + world.init_resource::(); + + // Spawn player and register + let player = world + .spawn((PlayerCharacter, TilePosition::new(5, 5, 0))) + .id(); + let player_sid = world.resource_mut::().register(player); + + // Spawn item near player + let item = world + .spawn(( + TilePosition::new(5, 4, 0), + ItemName("Manifest Copy".into()), + )) + .id(); + let item_sid = world.resource_mut::().register(item); + + // Issue Take verb + world.resource_mut::().push(PlayerInput { + tick: 0, + action: PlayerAction::Interact { + target_entity_id: Some(item_sid.0), + verb: Some("Take".into()), + }, + }); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_player_input); + schedule.run(&mut world); + + // Item should have CarriedBy + InventorySlot, no TilePosition + assert!(world.get::(item).is_none(), "item should leave the ground"); + let carried = world.get::(item).expect("item should have CarriedBy"); + assert_eq!(carried.0, player_sid); + let slot = world.get::(item).expect("item should have slot"); + assert_eq!(slot.0, 0, "first item goes to slot 0"); + } + + #[test] + fn place_verb_drops_item() { + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(InputQueue::default()); + world.insert_resource(SimulationTime::default()); + world.init_resource::(); + + let player = world + .spawn((PlayerCharacter, TilePosition::new(5, 5, 0))) + .id(); + let player_sid = world.resource_mut::().register(player); + + // Spawn item already in inventory (no TilePosition) + let item = world + .spawn(( + CarriedBy(player_sid), + ItemName("Manifest Copy".into()), + InventorySlot(0), + )) + .id(); + let item_sid = world.resource_mut::().register(item); + + // Issue Place verb + world.resource_mut::().push(PlayerInput { + tick: 0, + action: PlayerAction::Interact { + target_entity_id: Some(item_sid.0), + verb: Some("Place".into()), + }, + }); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_player_input); + schedule.run(&mut world); + + // Item should have TilePosition at player's location, no CarriedBy/InventorySlot + let pos = world.get::(item).expect("item should be on ground"); + assert_eq!(*pos, TilePosition::new(5, 5, 0), "placed at player position"); + assert!(world.get::(item).is_none(), "CarriedBy removed"); + assert!(world.get::(item).is_none(), "InventorySlot removed"); + } + + #[test] + fn take_verb_assigns_sequential_slots() { + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(InputQueue::default()); + world.insert_resource(SimulationTime::default()); + world.init_resource::(); + + let player = world + .spawn((PlayerCharacter, TilePosition::new(5, 5, 0))) + .id(); + let player_sid = world.resource_mut::().register(player); + + // Item already in slot 0 + world.spawn(( + CarriedBy(player_sid), + ItemName("Manifest".into()), + InventorySlot(0), + )); + + // New item on the ground + let item2 = world + .spawn(( + TilePosition::new(5, 4, 0), + ItemName("Token".into()), + )) + .id(); + let item2_sid = world.resource_mut::().register(item2); + + world.resource_mut::().push(PlayerInput { + tick: 0, + action: PlayerAction::Interact { + target_entity_id: Some(item2_sid.0), + verb: Some("Take".into()), + }, + }); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_player_input); + schedule.run(&mut world); + + let slot = world.get::(item2).expect("item should have slot"); + assert_eq!(slot.0, 1, "second item goes to slot 1"); + } + + #[test] + fn take_verb_full_inventory_rejected() { + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(InputQueue::default()); + world.insert_resource(SimulationTime::default()); + world.init_resource::(); + + let player = world + .spawn((PlayerCharacter, TilePosition::new(5, 5, 0))) + .id(); + let player_sid = world.resource_mut::().register(player); + + // Fill all 9 slots + for slot in 0..MAX_INVENTORY_SLOTS { + world.spawn(( + CarriedBy(player_sid), + ItemName(format!("Item {}", slot)), + InventorySlot(slot), + )); + } + + // Try to take another item + let item = world + .spawn(( + TilePosition::new(5, 4, 0), + ItemName("Overflow".into()), + )) + .id(); + let item_sid = world.resource_mut::().register(item); + + world.resource_mut::().push(PlayerInput { + tick: 0, + action: PlayerAction::Interact { + target_entity_id: Some(item_sid.0), + verb: Some("Take".into()), + }, + }); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_player_input); + schedule.run(&mut world); + + // Item should still be on the ground + assert!(world.get::(item).is_some(), "item stays on ground"); + assert!(world.get::(item).is_none(), "no CarriedBy when full"); + } } diff --git a/server/src/simulation/inventory.rs b/server/src/simulation/inventory.rs new file mode 100644 index 000000000..675eb6a49 --- /dev/null +++ b/server/src/simulation/inventory.rs @@ -0,0 +1,272 @@ +// Inventory system — CarriedBy component, Take/Place verb handling (#424, D-065) +// +// Items are world entities with CarriedBy(StableId) referencing the carrier. +// When carried, TilePosition is removed — this naturally enforces the info +// boundary: carried items don't appear in spatial queries (visibility, +// interactions) for other observers. Only the carrier's observer snapshot +// includes them via player_inventory. +// +// Take: removes TilePosition, adds CarriedBy + InventorySlot. +// Place: removes CarriedBy + InventorySlot, adds TilePosition at player pos. +// +// 3x3 grid = 9 universal slots (OQ-24 resolved). Slot assignment is +// first-available (0..8). + +use bevy_ecs::prelude::*; + +use crate::bridge::types::InventoryItem; +use crate::knowledge::types::StableId; + +/// Maximum inventory slots (3x3 grid, OQ-24). +pub const MAX_INVENTORY_SLOTS: u8 = 9; + +/// Marks an item as carried by an entity. References the carrier's StableId. +/// When present, the item entity should NOT have a TilePosition — it's +/// in someone's pocket, not on the ground. +#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)] +pub struct CarriedBy(pub StableId); + +/// Display name for an item entity, crossing the wire as InventoryItem.name. +#[derive(Component, Debug, Clone)] +pub struct ItemName(pub String); + +/// Inventory slot assignment (0..8 for 3x3 grid). +/// Assigned on Take, removed on Place. +#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)] +pub struct InventorySlot(pub u8); + +/// Find the next available inventory slot for a carrier. +/// Returns None if all 9 slots are occupied. +pub fn find_next_slot(occupied: &[u8]) -> Option { + for slot in 0..MAX_INVENTORY_SLOTS { + if !occupied.contains(&slot) { + return Some(slot); + } + } + None +} + +/// Collect inventory items for a specific carrier (by StableId). +/// Returns wire-format InventoryItem structs sorted by slot. +pub fn collect_inventory_for( + carrier_id: StableId, + items: &Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>, + registry: &crate::knowledge::EntityRegistry, +) -> Vec { + let mut result: Vec = items + .iter() + .filter(|(_, carried_by, _, _)| carried_by.0 == carrier_id) + .map(|(entity, _, name, slot)| { + let wire_id = registry + .to_stable(entity) + .map(|sid| sid.0) + .unwrap_or_else(|| { + tracing::error!(?entity, "carried item not in EntityRegistry"); + entity.to_bits() + }); + InventoryItem { + item_id: wire_id, + name: name.0.clone(), + slot: slot.0, + } + }) + .collect(); + result.sort_by_key(|item| item.slot); + result +} + +/// Get the list of occupied slots for a carrier. +pub fn occupied_slots_for( + carrier_id: StableId, + items: &Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>, +) -> Vec { + items + .iter() + .filter(|(_, carried_by, _, _)| carried_by.0 == carrier_id) + .map(|(_, _, _, slot)| slot.0) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::knowledge::EntityRegistry; + use bevy_ecs::world::World; + + fn setup_world() -> World { + let mut world = World::new(); + world.init_resource::(); + world + } + + #[test] + fn find_next_slot_empty_returns_zero() { + assert_eq!(find_next_slot(&[]), Some(0)); + } + + #[test] + fn find_next_slot_skips_occupied() { + assert_eq!(find_next_slot(&[0, 1, 2]), Some(3)); + } + + #[test] + fn find_next_slot_fills_gaps() { + assert_eq!(find_next_slot(&[0, 2, 4]), Some(1)); + } + + #[test] + fn find_next_slot_full_returns_none() { + let all: Vec = (0..9).collect(); + assert_eq!(find_next_slot(&all), None); + } + + #[test] + fn collect_inventory_empty_when_no_items() { + let mut world = setup_world(); + let player = world.spawn_empty().id(); + let player_sid = world.resource_mut::().register(player); + + let mut query_state = + world.query::<(Entity, &CarriedBy, &ItemName, &InventorySlot)>(); + + // Can't use system params directly in tests — use world query + // Instead, verify the logic by spawning items and checking + assert_eq!(query_state.iter(&world).count(), 0); + let _ = player_sid; // used for the filter + } + + #[test] + fn carried_item_appears_in_inventory() { + let mut world = setup_world(); + + let player = world.spawn_empty().id(); + let player_sid = world.resource_mut::().register(player); + + let item = world + .spawn(( + CarriedBy(player_sid), + ItemName("Manifest Copy".into()), + InventorySlot(0), + )) + .id(); + world.resource_mut::().register(item); + + // Use system_state for proper Query access + let mut system_state = bevy_ecs::system::SystemState::<( + Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>, + Res, + )>::new(&mut world); + let (items_query, registry) = system_state.get(&world); + + let inventory = collect_inventory_for(player_sid, &items_query, ®istry); + assert_eq!(inventory.len(), 1); + assert_eq!(inventory[0].name, "Manifest Copy"); + assert_eq!(inventory[0].slot, 0); + } + + #[test] + fn only_own_items_in_inventory() { + let mut world = setup_world(); + + let player = world.spawn_empty().id(); + let player_sid = world.resource_mut::().register(player); + + let other = world.spawn_empty().id(); + let other_sid = world.resource_mut::().register(other); + + // Player's item + let item1 = world + .spawn(( + CarriedBy(player_sid), + ItemName("Manifest Copy".into()), + InventorySlot(0), + )) + .id(); + world.resource_mut::().register(item1); + + // Other entity's item — should NOT appear in player's inventory + let item2 = world + .spawn(( + CarriedBy(other_sid), + ItemName("Access Token".into()), + InventorySlot(0), + )) + .id(); + world.resource_mut::().register(item2); + + let mut system_state = bevy_ecs::system::SystemState::<( + Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>, + Res, + )>::new(&mut world); + let (items_query, registry) = system_state.get(&world); + + let inventory = collect_inventory_for(player_sid, &items_query, ®istry); + assert_eq!(inventory.len(), 1, "info boundary: only own items"); + assert_eq!(inventory[0].name, "Manifest Copy"); + } + + #[test] + fn inventory_sorted_by_slot() { + let mut world = setup_world(); + + let player = world.spawn_empty().id(); + let player_sid = world.resource_mut::().register(player); + + // Spawn items in reverse slot order + for (slot, name) in [(2, "Comm Log"), (0, "Manifest"), (1, "Token")] { + let item = world + .spawn(( + CarriedBy(player_sid), + ItemName(name.into()), + InventorySlot(slot), + )) + .id(); + world.resource_mut::().register(item); + } + + let mut system_state = bevy_ecs::system::SystemState::<( + Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>, + Res, + )>::new(&mut world); + let (items_query, registry) = system_state.get(&world); + + let inventory = collect_inventory_for(player_sid, &items_query, ®istry); + assert_eq!(inventory.len(), 3); + assert_eq!(inventory[0].slot, 0); + assert_eq!(inventory[0].name, "Manifest"); + assert_eq!(inventory[1].slot, 1); + assert_eq!(inventory[1].name, "Token"); + assert_eq!(inventory[2].slot, 2); + assert_eq!(inventory[2].name, "Comm Log"); + } + + #[test] + fn occupied_slots_returns_correct_set() { + let mut world = setup_world(); + + let player = world.spawn_empty().id(); + let player_sid = world.resource_mut::().register(player); + + for slot in [0, 3, 7] { + world.spawn(( + CarriedBy(player_sid), + ItemName("Item".into()), + InventorySlot(slot), + )); + } + + let mut system_state = bevy_ecs::system::SystemState::< + Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>, + >::new(&mut world); + let items_query = system_state.get(&world); + + let mut slots = occupied_slots_for(player_sid, &items_query); + slots.sort(); + assert_eq!(slots, vec![0, 3, 7]); + } + + #[test] + fn max_slots_is_nine() { + assert_eq!(MAX_INVENTORY_SLOTS, 9); + } +} diff --git a/server/src/simulation/mod.rs b/server/src/simulation/mod.rs index b16cebe25..92c04a52b 100644 --- a/server/src/simulation/mod.rs +++ b/server/src/simulation/mod.rs @@ -6,6 +6,7 @@ use bevy_ecs::schedule::IntoScheduleConfigs; pub mod input; pub mod interaction; +pub mod inventory; pub mod monologue; pub mod movement; pub mod path_follow; From 2fc8bf9545a8b3e19fdc22c3f429e473f34dad55 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 14 Feb 2026 15:44:53 +0100 Subject: [PATCH 4/8] chore(meta): update changelog Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 109ca12e1..556152d34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). - Phase 2 verb filter (#422) — KG-gated observer-side verb processing: POI priority flips (D-060), Confront injection at KnowsDetails+ confidence, contradiction marking, archetype-specific label relabeling (Smuggler sees Move/Stash, Detective sees Scan/Flag on containers) - CharacterArchetype component — Smuggler/Detective archetype for Phase 2 verb label differentiation (D-057) - VerbKind::Confront — Phase 2 only verb injected when observer has KnowsDetails+ on an NPC at close range +- Smuggler inventory system (#424) — CarriedBy(StableId) component, Take/Place verbs, 9-slot (3x3 grid) capacity, auto-slot assignment, info boundary enforcement (carried items invisible to other observers) ### Changed - Protocol version bumped from 5 to 6 (stance, inventory, ObjectType, verb system fields) From 1d76100f5dfcfbdc182e7898e135e1476781d4a3 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 14 Feb 2026 16:00:09 +0100 Subject: [PATCH 5/8] feat(simulation): movement profile per archetype and sprint interaction suppression (#418, #419) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add MovementProfile component with per-archetype default stances (D-053). Sprint stance now explicitly clears the interaction buffer — no verbs computed or sent during sprint (D-055). Anomaly monologue pipeline is unaffected (handled by separate system). Co-Authored-By: Claude Opus 4.6 --- server/src/simulation/interaction.rs | 131 ++++++++++++++++++++++++++- server/src/simulation/stance.rs | 88 ++++++++++++++++++ 2 files changed, 216 insertions(+), 3 deletions(-) diff --git a/server/src/simulation/interaction.rs b/server/src/simulation/interaction.rs index de26c42ad..e802a5303 100644 --- a/server/src/simulation/interaction.rs +++ b/server/src/simulation/interaction.rs @@ -13,10 +13,11 @@ use bevy_ecs::prelude::*; // Re-export ObjectType for backward compatibility — definition moved to bridge::types (#422). pub use crate::bridge::types::ObjectType; -use crate::bridge::types::{EntityKind, NearbyInteraction, VerbKind, VerbOption}; +use crate::bridge::types::{EntityKind, MovementStance, NearbyInteraction, VerbKind, VerbOption}; use crate::knowledge::EntityRegistry; use crate::npc::Npc; use crate::simulation::movement::{PlayerCharacter, TilePosition}; +use crate::simulation::stance::Stance; /// Interaction range thresholds (Manhattan distance, same z-level) pub(crate) const CLOSE_RANGE: u32 = 2; @@ -96,7 +97,7 @@ impl ObjectType { #[allow(clippy::type_complexity)] pub fn compute_nearby_interactions( mut player_query: Query< - (&TilePosition, &mut NearbyInteractionBuffer), + (&TilePosition, &mut NearbyInteractionBuffer, Option<&Stance>), With, >, registry: Res, @@ -105,11 +106,18 @@ pub fn compute_nearby_interactions( (With, Without), >, ) { - let Ok((player_pos, mut buffer)) = player_query.single_mut() else { + let Ok((player_pos, mut buffer, stance_opt)) = player_query.single_mut() else { return; }; buffer.interactions.clear(); + // D-055: Sprint explicitly suppresses interaction buffer. + // No interaction verbs are computed or sent to the client during sprint. + // Anomaly monologue survives sprint (handled by separate monologue system). + if stance_opt.map(|s| s.0) == Some(MovementStance::Sprint) { + return; + } + for (entity, pos, is_npc, object_type) in interactables.iter() { let Some(distance) = player_pos.manhattan_distance(pos) else { continue; // Different z-level @@ -818,4 +826,121 @@ mod tests { ); } } + + // ----------------------------------------------------------------------- + // Sprint interaction buffer suppression (#419, D-055) + // ----------------------------------------------------------------------- + + /// Spawn player with Stance component for sprint suppression tests. + fn spawn_player_with_stance(world: &mut World, x: i32, y: i32, stance: MovementStance) -> Entity { + world + .spawn(( + PlayerCharacter, + TilePosition::new(x, y, 0), + NearbyInteractionBuffer::default(), + Stance(stance), + )) + .id() + } + + #[test] + fn sprint_suppresses_npc_interactions() { + let mut world = setup_world(); + spawn_player_with_stance(&mut world, 5, 5, MovementStance::Sprint); + world.spawn((Npc, TilePosition::new(5, 6, 0), Interactable)); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_nearby_interactions); + schedule.run(&mut world); + + let buffer = read_buffer(&mut world); + assert!(buffer.interactions.is_empty(), "sprint should suppress all interactions"); + } + + #[test] + fn sprint_suppresses_object_interactions() { + let mut world = setup_world(); + spawn_player_with_stance(&mut world, 5, 5, MovementStance::Sprint); + world.spawn((TilePosition::new(5, 6, 0), Interactable, ObjectType::Terminal)); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_nearby_interactions); + schedule.run(&mut world); + + let buffer = read_buffer(&mut world); + assert!(buffer.interactions.is_empty(), "sprint should suppress object interactions"); + } + + #[test] + fn walk_stance_allows_interactions() { + let mut world = setup_world(); + spawn_player_with_stance(&mut world, 5, 5, MovementStance::Walk); + world.spawn((Npc, TilePosition::new(5, 6, 0), Interactable)); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_nearby_interactions); + schedule.run(&mut world); + + let buffer = read_buffer(&mut world); + assert_eq!(buffer.interactions.len(), 1, "Walk should allow interactions"); + } + + #[test] + fn careful_stance_allows_interactions() { + let mut world = setup_world(); + spawn_player_with_stance(&mut world, 5, 5, MovementStance::Careful); + world.spawn((Npc, TilePosition::new(5, 6, 0), Interactable)); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_nearby_interactions); + schedule.run(&mut world); + + let buffer = read_buffer(&mut world); + assert_eq!(buffer.interactions.len(), 1, "Careful should allow interactions"); + } + + #[test] + fn crouch_stance_allows_interactions() { + let mut world = setup_world(); + spawn_player_with_stance(&mut world, 5, 5, MovementStance::Crouch); + world.spawn((Npc, TilePosition::new(5, 6, 0), Interactable)); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_nearby_interactions); + schedule.run(&mut world); + + let buffer = read_buffer(&mut world); + assert_eq!(buffer.interactions.len(), 1, "Crouch should allow interactions"); + } + + #[test] + fn no_stance_component_allows_interactions() { + // Backward compatibility: players without Stance still get interactions + let mut world = setup_world(); + spawn_player(&mut world, 5, 5); + world.spawn((Npc, TilePosition::new(5, 6, 0), Interactable)); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_nearby_interactions); + schedule.run(&mut world); + + let buffer = read_buffer(&mut world); + assert_eq!(buffer.interactions.len(), 1, "no Stance component should allow interactions"); + } + + #[test] + fn sprint_suppresses_multiple_nearby_entities() { + let mut world = setup_world(); + spawn_player_with_stance(&mut world, 5, 5, MovementStance::Sprint); + world.spawn((Npc, TilePosition::new(5, 6, 0), Interactable)); + world.spawn((TilePosition::new(6, 5, 0), Interactable, ObjectType::Container)); + world.spawn((TilePosition::new(4, 5, 0), Interactable)); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_nearby_interactions); + schedule.run(&mut world); + + let buffer = read_buffer(&mut world); + assert!(buffer.interactions.is_empty(), "sprint should suppress all 3 nearby entities"); + } } diff --git a/server/src/simulation/stance.rs b/server/src/simulation/stance.rs index 79994cec1..302318172 100644 --- a/server/src/simulation/stance.rs +++ b/server/src/simulation/stance.rs @@ -12,6 +12,43 @@ use bevy_ecs::prelude::*; use crate::bridge::types::MovementStance; +/// Per-archetype default movement configuration (D-053). +/// Stores the default stance so spawn code can initialize Stance from it. +/// +/// v0.1: smuggler and detective both default to Walk. +/// Future archetypes may differ (e.g., maintenance worker → Careful). +#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)] +pub struct MovementProfile { + pub default_stance: MovementStance, +} + +impl Default for MovementProfile { + fn default() -> Self { + Self { + default_stance: MovementStance::Walk, + } + } +} + +impl MovementProfile { + pub fn smuggler() -> Self { + Self { + default_stance: MovementStance::Walk, + } + } + + pub fn detective() -> Self { + Self { + default_stance: MovementStance::Walk, + } + } + + /// Create the initial Stance component from this profile's default. + pub fn initial_stance(&self) -> Stance { + Stance(self.default_stance) + } +} + /// ECS component tracking an entity's current movement stance. /// Attached to PlayerCharacter (and potentially NPCs in future). #[derive(Component, Debug, Clone, Copy, PartialEq, Eq)] @@ -159,4 +196,55 @@ mod tests { // Switch to sprint mid-cooldown assert!(cd.try_move(MovementStance::Sprint)); // sprint allows every tick } + + // ----------------------------------------------------------------------- + // MovementProfile tests (#418, D-053) + // ----------------------------------------------------------------------- + + #[test] + fn movement_profile_default_is_walk() { + let profile = MovementProfile::default(); + assert_eq!(profile.default_stance, MovementStance::Walk); + } + + #[test] + fn movement_profile_smuggler_defaults_to_walk() { + let profile = MovementProfile::smuggler(); + assert_eq!(profile.default_stance, MovementStance::Walk); + } + + #[test] + fn movement_profile_detective_defaults_to_walk() { + let profile = MovementProfile::detective(); + assert_eq!(profile.default_stance, MovementStance::Walk); + } + + #[test] + fn movement_profile_initial_stance_matches_default() { + let profile = MovementProfile::smuggler(); + let stance = profile.initial_stance(); + assert_eq!(stance.0, profile.default_stance); + } + + #[test] + fn movement_profile_custom_default_stance() { + let profile = MovementProfile { + default_stance: MovementStance::Careful, + }; + assert_eq!(profile.default_stance, MovementStance::Careful); + assert_eq!(profile.initial_stance().0, MovementStance::Careful); + } + + #[test] + fn movement_profile_as_ecs_component() { + let mut world = bevy_ecs::world::World::new(); + let profile = MovementProfile::smuggler(); + let entity = world.spawn((profile, profile.initial_stance(), PlayerMoveCooldown::default())).id(); + + let stored = world.get::(entity).unwrap(); + assert_eq!(stored.default_stance, MovementStance::Walk); + + let stance = world.get::(entity).unwrap(); + assert_eq!(stance.0, MovementStance::Walk); + } } From df81b24cd1301a5114e9298c0cc72d63990579a1 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 14 Feb 2026 16:00:20 +0100 Subject: [PATCH 6/8] feat(simulation): sprint anomaly double-take monologue (#428, D-055) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When sprinting past a Contradicted entity, queue a delayed retroactive monologue that fires after ~1.5s: "Wait — something wasn't right back there." SprintAnomalyQueue component with first-in-wins semantics. Detection in observer pipeline, processing in monologue system. Wire MovementProfile, Stance, and SprintAnomalyQueue into player spawn. Co-Authored-By: Claude Opus 4.6 --- server/src/bridge/mod.rs | 4 +- server/src/main.rs | 10 +- server/src/perception/observer/mod.rs | 25 +- server/src/simulation/monologue.rs | 426 ++++++++++++++++++++++++++ 4 files changed, 459 insertions(+), 6 deletions(-) diff --git a/server/src/bridge/mod.rs b/server/src/bridge/mod.rs index 5c3dd298e..98663e6fa 100644 --- a/server/src/bridge/mod.rs +++ b/server/src/bridge/mod.rs @@ -161,10 +161,12 @@ impl Plugin for BridgePlugin { .after(crate::simulation::movement::validate_movement), crate::simulation::monologue::trigger_monologue .after(crate::simulation::movement::validate_movement), + crate::simulation::monologue::process_sprint_anomaly_monologue + .after(crate::simulation::monologue::trigger_monologue), crate::perception::observer::compute_observer_snapshot .after(crate::perception::observer::compute_visibility_geometry) .after(crate::simulation::interaction::compute_nearby_interactions) - .after(crate::simulation::monologue::trigger_monologue) + .after(crate::simulation::monologue::process_sprint_anomaly_monologue) .before(crate::simulation::time::advance_tick), crate::perception::observation::emit_observation_events .after(crate::perception::observer::compute_observer_snapshot), diff --git a/server/src/main.rs b/server/src/main.rs index 7eb3c6f1b..0301e2f61 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -15,9 +15,10 @@ use settled_reach_server::npc::{ }; use settled_reach_server::perception::vision_cone::Facing; use settled_reach_server::simulation::interaction::{Interactable, NearbyInteractionBuffer}; -use settled_reach_server::simulation::monologue::{MonologueBuffer, MonologueState}; +use settled_reach_server::simulation::monologue::{MonologueBuffer, MonologueState, SprintAnomalyQueue}; use settled_reach_server::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap}; use settled_reach_server::simulation::path_follow::MovementSpeed; +use settled_reach_server::simulation::stance::{MovementProfile, PlayerMoveCooldown}; use settled_reach_server::simulation::time::DayPhase; use settled_reach_server::simulation::SimulationPlugin; @@ -63,7 +64,8 @@ fn main() { let mut registry = EntityRegistry::new(0); - // Player at (16,16) + // Player at (16,16) — smuggler archetype (#418, D-053) + let profile = MovementProfile::smuggler(); let player = app .world_mut() .spawn(( @@ -74,6 +76,10 @@ fn main() { NearbyInteractionBuffer::default(), MonologueState::default(), MonologueBuffer::default(), + SprintAnomalyQueue::default(), + profile, + profile.initial_stance(), + PlayerMoveCooldown::default(), )) .id(); registry.register(player); diff --git a/server/src/perception/observer/mod.rs b/server/src/perception/observer/mod.rs index f3f6c8e38..cd80ec780 100644 --- a/server/src/perception/observer/mod.rs +++ b/server/src/perception/observer/mod.rs @@ -16,7 +16,7 @@ use crate::perception::query::{ActivePerceptionMode, VisibilityGeometry}; use crate::perception::vision_cone::Facing; use crate::simulation::interaction::NearbyInteractionBuffer; use crate::simulation::inventory::{CarriedBy, InventorySlot, ItemName}; -use crate::simulation::monologue::MonologueBuffer; +use crate::simulation::monologue::{MonologueBuffer, SprintAnomalyQueue}; use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap}; use crate::simulation::stance::Stance; use crate::simulation::time::SimulationTime; @@ -53,7 +53,7 @@ pub fn compute_observer_snapshot( geometry: Res, registry: Res, mut observer_query: Query< - (Entity, &TilePosition, Option<&Facing>, &KnowledgeGraph, &mut NearbyInteractionBuffer, &mut MonologueBuffer, Option<&Stance>, Option<&CharacterArchetype>), + (Entity, &TilePosition, Option<&Facing>, &KnowledgeGraph, &mut NearbyInteractionBuffer, &mut MonologueBuffer, Option<&Stance>, Option<&CharacterArchetype>, Option<&mut SprintAnomalyQueue>), With, >, all_entities: Query<( @@ -65,7 +65,7 @@ pub fn compute_observer_snapshot( inventory_items: Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>, mut buffer: ResMut, ) { - let Ok((observer_entity, _observer_pos, facing_opt, observer_kg, mut interaction_buffer, mut monologue_buffer, stance_opt, archetype_opt)) = + let Ok((observer_entity, _observer_pos, facing_opt, observer_kg, mut interaction_buffer, mut monologue_buffer, stance_opt, archetype_opt, mut anomaly_queue_opt)) = observer_query.single_mut() else { return; @@ -101,6 +101,25 @@ pub fn compute_observer_snapshot( &mut entities, ); + // Sprint anomaly detection (#428, D-055) + // When sprinting, scan visible entities for Contradicted KG state. + // Queue the first match for delayed "double-take" monologue. + if stance_opt.map(|s| s.0) == Some(MovementStance::Sprint) { + if let Some(anomaly_queue) = anomaly_queue_opt.as_mut() { + if !anomaly_queue.has_pending() { + for &wire_id in &visible_ids { + let stable_id = StableId(wire_id); + if let Some(knowledge) = observer_kg.entity_knowledge(&stable_id) { + if knowledge.state == KnowledgeState::Contradicted { + anomaly_queue.push_anomaly(wire_id, time.tick); + break; // First-in wins + } + } + } + } + } + } + let game_time = GameTime { day: time.day(), time_of_day: time.time_of_day_minutes(), diff --git a/server/src/simulation/monologue.rs b/server/src/simulation/monologue.rs index 4e2421542..3f53c8b19 100644 --- a/server/src/simulation/monologue.rs +++ b/server/src/simulation/monologue.rs @@ -3,6 +3,10 @@ // Selects monologue lines from loaded content pools based on trigger conditions. // v0.1: enter_location (on first tick) + time_idle (periodic when player hasn't moved). // Lines are written to MonologueBuffer for inclusion in ObserverSnapshot. +// +// Sprint anomaly monologue (#428, D-055): +// When sprinting past a Contradicted entity, a delayed "double-take" monologue +// fires retroactively. Detection in observer pipeline, processing here. use bevy_ecs::prelude::*; use rand::Rng; @@ -24,6 +28,19 @@ const IDLE_THRESHOLD_TICKS: u64 = 100; /// Display duration for monologue text on client (seconds). const DISPLAY_DURATION: f32 = 5.0; +/// Tick delay before a sprint anomaly monologue fires (#428, D-055). +/// At ~60 ticks/second (60fps Full rate), 90 ticks ≈ 1.5 real seconds. +/// Tunable: adjust based on actual client frame rate. +pub(crate) const ANOMALY_DELAY_TICKS: u64 = 90; + +/// Hardcoded v0.1 sprint anomaly "double-take" lines. +/// Future: move to content pools with trigger="sprint_anomaly". +const ANOMALY_LINES: &[(&str, &str)] = &[ + ("sprint_anomaly_01", "Wait \u{2014} something wasn't right back there."), + ("sprint_anomaly_02", "Hold on. That face... why were they there?"), + ("sprint_anomaly_03", "Something's off. That wasn't where they should be."), +]; + /// Tracks monologue state for cooldown and trigger detection. /// Attached to the PlayerCharacter entity. #[derive(Component, Debug)] @@ -70,6 +87,101 @@ impl MonologueBuffer { } } +/// Queued sprint anomaly for delayed "double-take" monologue (#428, D-055). +/// +/// When sprinting past a Contradicted entity, the observer pipeline detects +/// the anomaly and pushes it here. After ANOMALY_DELAY_TICKS, the processing +/// system fires a retroactive monologue ("Wait — was that...?"). +/// +/// At most one anomaly is pending at a time (first-in wins). +#[derive(Component, Debug, Default)] +pub struct SprintAnomalyQueue { + pending: Option, +} + +#[derive(Debug, Clone)] +struct SprintAnomalyEntry { + entity_id: u64, + detected_tick: u64, +} + +impl SprintAnomalyQueue { + /// Queue an anomaly if none is pending. + /// First-in wins: subsequent anomalies are ignored until the current one fires. + pub fn push_anomaly(&mut self, entity_id: u64, tick: u64) { + if self.pending.is_none() { + self.pending = Some(SprintAnomalyEntry { + entity_id, + detected_tick: tick, + }); + } + } + + /// Take the pending anomaly if the delay has elapsed. + /// Returns the entity_id that triggered the anomaly. + pub fn take_ready(&mut self, current_tick: u64) -> Option { + if let Some(entry) = &self.pending { + if current_tick.saturating_sub(entry.detected_tick) >= ANOMALY_DELAY_TICKS { + let entity_id = entry.entity_id; + self.pending = None; + return Some(entity_id); + } + } + None + } + + /// Whether an anomaly is pending (detected but not yet fired). + pub fn has_pending(&self) -> bool { + self.pending.is_some() + } +} + +/// Process delayed sprint anomaly monologues (#428, D-055). +/// +/// Checks SprintAnomalyQueue for entries past the delay threshold and fires +/// a "double-take" monologue. Bypasses normal monologue cooldown since sprint +/// anomalies are event-driven, not periodic. Updates last_fired_tick so +/// subsequent normal monologue respects cooldown after the anomaly fires. +/// +/// System ordering: after trigger_monologue, before compute_observer_snapshot. +pub fn process_sprint_anomaly_monologue( + time: Res, + mut rng: ResMut, + mut query: Query< + (&mut SprintAnomalyQueue, &mut MonologueBuffer, &mut MonologueState), + With, + >, +) { + let Ok((mut queue, mut buffer, mut state)) = query.single_mut() else { + return; + }; + + // Don't override existing monologue from trigger_monologue + if buffer.event.is_some() { + return; + } + + if let Some(_entity_id) = queue.take_ready(time.tick) { + let index = rng.rng.random_range(0..ANOMALY_LINES.len()); + let (id, text) = ANOMALY_LINES[index]; + + buffer.event = Some(MonologueEvent { + id: id.to_string(), + text: text.to_string(), + duration_seconds: DISPLAY_DURATION, + }); + + // Update last_fired_tick so normal monologue respects cooldown + state.last_fired_tick = time.tick; + + tracing::debug!( + "Sprint anomaly monologue fired: id={}, tick={}", + id, + time.tick + ); + } +} + /// Monologue trigger system. /// /// Runs each tick. Checks trigger conditions against loaded content pools @@ -318,4 +430,318 @@ mod tests { let event = buffer.event.as_ref().unwrap(); assert_eq!(event.id, "test_idle_001"); } + + // ----------------------------------------------------------------------- + // SprintAnomalyQueue unit tests (#428, D-055) + // ----------------------------------------------------------------------- + + #[test] + fn anomaly_queue_default_is_empty() { + let queue = SprintAnomalyQueue::default(); + assert!(!queue.has_pending()); + } + + #[test] + fn anomaly_queue_push_stores_entry() { + let mut queue = SprintAnomalyQueue::default(); + queue.push_anomaly(42, 100); + assert!(queue.has_pending()); + } + + #[test] + fn anomaly_queue_first_in_wins() { + let mut queue = SprintAnomalyQueue::default(); + queue.push_anomaly(42, 100); + queue.push_anomaly(99, 101); // Should be ignored + assert!(queue.has_pending()); + + // The first anomaly (entity 42) should be the one that fires + let result = queue.take_ready(100 + ANOMALY_DELAY_TICKS); + assert_eq!(result, Some(42)); + } + + #[test] + fn anomaly_queue_take_ready_before_delay() { + let mut queue = SprintAnomalyQueue::default(); + queue.push_anomaly(42, 100); + + // Not enough delay yet + let result = queue.take_ready(100 + ANOMALY_DELAY_TICKS - 1); + assert_eq!(result, None); + assert!(queue.has_pending()); // Still pending + } + + #[test] + fn anomaly_queue_take_ready_at_delay() { + let mut queue = SprintAnomalyQueue::default(); + queue.push_anomaly(42, 100); + + // Exactly at delay threshold + let result = queue.take_ready(100 + ANOMALY_DELAY_TICKS); + assert_eq!(result, Some(42)); + assert!(!queue.has_pending()); // Consumed + } + + #[test] + fn anomaly_queue_take_ready_clears_entry() { + let mut queue = SprintAnomalyQueue::default(); + queue.push_anomaly(42, 100); + + let _ = queue.take_ready(100 + ANOMALY_DELAY_TICKS); + // Second take should return None + let result = queue.take_ready(100 + ANOMALY_DELAY_TICKS + 10); + assert_eq!(result, None); + } + + #[test] + fn anomaly_queue_can_push_after_take() { + let mut queue = SprintAnomalyQueue::default(); + queue.push_anomaly(42, 100); + let _ = queue.take_ready(100 + ANOMALY_DELAY_TICKS); + assert!(!queue.has_pending()); + + // Push a new anomaly after the first was consumed + queue.push_anomaly(99, 300); + assert!(queue.has_pending()); + let result = queue.take_ready(300 + ANOMALY_DELAY_TICKS); + assert_eq!(result, Some(99)); + } + + // ----------------------------------------------------------------------- + // process_sprint_anomaly_monologue system tests (#428, D-055) + // ----------------------------------------------------------------------- + + fn setup_anomaly_world() -> World { + let mut world = World::new(); + world.init_resource::(); + world.insert_resource(SimRng::new(42)); + world + } + + #[test] + fn anomaly_monologue_fires_after_delay() { + let mut world = setup_anomaly_world(); + let mut queue = SprintAnomalyQueue::default(); + queue.push_anomaly(42, 0); // Queued at tick 0 + + world.spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + MonologueState::default(), + MonologueBuffer::default(), + queue, + )); + + // Advance past delay + world.resource_mut::().tick = ANOMALY_DELAY_TICKS; + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_sprint_anomaly_monologue); + schedule.run(&mut world); + + let mut query = world.query::<&MonologueBuffer>(); + let buffer = query.single(&world).unwrap(); + assert!(buffer.event.is_some()); + let event = buffer.event.as_ref().unwrap(); + assert!(event.id.starts_with("sprint_anomaly_")); + } + + #[test] + fn anomaly_monologue_not_before_delay() { + let mut world = setup_anomaly_world(); + let mut queue = SprintAnomalyQueue::default(); + queue.push_anomaly(42, 0); + + world.spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + MonologueState::default(), + MonologueBuffer::default(), + queue, + )); + + // Still within delay + world.resource_mut::().tick = ANOMALY_DELAY_TICKS - 1; + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_sprint_anomaly_monologue); + schedule.run(&mut world); + + let mut query = world.query::<&MonologueBuffer>(); + let buffer = query.single(&world).unwrap(); + assert!(buffer.event.is_none()); + } + + #[test] + fn anomaly_monologue_does_not_override_existing() { + let mut world = setup_anomaly_world(); + let mut queue = SprintAnomalyQueue::default(); + queue.push_anomaly(42, 0); + + // Pre-fill the monologue buffer (as if trigger_monologue already wrote) + let mut buffer = MonologueBuffer::default(); + buffer.event = Some(MonologueEvent { + id: "existing_line".to_string(), + text: "I should keep this.".to_string(), + duration_seconds: 5.0, + }); + + world.spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + MonologueState::default(), + buffer, + queue, + )); + + world.resource_mut::().tick = ANOMALY_DELAY_TICKS; + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_sprint_anomaly_monologue); + schedule.run(&mut world); + + // Should still have the original line, not the anomaly line + let mut query = world.query::<&MonologueBuffer>(); + let buffer = query.single(&world).unwrap(); + assert_eq!(buffer.event.as_ref().unwrap().id, "existing_line"); + + // Queue should still be pending (not consumed) + let mut q = world.query::<&SprintAnomalyQueue>(); + assert!(q.single(&world).unwrap().has_pending()); + } + + #[test] + fn anomaly_monologue_updates_last_fired_tick() { + let mut world = setup_anomaly_world(); + let mut queue = SprintAnomalyQueue::default(); + queue.push_anomaly(42, 0); + + world.spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + MonologueState::default(), + MonologueBuffer::default(), + queue, + )); + + world.resource_mut::().tick = ANOMALY_DELAY_TICKS; + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_sprint_anomaly_monologue); + schedule.run(&mut world); + + let mut query = world.query::<&MonologueState>(); + let state = query.single(&world).unwrap(); + assert_eq!(state.last_fired_tick, ANOMALY_DELAY_TICKS); + } + + #[test] + fn anomaly_monologue_clears_queue_after_fire() { + let mut world = setup_anomaly_world(); + let mut queue = SprintAnomalyQueue::default(); + queue.push_anomaly(42, 0); + + world.spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + MonologueState::default(), + MonologueBuffer::default(), + queue, + )); + + world.resource_mut::().tick = ANOMALY_DELAY_TICKS; + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_sprint_anomaly_monologue); + schedule.run(&mut world); + + let mut query = world.query::<&SprintAnomalyQueue>(); + let queue = query.single(&world).unwrap(); + assert!(!queue.has_pending()); + } + + #[test] + fn anomaly_monologue_no_crash_without_queue() { + // Backward compat: entities without SprintAnomalyQueue don't crash + let mut world = setup_anomaly_world(); + world.spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + MonologueState::default(), + MonologueBuffer::default(), + )); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_sprint_anomaly_monologue); + // Should not panic + schedule.run(&mut world); + } + + #[test] + fn anomaly_delay_constant_is_90_ticks() { + // D-055 spec: ~1.5 real seconds at 60fps → 90 ticks + assert_eq!(ANOMALY_DELAY_TICKS, 90); + } + + #[test] + fn anomaly_lines_all_valid() { + // All hardcoded v0.1 lines should have id prefix and non-empty text + assert!(!ANOMALY_LINES.is_empty()); + for (id, text) in ANOMALY_LINES { + assert!(id.starts_with("sprint_anomaly_"), "id={} should start with sprint_anomaly_", id); + assert!(!text.is_empty(), "text for {} should be non-empty", id); + } + } + + #[test] + fn anomaly_full_cycle_detect_then_fire() { + // Full end-to-end: push anomaly at tick 0 → not fired at tick 89 → fires at tick 90 + let mut world = setup_anomaly_world(); + let mut queue = SprintAnomalyQueue::default(); + queue.push_anomaly(42, 0); + + world.spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + MonologueState::default(), + MonologueBuffer::default(), + queue, + )); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_sprint_anomaly_monologue); + + // Tick 89: still within delay — should NOT fire + world.resource_mut::().tick = ANOMALY_DELAY_TICKS - 1; + schedule.run(&mut world); + + let mut buf_query = world.query::<&MonologueBuffer>(); + assert!(buf_query.single(&world).unwrap().event.is_none(), "should not fire before delay"); + + let mut q_query = world.query::<&SprintAnomalyQueue>(); + assert!(q_query.single(&world).unwrap().has_pending(), "still pending before delay"); + + // Tick 90: delay elapsed — should fire + world.resource_mut::().tick = ANOMALY_DELAY_TICKS; + schedule.run(&mut world); + + let mut buf_query = world.query::<&MonologueBuffer>(); + let buffer = buf_query.single(&world).unwrap(); + assert!(buffer.event.is_some(), "should fire at delay threshold"); + let event = buffer.event.as_ref().unwrap(); + assert!(event.id.starts_with("sprint_anomaly_")); + assert_eq!(event.duration_seconds, DISPLAY_DURATION); + + // Queue should be cleared + let mut q_query = world.query::<&SprintAnomalyQueue>(); + assert!(!q_query.single(&world).unwrap().has_pending(), "queue cleared after fire"); + + // last_fired_tick should be updated + let mut state_query = world.query::<&MonologueState>(); + assert_eq!( + state_query.single(&world).unwrap().last_fired_tick, + ANOMALY_DELAY_TICKS, + "last_fired_tick updated for cooldown" + ); + } } From 3d63a4f9f6e5ec0371b5c085b49c68ff0be31663 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 14 Feb 2026 16:00:32 +0100 Subject: [PATCH 7/8] test(simulation): QA coverage for Phase 2 verbs, sprint suppression, occupancy, inventory Add comprehensive test suites across sprint 6 systems: - Phase 2 observer filter: Confront injection, archetype label relabeling, contradiction marking, POI priority ordering (12 tests) - Sprint anomaly: detection during sprint, queue semantics, delay timing, D-055 visible-but-suppressed integration (7 tests) - Wire format: Confront and CharacterArchetype msgpack roundtrip (4 tests) - TilePresence: 4-layer coexistence on same tile (1 test) - Inventory: Take/Place full roundtrip, capacity enforcement (3 tests) Co-Authored-By: Claude Opus 4.6 --- server/src/perception/observer/tests.rs | 1040 +++++++++++++++++++++++ server/src/simulation/input.rs | 101 +++ server/src/simulation/movement.rs | 46 + server/tests/serialization.rs | 93 ++ 4 files changed, 1280 insertions(+) diff --git a/server/src/perception/observer/tests.rs b/server/src/perception/observer/tests.rs index 14ceb5967..a84f1b351 100644 --- a/server/src/perception/observer/tests.rs +++ b/server/src/perception/observer/tests.rs @@ -684,3 +684,1043 @@ fn snapshot_v6_version_is_protocol_version() { "snapshot version must match PROTOCOL_VERSION constant" ); } + +// ----------------------------------------------------------------------- +// Phase 2 verb filter tests (#422, D-057) +// ----------------------------------------------------------------------- + +#[test] +fn phase2_confront_injected_for_npc_with_knows_details() { + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + // NPC at close range, directly north in LOS + let npc = world + .spawn(( + crate::npc::Npc, + TilePosition::new(16, 15, 0), + crate::simulation::interaction::Interactable, + )) + .id(); + let npc_sid = registry.register(npc); + + // Player has KnowsDetails confidence on NPC + let mut kg = KnowledgeGraph::new(); + kg.observe_entity(npc_sid, TilePosition::new(16, 15, 0), 50); + kg.observe_entity_leaving_los(&npc_sid, 60); + // Now at KnowsDetails (one step below Direct) + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + kg, + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + )) + .id(); + registry.register(player); + world.insert_resource(registry); + + run_full_pipeline(&mut world); + + let buffer = world.resource::(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + assert_eq!(snapshot.nearby_interactions.len(), 1); + let interaction = &snapshot.nearby_interactions[0]; + // Should have Talk, ExamineNpc, AND Confront (Phase 2 injected) + assert_eq!(interaction.verbs.len(), 3); + let confront = interaction.verbs.iter().find(|v| v.kind == VerbKind::Confront); + assert!(confront.is_some(), "Confront should be injected for KnowsDetails+"); + assert_eq!(confront.unwrap().priority, 3); + assert_eq!(confront.unwrap().label, "Confront"); +} + +#[test] +fn phase2_no_confront_without_knows_details() { + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + let npc = world + .spawn(( + crate::npc::Npc, + TilePosition::new(16, 15, 0), + crate::simulation::interaction::Interactable, + )) + .id(); + let npc_sid = registry.register(npc); + + // Player only Suspects this NPC (below KnowsDetails threshold) + let mut kg = KnowledgeGraph::new(); + kg.entities.insert(npc_sid, crate::knowledge::EntityKnowledge { + last_known_position: Some(TilePosition::new(16, 15, 0)), + last_observed_tick: 50, + last_updated_tick: 50, + confidence: KnowledgeConfidence::Suspects, + source: crate::knowledge::KnowledgeSource::Background, + state: KnowledgeState::Active, + relationship: RelationshipState::Unknown, + known_attributes: std::collections::BTreeMap::new(), + }); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + kg, + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + )) + .id(); + registry.register(player); + world.insert_resource(registry); + + run_full_pipeline(&mut world); + + let buffer = world.resource::(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + assert_eq!(snapshot.nearby_interactions.len(), 1); + let interaction = &snapshot.nearby_interactions[0]; + let confront = interaction.verbs.iter().find(|v| v.kind == VerbKind::Confront); + assert!(confront.is_none(), "Confront should NOT appear for Suspects confidence"); +} + +#[test] +fn phase2_no_confront_at_mid_range() { + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + // NPC at mid range (distance 4, > CLOSE_RANGE=2) + let npc = world + .spawn(( + crate::npc::Npc, + TilePosition::new(16, 12, 0), + crate::simulation::interaction::Interactable, + )) + .id(); + let npc_sid = registry.register(npc); + + // Player has KnowsDetails + let mut kg = KnowledgeGraph::new(); + kg.observe_entity(npc_sid, TilePosition::new(16, 12, 0), 50); + kg.observe_entity_leaving_los(&npc_sid, 60); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + kg, + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + )) + .id(); + registry.register(player); + world.insert_resource(registry); + + run_full_pipeline(&mut world); + + let buffer = world.resource::(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + assert_eq!(snapshot.nearby_interactions.len(), 1); + let interaction = &snapshot.nearby_interactions[0]; + // Mid range: only ExamineNpc, no Talk, no Confront + let confront = interaction.verbs.iter().find(|v| v.kind == VerbKind::Confront); + assert!(confront.is_none(), "Confront requires close range"); +} + +#[test] +fn phase2_contradiction_marks_interaction() { + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + let npc = world + .spawn(( + crate::npc::Npc, + TilePosition::new(16, 15, 0), + crate::simulation::interaction::Interactable, + )) + .id(); + let npc_sid = registry.register(npc); + + // Player has contradicted knowledge about NPC + let mut kg = KnowledgeGraph::new(); + kg.observe_entity(npc_sid, TilePosition::new(16, 15, 0), 50); + kg.entities.get_mut(&npc_sid).unwrap().state = KnowledgeState::Contradicted; + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + kg, + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + )) + .id(); + registry.register(player); + world.insert_resource(registry); + + run_full_pipeline(&mut world); + + let buffer = world.resource::(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + assert_eq!(snapshot.nearby_interactions.len(), 1); + assert!( + snapshot.nearby_interactions[0].contradicted, + "interaction should be marked contradicted" + ); +} + +#[test] +fn phase2_no_contradiction_for_active_knowledge() { + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + let npc = world + .spawn(( + crate::npc::Npc, + TilePosition::new(16, 15, 0), + crate::simulation::interaction::Interactable, + )) + .id(); + let npc_sid = registry.register(npc); + + // Player has Active (normal) knowledge — no contradiction + let mut kg = KnowledgeGraph::new(); + kg.observe_entity(npc_sid, TilePosition::new(16, 15, 0), 50); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + kg, + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + )) + .id(); + registry.register(player); + world.insert_resource(registry); + + run_full_pipeline(&mut world); + + let buffer = world.resource::(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + assert_eq!(snapshot.nearby_interactions.len(), 1); + assert!( + !snapshot.nearby_interactions[0].contradicted, + "interaction should NOT be contradicted for Active knowledge" + ); +} + +#[test] +fn phase2_smuggler_relabels_container_verbs() { + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + // Container at close range, north of player + let container = world + .spawn(( + TilePosition::new(16, 15, 0), + crate::simulation::interaction::Interactable, + ObjectType::Container, + )) + .id(); + registry.register(container); + + // Smuggler player + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + CharacterArchetype::Smuggler, + )) + .id(); + registry.register(player); + world.insert_resource(registry); + + run_full_pipeline(&mut world); + + let buffer = world.resource::(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + assert_eq!(snapshot.nearby_interactions.len(), 1); + let interaction = &snapshot.nearby_interactions[0]; + // Container at close range: Open→"Move", Search→"Stash", Observe stays "Observe" + let open_verb = interaction.verbs.iter().find(|v| v.kind == VerbKind::Open); + let search_verb = interaction.verbs.iter().find(|v| v.kind == VerbKind::Search); + let observe_verb = interaction.verbs.iter().find(|v| v.kind == VerbKind::Observe); + assert_eq!(open_verb.unwrap().label, "Move", "smuggler Open→Move"); + assert_eq!(search_verb.unwrap().label, "Stash", "smuggler Search→Stash"); + assert_eq!(observe_verb.unwrap().label, "Observe", "Observe unchanged"); +} + +#[test] +fn phase2_detective_relabels_container_verbs() { + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + let container = world + .spawn(( + TilePosition::new(16, 15, 0), + crate::simulation::interaction::Interactable, + ObjectType::Container, + )) + .id(); + registry.register(container); + + // Detective player (explicit) + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + CharacterArchetype::Detective, + )) + .id(); + registry.register(player); + world.insert_resource(registry); + + run_full_pipeline(&mut world); + + let buffer = world.resource::(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + assert_eq!(snapshot.nearby_interactions.len(), 1); + let interaction = &snapshot.nearby_interactions[0]; + let open_verb = interaction.verbs.iter().find(|v| v.kind == VerbKind::Open); + let search_verb = interaction.verbs.iter().find(|v| v.kind == VerbKind::Search); + assert_eq!(open_verb.unwrap().label, "Scan", "detective Open→Scan"); + assert_eq!(search_verb.unwrap().label, "Flag", "detective Search→Flag"); +} + +#[test] +fn phase2_default_archetype_is_detective() { + // When no CharacterArchetype component attached, defaults to Detective + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + let container = world + .spawn(( + TilePosition::new(16, 15, 0), + crate::simulation::interaction::Interactable, + ObjectType::Container, + )) + .id(); + registry.register(container); + + // Player WITHOUT CharacterArchetype component + 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_full_pipeline(&mut world); + + let buffer = world.resource::(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + assert_eq!(snapshot.nearby_interactions.len(), 1); + let interaction = &snapshot.nearby_interactions[0]; + // Default = Detective labels + let open_verb = interaction.verbs.iter().find(|v| v.kind == VerbKind::Open); + assert_eq!(open_verb.unwrap().label, "Scan", "default archetype should use Detective labels"); +} + +#[test] +fn phase2_non_container_keeps_default_labels() { + // Readable objects should keep their default labels regardless of archetype + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + let readable = world + .spawn(( + TilePosition::new(16, 15, 0), + crate::simulation::interaction::Interactable, + ObjectType::Readable, + )) + .id(); + registry.register(readable); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + CharacterArchetype::Smuggler, + )) + .id(); + registry.register(player); + world.insert_resource(registry); + + run_full_pipeline(&mut world); + + let buffer = world.resource::(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + assert_eq!(snapshot.nearby_interactions.len(), 1); + let interaction = &snapshot.nearby_interactions[0]; + let read_verb = interaction.verbs.iter().find(|v| v.kind == VerbKind::Read); + assert_eq!(read_verb.unwrap().label, "Read", "Readable labels unchanged for smuggler"); +} + +#[test] +fn phase2_object_type_carried_through_snapshot() { + // NearbyInteraction.object_type should be populated from Phase 1 + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + let terminal = world + .spawn(( + TilePosition::new(16, 15, 0), + crate::simulation::interaction::Interactable, + ObjectType::Terminal, + )) + .id(); + registry.register(terminal); + + 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_full_pipeline(&mut world); + + let buffer = world.resource::(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + assert_eq!(snapshot.nearby_interactions.len(), 1); + assert_eq!( + snapshot.nearby_interactions[0].object_type, + Some(ObjectType::Terminal), + "object_type should be carried through from Phase 1" + ); +} + +#[test] +fn phase2_npc_object_type_is_none() { + // NPCs should have object_type = None + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + let npc = world + .spawn(( + crate::npc::Npc, + TilePosition::new(16, 15, 0), + crate::simulation::interaction::Interactable, + )) + .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_full_pipeline(&mut world); + + let buffer = world.resource::(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + assert_eq!(snapshot.nearby_interactions.len(), 1); + assert_eq!( + snapshot.nearby_interactions[0].object_type, + None, + "NPC should have object_type=None" + ); +} + +// ----------------------------------------------------------------------- +// Sprint suppression end-to-end (#419 QA, D-055) +// ----------------------------------------------------------------------- + +#[test] +fn sprint_suppresses_interactions_through_full_pipeline() { + use crate::simulation::stance::Stance; + + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + // NPC at close range, directly north in LOS + let npc = world + .spawn(( + crate::npc::Npc, + TilePosition::new(16, 15, 0), + crate::simulation::interaction::Interactable, + )) + .id(); + registry.register(npc); + + // Player in Sprint stance + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + Stance(MovementStance::Sprint), + )) + .id(); + registry.register(player); + world.insert_resource(registry); + + run_full_pipeline(&mut world); + + let buffer = world.resource::(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + // NPC should be VISIBLE (sprint suppresses interpretation, not data per D-055) + let npcs: Vec<_> = snapshot + .entities + .iter() + .filter(|e| matches!(e.kind, EntityKind::Npc)) + .collect(); + assert_eq!(npcs.len(), 1, "NPC should still be visible during sprint"); + // But interactions should be empty + assert!( + snapshot.nearby_interactions.is_empty(), + "sprint should suppress all nearby_interactions in final snapshot" + ); + // Stance should be Sprint in snapshot + assert_eq!(snapshot.player_stance, MovementStance::Sprint); +} + +#[test] +fn phase2_poi_with_confront_verb_order() { + // POI NPC with KnowsDetails: ExamineNpc(1), Talk(2), Confront(3) + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + let npc = world + .spawn(( + crate::npc::Npc, + TilePosition::new(16, 15, 0), + crate::simulation::interaction::Interactable, + )) + .id(); + let npc_sid = registry.register(npc); + + let mut kg = KnowledgeGraph::new(); + kg.observe_entity(npc_sid, TilePosition::new(16, 15, 0), 50); + kg.observe_entity_leaving_los(&npc_sid, 60); + kg.set_relationship(&npc_sid, RelationshipState::PersonOfInterest); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + kg, + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + )) + .id(); + registry.register(player); + world.insert_resource(registry); + + run_full_pipeline(&mut world); + + let buffer = world.resource::(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + assert_eq!(snapshot.nearby_interactions.len(), 1); + let verbs = &snapshot.nearby_interactions[0].verbs; + assert_eq!(verbs.len(), 3, "POI+KnowsDetails: ExamineNpc + Talk + Confront"); + // POI flips ExamineNpc to priority 1, Talk to 2, Confront at 3 + assert_eq!(verbs[0].kind, VerbKind::ExamineNpc); + assert_eq!(verbs[0].priority, 1); + assert_eq!(verbs[1].kind, VerbKind::Talk); + assert_eq!(verbs[1].priority, 2); + assert_eq!(verbs[2].kind, VerbKind::Confront); + assert_eq!(verbs[2].priority, 3); +} + +// ----------------------------------------------------------------------- +// Inventory through observer pipeline (#424 QA, D-065) +// ----------------------------------------------------------------------- + +#[test] +fn carried_item_appears_in_snapshot_inventory() { + // D-065: player_inventory populated via collect_inventory_for through full pipeline + use crate::simulation::inventory::{CarriedBy, InventorySlot, ItemName}; + + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing::default(), + KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + )) + .id(); + let player_sid = registry.register(player); + + // Item carried by player (no TilePosition — in inventory) + let item = world + .spawn(( + CarriedBy(player_sid), + ItemName("Manifest Copy".into()), + InventorySlot(0), + )) + .id(); + registry.register(item); + + world.insert_resource(registry); + + run_observer_pipeline(&mut world); + + let buffer = world.resource::(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + assert_eq!(snapshot.player_inventory.len(), 1, "carried item should appear in snapshot"); + assert_eq!(snapshot.player_inventory[0].name, "Manifest Copy"); + assert_eq!(snapshot.player_inventory[0].slot, 0); +} + +#[test] +fn carried_item_not_in_visible_entities() { + // D-065 info boundary: carried items have no TilePosition, so they + // must NOT appear in the visible entity list (spatial queries skip them). + use crate::simulation::inventory::{CarriedBy, InventorySlot, ItemName}; + + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing::default(), + KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + )) + .id(); + let player_sid = registry.register(player); + + // Item in inventory: has CarriedBy but NO TilePosition + let item = world + .spawn(( + CarriedBy(player_sid), + ItemName("Corridor Access Token".into()), + InventorySlot(1), + )) + .id(); + registry.register(item); + + world.insert_resource(registry); + + run_observer_pipeline(&mut world); + + let buffer = world.resource::(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + // Only the player should be in visible entities — carried item has no TilePosition + assert_eq!( + snapshot.entities.len(), + 1, + "carried item without TilePosition must not appear in visible entities" + ); + assert!(matches!(snapshot.entities[0].kind, EntityKind::Player)); + // But it IS in the inventory + assert_eq!(snapshot.player_inventory.len(), 1); +} + +#[test] +fn multiple_carried_items_sorted_in_snapshot() { + // D-065: 3 v0.1 items, verify sorting by slot through pipeline + use crate::simulation::inventory::{CarriedBy, InventorySlot, ItemName}; + + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing::default(), + KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + )) + .id(); + let player_sid = registry.register(player); + + // Spawn 3 v0.1 items in reverse slot order + for (slot, name) in [(2, "Personal Comm Log"), (0, "Manifest Copy"), (1, "Corridor Access Token")] { + let item = world + .spawn(( + CarriedBy(player_sid), + ItemName(name.into()), + InventorySlot(slot), + )) + .id(); + registry.register(item); + } + + world.insert_resource(registry); + + run_observer_pipeline(&mut world); + + let buffer = world.resource::(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + assert_eq!(snapshot.player_inventory.len(), 3); + assert_eq!(snapshot.player_inventory[0].slot, 0); + assert_eq!(snapshot.player_inventory[0].name, "Manifest Copy"); + assert_eq!(snapshot.player_inventory[1].slot, 1); + assert_eq!(snapshot.player_inventory[1].name, "Corridor Access Token"); + assert_eq!(snapshot.player_inventory[2].slot, 2); + assert_eq!(snapshot.player_inventory[2].name, "Personal Comm Log"); +} + +// ----------------------------------------------------------------------- +// Sprint anomaly detection tests (#428, D-055) +// ----------------------------------------------------------------------- + +#[test] +fn sprint_past_contradicted_npc_queues_anomaly() { + use crate::simulation::monologue::SprintAnomalyQueue; + use crate::simulation::stance::Stance; + + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + // NPC in LOS, directly north + let npc = world + .spawn((crate::npc::Npc, TilePosition::new(16, 14, 0))) + .id(); + let npc_sid = registry.register(npc); + + // Player has Contradicted knowledge about the NPC + let mut kg = KnowledgeGraph::new(); + kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50); + kg.entities.get_mut(&npc_sid).unwrap().state = KnowledgeState::Contradicted; + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + kg, + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + Stance(MovementStance::Sprint), + SprintAnomalyQueue::default(), + )) + .id(); + registry.register(player); + world.insert_resource(registry); + + run_observer_pipeline(&mut world); + + // Anomaly should be queued + let mut query = world.query::<&SprintAnomalyQueue>(); + let queue = query.single(&world).unwrap(); + assert!(queue.has_pending(), "contradicted NPC while sprinting should queue anomaly"); +} + +#[test] +fn walk_past_contradicted_npc_does_not_queue_anomaly() { + use crate::simulation::monologue::SprintAnomalyQueue; + use crate::simulation::stance::Stance; + + 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(); + let npc_sid = registry.register(npc); + + let mut kg = KnowledgeGraph::new(); + kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50); + kg.entities.get_mut(&npc_sid).unwrap().state = KnowledgeState::Contradicted; + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + kg, + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + Stance(MovementStance::Walk), // Walking, not sprinting + SprintAnomalyQueue::default(), + )) + .id(); + registry.register(player); + world.insert_resource(registry); + + run_observer_pipeline(&mut world); + + let mut query = world.query::<&SprintAnomalyQueue>(); + let queue = query.single(&world).unwrap(); + assert!(!queue.has_pending(), "walking past contradicted NPC should NOT queue anomaly"); +} + +#[test] +fn sprint_past_active_npc_does_not_queue_anomaly() { + use crate::simulation::monologue::SprintAnomalyQueue; + use crate::simulation::stance::Stance; + + 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(); + let npc_sid = registry.register(npc); + + // Active knowledge (not contradicted) + let mut kg = KnowledgeGraph::new(); + kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + kg, + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + Stance(MovementStance::Sprint), + SprintAnomalyQueue::default(), + )) + .id(); + registry.register(player); + world.insert_resource(registry); + + run_observer_pipeline(&mut world); + + let mut query = world.query::<&SprintAnomalyQueue>(); + let queue = query.single(&world).unwrap(); + assert!(!queue.has_pending(), "sprint past Active NPC should NOT queue anomaly"); +} + +#[test] +fn sprint_anomaly_not_queued_when_already_pending() { + use crate::simulation::monologue::SprintAnomalyQueue; + use crate::simulation::stance::Stance; + + 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(); + let npc_sid = registry.register(npc); + + let mut kg = KnowledgeGraph::new(); + kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50); + kg.entities.get_mut(&npc_sid).unwrap().state = KnowledgeState::Contradicted; + + // Pre-fill anomaly queue with an existing pending entry + let mut queue = SprintAnomalyQueue::default(); + queue.push_anomaly(999, 0); // Different entity, already pending + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + kg, + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + Stance(MovementStance::Sprint), + queue, + )) + .id(); + registry.register(player); + world.insert_resource(registry); + + run_observer_pipeline(&mut world); + + // Queue should still have the original entry (first-in wins) + let mut query = world.query::<&SprintAnomalyQueue>(); + let queue = query.single(&world).unwrap(); + assert!(queue.has_pending()); + // The original entity_id should be preserved (not overwritten) + // We can't directly inspect the entity_id, but we can verify via take_ready +} + +#[test] +fn sprint_anomaly_without_queue_component_no_crash() { + use crate::simulation::stance::Stance; + + // Player without SprintAnomalyQueue should still work (backward compat) + 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(); + let npc_sid = registry.register(npc); + + let mut kg = KnowledgeGraph::new(); + kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50); + kg.entities.get_mut(&npc_sid).unwrap().state = KnowledgeState::Contradicted; + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + kg, + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + Stance(MovementStance::Sprint), + // No SprintAnomalyQueue — should not crash + )) + .id(); + registry.register(player); + world.insert_resource(registry); + + // Should run without panicking + run_observer_pipeline(&mut world); + + let buffer = world.resource::(); + assert!(buffer.snapshot.is_some(), "snapshot should still be produced"); +} + +#[test] +fn sprint_anomaly_npc_visible_but_interactions_suppressed() { + // D-055: sprint suppresses interpretation, not sensory data. + // The NPC should be visible AND queue an anomaly, but interactions empty. + use crate::simulation::monologue::SprintAnomalyQueue; + use crate::simulation::stance::Stance; + + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + let npc = world + .spawn(( + crate::npc::Npc, + TilePosition::new(16, 15, 0), + crate::simulation::interaction::Interactable, + )) + .id(); + let npc_sid = registry.register(npc); + + let mut kg = KnowledgeGraph::new(); + kg.observe_entity(npc_sid, TilePosition::new(16, 15, 0), 50); + kg.entities.get_mut(&npc_sid).unwrap().state = KnowledgeState::Contradicted; + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + kg, + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + Stance(MovementStance::Sprint), + SprintAnomalyQueue::default(), + )) + .id(); + registry.register(player); + world.insert_resource(registry); + + run_full_pipeline(&mut world); + + let buffer = world.resource::(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + + // NPC should be visible (sprint doesn't suppress visibility) + let npcs: Vec<_> = snapshot + .entities + .iter() + .filter(|e| matches!(e.kind, EntityKind::Npc)) + .collect(); + assert_eq!(npcs.len(), 1, "NPC should be visible during sprint"); + + // Interactions should be empty (sprint suppression) + assert!(snapshot.nearby_interactions.is_empty(), "sprint suppresses interactions"); + + // Anomaly should be queued + let mut query = world.query::<&SprintAnomalyQueue>(); + let queue = query.single(&world).unwrap(); + assert!(queue.has_pending(), "anomaly should be queued despite interaction suppression"); +} + +#[test] +fn sprint_anomaly_multiple_contradicted_npcs_only_first_queued() { + // D-055: first-in wins — only the first Contradicted entity per scan is queued + use crate::simulation::monologue::SprintAnomalyQueue; + use crate::simulation::stance::Stance; + + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + // Two contradicted NPCs in LOS + let npc1 = world + .spawn((crate::npc::Npc, TilePosition::new(16, 14, 0))) + .id(); + let npc1_sid = registry.register(npc1); + + let npc2 = world + .spawn((crate::npc::Npc, TilePosition::new(16, 12, 0))) + .id(); + let npc2_sid = registry.register(npc2); + + // Player has Contradicted knowledge about BOTH NPCs + let mut kg = KnowledgeGraph::new(); + kg.observe_entity(npc1_sid, TilePosition::new(16, 14, 0), 50); + kg.entities.get_mut(&npc1_sid).unwrap().state = KnowledgeState::Contradicted; + kg.observe_entity(npc2_sid, TilePosition::new(16, 12, 0), 50); + kg.entities.get_mut(&npc2_sid).unwrap().state = KnowledgeState::Contradicted; + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + kg, + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + Stance(MovementStance::Sprint), + SprintAnomalyQueue::default(), + )) + .id(); + registry.register(player); + world.insert_resource(registry); + + run_observer_pipeline(&mut world); + + // Exactly one anomaly should be queued (first-in wins, break after first) + let mut query = world.query::<&SprintAnomalyQueue>(); + let queue = query.single(&world).unwrap(); + assert!(queue.has_pending(), "one anomaly should be queued"); +} diff --git a/server/src/simulation/input.rs b/server/src/simulation/input.rs index b0a8cd6cb..b3a9fee05 100644 --- a/server/src/simulation/input.rs +++ b/server/src/simulation/input.rs @@ -819,4 +819,105 @@ mod tests { assert!(world.get::(item).is_some(), "item stays on ground"); assert!(world.get::(item).is_none(), "no CarriedBy when full"); } + + #[test] + fn take_then_place_roundtrip() { + // D-065: full cycle — item on ground → Take → carried → Place → ground again + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(InputQueue::default()); + world.insert_resource(SimulationTime::default()); + world.init_resource::(); + + let player = world + .spawn((PlayerCharacter, TilePosition::new(5, 5, 0))) + .id(); + let player_sid = world.resource_mut::().register(player); + + let item = world + .spawn(( + TilePosition::new(5, 4, 0), + ItemName("Manifest Copy".into()), + )) + .id(); + let item_sid = world.resource_mut::().register(item); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_player_input); + + // Step 1: Take + world.resource_mut::().push(PlayerInput { + tick: 0, + action: PlayerAction::Interact { + target_entity_id: Some(item_sid.0), + verb: Some("Take".into()), + }, + }); + schedule.run(&mut world); + + assert!(world.get::(item).is_none(), "item off ground after Take"); + assert_eq!(world.get::(item).unwrap().0, player_sid); + assert_eq!(world.get::(item).unwrap().0, 0); + + // Step 2: Place + world.resource_mut::().push(PlayerInput { + tick: 1, + action: PlayerAction::Interact { + target_entity_id: Some(item_sid.0), + verb: Some("Place".into()), + }, + }); + world.resource_mut::().tick = 1; + schedule.run(&mut world); + + let pos = world.get::(item).expect("item back on ground after Place"); + assert_eq!(*pos, TilePosition::new(5, 5, 0), "placed at player position"); + assert!(world.get::(item).is_none(), "CarriedBy removed after Place"); + assert!(world.get::(item).is_none(), "InventorySlot removed after Place"); + } + + #[test] + fn take_without_target_id_is_noop() { + // Edge case: Take verb with no target_entity_id should not panic + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(InputQueue::default()); + world.insert_resource(SimulationTime::default()); + world.init_resource::(); + + world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0))); + + world.resource_mut::().push(PlayerInput { + tick: 0, + action: PlayerAction::Interact { + target_entity_id: None, + verb: Some("Take".into()), + }, + }); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_player_input); + schedule.run(&mut world); // should not panic + } + + #[test] + fn place_without_target_id_is_noop() { + // Edge case: Place verb with no target_entity_id should not panic + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(InputQueue::default()); + world.insert_resource(SimulationTime::default()); + world.init_resource::(); + + world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0))); + + world.resource_mut::().push(PlayerInput { + tick: 0, + action: PlayerAction::Interact { + target_entity_id: None, + verb: Some("Place".into()), + }, + }); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_player_input); + schedule.run(&mut world); // should not panic + } } diff --git a/server/src/simulation/movement.rs b/server/src/simulation/movement.rs index 368121cbf..13ba4a7a6 100644 --- a/server/src/simulation/movement.rs +++ b/server/src/simulation/movement.rs @@ -857,4 +857,50 @@ mod tests { "two Fixtures on same tile should collide" ); } + + #[test] + fn all_four_layers_coexist_on_same_tile() { + // D-054: Standing + Prone + Seated + Fixture all share one tile + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(WalkabilityMap::new(10, 10, 1)); + + let target = TilePosition::new(5, 4, 0); + + // Fixture and Prone already at tile + world.spawn((target, TilePresence::Fixture)); + world.spawn((target, TilePresence::Prone)); + + // Standing mover enters + let standing = world + .spawn(( + TilePosition::new(5, 5, 0), + TilePresence::Standing, + MoveIntent { target }, + )) + .id(); + + // Seated mover enters from elsewhere + let seated = world + .spawn(( + TilePosition::new(5, 3, 0), + TilePresence::Seated, + MoveIntent { target }, + )) + .id(); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(validate_movement); + schedule.run(&mut world); + + assert_eq!( + *world.get::(standing).unwrap(), + target, + "Standing should share tile with Fixture + Prone" + ); + assert_eq!( + *world.get::(seated).unwrap(), + target, + "Seated should share tile with Fixture + Prone + Standing" + ); + } } diff --git a/server/tests/serialization.rs b/server/tests/serialization.rs index 70acfe4c7..d392f17cb 100644 --- a/server/tests/serialization.rs +++ b/server/tests/serialization.rs @@ -506,3 +506,96 @@ fn all_object_type_variants_roundtrip() { assert_eq!(decoded, obj_type, "ObjectType::{:?} roundtrip failed", obj_type); } } + +/// VerbKind::Confront (Phase 2, #422) must survive MessagePack round-trip. +/// Guards against Confront being omitted from serde mapping. +#[test] +fn verb_kind_confront_roundtrip() { + let mut snapshot = test_snapshot(0, vec![]); + snapshot.nearby_interactions = vec![NearbyInteraction { + entity_id: 1, + entity_type: EntityKind::Npc, + distance: 1, + verbs: vec![VerbOption { + kind: VerbKind::Confront, + label: "Confront".into(), + priority: 3, + available: true, + }], + object_type: None, + contradicted: false, + }]; + + let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); + let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize"); + + assert_eq!(decoded.nearby_interactions.len(), 1); + assert_eq!(decoded.nearby_interactions[0].verbs[0].kind, VerbKind::Confront); + assert_eq!(decoded.nearby_interactions[0].verbs[0].label, "Confront"); +} + +/// CharacterArchetype enum round-trips through MessagePack (#422). +/// Used in Phase 2 label relabeling — must survive the wire. +#[test] +fn all_character_archetype_variants_roundtrip() { + let archetypes = [ + CharacterArchetype::Smuggler, + CharacterArchetype::Detective, + ]; + + for archetype in archetypes { + let bytes = rmp_serde::to_vec_named(&archetype).expect("serialize"); + let decoded: CharacterArchetype = rmp_serde::from_slice(&bytes).expect("deserialize"); + assert_eq!(decoded, archetype, "CharacterArchetype::{:?} roundtrip failed", archetype); + } +} + +/// NearbyInteraction.contradicted=true round-trips through MessagePack (#422). +/// Guards the contradiction flag survives serialization. +#[test] +fn nearby_interaction_contradicted_roundtrip() { + let mut snapshot = test_snapshot(0, vec![]); + snapshot.nearby_interactions = vec![NearbyInteraction { + entity_id: 1, + entity_type: EntityKind::Npc, + distance: 1, + verbs: vec![VerbOption { + kind: VerbKind::Talk, + label: "Talk".into(), + priority: 1, + available: true, + }], + object_type: None, + contradicted: true, + }]; + + let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); + let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize"); + + assert!(decoded.nearby_interactions[0].contradicted, "contradicted flag should survive roundtrip"); +} + +/// NearbyInteraction.object_type round-trips through MessagePack (#422). +/// Verifies object_type=Some(Container) survives the wire. +#[test] +fn nearby_interaction_object_type_roundtrip() { + let mut snapshot = test_snapshot(0, vec![]); + snapshot.nearby_interactions = vec![NearbyInteraction { + entity_id: 1, + entity_type: EntityKind::Object, + distance: 1, + verbs: vec![VerbOption { + kind: VerbKind::Open, + label: "Open".into(), + priority: 1, + available: true, + }], + object_type: Some(ObjectType::Container), + contradicted: false, + }]; + + let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); + let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize"); + + assert_eq!(decoded.nearby_interactions[0].object_type, Some(ObjectType::Container)); +} From aadb155968daad2c1713aeccbe1c0b24a1fc1994 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 14 Feb 2026 16:01:08 +0100 Subject: [PATCH 8/8] chore(meta): update changelog Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 556152d34..a8fa03448 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,9 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). - CharacterArchetype component — Smuggler/Detective archetype for Phase 2 verb label differentiation (D-057) - VerbKind::Confront — Phase 2 only verb injected when observer has KnowsDetails+ on an NPC at close range - Smuggler inventory system (#424) — CarriedBy(StableId) component, Take/Place verbs, 9-slot (3x3 grid) capacity, auto-slot assignment, info boundary enforcement (carried items invisible to other observers) +- MovementProfile component (#418) — per-archetype default stance (smuggler=Walk, detective=Walk), applied on spawn, factory methods for future archetypes +- Sprint interaction buffer suppression (#419, D-055) — sprint stance explicitly clears interaction buffer, no verbs computed or sent during sprint, anomaly monologue pipeline unaffected +- Sprint anomaly double-take monologue (#428, D-055) — SprintAnomalyQueue component detects Contradicted entities during sprint, fires delayed retroactive monologue after ~1.5s ("Wait — something wasn't right back there"), first-in-wins queue semantics, 3 hardcoded v0.1 lines ### Changed - Protocol version bumped from 5 to 6 (stance, inventory, ObjectType, verb system fields) @@ -22,6 +25,9 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). - Input processing queries expanded for stance and cooldown components with backward-compatible Option wrapping - Observer pipeline queries expanded for Stance and CharacterArchetype components - NearbyInteraction carries object_type and contradicted fields for Phase 2 context +- BridgePlugin system ordering: process_sprint_anomaly_monologue runs after trigger_monologue, compute_observer_snapshot runs after anomaly processing +- Player spawn includes MovementProfile, Stance, PlayerMoveCooldown, and SprintAnomalyQueue components +- 331 tests total (131 new) — comprehensive QA coverage across stance, occupancy, Phase 2 verbs, sprint suppression, inventory, anomaly monologue, and wire format ### Added - D-066: Dual-scale grid — 0.5m simulation tiles for stealth granularity, 1m visual tiles for proportional art (2x retina factor). All world geometry 2x2 sim tile minimum so cover/LOS maps 1:1 with visuals. Amends OQ-01.