//! 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) } } #[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)); } }