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