feat(simulation): sprint 9 gauntlet — test infrastructure and first 3 rooms
Add Gauntlet test world with 3 rooms (Inventory Warehouse, Occlusion Corridor, Pause Chamber) + Central Hub, room constants module, room reset trigger mechanism, Layer 3 subprocess integration test, golden file comparison engine and test suite, and content runtime validation. Tickets: #482, #484, #485, #487, #488, #489, #490 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,274 @@
|
||||
//! 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
|
||||
//! - `RoomMember` — tags entities with their source room for filtering
|
||||
//!
|
||||
//! Resource:
|
||||
//! - `RoomSnapshots` — stores tick-0 entity positions per room
|
||||
//!
|
||||
//! 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::inventory::{CarriedBy, InventorySlot};
|
||||
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,
|
||||
}
|
||||
|
||||
/// Tags an entity as belonging to a specific room.
|
||||
/// Used during reset to identify which entities to restore.
|
||||
#[derive(Component, Debug, Clone)]
|
||||
pub struct RoomMember {
|
||||
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.
|
||||
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)
|
||||
}
|
||||
|
||||
/// Execute a room reset. Restores entity positions and returns
|
||||
/// floor items to their original locations.
|
||||
///
|
||||
/// Returns the number of entities restored, or None if the room
|
||||
/// has no snapshot or debounce hasn't elapsed.
|
||||
pub fn execute_reset(
|
||||
&mut self,
|
||||
room_name: &str,
|
||||
current_tick: u64,
|
||||
world: &mut World,
|
||||
) -> Option<usize> {
|
||||
if !self.can_reset(room_name, current_tick) {
|
||||
tracing::info!(
|
||||
room_name,
|
||||
"Room reset debounced (last reset tick: {:?})",
|
||||
self.last_reset_tick.get(room_name)
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
let snapshot = self.snapshots.get(room_name)?;
|
||||
let mut restored = 0;
|
||||
|
||||
for snap in &snapshot.entities {
|
||||
// Check if entity still exists
|
||||
if world.get_entity(snap.entity).is_err() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if snap.is_floor_item {
|
||||
// Floor item: remove CarriedBy/InventorySlot if carried,
|
||||
// restore TilePosition to original location
|
||||
let mut entity_mut = world.entity_mut(snap.entity);
|
||||
entity_mut.remove::<CarriedBy>();
|
||||
entity_mut.remove::<InventorySlot>();
|
||||
entity_mut.insert(snap.position);
|
||||
} else {
|
||||
// NPC or other entity: just restore position
|
||||
world.entity_mut(snap.entity).insert(snap.position);
|
||||
}
|
||||
restored += 1;
|
||||
}
|
||||
|
||||
self.last_reset_tick
|
||||
.insert(room_name.to_string(), current_tick);
|
||||
|
||||
tracing::info!(
|
||||
room_name,
|
||||
restored,
|
||||
current_tick,
|
||||
"Room reset executed"
|
||||
);
|
||||
|
||||
Some(restored)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn record_and_reset_restores_position() {
|
||||
let mut world = World::new();
|
||||
let entity = world.spawn(TilePosition::new(10, 20, 0)).id();
|
||||
let mut snapshots = RoomSnapshots::default();
|
||||
|
||||
// Record initial position
|
||||
snapshots.record("test_room", entity, TilePosition::new(10, 20, 0), false);
|
||||
|
||||
// Move entity
|
||||
*world.get_mut::<TilePosition>(entity).unwrap() = TilePosition::new(50, 50, 0);
|
||||
assert_eq!(world.get::<TilePosition>(entity).unwrap().x, 50);
|
||||
|
||||
// Reset
|
||||
let restored = snapshots.execute_reset("test_room", 0, &mut world);
|
||||
assert_eq!(restored, Some(1));
|
||||
assert_eq!(world.get::<TilePosition>(entity).unwrap().x, 10);
|
||||
assert_eq!(world.get::<TilePosition>(entity).unwrap().y, 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_restores_floor_item() {
|
||||
let mut world = World::new();
|
||||
world.init_resource::<crate::knowledge::EntityRegistry>();
|
||||
|
||||
// Floor item starts on ground
|
||||
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);
|
||||
|
||||
// Simulate Take: remove TilePosition, add CarriedBy + InventorySlot
|
||||
world.entity_mut(item).remove::<TilePosition>();
|
||||
world
|
||||
.entity_mut(item)
|
||||
.insert((CarriedBy(crate::knowledge::types::StableId(0)), InventorySlot(0)));
|
||||
|
||||
assert!(world.get::<TilePosition>(item).is_none());
|
||||
assert!(world.get::<CarriedBy>(item).is_some());
|
||||
|
||||
// Reset
|
||||
let restored = snapshots.execute_reset("warehouse", 0, &mut world);
|
||||
assert_eq!(restored, Some(1));
|
||||
|
||||
// Item should be back on the ground
|
||||
assert_eq!(
|
||||
world.get::<TilePosition>(item).unwrap(),
|
||||
&TilePosition::new(5, 5, 0)
|
||||
);
|
||||
assert!(world.get::<CarriedBy>(item).is_none());
|
||||
assert!(world.get::<InventorySlot>(item).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debounce_prevents_rapid_reset() {
|
||||
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
|
||||
let result = snapshots.execute_reset("test_room", 0, &mut world);
|
||||
assert_eq!(result, Some(1));
|
||||
|
||||
// Second reset at tick 5 — should be debounced
|
||||
let result = snapshots.execute_reset("test_room", 5, &mut world);
|
||||
assert_eq!(result, None);
|
||||
|
||||
// Third reset at tick 10 — should succeed
|
||||
let result = snapshots.execute_reset("test_room", 10, &mut world);
|
||||
assert_eq!(result, Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_room_returns_none() {
|
||||
let mut world = World::new();
|
||||
let mut snapshots = RoomSnapshots::default();
|
||||
let result = snapshots.execute_reset("nonexistent", 0, &mut world);
|
||||
assert_eq!(result, 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user