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>
955 lines
34 KiB
Rust
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
|
|
);
|
|
}
|
|
}
|
|
}
|