refactor(simulation): address PR #32 review — 14 items from Hoshe + Tyre

Hoshe (code quality):
- Remove dead RoomMember component from reset.rs
- Remove execute_reset (dual API trap); plan_reset is sole production path
- .unwrap() → .expect() on reset_plate in setup_gauntlet boot path
- Add 10s read timeout to TCP runtime test (prevents hangs)
- Register player in EntityRegistry in runtime boot test
- Document room_at z-range and corridor overlap assumptions
- Derive entity count from EXPECTED_ENTITY_COUNT constant (was hardcoded 24)
- Add reset plate (49-51) verification to stable_id_ranges_match_spec
- Add debounce exact boundary test (tick 9 rejected, tick 10 accepted)

Tyre (architecture):
- Gate test_world rooms/constants/setup behind "gauntlet" feature (default-on);
  reset module stays always-compiled (production dependency via input system)
- Document setup_gauntlet scheduler bypass for future tracking
- Extract runtime TCP test to content_runtime.rs (separate failure modes)

507 tests passing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-18 09:45:17 +01:00
co-authored by Claude Opus 4.6
parent e66352e0ea
commit 09eb6d7582
8 changed files with 281 additions and 251 deletions
+48 -115
View File
@@ -6,11 +6,14 @@
//!
//! 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
//!
//! 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
@@ -20,7 +23,6 @@
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.
@@ -34,13 +36,6 @@ 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 {
@@ -88,6 +83,9 @@ impl RoomSnapshots {
/// 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,
@@ -109,62 +107,6 @@ impl RoomSnapshots {
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)]
@@ -172,86 +114,77 @@ mod tests {
use super::*;
#[test]
fn record_and_reset_restores_position() {
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();
// 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);
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 reset_restores_floor_item() {
fn plan_reset_includes_floor_items() {
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 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());
let changes = snapshots.plan_reset("warehouse", 0).unwrap();
assert_eq!(changes.len(), 1);
assert!(changes[0].2); // is a floor item
}
#[test]
fn debounce_prevents_rapid_reset() {
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
let result = snapshots.execute_reset("test_room", 0, &mut world);
assert_eq!(result, Some(1));
assert!(snapshots.plan_reset("test_room", 0).is_some());
// Second reset at tick 5 — should be debounced
let result = snapshots.execute_reset("test_room", 5, &mut world);
assert_eq!(result, None);
assert!(snapshots.plan_reset("test_room", 5).is_none());
// Third reset at tick 10 — should succeed
let result = snapshots.execute_reset("test_room", 10, &mut world);
assert_eq!(result, Some(1));
assert!(snapshots.plan_reset("test_room", 10).is_some());
}
#[test]
fn unknown_room_returns_none() {
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();
let result = snapshots.execute_reset("nonexistent", 0, &mut world);
assert_eq!(result, None);
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]