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>
This commit is contained in:
@@ -82,6 +82,102 @@ pub fn occupied_slots_for(
|
||||
.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::*;
|
||||
@@ -263,4 +359,304 @@ mod tests {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user