diff --git a/client/tests/fixtures/msgpack/snapshot_empty.msgpack b/client/tests/fixtures/msgpack/snapshot_empty.msgpack index 3868af268..d76779755 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_empty.msgpack and b/client/tests/fixtures/msgpack/snapshot_empty.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack b/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack index 51037146a..94055d595 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack and b/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack b/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack index 708014458..698b97852 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack and b/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_player.msgpack b/client/tests/fixtures/msgpack/snapshot_player.msgpack index e759cbf5f..a3be5bea6 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_player.msgpack and b/client/tests/fixtures/msgpack/snapshot_player.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack b/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack index cb6219705..2b830526b 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack and b/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack differ 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); + } +}