feat(simulation): Sprint 6 Touch — stance, tile presence, verbs, protocol v6
Implements the core Sprint 6: Touch systems across 5 tickets: - #449 ObserverSnapshot v6: add player_stance (MovementStance) and player_inventory (Vec<InventoryItem>) wire fields with serde defaults for backward compatibility. Bump PROTOCOL_VERSION 5→6. - #417 Stance system: Sprint/Walk/Careful/Crouch movement stance with tick-based speed (1/2/3/4 ticks per move), monologue rate multipliers, and PlayerMoveCooldown component. ToggleStanceUp/Down player actions. - #420 TilePresence: posture-layer collision system allowing same-tile occupancy for different layers (Standing/Prone/Seated/Fixture). Layer-based collision in validate_movement. - #421 ObjectType component: Readable/Container/Terminal/Door/Pickup/ Furniture types with Phase 1 verb sets computed from type + proximity. - #422 Phase 2 verb filter: KG-gated observer-side verb processing — POI priority flips, Confront injection at KnowsDetails+, contradiction marking, archetype-specific label relabeling (Smuggler/Detective). 217 unit tests + 17 integration tests passing. All MessagePack fixtures regenerated for v6 wire format. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+259
-13
@@ -1,10 +1,11 @@
|
||||
// Input processing system
|
||||
// Timestamped player input events for deterministic simulation (D-010 principle 4)
|
||||
// PlayerInput: semantic actions (MoveNorth, Interact, UsePerceptionMode)
|
||||
// PlayerInput: semantic actions (MoveNorth, Interact, UsePerceptionMode, ToggleStance)
|
||||
|
||||
use crate::bridge::types::{PlayerAction, PlayerInput};
|
||||
use crate::perception::vision_cone::{facing_from_delta, Facing};
|
||||
use crate::simulation::movement::{MoveIntent, PlayerCharacter, TilePosition};
|
||||
use crate::simulation::stance::{PlayerMoveCooldown, Stance};
|
||||
use crate::simulation::time::{SimulationTime, TickRate};
|
||||
use bevy_ecs::prelude::*;
|
||||
use std::collections::VecDeque;
|
||||
@@ -55,25 +56,75 @@ impl InputQueue {
|
||||
}
|
||||
|
||||
/// Drains InputQueue for the current tick, converts PlayerActions to ECS components.
|
||||
/// Handles stance toggling (D-053) and movement cooldown based on current stance.
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub fn process_player_input(
|
||||
mut input_queue: ResMut<InputQueue>,
|
||||
mut time: ResMut<SimulationTime>,
|
||||
mut commands: Commands,
|
||||
player_query: Query<(Entity, &TilePosition), With<PlayerCharacter>>,
|
||||
mut player_query: Query<
|
||||
(Entity, &TilePosition, Option<&mut Stance>, Option<&mut PlayerMoveCooldown>),
|
||||
With<PlayerCharacter>,
|
||||
>,
|
||||
) {
|
||||
let current_tick = time.tick;
|
||||
let inputs = input_queue.drain_for_tick(current_tick);
|
||||
|
||||
// Track whether any movement was attempted this tick (for cooldown tick advance)
|
||||
let mut move_attempted = false;
|
||||
|
||||
for input in inputs {
|
||||
match input.action {
|
||||
PlayerAction::MoveNorth => apply_move(&player_query, &mut commands, 0, -1),
|
||||
PlayerAction::MoveSouth => apply_move(&player_query, &mut commands, 0, 1),
|
||||
PlayerAction::MoveEast => apply_move(&player_query, &mut commands, 1, 0),
|
||||
PlayerAction::MoveWest => apply_move(&player_query, &mut commands, -1, 0),
|
||||
PlayerAction::MoveNortheast => apply_move(&player_query, &mut commands, 1, -1),
|
||||
PlayerAction::MoveNorthwest => apply_move(&player_query, &mut commands, -1, -1),
|
||||
PlayerAction::MoveSoutheast => apply_move(&player_query, &mut commands, 1, 1),
|
||||
PlayerAction::MoveSouthwest => apply_move(&player_query, &mut commands, -1, 1),
|
||||
PlayerAction::MoveNorth => {
|
||||
move_attempted = true;
|
||||
apply_move(&mut player_query, &mut commands, 0, -1);
|
||||
}
|
||||
PlayerAction::MoveSouth => {
|
||||
move_attempted = true;
|
||||
apply_move(&mut player_query, &mut commands, 0, 1);
|
||||
}
|
||||
PlayerAction::MoveEast => {
|
||||
move_attempted = true;
|
||||
apply_move(&mut player_query, &mut commands, 1, 0);
|
||||
}
|
||||
PlayerAction::MoveWest => {
|
||||
move_attempted = true;
|
||||
apply_move(&mut player_query, &mut commands, -1, 0);
|
||||
}
|
||||
PlayerAction::MoveNortheast => {
|
||||
move_attempted = true;
|
||||
apply_move(&mut player_query, &mut commands, 1, -1);
|
||||
}
|
||||
PlayerAction::MoveNorthwest => {
|
||||
move_attempted = true;
|
||||
apply_move(&mut player_query, &mut commands, -1, -1);
|
||||
}
|
||||
PlayerAction::MoveSoutheast => {
|
||||
move_attempted = true;
|
||||
apply_move(&mut player_query, &mut commands, 1, 1);
|
||||
}
|
||||
PlayerAction::MoveSouthwest => {
|
||||
move_attempted = true;
|
||||
apply_move(&mut player_query, &mut commands, -1, 1);
|
||||
}
|
||||
PlayerAction::ToggleStanceUp => {
|
||||
if let Ok((_, _, Some(mut stance), _)) = player_query.single_mut() {
|
||||
let new_stance = stance.0.step_up();
|
||||
if new_stance != stance.0 {
|
||||
tracing::debug!("Stance up: {:?} -> {:?}", stance.0, new_stance);
|
||||
stance.0 = new_stance;
|
||||
}
|
||||
}
|
||||
}
|
||||
PlayerAction::ToggleStanceDown => {
|
||||
if let Ok((_, _, Some(mut stance), _)) = player_query.single_mut() {
|
||||
let new_stance = stance.0.step_down();
|
||||
if new_stance != stance.0 {
|
||||
tracing::debug!("Stance down: {:?} -> {:?}", stance.0, new_stance);
|
||||
stance.0 = new_stance;
|
||||
}
|
||||
}
|
||||
}
|
||||
PlayerAction::Pause => {
|
||||
time.tick_rate = TickRate::Paused;
|
||||
tracing::debug!("Simulation paused by player input");
|
||||
@@ -98,17 +149,43 @@ pub fn process_player_input(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no movement was attempted this tick, still advance cooldown counter
|
||||
if !move_attempted {
|
||||
if let Ok((_, _, _, Some(mut cooldown))) = player_query.single_mut() {
|
||||
cooldown.tick();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a movement action with stance-based cooldown enforcement.
|
||||
/// If the player has a Stance and PlayerMoveCooldown, movement is throttled
|
||||
/// according to the stance's ticks_per_move. Without these components,
|
||||
/// movement is unrestricted (backward compatibility).
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn apply_move(
|
||||
player_query: &Query<(Entity, &TilePosition), With<PlayerCharacter>>,
|
||||
player_query: &mut Query<
|
||||
(Entity, &TilePosition, Option<&mut Stance>, Option<&mut PlayerMoveCooldown>),
|
||||
With<PlayerCharacter>,
|
||||
>,
|
||||
commands: &mut Commands,
|
||||
dx: i32,
|
||||
dy: i32,
|
||||
) {
|
||||
let (entity, pos) = player_query
|
||||
.single()
|
||||
let (entity, pos, stance_opt, cooldown_opt) = player_query
|
||||
.single_mut()
|
||||
.expect("PlayerCharacter entity must exist when processing input");
|
||||
|
||||
let stance = stance_opt.map(|s| s.0).unwrap_or_default();
|
||||
|
||||
// Check cooldown if present
|
||||
if let Some(mut cooldown) = cooldown_opt {
|
||||
if !cooldown.try_move(stance) {
|
||||
tracing::trace!("Movement throttled by stance {:?} cooldown", stance);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
commands.entity(entity).insert(MoveIntent {
|
||||
target: TilePosition::new(pos.x + dx, pos.y + dy, pos.z),
|
||||
});
|
||||
@@ -261,4 +338,173 @@ mod tests {
|
||||
// No MoveIntent should be created (input for future tick)
|
||||
assert!(world.get::<MoveIntent>(player).is_none());
|
||||
}
|
||||
|
||||
use crate::bridge::types::MovementStance;
|
||||
|
||||
#[test]
|
||||
fn toggle_stance_up_changes_stance() {
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(InputQueue::default());
|
||||
world.insert_resource(SimulationTime::default());
|
||||
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
Stance::default(),
|
||||
PlayerMoveCooldown::default(),
|
||||
));
|
||||
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::ToggleStanceUp,
|
||||
});
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_player_input);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let mut query = world.query::<&Stance>();
|
||||
let stance = query.single(&world).unwrap();
|
||||
assert_eq!(stance.0, MovementStance::Sprint);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_stance_down_changes_stance() {
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(InputQueue::default());
|
||||
world.insert_resource(SimulationTime::default());
|
||||
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
Stance::default(),
|
||||
PlayerMoveCooldown::default(),
|
||||
));
|
||||
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::ToggleStanceDown,
|
||||
});
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_player_input);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let mut query = world.query::<&Stance>();
|
||||
let stance = query.single(&world).unwrap();
|
||||
assert_eq!(stance.0, MovementStance::Careful);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walk_stance_throttles_movement_to_every_2_ticks() {
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(InputQueue::default());
|
||||
world.insert_resource(SimulationTime::default());
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
Stance::default(), // Walk
|
||||
PlayerMoveCooldown::default(),
|
||||
))
|
||||
.id();
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_player_input);
|
||||
|
||||
// Tick 0: move north — should succeed (first move)
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::MoveNorth,
|
||||
});
|
||||
schedule.run(&mut world);
|
||||
assert!(world.get::<MoveIntent>(player).is_some(), "first move should succeed");
|
||||
|
||||
// Remove MoveIntent (simulating validate_movement consuming it)
|
||||
world.entity_mut(player).remove::<MoveIntent>();
|
||||
|
||||
// Tick 0 again: move north — should be throttled (cooldown)
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::MoveNorth,
|
||||
});
|
||||
schedule.run(&mut world);
|
||||
assert!(world.get::<MoveIntent>(player).is_none(), "second move should be throttled");
|
||||
|
||||
// Tick 0 again: move north — should succeed (cooldown elapsed)
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::MoveNorth,
|
||||
});
|
||||
schedule.run(&mut world);
|
||||
assert!(world.get::<MoveIntent>(player).is_some(), "third move should succeed after cooldown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sprint_stance_allows_every_tick() {
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(InputQueue::default());
|
||||
world.insert_resource(SimulationTime::default());
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
Stance(MovementStance::Sprint),
|
||||
PlayerMoveCooldown::default(),
|
||||
))
|
||||
.id();
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_player_input);
|
||||
|
||||
// First move
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::MoveNorth,
|
||||
});
|
||||
schedule.run(&mut world);
|
||||
assert!(world.get::<MoveIntent>(player).is_some());
|
||||
world.entity_mut(player).remove::<MoveIntent>();
|
||||
|
||||
// Second move — sprint allows every tick
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::MoveNorth,
|
||||
});
|
||||
schedule.run(&mut world);
|
||||
assert!(world.get::<MoveIntent>(player).is_some(), "sprint should allow every tick");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_stance_component_moves_unrestricted() {
|
||||
// Backward compatibility: entities without Stance/Cooldown move freely
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(InputQueue::default());
|
||||
world.insert_resource(SimulationTime::default());
|
||||
|
||||
let player = world
|
||||
.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)))
|
||||
.id();
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_player_input);
|
||||
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::MoveNorth,
|
||||
});
|
||||
schedule.run(&mut world);
|
||||
assert!(world.get::<MoveIntent>(player).is_some());
|
||||
world.entity_mut(player).remove::<MoveIntent>();
|
||||
|
||||
// Second move immediately — no throttle without components
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::MoveNorth,
|
||||
});
|
||||
schedule.run(&mut world);
|
||||
assert!(world.get::<MoveIntent>(player).is_some());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
// Interaction system — proximity detection + multi-verb InteractionOptions
|
||||
// Implements #404: server-side verb computation for context-sensitive [E] key
|
||||
// Extended by #421: ObjectType component + verb sets per type (D-057)
|
||||
// Spec: docs/design/interaction-verbs-v0.1.md
|
||||
// D-060: actions[] renamed to verbs[] across all surfaces
|
||||
//
|
||||
// Phase boundary: this system determines verb AVAILABILITY based on proximity
|
||||
// and entity type only. Verb PRIORITY adjustment (e.g. POI flipping Observe
|
||||
// above Talk) is a perception concern handled by the observer system.
|
||||
// Phase 2 filtering (KG-gated verbs) handled by #422.
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
|
||||
// Re-export ObjectType for backward compatibility — definition moved to bridge::types (#422).
|
||||
pub use crate::bridge::types::ObjectType;
|
||||
use crate::bridge::types::{EntityKind, NearbyInteraction, VerbKind, VerbOption};
|
||||
use crate::knowledge::EntityRegistry;
|
||||
use crate::npc::Npc;
|
||||
@@ -22,12 +27,70 @@ pub(crate) const MID_RANGE: u32 = 5;
|
||||
#[derive(Component, Debug, Clone)]
|
||||
pub struct Interactable;
|
||||
|
||||
// ObjectType enum is defined in bridge::types (wire type for NearbyInteraction).
|
||||
// VerbDef and verb_set() impl remain here in the simulation layer.
|
||||
|
||||
/// A verb definition in an ObjectType's Phase 1 verb set.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct VerbDef {
|
||||
pub kind: VerbKind,
|
||||
pub label: &'static str,
|
||||
pub priority: u8,
|
||||
/// Whether this verb requires close range (true) or works at mid range (false)
|
||||
pub close_only: bool,
|
||||
}
|
||||
|
||||
impl ObjectType {
|
||||
/// Phase 1 verb set: maximum possible verbs for this object type (D-057).
|
||||
/// No KG dependency — this is simulation-level verb computation.
|
||||
/// Phase 2 (#422) will filter these by the observer's knowledge.
|
||||
///
|
||||
/// Each verb has a default priority and range requirement:
|
||||
/// - Primary verbs (priority 1-2): the main actions for this type
|
||||
/// - Observe (priority 3): always available, works at mid range
|
||||
pub fn verb_set(&self) -> &'static [VerbDef] {
|
||||
match self {
|
||||
Self::Readable => &[
|
||||
VerbDef { kind: VerbKind::Read, label: "Read", priority: 1, close_only: true },
|
||||
VerbDef { kind: VerbKind::Observe, label: "Observe", priority: 2, close_only: false },
|
||||
],
|
||||
Self::Container => &[
|
||||
VerbDef { kind: VerbKind::Open, label: "Open", priority: 1, close_only: true },
|
||||
VerbDef { kind: VerbKind::Search, label: "Search", priority: 2, close_only: true },
|
||||
VerbDef { kind: VerbKind::Observe, label: "Observe", priority: 3, close_only: false },
|
||||
],
|
||||
Self::Terminal => &[
|
||||
VerbDef { kind: VerbKind::Use, label: "Use", priority: 1, close_only: true },
|
||||
VerbDef { kind: VerbKind::Observe, label: "Observe", priority: 2, close_only: false },
|
||||
],
|
||||
Self::Door => &[
|
||||
VerbDef { kind: VerbKind::Open, label: "Open", priority: 1, close_only: true },
|
||||
VerbDef { kind: VerbKind::Close, label: "Close", priority: 2, close_only: true },
|
||||
VerbDef { kind: VerbKind::Observe, label: "Observe", priority: 3, close_only: false },
|
||||
],
|
||||
Self::Pickup => &[
|
||||
VerbDef { kind: VerbKind::Take, label: "Take", priority: 1, close_only: true },
|
||||
VerbDef { kind: VerbKind::Observe, label: "Observe", priority: 2, close_only: false },
|
||||
],
|
||||
Self::Furniture => &[
|
||||
VerbDef { kind: VerbKind::Sit, label: "Sit", priority: 1, close_only: true },
|
||||
VerbDef { kind: VerbKind::Observe, label: "Observe", priority: 2, close_only: false },
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute nearby interactions for the player character.
|
||||
/// For each entity in range, determines available verbs sorted by priority.
|
||||
/// Results are written to the NearbyInteractionBuffer for inclusion in ObserverSnapshot.
|
||||
///
|
||||
/// Verb source priority:
|
||||
/// 1. NPCs: Talk + ExamineNpc (hardcoded, NPC-specific)
|
||||
/// 2. ObjectType: type-specific verb set from verb_set() (D-057 Phase 1)
|
||||
/// 3. Untyped objects: ExamineObject fallback (legacy/simple objects)
|
||||
///
|
||||
/// NOTE: Determines verb availability and default priority only. Relationship-based
|
||||
/// priority adjustment (e.g. POI → Observe first) is applied by the observer
|
||||
/// priority adjustment (e.g. POI -> Observe first) is applied by the observer
|
||||
/// system after taking the buffer. This keeps the simulation phase free of
|
||||
/// knowledge graph dependencies (D-010 phase boundary).
|
||||
#[allow(clippy::type_complexity)]
|
||||
@@ -38,7 +101,7 @@ pub fn compute_nearby_interactions(
|
||||
>,
|
||||
registry: Res<EntityRegistry>,
|
||||
interactables: Query<
|
||||
(Entity, &TilePosition, Option<&Npc>),
|
||||
(Entity, &TilePosition, Option<&Npc>, Option<&ObjectType>),
|
||||
(With<Interactable>, Without<PlayerCharacter>),
|
||||
>,
|
||||
) {
|
||||
@@ -47,7 +110,7 @@ pub fn compute_nearby_interactions(
|
||||
};
|
||||
buffer.interactions.clear();
|
||||
|
||||
for (entity, pos, is_npc) in interactables.iter() {
|
||||
for (entity, pos, is_npc, object_type) in interactables.iter() {
|
||||
let Some(distance) = player_pos.manhattan_distance(pos) else {
|
||||
continue; // Different z-level
|
||||
};
|
||||
@@ -65,44 +128,55 @@ pub fn compute_nearby_interactions(
|
||||
let is_close = distance <= CLOSE_RANGE;
|
||||
let mut verbs = Vec::new();
|
||||
|
||||
match entity_type {
|
||||
EntityKind::Npc => {
|
||||
if is_close {
|
||||
// Default priority: Talk first, Observe second.
|
||||
// Observer adjusts priority for POI entities.
|
||||
verbs.push(VerbOption {
|
||||
kind: VerbKind::Talk,
|
||||
label: "Talk".into(),
|
||||
priority: 1,
|
||||
available: true,
|
||||
});
|
||||
verbs.push(VerbOption {
|
||||
kind: VerbKind::ExamineNpc,
|
||||
label: "Observe".into(),
|
||||
priority: 2,
|
||||
available: true,
|
||||
});
|
||||
} else {
|
||||
// Mid range: only Examine NPC (Talk requires close range)
|
||||
verbs.push(VerbOption {
|
||||
kind: VerbKind::ExamineNpc,
|
||||
label: "Observe".into(),
|
||||
priority: 1,
|
||||
available: true,
|
||||
});
|
||||
}
|
||||
if is_npc.is_some() {
|
||||
// NPC verb logic — unchanged from #404
|
||||
if is_close {
|
||||
// Default priority: Talk first, Observe second.
|
||||
// Observer adjusts priority for POI entities.
|
||||
verbs.push(VerbOption {
|
||||
kind: VerbKind::Talk,
|
||||
label: "Talk".into(),
|
||||
priority: 1,
|
||||
available: true,
|
||||
});
|
||||
verbs.push(VerbOption {
|
||||
kind: VerbKind::ExamineNpc,
|
||||
label: "Observe".into(),
|
||||
priority: 2,
|
||||
available: true,
|
||||
});
|
||||
} else {
|
||||
// Mid range: only Examine NPC (Talk requires close range)
|
||||
verbs.push(VerbOption {
|
||||
kind: VerbKind::ExamineNpc,
|
||||
label: "Observe".into(),
|
||||
priority: 1,
|
||||
available: true,
|
||||
});
|
||||
}
|
||||
EntityKind::Object | EntityKind::Terrain => {
|
||||
if is_close {
|
||||
verbs.push(VerbOption {
|
||||
kind: VerbKind::ExamineObject,
|
||||
label: "Examine".into(),
|
||||
priority: 1,
|
||||
available: true,
|
||||
});
|
||||
} else if let Some(obj_type) = object_type {
|
||||
// D-057 Phase 1: ObjectType-driven verb set (#421)
|
||||
for def in obj_type.verb_set() {
|
||||
if def.close_only && !is_close {
|
||||
continue; // Skip close-only verbs when at mid range
|
||||
}
|
||||
verbs.push(VerbOption {
|
||||
kind: def.kind,
|
||||
label: def.label.into(),
|
||||
priority: def.priority,
|
||||
available: true,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Legacy fallback: untyped object (no ObjectType component)
|
||||
if is_close {
|
||||
verbs.push(VerbOption {
|
||||
kind: VerbKind::ExamineObject,
|
||||
label: "Examine".into(),
|
||||
priority: 1,
|
||||
available: true,
|
||||
});
|
||||
}
|
||||
EntityKind::Player => {} // No self-interaction
|
||||
}
|
||||
|
||||
if verbs.is_empty() {
|
||||
@@ -128,6 +202,8 @@ pub fn compute_nearby_interactions(
|
||||
entity_type,
|
||||
distance,
|
||||
verbs,
|
||||
object_type: object_type.copied(),
|
||||
contradicted: false, // Phase 2 sets this from KG
|
||||
});
|
||||
}
|
||||
|
||||
@@ -185,6 +261,10 @@ mod tests {
|
||||
query.single(world).unwrap()
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// NPC verb tests (unchanged from #404)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn npc_in_close_range_gets_talk_and_observe() {
|
||||
let mut world = setup_world();
|
||||
@@ -235,8 +315,186 @@ mod tests {
|
||||
assert!(buffer.interactions.is_empty());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// ObjectType verb tests (#421)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn object_in_close_range_gets_examine() {
|
||||
fn readable_close_range_gets_read_and_observe() {
|
||||
let mut world = setup_world();
|
||||
spawn_player(&mut world, 5, 5);
|
||||
world.spawn((
|
||||
TilePosition::new(5, 6, 0),
|
||||
Interactable,
|
||||
ObjectType::Readable,
|
||||
));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_nearby_interactions);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = read_buffer(&mut world);
|
||||
assert_eq!(buffer.interactions.len(), 1);
|
||||
assert_eq!(buffer.interactions[0].verbs.len(), 2);
|
||||
assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::Read);
|
||||
assert_eq!(buffer.interactions[0].verbs[0].label, "Read");
|
||||
assert_eq!(buffer.interactions[0].verbs[1].kind, VerbKind::Observe);
|
||||
assert_eq!(buffer.interactions[0].verbs[1].label, "Observe");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readable_mid_range_gets_observe_only() {
|
||||
let mut world = setup_world();
|
||||
spawn_player(&mut world, 5, 5);
|
||||
world.spawn((
|
||||
TilePosition::new(5, 9, 0),
|
||||
Interactable,
|
||||
ObjectType::Readable,
|
||||
));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_nearby_interactions);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = read_buffer(&mut world);
|
||||
assert_eq!(buffer.interactions.len(), 1);
|
||||
assert_eq!(buffer.interactions[0].verbs.len(), 1);
|
||||
assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::Observe);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn container_close_range_gets_open_search_observe() {
|
||||
let mut world = setup_world();
|
||||
spawn_player(&mut world, 5, 5);
|
||||
world.spawn((
|
||||
TilePosition::new(5, 6, 0),
|
||||
Interactable,
|
||||
ObjectType::Container,
|
||||
));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_nearby_interactions);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = read_buffer(&mut world);
|
||||
assert_eq!(buffer.interactions.len(), 1);
|
||||
assert_eq!(buffer.interactions[0].verbs.len(), 3);
|
||||
assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::Open);
|
||||
assert_eq!(buffer.interactions[0].verbs[1].kind, VerbKind::Search);
|
||||
assert_eq!(buffer.interactions[0].verbs[2].kind, VerbKind::Observe);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_close_range_gets_use_and_observe() {
|
||||
let mut world = setup_world();
|
||||
spawn_player(&mut world, 5, 5);
|
||||
world.spawn((
|
||||
TilePosition::new(5, 6, 0),
|
||||
Interactable,
|
||||
ObjectType::Terminal,
|
||||
));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_nearby_interactions);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = read_buffer(&mut world);
|
||||
assert_eq!(buffer.interactions.len(), 1);
|
||||
assert_eq!(buffer.interactions[0].verbs.len(), 2);
|
||||
assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::Use);
|
||||
assert_eq!(buffer.interactions[0].verbs[1].kind, VerbKind::Observe);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn door_close_range_gets_open_close_observe() {
|
||||
let mut world = setup_world();
|
||||
spawn_player(&mut world, 5, 5);
|
||||
world.spawn((
|
||||
TilePosition::new(5, 6, 0),
|
||||
Interactable,
|
||||
ObjectType::Door,
|
||||
));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_nearby_interactions);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = read_buffer(&mut world);
|
||||
assert_eq!(buffer.interactions.len(), 1);
|
||||
assert_eq!(buffer.interactions[0].verbs.len(), 3);
|
||||
assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::Open);
|
||||
assert_eq!(buffer.interactions[0].verbs[1].kind, VerbKind::Close);
|
||||
assert_eq!(buffer.interactions[0].verbs[2].kind, VerbKind::Observe);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pickup_close_range_gets_take_and_observe() {
|
||||
let mut world = setup_world();
|
||||
spawn_player(&mut world, 5, 5);
|
||||
world.spawn((
|
||||
TilePosition::new(5, 6, 0),
|
||||
Interactable,
|
||||
ObjectType::Pickup,
|
||||
));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_nearby_interactions);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = read_buffer(&mut world);
|
||||
assert_eq!(buffer.interactions.len(), 1);
|
||||
assert_eq!(buffer.interactions[0].verbs.len(), 2);
|
||||
assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::Take);
|
||||
assert_eq!(buffer.interactions[0].verbs[1].kind, VerbKind::Observe);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn furniture_close_range_gets_sit_and_observe() {
|
||||
let mut world = setup_world();
|
||||
spawn_player(&mut world, 5, 5);
|
||||
world.spawn((
|
||||
TilePosition::new(5, 6, 0),
|
||||
Interactable,
|
||||
ObjectType::Furniture,
|
||||
));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_nearby_interactions);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = read_buffer(&mut world);
|
||||
assert_eq!(buffer.interactions.len(), 1);
|
||||
assert_eq!(buffer.interactions[0].verbs.len(), 2);
|
||||
assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::Sit);
|
||||
assert_eq!(buffer.interactions[0].verbs[1].kind, VerbKind::Observe);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn container_mid_range_gets_observe_only() {
|
||||
let mut world = setup_world();
|
||||
spawn_player(&mut world, 5, 5);
|
||||
world.spawn((
|
||||
TilePosition::new(5, 9, 0),
|
||||
Interactable,
|
||||
ObjectType::Container,
|
||||
));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_nearby_interactions);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = read_buffer(&mut world);
|
||||
assert_eq!(buffer.interactions.len(), 1);
|
||||
assert_eq!(buffer.interactions[0].verbs.len(), 1);
|
||||
assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::Observe);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Legacy untyped object fallback
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn untyped_object_in_close_range_gets_examine() {
|
||||
let mut world = setup_world();
|
||||
spawn_player(&mut world, 5, 5);
|
||||
world.spawn((TilePosition::new(5, 6, 0), Interactable));
|
||||
@@ -251,6 +509,25 @@ mod tests {
|
||||
assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::ExamineObject);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn untyped_object_mid_range_no_verbs() {
|
||||
let mut world = setup_world();
|
||||
spawn_player(&mut world, 5, 5);
|
||||
world.spawn((TilePosition::new(5, 9, 0), Interactable));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_nearby_interactions);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = read_buffer(&mut world);
|
||||
// Untyped objects have no mid-range verbs
|
||||
assert!(buffer.interactions.is_empty());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// General interaction tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn different_z_level_no_interactions() {
|
||||
let mut world = setup_world();
|
||||
@@ -310,4 +587,235 @@ mod tests {
|
||||
assert_eq!(buffer.interactions.len(), 2);
|
||||
assert_eq!(buffer.interactions[0].distance, buffer.interactions[1].distance);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_npcs_and_objects_all_detected() {
|
||||
let mut world = setup_world();
|
||||
spawn_player(&mut world, 5, 5);
|
||||
// NPC nearby
|
||||
world.spawn((Npc, TilePosition::new(5, 6, 0), Interactable));
|
||||
// Typed object nearby
|
||||
world.spawn((
|
||||
TilePosition::new(6, 5, 0),
|
||||
Interactable,
|
||||
ObjectType::Terminal,
|
||||
));
|
||||
// Untyped object nearby
|
||||
world.spawn((TilePosition::new(4, 5, 0), Interactable));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_nearby_interactions);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = read_buffer(&mut world);
|
||||
assert_eq!(buffer.interactions.len(), 3);
|
||||
|
||||
// All should be at distance 1
|
||||
for interaction in &buffer.interactions {
|
||||
assert_eq!(interaction.distance, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// ObjectType::verb_set() unit tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn verb_set_readable_has_read_and_observe() {
|
||||
let verbs = ObjectType::Readable.verb_set();
|
||||
assert_eq!(verbs.len(), 2);
|
||||
assert_eq!(verbs[0].kind, VerbKind::Read);
|
||||
assert!(verbs[0].close_only);
|
||||
assert_eq!(verbs[1].kind, VerbKind::Observe);
|
||||
assert!(!verbs[1].close_only);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verb_set_container_has_three_verbs() {
|
||||
let verbs = ObjectType::Container.verb_set();
|
||||
assert_eq!(verbs.len(), 3);
|
||||
assert_eq!(verbs[0].kind, VerbKind::Open);
|
||||
assert_eq!(verbs[1].kind, VerbKind::Search);
|
||||
assert_eq!(verbs[2].kind, VerbKind::Observe);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_object_types_have_observe() {
|
||||
let types = [
|
||||
ObjectType::Readable,
|
||||
ObjectType::Container,
|
||||
ObjectType::Terminal,
|
||||
ObjectType::Door,
|
||||
ObjectType::Pickup,
|
||||
ObjectType::Furniture,
|
||||
];
|
||||
|
||||
for obj_type in types {
|
||||
let verbs = obj_type.verb_set();
|
||||
let has_observe = verbs.iter().any(|v| v.kind == VerbKind::Observe);
|
||||
assert!(has_observe, "{:?} should have Observe verb", obj_type);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_object_types_observe_is_mid_range() {
|
||||
let types = [
|
||||
ObjectType::Readable,
|
||||
ObjectType::Container,
|
||||
ObjectType::Terminal,
|
||||
ObjectType::Door,
|
||||
ObjectType::Pickup,
|
||||
ObjectType::Furniture,
|
||||
];
|
||||
|
||||
for obj_type in types {
|
||||
let verbs = obj_type.verb_set();
|
||||
let observe = verbs.iter().find(|v| v.kind == VerbKind::Observe).unwrap();
|
||||
assert!(
|
||||
!observe.close_only,
|
||||
"{:?}'s Observe should be available at mid range",
|
||||
obj_type
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Additional QA coverage (Hoshe, Sprint 6)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// All ObjectType variants at mid range should produce Observe only.
|
||||
/// Covers gap: only Readable + Container had explicit mid-range tests.
|
||||
#[test]
|
||||
fn all_object_types_mid_range_observe_only() {
|
||||
let types = [
|
||||
ObjectType::Readable,
|
||||
ObjectType::Container,
|
||||
ObjectType::Terminal,
|
||||
ObjectType::Door,
|
||||
ObjectType::Pickup,
|
||||
ObjectType::Furniture,
|
||||
];
|
||||
|
||||
for obj_type in types {
|
||||
let mut world = setup_world();
|
||||
spawn_player(&mut world, 5, 5);
|
||||
// Distance 4 = mid range (> CLOSE_RANGE=2, <= MID_RANGE=5)
|
||||
world.spawn((
|
||||
TilePosition::new(5, 9, 0),
|
||||
Interactable,
|
||||
obj_type,
|
||||
));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_nearby_interactions);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = read_buffer(&mut world);
|
||||
assert_eq!(
|
||||
buffer.interactions.len(), 1,
|
||||
"{:?} at mid range should produce 1 interaction", obj_type
|
||||
);
|
||||
assert_eq!(
|
||||
buffer.interactions[0].verbs.len(), 1,
|
||||
"{:?} at mid range should have exactly 1 verb (Observe)", obj_type
|
||||
);
|
||||
assert_eq!(
|
||||
buffer.interactions[0].verbs[0].kind, VerbKind::Observe,
|
||||
"{:?} at mid range verb should be Observe", obj_type
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// ObjectType entity beyond MID_RANGE produces no interactions.
|
||||
#[test]
|
||||
fn object_type_out_of_range_no_interactions() {
|
||||
let mut world = setup_world();
|
||||
spawn_player(&mut world, 5, 5);
|
||||
// Distance 6 = beyond MID_RANGE=5
|
||||
world.spawn((
|
||||
TilePosition::new(5, 11, 0),
|
||||
Interactable,
|
||||
ObjectType::Container,
|
||||
));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_nearby_interactions);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = read_buffer(&mut world);
|
||||
assert!(buffer.interactions.is_empty());
|
||||
}
|
||||
|
||||
/// Entity with both Npc and ObjectType: NPC verbs take priority.
|
||||
/// ObjectType verbs should NOT appear — NPCs have their own verb logic.
|
||||
#[test]
|
||||
fn npc_with_object_type_uses_npc_verbs() {
|
||||
let mut world = setup_world();
|
||||
spawn_player(&mut world, 5, 5);
|
||||
world.spawn((
|
||||
Npc,
|
||||
TilePosition::new(5, 6, 0),
|
||||
Interactable,
|
||||
ObjectType::Terminal, // Should be ignored
|
||||
));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_nearby_interactions);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = read_buffer(&mut world);
|
||||
assert_eq!(buffer.interactions.len(), 1);
|
||||
// Should get NPC verbs (Talk + ExamineNpc), NOT Terminal verbs (Use + Observe)
|
||||
assert_eq!(buffer.interactions[0].verbs.len(), 2);
|
||||
assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::Talk);
|
||||
assert_eq!(buffer.interactions[0].verbs[1].kind, VerbKind::ExamineNpc);
|
||||
}
|
||||
|
||||
/// All ObjectType primary verbs are close_only (except Observe).
|
||||
/// Ensures no accidental mid-range primary actions.
|
||||
#[test]
|
||||
fn all_primary_verbs_are_close_only() {
|
||||
let types = [
|
||||
ObjectType::Readable,
|
||||
ObjectType::Container,
|
||||
ObjectType::Terminal,
|
||||
ObjectType::Door,
|
||||
ObjectType::Pickup,
|
||||
ObjectType::Furniture,
|
||||
];
|
||||
|
||||
for obj_type in types {
|
||||
for def in obj_type.verb_set() {
|
||||
if def.kind == VerbKind::Observe {
|
||||
assert!(!def.close_only, "{:?} Observe should be mid-range", obj_type);
|
||||
} else {
|
||||
assert!(
|
||||
def.close_only,
|
||||
"{:?} {:?} should be close-only", obj_type, def.kind
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// D-057: max 4 verbs per entity. Verify no ObjectType exceeds this.
|
||||
#[test]
|
||||
fn verb_set_max_four_verbs() {
|
||||
let types = [
|
||||
ObjectType::Readable,
|
||||
ObjectType::Container,
|
||||
ObjectType::Terminal,
|
||||
ObjectType::Door,
|
||||
ObjectType::Pickup,
|
||||
ObjectType::Furniture,
|
||||
];
|
||||
|
||||
for obj_type in types {
|
||||
let verbs = obj_type.verb_set();
|
||||
assert!(
|
||||
verbs.len() <= 4,
|
||||
"{:?} has {} verbs, D-057 max is 4", obj_type, verbs.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ pub mod movement;
|
||||
pub mod path_follow;
|
||||
pub mod pathfinding;
|
||||
pub mod rng;
|
||||
pub mod stance;
|
||||
pub mod tier;
|
||||
pub mod time;
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Tile-based movement and collision system
|
||||
// Implements Sprint 1 ticket #236: walkability map and movement validation
|
||||
// Extended by #420: TilePresence posture layers for same-tile occupancy (D-054)
|
||||
// Chunk-based storage per D-012: supports chunk load/unload for future borderless generation
|
||||
// Y-down convention: North = y-1, South = y+1
|
||||
|
||||
@@ -14,6 +15,27 @@ pub const CHUNK_SIZE: i32 = 32;
|
||||
#[derive(Component, Debug)]
|
||||
pub struct PlayerCharacter;
|
||||
|
||||
/// Posture layer for same-tile occupancy (D-054, #420).
|
||||
///
|
||||
/// Multiple entities can share a tile if they occupy different posture layers.
|
||||
/// Two entities in the same layer on the same tile is a collision.
|
||||
///
|
||||
/// Examples: a Standing character can walk past a Seated NPC at a console,
|
||||
/// a Fixture (terminal) shares a tile with someone Seated at it.
|
||||
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
|
||||
pub enum TilePresence {
|
||||
/// Upright position — walking, standing, sprinting. Default for all entities.
|
||||
#[default]
|
||||
Standing,
|
||||
/// Low position — crouching or prone on the ground.
|
||||
Prone,
|
||||
/// Seated at furniture, console, or vehicle.
|
||||
Seated,
|
||||
/// Immovable world fixture — terminals, furniture, consoles.
|
||||
/// Occupies its layer permanently.
|
||||
Fixture,
|
||||
}
|
||||
|
||||
/// Tile position component for grid-based movement.
|
||||
/// Discrete integer coordinates used in simulation; converted to f32
|
||||
/// at the bridge boundary for VisibleEntity wire format.
|
||||
@@ -226,48 +248,59 @@ pub struct MoveIntent {
|
||||
}
|
||||
|
||||
/// System to validate and execute movement intents.
|
||||
/// Checks walkability map AND entity-entity collision before allowing moves.
|
||||
/// Processes all intents in a single pass: first collect occupied tiles from
|
||||
/// Checks walkability map AND layer-based entity collision before allowing moves.
|
||||
///
|
||||
/// Same-tile occupancy (D-054, #420): multiple entities can share a tile if they
|
||||
/// occupy different posture layers (TilePresence). Two entities in the same layer
|
||||
/// on the same tile is a collision. Entities without TilePresence default to Standing.
|
||||
///
|
||||
/// Processes all intents in a single pass: first collect occupied layer slots from
|
||||
/// entities without intents, then resolve movers in order — first valid claim
|
||||
/// to a tile wins.
|
||||
/// to a layer slot wins.
|
||||
/// Always removes MoveIntent component after processing.
|
||||
pub fn validate_movement(
|
||||
mut commands: Commands,
|
||||
walkability: Option<Res<WalkabilityMap>>,
|
||||
mut movers: Query<(Entity, &MoveIntent, &mut TilePosition)>,
|
||||
stationary: Query<(Entity, &TilePosition), Without<MoveIntent>>,
|
||||
mut movers: Query<(Entity, &MoveIntent, &mut TilePosition, Option<&TilePresence>)>,
|
||||
stationary: Query<(Entity, &TilePosition, Option<&TilePresence>), Without<MoveIntent>>,
|
||||
) {
|
||||
let Some(map) = walkability else {
|
||||
tracing::warn!("No WalkabilityMap loaded — rejecting all move intents");
|
||||
for (entity, _, _) in movers.iter() {
|
||||
for (entity, _, _, _) in movers.iter() {
|
||||
commands.entity(entity).remove::<MoveIntent>();
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
// Collect tiles occupied by stationary entities (no MoveIntent)
|
||||
let mut occupied: HashMap<TilePosition, Entity> = HashMap::new();
|
||||
for (entity, pos) in stationary.iter() {
|
||||
occupied.insert(*pos, entity);
|
||||
// Collect layer slots occupied by stationary entities (no MoveIntent).
|
||||
// Key: (position, layer) — two entities can share a tile if different layers.
|
||||
let mut occupied: HashMap<(TilePosition, TilePresence), Entity> = HashMap::new();
|
||||
for (entity, pos, presence) in stationary.iter() {
|
||||
let layer = presence.copied().unwrap_or_default();
|
||||
occupied.insert((*pos, layer), entity);
|
||||
}
|
||||
|
||||
for (entity, intent, mut position) in movers.iter_mut() {
|
||||
for (entity, intent, mut position, presence) in movers.iter_mut() {
|
||||
let target = &intent.target;
|
||||
let layer = presence.copied().unwrap_or_default();
|
||||
let slot = (*target, layer);
|
||||
|
||||
if !map.can_move_to(target) {
|
||||
tracing::trace!("Entity {:?} blocked by terrain at {:?}", entity, target);
|
||||
} else if occupied.contains_key(target) {
|
||||
tracing::trace!("Entity {:?} blocked by entity at {:?}", entity, target);
|
||||
} else if occupied.contains_key(&slot) {
|
||||
tracing::trace!(
|
||||
"Entity {:?} blocked by entity at {:?} (layer {:?})",
|
||||
entity, target, layer
|
||||
);
|
||||
} else {
|
||||
tracing::trace!(
|
||||
"Entity {:?} moving from {:?} to {:?}",
|
||||
entity,
|
||||
*position,
|
||||
target
|
||||
"Entity {:?} moving from {:?} to {:?} (layer {:?})",
|
||||
entity, *position, target, layer
|
||||
);
|
||||
// Free old tile, claim new tile
|
||||
occupied.remove(&*position);
|
||||
// Free old layer slot, claim new one
|
||||
occupied.remove(&(*position, layer));
|
||||
*position = *target;
|
||||
occupied.insert(*target, entity);
|
||||
occupied.insert(slot, entity);
|
||||
}
|
||||
commands.entity(entity).remove::<MoveIntent>();
|
||||
}
|
||||
@@ -570,4 +603,258 @@ mod tests {
|
||||
);
|
||||
assert!(world.get::<MoveIntent>(entity).is_none());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// TilePresence / same-tile occupancy tests (D-054, #420)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn tile_presence_default_is_standing() {
|
||||
assert_eq!(TilePresence::default(), TilePresence::Standing);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_layer_same_tile_blocks_movement() {
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(WalkabilityMap::new(10, 10, 1));
|
||||
|
||||
// Stationary entity at target, Standing layer
|
||||
world.spawn((TilePosition::new(5, 4, 0), TilePresence::Standing));
|
||||
|
||||
// Mover also Standing — should be blocked
|
||||
let mover = world
|
||||
.spawn((
|
||||
TilePosition::new(5, 5, 0),
|
||||
TilePresence::Standing,
|
||||
MoveIntent {
|
||||
target: TilePosition::new(5, 4, 0),
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(validate_movement);
|
||||
schedule.run(&mut world);
|
||||
|
||||
assert_eq!(
|
||||
*world.get::<TilePosition>(mover).unwrap(),
|
||||
TilePosition::new(5, 5, 0),
|
||||
"same-layer collision should block movement"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_layer_same_tile_allows_movement() {
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(WalkabilityMap::new(10, 10, 1));
|
||||
|
||||
// Fixture at target tile
|
||||
world.spawn((TilePosition::new(5, 4, 0), TilePresence::Fixture));
|
||||
|
||||
// Standing mover — different layer, should pass
|
||||
let mover = world
|
||||
.spawn((
|
||||
TilePosition::new(5, 5, 0),
|
||||
TilePresence::Standing,
|
||||
MoveIntent {
|
||||
target: TilePosition::new(5, 4, 0),
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(validate_movement);
|
||||
schedule.run(&mut world);
|
||||
|
||||
assert_eq!(
|
||||
*world.get::<TilePosition>(mover).unwrap(),
|
||||
TilePosition::new(5, 4, 0),
|
||||
"different layers should share a tile"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seated_and_fixture_share_tile() {
|
||||
// Common case: NPC seated at a terminal (Fixture)
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(WalkabilityMap::new(10, 10, 1));
|
||||
|
||||
// Terminal fixture at tile
|
||||
world.spawn((TilePosition::new(5, 4, 0), TilePresence::Fixture));
|
||||
|
||||
// Seated NPC moves to same tile
|
||||
let npc = world
|
||||
.spawn((
|
||||
TilePosition::new(5, 5, 0),
|
||||
TilePresence::Seated,
|
||||
MoveIntent {
|
||||
target: TilePosition::new(5, 4, 0),
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(validate_movement);
|
||||
schedule.run(&mut world);
|
||||
|
||||
assert_eq!(
|
||||
*world.get::<TilePosition>(npc).unwrap(),
|
||||
TilePosition::new(5, 4, 0),
|
||||
"Seated NPC should share tile with Fixture"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prone_and_standing_share_tile() {
|
||||
// Eavesdrop scenario: prone entity next to standing entity
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(WalkabilityMap::new(10, 10, 1));
|
||||
|
||||
// Standing NPC at tile
|
||||
world.spawn((TilePosition::new(5, 4, 0), TilePresence::Standing));
|
||||
|
||||
// Prone entity moves in — different layer
|
||||
let prone = world
|
||||
.spawn((
|
||||
TilePosition::new(5, 5, 0),
|
||||
TilePresence::Prone,
|
||||
MoveIntent {
|
||||
target: TilePosition::new(5, 4, 0),
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(validate_movement);
|
||||
schedule.run(&mut world);
|
||||
|
||||
assert_eq!(
|
||||
*world.get::<TilePosition>(prone).unwrap(),
|
||||
TilePosition::new(5, 4, 0),
|
||||
"Prone should share tile with Standing"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entity_without_tile_presence_defaults_to_standing() {
|
||||
// Backwards compat: entities spawned without TilePresence should
|
||||
// still collide with Standing entities (default layer).
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(WalkabilityMap::new(10, 10, 1));
|
||||
|
||||
// Stationary entity WITHOUT TilePresence component
|
||||
world.spawn(TilePosition::new(5, 4, 0));
|
||||
|
||||
// Mover also WITHOUT TilePresence — both default to Standing
|
||||
let mover = world
|
||||
.spawn((
|
||||
TilePosition::new(5, 5, 0),
|
||||
MoveIntent {
|
||||
target: TilePosition::new(5, 4, 0),
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(validate_movement);
|
||||
schedule.run(&mut world);
|
||||
|
||||
assert_eq!(
|
||||
*world.get::<TilePosition>(mover).unwrap(),
|
||||
TilePosition::new(5, 5, 0),
|
||||
"entities without TilePresence should default to Standing and collide"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entity_without_presence_blocked_by_standing() {
|
||||
// Entity without TilePresence blocked by explicit Standing entity
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(WalkabilityMap::new(10, 10, 1));
|
||||
|
||||
// Stationary with explicit Standing
|
||||
world.spawn((TilePosition::new(5, 4, 0), TilePresence::Standing));
|
||||
|
||||
// Mover without TilePresence (defaults to Standing)
|
||||
let mover = world
|
||||
.spawn((
|
||||
TilePosition::new(5, 5, 0),
|
||||
MoveIntent {
|
||||
target: TilePosition::new(5, 4, 0),
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(validate_movement);
|
||||
schedule.run(&mut world);
|
||||
|
||||
assert_eq!(
|
||||
*world.get::<TilePosition>(mover).unwrap(),
|
||||
TilePosition::new(5, 5, 0),
|
||||
"no-presence entity should collide with Standing"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_layers_on_same_tile() {
|
||||
// Maximum plausible scenario: Standing + Seated + Fixture on one tile
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(WalkabilityMap::new(10, 10, 1));
|
||||
|
||||
// Fixture already at tile
|
||||
world.spawn((TilePosition::new(5, 4, 0), TilePresence::Fixture));
|
||||
// Seated already at tile
|
||||
world.spawn((TilePosition::new(5, 4, 0), TilePresence::Seated));
|
||||
|
||||
// Standing mover enters — third layer
|
||||
let mover = world
|
||||
.spawn((
|
||||
TilePosition::new(5, 5, 0),
|
||||
TilePresence::Standing,
|
||||
MoveIntent {
|
||||
target: TilePosition::new(5, 4, 0),
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(validate_movement);
|
||||
schedule.run(&mut world);
|
||||
|
||||
assert_eq!(
|
||||
*world.get::<TilePosition>(mover).unwrap(),
|
||||
TilePosition::new(5, 4, 0),
|
||||
"three different layers should coexist on one tile"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_fixtures_same_tile_blocked() {
|
||||
// Edge case: two fixtures can't stack on the same tile
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(WalkabilityMap::new(10, 10, 1));
|
||||
|
||||
world.spawn((TilePosition::new(5, 4, 0), TilePresence::Fixture));
|
||||
|
||||
let mover = world
|
||||
.spawn((
|
||||
TilePosition::new(5, 5, 0),
|
||||
TilePresence::Fixture,
|
||||
MoveIntent {
|
||||
target: TilePosition::new(5, 4, 0),
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(validate_movement);
|
||||
schedule.run(&mut world);
|
||||
|
||||
assert_eq!(
|
||||
*world.get::<TilePosition>(mover).unwrap(),
|
||||
TilePosition::new(5, 5, 0),
|
||||
"two Fixtures on same tile should collide"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
// Stance system — D-053 movement stances with tick-based speed
|
||||
//
|
||||
// MovementStance (Sprint/Walk/Careful/Crouch) affects:
|
||||
// - Movement speed (ticks per step): Sprint=1, Walk=2, Careful=3, Crouch=4
|
||||
// - Monologue rate: Sprint=40%, Walk=100%, Careful=150%, Crouch=100%
|
||||
// - Interaction buffer: Sprint suppresses (D-055, wired in #419)
|
||||
//
|
||||
// The stance ladder is toggled via PlayerAction::ToggleStanceUp/Down.
|
||||
// This module provides the ECS component and movement cooldown.
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
|
||||
use crate::bridge::types::MovementStance;
|
||||
|
||||
/// ECS component tracking an entity's current movement stance.
|
||||
/// Attached to PlayerCharacter (and potentially NPCs in future).
|
||||
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Stance(pub MovementStance);
|
||||
|
||||
impl Default for Stance {
|
||||
fn default() -> Self {
|
||||
Stance(MovementStance::Walk)
|
||||
}
|
||||
}
|
||||
|
||||
/// Tracks ticks since last movement step for stance-based speed enforcement.
|
||||
/// The player's movement is throttled server-side based on their current stance.
|
||||
#[derive(Component, Debug, Clone)]
|
||||
pub struct PlayerMoveCooldown {
|
||||
pub ticks_since_last_move: u32,
|
||||
}
|
||||
|
||||
impl Default for PlayerMoveCooldown {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
// Start at max so first move is immediate
|
||||
ticks_since_last_move: u32::MAX,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PlayerMoveCooldown {
|
||||
/// Check if the player can move this tick given their stance.
|
||||
/// Returns true and resets the counter if movement is allowed.
|
||||
pub fn try_move(&mut self, stance: MovementStance) -> bool {
|
||||
self.ticks_since_last_move = self.ticks_since_last_move.saturating_add(1);
|
||||
if self.ticks_since_last_move >= stance.ticks_per_move() {
|
||||
self.ticks_since_last_move = 0;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Advance the cooldown counter without attempting a move.
|
||||
/// Call this each tick when no move input is present to keep the counter progressing.
|
||||
pub fn tick(&mut self) {
|
||||
self.ticks_since_last_move = self.ticks_since_last_move.saturating_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn stance_default_is_walk() {
|
||||
assert_eq!(Stance::default().0, MovementStance::Walk);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stance_ladder_step_up() {
|
||||
assert_eq!(MovementStance::Crouch.step_up(), MovementStance::Careful);
|
||||
assert_eq!(MovementStance::Careful.step_up(), MovementStance::Walk);
|
||||
assert_eq!(MovementStance::Walk.step_up(), MovementStance::Sprint);
|
||||
assert_eq!(MovementStance::Sprint.step_up(), MovementStance::Sprint);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stance_ladder_step_down() {
|
||||
assert_eq!(MovementStance::Sprint.step_down(), MovementStance::Walk);
|
||||
assert_eq!(MovementStance::Walk.step_down(), MovementStance::Careful);
|
||||
assert_eq!(MovementStance::Careful.step_down(), MovementStance::Crouch);
|
||||
assert_eq!(MovementStance::Crouch.step_down(), MovementStance::Crouch);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticks_per_move_values() {
|
||||
assert_eq!(MovementStance::Sprint.ticks_per_move(), 1);
|
||||
assert_eq!(MovementStance::Walk.ticks_per_move(), 2);
|
||||
assert_eq!(MovementStance::Careful.ticks_per_move(), 3);
|
||||
assert_eq!(MovementStance::Crouch.ticks_per_move(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monologue_rate_values() {
|
||||
assert_eq!(MovementStance::Sprint.monologue_rate_percent(), 40);
|
||||
assert_eq!(MovementStance::Walk.monologue_rate_percent(), 100);
|
||||
assert_eq!(MovementStance::Careful.monologue_rate_percent(), 150);
|
||||
assert_eq!(MovementStance::Crouch.monologue_rate_percent(), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cooldown_first_move_immediate() {
|
||||
let mut cd = PlayerMoveCooldown::default();
|
||||
// First move should always succeed (counter starts at MAX)
|
||||
assert!(cd.try_move(MovementStance::Walk));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cooldown_sprint_every_tick() {
|
||||
let mut cd = PlayerMoveCooldown::default();
|
||||
assert!(cd.try_move(MovementStance::Sprint)); // tick 1
|
||||
assert!(cd.try_move(MovementStance::Sprint)); // tick 2
|
||||
assert!(cd.try_move(MovementStance::Sprint)); // tick 3
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cooldown_walk_every_two_ticks() {
|
||||
let mut cd = PlayerMoveCooldown::default();
|
||||
assert!(cd.try_move(MovementStance::Walk)); // tick 1: allowed (first)
|
||||
assert!(!cd.try_move(MovementStance::Walk)); // tick 2: cooldown
|
||||
assert!(cd.try_move(MovementStance::Walk)); // tick 3: allowed
|
||||
assert!(!cd.try_move(MovementStance::Walk)); // tick 4: cooldown
|
||||
assert!(cd.try_move(MovementStance::Walk)); // tick 5: allowed
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cooldown_careful_every_three_ticks() {
|
||||
let mut cd = PlayerMoveCooldown::default();
|
||||
assert!(cd.try_move(MovementStance::Careful)); // tick 1: allowed (first)
|
||||
assert!(!cd.try_move(MovementStance::Careful)); // tick 2: cd
|
||||
assert!(!cd.try_move(MovementStance::Careful)); // tick 3: cd
|
||||
assert!(cd.try_move(MovementStance::Careful)); // tick 4: allowed
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cooldown_crouch_every_four_ticks() {
|
||||
let mut cd = PlayerMoveCooldown::default();
|
||||
assert!(cd.try_move(MovementStance::Crouch)); // tick 1: allowed (first)
|
||||
assert!(!cd.try_move(MovementStance::Crouch)); // tick 2: cd
|
||||
assert!(!cd.try_move(MovementStance::Crouch)); // tick 3: cd
|
||||
assert!(!cd.try_move(MovementStance::Crouch)); // tick 4: cd
|
||||
assert!(cd.try_move(MovementStance::Crouch)); // tick 5: allowed
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cooldown_tick_advances_counter() {
|
||||
let mut cd = PlayerMoveCooldown::default();
|
||||
assert!(cd.try_move(MovementStance::Walk)); // move
|
||||
cd.tick(); // no move, but counter advances
|
||||
assert!(cd.try_move(MovementStance::Walk)); // allowed after tick + try_move = 2
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cooldown_stance_switch_mid_cooldown() {
|
||||
let mut cd = PlayerMoveCooldown::default();
|
||||
assert!(cd.try_move(MovementStance::Crouch)); // move at crouch speed
|
||||
// Switch to sprint mid-cooldown
|
||||
assert!(cd.try_move(MovementStance::Sprint)); // sprint allows every tick
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user