Merge remote-tracking branch 'origin/server'
This commit is contained in:
@@ -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.
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
®istry,
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let (mut entities, visible_ids) =
|
||||
filter_visible_entities(&geometry, ®istry, 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
@@ -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,
|
||||
®istry,
|
||||
&player_query,
|
||||
&inventory_items,
|
||||
target_entity_id,
|
||||
);
|
||||
}
|
||||
Some("Place") => {
|
||||
handle_place(
|
||||
&mut commands,
|
||||
®istry,
|
||||
&player_query,
|
||||
target_entity_id,
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
tracing::info!(
|
||||
"Interact: target={:?}, verb={:?} — logged only, dialogue dispatch future scope (#415)",
|
||||
target_entity_id,
|
||||
verb,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
PlayerAction::UsePerceptionMode(ref mode) => {
|
||||
tracing::trace!("UsePerceptionMode({}) — no-op for Sprint 1", mode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, ®istry);
|
||||
assert_eq!(inventory.len(), 1);
|
||||
assert_eq!(inventory[0].name, "Manifest Copy");
|
||||
assert_eq!(inventory[0].slot, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_own_items_in_inventory() {
|
||||
let mut world = setup_world();
|
||||
|
||||
let player = world.spawn_empty().id();
|
||||
let player_sid = world.resource_mut::<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, ®istry);
|
||||
assert_eq!(inventory.len(), 1, "info boundary: only own items");
|
||||
assert_eq!(inventory[0].name, "Manifest Copy");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inventory_sorted_by_slot() {
|
||||
let mut world = setup_world();
|
||||
|
||||
let player = world.spawn_empty().id();
|
||||
let player_sid = world.resource_mut::<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, ®istry);
|
||||
assert_eq!(inventory.len(), 3);
|
||||
assert_eq!(inventory[0].slot, 0);
|
||||
assert_eq!(inventory[0].name, "Manifest");
|
||||
assert_eq!(inventory[1].slot, 1);
|
||||
assert_eq!(inventory[1].name, "Token");
|
||||
assert_eq!(inventory[2].slot, 2);
|
||||
assert_eq!(inventory[2].name, "Comm Log");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn occupied_slots_returns_correct_set() {
|
||||
let mut world = setup_world();
|
||||
|
||||
let player = world.spawn_empty().id();
|
||||
let player_sid = world.resource_mut::<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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user