// 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 { (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 { let mut result: Vec = 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 { 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::(); 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 = (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::().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::().register(player); let item = world .spawn(( CarriedBy(player_sid), ItemName("Manifest Copy".into()), InventorySlot(0), )) .id(); world.resource_mut::().register(item); // Use system_state for proper Query access let mut system_state = bevy_ecs::system::SystemState::<( Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>, Res, )>::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::().register(player); let other = world.spawn_empty().id(); let other_sid = world.resource_mut::().register(other); // Player's item let item1 = world .spawn(( CarriedBy(player_sid), ItemName("Manifest Copy".into()), InventorySlot(0), )) .id(); world.resource_mut::().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::().register(item2); let mut system_state = bevy_ecs::system::SystemState::<( Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>, Res, )>::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::().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::().register(item); } let mut system_state = bevy_ecs::system::SystemState::<( Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>, Res, )>::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::().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); } }