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:
@@ -45,6 +45,7 @@ pub mod invariants;
|
||||
pub mod reset;
|
||||
#[cfg(feature = "gauntlet")]
|
||||
pub mod rooms;
|
||||
pub mod teleport;
|
||||
|
||||
#[cfg(feature = "gauntlet")]
|
||||
use bevy_app::prelude::*;
|
||||
|
||||
@@ -115,6 +115,67 @@ impl RoomSnapshots {
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle Reset verb: restore a room's entities to their initial positions.
|
||||
/// Target entity must have a RoomResetTrigger component. Respects debounce.
|
||||
pub fn handle_reset(
|
||||
commands: &mut Commands,
|
||||
registry: &crate::knowledge::EntityRegistry,
|
||||
reset_triggers: &Query<&RoomResetTrigger>,
|
||||
room_snapshots: &mut Option<ResMut<RoomSnapshots>>,
|
||||
target_entity_id: Option<u64>,
|
||||
current_tick: u64,
|
||||
) {
|
||||
let Some(target_id) = target_entity_id else {
|
||||
tracing::warn!("Reset verb without target_entity_id");
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(snapshots) = room_snapshots.as_mut() else {
|
||||
tracing::warn!("Reset verb but RoomSnapshots resource not available");
|
||||
return;
|
||||
};
|
||||
|
||||
let target_stable = crate::knowledge::StableId(target_id);
|
||||
let Some(target_entity) = registry.to_entity(&target_stable) else {
|
||||
tracing::warn!(target_id, "Reset: target entity not in registry");
|
||||
return;
|
||||
};
|
||||
|
||||
let Ok(trigger) = reset_triggers.get(target_entity) else {
|
||||
tracing::warn!(target_id, "Reset: target is not a reset trigger");
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(changes) = snapshots.plan_reset(&trigger.room_name, current_tick) else {
|
||||
tracing::info!(
|
||||
room = trigger.room_name.as_str(),
|
||||
"Reset: debounced or unknown room"
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
let mut restored = 0;
|
||||
for (entity, position, is_floor_item) in changes {
|
||||
if is_floor_item {
|
||||
commands
|
||||
.entity(entity)
|
||||
.remove::<crate::simulation::inventory::CarriedBy>()
|
||||
.remove::<crate::simulation::inventory::InventorySlot>()
|
||||
.insert(position);
|
||||
} else {
|
||||
commands.entity(entity).insert(position);
|
||||
}
|
||||
restored += 1;
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
room = trigger.room_name.as_str(),
|
||||
restored,
|
||||
current_tick,
|
||||
"Room reset executed via Reset verb"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -208,4 +269,221 @@ mod tests {
|
||||
assert!(snapshots.can_reset("room", 110));
|
||||
assert!(snapshots.can_reset("room", 200));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Reset verb tests (#490; moved from input.rs, T-1062)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
use crate::bridge::types::{PlayerAction, PlayerInput};
|
||||
use crate::knowledge::EntityRegistry;
|
||||
use crate::simulation::input::{process_player_input, InputQueue};
|
||||
use crate::simulation::inventory::{CarriedBy, InventorySlot, ItemName};
|
||||
use crate::simulation::movement::PlayerCharacter;
|
||||
use crate::simulation::time::SimulationTime;
|
||||
|
||||
#[test]
|
||||
fn reset_verb_restores_floor_item() {
|
||||
// #490: Take a floor item, then Reset verb restores it to original position.
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(InputQueue::default());
|
||||
world.insert_resource(SimulationTime::default());
|
||||
world.init_resource::<EntityRegistry>();
|
||||
|
||||
// Player
|
||||
let player = world
|
||||
.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)))
|
||||
.id();
|
||||
let player_sid = world.resource_mut::<EntityRegistry>().register(player);
|
||||
|
||||
// Floor item at (5, 4)
|
||||
let item = world
|
||||
.spawn((TilePosition::new(5, 4, 0), ItemName("Keycard".into())))
|
||||
.id();
|
||||
let item_sid = world.resource_mut::<EntityRegistry>().register(item);
|
||||
|
||||
// Reset plate entity
|
||||
let plate = world
|
||||
.spawn((
|
||||
crate::simulation::interaction::Interactable,
|
||||
RoomResetTrigger {
|
||||
room_name: "test_room".to_string(),
|
||||
},
|
||||
TilePosition::new(5, 3, 0),
|
||||
))
|
||||
.id();
|
||||
let plate_sid = world.resource_mut::<EntityRegistry>().register(plate);
|
||||
|
||||
// Record snapshot: item is a floor item at its original position
|
||||
let mut snapshots = RoomSnapshots::default();
|
||||
snapshots.record("test_room", item, TilePosition::new(5, 4, 0), true);
|
||||
world.insert_resource(snapshots);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_player_input);
|
||||
|
||||
// Step 1: Take the item
|
||||
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 should be picked up"
|
||||
);
|
||||
assert_eq!(world.get::<CarriedBy>(item).unwrap().0, player_sid);
|
||||
|
||||
// Step 2: Reset via verb
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 1,
|
||||
action: PlayerAction::Interact {
|
||||
target_entity_id: Some(plate_sid.0),
|
||||
verb: Some("Reset".into()),
|
||||
},
|
||||
});
|
||||
world.resource_mut::<SimulationTime>().tick = 1;
|
||||
schedule.run(&mut world);
|
||||
|
||||
// Item should be back on the ground at original position
|
||||
let pos = world
|
||||
.get::<TilePosition>(item)
|
||||
.expect("Item should be restored to ground");
|
||||
assert_eq!(
|
||||
*pos,
|
||||
TilePosition::new(5, 4, 0),
|
||||
"Item at original position"
|
||||
);
|
||||
assert!(
|
||||
world.get::<CarriedBy>(item).is_none(),
|
||||
"CarriedBy removed after reset"
|
||||
);
|
||||
assert!(
|
||||
world.get::<InventorySlot>(item).is_none(),
|
||||
"InventorySlot removed after reset"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_verb_debounces() {
|
||||
// #490: Reset debounce prevents rapid-fire resets.
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(InputQueue::default());
|
||||
world.insert_resource(SimulationTime::default());
|
||||
world.init_resource::<EntityRegistry>();
|
||||
|
||||
let _player = world
|
||||
.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)))
|
||||
.id();
|
||||
|
||||
// NPC entity
|
||||
let npc = world.spawn(TilePosition::new(10, 10, 0)).id();
|
||||
world.resource_mut::<EntityRegistry>().register(npc);
|
||||
|
||||
// Reset plate
|
||||
let plate = world
|
||||
.spawn((
|
||||
crate::simulation::interaction::Interactable,
|
||||
RoomResetTrigger {
|
||||
room_name: "test_room".to_string(),
|
||||
},
|
||||
TilePosition::new(5, 3, 0),
|
||||
))
|
||||
.id();
|
||||
let plate_sid = world.resource_mut::<EntityRegistry>().register(plate);
|
||||
|
||||
let mut snapshots = RoomSnapshots::default();
|
||||
snapshots.record("test_room", npc, TilePosition::new(10, 10, 0), false);
|
||||
world.insert_resource(snapshots);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_player_input);
|
||||
|
||||
// First reset at tick 0 — should succeed
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::Interact {
|
||||
target_entity_id: Some(plate_sid.0),
|
||||
verb: Some("Reset".into()),
|
||||
},
|
||||
});
|
||||
schedule.run(&mut world);
|
||||
|
||||
// Move NPC to verify debounce blocks second reset
|
||||
*world.get_mut::<TilePosition>(npc).unwrap() = TilePosition::new(20, 20, 0);
|
||||
|
||||
// Second reset at tick 5 — should be debounced (< 10 ticks)
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 5,
|
||||
action: PlayerAction::Interact {
|
||||
target_entity_id: Some(plate_sid.0),
|
||||
verb: Some("Reset".into()),
|
||||
},
|
||||
});
|
||||
world.resource_mut::<SimulationTime>().tick = 5;
|
||||
schedule.run(&mut world);
|
||||
|
||||
// NPC should still be at moved position (reset was debounced)
|
||||
assert_eq!(
|
||||
world.get::<TilePosition>(npc).unwrap().x,
|
||||
20,
|
||||
"NPC not reset — debounced"
|
||||
);
|
||||
|
||||
// Third reset at tick 10 — should succeed
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 10,
|
||||
action: PlayerAction::Interact {
|
||||
target_entity_id: Some(plate_sid.0),
|
||||
verb: Some("Reset".into()),
|
||||
},
|
||||
});
|
||||
world.resource_mut::<SimulationTime>().tick = 10;
|
||||
schedule.run(&mut world);
|
||||
|
||||
// NPC should be back at original position
|
||||
assert_eq!(
|
||||
world.get::<TilePosition>(npc).unwrap().x,
|
||||
10,
|
||||
"NPC reset after debounce elapsed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_verb_without_snapshots_is_noop() {
|
||||
// Reset verb when no RoomSnapshots resource exists should not panic.
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(InputQueue::default());
|
||||
world.insert_resource(SimulationTime::default());
|
||||
world.init_resource::<EntityRegistry>();
|
||||
|
||||
world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)));
|
||||
|
||||
let plate = world
|
||||
.spawn((
|
||||
crate::simulation::interaction::Interactable,
|
||||
RoomResetTrigger {
|
||||
room_name: "test_room".to_string(),
|
||||
},
|
||||
TilePosition::new(5, 3, 0),
|
||||
))
|
||||
.id();
|
||||
let plate_sid = world.resource_mut::<EntityRegistry>().register(plate);
|
||||
|
||||
// No RoomSnapshots resource inserted — should be gracefully handled
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::Interact {
|
||||
target_entity_id: Some(plate_sid.0),
|
||||
verb: Some("Reset".into()),
|
||||
},
|
||||
});
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_player_input);
|
||||
schedule.run(&mut world); // should not panic
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
//! TeleportToHub QA action (#491) — Gauntlet-only return-to-hub verb.
|
||||
//!
|
||||
//! Moved out of simulation::input with the dispatcher split (T-1062): the
|
||||
//! action only exists on the Gauntlet test world, so it lives with it.
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
|
||||
use crate::simulation::input::PlayerInputQuery;
|
||||
|
||||
/// Handle TeleportToHub: move player to hub spawn, clear interaction state (#491).
|
||||
///
|
||||
/// Gauntlet-only action. On non-Gauntlet maps (feature disabled), logs a warning
|
||||
/// and returns. On Gauntlet maps, moves the player to HUB.spawn and removes
|
||||
/// dialogue, monologue, and interaction markers to prevent stale state.
|
||||
///
|
||||
/// Does NOT affect: room state, inventory, game time, knowledge graph.
|
||||
pub fn handle_teleport_to_hub(player_query: &mut PlayerInputQuery, commands: &mut Commands) {
|
||||
#[cfg(not(feature = "gauntlet"))]
|
||||
{
|
||||
tracing::warn!("TeleportToHub rejected: not a Gauntlet map");
|
||||
return;
|
||||
}
|
||||
|
||||
#[cfg(feature = "gauntlet")]
|
||||
{
|
||||
let Ok((player_entity, _, _, _)) = player_query.single() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let hub_spawn = crate::test_world::constants::HUB.spawn;
|
||||
|
||||
// Move player to hub spawn
|
||||
commands.entity(player_entity).insert(hub_spawn);
|
||||
|
||||
// Clear any pending movement
|
||||
commands
|
||||
.entity(player_entity)
|
||||
.remove::<crate::simulation::movement::MoveIntent>();
|
||||
|
||||
// Clear dialogue/interaction markers (including mid-confrontation state)
|
||||
commands
|
||||
.entity(player_entity)
|
||||
.remove::<crate::simulation::dialogue::TalkRequest>()
|
||||
.remove::<crate::simulation::dialogue::ActiveDialogue>()
|
||||
.remove::<crate::simulation::dialogue::WalkAwayRequest>()
|
||||
.remove::<crate::simulation::dialogue::ConfrontationDelivered>()
|
||||
.remove::<crate::simulation::dialogue::DialogueResponseRequest>();
|
||||
|
||||
tracing::info!(
|
||||
x = hub_spawn.x,
|
||||
y = hub_spawn.y,
|
||||
z = hub_spawn.z,
|
||||
"TeleportToHub: player moved to hub spawn"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests (#491; moved from input.rs, T-1062)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(all(test, feature = "gauntlet"))]
|
||||
mod tests {
|
||||
use crate::bridge::types::{PlayerAction, PlayerInput};
|
||||
use crate::simulation::input::{process_player_input, InputQueue};
|
||||
use crate::simulation::movement::{MoveIntent, PlayerCharacter, TilePosition};
|
||||
use crate::simulation::time::{SimulationTime, TickRate};
|
||||
|
||||
#[test]
|
||||
fn teleport_to_hub_moves_player() {
|
||||
// #491: TeleportToHub moves player to hub spawn position.
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(InputQueue::default());
|
||||
world.insert_resource(SimulationTime::default());
|
||||
world.init_resource::<crate::knowledge::EntityRegistry>();
|
||||
|
||||
// Spawn player at a non-hub position
|
||||
let player = world
|
||||
.spawn((PlayerCharacter, TilePosition::new(84, 58, 0)))
|
||||
.id();
|
||||
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::TeleportToHub,
|
||||
});
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_player_input);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let pos = world
|
||||
.get::<TilePosition>(player)
|
||||
.expect("player has position");
|
||||
let hub_spawn = crate::test_world::constants::HUB.spawn;
|
||||
assert_eq!(pos.x, hub_spawn.x, "player x at hub spawn");
|
||||
assert_eq!(pos.y, hub_spawn.y, "player y at hub spawn");
|
||||
assert_eq!(pos.z, hub_spawn.z, "player z at hub spawn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn teleport_to_hub_clears_dialogue_markers() {
|
||||
// #491: TeleportToHub removes ActiveDialogue, TalkRequest,
|
||||
// WalkAwayRequest, and ConfrontationDelivered.
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(InputQueue::default());
|
||||
world.insert_resource(SimulationTime::default());
|
||||
world.init_resource::<crate::knowledge::EntityRegistry>();
|
||||
|
||||
// Spawn a fake NPC target
|
||||
let npc = world.spawn(TilePosition::new(10, 10, 0)).id();
|
||||
|
||||
// Spawn player with active dialogue state + mid-confrontation marker
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(84, 58, 0),
|
||||
crate::simulation::dialogue::TalkRequest { target: npc },
|
||||
crate::simulation::dialogue::ActiveDialogue {
|
||||
target: npc,
|
||||
interaction_type: crate::knowledge::events::InteractionType::Talk,
|
||||
started_tick: 0,
|
||||
},
|
||||
crate::simulation::dialogue::WalkAwayRequest,
|
||||
crate::simulation::dialogue::ConfrontationDelivered { target: npc },
|
||||
))
|
||||
.id();
|
||||
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::TeleportToHub,
|
||||
});
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_player_input);
|
||||
schedule.run(&mut world);
|
||||
|
||||
assert!(
|
||||
world
|
||||
.get::<crate::simulation::dialogue::TalkRequest>(player)
|
||||
.is_none(),
|
||||
"TalkRequest cleared after teleport"
|
||||
);
|
||||
assert!(
|
||||
world
|
||||
.get::<crate::simulation::dialogue::ActiveDialogue>(player)
|
||||
.is_none(),
|
||||
"ActiveDialogue cleared after teleport"
|
||||
);
|
||||
assert!(
|
||||
world
|
||||
.get::<crate::simulation::dialogue::WalkAwayRequest>(player)
|
||||
.is_none(),
|
||||
"WalkAwayRequest cleared after teleport"
|
||||
);
|
||||
assert!(
|
||||
world
|
||||
.get::<crate::simulation::dialogue::ConfrontationDelivered>(player)
|
||||
.is_none(),
|
||||
"ConfrontationDelivered cleared after teleport"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn teleport_to_hub_clears_move_intent() {
|
||||
// #491: TeleportToHub removes any pending MoveIntent.
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(InputQueue::default());
|
||||
world.insert_resource(SimulationTime::default());
|
||||
world.init_resource::<crate::knowledge::EntityRegistry>();
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(84, 58, 0),
|
||||
MoveIntent {
|
||||
target: TilePosition::new(85, 58, 0),
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::TeleportToHub,
|
||||
});
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_player_input);
|
||||
schedule.run(&mut world);
|
||||
|
||||
assert!(
|
||||
world.get::<MoveIntent>(player).is_none(),
|
||||
"MoveIntent cleared after teleport"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn teleport_to_hub_allowed_while_paused() {
|
||||
// #491: TeleportToHub is a QA action — allowed even when paused.
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(InputQueue::default());
|
||||
let mut time = SimulationTime::default();
|
||||
time.tick_rate = TickRate::Paused;
|
||||
world.insert_resource(time);
|
||||
world.init_resource::<crate::knowledge::EntityRegistry>();
|
||||
|
||||
let player = world
|
||||
.spawn((PlayerCharacter, TilePosition::new(84, 58, 0)))
|
||||
.id();
|
||||
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::TeleportToHub,
|
||||
});
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_player_input);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let pos = world
|
||||
.get::<TilePosition>(player)
|
||||
.expect("player has position");
|
||||
let hub_spawn = crate::test_world::constants::HUB.spawn;
|
||||
assert_eq!(pos.x, hub_spawn.x, "teleport works while paused");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user