Files
settled-reach/server/src/simulation/inventory.rs
T
jpmschweitzerandClaude Fable 5 257d979aed refactor(simulation): split the input.rs and dialogue.rs dispatchers (T-1062)
input.rs 2,739 → 858 lines: per-domain action handlers moved to their
owning modules (inventory, movement, stance, examine, follow, interaction,
save_io, settings, bridge::debug, economy, vision_cone, bookmark,
test_world reset + new teleport.rs); input.rs keeps the queue, the thin
dispatch table, and pause/cooldown glue. All 9 type_complexity allows
dissolved via one PlayerInputQuery alias.

dialogue.rs → dialogue/ directory module: selection (631), response
(1,473), confrontation (714), mod.rs (226, shared session components +
re-exports — public paths preserved). Documented seam deviation:
process_walk_away lives with confrontation (D-064/D-063 share the same
world-response shape).

Mechanical, zero behavior change: determinism + golden_suite byte-identical
(independently re-verified); 1,504 lib tests unchanged — 23 input tests
moved with their subjects, 53 dialogue tests redistributed, zero deleted.
System scheduling registrations untouched (input_plugin.rs 0-line diff).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 17:07:36 +02:00

663 lines
22 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()
}
/// 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<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::<crate::simulation::movement::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.
pub fn handle_place(
commands: &mut Commands,
registry: &crate::knowledge::EntityRegistry,
player_query: &crate::simulation::input::PlayerInputQuery,
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::*;
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, &registry);
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, &registry);
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, &registry);
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);
}
// -----------------------------------------------------------------------
// 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::<EntityRegistry>();
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::<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::<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 = setup_verb_world();
let player = world
.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)))
.id();
let player_sid = world.resource_mut::<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::<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 = setup_verb_world();
let player = world
.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)))
.id();
let player_sid = world.resource_mut::<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::<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 = setup_verb_world();
let player = world
.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)))
.id();
let player_sid = world.resource_mut::<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::<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"
);
}
#[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::<EntityRegistry>().register(player);
let item = world
.spawn((TilePosition::new(5, 4, 0), ItemName("Manifest Copy".into())))
.id();
let item_sid = world.resource_mut::<EntityRegistry>().register(item);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_player_input);
// Step 1: Take
world.resource_mut::<InputQueue>().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::<TilePosition>(item).is_none(),
"item off ground after Take"
);
assert_eq!(world.get::<CarriedBy>(item).unwrap().0, player_sid);
assert_eq!(world.get::<InventorySlot>(item).unwrap().0, 0);
// Step 2: Place
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 1,
action: PlayerAction::Interact {
target_entity_id: Some(item_sid.0),
verb: Some("Place".into()),
},
});
world.resource_mut::<SimulationTime>().tick = 1;
schedule.run(&mut world);
let pos = world
.get::<TilePosition>(item)
.expect("item back on ground after Place");
assert_eq!(
*pos,
TilePosition::new(5, 5, 0),
"placed at player position"
);
assert!(
world.get::<CarriedBy>(item).is_none(),
"CarriedBy removed after Place"
);
assert!(
world.get::<InventorySlot>(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::<InputQueue>().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::<InputQueue>().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
}
}