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:
@@ -69,6 +69,19 @@ impl EntityRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
/// Advance the next StableId counter to `target`.
|
||||
/// Used to reserve StableId ranges for entities not yet spawned
|
||||
/// (e.g., Gauntlet rooms built in later sprints).
|
||||
/// Panics if `target` is less than the current next_id.
|
||||
pub fn reserve_up_to(&mut self, target: u64) {
|
||||
assert!(
|
||||
target >= self.next_id,
|
||||
"cannot reserve backwards: next_id={}, target={}",
|
||||
self.next_id, target
|
||||
);
|
||||
self.next_id = target;
|
||||
}
|
||||
|
||||
/// Number of registered entities.
|
||||
pub fn len(&self) -> usize {
|
||||
self.by_entity.len()
|
||||
@@ -229,6 +242,30 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reserve_up_to_advances_counter() {
|
||||
let mut world = World::new();
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
let e1 = world.spawn_empty().id();
|
||||
let id1 = registry.register(e1);
|
||||
assert_eq!(id1, StableId(0));
|
||||
|
||||
// Reserve through 5 (skip IDs 1-4)
|
||||
registry.reserve_up_to(5);
|
||||
|
||||
let e2 = world.spawn_empty().id();
|
||||
let id2 = registry.register(e2);
|
||||
assert_eq!(id2, StableId(5), "next ID after reserve should be 5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "cannot reserve backwards")]
|
||||
fn reserve_up_to_panics_on_backwards() {
|
||||
let mut registry = EntityRegistry::new(10);
|
||||
registry.reserve_up_to(5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unregister_unknown_entity_is_noop() {
|
||||
// #469: Unregistering an entity that was never registered must not panic.
|
||||
|
||||
@@ -9,3 +9,4 @@ pub mod npc;
|
||||
pub mod perception;
|
||||
pub mod simulation;
|
||||
pub mod storyteller;
|
||||
pub mod test_world;
|
||||
|
||||
+6
-2
@@ -114,8 +114,12 @@ fn main() {
|
||||
// Override SimRng with the chosen seed (SimulationPlugin defaults to seed 0)
|
||||
app.insert_resource(settled_reach_server::simulation::rng::SimRng::new(seed));
|
||||
|
||||
// Gauntlet content loader is future scope — proof room for all modes.
|
||||
setup_proof_room(&mut app);
|
||||
// Gauntlet test world for --test-mode, proof room for normal mode.
|
||||
if test_mode {
|
||||
settled_reach_server::test_world::setup_gauntlet(&mut app);
|
||||
} else {
|
||||
setup_proof_room(&mut app);
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"Simulation initialized (seed={}, test_mode={})",
|
||||
|
||||
@@ -11,6 +11,7 @@ use crate::simulation::inventory::{
|
||||
use crate::simulation::movement::{MoveIntent, PlayerCharacter, TilePosition};
|
||||
use crate::simulation::stance::{PlayerMoveCooldown, Stance};
|
||||
use crate::simulation::time::{SimulationTime, TickRate};
|
||||
use crate::test_world::reset::{RoomResetTrigger, RoomSnapshots};
|
||||
use bevy_ecs::prelude::*;
|
||||
use std::collections::VecDeque;
|
||||
|
||||
@@ -91,6 +92,8 @@ pub fn process_player_input(
|
||||
>,
|
||||
inventory_items: Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>,
|
||||
all_positions: Query<&TilePosition>,
|
||||
reset_triggers: Query<&RoomResetTrigger>,
|
||||
mut room_snapshots: Option<ResMut<RoomSnapshots>>,
|
||||
) {
|
||||
let current_tick = time.tick;
|
||||
let paused = time.paused();
|
||||
@@ -193,6 +196,16 @@ pub fn process_player_input(
|
||||
target_entity_id,
|
||||
);
|
||||
}
|
||||
Some("Reset") => {
|
||||
handle_reset(
|
||||
&mut commands,
|
||||
®istry,
|
||||
&reset_triggers,
|
||||
&mut room_snapshots,
|
||||
target_entity_id,
|
||||
current_tick,
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
tracing::info!(
|
||||
"Interact: target={:?}, verb={:?} — logged only",
|
||||
@@ -438,6 +451,67 @@ fn handle_place(
|
||||
);
|
||||
}
|
||||
|
||||
/// Handle Reset verb: restore a room's entities to their initial positions.
|
||||
/// Target entity must have a RoomResetTrigger component. Respects debounce.
|
||||
fn handle_reset(
|
||||
commands: &mut Commands,
|
||||
registry: &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 = 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::<CarriedBy>()
|
||||
.remove::<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::*;
|
||||
@@ -1396,4 +1470,224 @@ mod tests {
|
||||
schedule.add_systems(process_player_input);
|
||||
schedule.run(&mut world); // should not panic
|
||||
}
|
||||
|
||||
// === Room Reset Tests (#490) ===
|
||||
|
||||
#[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::<crate::knowledge::EntityRegistry>();
|
||||
|
||||
// Player
|
||||
let player = world
|
||||
.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)))
|
||||
.id();
|
||||
let player_sid = world
|
||||
.resource_mut::<crate::knowledge::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::<crate::knowledge::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::<crate::knowledge::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::<crate::knowledge::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::<crate::knowledge::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::<crate::knowledge::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::<crate::knowledge::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::<crate::knowledge::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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
//! Gauntlet room and entity constants — single source of truth.
|
||||
//!
|
||||
//! All Gauntlet tests and room builders reference these constants instead
|
||||
//! of magic numbers. Room geometry, observer positions, entity placements,
|
||||
//! and StableId ranges are defined here.
|
||||
//!
|
||||
//! Canonical spawn order determines StableId assignment. Do NOT reorder
|
||||
//! existing entries — append new rooms/entities at the end (additive-only).
|
||||
//!
|
||||
//! Specification source: gestalt-round3.md Section 7.
|
||||
|
||||
use crate::bridge::types::FacingDirection;
|
||||
use crate::perception::vision_cone::Facing;
|
||||
use crate::simulation::movement::TilePosition;
|
||||
|
||||
/// A Gauntlet room definition.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GauntletRoom {
|
||||
/// Room identifier string (e.g., "central_hub", "occlusion_corridor").
|
||||
pub name: &'static str,
|
||||
/// Top-left corner of the room (includes walls).
|
||||
pub origin: TilePosition,
|
||||
/// (width, height) in sim tiles (includes walls).
|
||||
pub size: (i32, i32),
|
||||
/// Player spawn position within this room (absolute coordinates).
|
||||
pub spawn: TilePosition,
|
||||
/// Golden file observer position (absolute coordinates).
|
||||
pub observer: TilePosition,
|
||||
/// Direction the observer faces for golden file snapshots.
|
||||
pub observer_facing: Facing,
|
||||
/// Reset plate location (if any). Only present in rooms with
|
||||
/// corridor entrances (not the hub).
|
||||
pub reset_plate: Option<TilePosition>,
|
||||
}
|
||||
|
||||
/// A Gauntlet entity definition.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GauntletEntity {
|
||||
/// Entity name (e.g., "npc_guard_visible", "crate_01").
|
||||
pub name: &'static str,
|
||||
/// Which room this entity belongs to.
|
||||
pub room: &'static str,
|
||||
/// Absolute position in the Gauntlet map.
|
||||
pub position: TilePosition,
|
||||
/// Entity kind for classification.
|
||||
pub kind: EntityKind,
|
||||
/// StableId assigned to this entity.
|
||||
pub stable_id: u64,
|
||||
}
|
||||
|
||||
/// Entity classification for Gauntlet test entities.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum EntityKind {
|
||||
Sign,
|
||||
Npc,
|
||||
Object,
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Room constants — canonical spawn order
|
||||
// ============================================================
|
||||
|
||||
pub const HUB: GauntletRoom = GauntletRoom {
|
||||
name: "central_hub",
|
||||
origin: TilePosition { x: 38, y: 46, z: 0 },
|
||||
size: (24, 24),
|
||||
spawn: TilePosition { x: 50, y: 58, z: 0 },
|
||||
observer: TilePosition { x: 50, y: 58, z: 0 },
|
||||
observer_facing: Facing(FacingDirection::North),
|
||||
reset_plate: None,
|
||||
};
|
||||
|
||||
pub const FOG_THEATER: GauntletRoom = GauntletRoom {
|
||||
name: "fog_theater",
|
||||
origin: TilePosition { x: 28, y: 2, z: 0 },
|
||||
size: (44, 32),
|
||||
spawn: TilePosition { x: 56, y: 18, z: 0 },
|
||||
observer: TilePosition { x: 56, y: 18, z: 0 },
|
||||
observer_facing: Facing(FacingDirection::South),
|
||||
reset_plate: Some(TilePosition { x: 50, y: 34, z: 0 }),
|
||||
};
|
||||
|
||||
pub const OCCLUSION_CORRIDOR: GauntletRoom = GauntletRoom {
|
||||
name: "occlusion_corridor",
|
||||
origin: TilePosition { x: 74, y: 48, z: 0 },
|
||||
size: (42, 22),
|
||||
spawn: TilePosition { x: 84, y: 58, z: 0 },
|
||||
observer: TilePosition { x: 84, y: 58, z: 0 },
|
||||
observer_facing: Facing(FacingDirection::East),
|
||||
reset_plate: Some(TilePosition { x: 73, y: 58, z: 0 }),
|
||||
};
|
||||
|
||||
pub const INVENTORY_WAREHOUSE: GauntletRoom = GauntletRoom {
|
||||
name: "inventory_warehouse",
|
||||
origin: TilePosition { x: 2, y: 40, z: 0 },
|
||||
size: (30, 28),
|
||||
spawn: TilePosition { x: 17, y: 54, z: 0 },
|
||||
observer: TilePosition { x: 17, y: 54, z: 0 },
|
||||
observer_facing: Facing(FacingDirection::East),
|
||||
reset_plate: Some(TilePosition { x: 33, y: 58, z: 0 }),
|
||||
};
|
||||
|
||||
pub const INTERACTION_GALLERY: GauntletRoom = GauntletRoom {
|
||||
name: "interaction_gallery",
|
||||
origin: TilePosition { x: 2, y: 82, z: 0 },
|
||||
size: (24, 20),
|
||||
spawn: TilePosition { x: 14, y: 92, z: 0 },
|
||||
observer: TilePosition { x: 14, y: 92, z: 0 },
|
||||
observer_facing: Facing(FacingDirection::East),
|
||||
reset_plate: Some(TilePosition { x: 14, y: 82, z: 0 }),
|
||||
};
|
||||
|
||||
pub const PAUSE_CHAMBER: GauntletRoom = GauntletRoom {
|
||||
name: "pause_chamber",
|
||||
origin: TilePosition { x: 42, y: 78, z: 0 },
|
||||
size: (16, 16),
|
||||
spawn: TilePosition { x: 50, y: 86, z: 0 },
|
||||
observer: TilePosition { x: 50, y: 86, z: 0 },
|
||||
observer_facing: Facing(FacingDirection::North),
|
||||
reset_plate: Some(TilePosition { x: 50, y: 77, z: 0 }),
|
||||
};
|
||||
|
||||
pub const DIALOGUE_ROOM: GauntletRoom = GauntletRoom {
|
||||
name: "dialogue_room",
|
||||
origin: TilePosition { x: 36, y: 104, z: 0 },
|
||||
size: (28, 20),
|
||||
spawn: TilePosition { x: 50, y: 114, z: 0 },
|
||||
observer: TilePosition { x: 50, y: 114, z: 0 },
|
||||
observer_facing: Facing(FacingDirection::North),
|
||||
reset_plate: Some(TilePosition { x: 50, y: 103, z: 0 }),
|
||||
};
|
||||
|
||||
pub const CROWD_PLAZA: GauntletRoom = GauntletRoom {
|
||||
name: "crowd_plaza",
|
||||
origin: TilePosition { x: 80, y: 78, z: 0 },
|
||||
size: (32, 32),
|
||||
spawn: TilePosition { x: 96, y: 94, z: 0 },
|
||||
observer: TilePosition { x: 96, y: 94, z: 0 },
|
||||
observer_facing: Facing(FacingDirection::West),
|
||||
reset_plate: Some(TilePosition { x: 80, y: 86, z: 0 }),
|
||||
};
|
||||
|
||||
/// All rooms in canonical spawn order.
|
||||
/// THIS ORDER DETERMINES STABLEID ASSIGNMENT.
|
||||
/// Do not reorder existing entries. Append new rooms at the end.
|
||||
pub const ROOMS: &[GauntletRoom] = &[
|
||||
HUB,
|
||||
FOG_THEATER,
|
||||
OCCLUSION_CORRIDOR,
|
||||
INVENTORY_WAREHOUSE,
|
||||
INTERACTION_GALLERY,
|
||||
PAUSE_CHAMBER,
|
||||
DIALOGUE_ROOM,
|
||||
CROWD_PLAZA,
|
||||
];
|
||||
|
||||
/// Look up which room a position falls in.
|
||||
/// Returns the first room whose bounding box contains the position.
|
||||
pub fn room_at(pos: &TilePosition) -> Option<&'static GauntletRoom> {
|
||||
ROOMS.iter().find(|r| {
|
||||
pos.x >= r.origin.x
|
||||
&& pos.x < r.origin.x + r.size.0
|
||||
&& pos.y >= r.origin.y
|
||||
&& pos.y < r.origin.y + r.size.1
|
||||
&& pos.z == r.origin.z
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// StableId range constants
|
||||
// ============================================================
|
||||
|
||||
/// Player entity always gets StableId 0.
|
||||
pub const PLAYER_STABLE_ID: u64 = 0;
|
||||
|
||||
/// StableId ranges per room (start, end inclusive).
|
||||
pub const HUB_STABLE_IDS: (u64, u64) = (1, 4);
|
||||
pub const FOG_THEATER_STABLE_IDS: (u64, u64) = (5, 8);
|
||||
pub const OCCLUSION_STABLE_IDS: (u64, u64) = (9, 12);
|
||||
pub const INVENTORY_STABLE_IDS: (u64, u64) = (13, 23);
|
||||
pub const INTERACTION_GALLERY_STABLE_IDS: (u64, u64) = (24, 28);
|
||||
pub const PAUSE_CHAMBER_STABLE_IDS: (u64, u64) = (29, 29);
|
||||
pub const DIALOGUE_ROOM_STABLE_IDS: (u64, u64) = (30, 33);
|
||||
pub const CROWD_PLAZA_STABLE_IDS: (u64, u64) = (34, 48);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn room_at_finds_hub() {
|
||||
let pos = TilePosition { x: 50, y: 58, z: 0 };
|
||||
let room = room_at(&pos).expect("Hub center should be in a room");
|
||||
assert_eq!(room.name, "central_hub");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn room_at_finds_occlusion_corridor() {
|
||||
let pos = TilePosition { x: 84, y: 58, z: 0 };
|
||||
let room = room_at(&pos).expect("Occlusion observer should be in a room");
|
||||
assert_eq!(room.name, "occlusion_corridor");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn room_at_finds_inventory_warehouse() {
|
||||
let pos = TilePosition { x: 17, y: 54, z: 0 };
|
||||
let room = room_at(&pos).expect("Inventory observer should be in a room");
|
||||
assert_eq!(room.name, "inventory_warehouse");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn room_at_finds_pause_chamber() {
|
||||
let pos = TilePosition { x: 50, y: 86, z: 0 };
|
||||
let room = room_at(&pos).expect("Pause observer should be in a room");
|
||||
assert_eq!(room.name, "pause_chamber");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn room_at_returns_none_for_corridor() {
|
||||
// Point inside corridor-E (between Hub and Occlusion)
|
||||
let pos = TilePosition { x: 66, y: 57, z: 0 };
|
||||
assert!(room_at(&pos).is_none(), "Corridor should not be in any room");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn room_at_returns_none_for_outside_map() {
|
||||
let pos = TilePosition { x: 200, y: 200, z: 0 };
|
||||
assert!(room_at(&pos).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_rooms_in_correct_order() {
|
||||
assert_eq!(ROOMS.len(), 8);
|
||||
assert_eq!(ROOMS[0].name, "central_hub");
|
||||
assert_eq!(ROOMS[1].name, "fog_theater");
|
||||
assert_eq!(ROOMS[2].name, "occlusion_corridor");
|
||||
assert_eq!(ROOMS[3].name, "inventory_warehouse");
|
||||
assert_eq!(ROOMS[4].name, "interaction_gallery");
|
||||
assert_eq!(ROOMS[5].name, "pause_chamber");
|
||||
assert_eq!(ROOMS[6].name, "dialogue_room");
|
||||
assert_eq!(ROOMS[7].name, "crowd_plaza");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rooms_do_not_overlap() {
|
||||
for (i, a) in ROOMS.iter().enumerate() {
|
||||
for (j, b) in ROOMS.iter().enumerate() {
|
||||
if i >= j {
|
||||
continue;
|
||||
}
|
||||
let overlap_x = a.origin.x < b.origin.x + b.size.0
|
||||
&& a.origin.x + a.size.0 > b.origin.x;
|
||||
let overlap_y = a.origin.y < b.origin.y + b.size.1
|
||||
&& a.origin.y + a.size.1 > b.origin.y;
|
||||
assert!(
|
||||
!(overlap_x && overlap_y),
|
||||
"Rooms {} and {} overlap",
|
||||
a.name, b.name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observer_inside_room() {
|
||||
for room in ROOMS {
|
||||
let obs = &room.observer;
|
||||
assert!(
|
||||
obs.x >= room.origin.x && obs.x < room.origin.x + room.size.0,
|
||||
"Observer x={} outside room {} (origin.x={}, width={})",
|
||||
obs.x, room.name, room.origin.x, room.size.0
|
||||
);
|
||||
assert!(
|
||||
obs.y >= room.origin.y && obs.y < room.origin.y + room.size.1,
|
||||
"Observer y={} outside room {} (origin.y={}, height={})",
|
||||
obs.y, room.name, room.origin.y, room.size.1
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stable_id_ranges_non_overlapping() {
|
||||
let ranges = [
|
||||
HUB_STABLE_IDS,
|
||||
FOG_THEATER_STABLE_IDS,
|
||||
OCCLUSION_STABLE_IDS,
|
||||
INVENTORY_STABLE_IDS,
|
||||
INTERACTION_GALLERY_STABLE_IDS,
|
||||
PAUSE_CHAMBER_STABLE_IDS,
|
||||
DIALOGUE_ROOM_STABLE_IDS,
|
||||
CROWD_PLAZA_STABLE_IDS,
|
||||
];
|
||||
for (i, a) in ranges.iter().enumerate() {
|
||||
for (j, b) in ranges.iter().enumerate() {
|
||||
if i >= j {
|
||||
continue;
|
||||
}
|
||||
assert!(
|
||||
a.1 < b.0 || b.1 < a.0,
|
||||
"StableId ranges {} and {} overlap: {:?} vs {:?}",
|
||||
i, j, a, b
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
//! Gauntlet test world — purpose-built rooms for systematic QA testing.
|
||||
//!
|
||||
//! NOT production content. This module provides deterministic room layouts
|
||||
//! with precise entity placement for golden file testing, regression testing,
|
||||
//! and manual QA sessions. Loaded instead of content/ when the server runs
|
||||
//! the Gauntlet map.
|
||||
//!
|
||||
//! Architecture (per workshop-outcomes.md Section 4):
|
||||
//! - Rooms are defined as Rust builder functions (hybrid YAML + Rust inject)
|
||||
//! - WalkabilityMap covers the full Gauntlet bounds (0-116 x 0-124)
|
||||
//! - Entities spawned in canonical order → StableId assignment is deterministic
|
||||
//! - Additive-only: existing rooms/entities never reordered
|
||||
//!
|
||||
//! StableId ranges (from gestalt-round3.md):
|
||||
//! Player: 0
|
||||
//! Hub signs: 1-4
|
||||
//! Fog Theater: 5-8 (reserved, not yet built)
|
||||
//! Occlusion Corridor: 9-12
|
||||
//! Inventory Warehouse: 13-23
|
||||
//! Interaction Gallery: 24-28 (reserved, not yet built)
|
||||
//! Pause Chamber: 29
|
||||
//! Dialogue Room: 30-33 (reserved, not yet built)
|
||||
//! Crowd Plaza: 34-48 (reserved, not yet built)
|
||||
//! Reset plates: 49-51 (Occlusion, Inventory, Pause)
|
||||
|
||||
pub mod constants;
|
||||
pub mod reset;
|
||||
pub mod rooms;
|
||||
|
||||
use bevy_app::prelude::*;
|
||||
|
||||
use crate::knowledge::registry::{EntityRegistry, StableEntityId};
|
||||
use crate::knowledge::KnowledgeGraph;
|
||||
use crate::knowledge::types::StableId;
|
||||
use crate::perception::cognitive_delay::CognitiveDelay;
|
||||
use crate::perception::vision_cone::Facing;
|
||||
use crate::simulation::interaction::{Interactable, NearbyInteractionBuffer};
|
||||
use crate::simulation::inventory::ItemName;
|
||||
use crate::simulation::listening::ListeningFocus;
|
||||
use crate::simulation::monologue::{MonologueBuffer, MonologueState, SprintAnomalyQueue};
|
||||
use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
|
||||
use crate::simulation::stance::{MovementProfile, PlayerMoveCooldown};
|
||||
|
||||
use reset::{RoomResetTrigger, RoomSnapshots};
|
||||
|
||||
/// Map bounds for the full Gauntlet world.
|
||||
pub const MAP_WIDTH: i32 = 117;
|
||||
pub const MAP_HEIGHT: i32 = 125;
|
||||
|
||||
/// Set up the Gauntlet test world.
|
||||
///
|
||||
/// Creates the full WalkabilityMap (blocked by default), carves room
|
||||
/// interiors and corridors, spawns the player and all room entities
|
||||
/// in canonical StableId order.
|
||||
pub fn setup_gauntlet(app: &mut App) {
|
||||
// Start with a fully blocked map, then carve rooms and corridors.
|
||||
let mut walkability = WalkabilityMap::new_blocked(MAP_WIDTH, MAP_HEIGHT, 1);
|
||||
|
||||
// Carve room interiors (2-tile-thick walls → interior starts 2 tiles in)
|
||||
carve_room_interior(&mut walkability, 38, 46, 24, 24); // Hub
|
||||
carve_room_interior(&mut walkability, 74, 48, 42, 22); // Occlusion Corridor
|
||||
carve_room_interior(&mut walkability, 2, 40, 30, 28); // Inventory Warehouse
|
||||
carve_room_interior(&mut walkability, 42, 78, 16, 16); // Pause Chamber
|
||||
|
||||
// Carve corridors between hub and rooms
|
||||
carve_corridor(&mut walkability, 62, 55, 12, 6); // corridor-E: Hub ↔ Occlusion
|
||||
carve_corridor(&mut walkability, 32, 55, 6, 6); // corridor-W: Hub ↔ Inventory
|
||||
carve_corridor(&mut walkability, 47, 70, 6, 8); // corridor-S: Hub ↔ Pause Chamber
|
||||
|
||||
// Set up Occlusion Corridor walls (relative positions converted to absolute)
|
||||
// North wall segment: rel x=[14,22], y=[6,7] — blocks LOS to npc_hidden_wall
|
||||
for x in 88..=96 {
|
||||
for y in 54..=55 {
|
||||
walkability.set_walkable(&TilePosition::new(x, y, 0), false);
|
||||
}
|
||||
}
|
||||
// South alcove walls: rel x=[4,8], y=[12,13]
|
||||
for x in 78..=82 {
|
||||
for y in 60..=61 {
|
||||
walkability.set_walkable(&TilePosition::new(x, y, 0), false);
|
||||
}
|
||||
}
|
||||
|
||||
app.insert_resource(walkability);
|
||||
|
||||
// Entity spawning in canonical StableId order.
|
||||
// Player gets StableId 0, then entities by room in workshop order.
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
// --- Player (StableId 0) ---
|
||||
// Spawn at Hub center: absolute (50, 58)
|
||||
let profile = MovementProfile::smuggler();
|
||||
let player_pos = TilePosition::new(50, 58, 0);
|
||||
let player = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
player_pos,
|
||||
Facing::default(),
|
||||
KnowledgeGraph::new(),
|
||||
NearbyInteractionBuffer::default(),
|
||||
MonologueState::default(),
|
||||
MonologueBuffer::default(),
|
||||
SprintAnomalyQueue::default(),
|
||||
CognitiveDelay::default(),
|
||||
ListeningFocus::new(player_pos),
|
||||
profile,
|
||||
profile.initial_stance(),
|
||||
PlayerMoveCooldown::default(),
|
||||
))
|
||||
.id();
|
||||
registry.register(player);
|
||||
|
||||
// --- Hub signs (StableId 1-4) ---
|
||||
rooms::hub::spawn_entities(app, &mut registry);
|
||||
|
||||
// --- Fog Theater (StableId 5-8) — reserved, not yet built ---
|
||||
registry.reserve_up_to(9);
|
||||
|
||||
// --- Occlusion Corridor (StableId 9-12) ---
|
||||
rooms::occlusion_corridor::spawn_entities(app, &mut registry);
|
||||
|
||||
// --- Inventory Warehouse (StableId 13-23) ---
|
||||
rooms::inventory_warehouse::spawn_entities(app, &mut registry);
|
||||
|
||||
// --- Interaction Gallery (StableId 24-28) — reserved, not yet built ---
|
||||
registry.reserve_up_to(29);
|
||||
|
||||
// --- Pause Chamber (StableId 29) ---
|
||||
rooms::pause_chamber::spawn_entities(app, &mut registry);
|
||||
|
||||
// --- Dialogue Room (StableId 30-33) — reserved, not yet built ---
|
||||
// --- Crowd Plaza (StableId 34-48) — reserved, not yet built ---
|
||||
registry.reserve_up_to(49);
|
||||
|
||||
// --- Reset plates (StableId 49-51) ---
|
||||
// Spawned at corridor entrances per workshop-outcomes.md Section 8.
|
||||
// Each plate triggers reset of its associated room.
|
||||
let reset_plates: &[(&str, TilePosition)] = &[
|
||||
("occlusion_corridor", constants::OCCLUSION_CORRIDOR.reset_plate.unwrap()),
|
||||
("inventory_warehouse", constants::INVENTORY_WAREHOUSE.reset_plate.unwrap()),
|
||||
("pause_chamber", constants::PAUSE_CHAMBER.reset_plate.unwrap()),
|
||||
];
|
||||
for &(room_name, pos) in reset_plates {
|
||||
let entity = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
Interactable,
|
||||
RoomResetTrigger {
|
||||
room_name: room_name.to_string(),
|
||||
},
|
||||
pos,
|
||||
))
|
||||
.id();
|
||||
let sid = registry.register(entity);
|
||||
app.world_mut()
|
||||
.entity_mut(entity)
|
||||
.insert(StableEntityId(sid));
|
||||
}
|
||||
|
||||
// --- Populate RoomSnapshots for reset mechanism (#490) ---
|
||||
let mut snapshots = RoomSnapshots::default();
|
||||
|
||||
// Occlusion Corridor entities (StableId 9-12): NPCs only, no floor items
|
||||
for id in 9..=12 {
|
||||
if let Some(entity) = registry.to_entity(&StableId(id)) {
|
||||
if let Some(pos) = app.world().get::<TilePosition>(entity) {
|
||||
snapshots.record("occlusion_corridor", entity, *pos, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Inventory Warehouse entities (StableId 13-23): crates are floor items, NPC is not
|
||||
for id in 13..=23 {
|
||||
if let Some(entity) = registry.to_entity(&StableId(id)) {
|
||||
if let Some(pos) = app.world().get::<TilePosition>(entity) {
|
||||
let is_floor_item = app.world().get::<ItemName>(entity).is_some();
|
||||
snapshots.record("inventory_warehouse", entity, *pos, is_floor_item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pause Chamber entity (StableId 29): NPC only
|
||||
if let Some(entity) = registry.to_entity(&StableId(29)) {
|
||||
if let Some(pos) = app.world().get::<TilePosition>(entity) {
|
||||
snapshots.record("pause_chamber", entity, *pos, false);
|
||||
}
|
||||
}
|
||||
|
||||
app.insert_resource(snapshots);
|
||||
app.insert_resource(registry);
|
||||
}
|
||||
|
||||
/// Carve the walkable interior of a room.
|
||||
/// Room has 2-tile-thick walls; interior starts 2 tiles inside each boundary.
|
||||
fn carve_room_interior(wm: &mut WalkabilityMap, ox: i32, oy: i32, w: i32, h: i32) {
|
||||
for y in (oy + 2)..(oy + h - 2) {
|
||||
for x in (ox + 2)..(ox + w - 2) {
|
||||
wm.set_walkable(&TilePosition::new(x, y, 0), true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Carve a corridor as a fully walkable rectangle.
|
||||
/// Corridors are 6 tiles wide with walls on both sides — we carve the
|
||||
/// interior 2 tiles in from each edge (leaving 2-tile walkable center).
|
||||
/// For narrow corridors (width=6), interior is 2 tiles wide.
|
||||
fn carve_corridor(wm: &mut WalkabilityMap, ox: i32, oy: i32, w: i32, h: i32) {
|
||||
// Corridors are fully walkable rectangles (wall tiles are the
|
||||
// surrounding room/map boundary). Carve interior with 1-tile margin.
|
||||
for y in (oy + 1)..(oy + h - 1) {
|
||||
for x in (ox + 1)..(ox + w - 1) {
|
||||
wm.set_walkable(&TilePosition::new(x, y, 0), true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::knowledge::registry::EntityRegistry;
|
||||
|
||||
#[test]
|
||||
fn gauntlet_setup_creates_expected_entities() {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(crate::simulation::SimulationPlugin);
|
||||
app.add_plugins(crate::knowledge::KnowledgePlugin);
|
||||
app.add_plugins(crate::npc::NpcPlugin);
|
||||
|
||||
setup_gauntlet(&mut app);
|
||||
|
||||
let registry = app.world().resource::<EntityRegistry>();
|
||||
// Player (0) + Hub signs (1-4) + Occlusion (9-12) + Inventory (13-23) + Pause (29)
|
||||
// + Reset plates (49-51)
|
||||
// = 1 + 4 + 4 + 11 + 1 + 3 = 24 entities
|
||||
assert_eq!(registry.len(), 24);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hub_center_is_walkable() {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(crate::simulation::SimulationPlugin);
|
||||
app.add_plugins(crate::knowledge::KnowledgePlugin);
|
||||
app.add_plugins(crate::npc::NpcPlugin);
|
||||
|
||||
setup_gauntlet(&mut app);
|
||||
|
||||
let wm = app.world().resource::<WalkabilityMap>();
|
||||
// Hub center at (50, 58) must be walkable
|
||||
assert!(wm.can_move_to(&TilePosition::new(50, 58, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn occlusion_north_wall_blocks() {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(crate::simulation::SimulationPlugin);
|
||||
app.add_plugins(crate::knowledge::KnowledgePlugin);
|
||||
app.add_plugins(crate::npc::NpcPlugin);
|
||||
|
||||
setup_gauntlet(&mut app);
|
||||
|
||||
let wm = app.world().resource::<WalkabilityMap>();
|
||||
// North wall segment at absolute (90, 54) should be blocked
|
||||
assert!(!wm.can_move_to(&TilePosition::new(90, 54, 0)));
|
||||
// But the corridor interior at (90, 56) should be walkable
|
||||
assert!(wm.can_move_to(&TilePosition::new(90, 56, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corridor_connects_hub_to_occlusion() {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(crate::simulation::SimulationPlugin);
|
||||
app.add_plugins(crate::knowledge::KnowledgePlugin);
|
||||
app.add_plugins(crate::npc::NpcPlugin);
|
||||
|
||||
setup_gauntlet(&mut app);
|
||||
|
||||
let wm = app.world().resource::<WalkabilityMap>();
|
||||
// corridor-E center should be walkable
|
||||
assert!(wm.can_move_to(&TilePosition::new(66, 57, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stable_id_ranges_match_spec() {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(crate::simulation::SimulationPlugin);
|
||||
app.add_plugins(crate::knowledge::KnowledgePlugin);
|
||||
app.add_plugins(crate::npc::NpcPlugin);
|
||||
|
||||
setup_gauntlet(&mut app);
|
||||
|
||||
let registry = app.world().resource::<EntityRegistry>();
|
||||
|
||||
// Player at StableId 0
|
||||
use crate::knowledge::types::StableId;
|
||||
assert!(registry.to_entity(&StableId(0)).is_some(), "Player at StableId 0");
|
||||
|
||||
// Hub signs at 1-4
|
||||
for id in 1..=4 {
|
||||
assert!(registry.to_entity(&StableId(id)).is_some(), "Hub sign at StableId {}", id);
|
||||
}
|
||||
|
||||
// Fog Theater 5-8 reserved (no entities)
|
||||
for id in 5..=8 {
|
||||
assert!(registry.to_entity(&StableId(id)).is_none(), "Fog Theater {} reserved", id);
|
||||
}
|
||||
|
||||
// Occlusion Corridor at 9-12
|
||||
for id in 9..=12 {
|
||||
assert!(registry.to_entity(&StableId(id)).is_some(), "Occlusion at StableId {}", id);
|
||||
}
|
||||
|
||||
// Inventory Warehouse at 13-23
|
||||
for id in 13..=23 {
|
||||
assert!(registry.to_entity(&StableId(id)).is_some(), "Inventory at StableId {}", id);
|
||||
}
|
||||
|
||||
// Interaction Gallery 24-28 reserved
|
||||
for id in 24..=28 {
|
||||
assert!(registry.to_entity(&StableId(id)).is_none(), "Gallery {} reserved", id);
|
||||
}
|
||||
|
||||
// Pause Chamber at 29
|
||||
assert!(registry.to_entity(&StableId(29)).is_some(), "Pause Chamber at StableId 29");
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
//! Central Hub — Room 0 (24x24)
|
||||
//!
|
||||
//! Connector room with 4 directional sign markers. No test entities.
|
||||
//! Hub spawn point: (12, 12) relative = (50, 58) absolute.
|
||||
//!
|
||||
//! Entities (StableId 1-4):
|
||||
//! sign_north (50, 48) — "Fog Theater"
|
||||
//! sign_east (60, 58) — "Occlusion Corridor"
|
||||
//! sign_south (50, 68) — "Pause Chamber"
|
||||
//! sign_west (40, 58) — "Inventory Warehouse"
|
||||
|
||||
use bevy_app::prelude::*;
|
||||
|
||||
use crate::knowledge::registry::{EntityRegistry, StableEntityId};
|
||||
use crate::simulation::interaction::Interactable;
|
||||
use crate::simulation::movement::TilePosition;
|
||||
|
||||
/// Room origin (top-left corner including walls).
|
||||
const ORIGIN_X: i32 = 38;
|
||||
const ORIGIN_Y: i32 = 46;
|
||||
|
||||
/// Sign positions as relative offsets from room origin.
|
||||
const SIGNS: &[(&str, i32, i32)] = &[
|
||||
("sign_north", 12, 2), // North marker
|
||||
("sign_east", 22, 12), // East marker
|
||||
("sign_south", 12, 22), // South marker
|
||||
("sign_west", 2, 12), // West marker
|
||||
];
|
||||
|
||||
/// Spawn hub sign entities in canonical order (StableId 1-4).
|
||||
pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) {
|
||||
for &(_name, rx, ry) in SIGNS {
|
||||
let pos = TilePosition::new(ORIGIN_X + rx, ORIGIN_Y + ry, 0);
|
||||
let entity = app.world_mut().spawn((Interactable, pos)).id();
|
||||
let sid = registry.register(entity);
|
||||
app.world_mut()
|
||||
.entity_mut(entity)
|
||||
.insert(StableEntityId(sid));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
//! Inventory Warehouse — Room 3 (30x28)
|
||||
//!
|
||||
//! Tests D-065 (9-slot inventory), pickup/drop verbs, CarriedBy component.
|
||||
//!
|
||||
//! Layout: Open warehouse floor with 10 item crates in a grid pattern
|
||||
//! and 1 NPC near the back wall for interaction testing while carrying items.
|
||||
//!
|
||||
//! Observer position: (15, 14) relative = (17, 54) absolute, facing East.
|
||||
//!
|
||||
//! Entities (StableId 13-23):
|
||||
//! crate_01..crate_10 — Floor items (Container ObjectType)
|
||||
//! npc_warehouse — Interaction target
|
||||
|
||||
use bevy_app::prelude::*;
|
||||
|
||||
use crate::bridge::types::ObjectType;
|
||||
use crate::knowledge::registry::{EntityRegistry, StableEntityId};
|
||||
use crate::npc::{Contentment, Npc, ToleranceThreshold, Want, WantKind};
|
||||
use crate::simulation::interaction::Interactable;
|
||||
use crate::simulation::inventory::ItemName;
|
||||
use crate::simulation::movement::TilePosition;
|
||||
use crate::simulation::path_follow::MovementSpeed;
|
||||
|
||||
/// Room origin (top-left corner including walls).
|
||||
const ORIGIN_X: i32 = 2;
|
||||
const ORIGIN_Y: i32 = 40;
|
||||
|
||||
/// Item crate definitions: (name, relative_x, relative_y, item_name).
|
||||
/// 10 crates for testing the 9-slot inventory limit.
|
||||
const CRATES: &[(&str, i32, i32, &str)] = &[
|
||||
("crate_01", 4, 4, "Keycard"),
|
||||
("crate_02", 8, 4, "Manifest"),
|
||||
("crate_03", 12, 4, "Datapad"),
|
||||
("crate_04", 16, 4, "Toolkit"),
|
||||
("crate_05", 4, 10, "Badge"),
|
||||
("crate_06", 8, 10, "Medkit"),
|
||||
("crate_07", 12, 10, "Ration"),
|
||||
("crate_08", 16, 10, "Cable"),
|
||||
("crate_09", 4, 16, "Seal"),
|
||||
("crate_10", 8, 16, "Chip"),
|
||||
];
|
||||
|
||||
/// Spawn Inventory Warehouse entities in canonical order (StableId 13-23).
|
||||
pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) {
|
||||
// Crates (StableId 13-22)
|
||||
for &(_name, rx, ry, item_name) in CRATES {
|
||||
let pos = TilePosition::new(ORIGIN_X + rx, ORIGIN_Y + ry, 0);
|
||||
let entity = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
Interactable,
|
||||
ObjectType::Container,
|
||||
ItemName(item_name.to_string()),
|
||||
pos,
|
||||
))
|
||||
.id();
|
||||
let sid = registry.register(entity);
|
||||
app.world_mut()
|
||||
.entity_mut(entity)
|
||||
.insert(StableEntityId(sid));
|
||||
}
|
||||
|
||||
// Warehouse NPC (StableId 23)
|
||||
let npc_pos = TilePosition::new(ORIGIN_X + 22, ORIGIN_Y + 14, 0);
|
||||
let entity = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
Npc,
|
||||
Interactable,
|
||||
npc_pos,
|
||||
Want {
|
||||
primary: WantKind::Wealth,
|
||||
intensity: 5,
|
||||
description: "Warehouse supervisor wants efficiency".to_string(),
|
||||
},
|
||||
Contentment { level: 10 },
|
||||
ToleranceThreshold {
|
||||
current_stress: 15,
|
||||
threshold: 65,
|
||||
},
|
||||
MovementSpeed::default(),
|
||||
))
|
||||
.id();
|
||||
let sid = registry.register(entity);
|
||||
app.world_mut()
|
||||
.entity_mut(entity)
|
||||
.insert(StableEntityId(sid));
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//! Gauntlet room builders.
|
||||
//!
|
||||
//! Each room module exports a `spawn_entities()` function that creates
|
||||
//! entities in canonical order for deterministic StableId assignment.
|
||||
|
||||
pub mod hub;
|
||||
pub mod inventory_warehouse;
|
||||
pub mod occlusion_corridor;
|
||||
pub mod pause_chamber;
|
||||
@@ -0,0 +1,66 @@
|
||||
//! Occlusion Corridor — Room 2 (42x22)
|
||||
//!
|
||||
//! Tests D-035 (symmetric shadowcasting), D-017 (perception modes),
|
||||
//! D-015 (vision cone sectors).
|
||||
//!
|
||||
//! Layout: Long east-west corridor with perpendicular wall segments
|
||||
//! creating visibility pockets. North alcove with hidden NPC,
|
||||
//! south alcove with partially visible NPC.
|
||||
//!
|
||||
//! Observer position: (10, 10) relative = (84, 58) absolute, facing East.
|
||||
//!
|
||||
//! Entities (StableId 9-12):
|
||||
//! npc_guard_visible (92, 58) — Clear LOS baseline
|
||||
//! npc_hidden_wall (92, 52) — Behind north wall segment
|
||||
//! npc_peripheral (80, 64) — South alcove, peripheral sector
|
||||
//! npc_far_end (110, 58) — Far end of corridor, tests range
|
||||
|
||||
use bevy_app::prelude::*;
|
||||
|
||||
use crate::knowledge::registry::{EntityRegistry, StableEntityId};
|
||||
use crate::npc::{Contentment, Npc, ToleranceThreshold, Want, WantKind};
|
||||
use crate::simulation::interaction::Interactable;
|
||||
use crate::simulation::movement::TilePosition;
|
||||
use crate::simulation::path_follow::MovementSpeed;
|
||||
|
||||
/// Room origin (top-left corner including walls).
|
||||
const ORIGIN_X: i32 = 74;
|
||||
const ORIGIN_Y: i32 = 48;
|
||||
|
||||
/// NPC definitions: (name, relative_x, relative_y, want_kind, want_intensity).
|
||||
const NPCS: &[(&str, i32, i32, WantKind, u8)] = &[
|
||||
("npc_guard_visible", 18, 10, WantKind::Safety, 5),
|
||||
("npc_hidden_wall", 18, 4, WantKind::Freedom, 3),
|
||||
("npc_peripheral", 6, 16, WantKind::Knowledge, 6),
|
||||
("npc_far_end", 36, 10, WantKind::Wealth, 4),
|
||||
];
|
||||
|
||||
/// Spawn Occlusion Corridor entities in canonical order (StableId 9-12).
|
||||
pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) {
|
||||
for &(name, rx, ry, want_kind, intensity) in NPCS {
|
||||
let pos = TilePosition::new(ORIGIN_X + rx, ORIGIN_Y + ry, 0);
|
||||
let entity = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
Npc,
|
||||
Interactable,
|
||||
pos,
|
||||
Want {
|
||||
primary: want_kind,
|
||||
intensity,
|
||||
description: format!("Occlusion Corridor test NPC: {}", name),
|
||||
},
|
||||
Contentment { level: 0 },
|
||||
ToleranceThreshold {
|
||||
current_stress: 0,
|
||||
threshold: 50,
|
||||
},
|
||||
MovementSpeed::default(),
|
||||
))
|
||||
.id();
|
||||
let sid = registry.register(entity);
|
||||
app.world_mut()
|
||||
.entity_mut(entity)
|
||||
.insert(StableEntityId(sid));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//! Pause Chamber — Room 5 (16x16)
|
||||
//!
|
||||
//! Tests D-031 (pause/unpause), Bug #3 regression (movement while paused).
|
||||
//!
|
||||
//! Layout: Minimal open room with a single NPC. No visual complexity —
|
||||
//! this room is pure state machine testing.
|
||||
//!
|
||||
//! Observer position: (8, 8) relative = (50, 86) absolute, facing North.
|
||||
//!
|
||||
//! Entities (StableId 29):
|
||||
//! npc_pause_target — Interaction target during pause (Talk should work)
|
||||
|
||||
use bevy_app::prelude::*;
|
||||
|
||||
use crate::knowledge::registry::{EntityRegistry, StableEntityId};
|
||||
use crate::npc::{Contentment, Npc, ToleranceThreshold, Want, WantKind};
|
||||
use crate::simulation::interaction::Interactable;
|
||||
use crate::simulation::movement::TilePosition;
|
||||
use crate::simulation::path_follow::MovementSpeed;
|
||||
|
||||
/// Room origin (top-left corner including walls).
|
||||
const ORIGIN_X: i32 = 42;
|
||||
const ORIGIN_Y: i32 = 78;
|
||||
|
||||
/// Spawn Pause Chamber entities in canonical order (StableId 29).
|
||||
pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) {
|
||||
let pos = TilePosition::new(ORIGIN_X + 6, ORIGIN_Y + 6, 0);
|
||||
let entity = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
Npc,
|
||||
Interactable,
|
||||
pos,
|
||||
Want {
|
||||
primary: WantKind::Safety,
|
||||
intensity: 3,
|
||||
description: "Wants a quiet posting".to_string(),
|
||||
},
|
||||
Contentment { level: 5 },
|
||||
ToleranceThreshold {
|
||||
current_stress: 10,
|
||||
threshold: 40,
|
||||
},
|
||||
MovementSpeed::default(),
|
||||
))
|
||||
.id();
|
||||
let sid = registry.register(entity);
|
||||
app.world_mut()
|
||||
.entity_mut(entity)
|
||||
.insert(StableEntityId(sid));
|
||||
}
|
||||
Reference in New Issue
Block a user