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,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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user