Files
settled-reach/server/src/test_world/mod.rs
T
jpmschweitzerandClaude Opus 4.6 daae3dd6ab feat(simulation): add Zone Gate gauntlet room and zone crossing detection (#512)
New test_world room with two zones (Terminal/Corridor) separated by
a door. Adds ZoneCrossEventQueue resource and detect_zone_crossings
system to fire events when the player crosses zone boundaries.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 16:34:21 +01:00

955 lines
34 KiB
Rust

//! 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.
//!
//! Module structure:
//! - `reset` — always compiled (used by simulation::input in production)
//! - `constants`, `rooms`, `setup_gauntlet` — gated behind "gauntlet" feature
//! (default-on; strip with --no-default-features for release builds)
//!
//! 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 + Sprint 11 + Sprint 13 + Sprint 22):
//! Player: 0
//! Hub signs: 1-4
//! Fog Theater: 5-8
//! Occlusion Corridor: 9-12
//! Inventory Warehouse: 13-23
//! Interaction Gallery: 24-28
//! Pause Chamber: 29
//! Dialogue Room: 30-33
//! Crowd Plaza: 34-48
//! Reset plates (Sprint 1-10 rooms): 49-55
//! Sprint Gauntlet: 56-57
//! Eavesdrop Alcove: 58-60
//! Confrontation Stage: 61-62
//! Reset plates (Sprint 11 rooms): 63-65
//! Sound Lab: 66-68
//! Decay Observatory: 69
//! Shift Change: 70-71
//! Reset plates (Sprint 13 rooms): 72-74
//! Zone Gate: 75 (door entity)
//! Reset plates (Sprint 22 rooms): 76
#[cfg(feature = "gauntlet")]
pub mod constants;
#[cfg(feature = "gauntlet")]
pub mod invariants;
pub mod reset;
#[cfg(feature = "gauntlet")]
pub mod rooms;
#[cfg(feature = "gauntlet")]
use bevy_app::prelude::*;
#[cfg(feature = "gauntlet")]
use crate::knowledge::registry::{EntityRegistry, StableEntityId};
#[cfg(feature = "gauntlet")]
use crate::knowledge::types::StableId;
#[cfg(feature = "gauntlet")]
use crate::knowledge::KnowledgeGraph;
#[cfg(feature = "gauntlet")]
use crate::perception::cognitive_delay::CognitiveDelay;
#[cfg(feature = "gauntlet")]
use crate::perception::vision_cone::Facing;
#[cfg(feature = "gauntlet")]
use crate::simulation::contraband::ScanEventBuffer;
#[cfg(feature = "gauntlet")]
use crate::simulation::interaction::{Interactable, NearbyInteractionBuffer};
#[cfg(feature = "gauntlet")]
use crate::simulation::inventory::ItemName;
#[cfg(feature = "gauntlet")]
use crate::simulation::listening::ListeningFocus;
use crate::simulation::monologue::{MonologueBuffer, MonologueState, SprintAnomalyQueue};
#[cfg(feature = "gauntlet")]
use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
#[cfg(feature = "gauntlet")]
use crate::simulation::stance::{MovementProfile, PlayerMoveCooldown};
#[cfg(feature = "gauntlet")]
use crate::simulation::zone::ZoneMap;
#[cfg(feature = "gauntlet")]
use reset::{RoomResetTrigger, RoomSnapshots};
#[cfg(feature = "gauntlet")]
/// Map bounds for the full Gauntlet world.
pub const MAP_WIDTH: i32 = 117;
#[cfg(feature = "gauntlet")]
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.
///
/// NOTE: This bypasses the normal startup system scheduler — entities are
/// spawned directly into the World rather than via startup systems. This
/// is intentional for deterministic test setups but should be revisited
/// if Gauntlet is ever served by the production startup pipeline.
#[cfg(feature = "gauntlet")]
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, 28, 2, 44, 32); // Fog Theater
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, 2, 82, 24, 20); // Interaction Gallery
carve_room_interior(&mut walkability, 42, 78, 16, 16); // Pause Chamber
carve_room_interior(&mut walkability, 36, 104, 28, 20); // Dialogue Room
carve_room_interior(&mut walkability, 80, 78, 32, 32); // Crowd Plaza
carve_room_interior(&mut walkability, 0, 2, 28, 20); // Sprint Gauntlet
carve_room_interior(&mut walkability, 74, 26, 24, 16); // Eavesdrop Alcove
carve_room_interior(&mut walkability, 84, 2, 32, 24); // Confrontation Stage
// Sprint 13 rooms
carve_room_interior(&mut walkability, 0, 104, 34, 20); // Sound Lab
carve_room_interior(&mut walkability, 0, 24, 24, 14); // Decay Observatory
carve_room_interior(&mut walkability, 64, 78, 16, 24); // Shift Change
// Sprint 22 rooms
carve_room_interior(&mut walkability, 64, 102, 16, 22); // Zone Gate
// Carve corridors between hub and rooms
carve_corridor(&mut walkability, 48, 34, 6, 12); // corridor-N: Hub ↔ Fog Theater
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
carve_corridor(&mut walkability, 12, 68, 6, 14); // corridor-SW: Inventory ↔ Interaction Gallery
carve_corridor(&mut walkability, 48, 94, 6, 10); // corridor-S2: Pause ↔ Dialogue Room
carve_corridor(&mut walkability, 58, 84, 22, 6); // corridor-E2: Pause ↔ Crowd Plaza
// Sprint 11 corridors
carve_corridor(&mut walkability, 12, 22, 6, 18); // corridor-NW: Sprint Gauntlet ↔ Inventory south
carve_corridor(&mut walkability, 62, 30, 12, 6); // corridor-NE: Eavesdrop Alcove ↔ Occlusion north
// 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);
// Zone map: assign zone IDs per room bounding box (D-077, D-073).
// Zone IDs are sequential per ROOMS order. Corridors remain unzoned (None).
// Exception: Zone Gate (index 14) uses two explicit zone IDs for its dual-zone split.
let mut zone_map = ZoneMap::default();
for (i, room) in constants::ROOMS.iter().enumerate() {
// Zone Gate handled separately below — skip in the sequential loop.
if room.name == "zone_gate" {
continue;
}
let zone_id = i as u16;
zone_map.set_rect(
room.origin.x,
room.origin.y,
room.size.0,
room.size.1,
room.origin.z,
zone_id,
);
}
// Zone Gate: Terminal side (left 8 cols, x=64-71) and Corridor side (right 8 cols, x=72-79).
zone_map.set_rect(
constants::ZONE_GATE.origin.x,
constants::ZONE_GATE.origin.y,
constants::ZONE_GATE.size.0 / 2,
constants::ZONE_GATE.size.1,
constants::ZONE_GATE.origin.z,
constants::ZONE_GATE_TERMINAL_ZONE_ID,
);
zone_map.set_rect(
constants::ZONE_GATE.origin.x + constants::ZONE_GATE.size.0 / 2,
constants::ZONE_GATE.origin.y,
constants::ZONE_GATE.size.0 / 2,
constants::ZONE_GATE.size.1,
constants::ZONE_GATE.origin.z,
constants::ZONE_GATE_CORRIDOR_ZONE_ID,
);
app.insert_resource(zone_map);
// 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(),
ScanEventBuffer::default(),
CognitiveDelay::default(),
ListeningFocus::new(player_pos),
profile,
profile.initial_stance(),
PlayerMoveCooldown::default(),
crate::simulation::pressure::CharacterPressure::default(),
))
.id();
registry.register(player);
// --- Hub signs (StableId 1-4) ---
rooms::hub::spawn_entities(app, &mut registry);
// --- Fog Theater (StableId 5-8) ---
rooms::fog_theater::spawn_entities(app, &mut registry);
// --- 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) ---
rooms::interaction_gallery::spawn_entities(app, &mut registry);
// --- Pause Chamber (StableId 29) ---
rooms::pause_chamber::spawn_entities(app, &mut registry);
// --- Dialogue Room (StableId 30-33) ---
rooms::dialogue_room::spawn_entities(app, &mut registry);
// --- Crowd Plaza (StableId 34-48) ---
rooms::crowd_plaza::spawn_entities(app, &mut registry);
// --- Reset plates (StableId 49-55) ---
// 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
.expect("occlusion_corridor should have a reset_plate"),
),
(
"inventory_warehouse",
constants::INVENTORY_WAREHOUSE
.reset_plate
.expect("inventory_warehouse should have a reset_plate"),
),
(
"pause_chamber",
constants::PAUSE_CHAMBER
.reset_plate
.expect("pause_chamber should have a reset_plate"),
),
(
"fog_theater",
constants::FOG_THEATER
.reset_plate
.expect("fog_theater should have a reset_plate"),
),
(
"interaction_gallery",
constants::INTERACTION_GALLERY
.reset_plate
.expect("interaction_gallery should have a reset_plate"),
),
(
"dialogue_room",
constants::DIALOGUE_ROOM
.reset_plate
.expect("dialogue_room should have a reset_plate"),
),
(
"crowd_plaza",
constants::CROWD_PLAZA
.reset_plate
.expect("crowd_plaza should have a reset_plate"),
),
];
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));
}
// --- Sprint Gauntlet (StableId 56-57) ---
rooms::sprint_gauntlet::spawn_entities(app, &mut registry);
// --- Eavesdrop Alcove (StableId 58-60) ---
rooms::eavesdrop_alcove::spawn_entities(app, &mut registry);
// --- Confrontation Stage (StableId 61-62) ---
rooms::confrontation_stage::spawn_entities(app, &mut registry);
// --- Sound Lab (StableId 66-68) ---
rooms::sound_lab::spawn_entities(app, &mut registry);
// --- Decay Observatory (StableId 69) ---
rooms::decay_observatory::spawn_entities(app, &mut registry);
// --- Shift Change (StableId 70-71) ---
rooms::shift_change::spawn_entities(app, &mut registry);
// --- Sprint 11 reset plates (StableId 63-65) ---
let sprint11_reset_plates: &[(&str, TilePosition)] = &[
(
"sprint_gauntlet",
constants::SPRINT_GAUNTLET
.reset_plate
.expect("sprint_gauntlet should have a reset_plate"),
),
(
"eavesdrop_alcove",
constants::EAVESDROP_ALCOVE
.reset_plate
.expect("eavesdrop_alcove should have a reset_plate"),
),
(
"confrontation_stage",
constants::CONFRONTATION_STAGE
.reset_plate
.expect("confrontation_stage should have a reset_plate"),
),
];
for &(room_name, pos) in sprint11_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));
}
// --- Sprint 13 reset plates (StableId 72-74) ---
// NOTE: Sprint 22 reset plate (zone_gate) is spawned AFTER these to maintain additive order.
// The sprint13 block is numbered 72-74; zone_gate entities are 75; sprint22 plate is 76.
// The spawn order below matches the StableId allocation table in the module doc.
let sprint13_reset_plates: &[(&str, TilePosition)] = &[
(
"sound_lab",
constants::SOUND_LAB
.reset_plate
.expect("sound_lab should have a reset_plate"),
),
(
"decay_observatory",
constants::DECAY_OBSERVATORY
.reset_plate
.expect("decay_observatory should have a reset_plate"),
),
(
"shift_change",
constants::SHIFT_CHANGE
.reset_plate
.expect("shift_change should have a reset_plate"),
),
];
for &(room_name, pos) in sprint13_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));
}
// --- Zone Gate (StableId 75) ---
// Spawned AFTER Sprint 13 reset plates so it gets ID 75 per the allocation table.
rooms::zone_gate::spawn_entities(app, &mut registry);
// --- Sprint 22 reset plates (StableId 76) ---
let sprint22_reset_plates: &[(&str, TilePosition)] = &[(
"zone_gate",
constants::ZONE_GATE
.reset_plate
.expect("zone_gate should have a reset_plate"),
)];
for &(room_name, pos) in sprint22_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();
// Fog Theater entities (StableId 5-8): NPCs only
for id in constants::FOG_THEATER_STABLE_IDS.0..=constants::FOG_THEATER_STABLE_IDS.1 {
if let Some(entity) = registry.to_entity(&StableId(id)) {
if let Some(pos) = app.world().get::<TilePosition>(entity) {
snapshots.record("fog_theater", entity, *pos, false);
}
}
}
// 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);
}
}
}
// Interaction Gallery entities (StableId 24-28): objects only, no floor items
for id in
constants::INTERACTION_GALLERY_STABLE_IDS.0..=constants::INTERACTION_GALLERY_STABLE_IDS.1
{
if let Some(entity) = registry.to_entity(&StableId(id)) {
if let Some(pos) = app.world().get::<TilePosition>(entity) {
snapshots.record("interaction_gallery", entity, *pos, false);
}
}
}
// 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);
}
}
// Dialogue Room entities (StableId 30-33): NPCs only
for id in constants::DIALOGUE_ROOM_STABLE_IDS.0..=constants::DIALOGUE_ROOM_STABLE_IDS.1 {
if let Some(entity) = registry.to_entity(&StableId(id)) {
if let Some(pos) = app.world().get::<TilePosition>(entity) {
snapshots.record("dialogue_room", entity, *pos, false);
}
}
}
// Crowd Plaza entities (StableId 34-48): NPCs only
for id in constants::CROWD_PLAZA_STABLE_IDS.0..=constants::CROWD_PLAZA_STABLE_IDS.1 {
if let Some(entity) = registry.to_entity(&StableId(id)) {
if let Some(pos) = app.world().get::<TilePosition>(entity) {
snapshots.record("crowd_plaza", entity, *pos, false);
}
}
}
// Sprint Gauntlet entities (StableId 56-57): sign + NPC, no floor items
for id in constants::SPRINT_GAUNTLET_STABLE_IDS.0..=constants::SPRINT_GAUNTLET_STABLE_IDS.1 {
if let Some(entity) = registry.to_entity(&StableId(id)) {
if let Some(pos) = app.world().get::<TilePosition>(entity) {
snapshots.record("sprint_gauntlet", entity, *pos, false);
}
}
}
// Eavesdrop Alcove entities (StableId 58-60): NPCs + sign, no floor items
for id in constants::EAVESDROP_ALCOVE_STABLE_IDS.0..=constants::EAVESDROP_ALCOVE_STABLE_IDS.1 {
if let Some(entity) = registry.to_entity(&StableId(id)) {
if let Some(pos) = app.world().get::<TilePosition>(entity) {
snapshots.record("eavesdrop_alcove", entity, *pos, false);
}
}
}
// Confrontation Stage entities (StableId 61-62): NPCs only, no floor items
for id in
constants::CONFRONTATION_STAGE_STABLE_IDS.0..=constants::CONFRONTATION_STAGE_STABLE_IDS.1
{
if let Some(entity) = registry.to_entity(&StableId(id)) {
if let Some(pos) = app.world().get::<TilePosition>(entity) {
snapshots.record("confrontation_stage", entity, *pos, false);
}
}
}
// Sound Lab entities (StableId 66-68): NPCs only, no floor items
for id in constants::SOUND_LAB_STABLE_IDS.0..=constants::SOUND_LAB_STABLE_IDS.1 {
if let Some(entity) = registry.to_entity(&StableId(id)) {
if let Some(pos) = app.world().get::<TilePosition>(entity) {
snapshots.record("sound_lab", entity, *pos, false);
}
}
}
// Decay Observatory entities (StableId 69): NPC only, no floor items
for id in
constants::DECAY_OBSERVATORY_STABLE_IDS.0..=constants::DECAY_OBSERVATORY_STABLE_IDS.1
{
if let Some(entity) = registry.to_entity(&StableId(id)) {
if let Some(pos) = app.world().get::<TilePosition>(entity) {
snapshots.record("decay_observatory", entity, *pos, false);
}
}
}
// Shift Change entities (StableId 70-71): NPCs only, no floor items
for id in constants::SHIFT_CHANGE_STABLE_IDS.0..=constants::SHIFT_CHANGE_STABLE_IDS.1 {
if let Some(entity) = registry.to_entity(&StableId(id)) {
if let Some(pos) = app.world().get::<TilePosition>(entity) {
snapshots.record("shift_change", entity, *pos, false);
}
}
}
// Zone Gate entities (StableId 75): door only, no floor items
for id in constants::ZONE_GATE_STABLE_IDS.0..=constants::ZONE_GATE_STABLE_IDS.1 {
if let Some(entity) = registry.to_entity(&StableId(id)) {
if let Some(pos) = app.world().get::<TilePosition>(entity) {
snapshots.record("zone_gate", entity, *pos, false);
}
}
}
app.insert_resource(snapshots);
// --- Sprint 14 component fixup ---
// Attach MoodState and InteractionMemory to all Npc entities that are
// missing them. Gauntlet room builders don't include these yet — this
// ensures invariant S14-1/S14-2 pass and the mood/trust systems have
// valid component targets.
{
use crate::npc::interaction::InteractionMemory;
use crate::npc::mood::MoodState;
use crate::npc::Npc;
let missing_mood: Vec<bevy_ecs::prelude::Entity> = {
let mut q = app
.world_mut()
.query_filtered::<bevy_ecs::prelude::Entity, (
bevy_ecs::prelude::With<Npc>,
bevy_ecs::prelude::Without<MoodState>,
)>();
q.iter(app.world()).collect()
};
for entity in missing_mood {
app.world_mut()
.entity_mut(entity)
.insert(MoodState::default());
}
let missing_mem: Vec<bevy_ecs::prelude::Entity> = {
let mut q = app
.world_mut()
.query_filtered::<bevy_ecs::prelude::Entity, (
bevy_ecs::prelude::With<Npc>,
bevy_ecs::prelude::Without<InteractionMemory>,
)>();
q.iter(app.world()).collect()
};
for entity in missing_mem {
app.world_mut()
.entity_mut(entity)
.insert(InteractionMemory::default());
}
}
// --- Dialogue fixup: wire up DialogueProfile on all Npc entities missing one ---
// Gauntlet room builders (except dialogue_room) don't include DialogueProfile.
// Without it, the Talk verb silently no-ops. This fixup ensures every NPC
// can respond to Talk using content from the YAML dialogue pools.
{
use crate::npc::Npc;
use crate::simulation::conversation::NpcColorIndex;
use crate::simulation::dialogue::{CurrentMood, DialogueProfile};
// (location, role) pairs matching content/campaigns/.../dialogue/ YAML pools.
// Cycling through these gives NPC variety across rooms.
const DIALOGUE_ROLES: &[(&str, &str)] = &[
("the-terminal", "dock-worker"),
("the-terminal", "courier"),
("the-terminal", "maintenance-tech"),
("the-terminal", "new-hire"),
("the-terminal", "scheduler"),
("the-terminal", "shift-supervisor"),
("the-last-shift", "bartender"),
("the-last-shift", "bar-regular"),
("the-last-shift", "day-worker"),
("maintenance-corridors", "transit-worker"),
];
let missing: Vec<bevy_ecs::prelude::Entity> = {
let mut q = app
.world_mut()
.query_filtered::<bevy_ecs::prelude::Entity, (
bevy_ecs::prelude::With<Npc>,
bevy_ecs::prelude::Without<DialogueProfile>,
)>();
q.iter(app.world()).collect()
};
for (i, entity) in missing.iter().enumerate() {
let (location, role) = DIALOGUE_ROLES[i % DIALOGUE_ROLES.len()];
let color_index = registry
.to_stable(*entity)
.map(|sid| (sid.0 % 8) as u8)
.unwrap_or(0u8);
app.world_mut().entity_mut(*entity).insert((
DialogueProfile {
location: location.to_string(),
role: role.to_string(),
},
CurrentMood::default(),
NpcColorIndex(color_index),
));
}
}
app.insert_resource(registry);
}
#[cfg(feature = "gauntlet")]
/// 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);
}
}
}
#[cfg(feature = "gauntlet")]
/// 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(all(test, feature = "gauntlet"))]
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);
// Run all 29 world-query invariants against the fully-initialized gauntlet world.
invariants::run_invariants(app.world_mut());
let registry = app.world().resource::<EntityRegistry>();
assert_eq!(
registry.len(),
constants::EXPECTED_ENTITY_COUNT,
"entity count should match EXPECTED_ENTITY_COUNT derived from StableId ranges"
);
}
#[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 at 5-8
for id in 5..=8 {
assert!(
registry.to_entity(&StableId(id)).is_some(),
"Fog Theater at StableId {}",
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 at 24-28
for id in 24..=28 {
assert!(
registry.to_entity(&StableId(id)).is_some(),
"Gallery at StableId {}",
id
);
}
// Pause Chamber at 29
assert!(
registry.to_entity(&StableId(29)).is_some(),
"Pause Chamber at StableId 29"
);
// Dialogue Room at 30-33
for id in 30..=33 {
assert!(
registry.to_entity(&StableId(id)).is_some(),
"Dialogue Room at StableId {}",
id
);
}
// Crowd Plaza at 34-48
for id in 34..=48 {
assert!(
registry.to_entity(&StableId(id)).is_some(),
"Crowd Plaza at StableId {}",
id
);
}
// Reset plates at 49-55
for id in constants::RESET_PLATE_STABLE_IDS.0..=constants::RESET_PLATE_STABLE_IDS.1 {
assert!(
registry.to_entity(&StableId(id)).is_some(),
"Reset plate at StableId {}",
id
);
}
// Sprint Gauntlet at 56-57
for id in constants::SPRINT_GAUNTLET_STABLE_IDS.0..=constants::SPRINT_GAUNTLET_STABLE_IDS.1
{
assert!(
registry.to_entity(&StableId(id)).is_some(),
"Sprint Gauntlet at StableId {}",
id
);
}
// Eavesdrop Alcove at 58-60
for id in
constants::EAVESDROP_ALCOVE_STABLE_IDS.0..=constants::EAVESDROP_ALCOVE_STABLE_IDS.1
{
assert!(
registry.to_entity(&StableId(id)).is_some(),
"Eavesdrop Alcove at StableId {}",
id
);
}
// Confrontation Stage at 61-62
for id in constants::CONFRONTATION_STAGE_STABLE_IDS.0
..=constants::CONFRONTATION_STAGE_STABLE_IDS.1
{
assert!(
registry.to_entity(&StableId(id)).is_some(),
"Confrontation Stage at StableId {}",
id
);
}
// Sprint 11 reset plates at 63-65
for id in constants::SPRINT11_RESET_PLATE_STABLE_IDS.0
..=constants::SPRINT11_RESET_PLATE_STABLE_IDS.1
{
assert!(
registry.to_entity(&StableId(id)).is_some(),
"Sprint 11 reset plate at StableId {}",
id
);
}
// Sound Lab at 66-68
for id in constants::SOUND_LAB_STABLE_IDS.0..=constants::SOUND_LAB_STABLE_IDS.1 {
assert!(
registry.to_entity(&StableId(id)).is_some(),
"Sound Lab at StableId {}",
id
);
}
// Decay Observatory at 69
for id in constants::DECAY_OBSERVATORY_STABLE_IDS.0
..=constants::DECAY_OBSERVATORY_STABLE_IDS.1
{
assert!(
registry.to_entity(&StableId(id)).is_some(),
"Decay Observatory at StableId {}",
id
);
}
// Shift Change at 70-71
for id in constants::SHIFT_CHANGE_STABLE_IDS.0..=constants::SHIFT_CHANGE_STABLE_IDS.1 {
assert!(
registry.to_entity(&StableId(id)).is_some(),
"Shift Change at StableId {}",
id
);
}
// Sprint 13 reset plates at 72-74
for id in constants::SPRINT13_RESET_PLATE_STABLE_IDS.0
..=constants::SPRINT13_RESET_PLATE_STABLE_IDS.1
{
assert!(
registry.to_entity(&StableId(id)).is_some(),
"Sprint 13 reset plate at StableId {}",
id
);
}
// Zone Gate at 75
for id in constants::ZONE_GATE_STABLE_IDS.0..=constants::ZONE_GATE_STABLE_IDS.1 {
assert!(
registry.to_entity(&StableId(id)).is_some(),
"Zone Gate at StableId {}",
id
);
}
// Sprint 22 reset plate at 76
for id in constants::SPRINT22_RESET_PLATE_STABLE_IDS.0
..=constants::SPRINT22_RESET_PLATE_STABLE_IDS.1
{
assert!(
registry.to_entity(&StableId(id)).is_some(),
"Sprint 22 reset plate at StableId {}",
id
);
}
}
}