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:
2026-02-18 02:25:57 +01:00
co-authored by Claude Opus 4.6
parent 58072bc119
commit e66352e0ea
19 changed files with 7906 additions and 23 deletions
+306
View File
@@ -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
);
}
}
}
}