//! 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, } /// Resource holding initial entity state per room and debounce tracking. #[derive(Resource, Debug, Default)] pub struct RoomSnapshots { snapshots: BTreeMap, last_reset_tick: BTreeMap, } 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> { 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>, target_entity_id: Option, 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::() .remove::() .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::(); // Player let player = world .spawn((PlayerCharacter, TilePosition::new(5, 5, 0))) .id(); let player_sid = world.resource_mut::().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::().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::().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::().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::(item).is_none(), "Item should be picked up" ); assert_eq!(world.get::(item).unwrap().0, player_sid); // Step 2: Reset via verb world.resource_mut::().push(PlayerInput { tick: 1, action: PlayerAction::Interact { target_entity_id: Some(plate_sid.0), verb: Some("Reset".into()), }, }); world.resource_mut::().tick = 1; schedule.run(&mut world); // Item should be back on the ground at original position let pos = world .get::(item) .expect("Item should be restored to ground"); assert_eq!( *pos, TilePosition::new(5, 4, 0), "Item at original position" ); assert!( world.get::(item).is_none(), "CarriedBy removed after reset" ); assert!( world.get::(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::(); 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::().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::().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::().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::(npc).unwrap() = TilePosition::new(20, 20, 0); // Second reset at tick 5 — should be debounced (< 10 ticks) world.resource_mut::().push(PlayerInput { tick: 5, action: PlayerAction::Interact { target_entity_id: Some(plate_sid.0), verb: Some("Reset".into()), }, }); world.resource_mut::().tick = 5; schedule.run(&mut world); // NPC should still be at moved position (reset was debounced) assert_eq!( world.get::(npc).unwrap().x, 20, "NPC not reset — debounced" ); // Third reset at tick 10 — should succeed world.resource_mut::().push(PlayerInput { tick: 10, action: PlayerAction::Interact { target_entity_id: Some(plate_sid.0), verb: Some("Reset".into()), }, }); world.resource_mut::().tick = 10; schedule.run(&mut world); // NPC should be back at original position assert_eq!( world.get::(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::(); 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::().register(plate); // No RoomSnapshots resource inserted — should be gracefully handled world.resource_mut::().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 } }