Merge remote-tracking branch 'origin/server'

This commit is contained in:
2026-02-14 16:09:18 +01:00
23 changed files with 4406 additions and 108 deletions
+23
View File
@@ -6,6 +6,29 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
## [Unreleased]
### Added
- ObserverSnapshot v6 wire protocol (#449) — player_stance (MovementStance) and player_inventory (Vec\<InventoryItem\>) fields with serde defaults for backward compatibility
- Stance system (#417) — Sprint/Walk/Careful/Crouch movement stance with tick-based speed (1/2/3/4 ticks per move), monologue rate multipliers (40%/100%/150%/100%), PlayerMoveCooldown component, ToggleStanceUp/Down player actions
- TilePresence posture layers (#420) — Standing/Prone/Seated/Fixture occupancy layers enabling same-tile coexistence (e.g. seated NPC + standing player), layer-based collision in validate_movement
- ObjectType component (#421) — Readable/Container/Terminal/Door/Pickup/Furniture types with Phase 1 verb sets computed from type + proximity range
- Phase 2 verb filter (#422) — KG-gated observer-side verb processing: POI priority flips (D-060), Confront injection at KnowsDetails+ confidence, contradiction marking, archetype-specific label relabeling (Smuggler sees Move/Stash, Detective sees Scan/Flag on containers)
- CharacterArchetype component — Smuggler/Detective archetype for Phase 2 verb label differentiation (D-057)
- VerbKind::Confront — Phase 2 only verb injected when observer has KnowsDetails+ on an NPC at close range
- Smuggler inventory system (#424) — CarriedBy(StableId) component, Take/Place verbs, 9-slot (3x3 grid) capacity, auto-slot assignment, info boundary enforcement (carried items invisible to other observers)
- MovementProfile component (#418) — per-archetype default stance (smuggler=Walk, detective=Walk), applied on spawn, factory methods for future archetypes
- Sprint interaction buffer suppression (#419, D-055) — sprint stance explicitly clears interaction buffer, no verbs computed or sent during sprint, anomaly monologue pipeline unaffected
- Sprint anomaly double-take monologue (#428, D-055) — SprintAnomalyQueue component detects Contradicted entities during sprint, fires delayed retroactive monologue after ~1.5s ("Wait — something wasn't right back there"), first-in-wins queue semantics, 3 hardcoded v0.1 lines
### Changed
- Protocol version bumped from 5 to 6 (stance, inventory, ObjectType, verb system fields)
- MessagePack fixtures regenerated for protocol v6
- Input processing queries expanded for stance and cooldown components with backward-compatible Option wrapping
- Observer pipeline queries expanded for Stance and CharacterArchetype components
- NearbyInteraction carries object_type and contradicted fields for Phase 2 context
- BridgePlugin system ordering: process_sprint_anomaly_monologue runs after trigger_monologue, compute_observer_snapshot runs after anomaly processing
- Player spawn includes MovementProfile, Stance, PlayerMoveCooldown, and SprintAnomalyQueue components
- 331 tests total (131 new) — comprehensive QA coverage across stance, occupancy, Phase 2 verbs, sprint suppression, inventory, anomaly monologue, and wire format
### Added
- D-066: Dual-scale grid — 0.5m simulation tiles for stealth granularity, 1m visual tiles for proportional art (2x retina factor). All world geometry 2x2 sim tile minimum so cover/LOS maps 1:1 with visuals. Amends OQ-01.
- Sprint CLI (`db/connectors/sprint`) — unified sprint lifecycle management with 5 subcommands: status, start, stop, start-work, prepare. Auto-detects sprint from DB state and team from git branch. Guards prevent activating unplanned sprints.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+3 -1
View File
@@ -161,10 +161,12 @@ impl Plugin for BridgePlugin {
.after(crate::simulation::movement::validate_movement),
crate::simulation::monologue::trigger_monologue
.after(crate::simulation::movement::validate_movement),
crate::simulation::monologue::process_sprint_anomaly_monologue
.after(crate::simulation::monologue::trigger_monologue),
crate::perception::observer::compute_observer_snapshot
.after(crate::perception::observer::compute_visibility_geometry)
.after(crate::simulation::interaction::compute_nearby_interactions)
.after(crate::simulation::monologue::trigger_monologue)
.after(crate::simulation::monologue::process_sprint_anomaly_monologue)
.before(crate::simulation::time::advance_tick),
crate::perception::observation::emit_observation_events
.after(crate::perception::observer::compute_observer_snapshot),
+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).
+8 -2
View File
@@ -15,9 +15,10 @@ use settled_reach_server::npc::{
};
use settled_reach_server::perception::vision_cone::Facing;
use settled_reach_server::simulation::interaction::{Interactable, NearbyInteractionBuffer};
use settled_reach_server::simulation::monologue::{MonologueBuffer, MonologueState};
use settled_reach_server::simulation::monologue::{MonologueBuffer, MonologueState, SprintAnomalyQueue};
use settled_reach_server::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
use settled_reach_server::simulation::path_follow::MovementSpeed;
use settled_reach_server::simulation::stance::{MovementProfile, PlayerMoveCooldown};
use settled_reach_server::simulation::time::DayPhase;
use settled_reach_server::simulation::SimulationPlugin;
@@ -63,7 +64,8 @@ fn main() {
let mut registry = EntityRegistry::new(0);
// Player at (16,16)
// Player at (16,16) — smuggler archetype (#418, D-053)
let profile = MovementProfile::smuggler();
let player = app
.world_mut()
.spawn((
@@ -74,6 +76,10 @@ fn main() {
NearbyInteractionBuffer::default(),
MonologueState::default(),
MonologueBuffer::default(),
SprintAnomalyQueue::default(),
profile,
profile.initial_stance(),
PlayerMoveCooldown::default(),
))
.id();
registry.register(player);
+140 -17
View File
@@ -10,12 +10,15 @@ 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::inventory::{CarriedBy, InventorySlot, ItemName};
use crate::simulation::monologue::{MonologueBuffer, SprintAnomalyQueue};
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 +53,7 @@ pub fn compute_observer_snapshot(
geometry: Res<VisibilityGeometry>,
registry: Res<EntityRegistry>,
mut observer_query: Query<
(&TilePosition, Option<&Facing>, &KnowledgeGraph, &mut NearbyInteractionBuffer, &mut MonologueBuffer),
(Entity, &TilePosition, Option<&Facing>, &KnowledgeGraph, &mut NearbyInteractionBuffer, &mut MonologueBuffer, Option<&Stance>, Option<&CharacterArchetype>, Option<&mut SprintAnomalyQueue>),
With<PlayerCharacter>,
>,
all_entities: Query<(
@@ -59,9 +62,10 @@ pub fn compute_observer_snapshot(
Option<&PlayerCharacter>,
Option<&crate::npc::Npc>,
)>,
inventory_items: Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>,
mut buffer: ResMut<SnapshotBuffer>,
) {
let Ok((_observer_pos, facing_opt, observer_kg, mut interaction_buffer, mut monologue_buffer)) =
let Ok((observer_entity, _observer_pos, facing_opt, observer_kg, mut interaction_buffer, mut monologue_buffer, stance_opt, archetype_opt, mut anomaly_queue_opt)) =
observer_query.single_mut()
else {
return;
@@ -71,6 +75,20 @@ pub fn compute_observer_snapshot(
.map(|f| f.0)
.unwrap_or(FacingDirection::default());
let archetype = archetype_opt.copied().unwrap_or_default();
// Collect player inventory (D-065 info boundary: only own items)
let player_inventory = registry
.to_stable(observer_entity)
.map(|player_sid| {
crate::simulation::inventory::collect_inventory_for(
player_sid,
&inventory_items,
&registry,
)
})
.unwrap_or_default();
let (mut entities, visible_ids) =
filter_visible_entities(&geometry, &registry, observer_kg, &all_entities);
@@ -83,6 +101,25 @@ pub fn compute_observer_snapshot(
&mut entities,
);
// Sprint anomaly detection (#428, D-055)
// When sprinting, scan visible entities for Contradicted KG state.
// Queue the first match for delayed "double-take" monologue.
if stance_opt.map(|s| s.0) == Some(MovementStance::Sprint) {
if let Some(anomaly_queue) = anomaly_queue_opt.as_mut() {
if !anomaly_queue.has_pending() {
for &wire_id in &visible_ids {
let stable_id = StableId(wire_id);
if let Some(knowledge) = observer_kg.entity_knowledge(&stable_id) {
if knowledge.state == KnowledgeState::Contradicted {
anomaly_queue.push_anomaly(wire_id, time.tick);
break; // First-in wins
}
}
}
}
}
}
let game_time = GameTime {
day: time.day(),
time_of_day: time.time_of_day_minutes(),
@@ -90,9 +127,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 +146,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,
entities,
visible_tiles: geometry.visible_tiles.clone(),
nearby_interactions,
@@ -250,26 +289,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,
}
}
File diff suppressed because it is too large Load Diff
+678 -19
View File
@@ -1,10 +1,15 @@
// 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::knowledge::{EntityRegistry, StableId};
use crate::perception::vision_cone::{facing_from_delta, Facing};
use crate::simulation::inventory::{
find_next_slot, occupied_slots_for, CarriedBy, InventorySlot, ItemName, MAX_INVENTORY_SLOTS,
};
use crate::simulation::movement::{MoveIntent, PlayerCharacter, TilePosition};
use crate::simulation::stance::{PlayerMoveCooldown, Stance};
use crate::simulation::time::{SimulationTime, TickRate};
use bevy_ecs::prelude::*;
use std::collections::VecDeque;
@@ -55,25 +60,77 @@ impl InputQueue {
}
/// Drains InputQueue for the current tick, converts PlayerActions to ECS components.
/// Handles stance toggling (D-053), movement cooldown, and Take/Place verbs (#424).
#[allow(clippy::type_complexity)]
pub fn process_player_input(
mut input_queue: ResMut<InputQueue>,
mut time: ResMut<SimulationTime>,
mut commands: Commands,
player_query: Query<(Entity, &TilePosition), With<PlayerCharacter>>,
registry: Res<EntityRegistry>,
mut player_query: Query<
(Entity, &TilePosition, Option<&mut Stance>, Option<&mut PlayerMoveCooldown>),
With<PlayerCharacter>,
>,
inventory_items: Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>,
) {
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");
@@ -86,29 +143,76 @@ pub fn process_player_input(
time.tick_rate = rate;
tracing::debug!("Tick rate set to {:?} by player input", rate);
}
PlayerAction::Interact { target_entity_id, verb } => {
tracing::info!(
"Interact: target={:?}, verb={:?} — logged only, dialogue dispatch future scope (#415)",
target_entity_id,
verb,
);
PlayerAction::Interact { target_entity_id, ref verb } => {
match verb.as_deref() {
Some("Take") => {
handle_take(
&mut commands,
&registry,
&player_query,
&inventory_items,
target_entity_id,
);
}
Some("Place") => {
handle_place(
&mut commands,
&registry,
&player_query,
target_entity_id,
);
}
_ => {
tracing::info!(
"Interact: target={:?}, verb={:?} — logged only, dialogue dispatch future scope (#415)",
target_entity_id,
verb,
);
}
}
}
PlayerAction::UsePerceptionMode(ref mode) => {
tracing::trace!("UsePerceptionMode({}) — no-op for Sprint 1", mode);
}
}
}
// 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),
});
@@ -118,6 +222,105 @@ fn apply_move(
.insert(Facing(facing_from_delta(dx, dy)));
}
/// Handle Take verb: pick up an item entity and add it to the player's inventory.
/// Removes TilePosition (item is no longer on the ground — info boundary enforcement),
/// adds CarriedBy + InventorySlot components.
#[allow(clippy::type_complexity)]
fn handle_take(
commands: &mut Commands,
registry: &EntityRegistry,
player_query: &Query<
(Entity, &TilePosition, Option<&mut Stance>, Option<&mut PlayerMoveCooldown>),
With<PlayerCharacter>,
>,
inventory_items: &Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>,
target_entity_id: Option<u64>,
) {
let Some(target_id) = target_entity_id else {
tracing::warn!("Take verb without target_entity_id");
return;
};
let Ok((player_entity, _, _, _)) = player_query.single() else {
return;
};
let Some(player_sid) = registry.to_stable(player_entity) else {
tracing::error!("Player entity not in EntityRegistry");
return;
};
// Resolve wire ID to ECS entity
let target_stable = StableId(target_id);
let Some(target_entity) = registry.to_entity(&target_stable) else {
tracing::warn!(target_id, "Take: target entity not in registry");
return;
};
// Check inventory capacity
let occupied = occupied_slots_for(player_sid, inventory_items);
let Some(slot) = find_next_slot(&occupied) else {
tracing::info!("Inventory full ({} slots), cannot take item", MAX_INVENTORY_SLOTS);
return;
};
// Remove TilePosition (item leaves the ground), add CarriedBy + InventorySlot
commands.entity(target_entity)
.remove::<TilePosition>()
.insert((CarriedBy(player_sid), InventorySlot(slot)));
tracing::info!(
target_id,
slot,
"Take: item picked up and added to inventory slot",
);
}
/// Handle Place verb: remove an item from inventory and place it on the ground
/// at the player's current position. Removes CarriedBy + InventorySlot, adds
/// TilePosition at the player's current tile.
#[allow(clippy::type_complexity)]
fn handle_place(
commands: &mut Commands,
registry: &EntityRegistry,
player_query: &Query<
(Entity, &TilePosition, Option<&mut Stance>, Option<&mut PlayerMoveCooldown>),
With<PlayerCharacter>,
>,
target_entity_id: Option<u64>,
) {
let Some(target_id) = target_entity_id else {
tracing::warn!("Place verb without target_entity_id");
return;
};
let Ok((_, player_pos, _, _)) = player_query.single() else {
return;
};
let target_stable = StableId(target_id);
let Some(target_entity) = registry.to_entity(&target_stable) else {
tracing::warn!(target_id, "Place: target entity not in registry");
return;
};
let place_pos = *player_pos;
// Remove inventory components, place item at player's tile
commands.entity(target_entity)
.remove::<CarriedBy>()
.remove::<InventorySlot>()
.insert(place_pos);
tracing::info!(
target_id,
x = place_pos.x,
y = place_pos.y,
z = place_pos.z,
"Place: item dropped at player position",
);
}
#[cfg(test)]
mod tests {
use super::*;
@@ -168,6 +371,7 @@ mod tests {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(InputQueue::default());
world.insert_resource(SimulationTime::default());
world.init_resource::<crate::knowledge::EntityRegistry>();
let player = world
.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)))
@@ -191,6 +395,7 @@ mod tests {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(InputQueue::default());
world.insert_resource(SimulationTime::default());
world.init_resource::<crate::knowledge::EntityRegistry>();
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
@@ -209,6 +414,7 @@ mod tests {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(InputQueue::default());
world.insert_resource(SimulationTime::default());
world.init_resource::<crate::knowledge::EntityRegistry>();
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
@@ -228,6 +434,7 @@ mod tests {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(InputQueue::default());
world.insert_resource(SimulationTime::default());
world.init_resource::<crate::knowledge::EntityRegistry>();
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
@@ -244,6 +451,7 @@ mod tests {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(InputQueue::default());
world.insert_resource(SimulationTime::default());
world.init_resource::<crate::knowledge::EntityRegistry>();
let player = world
.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)))
@@ -261,4 +469,455 @@ 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.init_resource::<crate::knowledge::EntityRegistry>();
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.init_resource::<crate::knowledge::EntityRegistry>();
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());
world.init_resource::<crate::knowledge::EntityRegistry>();
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());
world.init_resource::<crate::knowledge::EntityRegistry>();
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());
world.init_resource::<crate::knowledge::EntityRegistry>();
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());
}
#[test]
fn take_verb_picks_up_item() {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(InputQueue::default());
world.insert_resource(SimulationTime::default());
world.init_resource::<crate::knowledge::EntityRegistry>();
// Spawn player and register
let player = world
.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)))
.id();
let player_sid = world.resource_mut::<crate::knowledge::EntityRegistry>().register(player);
// Spawn item near player
let item = world
.spawn((
TilePosition::new(5, 4, 0),
ItemName("Manifest Copy".into()),
))
.id();
let item_sid = world.resource_mut::<crate::knowledge::EntityRegistry>().register(item);
// Issue Take verb
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::Interact {
target_entity_id: Some(item_sid.0),
verb: Some("Take".into()),
},
});
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_player_input);
schedule.run(&mut world);
// Item should have CarriedBy + InventorySlot, no TilePosition
assert!(world.get::<TilePosition>(item).is_none(), "item should leave the ground");
let carried = world.get::<CarriedBy>(item).expect("item should have CarriedBy");
assert_eq!(carried.0, player_sid);
let slot = world.get::<InventorySlot>(item).expect("item should have slot");
assert_eq!(slot.0, 0, "first item goes to slot 0");
}
#[test]
fn place_verb_drops_item() {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(InputQueue::default());
world.insert_resource(SimulationTime::default());
world.init_resource::<crate::knowledge::EntityRegistry>();
let player = world
.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)))
.id();
let player_sid = world.resource_mut::<crate::knowledge::EntityRegistry>().register(player);
// Spawn item already in inventory (no TilePosition)
let item = world
.spawn((
CarriedBy(player_sid),
ItemName("Manifest Copy".into()),
InventorySlot(0),
))
.id();
let item_sid = world.resource_mut::<crate::knowledge::EntityRegistry>().register(item);
// Issue Place verb
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::Interact {
target_entity_id: Some(item_sid.0),
verb: Some("Place".into()),
},
});
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_player_input);
schedule.run(&mut world);
// Item should have TilePosition at player's location, no CarriedBy/InventorySlot
let pos = world.get::<TilePosition>(item).expect("item should be on ground");
assert_eq!(*pos, TilePosition::new(5, 5, 0), "placed at player position");
assert!(world.get::<CarriedBy>(item).is_none(), "CarriedBy removed");
assert!(world.get::<InventorySlot>(item).is_none(), "InventorySlot removed");
}
#[test]
fn take_verb_assigns_sequential_slots() {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(InputQueue::default());
world.insert_resource(SimulationTime::default());
world.init_resource::<crate::knowledge::EntityRegistry>();
let player = world
.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)))
.id();
let player_sid = world.resource_mut::<crate::knowledge::EntityRegistry>().register(player);
// Item already in slot 0
world.spawn((
CarriedBy(player_sid),
ItemName("Manifest".into()),
InventorySlot(0),
));
// New item on the ground
let item2 = world
.spawn((
TilePosition::new(5, 4, 0),
ItemName("Token".into()),
))
.id();
let item2_sid = world.resource_mut::<crate::knowledge::EntityRegistry>().register(item2);
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::Interact {
target_entity_id: Some(item2_sid.0),
verb: Some("Take".into()),
},
});
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_player_input);
schedule.run(&mut world);
let slot = world.get::<InventorySlot>(item2).expect("item should have slot");
assert_eq!(slot.0, 1, "second item goes to slot 1");
}
#[test]
fn take_verb_full_inventory_rejected() {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(InputQueue::default());
world.insert_resource(SimulationTime::default());
world.init_resource::<crate::knowledge::EntityRegistry>();
let player = world
.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)))
.id();
let player_sid = world.resource_mut::<crate::knowledge::EntityRegistry>().register(player);
// Fill all 9 slots
for slot in 0..MAX_INVENTORY_SLOTS {
world.spawn((
CarriedBy(player_sid),
ItemName(format!("Item {}", slot)),
InventorySlot(slot),
));
}
// Try to take another item
let item = world
.spawn((
TilePosition::new(5, 4, 0),
ItemName("Overflow".into()),
))
.id();
let item_sid = world.resource_mut::<crate::knowledge::EntityRegistry>().register(item);
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::Interact {
target_entity_id: Some(item_sid.0),
verb: Some("Take".into()),
},
});
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_player_input);
schedule.run(&mut world);
// Item should still be on the ground
assert!(world.get::<TilePosition>(item).is_some(), "item stays on ground");
assert!(world.get::<CarriedBy>(item).is_none(), "no CarriedBy when full");
}
#[test]
fn take_then_place_roundtrip() {
// D-065: full cycle — item on ground → Take → carried → Place → ground again
let mut world = bevy_ecs::world::World::new();
world.insert_resource(InputQueue::default());
world.insert_resource(SimulationTime::default());
world.init_resource::<crate::knowledge::EntityRegistry>();
let player = world
.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)))
.id();
let player_sid = world.resource_mut::<crate::knowledge::EntityRegistry>().register(player);
let item = world
.spawn((
TilePosition::new(5, 4, 0),
ItemName("Manifest Copy".into()),
))
.id();
let item_sid = world.resource_mut::<crate::knowledge::EntityRegistry>().register(item);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_player_input);
// Step 1: Take
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::Interact {
target_entity_id: Some(item_sid.0),
verb: Some("Take".into()),
},
});
schedule.run(&mut world);
assert!(world.get::<TilePosition>(item).is_none(), "item off ground after Take");
assert_eq!(world.get::<CarriedBy>(item).unwrap().0, player_sid);
assert_eq!(world.get::<InventorySlot>(item).unwrap().0, 0);
// Step 2: Place
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 1,
action: PlayerAction::Interact {
target_entity_id: Some(item_sid.0),
verb: Some("Place".into()),
},
});
world.resource_mut::<SimulationTime>().tick = 1;
schedule.run(&mut world);
let pos = world.get::<TilePosition>(item).expect("item back on ground after Place");
assert_eq!(*pos, TilePosition::new(5, 5, 0), "placed at player position");
assert!(world.get::<CarriedBy>(item).is_none(), "CarriedBy removed after Place");
assert!(world.get::<InventorySlot>(item).is_none(), "InventorySlot removed after Place");
}
#[test]
fn take_without_target_id_is_noop() {
// Edge case: Take verb with no target_entity_id should not panic
let mut world = bevy_ecs::world::World::new();
world.insert_resource(InputQueue::default());
world.insert_resource(SimulationTime::default());
world.init_resource::<crate::knowledge::EntityRegistry>();
world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)));
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::Interact {
target_entity_id: None,
verb: Some("Take".into()),
},
});
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_player_input);
schedule.run(&mut world); // should not panic
}
#[test]
fn place_without_target_id_is_noop() {
// Edge case: Place verb with no target_entity_id should not panic
let mut world = bevy_ecs::world::World::new();
world.insert_resource(InputQueue::default());
world.insert_resource(SimulationTime::default());
world.init_resource::<crate::knowledge::EntityRegistry>();
world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)));
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::Interact {
target_entity_id: None,
verb: Some("Place".into()),
},
});
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_player_input);
schedule.run(&mut world); // should not panic
}
}
+675 -42
View File
@@ -1,17 +1,23 @@
// 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::*;
use crate::bridge::types::{EntityKind, NearbyInteraction, VerbKind, VerbOption};
// Re-export ObjectType for backward compatibility — definition moved to bridge::types (#422).
pub use crate::bridge::types::ObjectType;
use crate::bridge::types::{EntityKind, MovementStance, NearbyInteraction, VerbKind, VerbOption};
use crate::knowledge::EntityRegistry;
use crate::npc::Npc;
use crate::simulation::movement::{PlayerCharacter, TilePosition};
use crate::simulation::stance::Stance;
/// Interaction range thresholds (Manhattan distance, same z-level)
pub(crate) const CLOSE_RANGE: u32 = 2;
@@ -22,32 +28,97 @@ 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)]
pub fn compute_nearby_interactions(
mut player_query: Query<
(&TilePosition, &mut NearbyInteractionBuffer),
(&TilePosition, &mut NearbyInteractionBuffer, Option<&Stance>),
With<PlayerCharacter>,
>,
registry: Res<EntityRegistry>,
interactables: Query<
(Entity, &TilePosition, Option<&Npc>),
(Entity, &TilePosition, Option<&Npc>, Option<&ObjectType>),
(With<Interactable>, Without<PlayerCharacter>),
>,
) {
let Ok((player_pos, mut buffer)) = player_query.single_mut() else {
let Ok((player_pos, mut buffer, stance_opt)) = player_query.single_mut() else {
return;
};
buffer.interactions.clear();
for (entity, pos, is_npc) in interactables.iter() {
// D-055: Sprint explicitly suppresses interaction buffer.
// No interaction verbs are computed or sent to the client during sprint.
// Anomaly monologue survives sprint (handled by separate monologue system).
if stance_opt.map(|s| s.0) == Some(MovementStance::Sprint) {
return;
}
for (entity, pos, is_npc, object_type) in interactables.iter() {
let Some(distance) = player_pos.manhattan_distance(pos) else {
continue; // Different z-level
};
@@ -65,44 +136,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 +210,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 +269,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 +323,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 +517,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 +595,352 @@ 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()
);
}
}
// -----------------------------------------------------------------------
// Sprint interaction buffer suppression (#419, D-055)
// -----------------------------------------------------------------------
/// Spawn player with Stance component for sprint suppression tests.
fn spawn_player_with_stance(world: &mut World, x: i32, y: i32, stance: MovementStance) -> Entity {
world
.spawn((
PlayerCharacter,
TilePosition::new(x, y, 0),
NearbyInteractionBuffer::default(),
Stance(stance),
))
.id()
}
#[test]
fn sprint_suppresses_npc_interactions() {
let mut world = setup_world();
spawn_player_with_stance(&mut world, 5, 5, MovementStance::Sprint);
world.spawn((Npc, TilePosition::new(5, 6, 0), Interactable));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_nearby_interactions);
schedule.run(&mut world);
let buffer = read_buffer(&mut world);
assert!(buffer.interactions.is_empty(), "sprint should suppress all interactions");
}
#[test]
fn sprint_suppresses_object_interactions() {
let mut world = setup_world();
spawn_player_with_stance(&mut world, 5, 5, MovementStance::Sprint);
world.spawn((TilePosition::new(5, 6, 0), Interactable, ObjectType::Terminal));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_nearby_interactions);
schedule.run(&mut world);
let buffer = read_buffer(&mut world);
assert!(buffer.interactions.is_empty(), "sprint should suppress object interactions");
}
#[test]
fn walk_stance_allows_interactions() {
let mut world = setup_world();
spawn_player_with_stance(&mut world, 5, 5, MovementStance::Walk);
world.spawn((Npc, TilePosition::new(5, 6, 0), Interactable));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_nearby_interactions);
schedule.run(&mut world);
let buffer = read_buffer(&mut world);
assert_eq!(buffer.interactions.len(), 1, "Walk should allow interactions");
}
#[test]
fn careful_stance_allows_interactions() {
let mut world = setup_world();
spawn_player_with_stance(&mut world, 5, 5, MovementStance::Careful);
world.spawn((Npc, TilePosition::new(5, 6, 0), Interactable));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_nearby_interactions);
schedule.run(&mut world);
let buffer = read_buffer(&mut world);
assert_eq!(buffer.interactions.len(), 1, "Careful should allow interactions");
}
#[test]
fn crouch_stance_allows_interactions() {
let mut world = setup_world();
spawn_player_with_stance(&mut world, 5, 5, MovementStance::Crouch);
world.spawn((Npc, TilePosition::new(5, 6, 0), Interactable));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_nearby_interactions);
schedule.run(&mut world);
let buffer = read_buffer(&mut world);
assert_eq!(buffer.interactions.len(), 1, "Crouch should allow interactions");
}
#[test]
fn no_stance_component_allows_interactions() {
// Backward compatibility: players without Stance still get interactions
let mut world = setup_world();
spawn_player(&mut world, 5, 5);
world.spawn((Npc, TilePosition::new(5, 6, 0), Interactable));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_nearby_interactions);
schedule.run(&mut world);
let buffer = read_buffer(&mut world);
assert_eq!(buffer.interactions.len(), 1, "no Stance component should allow interactions");
}
#[test]
fn sprint_suppresses_multiple_nearby_entities() {
let mut world = setup_world();
spawn_player_with_stance(&mut world, 5, 5, MovementStance::Sprint);
world.spawn((Npc, TilePosition::new(5, 6, 0), Interactable));
world.spawn((TilePosition::new(6, 5, 0), Interactable, ObjectType::Container));
world.spawn((TilePosition::new(4, 5, 0), Interactable));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_nearby_interactions);
schedule.run(&mut world);
let buffer = read_buffer(&mut world);
assert!(buffer.interactions.is_empty(), "sprint should suppress all 3 nearby entities");
}
}
+272
View File
@@ -0,0 +1,272 @@
// Inventory system — CarriedBy component, Take/Place verb handling (#424, D-065)
//
// Items are world entities with CarriedBy(StableId) referencing the carrier.
// When carried, TilePosition is removed — this naturally enforces the info
// boundary: carried items don't appear in spatial queries (visibility,
// interactions) for other observers. Only the carrier's observer snapshot
// includes them via player_inventory.
//
// Take: removes TilePosition, adds CarriedBy + InventorySlot.
// Place: removes CarriedBy + InventorySlot, adds TilePosition at player pos.
//
// 3x3 grid = 9 universal slots (OQ-24 resolved). Slot assignment is
// first-available (0..8).
use bevy_ecs::prelude::*;
use crate::bridge::types::InventoryItem;
use crate::knowledge::types::StableId;
/// Maximum inventory slots (3x3 grid, OQ-24).
pub const MAX_INVENTORY_SLOTS: u8 = 9;
/// Marks an item as carried by an entity. References the carrier's StableId.
/// When present, the item entity should NOT have a TilePosition — it's
/// in someone's pocket, not on the ground.
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
pub struct CarriedBy(pub StableId);
/// Display name for an item entity, crossing the wire as InventoryItem.name.
#[derive(Component, Debug, Clone)]
pub struct ItemName(pub String);
/// Inventory slot assignment (0..8 for 3x3 grid).
/// Assigned on Take, removed on Place.
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
pub struct InventorySlot(pub u8);
/// Find the next available inventory slot for a carrier.
/// Returns None if all 9 slots are occupied.
pub fn find_next_slot(occupied: &[u8]) -> Option<u8> {
for slot in 0..MAX_INVENTORY_SLOTS {
if !occupied.contains(&slot) {
return Some(slot);
}
}
None
}
/// Collect inventory items for a specific carrier (by StableId).
/// Returns wire-format InventoryItem structs sorted by slot.
pub fn collect_inventory_for(
carrier_id: StableId,
items: &Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>,
registry: &crate::knowledge::EntityRegistry,
) -> Vec<InventoryItem> {
let mut result: Vec<InventoryItem> = items
.iter()
.filter(|(_, carried_by, _, _)| carried_by.0 == carrier_id)
.map(|(entity, _, name, slot)| {
let wire_id = registry
.to_stable(entity)
.map(|sid| sid.0)
.unwrap_or_else(|| {
tracing::error!(?entity, "carried item not in EntityRegistry");
entity.to_bits()
});
InventoryItem {
item_id: wire_id,
name: name.0.clone(),
slot: slot.0,
}
})
.collect();
result.sort_by_key(|item| item.slot);
result
}
/// Get the list of occupied slots for a carrier.
pub fn occupied_slots_for(
carrier_id: StableId,
items: &Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>,
) -> Vec<u8> {
items
.iter()
.filter(|(_, carried_by, _, _)| carried_by.0 == carrier_id)
.map(|(_, _, _, slot)| slot.0)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::knowledge::EntityRegistry;
use bevy_ecs::world::World;
fn setup_world() -> World {
let mut world = World::new();
world.init_resource::<EntityRegistry>();
world
}
#[test]
fn find_next_slot_empty_returns_zero() {
assert_eq!(find_next_slot(&[]), Some(0));
}
#[test]
fn find_next_slot_skips_occupied() {
assert_eq!(find_next_slot(&[0, 1, 2]), Some(3));
}
#[test]
fn find_next_slot_fills_gaps() {
assert_eq!(find_next_slot(&[0, 2, 4]), Some(1));
}
#[test]
fn find_next_slot_full_returns_none() {
let all: Vec<u8> = (0..9).collect();
assert_eq!(find_next_slot(&all), None);
}
#[test]
fn collect_inventory_empty_when_no_items() {
let mut world = setup_world();
let player = world.spawn_empty().id();
let player_sid = world.resource_mut::<EntityRegistry>().register(player);
let mut query_state =
world.query::<(Entity, &CarriedBy, &ItemName, &InventorySlot)>();
// Can't use system params directly in tests — use world query
// Instead, verify the logic by spawning items and checking
assert_eq!(query_state.iter(&world).count(), 0);
let _ = player_sid; // used for the filter
}
#[test]
fn carried_item_appears_in_inventory() {
let mut world = setup_world();
let player = world.spawn_empty().id();
let player_sid = world.resource_mut::<EntityRegistry>().register(player);
let item = world
.spawn((
CarriedBy(player_sid),
ItemName("Manifest Copy".into()),
InventorySlot(0),
))
.id();
world.resource_mut::<EntityRegistry>().register(item);
// Use system_state for proper Query access
let mut system_state = bevy_ecs::system::SystemState::<(
Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>,
Res<EntityRegistry>,
)>::new(&mut world);
let (items_query, registry) = system_state.get(&world);
let inventory = collect_inventory_for(player_sid, &items_query, &registry);
assert_eq!(inventory.len(), 1);
assert_eq!(inventory[0].name, "Manifest Copy");
assert_eq!(inventory[0].slot, 0);
}
#[test]
fn only_own_items_in_inventory() {
let mut world = setup_world();
let player = world.spawn_empty().id();
let player_sid = world.resource_mut::<EntityRegistry>().register(player);
let other = world.spawn_empty().id();
let other_sid = world.resource_mut::<EntityRegistry>().register(other);
// Player's item
let item1 = world
.spawn((
CarriedBy(player_sid),
ItemName("Manifest Copy".into()),
InventorySlot(0),
))
.id();
world.resource_mut::<EntityRegistry>().register(item1);
// Other entity's item — should NOT appear in player's inventory
let item2 = world
.spawn((
CarriedBy(other_sid),
ItemName("Access Token".into()),
InventorySlot(0),
))
.id();
world.resource_mut::<EntityRegistry>().register(item2);
let mut system_state = bevy_ecs::system::SystemState::<(
Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>,
Res<EntityRegistry>,
)>::new(&mut world);
let (items_query, registry) = system_state.get(&world);
let inventory = collect_inventory_for(player_sid, &items_query, &registry);
assert_eq!(inventory.len(), 1, "info boundary: only own items");
assert_eq!(inventory[0].name, "Manifest Copy");
}
#[test]
fn inventory_sorted_by_slot() {
let mut world = setup_world();
let player = world.spawn_empty().id();
let player_sid = world.resource_mut::<EntityRegistry>().register(player);
// Spawn items in reverse slot order
for (slot, name) in [(2, "Comm Log"), (0, "Manifest"), (1, "Token")] {
let item = world
.spawn((
CarriedBy(player_sid),
ItemName(name.into()),
InventorySlot(slot),
))
.id();
world.resource_mut::<EntityRegistry>().register(item);
}
let mut system_state = bevy_ecs::system::SystemState::<(
Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>,
Res<EntityRegistry>,
)>::new(&mut world);
let (items_query, registry) = system_state.get(&world);
let inventory = collect_inventory_for(player_sid, &items_query, &registry);
assert_eq!(inventory.len(), 3);
assert_eq!(inventory[0].slot, 0);
assert_eq!(inventory[0].name, "Manifest");
assert_eq!(inventory[1].slot, 1);
assert_eq!(inventory[1].name, "Token");
assert_eq!(inventory[2].slot, 2);
assert_eq!(inventory[2].name, "Comm Log");
}
#[test]
fn occupied_slots_returns_correct_set() {
let mut world = setup_world();
let player = world.spawn_empty().id();
let player_sid = world.resource_mut::<EntityRegistry>().register(player);
for slot in [0, 3, 7] {
world.spawn((
CarriedBy(player_sid),
ItemName("Item".into()),
InventorySlot(slot),
));
}
let mut system_state = bevy_ecs::system::SystemState::<
Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>,
>::new(&mut world);
let items_query = system_state.get(&world);
let mut slots = occupied_slots_for(player_sid, &items_query);
slots.sort();
assert_eq!(slots, vec![0, 3, 7]);
}
#[test]
fn max_slots_is_nine() {
assert_eq!(MAX_INVENTORY_SLOTS, 9);
}
}
+2
View File
@@ -6,11 +6,13 @@ use bevy_ecs::schedule::IntoScheduleConfigs;
pub mod input;
pub mod interaction;
pub mod inventory;
pub mod monologue;
pub mod movement;
pub mod path_follow;
pub mod pathfinding;
pub mod rng;
pub mod stance;
pub mod tier;
pub mod time;
+426
View File
@@ -3,6 +3,10 @@
// Selects monologue lines from loaded content pools based on trigger conditions.
// v0.1: enter_location (on first tick) + time_idle (periodic when player hasn't moved).
// Lines are written to MonologueBuffer for inclusion in ObserverSnapshot.
//
// Sprint anomaly monologue (#428, D-055):
// When sprinting past a Contradicted entity, a delayed "double-take" monologue
// fires retroactively. Detection in observer pipeline, processing here.
use bevy_ecs::prelude::*;
use rand::Rng;
@@ -24,6 +28,19 @@ const IDLE_THRESHOLD_TICKS: u64 = 100;
/// Display duration for monologue text on client (seconds).
const DISPLAY_DURATION: f32 = 5.0;
/// Tick delay before a sprint anomaly monologue fires (#428, D-055).
/// At ~60 ticks/second (60fps Full rate), 90 ticks ≈ 1.5 real seconds.
/// Tunable: adjust based on actual client frame rate.
pub(crate) const ANOMALY_DELAY_TICKS: u64 = 90;
/// Hardcoded v0.1 sprint anomaly "double-take" lines.
/// Future: move to content pools with trigger="sprint_anomaly".
const ANOMALY_LINES: &[(&str, &str)] = &[
("sprint_anomaly_01", "Wait \u{2014} something wasn't right back there."),
("sprint_anomaly_02", "Hold on. That face... why were they there?"),
("sprint_anomaly_03", "Something's off. That wasn't where they should be."),
];
/// Tracks monologue state for cooldown and trigger detection.
/// Attached to the PlayerCharacter entity.
#[derive(Component, Debug)]
@@ -70,6 +87,101 @@ impl MonologueBuffer {
}
}
/// Queued sprint anomaly for delayed "double-take" monologue (#428, D-055).
///
/// When sprinting past a Contradicted entity, the observer pipeline detects
/// the anomaly and pushes it here. After ANOMALY_DELAY_TICKS, the processing
/// system fires a retroactive monologue ("Wait — was that...?").
///
/// At most one anomaly is pending at a time (first-in wins).
#[derive(Component, Debug, Default)]
pub struct SprintAnomalyQueue {
pending: Option<SprintAnomalyEntry>,
}
#[derive(Debug, Clone)]
struct SprintAnomalyEntry {
entity_id: u64,
detected_tick: u64,
}
impl SprintAnomalyQueue {
/// Queue an anomaly if none is pending.
/// First-in wins: subsequent anomalies are ignored until the current one fires.
pub fn push_anomaly(&mut self, entity_id: u64, tick: u64) {
if self.pending.is_none() {
self.pending = Some(SprintAnomalyEntry {
entity_id,
detected_tick: tick,
});
}
}
/// Take the pending anomaly if the delay has elapsed.
/// Returns the entity_id that triggered the anomaly.
pub fn take_ready(&mut self, current_tick: u64) -> Option<u64> {
if let Some(entry) = &self.pending {
if current_tick.saturating_sub(entry.detected_tick) >= ANOMALY_DELAY_TICKS {
let entity_id = entry.entity_id;
self.pending = None;
return Some(entity_id);
}
}
None
}
/// Whether an anomaly is pending (detected but not yet fired).
pub fn has_pending(&self) -> bool {
self.pending.is_some()
}
}
/// Process delayed sprint anomaly monologues (#428, D-055).
///
/// Checks SprintAnomalyQueue for entries past the delay threshold and fires
/// a "double-take" monologue. Bypasses normal monologue cooldown since sprint
/// anomalies are event-driven, not periodic. Updates last_fired_tick so
/// subsequent normal monologue respects cooldown after the anomaly fires.
///
/// System ordering: after trigger_monologue, before compute_observer_snapshot.
pub fn process_sprint_anomaly_monologue(
time: Res<SimulationTime>,
mut rng: ResMut<SimRng>,
mut query: Query<
(&mut SprintAnomalyQueue, &mut MonologueBuffer, &mut MonologueState),
With<PlayerCharacter>,
>,
) {
let Ok((mut queue, mut buffer, mut state)) = query.single_mut() else {
return;
};
// Don't override existing monologue from trigger_monologue
if buffer.event.is_some() {
return;
}
if let Some(_entity_id) = queue.take_ready(time.tick) {
let index = rng.rng.random_range(0..ANOMALY_LINES.len());
let (id, text) = ANOMALY_LINES[index];
buffer.event = Some(MonologueEvent {
id: id.to_string(),
text: text.to_string(),
duration_seconds: DISPLAY_DURATION,
});
// Update last_fired_tick so normal monologue respects cooldown
state.last_fired_tick = time.tick;
tracing::debug!(
"Sprint anomaly monologue fired: id={}, tick={}",
id,
time.tick
);
}
}
/// Monologue trigger system.
///
/// Runs each tick. Checks trigger conditions against loaded content pools
@@ -318,4 +430,318 @@ mod tests {
let event = buffer.event.as_ref().unwrap();
assert_eq!(event.id, "test_idle_001");
}
// -----------------------------------------------------------------------
// SprintAnomalyQueue unit tests (#428, D-055)
// -----------------------------------------------------------------------
#[test]
fn anomaly_queue_default_is_empty() {
let queue = SprintAnomalyQueue::default();
assert!(!queue.has_pending());
}
#[test]
fn anomaly_queue_push_stores_entry() {
let mut queue = SprintAnomalyQueue::default();
queue.push_anomaly(42, 100);
assert!(queue.has_pending());
}
#[test]
fn anomaly_queue_first_in_wins() {
let mut queue = SprintAnomalyQueue::default();
queue.push_anomaly(42, 100);
queue.push_anomaly(99, 101); // Should be ignored
assert!(queue.has_pending());
// The first anomaly (entity 42) should be the one that fires
let result = queue.take_ready(100 + ANOMALY_DELAY_TICKS);
assert_eq!(result, Some(42));
}
#[test]
fn anomaly_queue_take_ready_before_delay() {
let mut queue = SprintAnomalyQueue::default();
queue.push_anomaly(42, 100);
// Not enough delay yet
let result = queue.take_ready(100 + ANOMALY_DELAY_TICKS - 1);
assert_eq!(result, None);
assert!(queue.has_pending()); // Still pending
}
#[test]
fn anomaly_queue_take_ready_at_delay() {
let mut queue = SprintAnomalyQueue::default();
queue.push_anomaly(42, 100);
// Exactly at delay threshold
let result = queue.take_ready(100 + ANOMALY_DELAY_TICKS);
assert_eq!(result, Some(42));
assert!(!queue.has_pending()); // Consumed
}
#[test]
fn anomaly_queue_take_ready_clears_entry() {
let mut queue = SprintAnomalyQueue::default();
queue.push_anomaly(42, 100);
let _ = queue.take_ready(100 + ANOMALY_DELAY_TICKS);
// Second take should return None
let result = queue.take_ready(100 + ANOMALY_DELAY_TICKS + 10);
assert_eq!(result, None);
}
#[test]
fn anomaly_queue_can_push_after_take() {
let mut queue = SprintAnomalyQueue::default();
queue.push_anomaly(42, 100);
let _ = queue.take_ready(100 + ANOMALY_DELAY_TICKS);
assert!(!queue.has_pending());
// Push a new anomaly after the first was consumed
queue.push_anomaly(99, 300);
assert!(queue.has_pending());
let result = queue.take_ready(300 + ANOMALY_DELAY_TICKS);
assert_eq!(result, Some(99));
}
// -----------------------------------------------------------------------
// process_sprint_anomaly_monologue system tests (#428, D-055)
// -----------------------------------------------------------------------
fn setup_anomaly_world() -> World {
let mut world = World::new();
world.init_resource::<SimulationTime>();
world.insert_resource(SimRng::new(42));
world
}
#[test]
fn anomaly_monologue_fires_after_delay() {
let mut world = setup_anomaly_world();
let mut queue = SprintAnomalyQueue::default();
queue.push_anomaly(42, 0); // Queued at tick 0
world.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
MonologueState::default(),
MonologueBuffer::default(),
queue,
));
// Advance past delay
world.resource_mut::<SimulationTime>().tick = ANOMALY_DELAY_TICKS;
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_sprint_anomaly_monologue);
schedule.run(&mut world);
let mut query = world.query::<&MonologueBuffer>();
let buffer = query.single(&world).unwrap();
assert!(buffer.event.is_some());
let event = buffer.event.as_ref().unwrap();
assert!(event.id.starts_with("sprint_anomaly_"));
}
#[test]
fn anomaly_monologue_not_before_delay() {
let mut world = setup_anomaly_world();
let mut queue = SprintAnomalyQueue::default();
queue.push_anomaly(42, 0);
world.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
MonologueState::default(),
MonologueBuffer::default(),
queue,
));
// Still within delay
world.resource_mut::<SimulationTime>().tick = ANOMALY_DELAY_TICKS - 1;
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_sprint_anomaly_monologue);
schedule.run(&mut world);
let mut query = world.query::<&MonologueBuffer>();
let buffer = query.single(&world).unwrap();
assert!(buffer.event.is_none());
}
#[test]
fn anomaly_monologue_does_not_override_existing() {
let mut world = setup_anomaly_world();
let mut queue = SprintAnomalyQueue::default();
queue.push_anomaly(42, 0);
// Pre-fill the monologue buffer (as if trigger_monologue already wrote)
let mut buffer = MonologueBuffer::default();
buffer.event = Some(MonologueEvent {
id: "existing_line".to_string(),
text: "I should keep this.".to_string(),
duration_seconds: 5.0,
});
world.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
MonologueState::default(),
buffer,
queue,
));
world.resource_mut::<SimulationTime>().tick = ANOMALY_DELAY_TICKS;
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_sprint_anomaly_monologue);
schedule.run(&mut world);
// Should still have the original line, not the anomaly line
let mut query = world.query::<&MonologueBuffer>();
let buffer = query.single(&world).unwrap();
assert_eq!(buffer.event.as_ref().unwrap().id, "existing_line");
// Queue should still be pending (not consumed)
let mut q = world.query::<&SprintAnomalyQueue>();
assert!(q.single(&world).unwrap().has_pending());
}
#[test]
fn anomaly_monologue_updates_last_fired_tick() {
let mut world = setup_anomaly_world();
let mut queue = SprintAnomalyQueue::default();
queue.push_anomaly(42, 0);
world.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
MonologueState::default(),
MonologueBuffer::default(),
queue,
));
world.resource_mut::<SimulationTime>().tick = ANOMALY_DELAY_TICKS;
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_sprint_anomaly_monologue);
schedule.run(&mut world);
let mut query = world.query::<&MonologueState>();
let state = query.single(&world).unwrap();
assert_eq!(state.last_fired_tick, ANOMALY_DELAY_TICKS);
}
#[test]
fn anomaly_monologue_clears_queue_after_fire() {
let mut world = setup_anomaly_world();
let mut queue = SprintAnomalyQueue::default();
queue.push_anomaly(42, 0);
world.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
MonologueState::default(),
MonologueBuffer::default(),
queue,
));
world.resource_mut::<SimulationTime>().tick = ANOMALY_DELAY_TICKS;
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_sprint_anomaly_monologue);
schedule.run(&mut world);
let mut query = world.query::<&SprintAnomalyQueue>();
let queue = query.single(&world).unwrap();
assert!(!queue.has_pending());
}
#[test]
fn anomaly_monologue_no_crash_without_queue() {
// Backward compat: entities without SprintAnomalyQueue don't crash
let mut world = setup_anomaly_world();
world.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
MonologueState::default(),
MonologueBuffer::default(),
));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_sprint_anomaly_monologue);
// Should not panic
schedule.run(&mut world);
}
#[test]
fn anomaly_delay_constant_is_90_ticks() {
// D-055 spec: ~1.5 real seconds at 60fps → 90 ticks
assert_eq!(ANOMALY_DELAY_TICKS, 90);
}
#[test]
fn anomaly_lines_all_valid() {
// All hardcoded v0.1 lines should have id prefix and non-empty text
assert!(!ANOMALY_LINES.is_empty());
for (id, text) in ANOMALY_LINES {
assert!(id.starts_with("sprint_anomaly_"), "id={} should start with sprint_anomaly_", id);
assert!(!text.is_empty(), "text for {} should be non-empty", id);
}
}
#[test]
fn anomaly_full_cycle_detect_then_fire() {
// Full end-to-end: push anomaly at tick 0 → not fired at tick 89 → fires at tick 90
let mut world = setup_anomaly_world();
let mut queue = SprintAnomalyQueue::default();
queue.push_anomaly(42, 0);
world.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
MonologueState::default(),
MonologueBuffer::default(),
queue,
));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_sprint_anomaly_monologue);
// Tick 89: still within delay — should NOT fire
world.resource_mut::<SimulationTime>().tick = ANOMALY_DELAY_TICKS - 1;
schedule.run(&mut world);
let mut buf_query = world.query::<&MonologueBuffer>();
assert!(buf_query.single(&world).unwrap().event.is_none(), "should not fire before delay");
let mut q_query = world.query::<&SprintAnomalyQueue>();
assert!(q_query.single(&world).unwrap().has_pending(), "still pending before delay");
// Tick 90: delay elapsed — should fire
world.resource_mut::<SimulationTime>().tick = ANOMALY_DELAY_TICKS;
schedule.run(&mut world);
let mut buf_query = world.query::<&MonologueBuffer>();
let buffer = buf_query.single(&world).unwrap();
assert!(buffer.event.is_some(), "should fire at delay threshold");
let event = buffer.event.as_ref().unwrap();
assert!(event.id.starts_with("sprint_anomaly_"));
assert_eq!(event.duration_seconds, DISPLAY_DURATION);
// Queue should be cleared
let mut q_query = world.query::<&SprintAnomalyQueue>();
assert!(!q_query.single(&world).unwrap().has_pending(), "queue cleared after fire");
// last_fired_tick should be updated
let mut state_query = world.query::<&MonologueState>();
assert_eq!(
state_query.single(&world).unwrap().last_fired_tick,
ANOMALY_DELAY_TICKS,
"last_fired_tick updated for cooldown"
);
}
}
+353 -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,304 @@ 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"
);
}
#[test]
fn all_four_layers_coexist_on_same_tile() {
// D-054: Standing + Prone + Seated + Fixture all share one tile
let mut world = bevy_ecs::world::World::new();
world.insert_resource(WalkabilityMap::new(10, 10, 1));
let target = TilePosition::new(5, 4, 0);
// Fixture and Prone already at tile
world.spawn((target, TilePresence::Fixture));
world.spawn((target, TilePresence::Prone));
// Standing mover enters
let standing = world
.spawn((
TilePosition::new(5, 5, 0),
TilePresence::Standing,
MoveIntent { target },
))
.id();
// Seated mover enters from elsewhere
let seated = world
.spawn((
TilePosition::new(5, 3, 0),
TilePresence::Seated,
MoveIntent { target },
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(validate_movement);
schedule.run(&mut world);
assert_eq!(
*world.get::<TilePosition>(standing).unwrap(),
target,
"Standing should share tile with Fixture + Prone"
);
assert_eq!(
*world.get::<TilePosition>(seated).unwrap(),
target,
"Seated should share tile with Fixture + Prone + Standing"
);
}
}
+250
View File
@@ -0,0 +1,250 @@
// 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;
/// Per-archetype default movement configuration (D-053).
/// Stores the default stance so spawn code can initialize Stance from it.
///
/// v0.1: smuggler and detective both default to Walk.
/// Future archetypes may differ (e.g., maintenance worker → Careful).
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
pub struct MovementProfile {
pub default_stance: MovementStance,
}
impl Default for MovementProfile {
fn default() -> Self {
Self {
default_stance: MovementStance::Walk,
}
}
}
impl MovementProfile {
pub fn smuggler() -> Self {
Self {
default_stance: MovementStance::Walk,
}
}
pub fn detective() -> Self {
Self {
default_stance: MovementStance::Walk,
}
}
/// Create the initial Stance component from this profile's default.
pub fn initial_stance(&self) -> Stance {
Stance(self.default_stance)
}
}
/// ECS component tracking an entity's current movement stance.
/// Attached to PlayerCharacter (and potentially NPCs in future).
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
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
}
// -----------------------------------------------------------------------
// MovementProfile tests (#418, D-053)
// -----------------------------------------------------------------------
#[test]
fn movement_profile_default_is_walk() {
let profile = MovementProfile::default();
assert_eq!(profile.default_stance, MovementStance::Walk);
}
#[test]
fn movement_profile_smuggler_defaults_to_walk() {
let profile = MovementProfile::smuggler();
assert_eq!(profile.default_stance, MovementStance::Walk);
}
#[test]
fn movement_profile_detective_defaults_to_walk() {
let profile = MovementProfile::detective();
assert_eq!(profile.default_stance, MovementStance::Walk);
}
#[test]
fn movement_profile_initial_stance_matches_default() {
let profile = MovementProfile::smuggler();
let stance = profile.initial_stance();
assert_eq!(stance.0, profile.default_stance);
}
#[test]
fn movement_profile_custom_default_stance() {
let profile = MovementProfile {
default_stance: MovementStance::Careful,
};
assert_eq!(profile.default_stance, MovementStance::Careful);
assert_eq!(profile.initial_stance().0, MovementStance::Careful);
}
#[test]
fn movement_profile_as_ecs_component() {
let mut world = bevy_ecs::world::World::new();
let profile = MovementProfile::smuggler();
let entity = world.spawn((profile, profile.initial_stance(), PlayerMoveCooldown::default())).id();
let stored = world.get::<MovementProfile>(entity).unwrap();
assert_eq!(stored.default_stance, MovementStance::Walk);
let stance = world.get::<Stance>(entity).unwrap();
assert_eq!(stance.0, MovementStance::Walk);
}
}
+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,
+301 -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,297 @@ 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);
}
}
/// VerbKind::Confront (Phase 2, #422) must survive MessagePack round-trip.
/// Guards against Confront being omitted from serde mapping.
#[test]
fn verb_kind_confront_roundtrip() {
let mut snapshot = test_snapshot(0, vec![]);
snapshot.nearby_interactions = vec![NearbyInteraction {
entity_id: 1,
entity_type: EntityKind::Npc,
distance: 1,
verbs: vec![VerbOption {
kind: VerbKind::Confront,
label: "Confront".into(),
priority: 3,
available: true,
}],
object_type: None,
contradicted: false,
}];
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
assert_eq!(decoded.nearby_interactions.len(), 1);
assert_eq!(decoded.nearby_interactions[0].verbs[0].kind, VerbKind::Confront);
assert_eq!(decoded.nearby_interactions[0].verbs[0].label, "Confront");
}
/// CharacterArchetype enum round-trips through MessagePack (#422).
/// Used in Phase 2 label relabeling — must survive the wire.
#[test]
fn all_character_archetype_variants_roundtrip() {
let archetypes = [
CharacterArchetype::Smuggler,
CharacterArchetype::Detective,
];
for archetype in archetypes {
let bytes = rmp_serde::to_vec_named(&archetype).expect("serialize");
let decoded: CharacterArchetype = rmp_serde::from_slice(&bytes).expect("deserialize");
assert_eq!(decoded, archetype, "CharacterArchetype::{:?} roundtrip failed", archetype);
}
}
/// NearbyInteraction.contradicted=true round-trips through MessagePack (#422).
/// Guards the contradiction flag survives serialization.
#[test]
fn nearby_interaction_contradicted_roundtrip() {
let mut snapshot = test_snapshot(0, vec![]);
snapshot.nearby_interactions = vec![NearbyInteraction {
entity_id: 1,
entity_type: EntityKind::Npc,
distance: 1,
verbs: vec![VerbOption {
kind: VerbKind::Talk,
label: "Talk".into(),
priority: 1,
available: true,
}],
object_type: None,
contradicted: true,
}];
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
assert!(decoded.nearby_interactions[0].contradicted, "contradicted flag should survive roundtrip");
}
/// NearbyInteraction.object_type round-trips through MessagePack (#422).
/// Verifies object_type=Some(Container) survives the wire.
#[test]
fn nearby_interaction_object_type_roundtrip() {
let mut snapshot = test_snapshot(0, vec![]);
snapshot.nearby_interactions = vec![NearbyInteraction {
entity_id: 1,
entity_type: EntityKind::Object,
distance: 1,
verbs: vec![VerbOption {
kind: VerbKind::Open,
label: "Open".into(),
priority: 1,
available: true,
}],
object_type: Some(ObjectType::Container),
contradicted: false,
}];
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
assert_eq!(decoded.nearby_interactions[0].object_type, Some(ObjectType::Container));
}