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>
490 lines
17 KiB
Rust
490 lines
17 KiB
Rust
//! Room reset trigger mechanism (#490).
|
|
//!
|
|
//! Restores a room's entities to their initial positions when the player
|
|
//! interacts with a reset plate. Used during Gauntlet QA sessions to
|
|
//! re-test a room without restarting the server.
|
|
//!
|
|
//! Components:
|
|
//! - `RoomResetTrigger` — marks an entity as a reset plate for a room
|
|
//!
|
|
//! Resource:
|
|
//! - `RoomSnapshots` — stores tick-0 entity positions per room
|
|
//!
|
|
//! Production path: `plan_reset` returns planned changes as a Vec, which
|
|
//! the input system applies via Commands (see simulation::input). This
|
|
//! avoids exclusive World access and is scheduler-friendly.
|
|
//!
|
|
//! Spec (workshop-outcomes.md Section 8):
|
|
//! - Trigger: Interact with reset plate entity (verb "Reset")
|
|
//! - Resets: entity positions, carried items from room returned to floor
|
|
//! - Does NOT reset: other rooms, player position, session timer, SimRng
|
|
//! - Debounce: 10-tick cooldown per room
|
|
|
|
use bevy_ecs::prelude::*;
|
|
use std::collections::BTreeMap;
|
|
|
|
use crate::simulation::movement::TilePosition;
|
|
|
|
/// Debounce cooldown in ticks between resets of the same room.
|
|
pub const RESET_DEBOUNCE_TICKS: u64 = 10;
|
|
|
|
/// Marks an entity as a room reset trigger (reset plate).
|
|
/// The player interacts with this entity using the "Reset" verb
|
|
/// to restore the room to its initial state.
|
|
#[derive(Component, Debug, Clone)]
|
|
pub struct RoomResetTrigger {
|
|
pub room_name: String,
|
|
}
|
|
|
|
/// Snapshot of a single entity's initial position.
|
|
#[derive(Debug, Clone)]
|
|
struct EntitySnapshot {
|
|
entity: Entity,
|
|
position: TilePosition,
|
|
/// Whether this entity was a floor item (had TilePosition + ItemName at spawn).
|
|
is_floor_item: bool,
|
|
}
|
|
|
|
/// Per-room initial state snapshot.
|
|
#[derive(Debug, Clone, Default)]
|
|
struct RoomSnapshotData {
|
|
entities: Vec<EntitySnapshot>,
|
|
}
|
|
|
|
/// Resource holding initial entity state per room and debounce tracking.
|
|
#[derive(Resource, Debug, Default)]
|
|
pub struct RoomSnapshots {
|
|
snapshots: BTreeMap<String, RoomSnapshotData>,
|
|
last_reset_tick: BTreeMap<String, u64>,
|
|
}
|
|
|
|
impl RoomSnapshots {
|
|
/// Record the initial position of an entity in a room.
|
|
pub fn record(
|
|
&mut self,
|
|
room_name: &str,
|
|
entity: Entity,
|
|
position: TilePosition,
|
|
is_floor_item: bool,
|
|
) {
|
|
self.snapshots
|
|
.entry(room_name.to_string())
|
|
.or_default()
|
|
.entities
|
|
.push(EntitySnapshot {
|
|
entity,
|
|
position,
|
|
is_floor_item,
|
|
});
|
|
}
|
|
|
|
/// Check if a reset is allowed (debounce check).
|
|
pub fn can_reset(&self, room_name: &str, current_tick: u64) -> bool {
|
|
match self.last_reset_tick.get(room_name) {
|
|
Some(&last) => current_tick >= last + RESET_DEBOUNCE_TICKS,
|
|
None => true,
|
|
}
|
|
}
|
|
|
|
/// Plan a room reset for use with Commands (system-friendly).
|
|
/// Returns the list of (entity, position, is_floor_item) changes to apply,
|
|
/// or None if debounced or unknown room. Updates debounce tracking.
|
|
///
|
|
/// This is the canonical production API — the input system applies the
|
|
/// returned changes via Commands to avoid exclusive World access.
|
|
pub fn plan_reset(
|
|
&mut self,
|
|
room_name: &str,
|
|
current_tick: u64,
|
|
) -> Option<Vec<(Entity, TilePosition, bool)>> {
|
|
if !self.can_reset(room_name, current_tick) {
|
|
return None;
|
|
}
|
|
|
|
let snapshot = self.snapshots.get(room_name)?;
|
|
let changes: Vec<_> = snapshot
|
|
.entities
|
|
.iter()
|
|
.map(|s| (s.entity, s.position, s.is_floor_item))
|
|
.collect();
|
|
|
|
self.last_reset_tick
|
|
.insert(room_name.to_string(), current_tick);
|
|
|
|
Some(changes)
|
|
}
|
|
}
|
|
|
|
/// 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::*;
|
|
|
|
#[test]
|
|
fn plan_reset_returns_correct_changes() {
|
|
let mut world = World::new();
|
|
let entity = world.spawn(TilePosition::new(10, 20, 0)).id();
|
|
let mut snapshots = RoomSnapshots::default();
|
|
snapshots.record("test_room", entity, TilePosition::new(10, 20, 0), false);
|
|
|
|
let changes = snapshots.plan_reset("test_room", 0);
|
|
assert!(changes.is_some());
|
|
let changes = changes.unwrap();
|
|
assert_eq!(changes.len(), 1);
|
|
assert_eq!(changes[0].0, entity);
|
|
assert_eq!(changes[0].1, TilePosition::new(10, 20, 0));
|
|
assert!(!changes[0].2); // not a floor item
|
|
}
|
|
|
|
#[test]
|
|
fn plan_reset_includes_floor_items() {
|
|
let mut world = World::new();
|
|
let item = world.spawn(TilePosition::new(5, 5, 0)).id();
|
|
let mut snapshots = RoomSnapshots::default();
|
|
snapshots.record("warehouse", item, TilePosition::new(5, 5, 0), true);
|
|
|
|
let changes = snapshots.plan_reset("warehouse", 0).unwrap();
|
|
assert_eq!(changes.len(), 1);
|
|
assert!(changes[0].2); // is a floor item
|
|
}
|
|
|
|
#[test]
|
|
fn plan_reset_debounces() {
|
|
let mut world = World::new();
|
|
let entity = world.spawn(TilePosition::new(10, 20, 0)).id();
|
|
let mut snapshots = RoomSnapshots::default();
|
|
snapshots.record("test_room", entity, TilePosition::new(10, 20, 0), false);
|
|
|
|
// First reset at tick 0
|
|
assert!(snapshots.plan_reset("test_room", 0).is_some());
|
|
|
|
// Second reset at tick 5 — should be debounced
|
|
assert!(snapshots.plan_reset("test_room", 5).is_none());
|
|
|
|
// Third reset at tick 10 — should succeed
|
|
assert!(snapshots.plan_reset("test_room", 10).is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn plan_reset_debounce_exact_boundary() {
|
|
let mut world = World::new();
|
|
let entity = world.spawn(TilePosition::new(10, 20, 0)).id();
|
|
let mut snapshots = RoomSnapshots::default();
|
|
snapshots.record("test_room", entity, TilePosition::new(10, 20, 0), false);
|
|
|
|
// Reset at tick 0
|
|
assert!(snapshots.plan_reset("test_room", 0).is_some());
|
|
|
|
// Tick 9: exactly one tick before debounce expires — must be rejected
|
|
assert!(
|
|
snapshots.plan_reset("test_room", 9).is_none(),
|
|
"tick 9 should be rejected (debounce is 10 ticks)"
|
|
);
|
|
|
|
// Tick 10: exact debounce boundary — must be accepted
|
|
assert!(
|
|
snapshots.plan_reset("test_room", 10).is_some(),
|
|
"tick 10 should be accepted (debounce elapsed)"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn plan_reset_unknown_room_returns_none() {
|
|
let mut snapshots = RoomSnapshots::default();
|
|
assert!(snapshots.plan_reset("nonexistent", 0).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn can_reset_fresh_room() {
|
|
let snapshots = RoomSnapshots::default();
|
|
assert!(snapshots.can_reset("any_room", 0));
|
|
}
|
|
|
|
#[test]
|
|
fn can_reset_after_debounce() {
|
|
let mut snapshots = RoomSnapshots::default();
|
|
snapshots.last_reset_tick.insert("room".to_string(), 100);
|
|
|
|
assert!(!snapshots.can_reset("room", 105));
|
|
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
|
|
}
|
|
}
|