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:
2026-06-12 17:07:36 +02:00
co-authored by Claude Fable 5
parent 3a23378a88
commit 257d979aed
23 changed files with 4819 additions and 4735 deletions
+1
View File
@@ -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::*;
+278
View File
@@ -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
}
}
+225
View File
@@ -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");
}
}