#491: PlayerAction::TeleportToHub — moves player to hub spawn, clears dialogue/monologue/interaction buffer, Gauntlet-only with log warning. #520: ConfrontationDelivered event — Tier 2 animation shift, relationship state decrement (D-033), monologue spike emission. #519: Walk-away Phase 2 — NPC animation shift + routine deviation on dialogue exit (D-064). #498: Four new Gauntlet rooms — Interaction Gallery, Fog Theater, Crowd Plaza, Dialogue Room with constants and wiring. #514: blocked_entities Vec<u64> on ObserverSnapshot, protocol v9. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1971 lines
66 KiB
Rust
1971 lines
66 KiB
Rust
// Input processing system
|
|
// Timestamped player input events for deterministic simulation (D-010 principle 4)
|
|
// 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 crate::test_world::reset::{RoomResetTrigger, RoomSnapshots};
|
|
use bevy_ecs::prelude::*;
|
|
use std::collections::VecDeque;
|
|
|
|
/// Maximum number of inputs the queue will hold before dropping oldest.
|
|
/// Prevents unbounded memory growth from input flooding.
|
|
pub const INPUT_QUEUE_CAPACITY: usize = 1000;
|
|
|
|
/// Queue of pending player inputs, ordered by tick
|
|
#[derive(Resource, Debug, Default)]
|
|
pub struct InputQueue {
|
|
queue: VecDeque<PlayerInput>,
|
|
}
|
|
|
|
impl InputQueue {
|
|
/// Add a new input to the queue.
|
|
/// Inputs must be pushed in tick order for deterministic processing.
|
|
/// Panics in debug builds if tick ordering is violated.
|
|
/// Drops oldest inputs when capacity is exceeded.
|
|
pub fn push(&mut self, input: PlayerInput) {
|
|
debug_assert!(
|
|
self.queue.back().is_none_or(|last| last.tick <= input.tick),
|
|
"InputQueue: tick ordering violated (last={}, new={})",
|
|
self.queue.back().map_or(0, |last| last.tick),
|
|
input.tick,
|
|
);
|
|
if self.queue.len() >= INPUT_QUEUE_CAPACITY {
|
|
let dropped = self.queue.pop_front();
|
|
tracing::warn!(
|
|
"InputQueue at capacity ({}), dropping oldest input (tick={})",
|
|
INPUT_QUEUE_CAPACITY,
|
|
dropped.map_or(0, |d| d.tick),
|
|
);
|
|
}
|
|
self.queue.push_back(input);
|
|
}
|
|
|
|
/// Drain all inputs for ticks <= the given tick
|
|
/// Returns inputs in FIFO order
|
|
pub fn drain_for_tick(&mut self, tick: u64) -> Vec<PlayerInput> {
|
|
let mut result = Vec::new();
|
|
while let Some(front) = self.queue.front() {
|
|
if front.tick <= tick {
|
|
result.push(self.queue.pop_front().unwrap());
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
result
|
|
}
|
|
|
|
/// Get the current queue length
|
|
pub fn len(&self) -> usize {
|
|
self.queue.len()
|
|
}
|
|
|
|
/// Check if the queue is empty
|
|
pub fn is_empty(&self) -> bool {
|
|
self.queue.is_empty()
|
|
}
|
|
}
|
|
|
|
/// 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, clippy::too_many_arguments)]
|
|
pub fn process_player_input(
|
|
mut input_queue: ResMut<InputQueue>,
|
|
mut time: ResMut<SimulationTime>,
|
|
mut commands: Commands,
|
|
registry: Res<EntityRegistry>,
|
|
mut player_query: Query<
|
|
(
|
|
Entity,
|
|
&TilePosition,
|
|
Option<&mut Stance>,
|
|
Option<&mut PlayerMoveCooldown>,
|
|
),
|
|
With<PlayerCharacter>,
|
|
>,
|
|
inventory_items: Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>,
|
|
all_positions: Query<&TilePosition>,
|
|
reset_triggers: Query<&RoomResetTrigger>,
|
|
mut room_snapshots: Option<ResMut<RoomSnapshots>>,
|
|
) {
|
|
let current_tick = time.tick;
|
|
let paused = time.paused();
|
|
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 {
|
|
// Discard all gameplay actions while paused (D-052, R2-OQ-01).
|
|
// Only Pause/Unpause/TeleportToHub are processed — everything else is discarded.
|
|
// TeleportToHub is exempted because it's a Gauntlet QA action (#491).
|
|
if paused
|
|
&& !matches!(
|
|
input.action,
|
|
PlayerAction::Pause | PlayerAction::Unpause | PlayerAction::TeleportToHub
|
|
)
|
|
{
|
|
continue;
|
|
}
|
|
match input.action {
|
|
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");
|
|
}
|
|
PlayerAction::Unpause => {
|
|
time.tick_rate = TickRate::Full;
|
|
tracing::debug!("Simulation unpaused by player input");
|
|
}
|
|
PlayerAction::SetTickRate(rate) => {
|
|
time.tick_rate = rate;
|
|
tracing::debug!("Tick rate set to {:?} by player input", rate);
|
|
}
|
|
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);
|
|
}
|
|
Some("Talk") => {
|
|
handle_talk(
|
|
&mut commands,
|
|
®istry,
|
|
&player_query,
|
|
&all_positions,
|
|
target_entity_id,
|
|
);
|
|
}
|
|
Some("Confront") => {
|
|
handle_confront(
|
|
&mut commands,
|
|
®istry,
|
|
&player_query,
|
|
&all_positions,
|
|
target_entity_id,
|
|
);
|
|
}
|
|
Some("Reset") => {
|
|
handle_reset(
|
|
&mut commands,
|
|
®istry,
|
|
&reset_triggers,
|
|
&mut room_snapshots,
|
|
target_entity_id,
|
|
current_tick,
|
|
);
|
|
}
|
|
_ => {
|
|
tracing::info!(
|
|
"Interact: target={:?}, verb={:?} — logged only",
|
|
target_entity_id,
|
|
verb,
|
|
);
|
|
}
|
|
},
|
|
PlayerAction::WalkAway => {
|
|
if let Ok((player_entity, _, _, _)) = player_query.single() {
|
|
commands
|
|
.entity(player_entity)
|
|
.insert(crate::simulation::dialogue::WalkAwayRequest);
|
|
tracing::debug!("WalkAway: marker set on player");
|
|
}
|
|
}
|
|
PlayerAction::TeleportToHub => {
|
|
handle_teleport_to_hub(&mut player_query, &mut commands);
|
|
}
|
|
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: &mut Query<
|
|
(
|
|
Entity,
|
|
&TilePosition,
|
|
Option<&mut Stance>,
|
|
Option<&mut PlayerMoveCooldown>,
|
|
),
|
|
With<PlayerCharacter>,
|
|
>,
|
|
commands: &mut Commands,
|
|
dx: i32,
|
|
dy: i32,
|
|
) {
|
|
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),
|
|
});
|
|
// Update facing direction based on movement (D-015 vision cone)
|
|
commands
|
|
.entity(entity)
|
|
.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 Talk verb: set TalkRequest marker on the player entity for the target NPC.
|
|
/// The actual dialogue pipeline runs in process_talk_interaction (dialogue.rs).
|
|
/// Server-side range check: Talk requires CLOSE_RANGE (same as interaction system).
|
|
#[allow(clippy::type_complexity)]
|
|
fn handle_talk(
|
|
commands: &mut Commands,
|
|
registry: &EntityRegistry,
|
|
player_query: &Query<
|
|
(
|
|
Entity,
|
|
&TilePosition,
|
|
Option<&mut Stance>,
|
|
Option<&mut PlayerMoveCooldown>,
|
|
),
|
|
With<PlayerCharacter>,
|
|
>,
|
|
all_positions: &Query<&TilePosition>,
|
|
target_entity_id: Option<u64>,
|
|
) {
|
|
let Some(target_id) = target_entity_id else {
|
|
tracing::warn!("Talk verb without target_entity_id");
|
|
return;
|
|
};
|
|
|
|
let Ok((player_entity, 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, "Talk: target entity not in registry");
|
|
return;
|
|
};
|
|
|
|
// Server-side range check: reject Talk if target is beyond close range
|
|
if let Ok(target_pos) = all_positions.get(target_entity) {
|
|
let distance = player_pos
|
|
.manhattan_distance(target_pos)
|
|
.unwrap_or(u32::MAX);
|
|
if distance > crate::simulation::interaction::CLOSE_RANGE {
|
|
tracing::info!(
|
|
target_id,
|
|
distance,
|
|
"Talk: target out of range (max {})",
|
|
crate::simulation::interaction::CLOSE_RANGE,
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
commands
|
|
.entity(player_entity)
|
|
.insert(crate::simulation::dialogue::TalkRequest {
|
|
target: target_entity,
|
|
});
|
|
|
|
tracing::debug!(target_id, "Talk: TalkRequest marker set on player");
|
|
}
|
|
|
|
/// Handle Confront verb: set ConfrontationDelivered marker on the player entity (#520, D-063).
|
|
/// The confrontation response system runs in process_confrontation_response (dialogue.rs).
|
|
/// Server-side range check: Confront requires CLOSE_RANGE (same as Talk).
|
|
#[allow(clippy::type_complexity)]
|
|
fn handle_confront(
|
|
commands: &mut Commands,
|
|
registry: &EntityRegistry,
|
|
player_query: &Query<
|
|
(
|
|
Entity,
|
|
&TilePosition,
|
|
Option<&mut Stance>,
|
|
Option<&mut PlayerMoveCooldown>,
|
|
),
|
|
With<PlayerCharacter>,
|
|
>,
|
|
all_positions: &Query<&TilePosition>,
|
|
target_entity_id: Option<u64>,
|
|
) {
|
|
let Some(target_id) = target_entity_id else {
|
|
tracing::warn!("Confront verb without target_entity_id");
|
|
return;
|
|
};
|
|
|
|
let Ok((player_entity, 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, "Confront: target entity not in registry");
|
|
return;
|
|
};
|
|
|
|
// Server-side range check: reject Confront if target is beyond close range
|
|
if let Ok(target_pos) = all_positions.get(target_entity) {
|
|
let distance = player_pos
|
|
.manhattan_distance(target_pos)
|
|
.unwrap_or(u32::MAX);
|
|
if distance > crate::simulation::interaction::CLOSE_RANGE {
|
|
tracing::info!(
|
|
target_id,
|
|
distance,
|
|
"Confront: target out of range (max {})",
|
|
crate::simulation::interaction::CLOSE_RANGE,
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
commands
|
|
.entity(player_entity)
|
|
.insert(crate::simulation::dialogue::ConfrontationDelivered {
|
|
target: target_entity,
|
|
});
|
|
|
|
tracing::debug!(target_id, "Confront: ConfrontationDelivered marker set on player");
|
|
}
|
|
|
|
/// 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",
|
|
);
|
|
}
|
|
|
|
/// Handle Reset verb: restore a room's entities to their initial positions.
|
|
/// Target entity must have a RoomResetTrigger component. Respects debounce.
|
|
fn handle_reset(
|
|
commands: &mut Commands,
|
|
registry: &EntityRegistry,
|
|
reset_triggers: &Query<&RoomResetTrigger>,
|
|
room_snapshots: &mut Option<ResMut<RoomSnapshots>>,
|
|
target_entity_id: Option<u64>,
|
|
current_tick: u64,
|
|
) {
|
|
let Some(target_id) = target_entity_id else {
|
|
tracing::warn!("Reset verb without target_entity_id");
|
|
return;
|
|
};
|
|
|
|
let Some(snapshots) = room_snapshots.as_mut() else {
|
|
tracing::warn!("Reset verb but RoomSnapshots resource not available");
|
|
return;
|
|
};
|
|
|
|
let target_stable = StableId(target_id);
|
|
let Some(target_entity) = registry.to_entity(&target_stable) else {
|
|
tracing::warn!(target_id, "Reset: target entity not in registry");
|
|
return;
|
|
};
|
|
|
|
let Ok(trigger) = reset_triggers.get(target_entity) else {
|
|
tracing::warn!(target_id, "Reset: target is not a reset trigger");
|
|
return;
|
|
};
|
|
|
|
let Some(changes) = snapshots.plan_reset(&trigger.room_name, current_tick) else {
|
|
tracing::info!(
|
|
room = trigger.room_name.as_str(),
|
|
"Reset: debounced or unknown room"
|
|
);
|
|
return;
|
|
};
|
|
|
|
let mut restored = 0;
|
|
for (entity, position, is_floor_item) in changes {
|
|
if is_floor_item {
|
|
commands
|
|
.entity(entity)
|
|
.remove::<CarriedBy>()
|
|
.remove::<InventorySlot>()
|
|
.insert(position);
|
|
} else {
|
|
commands.entity(entity).insert(position);
|
|
}
|
|
restored += 1;
|
|
}
|
|
|
|
tracing::info!(
|
|
room = trigger.room_name.as_str(),
|
|
restored,
|
|
current_tick,
|
|
"Room reset executed via Reset verb"
|
|
);
|
|
}
|
|
|
|
/// Handle TeleportToHub: move player to hub spawn, clear interaction state (#491).
|
|
///
|
|
/// Gauntlet-only action. On non-Gauntlet maps (feature disabled), logs a warning
|
|
/// and returns. On Gauntlet maps, moves the player to HUB.spawn and removes
|
|
/// dialogue, monologue, and interaction markers to prevent stale state.
|
|
///
|
|
/// Does NOT affect: room state, inventory, game time, knowledge graph.
|
|
#[allow(clippy::type_complexity)]
|
|
fn handle_teleport_to_hub(
|
|
player_query: &mut Query<
|
|
(
|
|
Entity,
|
|
&TilePosition,
|
|
Option<&mut Stance>,
|
|
Option<&mut PlayerMoveCooldown>,
|
|
),
|
|
With<PlayerCharacter>,
|
|
>,
|
|
commands: &mut Commands,
|
|
) {
|
|
#[cfg(not(feature = "gauntlet"))]
|
|
{
|
|
tracing::warn!("TeleportToHub rejected: not a Gauntlet map");
|
|
return;
|
|
}
|
|
|
|
#[cfg(feature = "gauntlet")]
|
|
{
|
|
let Ok((player_entity, _, _, _)) = player_query.single() else {
|
|
return;
|
|
};
|
|
|
|
let hub_spawn = crate::test_world::constants::HUB.spawn;
|
|
|
|
// Move player to hub spawn
|
|
commands.entity(player_entity).insert(hub_spawn);
|
|
|
|
// Clear any pending movement
|
|
commands.entity(player_entity).remove::<MoveIntent>();
|
|
|
|
// Clear dialogue/interaction markers
|
|
commands
|
|
.entity(player_entity)
|
|
.remove::<crate::simulation::dialogue::TalkRequest>()
|
|
.remove::<crate::simulation::dialogue::ActiveDialogue>()
|
|
.remove::<crate::simulation::dialogue::WalkAwayRequest>();
|
|
|
|
tracing::info!(
|
|
x = hub_spawn.x,
|
|
y = hub_spawn.y,
|
|
z = hub_spawn.z,
|
|
"TeleportToHub: player moved to hub spawn"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn drain_returns_inputs_up_to_tick() {
|
|
let mut queue = InputQueue::default();
|
|
queue.push(PlayerInput {
|
|
tick: 1,
|
|
action: PlayerAction::MoveNorth,
|
|
});
|
|
queue.push(PlayerInput {
|
|
tick: 2,
|
|
action: PlayerAction::MoveSouth,
|
|
});
|
|
queue.push(PlayerInput {
|
|
tick: 5,
|
|
action: PlayerAction::Interact {
|
|
target_entity_id: None,
|
|
verb: None,
|
|
},
|
|
});
|
|
let inputs = queue.drain_for_tick(3);
|
|
assert_eq!(inputs.len(), 2);
|
|
assert_eq!(queue.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn drain_empty_queue_returns_empty() {
|
|
let mut queue = InputQueue::default();
|
|
let inputs = queue.drain_for_tick(10);
|
|
assert!(inputs.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
#[should_panic(expected = "tick ordering violated")]
|
|
fn push_rejects_out_of_order_in_debug() {
|
|
let mut queue = InputQueue::default();
|
|
queue.push(PlayerInput {
|
|
tick: 5,
|
|
action: PlayerAction::MoveNorth,
|
|
});
|
|
queue.push(PlayerInput {
|
|
tick: 2,
|
|
action: PlayerAction::MoveSouth,
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn process_input_move_creates_intent() {
|
|
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();
|
|
|
|
world.resource_mut::<InputQueue>().push(PlayerInput {
|
|
tick: 0,
|
|
action: PlayerAction::MoveNorth,
|
|
});
|
|
|
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
|
schedule.add_systems(process_player_input);
|
|
schedule.run(&mut world);
|
|
|
|
let intent = world.get::<MoveIntent>(player).unwrap();
|
|
assert_eq!(intent.target, TilePosition::new(5, 4, 0));
|
|
}
|
|
|
|
#[test]
|
|
fn process_input_pause_sets_paused() {
|
|
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,
|
|
action: PlayerAction::Pause,
|
|
});
|
|
|
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
|
schedule.add_systems(process_player_input);
|
|
schedule.run(&mut world);
|
|
|
|
assert_eq!(
|
|
world.resource::<SimulationTime>().tick_rate,
|
|
TickRate::Paused
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn process_input_set_tick_rate() {
|
|
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,
|
|
action: PlayerAction::SetTickRate(TickRate::Half),
|
|
});
|
|
|
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
|
schedule.add_systems(process_player_input);
|
|
schedule.run(&mut world);
|
|
|
|
assert_eq!(world.resource::<SimulationTime>().tick_rate, TickRate::Half);
|
|
}
|
|
|
|
#[test]
|
|
#[should_panic(expected = "PlayerCharacter entity must exist")]
|
|
fn process_input_no_player_panics() {
|
|
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,
|
|
action: PlayerAction::MoveNorth,
|
|
});
|
|
|
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
|
schedule.add_systems(process_player_input);
|
|
schedule.run(&mut world);
|
|
}
|
|
|
|
#[test]
|
|
fn process_input_future_tick_ignored() {
|
|
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();
|
|
|
|
world.resource_mut::<InputQueue>().push(PlayerInput {
|
|
tick: 5,
|
|
action: PlayerAction::MoveNorth,
|
|
});
|
|
|
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
|
schedule.add_systems(process_player_input);
|
|
schedule.run(&mut world);
|
|
|
|
// 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"
|
|
);
|
|
}
|
|
|
|
// === Pause Guard Tests (#461, #462, #463) ===
|
|
// Prevent Bug #3 recurrence: player movement while paused.
|
|
// The pause guard at process_player_input discards movement inputs
|
|
// when SimulationTime.tick_rate == TickRate::Paused (D-052).
|
|
|
|
#[test]
|
|
fn movement_discarded_while_paused() {
|
|
// #461: Movement input rejected while paused — prevents Bug #3 recurrence.
|
|
let mut world = bevy_ecs::world::World::new();
|
|
world.insert_resource(InputQueue::default());
|
|
let mut time = SimulationTime::default();
|
|
time.tick_rate = TickRate::Paused;
|
|
world.insert_resource(time);
|
|
world.init_resource::<crate::knowledge::EntityRegistry>();
|
|
|
|
let player = world
|
|
.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)))
|
|
.id();
|
|
|
|
world.resource_mut::<InputQueue>().push(PlayerInput {
|
|
tick: 0,
|
|
action: PlayerAction::MoveNorth,
|
|
});
|
|
|
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
|
schedule.add_systems(process_player_input);
|
|
schedule.run(&mut world);
|
|
|
|
// Movement must be discarded — no MoveIntent created
|
|
assert!(
|
|
world.get::<MoveIntent>(player).is_none(),
|
|
"MoveNorth must be discarded while paused (Bug #3 guard)"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn unpause_accepted_while_paused() {
|
|
// #462: Unpause command is the one control action allowed while paused.
|
|
let mut world = bevy_ecs::world::World::new();
|
|
world.insert_resource(InputQueue::default());
|
|
let mut time = SimulationTime::default();
|
|
time.tick_rate = TickRate::Paused;
|
|
world.insert_resource(time);
|
|
world.init_resource::<crate::knowledge::EntityRegistry>();
|
|
|
|
// Player entity required for process_player_input (even if no movement)
|
|
world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)));
|
|
|
|
world.resource_mut::<InputQueue>().push(PlayerInput {
|
|
tick: 0,
|
|
action: PlayerAction::Unpause,
|
|
});
|
|
|
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
|
schedule.add_systems(process_player_input);
|
|
schedule.run(&mut world);
|
|
|
|
assert_eq!(
|
|
world.resource::<SimulationTime>().tick_rate,
|
|
TickRate::Full,
|
|
"Unpause must be accepted while paused"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn pause_unpause_roundtrip_with_movement() {
|
|
// #463: Full cycle — pause -> move (rejected) -> unpause -> move (accepted).
|
|
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);
|
|
|
|
// Step 1: Pause
|
|
world.resource_mut::<InputQueue>().push(PlayerInput {
|
|
tick: 0,
|
|
action: PlayerAction::Pause,
|
|
});
|
|
schedule.run(&mut world);
|
|
assert_eq!(
|
|
world.resource::<SimulationTime>().tick_rate,
|
|
TickRate::Paused,
|
|
"Step 1: game should be paused"
|
|
);
|
|
|
|
// Step 2: Move while paused — must be rejected
|
|
world.resource_mut::<InputQueue>().push(PlayerInput {
|
|
tick: 0,
|
|
action: PlayerAction::MoveNorth,
|
|
});
|
|
schedule.run(&mut world);
|
|
assert!(
|
|
world.get::<MoveIntent>(player).is_none(),
|
|
"Step 2: movement must be rejected while paused"
|
|
);
|
|
|
|
// Step 3: Unpause
|
|
world.resource_mut::<InputQueue>().push(PlayerInput {
|
|
tick: 0,
|
|
action: PlayerAction::Unpause,
|
|
});
|
|
schedule.run(&mut world);
|
|
assert_eq!(
|
|
world.resource::<SimulationTime>().tick_rate,
|
|
TickRate::Full,
|
|
"Step 3: game should be unpaused"
|
|
);
|
|
|
|
// Step 4: Move after unpause — must succeed
|
|
world.resource_mut::<InputQueue>().push(PlayerInput {
|
|
tick: 0,
|
|
action: PlayerAction::MoveNorth,
|
|
});
|
|
schedule.run(&mut world);
|
|
assert!(
|
|
world.get::<MoveIntent>(player).is_some(),
|
|
"Step 4: movement must succeed after unpause"
|
|
);
|
|
}
|
|
|
|
// === Remaining Pause Guard Tests (#468) ===
|
|
// Edge cases: stance, interact, batch discard, and SetTickRate while paused.
|
|
|
|
#[test]
|
|
fn stance_toggle_rejected_while_paused() {
|
|
// #468: Stance toggle rejected while paused.
|
|
let mut world = bevy_ecs::world::World::new();
|
|
world.insert_resource(InputQueue::default());
|
|
let mut time = SimulationTime::default();
|
|
time.tick_rate = TickRate::Paused;
|
|
world.insert_resource(time);
|
|
world.init_resource::<crate::knowledge::EntityRegistry>();
|
|
|
|
world.spawn((
|
|
PlayerCharacter,
|
|
TilePosition::new(5, 5, 0),
|
|
Stance::default(), // Walk
|
|
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::Walk,
|
|
"Stance toggle must be rejected while paused"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn interact_rejected_while_paused() {
|
|
// #468: Interact rejected while paused.
|
|
// This test verifies no panic and no side effects — interact is a no-op while paused.
|
|
let mut world = bevy_ecs::world::World::new();
|
|
world.insert_resource(InputQueue::default());
|
|
let mut time = SimulationTime::default();
|
|
time.tick_rate = TickRate::Paused;
|
|
world.insert_resource(time);
|
|
world.init_resource::<crate::knowledge::EntityRegistry>();
|
|
|
|
let player = world
|
|
.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)))
|
|
.id();
|
|
world
|
|
.resource_mut::<crate::knowledge::EntityRegistry>()
|
|
.register(player);
|
|
|
|
// Spawn item on the ground
|
|
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);
|
|
|
|
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 must remain on ground — Take rejected while paused
|
|
assert!(
|
|
world.get::<TilePosition>(item).is_some(),
|
|
"Item must stay on ground — interact rejected while paused"
|
|
);
|
|
assert!(
|
|
world.get::<CarriedBy>(item).is_none(),
|
|
"Item must not be picked up while paused"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn batch_discard_while_paused() {
|
|
// #468: All inputs in a batch discarded while paused (except Pause/Unpause).
|
|
let mut world = bevy_ecs::world::World::new();
|
|
world.insert_resource(InputQueue::default());
|
|
let mut time = SimulationTime::default();
|
|
time.tick_rate = TickRate::Paused;
|
|
world.insert_resource(time);
|
|
world.init_resource::<crate::knowledge::EntityRegistry>();
|
|
|
|
let player = world
|
|
.spawn((
|
|
PlayerCharacter,
|
|
TilePosition::new(5, 5, 0),
|
|
Stance::default(),
|
|
PlayerMoveCooldown::default(),
|
|
))
|
|
.id();
|
|
|
|
// Push a batch of mixed inputs — all should be discarded except Unpause
|
|
let queue = &mut world.resource_mut::<InputQueue>();
|
|
queue.push(PlayerInput {
|
|
tick: 0,
|
|
action: PlayerAction::MoveNorth,
|
|
});
|
|
queue.push(PlayerInput {
|
|
tick: 0,
|
|
action: PlayerAction::ToggleStanceUp,
|
|
});
|
|
queue.push(PlayerInput {
|
|
tick: 0,
|
|
action: PlayerAction::SetTickRate(TickRate::Half),
|
|
});
|
|
|
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
|
schedule.add_systems(process_player_input);
|
|
schedule.run(&mut world);
|
|
|
|
// All gameplay actions discarded
|
|
assert!(
|
|
world.get::<MoveIntent>(player).is_none(),
|
|
"Movement discarded in batch"
|
|
);
|
|
let mut query = world.query::<&Stance>();
|
|
let stance = query.single(&world).unwrap();
|
|
assert_eq!(stance.0, MovementStance::Walk, "Stance unchanged in batch");
|
|
assert_eq!(
|
|
world.resource::<SimulationTime>().tick_rate,
|
|
TickRate::Paused,
|
|
"SetTickRate discarded in batch — still paused"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn set_tick_rate_rejected_while_paused() {
|
|
// #468 / R2-OQ-01: SetTickRate(Half) while paused is a bug — must be rejected.
|
|
let mut world = bevy_ecs::world::World::new();
|
|
world.insert_resource(InputQueue::default());
|
|
let mut time = SimulationTime::default();
|
|
time.tick_rate = TickRate::Paused;
|
|
world.insert_resource(time);
|
|
world.init_resource::<crate::knowledge::EntityRegistry>();
|
|
|
|
world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)));
|
|
|
|
world.resource_mut::<InputQueue>().push(PlayerInput {
|
|
tick: 0,
|
|
action: PlayerAction::SetTickRate(TickRate::Half),
|
|
});
|
|
|
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
|
schedule.add_systems(process_player_input);
|
|
schedule.run(&mut world);
|
|
|
|
assert_eq!(
|
|
world.resource::<SimulationTime>().tick_rate,
|
|
TickRate::Paused,
|
|
"SetTickRate must be rejected while paused (R2-OQ-01)"
|
|
);
|
|
}
|
|
|
|
#[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
|
|
}
|
|
|
|
// === Room Reset Tests (#490) ===
|
|
|
|
#[test]
|
|
fn reset_verb_restores_floor_item() {
|
|
// #490: Take a floor item, then Reset verb restores it to original position.
|
|
let mut world = bevy_ecs::world::World::new();
|
|
world.insert_resource(InputQueue::default());
|
|
world.insert_resource(SimulationTime::default());
|
|
world.init_resource::<crate::knowledge::EntityRegistry>();
|
|
|
|
// Player
|
|
let player = world
|
|
.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)))
|
|
.id();
|
|
let player_sid = world
|
|
.resource_mut::<crate::knowledge::EntityRegistry>()
|
|
.register(player);
|
|
|
|
// Floor item at (5, 4)
|
|
let item = world
|
|
.spawn((TilePosition::new(5, 4, 0), ItemName("Keycard".into())))
|
|
.id();
|
|
let item_sid = world
|
|
.resource_mut::<crate::knowledge::EntityRegistry>()
|
|
.register(item);
|
|
|
|
// Reset plate entity
|
|
let plate = world
|
|
.spawn((
|
|
crate::simulation::interaction::Interactable,
|
|
RoomResetTrigger {
|
|
room_name: "test_room".to_string(),
|
|
},
|
|
TilePosition::new(5, 3, 0),
|
|
))
|
|
.id();
|
|
let plate_sid = world
|
|
.resource_mut::<crate::knowledge::EntityRegistry>()
|
|
.register(plate);
|
|
|
|
// Record snapshot: item is a floor item at its original position
|
|
let mut snapshots = RoomSnapshots::default();
|
|
snapshots.record("test_room", item, TilePosition::new(5, 4, 0), true);
|
|
world.insert_resource(snapshots);
|
|
|
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
|
schedule.add_systems(process_player_input);
|
|
|
|
// Step 1: Take the item
|
|
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 should be picked up"
|
|
);
|
|
assert_eq!(world.get::<CarriedBy>(item).unwrap().0, player_sid);
|
|
|
|
// Step 2: Reset via verb
|
|
world.resource_mut::<InputQueue>().push(PlayerInput {
|
|
tick: 1,
|
|
action: PlayerAction::Interact {
|
|
target_entity_id: Some(plate_sid.0),
|
|
verb: Some("Reset".into()),
|
|
},
|
|
});
|
|
world.resource_mut::<SimulationTime>().tick = 1;
|
|
schedule.run(&mut world);
|
|
|
|
// Item should be back on the ground at original position
|
|
let pos = world
|
|
.get::<TilePosition>(item)
|
|
.expect("Item should be restored to ground");
|
|
assert_eq!(
|
|
*pos,
|
|
TilePosition::new(5, 4, 0),
|
|
"Item at original position"
|
|
);
|
|
assert!(
|
|
world.get::<CarriedBy>(item).is_none(),
|
|
"CarriedBy removed after reset"
|
|
);
|
|
assert!(
|
|
world.get::<InventorySlot>(item).is_none(),
|
|
"InventorySlot removed after reset"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn reset_verb_debounces() {
|
|
// #490: Reset debounce prevents rapid-fire resets.
|
|
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();
|
|
|
|
// NPC entity
|
|
let npc = world.spawn(TilePosition::new(10, 10, 0)).id();
|
|
world
|
|
.resource_mut::<crate::knowledge::EntityRegistry>()
|
|
.register(npc);
|
|
|
|
// Reset plate
|
|
let plate = world
|
|
.spawn((
|
|
crate::simulation::interaction::Interactable,
|
|
RoomResetTrigger {
|
|
room_name: "test_room".to_string(),
|
|
},
|
|
TilePosition::new(5, 3, 0),
|
|
))
|
|
.id();
|
|
let plate_sid = world
|
|
.resource_mut::<crate::knowledge::EntityRegistry>()
|
|
.register(plate);
|
|
|
|
let mut snapshots = RoomSnapshots::default();
|
|
snapshots.record("test_room", npc, TilePosition::new(10, 10, 0), false);
|
|
world.insert_resource(snapshots);
|
|
|
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
|
schedule.add_systems(process_player_input);
|
|
|
|
// First reset at tick 0 — should succeed
|
|
world.resource_mut::<InputQueue>().push(PlayerInput {
|
|
tick: 0,
|
|
action: PlayerAction::Interact {
|
|
target_entity_id: Some(plate_sid.0),
|
|
verb: Some("Reset".into()),
|
|
},
|
|
});
|
|
schedule.run(&mut world);
|
|
|
|
// Move NPC to verify debounce blocks second reset
|
|
*world.get_mut::<TilePosition>(npc).unwrap() = TilePosition::new(20, 20, 0);
|
|
|
|
// Second reset at tick 5 — should be debounced (< 10 ticks)
|
|
world.resource_mut::<InputQueue>().push(PlayerInput {
|
|
tick: 5,
|
|
action: PlayerAction::Interact {
|
|
target_entity_id: Some(plate_sid.0),
|
|
verb: Some("Reset".into()),
|
|
},
|
|
});
|
|
world.resource_mut::<SimulationTime>().tick = 5;
|
|
schedule.run(&mut world);
|
|
|
|
// NPC should still be at moved position (reset was debounced)
|
|
assert_eq!(
|
|
world.get::<TilePosition>(npc).unwrap().x,
|
|
20,
|
|
"NPC not reset — debounced"
|
|
);
|
|
|
|
// Third reset at tick 10 — should succeed
|
|
world.resource_mut::<InputQueue>().push(PlayerInput {
|
|
tick: 10,
|
|
action: PlayerAction::Interact {
|
|
target_entity_id: Some(plate_sid.0),
|
|
verb: Some("Reset".into()),
|
|
},
|
|
});
|
|
world.resource_mut::<SimulationTime>().tick = 10;
|
|
schedule.run(&mut world);
|
|
|
|
// NPC should be back at original position
|
|
assert_eq!(
|
|
world.get::<TilePosition>(npc).unwrap().x,
|
|
10,
|
|
"NPC reset after debounce elapsed"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn reset_verb_without_snapshots_is_noop() {
|
|
// Reset verb when no RoomSnapshots resource exists 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)));
|
|
|
|
let plate = world
|
|
.spawn((
|
|
crate::simulation::interaction::Interactable,
|
|
RoomResetTrigger {
|
|
room_name: "test_room".to_string(),
|
|
},
|
|
TilePosition::new(5, 3, 0),
|
|
))
|
|
.id();
|
|
let plate_sid = world
|
|
.resource_mut::<crate::knowledge::EntityRegistry>()
|
|
.register(plate);
|
|
|
|
// No RoomSnapshots resource inserted — should be gracefully handled
|
|
world.resource_mut::<InputQueue>().push(PlayerInput {
|
|
tick: 0,
|
|
action: PlayerAction::Interact {
|
|
target_entity_id: Some(plate_sid.0),
|
|
verb: Some("Reset".into()),
|
|
},
|
|
});
|
|
|
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
|
schedule.add_systems(process_player_input);
|
|
schedule.run(&mut world); // should not panic
|
|
}
|
|
|
|
// === TeleportToHub Tests (#491) ===
|
|
|
|
#[cfg(feature = "gauntlet")]
|
|
#[test]
|
|
fn teleport_to_hub_moves_player() {
|
|
// #491: TeleportToHub moves player to hub spawn position.
|
|
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 at a non-hub position
|
|
let player = world
|
|
.spawn((PlayerCharacter, TilePosition::new(84, 58, 0)))
|
|
.id();
|
|
|
|
world.resource_mut::<InputQueue>().push(PlayerInput {
|
|
tick: 0,
|
|
action: PlayerAction::TeleportToHub,
|
|
});
|
|
|
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
|
schedule.add_systems(process_player_input);
|
|
schedule.run(&mut world);
|
|
|
|
let pos = world.get::<TilePosition>(player).expect("player has position");
|
|
let hub_spawn = crate::test_world::constants::HUB.spawn;
|
|
assert_eq!(pos.x, hub_spawn.x, "player x at hub spawn");
|
|
assert_eq!(pos.y, hub_spawn.y, "player y at hub spawn");
|
|
assert_eq!(pos.z, hub_spawn.z, "player z at hub spawn");
|
|
}
|
|
|
|
#[cfg(feature = "gauntlet")]
|
|
#[test]
|
|
fn teleport_to_hub_clears_dialogue_markers() {
|
|
// #491: TeleportToHub removes ActiveDialogue and TalkRequest.
|
|
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 a fake NPC target
|
|
let npc = world.spawn(TilePosition::new(10, 10, 0)).id();
|
|
|
|
// Spawn player with active dialogue state
|
|
let player = world
|
|
.spawn((
|
|
PlayerCharacter,
|
|
TilePosition::new(84, 58, 0),
|
|
crate::simulation::dialogue::TalkRequest { target: npc },
|
|
crate::simulation::dialogue::ActiveDialogue {
|
|
target: npc,
|
|
interaction_type: crate::knowledge::events::InteractionType::Talk,
|
|
started_tick: 0,
|
|
},
|
|
))
|
|
.id();
|
|
|
|
world.resource_mut::<InputQueue>().push(PlayerInput {
|
|
tick: 0,
|
|
action: PlayerAction::TeleportToHub,
|
|
});
|
|
|
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
|
schedule.add_systems(process_player_input);
|
|
schedule.run(&mut world);
|
|
|
|
assert!(
|
|
world
|
|
.get::<crate::simulation::dialogue::TalkRequest>(player)
|
|
.is_none(),
|
|
"TalkRequest cleared after teleport"
|
|
);
|
|
assert!(
|
|
world
|
|
.get::<crate::simulation::dialogue::ActiveDialogue>(player)
|
|
.is_none(),
|
|
"ActiveDialogue cleared after teleport"
|
|
);
|
|
}
|
|
|
|
#[cfg(feature = "gauntlet")]
|
|
#[test]
|
|
fn teleport_to_hub_clears_move_intent() {
|
|
// #491: TeleportToHub removes any pending MoveIntent.
|
|
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(84, 58, 0),
|
|
MoveIntent {
|
|
target: TilePosition::new(85, 58, 0),
|
|
},
|
|
))
|
|
.id();
|
|
|
|
world.resource_mut::<InputQueue>().push(PlayerInput {
|
|
tick: 0,
|
|
action: PlayerAction::TeleportToHub,
|
|
});
|
|
|
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
|
schedule.add_systems(process_player_input);
|
|
schedule.run(&mut world);
|
|
|
|
assert!(
|
|
world.get::<MoveIntent>(player).is_none(),
|
|
"MoveIntent cleared after teleport"
|
|
);
|
|
}
|
|
|
|
#[cfg(feature = "gauntlet")]
|
|
#[test]
|
|
fn teleport_to_hub_allowed_while_paused() {
|
|
// #491: TeleportToHub is a QA action — allowed even when paused.
|
|
let mut world = bevy_ecs::world::World::new();
|
|
world.insert_resource(InputQueue::default());
|
|
let mut time = SimulationTime::default();
|
|
time.tick_rate = TickRate::Paused;
|
|
world.insert_resource(time);
|
|
world.init_resource::<crate::knowledge::EntityRegistry>();
|
|
|
|
let player = world
|
|
.spawn((PlayerCharacter, TilePosition::new(84, 58, 0)))
|
|
.id();
|
|
|
|
world.resource_mut::<InputQueue>().push(PlayerInput {
|
|
tick: 0,
|
|
action: PlayerAction::TeleportToHub,
|
|
});
|
|
|
|
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
|
schedule.add_systems(process_player_input);
|
|
schedule.run(&mut world);
|
|
|
|
let pos = world.get::<TilePosition>(player).expect("player has position");
|
|
let hub_spawn = crate::test_world::constants::HUB.spawn;
|
|
assert_eq!(pos.x, hub_spawn.x, "teleport works while paused");
|
|
}
|
|
}
|