Files
settled-reach/server/src/simulation/input.rs
T
jpmschweitzerandClaude Opus 4.6 3d63a4f9f6 test(simulation): QA coverage for Phase 2 verbs, sprint suppression, occupancy, inventory
Add comprehensive test suites across sprint 6 systems:
- Phase 2 observer filter: Confront injection, archetype label relabeling,
  contradiction marking, POI priority ordering (12 tests)
- Sprint anomaly: detection during sprint, queue semantics, delay timing,
  D-055 visible-but-suppressed integration (7 tests)
- Wire format: Confront and CharacterArchetype msgpack roundtrip (4 tests)
- TilePresence: 4-layer coexistence on same tile (1 test)
- Inventory: Take/Place full roundtrip, capacity enforcement (3 tests)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 16:00:32 +01:00

924 lines
33 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 bevy_ecs::prelude::*;
use std::collections::VecDeque;
/// 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.
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,
);
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)]
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)>,
) {
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 => {
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,
&registry,
&player_query,
&inventory_items,
target_entity_id,
);
}
Some("Place") => {
handle_place(
&mut commands,
&registry,
&player_query,
target_entity_id,
);
}
_ => {
tracing::info!(
"Interact: target={:?}, verb={:?} — logged only, dialogue dispatch future scope (#415)",
target_entity_id,
verb,
);
}
}
}
PlayerAction::UsePerceptionMode(ref mode) => {
tracing::trace!("UsePerceptionMode({}) — no-op for Sprint 1", mode);
}
}
}
// If no movement was attempted this tick, still advance cooldown counter
if !move_attempted {
if let Ok((_, _, _, Some(mut cooldown))) = player_query.single_mut() {
cooldown.tick();
}
}
}
/// Apply a movement action with stance-based cooldown enforcement.
/// If the player has a Stance and PlayerMoveCooldown, movement is throttled
/// according to the stance's ticks_per_move. Without these components,
/// movement is unrestricted (backward compatibility).
#[allow(clippy::type_complexity)]
fn apply_move(
player_query: &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 Place verb: remove an item from inventory and place it on the ground
/// at the player's current position. Removes CarriedBy + InventorySlot, adds
/// TilePosition at the player's current tile.
#[allow(clippy::type_complexity)]
fn handle_place(
commands: &mut Commands,
registry: &EntityRegistry,
player_query: &Query<
(Entity, &TilePosition, Option<&mut Stance>, Option<&mut PlayerMoveCooldown>),
With<PlayerCharacter>,
>,
target_entity_id: Option<u64>,
) {
let Some(target_id) = target_entity_id else {
tracing::warn!("Place verb without target_entity_id");
return;
};
let Ok((_, player_pos, _, _)) = player_query.single() else {
return;
};
let target_stable = StableId(target_id);
let Some(target_entity) = registry.to_entity(&target_stable) else {
tracing::warn!(target_id, "Place: target entity not in registry");
return;
};
let place_pos = *player_pos;
// Remove inventory components, place item at player's tile
commands.entity(target_entity)
.remove::<CarriedBy>()
.remove::<InventorySlot>()
.insert(place_pos);
tracing::info!(
target_id,
x = place_pos.x,
y = place_pos.y,
z = place_pos.z,
"Place: item dropped at player position",
);
}
#[cfg(test)]
mod tests {
use super::*;
#[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");
}
#[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
}
}