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:
@@ -15,6 +15,7 @@ use crate::knowledge::{EntityRegistry, KnowledgeGraph, StableId};
|
||||
use crate::perception::query::{ActivePerceptionMode, VisibilityGeometry};
|
||||
use crate::perception::vision_cone::Facing;
|
||||
use crate::simulation::interaction::NearbyInteractionBuffer;
|
||||
use crate::simulation::inventory::{CarriedBy, InventorySlot, ItemName};
|
||||
use crate::simulation::monologue::MonologueBuffer;
|
||||
use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
|
||||
use crate::simulation::stance::Stance;
|
||||
@@ -52,7 +53,7 @@ pub fn compute_observer_snapshot(
|
||||
geometry: Res<VisibilityGeometry>,
|
||||
registry: Res<EntityRegistry>,
|
||||
mut observer_query: Query<
|
||||
(&TilePosition, Option<&Facing>, &KnowledgeGraph, &mut NearbyInteractionBuffer, &mut MonologueBuffer, Option<&Stance>, Option<&CharacterArchetype>),
|
||||
(Entity, &TilePosition, Option<&Facing>, &KnowledgeGraph, &mut NearbyInteractionBuffer, &mut MonologueBuffer, Option<&Stance>, Option<&CharacterArchetype>),
|
||||
With<PlayerCharacter>,
|
||||
>,
|
||||
all_entities: Query<(
|
||||
@@ -61,9 +62,10 @@ pub fn compute_observer_snapshot(
|
||||
Option<&PlayerCharacter>,
|
||||
Option<&crate::npc::Npc>,
|
||||
)>,
|
||||
inventory_items: Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>,
|
||||
mut buffer: ResMut<SnapshotBuffer>,
|
||||
) {
|
||||
let Ok((_observer_pos, facing_opt, observer_kg, mut interaction_buffer, mut monologue_buffer, stance_opt, archetype_opt)) =
|
||||
let Ok((observer_entity, _observer_pos, facing_opt, observer_kg, mut interaction_buffer, mut monologue_buffer, stance_opt, archetype_opt)) =
|
||||
observer_query.single_mut()
|
||||
else {
|
||||
return;
|
||||
@@ -75,6 +77,18 @@ pub fn compute_observer_snapshot(
|
||||
|
||||
let archetype = archetype_opt.copied().unwrap_or_default();
|
||||
|
||||
// Collect player inventory (D-065 info boundary: only own items)
|
||||
let player_inventory = registry
|
||||
.to_stable(observer_entity)
|
||||
.map(|player_sid| {
|
||||
crate::simulation::inventory::collect_inventory_for(
|
||||
player_sid,
|
||||
&inventory_items,
|
||||
®istry,
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let (mut entities, visible_ids) =
|
||||
filter_visible_entities(&geometry, ®istry, observer_kg, &all_entities);
|
||||
|
||||
@@ -114,7 +128,7 @@ pub fn compute_observer_snapshot(
|
||||
game_time,
|
||||
player_facing: facing,
|
||||
player_stance: stance_opt.map(|s| s.0).unwrap_or_default(),
|
||||
player_inventory: Vec::new(),
|
||||
player_inventory,
|
||||
entities,
|
||||
visible_tiles: geometry.visible_tiles.clone(),
|
||||
nearby_interactions,
|
||||
|
||||
@@ -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,
|
||||
®istry,
|
||||
&player_query,
|
||||
&inventory_items,
|
||||
target_entity_id,
|
||||
);
|
||||
}
|
||||
Some("Place") => {
|
||||
handle_place(
|
||||
&mut commands,
|
||||
®istry,
|
||||
&player_query,
|
||||
target_entity_id,
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
tracing::info!(
|
||||
"Interact: target={:?}, verb={:?} — logged only, dialogue dispatch future scope (#415)",
|
||||
target_entity_id,
|
||||
verb,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
PlayerAction::UsePerceptionMode(ref mode) => {
|
||||
tracing::trace!("UsePerceptionMode({}) — no-op for Sprint 1", mode);
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
// Inventory system — CarriedBy component, Take/Place verb handling (#424, D-065)
|
||||
//
|
||||
// Items are world entities with CarriedBy(StableId) referencing the carrier.
|
||||
// When carried, TilePosition is removed — this naturally enforces the info
|
||||
// boundary: carried items don't appear in spatial queries (visibility,
|
||||
// interactions) for other observers. Only the carrier's observer snapshot
|
||||
// includes them via player_inventory.
|
||||
//
|
||||
// Take: removes TilePosition, adds CarriedBy + InventorySlot.
|
||||
// Place: removes CarriedBy + InventorySlot, adds TilePosition at player pos.
|
||||
//
|
||||
// 3x3 grid = 9 universal slots (OQ-24 resolved). Slot assignment is
|
||||
// first-available (0..8).
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
|
||||
use crate::bridge::types::InventoryItem;
|
||||
use crate::knowledge::types::StableId;
|
||||
|
||||
/// Maximum inventory slots (3x3 grid, OQ-24).
|
||||
pub const MAX_INVENTORY_SLOTS: u8 = 9;
|
||||
|
||||
/// Marks an item as carried by an entity. References the carrier's StableId.
|
||||
/// When present, the item entity should NOT have a TilePosition — it's
|
||||
/// in someone's pocket, not on the ground.
|
||||
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct CarriedBy(pub StableId);
|
||||
|
||||
/// Display name for an item entity, crossing the wire as InventoryItem.name.
|
||||
#[derive(Component, Debug, Clone)]
|
||||
pub struct ItemName(pub String);
|
||||
|
||||
/// Inventory slot assignment (0..8 for 3x3 grid).
|
||||
/// Assigned on Take, removed on Place.
|
||||
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct InventorySlot(pub u8);
|
||||
|
||||
/// Find the next available inventory slot for a carrier.
|
||||
/// Returns None if all 9 slots are occupied.
|
||||
pub fn find_next_slot(occupied: &[u8]) -> Option<u8> {
|
||||
for slot in 0..MAX_INVENTORY_SLOTS {
|
||||
if !occupied.contains(&slot) {
|
||||
return Some(slot);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Collect inventory items for a specific carrier (by StableId).
|
||||
/// Returns wire-format InventoryItem structs sorted by slot.
|
||||
pub fn collect_inventory_for(
|
||||
carrier_id: StableId,
|
||||
items: &Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>,
|
||||
registry: &crate::knowledge::EntityRegistry,
|
||||
) -> Vec<InventoryItem> {
|
||||
let mut result: Vec<InventoryItem> = items
|
||||
.iter()
|
||||
.filter(|(_, carried_by, _, _)| carried_by.0 == carrier_id)
|
||||
.map(|(entity, _, name, slot)| {
|
||||
let wire_id = registry
|
||||
.to_stable(entity)
|
||||
.map(|sid| sid.0)
|
||||
.unwrap_or_else(|| {
|
||||
tracing::error!(?entity, "carried item not in EntityRegistry");
|
||||
entity.to_bits()
|
||||
});
|
||||
InventoryItem {
|
||||
item_id: wire_id,
|
||||
name: name.0.clone(),
|
||||
slot: slot.0,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
result.sort_by_key(|item| item.slot);
|
||||
result
|
||||
}
|
||||
|
||||
/// Get the list of occupied slots for a carrier.
|
||||
pub fn occupied_slots_for(
|
||||
carrier_id: StableId,
|
||||
items: &Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>,
|
||||
) -> Vec<u8> {
|
||||
items
|
||||
.iter()
|
||||
.filter(|(_, carried_by, _, _)| carried_by.0 == carrier_id)
|
||||
.map(|(_, _, _, slot)| slot.0)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::knowledge::EntityRegistry;
|
||||
use bevy_ecs::world::World;
|
||||
|
||||
fn setup_world() -> World {
|
||||
let mut world = World::new();
|
||||
world.init_resource::<EntityRegistry>();
|
||||
world
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_next_slot_empty_returns_zero() {
|
||||
assert_eq!(find_next_slot(&[]), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_next_slot_skips_occupied() {
|
||||
assert_eq!(find_next_slot(&[0, 1, 2]), Some(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_next_slot_fills_gaps() {
|
||||
assert_eq!(find_next_slot(&[0, 2, 4]), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_next_slot_full_returns_none() {
|
||||
let all: Vec<u8> = (0..9).collect();
|
||||
assert_eq!(find_next_slot(&all), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_inventory_empty_when_no_items() {
|
||||
let mut world = setup_world();
|
||||
let player = world.spawn_empty().id();
|
||||
let player_sid = world.resource_mut::<EntityRegistry>().register(player);
|
||||
|
||||
let mut query_state =
|
||||
world.query::<(Entity, &CarriedBy, &ItemName, &InventorySlot)>();
|
||||
|
||||
// Can't use system params directly in tests — use world query
|
||||
// Instead, verify the logic by spawning items and checking
|
||||
assert_eq!(query_state.iter(&world).count(), 0);
|
||||
let _ = player_sid; // used for the filter
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn carried_item_appears_in_inventory() {
|
||||
let mut world = setup_world();
|
||||
|
||||
let player = world.spawn_empty().id();
|
||||
let player_sid = world.resource_mut::<EntityRegistry>().register(player);
|
||||
|
||||
let item = world
|
||||
.spawn((
|
||||
CarriedBy(player_sid),
|
||||
ItemName("Manifest Copy".into()),
|
||||
InventorySlot(0),
|
||||
))
|
||||
.id();
|
||||
world.resource_mut::<EntityRegistry>().register(item);
|
||||
|
||||
// Use system_state for proper Query access
|
||||
let mut system_state = bevy_ecs::system::SystemState::<(
|
||||
Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>,
|
||||
Res<EntityRegistry>,
|
||||
)>::new(&mut world);
|
||||
let (items_query, registry) = system_state.get(&world);
|
||||
|
||||
let inventory = collect_inventory_for(player_sid, &items_query, ®istry);
|
||||
assert_eq!(inventory.len(), 1);
|
||||
assert_eq!(inventory[0].name, "Manifest Copy");
|
||||
assert_eq!(inventory[0].slot, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_own_items_in_inventory() {
|
||||
let mut world = setup_world();
|
||||
|
||||
let player = world.spawn_empty().id();
|
||||
let player_sid = world.resource_mut::<EntityRegistry>().register(player);
|
||||
|
||||
let other = world.spawn_empty().id();
|
||||
let other_sid = world.resource_mut::<EntityRegistry>().register(other);
|
||||
|
||||
// Player's item
|
||||
let item1 = world
|
||||
.spawn((
|
||||
CarriedBy(player_sid),
|
||||
ItemName("Manifest Copy".into()),
|
||||
InventorySlot(0),
|
||||
))
|
||||
.id();
|
||||
world.resource_mut::<EntityRegistry>().register(item1);
|
||||
|
||||
// Other entity's item — should NOT appear in player's inventory
|
||||
let item2 = world
|
||||
.spawn((
|
||||
CarriedBy(other_sid),
|
||||
ItemName("Access Token".into()),
|
||||
InventorySlot(0),
|
||||
))
|
||||
.id();
|
||||
world.resource_mut::<EntityRegistry>().register(item2);
|
||||
|
||||
let mut system_state = bevy_ecs::system::SystemState::<(
|
||||
Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>,
|
||||
Res<EntityRegistry>,
|
||||
)>::new(&mut world);
|
||||
let (items_query, registry) = system_state.get(&world);
|
||||
|
||||
let inventory = collect_inventory_for(player_sid, &items_query, ®istry);
|
||||
assert_eq!(inventory.len(), 1, "info boundary: only own items");
|
||||
assert_eq!(inventory[0].name, "Manifest Copy");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inventory_sorted_by_slot() {
|
||||
let mut world = setup_world();
|
||||
|
||||
let player = world.spawn_empty().id();
|
||||
let player_sid = world.resource_mut::<EntityRegistry>().register(player);
|
||||
|
||||
// Spawn items in reverse slot order
|
||||
for (slot, name) in [(2, "Comm Log"), (0, "Manifest"), (1, "Token")] {
|
||||
let item = world
|
||||
.spawn((
|
||||
CarriedBy(player_sid),
|
||||
ItemName(name.into()),
|
||||
InventorySlot(slot),
|
||||
))
|
||||
.id();
|
||||
world.resource_mut::<EntityRegistry>().register(item);
|
||||
}
|
||||
|
||||
let mut system_state = bevy_ecs::system::SystemState::<(
|
||||
Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>,
|
||||
Res<EntityRegistry>,
|
||||
)>::new(&mut world);
|
||||
let (items_query, registry) = system_state.get(&world);
|
||||
|
||||
let inventory = collect_inventory_for(player_sid, &items_query, ®istry);
|
||||
assert_eq!(inventory.len(), 3);
|
||||
assert_eq!(inventory[0].slot, 0);
|
||||
assert_eq!(inventory[0].name, "Manifest");
|
||||
assert_eq!(inventory[1].slot, 1);
|
||||
assert_eq!(inventory[1].name, "Token");
|
||||
assert_eq!(inventory[2].slot, 2);
|
||||
assert_eq!(inventory[2].name, "Comm Log");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn occupied_slots_returns_correct_set() {
|
||||
let mut world = setup_world();
|
||||
|
||||
let player = world.spawn_empty().id();
|
||||
let player_sid = world.resource_mut::<EntityRegistry>().register(player);
|
||||
|
||||
for slot in [0, 3, 7] {
|
||||
world.spawn((
|
||||
CarriedBy(player_sid),
|
||||
ItemName("Item".into()),
|
||||
InventorySlot(slot),
|
||||
));
|
||||
}
|
||||
|
||||
let mut system_state = bevy_ecs::system::SystemState::<
|
||||
Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>,
|
||||
>::new(&mut world);
|
||||
let items_query = system_state.get(&world);
|
||||
|
||||
let mut slots = occupied_slots_for(player_sid, &items_query);
|
||||
slots.sort();
|
||||
assert_eq!(slots, vec![0, 3, 7]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_slots_is_nine() {
|
||||
assert_eq!(MAX_INVENTORY_SLOTS, 9);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use bevy_ecs::schedule::IntoScheduleConfigs;
|
||||
|
||||
pub mod input;
|
||||
pub mod interaction;
|
||||
pub mod inventory;
|
||||
pub mod monologue;
|
||||
pub mod movement;
|
||||
pub mod path_follow;
|
||||
|
||||
Reference in New Issue
Block a user