Formatting pass across simulation, perception, knowledge, NPC, and test modules. Includes two clippy fixes in monologue.rs (.values() instead of for (_, v) pattern). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
267 lines
8.9 KiB
Rust
267 lines
8.9 KiB
Rust
// 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> {
|
|
(0..MAX_INVENTORY_SLOTS).find(|slot| !occupied.contains(slot))
|
|
}
|
|
|
|
/// 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);
|
|
}
|
|
}
|