feat(simulation): smuggler inventory — CarriedBy, Take/Place verbs (#424)

Implement physical inventory system per D-065:

- CarriedBy(StableId) component marks items as carried by an entity
- ItemName and InventorySlot components for display and slot assignment
- Take verb: removes TilePosition, adds CarriedBy + InventorySlot
- Place verb: removes CarriedBy + InventorySlot, adds TilePosition
- Observer snapshot populates player_inventory from carried items query
- Info boundary: carried items lack TilePosition, naturally excluded
  from all spatial queries (visibility, interactions) for other entities
- 9-slot capacity (3x3 grid per OQ-24), auto-assigns first free slot
- 8 new tests covering take, place, sequential slots, full rejection

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-14 15:44:37 +01:00
co-authored by Claude Opus 4.6
parent 32422ac4bc
commit 6ef7029707
4 changed files with 609 additions and 10 deletions
+319 -7
View File
@@ -3,7 +3,11 @@
// 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};
@@ -56,16 +60,18 @@ 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.
/// 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);
@@ -137,12 +143,33 @@ pub fn process_player_input(
time.tick_rate = rate;
tracing::debug!("Tick rate set to {:?} by player input", rate);
}
PlayerAction::Interact { target_entity_id, verb } => {
tracing::info!(
"Interact: target={:?}, verb={:?} — logged only, dialogue dispatch future scope (#415)",
target_entity_id,
verb,
);
PlayerAction::Interact { target_entity_id, ref verb } => {
match verb.as_deref() {
Some("Take") => {
handle_take(
&mut commands,
&registry,
&player_query,
&inventory_items,
target_entity_id,
);
}
Some("Place") => {
handle_place(
&mut commands,
&registry,
&player_query,
target_entity_id,
);
}
_ => {
tracing::info!(
"Interact: target={:?}, verb={:?} — logged only, dialogue dispatch future scope (#415)",
target_entity_id,
verb,
);
}
}
}
PlayerAction::UsePerceptionMode(ref mode) => {
tracing::trace!("UsePerceptionMode({}) — no-op for Sprint 1", mode);
@@ -195,6 +222,105 @@ fn apply_move(
.insert(Facing(facing_from_delta(dx, dy)));
}
/// Handle Take verb: pick up an item entity and add it to the player's inventory.
/// Removes TilePosition (item is no longer on the ground — info boundary enforcement),
/// adds CarriedBy + InventorySlot components.
#[allow(clippy::type_complexity)]
fn handle_take(
commands: &mut Commands,
registry: &EntityRegistry,
player_query: &Query<
(Entity, &TilePosition, Option<&mut Stance>, Option<&mut PlayerMoveCooldown>),
With<PlayerCharacter>,
>,
inventory_items: &Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>,
target_entity_id: Option<u64>,
) {
let Some(target_id) = target_entity_id else {
tracing::warn!("Take verb without target_entity_id");
return;
};
let Ok((player_entity, _, _, _)) = player_query.single() else {
return;
};
let Some(player_sid) = registry.to_stable(player_entity) else {
tracing::error!("Player entity not in EntityRegistry");
return;
};
// Resolve wire ID to ECS entity
let target_stable = StableId(target_id);
let Some(target_entity) = registry.to_entity(&target_stable) else {
tracing::warn!(target_id, "Take: target entity not in registry");
return;
};
// Check inventory capacity
let occupied = occupied_slots_for(player_sid, inventory_items);
let Some(slot) = find_next_slot(&occupied) else {
tracing::info!("Inventory full ({} slots), cannot take item", MAX_INVENTORY_SLOTS);
return;
};
// Remove TilePosition (item leaves the ground), add CarriedBy + InventorySlot
commands.entity(target_entity)
.remove::<TilePosition>()
.insert((CarriedBy(player_sid), InventorySlot(slot)));
tracing::info!(
target_id,
slot,
"Take: item picked up and added to inventory slot",
);
}
/// Handle Place verb: remove an item from inventory and place it on the ground
/// at the player's current position. Removes CarriedBy + InventorySlot, adds
/// TilePosition at the player's current tile.
#[allow(clippy::type_complexity)]
fn handle_place(
commands: &mut Commands,
registry: &EntityRegistry,
player_query: &Query<
(Entity, &TilePosition, Option<&mut Stance>, Option<&mut PlayerMoveCooldown>),
With<PlayerCharacter>,
>,
target_entity_id: Option<u64>,
) {
let Some(target_id) = target_entity_id else {
tracing::warn!("Place verb without target_entity_id");
return;
};
let Ok((_, player_pos, _, _)) = player_query.single() else {
return;
};
let target_stable = StableId(target_id);
let Some(target_entity) = registry.to_entity(&target_stable) else {
tracing::warn!(target_id, "Place: target entity not in registry");
return;
};
let place_pos = *player_pos;
// Remove inventory components, place item at player's tile
commands.entity(target_entity)
.remove::<CarriedBy>()
.remove::<InventorySlot>()
.insert(place_pos);
tracing::info!(
target_id,
x = place_pos.x,
y = place_pos.y,
z = place_pos.z,
"Place: item dropped at player position",
);
}
#[cfg(test)]
mod tests {
use super::*;
@@ -245,6 +371,7 @@ mod tests {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(InputQueue::default());
world.insert_resource(SimulationTime::default());
world.init_resource::<crate::knowledge::EntityRegistry>();
let player = world
.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)))
@@ -268,6 +395,7 @@ mod tests {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(InputQueue::default());
world.insert_resource(SimulationTime::default());
world.init_resource::<crate::knowledge::EntityRegistry>();
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
@@ -286,6 +414,7 @@ mod tests {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(InputQueue::default());
world.insert_resource(SimulationTime::default());
world.init_resource::<crate::knowledge::EntityRegistry>();
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
@@ -305,6 +434,7 @@ mod tests {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(InputQueue::default());
world.insert_resource(SimulationTime::default());
world.init_resource::<crate::knowledge::EntityRegistry>();
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
@@ -321,6 +451,7 @@ mod tests {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(InputQueue::default());
world.insert_resource(SimulationTime::default());
world.init_resource::<crate::knowledge::EntityRegistry>();
let player = world
.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)))
@@ -346,6 +477,7 @@ mod tests {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(InputQueue::default());
world.insert_resource(SimulationTime::default());
world.init_resource::<crate::knowledge::EntityRegistry>();
world.spawn((
PlayerCharacter,
@@ -373,6 +505,7 @@ mod tests {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(InputQueue::default());
world.insert_resource(SimulationTime::default());
world.init_resource::<crate::knowledge::EntityRegistry>();
world.spawn((
PlayerCharacter,
@@ -400,6 +533,7 @@ mod tests {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(InputQueue::default());
world.insert_resource(SimulationTime::default());
world.init_resource::<crate::knowledge::EntityRegistry>();
let player = world
.spawn((
@@ -446,6 +580,7 @@ mod tests {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(InputQueue::default());
world.insert_resource(SimulationTime::default());
world.init_resource::<crate::knowledge::EntityRegistry>();
let player = world
.spawn((
@@ -483,6 +618,7 @@ mod tests {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(InputQueue::default());
world.insert_resource(SimulationTime::default());
world.init_resource::<crate::knowledge::EntityRegistry>();
let player = world
.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)))
@@ -507,4 +643,180 @@ mod tests {
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");
}
}