// 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() } /// 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. pub fn handle_take( commands: &mut Commands, registry: &crate::knowledge::EntityRegistry, player_query: &crate::simulation::input::PlayerInputQuery, inventory_items: &Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>, target_entity_id: Option, ) { 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::() .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. pub fn handle_place( commands: &mut Commands, registry: &crate::knowledge::EntityRegistry, player_query: &crate::simulation::input::PlayerInputQuery, target_entity_id: Option, ) { 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::() .remove::() .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::*; 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); } // ----------------------------------------------------------------------- // Take/Place verb tests (#424, D-065; moved from input.rs, T-1062) // ----------------------------------------------------------------------- use crate::bridge::types::{PlayerAction, PlayerInput}; use crate::simulation::input::{process_player_input, InputQueue}; use crate::simulation::movement::{PlayerCharacter, TilePosition}; use crate::simulation::time::SimulationTime; fn setup_verb_world() -> World { let mut world = World::new(); world.insert_resource(InputQueue::default()); world.insert_resource(SimulationTime::default()); world.init_resource::(); world } #[test] fn take_verb_picks_up_item() { let mut world = setup_verb_world(); // Spawn player and register let player = world .spawn((PlayerCharacter, TilePosition::new(5, 5, 0))) .id(); let player_sid = world.resource_mut::().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::().register(item); // Issue Take verb world.resource_mut::().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::(item).is_none(), "item should leave the ground" ); let carried = world .get::(item) .expect("item should have CarriedBy"); assert_eq!(carried.0, player_sid); let slot = world .get::(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 = setup_verb_world(); let player = world .spawn((PlayerCharacter, TilePosition::new(5, 5, 0))) .id(); let player_sid = world.resource_mut::().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::().register(item); // Issue Place verb world.resource_mut::().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::(item) .expect("item should be on ground"); assert_eq!( *pos, TilePosition::new(5, 5, 0), "placed at player position" ); assert!(world.get::(item).is_none(), "CarriedBy removed"); assert!( world.get::(item).is_none(), "InventorySlot removed" ); } #[test] fn take_verb_assigns_sequential_slots() { let mut world = setup_verb_world(); let player = world .spawn((PlayerCharacter, TilePosition::new(5, 5, 0))) .id(); let player_sid = world.resource_mut::().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::().register(item2); world.resource_mut::().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::(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 = setup_verb_world(); let player = world .spawn((PlayerCharacter, TilePosition::new(5, 5, 0))) .id(); let player_sid = world.resource_mut::().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::().register(item); world.resource_mut::().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::(item).is_some(), "item stays on ground" ); assert!( world.get::(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 = setup_verb_world(); let player = world .spawn((PlayerCharacter, TilePosition::new(5, 5, 0))) .id(); let player_sid = world.resource_mut::().register(player); let item = world .spawn((TilePosition::new(5, 4, 0), ItemName("Manifest Copy".into()))) .id(); let item_sid = world.resource_mut::().register(item); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_player_input); // Step 1: Take world.resource_mut::().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::(item).is_none(), "item off ground after Take" ); assert_eq!(world.get::(item).unwrap().0, player_sid); assert_eq!(world.get::(item).unwrap().0, 0); // Step 2: Place world.resource_mut::().push(PlayerInput { tick: 1, action: PlayerAction::Interact { target_entity_id: Some(item_sid.0), verb: Some("Place".into()), }, }); world.resource_mut::().tick = 1; schedule.run(&mut world); let pos = world .get::(item) .expect("item back on ground after Place"); assert_eq!( *pos, TilePosition::new(5, 5, 0), "placed at player position" ); assert!( world.get::(item).is_none(), "CarriedBy removed after Place" ); assert!( world.get::(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 = setup_verb_world(); world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0))); world.resource_mut::().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 = setup_verb_world(); world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0))); world.resource_mut::().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 } }