Files
settled-reach/server/src/test_world/mod.rs
T
jpmschweitzerandClaude Opus 4.6 258b266f15 fix(simulation): address PR #39 review — 3 warnings + 6 suggestions
Warnings fixed:
- contraband.rs: scan event now always emits even when NPC already
  knows (was skipped by early `continue`). Contract matches doc.
- test_world/mod.rs: ScanEventBuffer added to player spawn bundle
  so check_contraband_scan doesn't silently no-op in gauntlet mode.
- npc/mod.rs → simulation/mod.rs: moved check_contraband_scan
  registration to SimulationPlugin (operates on player inventory and
  snapshot pipeline, consistent with process_talk_interaction).

Suggestions addressed:
- cross_room_transitions.rs T1: clarified standalone position vs
  constants.rs observer position in comment.
- dialogue.rs: Vec<&str> dedup replaced with BTreeSet<&str> for
  deterministic iteration (project convention).
- contraband.rs: added test for multiple simultaneous ScanAuthority
  NPCs in range (564 tests total).
- dialogue.rs: doc-comment on relationship_to_trust explaining
  KnowledgeConfidence ordering and Suspects default.
- cross_room_transitions.rs T5: noted direct KG API usage vs full
  perception system.
- sprint_gauntlet.rs: documented intentional Contentment { level: 0 }.
- content_scaling.rs: noted GAUNTLET_NPC_COUNT is manually maintained.
- contraband.rs: doc-comment on cross-plugin registration rationale.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:23:02 +01:00

629 lines
22 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):
//! 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
#[cfg(feature = "gauntlet")]
pub mod constants;
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::interaction::{Interactable, NearbyInteractionBuffer};
#[cfg(feature = "gauntlet")]
use crate::simulation::inventory::ItemName;
#[cfg(feature = "gauntlet")]
use crate::simulation::listening::ListeningFocus;
#[cfg(feature = "gauntlet")]
use crate::simulation::contraband::ScanEventBuffer;
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 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
// 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);
// 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(),
))
.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);
// --- 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));
}
// --- 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);
}
}
}
app.insert_resource(snapshots);
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);
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
);
}
}
}