feat(simulation): Sprint 6 Touch — stance, tile presence, verbs, protocol v6

Implements the core Sprint 6: Touch systems across 5 tickets:

- #449 ObserverSnapshot v6: add player_stance (MovementStance) and
  player_inventory (Vec<InventoryItem>) 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 <noreply@anthropic.com>
This commit is contained in:
2026-02-14 15:38:59 +01:00
co-authored by Claude Opus 4.6
parent 651b1d34a6
commit 98f4cedc03
18 changed files with 1825 additions and 95 deletions
+173 -4
View File
@@ -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<InventoryItem>,
/// All entities visible to the observer (filtered by LOS + vision cone)
pub entities: Vec<VisibleEntity>,
/// 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<VerbOption>,
/// 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<ObjectType>,
/// 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).
+106 -16
View File
@@ -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<VisibilityGeometry>,
registry: Res<EntityRegistry>,
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<PlayerCharacter>,
>,
all_entities: Query<(
@@ -61,7 +63,7 @@ pub fn compute_observer_snapshot(
)>,
mut buffer: ResMut<SnapshotBuffer>,
) {
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, &registry, 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<ObjectType>,
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,
}
}
+53 -1
View File
@@ -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::<SnapshotBuffer>();
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::<SnapshotBuffer>();
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::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
assert_eq!(
snapshot.version,
crate::bridge::types::PROTOCOL_VERSION,
"snapshot version must match PROTOCOL_VERSION constant"
);
}
+259 -13
View File
@@ -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<InputQueue>,
mut time: ResMut<SimulationTime>,
mut commands: Commands,
player_query: Query<(Entity, &TilePosition), With<PlayerCharacter>>,
mut player_query: Query<
(Entity, &TilePosition, Option<&mut Stance>, Option<&mut PlayerMoveCooldown>),
With<PlayerCharacter>,
>,
) {
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<PlayerCharacter>>,
player_query: &mut Query<
(Entity, &TilePosition, Option<&mut Stance>, Option<&mut PlayerMoveCooldown>),
With<PlayerCharacter>,
>,
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::<MoveIntent>(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::<InputQueue>().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::<InputQueue>().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::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::MoveNorth,
});
schedule.run(&mut world);
assert!(world.get::<MoveIntent>(player).is_some(), "first move should succeed");
// Remove MoveIntent (simulating validate_movement consuming it)
world.entity_mut(player).remove::<MoveIntent>();
// Tick 0 again: move north — should be throttled (cooldown)
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::MoveNorth,
});
schedule.run(&mut world);
assert!(world.get::<MoveIntent>(player).is_none(), "second move should be throttled");
// Tick 0 again: move north — should succeed (cooldown elapsed)
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::MoveNorth,
});
schedule.run(&mut world);
assert!(world.get::<MoveIntent>(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::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::MoveNorth,
});
schedule.run(&mut world);
assert!(world.get::<MoveIntent>(player).is_some());
world.entity_mut(player).remove::<MoveIntent>();
// Second move — sprint allows every tick
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::MoveNorth,
});
schedule.run(&mut world);
assert!(world.get::<MoveIntent>(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::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::MoveNorth,
});
schedule.run(&mut world);
assert!(world.get::<MoveIntent>(player).is_some());
world.entity_mut(player).remove::<MoveIntent>();
// Second move immediately — no throttle without components
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::MoveNorth,
});
schedule.run(&mut world);
assert!(world.get::<MoveIntent>(player).is_some());
}
}
+547 -39
View File
@@ -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<EntityRegistry>,
interactables: Query<
(Entity, &TilePosition, Option<&Npc>),
(Entity, &TilePosition, Option<&Npc>, Option<&ObjectType>),
(With<Interactable>, Without<PlayerCharacter>),
>,
) {
@@ -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()
);
}
}
}
+1
View File
@@ -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;
+307 -20
View File
@@ -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<Res<WalkabilityMap>>,
mut movers: Query<(Entity, &MoveIntent, &mut TilePosition)>,
stationary: Query<(Entity, &TilePosition), Without<MoveIntent>>,
mut movers: Query<(Entity, &MoveIntent, &mut TilePosition, Option<&TilePresence>)>,
stationary: Query<(Entity, &TilePosition, Option<&TilePresence>), Without<MoveIntent>>,
) {
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::<MoveIntent>();
}
return;
};
// Collect tiles occupied by stationary entities (no MoveIntent)
let mut occupied: HashMap<TilePosition, Entity> = 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::<MoveIntent>();
}
@@ -570,4 +603,258 @@ mod tests {
);
assert!(world.get::<MoveIntent>(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::<TilePosition>(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::<TilePosition>(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::<TilePosition>(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::<TilePosition>(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::<TilePosition>(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::<TilePosition>(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::<TilePosition>(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::<TilePosition>(mover).unwrap(),
TilePosition::new(5, 5, 0),
"two Fixtures on same tile should collide"
);
}
}
+162
View File
@@ -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
}
}
+2
View File
@@ -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,
+2
View File
@@ -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,
+1 -1
View File
@@ -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);
+4
View File
@@ -27,6 +27,8 @@ fn fixture_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> 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,
+208 -1
View File
@@ -16,6 +16,8 @@ fn test_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> 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<VisibleEntity>,
visible_tiles: Vec<VisibleTile>,
nearby_interactions: Vec<NearbyInteraction>,
current_monologue: Option<MonologueEvent>,
}
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<InventoryItem> = (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);
}
}