From e66352e0eab6bdfeae1ddc62f05aeacff4b21f77 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 18 Feb 2026 02:25:57 +0100 Subject: [PATCH 1/3] =?UTF-8?q?feat(simulation):=20sprint=209=20gauntlet?= =?UTF-8?q?=20=E2=80=94=20test=20infrastructure=20and=20first=203=20rooms?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/Cargo.lock | 20 + server/Cargo.toml | 3 + server/src/knowledge/registry.rs | 37 + server/src/lib.rs | 1 + server/src/main.rs | 8 +- server/src/simulation/input.rs | 294 + server/src/test_world/constants.rs | 306 + server/src/test_world/mod.rs | 326 + server/src/test_world/reset.rs | 274 + server/src/test_world/rooms/hub.rs | 40 + .../test_world/rooms/inventory_warehouse.rs | 88 + server/src/test_world/rooms/mod.rs | 9 + .../test_world/rooms/occlusion_corridor.rs | 66 + server/src/test_world/rooms/pause_chamber.rs | 51 + server/tests/content_loading.rs | 130 + server/tests/golden/proof_room_tick_10.json | 5507 +++++++++++++++++ server/tests/golden_suite.rs | 399 ++ server/tests/layer3.rs | 132 + tooling/test-client/src/golden.rs | 238 +- 19 files changed, 7906 insertions(+), 23 deletions(-) create mode 100644 server/src/test_world/constants.rs create mode 100644 server/src/test_world/mod.rs create mode 100644 server/src/test_world/reset.rs create mode 100644 server/src/test_world/rooms/hub.rs create mode 100644 server/src/test_world/rooms/inventory_warehouse.rs create mode 100644 server/src/test_world/rooms/mod.rs create mode 100644 server/src/test_world/rooms/occlusion_corridor.rs create mode 100644 server/src/test_world/rooms/pause_chamber.rs create mode 100644 server/tests/golden/proof_room_tick_10.json create mode 100644 server/tests/golden_suite.rs create mode 100644 server/tests/layer3.rs diff --git a/server/Cargo.lock b/server/Cargo.lock index 13d7040f3..88dc1b8fd 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -950,6 +950,19 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "serde_yaml" version = "0.9.34+deprecated" @@ -975,6 +988,7 @@ dependencies = [ "rand_chacha", "rmp-serde", "serde", + "serde_json", "serde_yaml", "thiserror", "tracing", @@ -1373,3 +1387,9 @@ dependencies = [ "quote", "syn", ] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/server/Cargo.toml b/server/Cargo.toml index 57af583e8..3eacdd19d 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -16,3 +16,6 @@ pathfinding = "4.11" thiserror = "2" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +[dev-dependencies] +serde_json = "1" diff --git a/server/src/knowledge/registry.rs b/server/src/knowledge/registry.rs index 32628f693..1c8ade658 100644 --- a/server/src/knowledge/registry.rs +++ b/server/src/knowledge/registry.rs @@ -69,6 +69,19 @@ impl EntityRegistry { } } + /// Advance the next StableId counter to `target`. + /// Used to reserve StableId ranges for entities not yet spawned + /// (e.g., Gauntlet rooms built in later sprints). + /// Panics if `target` is less than the current next_id. + pub fn reserve_up_to(&mut self, target: u64) { + assert!( + target >= self.next_id, + "cannot reserve backwards: next_id={}, target={}", + self.next_id, target + ); + self.next_id = target; + } + /// Number of registered entities. pub fn len(&self) -> usize { self.by_entity.len() @@ -229,6 +242,30 @@ mod tests { ); } + #[test] + fn reserve_up_to_advances_counter() { + let mut world = World::new(); + let mut registry = EntityRegistry::new(0); + + let e1 = world.spawn_empty().id(); + let id1 = registry.register(e1); + assert_eq!(id1, StableId(0)); + + // Reserve through 5 (skip IDs 1-4) + registry.reserve_up_to(5); + + let e2 = world.spawn_empty().id(); + let id2 = registry.register(e2); + assert_eq!(id2, StableId(5), "next ID after reserve should be 5"); + } + + #[test] + #[should_panic(expected = "cannot reserve backwards")] + fn reserve_up_to_panics_on_backwards() { + let mut registry = EntityRegistry::new(10); + registry.reserve_up_to(5); + } + #[test] fn unregister_unknown_entity_is_noop() { // #469: Unregistering an entity that was never registered must not panic. diff --git a/server/src/lib.rs b/server/src/lib.rs index 5e3335ba0..b35dc0dab 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -9,3 +9,4 @@ pub mod npc; pub mod perception; pub mod simulation; pub mod storyteller; +pub mod test_world; diff --git a/server/src/main.rs b/server/src/main.rs index 3433036e1..059fa8e2e 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -114,8 +114,12 @@ fn main() { // Override SimRng with the chosen seed (SimulationPlugin defaults to seed 0) app.insert_resource(settled_reach_server::simulation::rng::SimRng::new(seed)); - // Gauntlet content loader is future scope — proof room for all modes. - setup_proof_room(&mut app); + // Gauntlet test world for --test-mode, proof room for normal mode. + if test_mode { + settled_reach_server::test_world::setup_gauntlet(&mut app); + } else { + setup_proof_room(&mut app); + } tracing::info!( "Simulation initialized (seed={}, test_mode={})", diff --git a/server/src/simulation/input.rs b/server/src/simulation/input.rs index 2e4f6a367..3d8feb6d4 100644 --- a/server/src/simulation/input.rs +++ b/server/src/simulation/input.rs @@ -11,6 +11,7 @@ use crate::simulation::inventory::{ use crate::simulation::movement::{MoveIntent, PlayerCharacter, TilePosition}; use crate::simulation::stance::{PlayerMoveCooldown, Stance}; use crate::simulation::time::{SimulationTime, TickRate}; +use crate::test_world::reset::{RoomResetTrigger, RoomSnapshots}; use bevy_ecs::prelude::*; use std::collections::VecDeque; @@ -91,6 +92,8 @@ pub fn process_player_input( >, inventory_items: Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>, all_positions: Query<&TilePosition>, + reset_triggers: Query<&RoomResetTrigger>, + mut room_snapshots: Option>, ) { let current_tick = time.tick; let paused = time.paused(); @@ -193,6 +196,16 @@ pub fn process_player_input( target_entity_id, ); } + Some("Reset") => { + handle_reset( + &mut commands, + ®istry, + &reset_triggers, + &mut room_snapshots, + target_entity_id, + current_tick, + ); + } _ => { tracing::info!( "Interact: target={:?}, verb={:?} — logged only", @@ -438,6 +451,67 @@ fn handle_place( ); } +/// Handle Reset verb: restore a room's entities to their initial positions. +/// Target entity must have a RoomResetTrigger component. Respects debounce. +fn handle_reset( + commands: &mut Commands, + registry: &EntityRegistry, + reset_triggers: &Query<&RoomResetTrigger>, + room_snapshots: &mut Option>, + target_entity_id: Option, + current_tick: u64, +) { + let Some(target_id) = target_entity_id else { + tracing::warn!("Reset verb without target_entity_id"); + return; + }; + + let Some(snapshots) = room_snapshots.as_mut() else { + tracing::warn!("Reset verb but RoomSnapshots resource not available"); + return; + }; + + let target_stable = StableId(target_id); + let Some(target_entity) = registry.to_entity(&target_stable) else { + tracing::warn!(target_id, "Reset: target entity not in registry"); + return; + }; + + let Ok(trigger) = reset_triggers.get(target_entity) else { + tracing::warn!(target_id, "Reset: target is not a reset trigger"); + return; + }; + + let Some(changes) = snapshots.plan_reset(&trigger.room_name, current_tick) else { + tracing::info!( + room = trigger.room_name.as_str(), + "Reset: debounced or unknown room" + ); + return; + }; + + let mut restored = 0; + for (entity, position, is_floor_item) in changes { + if is_floor_item { + commands + .entity(entity) + .remove::() + .remove::() + .insert(position); + } else { + commands.entity(entity).insert(position); + } + restored += 1; + } + + tracing::info!( + room = trigger.room_name.as_str(), + restored, + current_tick, + "Room reset executed via Reset verb" + ); +} + #[cfg(test)] mod tests { use super::*; @@ -1396,4 +1470,224 @@ mod tests { schedule.add_systems(process_player_input); schedule.run(&mut world); // should not panic } + + // === Room Reset Tests (#490) === + + #[test] + fn reset_verb_restores_floor_item() { + // #490: Take a floor item, then Reset verb restores it to original position. + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(InputQueue::default()); + world.insert_resource(SimulationTime::default()); + world.init_resource::(); + + // Player + let player = world + .spawn((PlayerCharacter, TilePosition::new(5, 5, 0))) + .id(); + let player_sid = world + .resource_mut::() + .register(player); + + // Floor item at (5, 4) + let item = world + .spawn((TilePosition::new(5, 4, 0), ItemName("Keycard".into()))) + .id(); + let item_sid = world + .resource_mut::() + .register(item); + + // Reset plate entity + let plate = world + .spawn(( + crate::simulation::interaction::Interactable, + RoomResetTrigger { + room_name: "test_room".to_string(), + }, + TilePosition::new(5, 3, 0), + )) + .id(); + let plate_sid = world + .resource_mut::() + .register(plate); + + // Record snapshot: item is a floor item at its original position + let mut snapshots = RoomSnapshots::default(); + snapshots.record("test_room", item, TilePosition::new(5, 4, 0), true); + world.insert_resource(snapshots); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_player_input); + + // Step 1: Take the item + world.resource_mut::().push(PlayerInput { + tick: 0, + action: PlayerAction::Interact { + target_entity_id: Some(item_sid.0), + verb: Some("Take".into()), + }, + }); + schedule.run(&mut world); + + assert!( + world.get::(item).is_none(), + "Item should be picked up" + ); + assert_eq!(world.get::(item).unwrap().0, player_sid); + + // Step 2: Reset via verb + world.resource_mut::().push(PlayerInput { + tick: 1, + action: PlayerAction::Interact { + target_entity_id: Some(plate_sid.0), + verb: Some("Reset".into()), + }, + }); + world.resource_mut::().tick = 1; + schedule.run(&mut world); + + // Item should be back on the ground at original position + let pos = world + .get::(item) + .expect("Item should be restored to ground"); + assert_eq!( + *pos, + TilePosition::new(5, 4, 0), + "Item at original position" + ); + assert!( + world.get::(item).is_none(), + "CarriedBy removed after reset" + ); + assert!( + world.get::(item).is_none(), + "InventorySlot removed after reset" + ); + } + + #[test] + fn reset_verb_debounces() { + // #490: Reset debounce prevents rapid-fire resets. + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(InputQueue::default()); + world.insert_resource(SimulationTime::default()); + world.init_resource::(); + + let _player = world + .spawn((PlayerCharacter, TilePosition::new(5, 5, 0))) + .id(); + + // NPC entity + let npc = world.spawn(TilePosition::new(10, 10, 0)).id(); + world + .resource_mut::() + .register(npc); + + // Reset plate + let plate = world + .spawn(( + crate::simulation::interaction::Interactable, + RoomResetTrigger { + room_name: "test_room".to_string(), + }, + TilePosition::new(5, 3, 0), + )) + .id(); + let plate_sid = world + .resource_mut::() + .register(plate); + + let mut snapshots = RoomSnapshots::default(); + snapshots.record("test_room", npc, TilePosition::new(10, 10, 0), false); + world.insert_resource(snapshots); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_player_input); + + // First reset at tick 0 — should succeed + world.resource_mut::().push(PlayerInput { + tick: 0, + action: PlayerAction::Interact { + target_entity_id: Some(plate_sid.0), + verb: Some("Reset".into()), + }, + }); + schedule.run(&mut world); + + // Move NPC to verify debounce blocks second reset + *world.get_mut::(npc).unwrap() = TilePosition::new(20, 20, 0); + + // Second reset at tick 5 — should be debounced (< 10 ticks) + world.resource_mut::().push(PlayerInput { + tick: 5, + action: PlayerAction::Interact { + target_entity_id: Some(plate_sid.0), + verb: Some("Reset".into()), + }, + }); + world.resource_mut::().tick = 5; + schedule.run(&mut world); + + // NPC should still be at moved position (reset was debounced) + assert_eq!( + world.get::(npc).unwrap().x, + 20, + "NPC not reset — debounced" + ); + + // Third reset at tick 10 — should succeed + world.resource_mut::().push(PlayerInput { + tick: 10, + action: PlayerAction::Interact { + target_entity_id: Some(plate_sid.0), + verb: Some("Reset".into()), + }, + }); + world.resource_mut::().tick = 10; + schedule.run(&mut world); + + // NPC should be back at original position + assert_eq!( + world.get::(npc).unwrap().x, + 10, + "NPC reset after debounce elapsed" + ); + } + + #[test] + fn reset_verb_without_snapshots_is_noop() { + // Reset verb when no RoomSnapshots resource exists should not panic. + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(InputQueue::default()); + world.insert_resource(SimulationTime::default()); + world.init_resource::(); + + world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0))); + + let plate = world + .spawn(( + crate::simulation::interaction::Interactable, + RoomResetTrigger { + room_name: "test_room".to_string(), + }, + TilePosition::new(5, 3, 0), + )) + .id(); + let plate_sid = world + .resource_mut::() + .register(plate); + + // No RoomSnapshots resource inserted — should be gracefully handled + world.resource_mut::().push(PlayerInput { + tick: 0, + action: PlayerAction::Interact { + target_entity_id: Some(plate_sid.0), + verb: Some("Reset".into()), + }, + }); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_player_input); + schedule.run(&mut world); // should not panic + } } diff --git a/server/src/test_world/constants.rs b/server/src/test_world/constants.rs new file mode 100644 index 000000000..399d329a0 --- /dev/null +++ b/server/src/test_world/constants.rs @@ -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, +} + +/// 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 + ); + } + } + } +} diff --git a/server/src/test_world/mod.rs b/server/src/test_world/mod.rs new file mode 100644 index 000000000..7b352beec --- /dev/null +++ b/server/src/test_world/mod.rs @@ -0,0 +1,326 @@ +//! 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. +//! +//! 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): +//! Player: 0 +//! Hub signs: 1-4 +//! Fog Theater: 5-8 (reserved, not yet built) +//! Occlusion Corridor: 9-12 +//! Inventory Warehouse: 13-23 +//! Interaction Gallery: 24-28 (reserved, not yet built) +//! Pause Chamber: 29 +//! Dialogue Room: 30-33 (reserved, not yet built) +//! Crowd Plaza: 34-48 (reserved, not yet built) +//! Reset plates: 49-51 (Occlusion, Inventory, Pause) + +pub mod constants; +pub mod reset; +pub mod rooms; + +use bevy_app::prelude::*; + +use crate::knowledge::registry::{EntityRegistry, StableEntityId}; +use crate::knowledge::KnowledgeGraph; +use crate::knowledge::types::StableId; +use crate::perception::cognitive_delay::CognitiveDelay; +use crate::perception::vision_cone::Facing; +use crate::simulation::interaction::{Interactable, NearbyInteractionBuffer}; +use crate::simulation::inventory::ItemName; +use crate::simulation::listening::ListeningFocus; +use crate::simulation::monologue::{MonologueBuffer, MonologueState, SprintAnomalyQueue}; +use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap}; +use crate::simulation::stance::{MovementProfile, PlayerMoveCooldown}; + +use reset::{RoomResetTrigger, RoomSnapshots}; + +/// Map bounds for the full Gauntlet world. +pub const MAP_WIDTH: i32 = 117; +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. +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, 74, 48, 42, 22); // Occlusion Corridor + carve_room_interior(&mut walkability, 2, 40, 30, 28); // Inventory Warehouse + carve_room_interior(&mut walkability, 42, 78, 16, 16); // Pause Chamber + + // Carve corridors between hub and rooms + 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 + + // 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(), + 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) — reserved, not yet built --- + registry.reserve_up_to(9); + + // --- 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) — reserved, not yet built --- + registry.reserve_up_to(29); + + // --- Pause Chamber (StableId 29) --- + rooms::pause_chamber::spawn_entities(app, &mut registry); + + // --- Dialogue Room (StableId 30-33) — reserved, not yet built --- + // --- Crowd Plaza (StableId 34-48) — reserved, not yet built --- + registry.reserve_up_to(49); + + // --- Reset plates (StableId 49-51) --- + // 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.unwrap()), + ("inventory_warehouse", constants::INVENTORY_WAREHOUSE.reset_plate.unwrap()), + ("pause_chamber", constants::PAUSE_CHAMBER.reset_plate.unwrap()), + ]; + 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)); + } + + // --- Populate RoomSnapshots for reset mechanism (#490) --- + let mut snapshots = RoomSnapshots::default(); + + // 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::(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::(entity) { + let is_floor_item = app.world().get::(entity).is_some(); + snapshots.record("inventory_warehouse", entity, *pos, is_floor_item); + } + } + } + + // Pause Chamber entity (StableId 29): NPC only + if let Some(entity) = registry.to_entity(&StableId(29)) { + if let Some(pos) = app.world().get::(entity) { + snapshots.record("pause_chamber", entity, *pos, false); + } + } + + app.insert_resource(snapshots); + app.insert_resource(registry); +} + +/// 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); + } + } +} + +/// 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(test)] +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::(); + // Player (0) + Hub signs (1-4) + Occlusion (9-12) + Inventory (13-23) + Pause (29) + // + Reset plates (49-51) + // = 1 + 4 + 4 + 11 + 1 + 3 = 24 entities + assert_eq!(registry.len(), 24); + } + + #[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::(); + // 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::(); + // 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::(); + // 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::(); + + // 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 5-8 reserved (no entities) + for id in 5..=8 { + assert!(registry.to_entity(&StableId(id)).is_none(), "Fog Theater {} reserved", 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 24-28 reserved + for id in 24..=28 { + assert!(registry.to_entity(&StableId(id)).is_none(), "Gallery {} reserved", id); + } + + // Pause Chamber at 29 + assert!(registry.to_entity(&StableId(29)).is_some(), "Pause Chamber at StableId 29"); + } +} diff --git a/server/src/test_world/reset.rs b/server/src/test_world/reset.rs new file mode 100644 index 000000000..45d444007 --- /dev/null +++ b/server/src/test_world/reset.rs @@ -0,0 +1,274 @@ +//! Room reset trigger mechanism (#490). +//! +//! Restores a room's entities to their initial positions when the player +//! interacts with a reset plate. Used during Gauntlet QA sessions to +//! re-test a room without restarting the server. +//! +//! Components: +//! - `RoomResetTrigger` — marks an entity as a reset plate for a room +//! - `RoomMember` — tags entities with their source room for filtering +//! +//! Resource: +//! - `RoomSnapshots` — stores tick-0 entity positions per room +//! +//! Spec (workshop-outcomes.md Section 8): +//! - Trigger: Interact with reset plate entity (verb "Reset") +//! - Resets: entity positions, carried items from room returned to floor +//! - Does NOT reset: other rooms, player position, session timer, SimRng +//! - Debounce: 10-tick cooldown per room + +use bevy_ecs::prelude::*; +use std::collections::BTreeMap; + +use crate::simulation::inventory::{CarriedBy, InventorySlot}; +use crate::simulation::movement::TilePosition; + +/// Debounce cooldown in ticks between resets of the same room. +pub const RESET_DEBOUNCE_TICKS: u64 = 10; + +/// Marks an entity as a room reset trigger (reset plate). +/// The player interacts with this entity using the "Reset" verb +/// to restore the room to its initial state. +#[derive(Component, Debug, Clone)] +pub struct RoomResetTrigger { + pub room_name: String, +} + +/// Tags an entity as belonging to a specific room. +/// Used during reset to identify which entities to restore. +#[derive(Component, Debug, Clone)] +pub struct RoomMember { + pub room_name: String, +} + +/// Snapshot of a single entity's initial position. +#[derive(Debug, Clone)] +struct EntitySnapshot { + entity: Entity, + position: TilePosition, + /// Whether this entity was a floor item (had TilePosition + ItemName at spawn). + is_floor_item: bool, +} + +/// Per-room initial state snapshot. +#[derive(Debug, Clone, Default)] +struct RoomSnapshotData { + entities: Vec, +} + +/// Resource holding initial entity state per room and debounce tracking. +#[derive(Resource, Debug, Default)] +pub struct RoomSnapshots { + snapshots: BTreeMap, + last_reset_tick: BTreeMap, +} + +impl RoomSnapshots { + /// Record the initial position of an entity in a room. + pub fn record(&mut self, room_name: &str, entity: Entity, position: TilePosition, is_floor_item: bool) { + self.snapshots + .entry(room_name.to_string()) + .or_default() + .entities + .push(EntitySnapshot { + entity, + position, + is_floor_item, + }); + } + + /// Check if a reset is allowed (debounce check). + pub fn can_reset(&self, room_name: &str, current_tick: u64) -> bool { + match self.last_reset_tick.get(room_name) { + Some(&last) => current_tick >= last + RESET_DEBOUNCE_TICKS, + None => true, + } + } + + /// Plan a room reset for use with Commands (system-friendly). + /// Returns the list of (entity, position, is_floor_item) changes to apply, + /// or None if debounced or unknown room. Updates debounce tracking. + pub fn plan_reset( + &mut self, + room_name: &str, + current_tick: u64, + ) -> Option> { + if !self.can_reset(room_name, current_tick) { + return None; + } + + let snapshot = self.snapshots.get(room_name)?; + let changes: Vec<_> = snapshot + .entities + .iter() + .map(|s| (s.entity, s.position, s.is_floor_item)) + .collect(); + + self.last_reset_tick + .insert(room_name.to_string(), current_tick); + + Some(changes) + } + + /// Execute a room reset. Restores entity positions and returns + /// floor items to their original locations. + /// + /// Returns the number of entities restored, or None if the room + /// has no snapshot or debounce hasn't elapsed. + pub fn execute_reset( + &mut self, + room_name: &str, + current_tick: u64, + world: &mut World, + ) -> Option { + if !self.can_reset(room_name, current_tick) { + tracing::info!( + room_name, + "Room reset debounced (last reset tick: {:?})", + self.last_reset_tick.get(room_name) + ); + return None; + } + + let snapshot = self.snapshots.get(room_name)?; + let mut restored = 0; + + for snap in &snapshot.entities { + // Check if entity still exists + if world.get_entity(snap.entity).is_err() { + continue; + } + + if snap.is_floor_item { + // Floor item: remove CarriedBy/InventorySlot if carried, + // restore TilePosition to original location + let mut entity_mut = world.entity_mut(snap.entity); + entity_mut.remove::(); + entity_mut.remove::(); + entity_mut.insert(snap.position); + } else { + // NPC or other entity: just restore position + world.entity_mut(snap.entity).insert(snap.position); + } + restored += 1; + } + + self.last_reset_tick + .insert(room_name.to_string(), current_tick); + + tracing::info!( + room_name, + restored, + current_tick, + "Room reset executed" + ); + + Some(restored) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn record_and_reset_restores_position() { + let mut world = World::new(); + let entity = world.spawn(TilePosition::new(10, 20, 0)).id(); + let mut snapshots = RoomSnapshots::default(); + + // Record initial position + snapshots.record("test_room", entity, TilePosition::new(10, 20, 0), false); + + // Move entity + *world.get_mut::(entity).unwrap() = TilePosition::new(50, 50, 0); + assert_eq!(world.get::(entity).unwrap().x, 50); + + // Reset + let restored = snapshots.execute_reset("test_room", 0, &mut world); + assert_eq!(restored, Some(1)); + assert_eq!(world.get::(entity).unwrap().x, 10); + assert_eq!(world.get::(entity).unwrap().y, 20); + } + + #[test] + fn reset_restores_floor_item() { + let mut world = World::new(); + world.init_resource::(); + + // Floor item starts on ground + let item = world + .spawn(TilePosition::new(5, 5, 0)) + .id(); + + let mut snapshots = RoomSnapshots::default(); + snapshots.record("warehouse", item, TilePosition::new(5, 5, 0), true); + + // Simulate Take: remove TilePosition, add CarriedBy + InventorySlot + world.entity_mut(item).remove::(); + world + .entity_mut(item) + .insert((CarriedBy(crate::knowledge::types::StableId(0)), InventorySlot(0))); + + assert!(world.get::(item).is_none()); + assert!(world.get::(item).is_some()); + + // Reset + let restored = snapshots.execute_reset("warehouse", 0, &mut world); + assert_eq!(restored, Some(1)); + + // Item should be back on the ground + assert_eq!( + world.get::(item).unwrap(), + &TilePosition::new(5, 5, 0) + ); + assert!(world.get::(item).is_none()); + assert!(world.get::(item).is_none()); + } + + #[test] + fn debounce_prevents_rapid_reset() { + let mut world = World::new(); + let entity = world.spawn(TilePosition::new(10, 20, 0)).id(); + let mut snapshots = RoomSnapshots::default(); + snapshots.record("test_room", entity, TilePosition::new(10, 20, 0), false); + + // First reset at tick 0 + let result = snapshots.execute_reset("test_room", 0, &mut world); + assert_eq!(result, Some(1)); + + // Second reset at tick 5 — should be debounced + let result = snapshots.execute_reset("test_room", 5, &mut world); + assert_eq!(result, None); + + // Third reset at tick 10 — should succeed + let result = snapshots.execute_reset("test_room", 10, &mut world); + assert_eq!(result, Some(1)); + } + + #[test] + fn unknown_room_returns_none() { + let mut world = World::new(); + let mut snapshots = RoomSnapshots::default(); + let result = snapshots.execute_reset("nonexistent", 0, &mut world); + assert_eq!(result, None); + } + + #[test] + fn can_reset_fresh_room() { + let snapshots = RoomSnapshots::default(); + assert!(snapshots.can_reset("any_room", 0)); + } + + #[test] + fn can_reset_after_debounce() { + let mut snapshots = RoomSnapshots::default(); + snapshots + .last_reset_tick + .insert("room".to_string(), 100); + + assert!(!snapshots.can_reset("room", 105)); + assert!(snapshots.can_reset("room", 110)); + assert!(snapshots.can_reset("room", 200)); + } +} diff --git a/server/src/test_world/rooms/hub.rs b/server/src/test_world/rooms/hub.rs new file mode 100644 index 000000000..f7bff55b3 --- /dev/null +++ b/server/src/test_world/rooms/hub.rs @@ -0,0 +1,40 @@ +//! Central Hub — Room 0 (24x24) +//! +//! Connector room with 4 directional sign markers. No test entities. +//! Hub spawn point: (12, 12) relative = (50, 58) absolute. +//! +//! Entities (StableId 1-4): +//! sign_north (50, 48) — "Fog Theater" +//! sign_east (60, 58) — "Occlusion Corridor" +//! sign_south (50, 68) — "Pause Chamber" +//! sign_west (40, 58) — "Inventory Warehouse" + +use bevy_app::prelude::*; + +use crate::knowledge::registry::{EntityRegistry, StableEntityId}; +use crate::simulation::interaction::Interactable; +use crate::simulation::movement::TilePosition; + +/// Room origin (top-left corner including walls). +const ORIGIN_X: i32 = 38; +const ORIGIN_Y: i32 = 46; + +/// Sign positions as relative offsets from room origin. +const SIGNS: &[(&str, i32, i32)] = &[ + ("sign_north", 12, 2), // North marker + ("sign_east", 22, 12), // East marker + ("sign_south", 12, 22), // South marker + ("sign_west", 2, 12), // West marker +]; + +/// Spawn hub sign entities in canonical order (StableId 1-4). +pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) { + for &(_name, rx, ry) in SIGNS { + let pos = TilePosition::new(ORIGIN_X + rx, ORIGIN_Y + ry, 0); + let entity = app.world_mut().spawn((Interactable, pos)).id(); + let sid = registry.register(entity); + app.world_mut() + .entity_mut(entity) + .insert(StableEntityId(sid)); + } +} diff --git a/server/src/test_world/rooms/inventory_warehouse.rs b/server/src/test_world/rooms/inventory_warehouse.rs new file mode 100644 index 000000000..efd2dbbd5 --- /dev/null +++ b/server/src/test_world/rooms/inventory_warehouse.rs @@ -0,0 +1,88 @@ +//! Inventory Warehouse — Room 3 (30x28) +//! +//! Tests D-065 (9-slot inventory), pickup/drop verbs, CarriedBy component. +//! +//! Layout: Open warehouse floor with 10 item crates in a grid pattern +//! and 1 NPC near the back wall for interaction testing while carrying items. +//! +//! Observer position: (15, 14) relative = (17, 54) absolute, facing East. +//! +//! Entities (StableId 13-23): +//! crate_01..crate_10 — Floor items (Container ObjectType) +//! npc_warehouse — Interaction target + +use bevy_app::prelude::*; + +use crate::bridge::types::ObjectType; +use crate::knowledge::registry::{EntityRegistry, StableEntityId}; +use crate::npc::{Contentment, Npc, ToleranceThreshold, Want, WantKind}; +use crate::simulation::interaction::Interactable; +use crate::simulation::inventory::ItemName; +use crate::simulation::movement::TilePosition; +use crate::simulation::path_follow::MovementSpeed; + +/// Room origin (top-left corner including walls). +const ORIGIN_X: i32 = 2; +const ORIGIN_Y: i32 = 40; + +/// Item crate definitions: (name, relative_x, relative_y, item_name). +/// 10 crates for testing the 9-slot inventory limit. +const CRATES: &[(&str, i32, i32, &str)] = &[ + ("crate_01", 4, 4, "Keycard"), + ("crate_02", 8, 4, "Manifest"), + ("crate_03", 12, 4, "Datapad"), + ("crate_04", 16, 4, "Toolkit"), + ("crate_05", 4, 10, "Badge"), + ("crate_06", 8, 10, "Medkit"), + ("crate_07", 12, 10, "Ration"), + ("crate_08", 16, 10, "Cable"), + ("crate_09", 4, 16, "Seal"), + ("crate_10", 8, 16, "Chip"), +]; + +/// Spawn Inventory Warehouse entities in canonical order (StableId 13-23). +pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) { + // Crates (StableId 13-22) + for &(_name, rx, ry, item_name) in CRATES { + let pos = TilePosition::new(ORIGIN_X + rx, ORIGIN_Y + ry, 0); + let entity = app + .world_mut() + .spawn(( + Interactable, + ObjectType::Container, + ItemName(item_name.to_string()), + pos, + )) + .id(); + let sid = registry.register(entity); + app.world_mut() + .entity_mut(entity) + .insert(StableEntityId(sid)); + } + + // Warehouse NPC (StableId 23) + let npc_pos = TilePosition::new(ORIGIN_X + 22, ORIGIN_Y + 14, 0); + let entity = app + .world_mut() + .spawn(( + Npc, + Interactable, + npc_pos, + Want { + primary: WantKind::Wealth, + intensity: 5, + description: "Warehouse supervisor wants efficiency".to_string(), + }, + Contentment { level: 10 }, + ToleranceThreshold { + current_stress: 15, + threshold: 65, + }, + MovementSpeed::default(), + )) + .id(); + let sid = registry.register(entity); + app.world_mut() + .entity_mut(entity) + .insert(StableEntityId(sid)); +} diff --git a/server/src/test_world/rooms/mod.rs b/server/src/test_world/rooms/mod.rs new file mode 100644 index 000000000..0d464b2d9 --- /dev/null +++ b/server/src/test_world/rooms/mod.rs @@ -0,0 +1,9 @@ +//! Gauntlet room builders. +//! +//! Each room module exports a `spawn_entities()` function that creates +//! entities in canonical order for deterministic StableId assignment. + +pub mod hub; +pub mod inventory_warehouse; +pub mod occlusion_corridor; +pub mod pause_chamber; diff --git a/server/src/test_world/rooms/occlusion_corridor.rs b/server/src/test_world/rooms/occlusion_corridor.rs new file mode 100644 index 000000000..49638465f --- /dev/null +++ b/server/src/test_world/rooms/occlusion_corridor.rs @@ -0,0 +1,66 @@ +//! Occlusion Corridor — Room 2 (42x22) +//! +//! Tests D-035 (symmetric shadowcasting), D-017 (perception modes), +//! D-015 (vision cone sectors). +//! +//! Layout: Long east-west corridor with perpendicular wall segments +//! creating visibility pockets. North alcove with hidden NPC, +//! south alcove with partially visible NPC. +//! +//! Observer position: (10, 10) relative = (84, 58) absolute, facing East. +//! +//! Entities (StableId 9-12): +//! npc_guard_visible (92, 58) — Clear LOS baseline +//! npc_hidden_wall (92, 52) — Behind north wall segment +//! npc_peripheral (80, 64) — South alcove, peripheral sector +//! npc_far_end (110, 58) — Far end of corridor, tests range + +use bevy_app::prelude::*; + +use crate::knowledge::registry::{EntityRegistry, StableEntityId}; +use crate::npc::{Contentment, Npc, ToleranceThreshold, Want, WantKind}; +use crate::simulation::interaction::Interactable; +use crate::simulation::movement::TilePosition; +use crate::simulation::path_follow::MovementSpeed; + +/// Room origin (top-left corner including walls). +const ORIGIN_X: i32 = 74; +const ORIGIN_Y: i32 = 48; + +/// NPC definitions: (name, relative_x, relative_y, want_kind, want_intensity). +const NPCS: &[(&str, i32, i32, WantKind, u8)] = &[ + ("npc_guard_visible", 18, 10, WantKind::Safety, 5), + ("npc_hidden_wall", 18, 4, WantKind::Freedom, 3), + ("npc_peripheral", 6, 16, WantKind::Knowledge, 6), + ("npc_far_end", 36, 10, WantKind::Wealth, 4), +]; + +/// Spawn Occlusion Corridor entities in canonical order (StableId 9-12). +pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) { + for &(name, rx, ry, want_kind, intensity) in NPCS { + let pos = TilePosition::new(ORIGIN_X + rx, ORIGIN_Y + ry, 0); + let entity = app + .world_mut() + .spawn(( + Npc, + Interactable, + pos, + Want { + primary: want_kind, + intensity, + description: format!("Occlusion Corridor test NPC: {}", name), + }, + Contentment { level: 0 }, + ToleranceThreshold { + current_stress: 0, + threshold: 50, + }, + MovementSpeed::default(), + )) + .id(); + let sid = registry.register(entity); + app.world_mut() + .entity_mut(entity) + .insert(StableEntityId(sid)); + } +} diff --git a/server/src/test_world/rooms/pause_chamber.rs b/server/src/test_world/rooms/pause_chamber.rs new file mode 100644 index 000000000..fb8aefe4d --- /dev/null +++ b/server/src/test_world/rooms/pause_chamber.rs @@ -0,0 +1,51 @@ +//! Pause Chamber — Room 5 (16x16) +//! +//! Tests D-031 (pause/unpause), Bug #3 regression (movement while paused). +//! +//! Layout: Minimal open room with a single NPC. No visual complexity — +//! this room is pure state machine testing. +//! +//! Observer position: (8, 8) relative = (50, 86) absolute, facing North. +//! +//! Entities (StableId 29): +//! npc_pause_target — Interaction target during pause (Talk should work) + +use bevy_app::prelude::*; + +use crate::knowledge::registry::{EntityRegistry, StableEntityId}; +use crate::npc::{Contentment, Npc, ToleranceThreshold, Want, WantKind}; +use crate::simulation::interaction::Interactable; +use crate::simulation::movement::TilePosition; +use crate::simulation::path_follow::MovementSpeed; + +/// Room origin (top-left corner including walls). +const ORIGIN_X: i32 = 42; +const ORIGIN_Y: i32 = 78; + +/// Spawn Pause Chamber entities in canonical order (StableId 29). +pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) { + let pos = TilePosition::new(ORIGIN_X + 6, ORIGIN_Y + 6, 0); + let entity = app + .world_mut() + .spawn(( + Npc, + Interactable, + pos, + Want { + primary: WantKind::Safety, + intensity: 3, + description: "Wants a quiet posting".to_string(), + }, + Contentment { level: 5 }, + ToleranceThreshold { + current_stress: 10, + threshold: 40, + }, + MovementSpeed::default(), + )) + .id(); + let sid = registry.register(entity); + app.world_mut() + .entity_mut(entity) + .insert(StableEntityId(sid)); +} diff --git a/server/tests/content_loading.rs b/server/tests/content_loading.rs index dfc2dcbf1..afeb53ea9 100644 --- a/server/tests/content_loading.rs +++ b/server/tests/content_loading.rs @@ -3,6 +3,9 @@ //! Tests the full pipeline: discover content → deserialize YAML → spawn ECS entities. //! Uses real content files from content/ directory for structural content, //! and a test fixture for isolated NPC profile spawning. +//! +//! Also includes runtime validation (#489): boot the full plugin stack with real +//! content, tick 10 times over TCP, and assert a valid ObserverSnapshot. use bevy_app::prelude::*; use bevy_ecs::prelude::*; @@ -416,3 +419,130 @@ fn spawn_real_content_with_relationships_and_secrets() { .expect("Nils should have Want"); assert_eq!(nils_want.primary, npc::WantKind::Power); } + +// ----------------------------------------------------------------------- +// Test: Runtime validation — boot + tick 10 + snapshot (#489) +// +// Smoke test for production content. Boots the full plugin stack with real +// content over TCP, ticks 10 times, and asserts a valid ObserverSnapshot. +// Catches runtime panics from broken entity references, missing components, +// or content schema issues that pass YAML validation but fail at tick time. +// ----------------------------------------------------------------------- + +#[test] +fn content_runtime_boot_tick_10_snapshot() { + use settled_reach_server::bridge::framing::{read_framed, write_framed}; + use settled_reach_server::bridge::tcp::TcpBridge; + use settled_reach_server::bridge::types::*; + use settled_reach_server::bridge::{BridgePlugin, BridgeResource}; + use settled_reach_server::knowledge::KnowledgeGraph; + use settled_reach_server::perception::cognitive_delay::CognitiveDelay; + use settled_reach_server::perception::vision_cone::Facing; + use settled_reach_server::simulation::interaction::NearbyInteractionBuffer; + use settled_reach_server::simulation::listening::ListeningFocus; + use settled_reach_server::simulation::monologue::{ + MonologueBuffer, MonologueState, SprintAnomalyQueue, + }; + use settled_reach_server::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap}; + use settled_reach_server::simulation::stance::{MovementProfile, PlayerMoveCooldown}; + use std::io::{BufReader, BufWriter}; + use std::net::{TcpListener, TcpStream}; + use std::thread; + + let root = content_root(); + if !root.join("content.yaml").exists() { + eprintln!("Skipping: content directory not found at {:?}", root); + return; + } + + let listener = TcpListener::bind("127.0.0.1:0").expect("bind listener"); + let server_addr = listener.local_addr().expect("get local addr"); + + // Server thread: full plugin stack with real content + let server_handle = thread::spawn(move || { + let bridge = TcpBridge::accept_on(listener).expect("accept connection"); + + let mut app = App::new(); + app.add_plugins(SimulationPlugin); + app.add_plugins(BridgePlugin); + app.add_plugins(settled_reach_server::knowledge::KnowledgePlugin); + app.add_plugins(settled_reach_server::npc::NpcPlugin); + app.insert_resource(ContentConfig { + content_root: root, + ..Default::default() + }); + app.add_plugins(ContentPlugin); + app.insert_resource(BridgeResource::new(bridge)); + app.insert_resource(WalkabilityMap::new(32, 32, 1)); + + // Spawn player with all required observer pipeline components + let profile = MovementProfile::smuggler(); + app.world_mut().spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing::default(), + KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), + MonologueState::default(), + MonologueBuffer::default(), + SprintAnomalyQueue::default(), + CognitiveDelay::default(), + ListeningFocus::new(TilePosition::new(16, 16, 0)), + profile, + profile.initial_stance(), + PlayerMoveCooldown::default(), + )); + + // Tick 10 times — any panic here means content has a runtime bug + for _ in 0..10 { + app.update(); + } + }); + + // Client: connect and receive 10 snapshots + let stream = TcpStream::connect(server_addr).expect("client connect"); + let mut reader = BufReader::new(stream.try_clone().expect("clone for reader")); + let mut writer = BufWriter::new(stream); + + let mut last_snapshot = None; + for tick in 0..10 { + let payload = read_framed(&mut reader) + .unwrap_or_else(|e| panic!("read error at tick {}: {}", tick, e)) + .unwrap_or_else(|| panic!("unexpected EOF at tick {}", tick)); + + let snapshot: ObserverSnapshot = rmp_serde::from_slice(&payload) + .unwrap_or_else(|e| panic!("deserialization error at tick {}: {}", tick, e)); + + last_snapshot = Some(snapshot); + + // Send empty input for next tick + let empty: Vec = vec![]; + let input_payload = rmp_serde::to_vec(&empty).expect("serialize empty input"); + if let Err(_) = write_framed(&mut writer, &input_payload) { + // Server may have shut down after tick 10 — that's fine + break; + } + } + + drop(reader); + drop(writer); + + // Server thread must not have panicked + server_handle + .join() + .expect("server thread panicked — content triggered a runtime error during tick processing"); + + // Validate final snapshot + let snapshot = last_snapshot.expect("should have received at least one snapshot"); + assert_eq!( + snapshot.version, PROTOCOL_VERSION, + "snapshot protocol version mismatch" + ); + // Content-spawned NPCs should be visible (they all spawn at 0,0,0 by default) + // The player is at 16,16 — content NPCs are far away but the player entity itself + // should always be in the snapshot + assert!( + !snapshot.entities.is_empty(), + "snapshot should contain at least the player entity" + ); +} diff --git a/server/tests/golden/proof_room_tick_10.json b/server/tests/golden/proof_room_tick_10.json new file mode 100644 index 000000000..d6db3f02b --- /dev/null +++ b/server/tests/golden/proof_room_tick_10.json @@ -0,0 +1,5507 @@ +{ + "current_monologue": null, + "dialogue_response": null, + "entities": [ + { + "entity_id": 0, + "kind": "Player", + "observation": "Visible", + "relationship": "Known", + "visibility": "Forward", + "x": 17.5, + "y": 13.5, + "z": 0 + }, + { + "entity_id": 1, + "kind": "Npc", + "observation": "Visible", + "relationship": "Unknown", + "visibility": "Peripheral", + "x": 16.5, + "y": 13.5, + "z": 0 + }, + { + "entity_id": 3, + "kind": "Npc", + "observation": "Visible", + "relationship": "Unknown", + "visibility": "Peripheral", + "x": 18.5, + "y": 14.5, + "z": 0 + } + ], + "game_time": { + "day": 0, + "day_phase": "Morning", + "tick_rate": "Paused", + "time_of_day": 0 + }, + "nearby_interactions": [], + "pending_recognitions": [ + { + "entity_id": 1, + "remaining_ticks": 1, + "total_delay_ticks": 6, + "x": 16.5, + "y": 13.5, + "z": 0 + }, + { + "entity_id": 3, + "remaining_ticks": 0, + "total_delay_ticks": 6, + "x": 18.5, + "y": 14.5, + "z": 0 + } + ], + "player_facing": "North", + "player_inventory": [], + "player_stance": "Sprint", + "tick": 8, + "version": 8, + "visible_tiles": [ + { + "tile_kind": "Wall", + "visibility": "Forward", + "x": -1, + "y": -1, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Forward", + "x": -1, + "y": 0, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Forward", + "x": -1, + "y": 1, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Forward", + "x": -1, + "y": 2, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Peripheral", + "x": -1, + "y": 3, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Peripheral", + "x": -1, + "y": 4, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Peripheral", + "x": -1, + "y": 5, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Peripheral", + "x": -1, + "y": 6, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Peripheral", + "x": -1, + "y": 7, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Peripheral", + "x": -1, + "y": 8, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Peripheral", + "x": -1, + "y": 9, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Peripheral", + "x": -1, + "y": 10, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Peripheral", + "x": -1, + "y": 11, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Peripheral", + "x": -1, + "y": 12, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Peripheral", + "x": -1, + "y": 13, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Peripheral", + "x": -1, + "y": 14, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Peripheral", + "x": -1, + "y": 15, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Peripheral", + "x": -1, + "y": 16, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Peripheral", + "x": -1, + "y": 17, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Peripheral", + "x": -1, + "y": 18, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Peripheral", + "x": -1, + "y": 19, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Peripheral", + "x": -1, + "y": 20, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Peripheral", + "x": -1, + "y": 21, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Peripheral", + "x": -1, + "y": 22, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Forward", + "x": 0, + "y": -1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 0, + "y": 0, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 0, + "y": 1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 0, + "y": 2, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 0, + "y": 3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 0, + "y": 4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 0, + "y": 5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 0, + "y": 6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 0, + "y": 7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 0, + "y": 8, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 0, + "y": 9, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 0, + "y": 10, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 0, + "y": 11, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 0, + "y": 12, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 0, + "y": 13, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 0, + "y": 14, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 0, + "y": 15, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 0, + "y": 16, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 0, + "y": 17, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 0, + "y": 18, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 0, + "y": 19, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 0, + "y": 20, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 0, + "y": 21, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Forward", + "x": 1, + "y": -1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 1, + "y": 0, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 1, + "y": 1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 1, + "y": 2, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 1, + "y": 3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 1, + "y": 4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 1, + "y": 5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 1, + "y": 6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 1, + "y": 7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 1, + "y": 8, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 1, + "y": 9, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 1, + "y": 10, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 1, + "y": 11, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 1, + "y": 12, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 1, + "y": 13, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 1, + "y": 14, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 1, + "y": 15, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 1, + "y": 16, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 1, + "y": 17, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 1, + "y": 18, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 1, + "y": 19, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 1, + "y": 20, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 1, + "y": 21, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Forward", + "x": 2, + "y": -1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 2, + "y": 0, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 2, + "y": 1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 2, + "y": 2, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 2, + "y": 3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 2, + "y": 4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 2, + "y": 5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 2, + "y": 6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 2, + "y": 7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 2, + "y": 8, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 2, + "y": 9, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 2, + "y": 10, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 2, + "y": 11, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 2, + "y": 12, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 2, + "y": 13, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 2, + "y": 14, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 2, + "y": 15, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 2, + "y": 16, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 2, + "y": 17, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 2, + "y": 18, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 2, + "y": 19, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 2, + "y": 20, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Forward", + "x": 3, + "y": -1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 3, + "y": 0, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 3, + "y": 1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 3, + "y": 2, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 3, + "y": 3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 3, + "y": 4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 3, + "y": 5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 3, + "y": 6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 3, + "y": 7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 3, + "y": 8, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 3, + "y": 9, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 3, + "y": 10, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 3, + "y": 11, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 3, + "y": 12, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 3, + "y": 13, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 3, + "y": 14, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 3, + "y": 15, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 3, + "y": 16, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 3, + "y": 17, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 3, + "y": 18, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 3, + "y": 19, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 3, + "y": 20, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Forward", + "x": 4, + "y": -1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 4, + "y": 0, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 4, + "y": 1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 4, + "y": 2, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 4, + "y": 3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 4, + "y": 4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 4, + "y": 5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 4, + "y": 6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 4, + "y": 7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 4, + "y": 8, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 4, + "y": 9, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 4, + "y": 10, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 4, + "y": 11, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 4, + "y": 12, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 4, + "y": 13, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 4, + "y": 14, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 4, + "y": 15, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 4, + "y": 16, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 4, + "y": 17, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 4, + "y": 18, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 4, + "y": 19, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Forward", + "x": 5, + "y": -1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 5, + "y": 0, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 5, + "y": 1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 5, + "y": 2, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 5, + "y": 3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 5, + "y": 4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 5, + "y": 5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 5, + "y": 6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 5, + "y": 7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 5, + "y": 8, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 5, + "y": 9, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 5, + "y": 10, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 5, + "y": 11, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 5, + "y": 12, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 5, + "y": 13, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 5, + "y": 14, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 5, + "y": 15, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 5, + "y": 16, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 5, + "y": 17, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 5, + "y": 18, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 5, + "y": 19, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Forward", + "x": 6, + "y": -1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 6, + "y": 0, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 6, + "y": 1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 6, + "y": 2, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 6, + "y": 3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 6, + "y": 4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 6, + "y": 5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 6, + "y": 6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 6, + "y": 7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 6, + "y": 8, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 6, + "y": 9, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 6, + "y": 10, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 6, + "y": 11, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 6, + "y": 12, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 6, + "y": 13, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 6, + "y": 14, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 6, + "y": 15, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 6, + "y": 16, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 6, + "y": 17, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 6, + "y": 18, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Forward", + "x": 7, + "y": -1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 7, + "y": 0, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 7, + "y": 1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 7, + "y": 2, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 7, + "y": 3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 7, + "y": 4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 7, + "y": 5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 7, + "y": 6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 7, + "y": 7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 7, + "y": 8, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 7, + "y": 9, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 7, + "y": 10, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 7, + "y": 11, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 7, + "y": 12, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 7, + "y": 13, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 7, + "y": 14, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 7, + "y": 15, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 7, + "y": 16, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 7, + "y": 17, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 7, + "y": 18, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Forward", + "x": 8, + "y": -1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 8, + "y": 0, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 8, + "y": 1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 8, + "y": 2, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 8, + "y": 3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 8, + "y": 4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 8, + "y": 5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 8, + "y": 6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 8, + "y": 7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 8, + "y": 8, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 8, + "y": 9, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 8, + "y": 10, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 8, + "y": 11, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 8, + "y": 12, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 8, + "y": 13, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 8, + "y": 14, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 8, + "y": 15, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 8, + "y": 16, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 8, + "y": 17, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Forward", + "x": 9, + "y": -1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 9, + "y": 0, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 9, + "y": 1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 9, + "y": 2, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 9, + "y": 3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 9, + "y": 4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 9, + "y": 5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 9, + "y": 6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 9, + "y": 7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 9, + "y": 8, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 9, + "y": 9, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 9, + "y": 10, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 9, + "y": 11, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 9, + "y": 12, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 9, + "y": 13, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 9, + "y": 14, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 9, + "y": 15, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 9, + "y": 16, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 9, + "y": 17, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Forward", + "x": 10, + "y": -1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 10, + "y": 0, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 10, + "y": 1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 10, + "y": 2, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 10, + "y": 3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 10, + "y": 4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 10, + "y": 5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 10, + "y": 6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 10, + "y": 7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 10, + "y": 8, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 10, + "y": 9, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 10, + "y": 10, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 10, + "y": 11, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 10, + "y": 12, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 10, + "y": 13, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 10, + "y": 14, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 10, + "y": 15, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 10, + "y": 16, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Forward", + "x": 11, + "y": -1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 11, + "y": 0, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 11, + "y": 1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 11, + "y": 2, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 11, + "y": 3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 11, + "y": 4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 11, + "y": 5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 11, + "y": 6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 11, + "y": 7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 11, + "y": 8, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 11, + "y": 9, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 11, + "y": 10, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 11, + "y": 11, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 11, + "y": 12, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 11, + "y": 13, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 11, + "y": 14, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 11, + "y": 15, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 11, + "y": 16, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Forward", + "x": 12, + "y": -1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 12, + "y": 0, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 12, + "y": 1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 12, + "y": 2, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 12, + "y": 3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 12, + "y": 4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 12, + "y": 5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 12, + "y": 6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 12, + "y": 7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 12, + "y": 8, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 12, + "y": 9, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 12, + "y": 10, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 12, + "y": 11, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 12, + "y": 12, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 12, + "y": 13, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 12, + "y": 14, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 12, + "y": 15, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Forward", + "x": 13, + "y": -1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 13, + "y": 0, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 13, + "y": 1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 13, + "y": 2, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 13, + "y": 3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 13, + "y": 4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 13, + "y": 5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 13, + "y": 6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 13, + "y": 7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 13, + "y": 8, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 13, + "y": 9, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 13, + "y": 10, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 13, + "y": 11, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 13, + "y": 12, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 13, + "y": 13, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 13, + "y": 14, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 13, + "y": 15, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Forward", + "x": 14, + "y": -1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 14, + "y": 0, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 14, + "y": 1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 14, + "y": 2, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 14, + "y": 3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 14, + "y": 4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 14, + "y": 5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 14, + "y": 6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 14, + "y": 7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 14, + "y": 8, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 14, + "y": 9, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 14, + "y": 10, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 14, + "y": 11, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 14, + "y": 12, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 14, + "y": 13, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 14, + "y": 14, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Forward", + "x": 15, + "y": -1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 15, + "y": 0, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 15, + "y": 1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 15, + "y": 2, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 15, + "y": 3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 15, + "y": 4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 15, + "y": 5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 15, + "y": 6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 15, + "y": 7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 15, + "y": 8, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 15, + "y": 9, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 15, + "y": 10, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 15, + "y": 11, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 15, + "y": 12, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 15, + "y": 13, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 15, + "y": 14, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Forward", + "x": 16, + "y": -1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 16, + "y": 0, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 16, + "y": 1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 16, + "y": 2, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 16, + "y": 3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 16, + "y": 4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 16, + "y": 5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 16, + "y": 6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 16, + "y": 7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 16, + "y": 8, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 16, + "y": 9, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 16, + "y": 10, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 16, + "y": 11, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 16, + "y": 12, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 16, + "y": 13, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Peripheral", + "x": 16, + "y": 14, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Forward", + "x": 17, + "y": -1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 17, + "y": 0, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 17, + "y": 1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 17, + "y": 2, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 17, + "y": 3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 17, + "y": 4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 17, + "y": 5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 17, + "y": 6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 17, + "y": 7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 17, + "y": 8, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 17, + "y": 9, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 17, + "y": 10, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 17, + "y": 11, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 17, + "y": 12, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 17, + "y": 13, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Forward", + "x": 18, + "y": -1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 18, + "y": 0, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 18, + "y": 1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 18, + "y": 2, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 18, + "y": 3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 18, + "y": 4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 18, + "y": 5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 18, + "y": 6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 18, + "y": 7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 18, + "y": 8, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 18, + "y": 9, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 18, + "y": 10, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 18, + "y": 11, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 18, + "y": 12, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 18, + "y": 13, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 18, + "y": 14, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Forward", + "x": 19, + "y": -1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 19, + "y": 0, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 19, + "y": 1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 19, + "y": 2, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 19, + "y": 3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 19, + "y": 4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 19, + "y": 5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 19, + "y": 6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 19, + "y": 7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 19, + "y": 8, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 19, + "y": 9, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 19, + "y": 10, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 19, + "y": 11, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 19, + "y": 12, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 19, + "y": 13, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 19, + "y": 14, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 19, + "y": 15, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 19, + "y": 16, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Forward", + "x": 20, + "y": -1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 20, + "y": 0, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 20, + "y": 1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 20, + "y": 2, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 20, + "y": 3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 20, + "y": 4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 20, + "y": 5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 20, + "y": 6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 20, + "y": 7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 20, + "y": 8, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 20, + "y": 9, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 20, + "y": 10, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 20, + "y": 11, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 20, + "y": 12, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 20, + "y": 13, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 20, + "y": 14, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 20, + "y": 15, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 20, + "y": 16, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 20, + "y": 17, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 20, + "y": 18, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Forward", + "x": 21, + "y": -1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 21, + "y": 0, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 21, + "y": 1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 21, + "y": 2, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 21, + "y": 3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 21, + "y": 4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 21, + "y": 5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 21, + "y": 6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 21, + "y": 7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 21, + "y": 8, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 21, + "y": 9, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 21, + "y": 10, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 21, + "y": 11, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 21, + "y": 12, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 21, + "y": 13, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 21, + "y": 14, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 21, + "y": 15, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 21, + "y": 16, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 21, + "y": 17, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 21, + "y": 18, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 21, + "y": 19, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Forward", + "x": 22, + "y": -1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 22, + "y": 0, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 22, + "y": 1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 22, + "y": 2, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 22, + "y": 3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 22, + "y": 4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 22, + "y": 5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 22, + "y": 6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 22, + "y": 7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 22, + "y": 8, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 22, + "y": 9, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 22, + "y": 10, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 22, + "y": 11, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 22, + "y": 12, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 22, + "y": 13, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 22, + "y": 14, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 22, + "y": 15, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 22, + "y": 16, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 22, + "y": 17, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 22, + "y": 18, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 22, + "y": 19, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 22, + "y": 20, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 22, + "y": 21, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Forward", + "x": 23, + "y": -1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 23, + "y": 0, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 23, + "y": 1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 23, + "y": 2, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 23, + "y": 3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 23, + "y": 4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 23, + "y": 5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 23, + "y": 6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 23, + "y": 7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 23, + "y": 8, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 23, + "y": 9, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 23, + "y": 10, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 23, + "y": 11, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 23, + "y": 12, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 23, + "y": 13, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 23, + "y": 14, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 23, + "y": 15, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 23, + "y": 16, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 23, + "y": 17, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 23, + "y": 18, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 23, + "y": 19, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 23, + "y": 20, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 23, + "y": 21, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 23, + "y": 22, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 23, + "y": 23, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Forward", + "x": 24, + "y": -1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 24, + "y": 0, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 24, + "y": 1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 24, + "y": 2, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 24, + "y": 3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 24, + "y": 4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 24, + "y": 5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 24, + "y": 6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 24, + "y": 7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 24, + "y": 8, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 24, + "y": 9, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 24, + "y": 10, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 24, + "y": 11, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 24, + "y": 12, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 24, + "y": 13, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 24, + "y": 14, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 24, + "y": 15, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 24, + "y": 16, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 24, + "y": 17, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 24, + "y": 18, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 24, + "y": 19, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 24, + "y": 20, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 24, + "y": 21, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 24, + "y": 22, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 24, + "y": 23, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 24, + "y": 24, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 24, + "y": 25, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Forward", + "x": 25, + "y": -1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 25, + "y": 0, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 25, + "y": 1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 25, + "y": 2, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 25, + "y": 3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 25, + "y": 4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 25, + "y": 5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 25, + "y": 6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 25, + "y": 7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 25, + "y": 8, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 25, + "y": 9, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 25, + "y": 10, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 25, + "y": 11, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 25, + "y": 12, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 25, + "y": 13, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 25, + "y": 14, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 25, + "y": 15, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 25, + "y": 16, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 25, + "y": 17, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 25, + "y": 18, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 25, + "y": 19, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 25, + "y": 20, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 25, + "y": 21, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 25, + "y": 22, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 25, + "y": 23, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 25, + "y": 24, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 25, + "y": 25, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 25, + "y": 26, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Forward", + "x": 26, + "y": -1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 26, + "y": 0, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 26, + "y": 1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 26, + "y": 2, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 26, + "y": 3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 26, + "y": 4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 26, + "y": 5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 26, + "y": 6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 26, + "y": 7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 26, + "y": 8, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 26, + "y": 9, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 26, + "y": 10, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 26, + "y": 11, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 26, + "y": 12, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 26, + "y": 13, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 26, + "y": 14, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 26, + "y": 15, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 26, + "y": 16, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 26, + "y": 17, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 26, + "y": 18, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 26, + "y": 19, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 26, + "y": 20, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 26, + "y": 21, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 26, + "y": 22, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 26, + "y": 23, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 26, + "y": 24, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 26, + "y": 25, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 26, + "y": 26, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 26, + "y": 27, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 26, + "y": 28, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Forward", + "x": 27, + "y": -1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 27, + "y": 0, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 27, + "y": 1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 27, + "y": 2, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 27, + "y": 3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 27, + "y": 4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 27, + "y": 5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 27, + "y": 6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 27, + "y": 7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 27, + "y": 8, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 27, + "y": 9, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 27, + "y": 10, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 27, + "y": 11, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 27, + "y": 12, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 27, + "y": 13, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 27, + "y": 14, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 27, + "y": 15, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 27, + "y": 16, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 27, + "y": 17, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 27, + "y": 18, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 27, + "y": 19, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 27, + "y": 20, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 27, + "y": 21, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 27, + "y": 22, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 27, + "y": 23, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 27, + "y": 24, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 27, + "y": 25, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 27, + "y": 26, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 27, + "y": 27, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 27, + "y": 28, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 27, + "y": 29, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 27, + "y": 30, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Forward", + "x": 28, + "y": -1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 28, + "y": 0, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 28, + "y": 1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 28, + "y": 2, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 28, + "y": 3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 28, + "y": 4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 28, + "y": 5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 28, + "y": 6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 28, + "y": 7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 28, + "y": 8, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 28, + "y": 9, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 28, + "y": 10, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 28, + "y": 11, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 28, + "y": 12, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 28, + "y": 13, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 28, + "y": 14, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 28, + "y": 15, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 28, + "y": 16, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 28, + "y": 17, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 28, + "y": 18, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 28, + "y": 19, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 28, + "y": 20, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 28, + "y": 21, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 28, + "y": 22, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 28, + "y": 23, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 28, + "y": 24, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 28, + "y": 25, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 28, + "y": 26, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 28, + "y": 27, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 28, + "y": 28, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 28, + "y": 29, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 28, + "y": 30, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 28, + "y": 31, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Peripheral", + "x": 28, + "y": 32, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Forward", + "x": 29, + "y": -1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 29, + "y": 0, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 29, + "y": 1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 29, + "y": 2, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 29, + "y": 3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 29, + "y": 4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 29, + "y": 5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 29, + "y": 6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 29, + "y": 7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 29, + "y": 8, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 29, + "y": 9, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 29, + "y": 10, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 29, + "y": 11, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 29, + "y": 12, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 29, + "y": 13, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 29, + "y": 14, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 29, + "y": 15, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 29, + "y": 16, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 29, + "y": 17, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 29, + "y": 18, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 29, + "y": 19, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 29, + "y": 20, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 29, + "y": 21, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 29, + "y": 22, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 29, + "y": 23, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 29, + "y": 24, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 29, + "y": 25, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 29, + "y": 26, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 29, + "y": 27, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 29, + "y": 28, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 29, + "y": 29, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 29, + "y": 30, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 29, + "y": 31, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Peripheral", + "x": 29, + "y": 32, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Forward", + "x": 30, + "y": -1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 30, + "y": 0, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 30, + "y": 1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 30, + "y": 2, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 30, + "y": 3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 30, + "y": 4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 30, + "y": 5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 30, + "y": 6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 30, + "y": 7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 30, + "y": 8, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 30, + "y": 9, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 30, + "y": 10, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 30, + "y": 11, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 30, + "y": 12, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 30, + "y": 13, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 30, + "y": 14, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 30, + "y": 15, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 30, + "y": 16, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 30, + "y": 17, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 30, + "y": 18, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 30, + "y": 19, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 30, + "y": 20, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 30, + "y": 21, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 30, + "y": 22, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 30, + "y": 23, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 30, + "y": 24, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 30, + "y": 25, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 30, + "y": 26, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 30, + "y": 27, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 30, + "y": 28, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 30, + "y": 29, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 30, + "y": 30, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 30, + "y": 31, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Peripheral", + "x": 30, + "y": 32, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Forward", + "x": 31, + "y": -1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 31, + "y": 0, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 31, + "y": 1, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 31, + "y": 2, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 31, + "y": 3, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Forward", + "x": 31, + "y": 4, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 31, + "y": 5, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 31, + "y": 6, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 31, + "y": 7, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 31, + "y": 8, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 31, + "y": 9, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 31, + "y": 10, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 31, + "y": 11, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 31, + "y": 12, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 31, + "y": 13, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 31, + "y": 14, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 31, + "y": 15, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 31, + "y": 16, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 31, + "y": 17, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 31, + "y": 18, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 31, + "y": 19, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 31, + "y": 20, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 31, + "y": 21, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 31, + "y": 22, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 31, + "y": 23, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 31, + "y": 24, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 31, + "y": 25, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 31, + "y": 26, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 31, + "y": 27, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 31, + "y": 28, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 31, + "y": 29, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 31, + "y": 30, + "z": 0 + }, + { + "tile_kind": "Floor", + "visibility": "Peripheral", + "x": 31, + "y": 31, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Peripheral", + "x": 31, + "y": 32, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Forward", + "x": 32, + "y": -1, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Forward", + "x": 32, + "y": 0, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Forward", + "x": 32, + "y": 1, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Forward", + "x": 32, + "y": 2, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Forward", + "x": 32, + "y": 3, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Forward", + "x": 32, + "y": 4, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Peripheral", + "x": 32, + "y": 5, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Peripheral", + "x": 32, + "y": 6, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Peripheral", + "x": 32, + "y": 7, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Peripheral", + "x": 32, + "y": 8, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Peripheral", + "x": 32, + "y": 9, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Peripheral", + "x": 32, + "y": 10, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Peripheral", + "x": 32, + "y": 11, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Peripheral", + "x": 32, + "y": 12, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Peripheral", + "x": 32, + "y": 13, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Peripheral", + "x": 32, + "y": 14, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Peripheral", + "x": 32, + "y": 15, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Peripheral", + "x": 32, + "y": 16, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Peripheral", + "x": 32, + "y": 17, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Peripheral", + "x": 32, + "y": 18, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Peripheral", + "x": 32, + "y": 19, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Peripheral", + "x": 32, + "y": 20, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Peripheral", + "x": 32, + "y": 21, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Peripheral", + "x": 32, + "y": 22, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Peripheral", + "x": 32, + "y": 23, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Peripheral", + "x": 32, + "y": 24, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Peripheral", + "x": 32, + "y": 25, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Peripheral", + "x": 32, + "y": 26, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Peripheral", + "x": 32, + "y": 27, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Peripheral", + "x": 32, + "y": 28, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Peripheral", + "x": 32, + "y": 29, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Peripheral", + "x": 32, + "y": 30, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Peripheral", + "x": 32, + "y": 31, + "z": 0 + }, + { + "tile_kind": "Wall", + "visibility": "Peripheral", + "x": 32, + "y": 32, + "z": 0 + } + ] +} diff --git a/server/tests/golden_suite.rs b/server/tests/golden_suite.rs new file mode 100644 index 000000000..36660a971 --- /dev/null +++ b/server/tests/golden_suite.rs @@ -0,0 +1,399 @@ +//! Golden file regression test (#485) +//! +//! Runs a 10-tick deterministic replay, serializes the final ObserverSnapshot +//! to JSON, and compares against a committed golden file. Any deviation fails +//! the test with a field-level diff. +//! +//! To regenerate golden files after intentional changes: +//! UPDATE_GOLDEN=1 cargo test --test golden_suite +//! +//! Spec references: D-010 (deterministic simulation), D-030 (testability) + +use settled_reach_server::bridge::types::*; +use settled_reach_server::bridge::{BridgePlugin, SnapshotBuffer}; +use settled_reach_server::knowledge::registry::EntityRegistry; +use settled_reach_server::knowledge::{KnowledgeGraph, KnowledgePlugin}; +use settled_reach_server::npc::relationships::{RelationshipEdge, RelationshipGraph}; +use settled_reach_server::npc::{ + Contentment, DailyRoutine, Npc, NpcPlugin, RelationshipKind, RoutineEntry, ToleranceThreshold, + Want, WantKind, +}; +use settled_reach_server::perception::cognitive_delay::CognitiveDelay; +use settled_reach_server::perception::vision_cone::Facing; +use settled_reach_server::simulation::interaction::{Interactable, NearbyInteractionBuffer}; +use settled_reach_server::simulation::listening::ListeningFocus; +use settled_reach_server::simulation::monologue::{ + MonologueBuffer, MonologueState, SprintAnomalyQueue, +}; +use settled_reach_server::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap}; +use settled_reach_server::simulation::path_follow::MovementSpeed; +use settled_reach_server::simulation::rng::SimRng; +use settled_reach_server::simulation::stance::{MovementProfile, PlayerMoveCooldown}; +use settled_reach_server::simulation::time::DayPhase; +use settled_reach_server::simulation::SimulationPlugin; + +use bevy_app::prelude::*; +use serde_json::Value; +use std::collections::BTreeMap; +use std::path::PathBuf; + +const GOLDEN_DIR: &str = "tests/golden"; +const GOLDEN_FILE: &str = "tests/golden/proof_room_tick_10.json"; +const SEED: u64 = 42; +const NUM_TICKS: usize = 10; + +/// Build a deterministic simulation app with the proof room. +/// Mirrors the setup in determinism.rs / main.rs. +fn build_app(seed: u64) -> App { + let mut app = App::new(); + app.add_plugins(SimulationPlugin); + app.add_plugins(BridgePlugin); + app.add_plugins(KnowledgePlugin); + app.add_plugins(NpcPlugin); + app.insert_resource(SimRng::new(seed)); + app.insert_resource(WalkabilityMap::new(32, 32, 1)); + + { + let mut wm = app.world_mut().resource_mut::(); + wm.set_walkable(&TilePosition::new(16, 14, 0), false); + } + + let mut registry = EntityRegistry::new(0); + + let profile = MovementProfile::smuggler(); + let player = app + .world_mut() + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing::default(), + KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), + MonologueState::default(), + MonologueBuffer::default(), + SprintAnomalyQueue::default(), + CognitiveDelay::default(), + ListeningFocus::new(TilePosition::new(16, 16, 0)), + profile, + profile.initial_stance(), + PlayerMoveCooldown::default(), + )) + .id(); + registry.register(player); + + let npc1 = app + .world_mut() + .spawn(( + Npc, + Interactable, + TilePosition::new(16, 13, 0), + Want { + primary: WantKind::Wealth, + intensity: 6, + description: "Wants a bigger share of docking fees".into(), + }, + DailyRoutine { + entries: vec![ + RoutineEntry { + phase: DayPhase::Morning, + location: TilePosition::new(16, 13, 0), + activity: "Prep cargo bay".into(), + }, + RoutineEntry { + phase: DayPhase::Afternoon, + location: TilePosition::new(20, 10, 0), + activity: "Unload freight".into(), + }, + RoutineEntry { + phase: DayPhase::Evening, + location: TilePosition::new(10, 20, 0), + activity: "Drink at canteen".into(), + }, + RoutineEntry { + phase: DayPhase::Night, + location: TilePosition::new(16, 13, 0), + activity: "Sleep in bunk".into(), + }, + ], + description: "Dock worker shift pattern".into(), + }, + Contentment { level: 20 }, + ToleranceThreshold { + current_stress: 30, + threshold: 70, + }, + MovementSpeed::new(2), + )) + .id(); + let npc1_sid = registry.register(npc1); + + let npc2 = app + .world_mut() + .spawn(( + Npc, + Interactable, + TilePosition::new(14, 18, 0), + Want { + primary: WantKind::Knowledge, + intensity: 8, + description: "Obsessed with pre-Collapse sensor arrays".into(), + }, + DailyRoutine { + entries: vec![ + RoutineEntry { + phase: DayPhase::Morning, + location: TilePosition::new(14, 18, 0), + activity: "Calibrate instruments".into(), + }, + RoutineEntry { + phase: DayPhase::Afternoon, + location: TilePosition::new(22, 22, 0), + activity: "Field survey".into(), + }, + ], + description: "Field tech survey pattern".into(), + }, + Contentment { level: 45 }, + ToleranceThreshold { + current_stress: 10, + threshold: 60, + }, + MovementSpeed::default(), + )) + .id(); + let npc2_sid = registry.register(npc2); + + let npc3 = app + .world_mut() + .spawn(( + Npc, + Interactable, + TilePosition::new(18, 14, 0), + Want { + primary: WantKind::Safety, + intensity: 4, + description: "Wants a quiet shift".into(), + }, + Contentment { level: -5 }, + ToleranceThreshold { + current_stress: 45, + threshold: 55, + }, + )) + .id(); + let npc3_sid = registry.register(npc3); + + { + let mut rel_graph = app.world_mut().resource_mut::(); + rel_graph.set_relationship( + npc1_sid, + npc3_sid, + RelationshipEdge { + kind: RelationshipKind::Colleague, + trust: 3, + history: vec![], + last_interaction_tick: 0, + }, + ); + rel_graph.set_relationship( + npc3_sid, + npc2_sid, + RelationshipEdge { + kind: RelationshipKind::Rival, + trust: -4, + history: vec![], + last_interaction_tick: 0, + }, + ); + } + + app.insert_resource(registry); + app +} + +/// Standard 10-tick input sequence for golden file tests. +/// Matches the first 10 ticks of the determinism test in determinism.rs. +fn standard_inputs() -> Vec> { + vec![ + // Tick 0: idle — baseline snapshot + vec![], + // Tick 1: move north + vec![PlayerInput { + tick: 1, + action: PlayerAction::MoveNorth, + }], + // Tick 2: idle + vec![], + // Tick 3: move east + vec![PlayerInput { + tick: 3, + action: PlayerAction::MoveEast, + }], + // Tick 4: idle + vec![], + // Tick 5: move north + vec![PlayerInput { + tick: 5, + action: PlayerAction::MoveNorth, + }], + // Tick 6: stance toggle up + vec![PlayerInput { + tick: 6, + action: PlayerAction::ToggleStanceUp, + }], + // Tick 7: move north + vec![PlayerInput { + tick: 7, + action: PlayerAction::MoveNorth, + }], + // Tick 8: pause + vec![PlayerInput { + tick: 8, + action: PlayerAction::Pause, + }], + // Tick 9: move while paused (should be discarded) + vec![PlayerInput { + tick: 9, + action: PlayerAction::MoveNorth, + }], + ] +} + +/// Recursively sort all object keys for deterministic JSON output. +fn sort_json_keys(value: &Value) -> Value { + match value { + Value::Object(map) => { + let sorted: BTreeMap = map + .iter() + .map(|(k, v)| (k.clone(), sort_json_keys(v))) + .collect(); + Value::Object(sorted.into_iter().collect()) + } + Value::Array(arr) => Value::Array(arr.iter().map(sort_json_keys).collect()), + other => other.clone(), + } +} + +/// Recursive JSON diff — reports all field-level differences with paths. +fn diff_json(path: &str, expected: &Value, actual: &Value, diffs: &mut Vec) { + match (expected, actual) { + (Value::Object(e), Value::Object(a)) => { + let mut all_keys: Vec<&String> = e.keys().chain(a.keys()).collect(); + all_keys.sort(); + all_keys.dedup(); + for key in all_keys { + let child = if path.is_empty() { + format!(".{}", key) + } else { + format!("{}.{}", path, key) + }; + match (e.get(key), a.get(key)) { + (Some(ev), Some(av)) => diff_json(&child, ev, av, diffs), + (Some(_), None) => diffs.push(format!("{}: missing in actual", child)), + (None, Some(_)) => diffs.push(format!("{}: unexpected in actual", child)), + (None, None) => unreachable!(), + } + } + } + (Value::Array(e), Value::Array(a)) => { + for i in 0..e.len().max(a.len()) { + let child = format!("{}[{}]", path, i); + match (e.get(i), a.get(i)) { + (Some(ev), Some(av)) => diff_json(&child, ev, av, diffs), + (Some(_), None) => diffs.push(format!("{}: missing in actual", child)), + (None, Some(_)) => diffs.push(format!("{}: unexpected in actual", child)), + (None, None) => unreachable!(), + } + } + } + _ => { + if expected != actual { + diffs.push(format!( + "{}: expected {}, got {}", + path, expected, actual + )); + } + } + } +} + +#[test] +fn proof_room_tick_10_matches_golden() { + let mut app = build_app(SEED); + let inputs = standard_inputs(); + + assert_eq!(inputs.len(), NUM_TICKS); + + let mut last_snapshot: Option = None; + + for tick_inputs in &inputs { + { + let mut queue = app + .world_mut() + .resource_mut::(); + for input in tick_inputs { + queue.push(input.clone()); + } + } + + app.update(); + + let buffer = app.world().resource::(); + if let Some(snapshot) = &buffer.snapshot { + last_snapshot = Some(snapshot.clone()); + } + } + + let snapshot = last_snapshot.expect("no snapshot produced after 10 ticks"); + + // Serialize to sorted JSON for deterministic comparison + let actual_value: Value = serde_json::to_value(&snapshot).expect("serialize to JSON"); + let actual_sorted = sort_json_keys(&actual_value); + let actual_json = + serde_json::to_string_pretty(&actual_sorted).expect("format JSON") + "\n"; + + let golden_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(GOLDEN_FILE); + + // UPDATE_GOLDEN=1 mode: write the golden file and return + if std::env::var("UPDATE_GOLDEN").is_ok() { + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(GOLDEN_DIR); + std::fs::create_dir_all(&dir).expect("create golden directory"); + std::fs::write(&golden_path, &actual_json).expect("write golden file"); + eprintln!( + "Golden file written: {} ({} bytes)", + golden_path.display(), + actual_json.len() + ); + return; + } + + // Normal mode: compare against golden file + let golden_json = std::fs::read_to_string(&golden_path).unwrap_or_else(|e| { + panic!( + "Golden file not found: {}. Run with UPDATE_GOLDEN=1 to generate.\nError: {}", + golden_path.display(), + e + ) + }); + let golden_value: Value = + serde_json::from_str(&golden_json).expect("parse golden file as JSON"); + + let mut diffs = Vec::new(); + diff_json("", &golden_value, &actual_sorted, &mut diffs); + + if !diffs.is_empty() { + let mut msg = format!( + "Golden file mismatch ({} differences):\n", + diffs.len() + ); + for diff in &diffs { + msg.push_str(&format!(" {}\n", diff)); + } + msg.push_str(&format!( + "\nTo update: UPDATE_GOLDEN=1 cargo test --test golden_suite\n\ + Golden file: {}", + golden_path.display() + )); + panic!("{}", msg); + } +} diff --git a/server/tests/layer3.rs b/server/tests/layer3.rs new file mode 100644 index 000000000..07cc6eaf1 --- /dev/null +++ b/server/tests/layer3.rs @@ -0,0 +1,132 @@ +//! Layer 3 integration test: real subprocess IPC (D-030) +//! +//! Spawns the server binary as a child process with --test-mode --port 0, +//! parses the LISTENING:{port} handshake from stdout, connects via TCP, +//! sends a PlayerInput, and reads back an ObserverSnapshot. +//! +//! This is the highest-fidelity test layer: no mocks, no in-process bridge. +//! The server runs as a separate OS process, exactly as it does in production. +//! +//! Spec references: D-020 (subprocess IPC), D-030 (Layer 3 integration tests) + +use settled_reach_server::bridge::framing::{read_framed, write_framed}; +use settled_reach_server::bridge::types::*; +use std::io::{BufRead, BufReader, BufWriter}; +use std::net::TcpStream; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +/// Timeout for the server to emit LISTENING:{port} on stdout. +const LISTEN_TIMEOUT: Duration = Duration::from_secs(15); + +/// Timeout for the client to receive a snapshot after sending input. +const SNAPSHOT_TIMEOUT: Duration = Duration::from_secs(10); + +#[test] +fn server_subprocess_sends_snapshot_on_connect() { + // 1. Spawn server binary with --test-mode --port 0 + let server_bin = env!("CARGO_BIN_EXE_settled-reach-server"); + let mut child = Command::new(server_bin) + .args(["--test-mode", "--port", "0"]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("failed to spawn server binary"); + + let stdout = child.stdout.take().expect("stdout not captured"); + let mut stdout_reader = BufReader::new(stdout); + + // 2. Parse LISTENING:{port} from stdout + let port = { + let deadline = Instant::now() + LISTEN_TIMEOUT; + let mut line = String::new(); + loop { + line.clear(); + match stdout_reader.read_line(&mut line) { + Ok(0) => panic!("server stdout closed before LISTENING signal"), + Ok(_) => { + let trimmed = line.trim(); + if let Some(port_str) = trimmed.strip_prefix("LISTENING:") { + break port_str + .parse::() + .unwrap_or_else(|e| panic!("invalid port '{}': {}", port_str, e)); + } + } + Err(e) => panic!("failed to read server stdout: {}", e), + } + assert!( + Instant::now() < deadline, + "timed out waiting for LISTENING signal" + ); + } + }; + + // 3. Connect to the server via TCP + let addr = format!("127.0.0.1:{}", port); + let stream = TcpStream::connect(&addr) + .unwrap_or_else(|e| panic!("failed to connect to server at {}: {}", addr, e)); + stream + .set_read_timeout(Some(SNAPSHOT_TIMEOUT)) + .expect("set read timeout"); + + let mut reader = BufReader::new(stream.try_clone().expect("clone stream for reader")); + let mut writer = BufWriter::new(stream); + + // 4. Send one PlayerInput (idle tick 0) + let inputs = vec![PlayerInput { + tick: 0, + action: PlayerAction::MoveNorth, + }]; + let payload = rmp_serde::to_vec_named(&inputs).expect("serialize PlayerInput"); + write_framed(&mut writer, &payload).expect("send PlayerInput to server"); + + // 5. Read one ObserverSnapshot + let response = read_framed(&mut reader) + .expect("read snapshot frame") + .expect("server closed connection before sending snapshot"); + let snapshot: ObserverSnapshot = + rmp_serde::from_slice(&response).expect("deserialize ObserverSnapshot"); + + // 6. Assert protocol correctness (D-020) + assert_eq!( + snapshot.version, PROTOCOL_VERSION, + "protocol version mismatch: got {}, expected {}", + snapshot.version, PROTOCOL_VERSION + ); + assert!( + snapshot.entities.len() > 0, + "snapshot should contain at least one entity (the player), got 0" + ); + + // The proof room has a player + NPCs. Verify the player entity exists. + let has_player = snapshot + .entities + .iter() + .any(|e| matches!(e.kind, EntityKind::Player)); + assert!(has_player, "snapshot must contain a Player entity"); + + // 7. Clean up: drop connection so the server exits its game loop + drop(reader); + drop(writer); + + // Wait for child to exit (with timeout) + let exit_deadline = Instant::now() + Duration::from_secs(5); + loop { + match child.try_wait() { + Ok(Some(_status)) => break, + Ok(None) => { + if Instant::now() > exit_deadline { + child.kill().ok(); + child.wait().ok(); + break; + } + std::thread::sleep(Duration::from_millis(50)); + } + Err(e) => { + eprintln!("error waiting for server process: {}", e); + child.kill().ok(); + break; + } + } + } +} diff --git a/tooling/test-client/src/golden.rs b/tooling/test-client/src/golden.rs index cb797969b..a9f162762 100644 --- a/tooling/test-client/src/golden.rs +++ b/tooling/test-client/src/golden.rs @@ -1,9 +1,16 @@ -// Golden file comparison for test-client. +// Golden file comparison for test-client (#484). // Compares the final ObserverSnapshot (as JSON) against a golden file. // Reports field-by-field differences with JSON paths. +// +// Key properties: +// - Recursive diff with JSON path tracking (e.g. ".entities[0].x") +// - Object keys sorted before compare (BTreeMap semantics via serde_json) +// - Pretty-printed field-level diffs: path + expected + got +// - Used by #485 golden file test suite and --golden CLI flag use serde_json::Value; use settled_reach_server::bridge::types::ObserverSnapshot; +use std::collections::BTreeMap; use std::path::Path; /// Compare an ObserverSnapshot against a golden JSON file. @@ -24,41 +31,89 @@ pub fn compare_golden( let actual: Value = serde_json::to_value(snapshot) .map_err(|e| format!("failed to serialize snapshot: {}", e))?; + Ok(diff_json_values(&golden, &actual)) +} + +/// Compare two JSON values and return all field-level differences. +/// Object keys are sorted (BTreeMap order) for deterministic comparison. +/// Returns an empty Vec when the values are equal. +pub fn diff_json_values(expected: &Value, actual: &Value) -> Vec { let mut diffs = Vec::new(); - diff_values("", &golden, &actual, &mut diffs); - Ok(diffs) + diff_values("", expected, actual, &mut diffs); + diffs +} + +/// Generate a golden file from an ObserverSnapshot. +/// Writes sorted, pretty-printed JSON for human readability and stable diffs. +pub fn generate_golden(golden_path: &Path, snapshot: &ObserverSnapshot) -> Result<(), String> { + let value: Value = serde_json::to_value(snapshot) + .map_err(|e| format!("failed to serialize snapshot: {}", e))?; + let sorted = sort_json_keys(&value); + let json = serde_json::to_string_pretty(&sorted) + .map_err(|e| format!("failed to format JSON: {}", e))?; + + if let Some(parent) = golden_path.parent() { + std::fs::create_dir_all(parent).map_err(|e| { + format!( + "failed to create directory {}: {}", + parent.display(), + e + ) + })?; + } + + std::fs::write(golden_path, format!("{}\n", json)).map_err(|e| { + format!( + "failed to write golden file {}: {}", + golden_path.display(), + e + ) + }) +} + +/// Recursively sort all object keys in a JSON value. +/// Ensures deterministic serialization regardless of insertion order. +fn sort_json_keys(value: &Value) -> Value { + match value { + Value::Object(map) => { + let sorted: BTreeMap = map + .iter() + .map(|(k, v)| (k.clone(), sort_json_keys(v))) + .collect(); + Value::Object(sorted.into_iter().collect()) + } + Value::Array(arr) => Value::Array(arr.iter().map(sort_json_keys).collect()), + other => other.clone(), + } } fn diff_values(path: &str, expected: &Value, actual: &Value, diffs: &mut Vec) { match (expected, actual) { (Value::Object(e), Value::Object(a)) => { - for key in e.keys() { + // Collect all keys from both sides, sorted for deterministic output + let mut all_keys: Vec<&String> = e.keys().chain(a.keys()).collect(); + all_keys.sort(); + all_keys.dedup(); + + for key in all_keys { let child_path = if path.is_empty() { format!(".{}", key) } else { format!("{}.{}", path, key) }; - match a.get(key) { - Some(av) => diff_values(&child_path, &e[key], av, diffs), - None => diffs.push(format!( + match (e.get(key), a.get(key)) { + (Some(ev), Some(av)) => diff_values(&child_path, ev, av, diffs), + (Some(ev), None) => diffs.push(format!( "{}: expected {}, got ", child_path, - format_value(&e[key]) + format_value(ev) )), - } - } - for key in a.keys() { - if !e.contains_key(key) { - let child_path = if path.is_empty() { - format!(".{}", key) - } else { - format!("{}.{}", path, key) - }; - diffs.push(format!( + (None, Some(av)) => diffs.push(format!( "{}: expected , got {}", child_path, - format_value(&a[key]) - )); + format_value(av) + )), + (None, None) => unreachable!(), } } } @@ -82,6 +137,15 @@ fn diff_values(path: &str, expected: &Value, actual: &Value, diffs: &mut Vec { + // Type mismatch (e.g. number vs string) + diffs.push(format!( + "{}: type mismatch — expected {}, got {}", + path, + format_value(e), + format_value(a) + )); + } _ => { if expected != actual { diffs.push(format!( @@ -99,6 +163,138 @@ fn format_value(v: &Value) -> String { match v { Value::String(s) => format!("{:?}", s), Value::Null => "null".to_string(), - other => other.to_string(), + Value::Bool(b) => b.to_string(), + Value::Number(n) => n.to_string(), + Value::Array(arr) => format!("[...] ({} elements)", arr.len()), + Value::Object(map) => format!("{{...}} ({} keys)", map.len()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn identical_values_produce_no_diffs() { + let a = json!({"version": 8, "tick": 0, "entities": []}); + let b = json!({"version": 8, "tick": 0, "entities": []}); + assert!(diff_json_values(&a, &b).is_empty()); + } + + #[test] + fn primitive_mismatch_reports_path() { + let a = json!({"version": 8, "tick": 0}); + let b = json!({"version": 8, "tick": 5}); + let diffs = diff_json_values(&a, &b); + assert_eq!(diffs.len(), 1); + assert!(diffs[0].contains(".tick")); + assert!(diffs[0].contains("expected 0")); + assert!(diffs[0].contains("got 5")); + } + + #[test] + fn missing_key_in_actual() { + let expected = json!({"version": 8, "tick": 0}); + let actual = json!({"version": 8}); + let diffs = diff_json_values(&expected, &actual); + assert_eq!(diffs.len(), 1); + assert!(diffs[0].contains(".tick")); + assert!(diffs[0].contains("")); + } + + #[test] + fn extra_key_in_actual() { + let expected = json!({"version": 8}); + let actual = json!({"version": 8, "tick": 0}); + let diffs = diff_json_values(&expected, &actual); + assert_eq!(diffs.len(), 1); + assert!(diffs[0].contains(".tick")); + assert!(diffs[0].contains("expected ")); + } + + #[test] + fn nested_object_diff() { + let a = json!({"game_time": {"day": 0, "time_of_day": 100}}); + let b = json!({"game_time": {"day": 0, "time_of_day": 200}}); + let diffs = diff_json_values(&a, &b); + assert_eq!(diffs.len(), 1); + assert!(diffs[0].contains(".game_time.time_of_day")); + } + + #[test] + fn array_length_difference() { + let a = json!({"entities": [{"id": 1}, {"id": 2}]}); + let b = json!({"entities": [{"id": 1}]}); + let diffs = diff_json_values(&a, &b); + assert_eq!(diffs.len(), 1); + assert!(diffs[0].contains(".entities[1]")); + assert!(diffs[0].contains("")); + } + + #[test] + fn array_element_diff() { + let a = json!({"entities": [{"x": 10.5, "y": 20.0}]}); + let b = json!({"entities": [{"x": 10.5, "y": 25.0}]}); + let diffs = diff_json_values(&a, &b); + assert_eq!(diffs.len(), 1); + assert!(diffs[0].contains(".entities[0].y")); + } + + #[test] + fn type_mismatch_reports_clearly() { + let a = json!({"tick": 0}); + let b = json!({"tick": "zero"}); + let diffs = diff_json_values(&a, &b); + assert_eq!(diffs.len(), 1); + assert!(diffs[0].contains("type mismatch")); + } + + #[test] + fn deeply_nested_path() { + let a = json!({"a": {"b": {"c": {"d": 1}}}}); + let b = json!({"a": {"b": {"c": {"d": 2}}}}); + let diffs = diff_json_values(&a, &b); + assert_eq!(diffs.len(), 1); + assert_eq!(diffs[0], ".a.b.c.d: expected 1, got 2"); + } + + #[test] + fn multiple_diffs_all_reported() { + let a = json!({"version": 7, "tick": 0, "entities": [{"id": 1}]}); + let b = json!({"version": 8, "tick": 5, "entities": [{"id": 2}]}); + let diffs = diff_json_values(&a, &b); + assert_eq!(diffs.len(), 3); + } + + #[test] + fn sort_json_keys_is_deterministic() { + let a = json!({"z": 1, "a": 2, "m": {"z": 3, "a": 4}}); + let sorted = sort_json_keys(&a); + let output = serde_json::to_string(&sorted).unwrap(); + assert_eq!(output, r#"{"a":2,"m":{"a":4,"z":3},"z":1}"#); + } + + #[test] + fn empty_objects_match() { + let a = json!({}); + let b = json!({}); + assert!(diff_json_values(&a, &b).is_empty()); + } + + #[test] + fn null_values_match() { + let a = json!({"field": null}); + let b = json!({"field": null}); + assert!(diff_json_values(&a, &b).is_empty()); + } + + #[test] + fn null_vs_absent_reports_diff() { + let a = json!({"field": null}); + let b = json!({}); + let diffs = diff_json_values(&a, &b); + assert_eq!(diffs.len(), 1); + assert!(diffs[0].contains(".field")); } } From 09eb6d75827edc9737699dc409a050e5c81f80a6 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 18 Feb 2026 09:45:17 +0100 Subject: [PATCH 2/3] =?UTF-8?q?refactor(simulation):=20address=20PR=20#32?= =?UTF-8?q?=20review=20=E2=80=94=2014=20items=20from=20Hoshe=20+=20Tyre?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hoshe (code quality): - Remove dead RoomMember component from reset.rs - Remove execute_reset (dual API trap); plan_reset is sole production path - .unwrap() → .expect() on reset_plate in setup_gauntlet boot path - Add 10s read timeout to TCP runtime test (prevents hangs) - Register player in EntityRegistry in runtime boot test - Document room_at z-range and corridor overlap assumptions - Derive entity count from EXPECTED_ENTITY_COUNT constant (was hardcoded 24) - Add reset plate (49-51) verification to stable_id_ranges_match_spec - Add debounce exact boundary test (tick 9 rejected, tick 10 accepted) Tyre (architecture): - Gate test_world rooms/constants/setup behind "gauntlet" feature (default-on); reset module stays always-compiled (production dependency via input system) - Document setup_gauntlet scheduler bypass for future tracking - Extract runtime TCP test to content_runtime.rs (separate failure modes) 507 tests passing. Co-Authored-By: Claude Opus 4.6 --- server/Cargo.toml | 4 + server/src/lib.rs | 3 + server/src/main.rs | 6 ++ server/src/test_world/constants.rs | 19 ++++ server/src/test_world/mod.rs | 56 ++++++++-- server/src/test_world/reset.rs | 163 +++++++++-------------------- server/tests/content_loading.rs | 131 +---------------------- server/tests/content_runtime.rs | 150 ++++++++++++++++++++++++++ 8 files changed, 281 insertions(+), 251 deletions(-) create mode 100644 server/tests/content_runtime.rs diff --git a/server/Cargo.toml b/server/Cargo.toml index 3eacdd19d..5d7da1f66 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -17,5 +17,9 @@ thiserror = "2" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } +[features] +default = ["gauntlet"] +gauntlet = [] + [dev-dependencies] serde_json = "1" diff --git a/server/src/lib.rs b/server/src/lib.rs index b35dc0dab..6d48ae689 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -9,4 +9,7 @@ pub mod npc; pub mod perception; pub mod simulation; pub mod storyteller; +// test_world::reset is always compiled (used by simulation::input). +// Room definitions, constants, and setup_gauntlet are gated behind +// the "gauntlet" feature (default-on) to allow stripping from release builds. pub mod test_world; diff --git a/server/src/main.rs b/server/src/main.rs index 059fa8e2e..ec5e85547 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -116,7 +116,13 @@ fn main() { // Gauntlet test world for --test-mode, proof room for normal mode. if test_mode { + #[cfg(feature = "gauntlet")] settled_reach_server::test_world::setup_gauntlet(&mut app); + #[cfg(not(feature = "gauntlet"))] + { + eprintln!("--test-mode requires the 'gauntlet' feature"); + std::process::exit(1); + } } else { setup_proof_room(&mut app); } diff --git a/server/src/test_world/constants.rs b/server/src/test_world/constants.rs index 399d329a0..4ed21935d 100644 --- a/server/src/test_world/constants.rs +++ b/server/src/test_world/constants.rs @@ -156,6 +156,13 @@ pub const ROOMS: &[GauntletRoom] = &[ /// Look up which room a position falls in. /// Returns the first room whose bounding box contains the position. +/// +/// Assumptions: +/// - All rooms are at z=0 (single-floor Gauntlet). Positions on other +/// z-levels will never match. +/// - Room bounding boxes must not overlap (verified by `rooms_do_not_overlap` +/// test). Corridors are NOT rooms and intentionally fall outside all +/// bounding boxes — `room_at` returns None for corridor positions. pub fn room_at(pos: &TilePosition) -> Option<&'static GauntletRoom> { ROOMS.iter().find(|r| { pos.x >= r.origin.x @@ -182,6 +189,17 @@ 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); +pub const RESET_PLATE_STABLE_IDS: (u64, u64) = (49, 51); + +/// Number of actively-spawned entities in the current Gauntlet build. +/// Derived from StableId ranges of built rooms + player + reset plates. +/// Reserved (unbuilt) rooms do not contribute entities. +pub const EXPECTED_ENTITY_COUNT: usize = 1 // player (StableId 0) + + (HUB_STABLE_IDS.1 - HUB_STABLE_IDS.0 + 1) as usize + + (OCCLUSION_STABLE_IDS.1 - OCCLUSION_STABLE_IDS.0 + 1) as usize + + (INVENTORY_STABLE_IDS.1 - INVENTORY_STABLE_IDS.0 + 1) as usize + + (PAUSE_CHAMBER_STABLE_IDS.1 - PAUSE_CHAMBER_STABLE_IDS.0 + 1) as usize + + (RESET_PLATE_STABLE_IDS.1 - RESET_PLATE_STABLE_IDS.0 + 1) as usize; #[cfg(test)] mod tests { @@ -289,6 +307,7 @@ mod tests { PAUSE_CHAMBER_STABLE_IDS, DIALOGUE_ROOM_STABLE_IDS, CROWD_PLAZA_STABLE_IDS, + RESET_PLATE_STABLE_IDS, ]; for (i, a) in ranges.iter().enumerate() { for (j, b) in ranges.iter().enumerate() { diff --git a/server/src/test_world/mod.rs b/server/src/test_world/mod.rs index 7b352beec..ace9c13e2 100644 --- a/server/src/test_world/mod.rs +++ b/server/src/test_world/mod.rs @@ -5,6 +5,11 @@ //! 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) @@ -23,28 +28,45 @@ //! Crowd Plaza: 34-48 (reserved, not yet built) //! Reset plates: 49-51 (Occlusion, Inventory, Pause) +#[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::KnowledgeGraph; +#[cfg(feature = "gauntlet")] use crate::knowledge::types::StableId; +#[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::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. @@ -52,6 +74,12 @@ pub const MAP_HEIGHT: i32 = 125; /// 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); @@ -137,9 +165,9 @@ pub fn setup_gauntlet(app: &mut App) { // 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.unwrap()), - ("inventory_warehouse", constants::INVENTORY_WAREHOUSE.reset_plate.unwrap()), - ("pause_chamber", constants::PAUSE_CHAMBER.reset_plate.unwrap()), + ("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")), ]; for &(room_name, pos) in reset_plates { let entity = app @@ -191,6 +219,7 @@ pub fn setup_gauntlet(app: &mut App) { 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) { @@ -201,6 +230,7 @@ fn carve_room_interior(wm: &mut WalkabilityMap, ox: i32, oy: i32, w: i32, h: i32 } } +#[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). @@ -215,7 +245,7 @@ fn carve_corridor(wm: &mut WalkabilityMap, ox: i32, oy: i32, w: i32, h: i32) { } } -#[cfg(test)] +#[cfg(all(test, feature = "gauntlet"))] mod tests { use super::*; use crate::knowledge::registry::EntityRegistry; @@ -230,10 +260,11 @@ mod tests { setup_gauntlet(&mut app); let registry = app.world().resource::(); - // Player (0) + Hub signs (1-4) + Occlusion (9-12) + Inventory (13-23) + Pause (29) - // + Reset plates (49-51) - // = 1 + 4 + 4 + 11 + 1 + 3 = 24 entities - assert_eq!(registry.len(), 24); + assert_eq!( + registry.len(), + constants::EXPECTED_ENTITY_COUNT, + "entity count should match EXPECTED_ENTITY_COUNT derived from StableId ranges" + ); } #[test] @@ -322,5 +353,14 @@ mod tests { // Pause Chamber at 29 assert!(registry.to_entity(&StableId(29)).is_some(), "Pause Chamber at StableId 29"); + + // Reset plates at 49-51 + 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 + ); + } } } diff --git a/server/src/test_world/reset.rs b/server/src/test_world/reset.rs index 45d444007..b8e65aba9 100644 --- a/server/src/test_world/reset.rs +++ b/server/src/test_world/reset.rs @@ -6,11 +6,14 @@ //! //! Components: //! - `RoomResetTrigger` — marks an entity as a reset plate for a room -//! - `RoomMember` — tags entities with their source room for filtering //! //! Resource: //! - `RoomSnapshots` — stores tick-0 entity positions per room //! +//! Production path: `plan_reset` returns planned changes as a Vec, which +//! the input system applies via Commands (see simulation::input). This +//! avoids exclusive World access and is scheduler-friendly. +//! //! Spec (workshop-outcomes.md Section 8): //! - Trigger: Interact with reset plate entity (verb "Reset") //! - Resets: entity positions, carried items from room returned to floor @@ -20,7 +23,6 @@ use bevy_ecs::prelude::*; use std::collections::BTreeMap; -use crate::simulation::inventory::{CarriedBy, InventorySlot}; use crate::simulation::movement::TilePosition; /// Debounce cooldown in ticks between resets of the same room. @@ -34,13 +36,6 @@ pub struct RoomResetTrigger { pub room_name: String, } -/// Tags an entity as belonging to a specific room. -/// Used during reset to identify which entities to restore. -#[derive(Component, Debug, Clone)] -pub struct RoomMember { - pub room_name: String, -} - /// Snapshot of a single entity's initial position. #[derive(Debug, Clone)] struct EntitySnapshot { @@ -88,6 +83,9 @@ impl RoomSnapshots { /// Plan a room reset for use with Commands (system-friendly). /// Returns the list of (entity, position, is_floor_item) changes to apply, /// or None if debounced or unknown room. Updates debounce tracking. + /// + /// This is the canonical production API — the input system applies the + /// returned changes via Commands to avoid exclusive World access. pub fn plan_reset( &mut self, room_name: &str, @@ -109,62 +107,6 @@ impl RoomSnapshots { Some(changes) } - - /// Execute a room reset. Restores entity positions and returns - /// floor items to their original locations. - /// - /// Returns the number of entities restored, or None if the room - /// has no snapshot or debounce hasn't elapsed. - pub fn execute_reset( - &mut self, - room_name: &str, - current_tick: u64, - world: &mut World, - ) -> Option { - if !self.can_reset(room_name, current_tick) { - tracing::info!( - room_name, - "Room reset debounced (last reset tick: {:?})", - self.last_reset_tick.get(room_name) - ); - return None; - } - - let snapshot = self.snapshots.get(room_name)?; - let mut restored = 0; - - for snap in &snapshot.entities { - // Check if entity still exists - if world.get_entity(snap.entity).is_err() { - continue; - } - - if snap.is_floor_item { - // Floor item: remove CarriedBy/InventorySlot if carried, - // restore TilePosition to original location - let mut entity_mut = world.entity_mut(snap.entity); - entity_mut.remove::(); - entity_mut.remove::(); - entity_mut.insert(snap.position); - } else { - // NPC or other entity: just restore position - world.entity_mut(snap.entity).insert(snap.position); - } - restored += 1; - } - - self.last_reset_tick - .insert(room_name.to_string(), current_tick); - - tracing::info!( - room_name, - restored, - current_tick, - "Room reset executed" - ); - - Some(restored) - } } #[cfg(test)] @@ -172,86 +114,77 @@ mod tests { use super::*; #[test] - fn record_and_reset_restores_position() { + fn plan_reset_returns_correct_changes() { let mut world = World::new(); let entity = world.spawn(TilePosition::new(10, 20, 0)).id(); let mut snapshots = RoomSnapshots::default(); - - // Record initial position snapshots.record("test_room", entity, TilePosition::new(10, 20, 0), false); - // Move entity - *world.get_mut::(entity).unwrap() = TilePosition::new(50, 50, 0); - assert_eq!(world.get::(entity).unwrap().x, 50); - - // Reset - let restored = snapshots.execute_reset("test_room", 0, &mut world); - assert_eq!(restored, Some(1)); - assert_eq!(world.get::(entity).unwrap().x, 10); - assert_eq!(world.get::(entity).unwrap().y, 20); + let changes = snapshots.plan_reset("test_room", 0); + assert!(changes.is_some()); + let changes = changes.unwrap(); + assert_eq!(changes.len(), 1); + assert_eq!(changes[0].0, entity); + assert_eq!(changes[0].1, TilePosition::new(10, 20, 0)); + assert!(!changes[0].2); // not a floor item } #[test] - fn reset_restores_floor_item() { + fn plan_reset_includes_floor_items() { let mut world = World::new(); - world.init_resource::(); - - // Floor item starts on ground - let item = world - .spawn(TilePosition::new(5, 5, 0)) - .id(); - + let item = world.spawn(TilePosition::new(5, 5, 0)).id(); let mut snapshots = RoomSnapshots::default(); snapshots.record("warehouse", item, TilePosition::new(5, 5, 0), true); - // Simulate Take: remove TilePosition, add CarriedBy + InventorySlot - world.entity_mut(item).remove::(); - world - .entity_mut(item) - .insert((CarriedBy(crate::knowledge::types::StableId(0)), InventorySlot(0))); - - assert!(world.get::(item).is_none()); - assert!(world.get::(item).is_some()); - - // Reset - let restored = snapshots.execute_reset("warehouse", 0, &mut world); - assert_eq!(restored, Some(1)); - - // Item should be back on the ground - assert_eq!( - world.get::(item).unwrap(), - &TilePosition::new(5, 5, 0) - ); - assert!(world.get::(item).is_none()); - assert!(world.get::(item).is_none()); + let changes = snapshots.plan_reset("warehouse", 0).unwrap(); + assert_eq!(changes.len(), 1); + assert!(changes[0].2); // is a floor item } #[test] - fn debounce_prevents_rapid_reset() { + fn plan_reset_debounces() { let mut world = World::new(); let entity = world.spawn(TilePosition::new(10, 20, 0)).id(); let mut snapshots = RoomSnapshots::default(); snapshots.record("test_room", entity, TilePosition::new(10, 20, 0), false); // First reset at tick 0 - let result = snapshots.execute_reset("test_room", 0, &mut world); - assert_eq!(result, Some(1)); + assert!(snapshots.plan_reset("test_room", 0).is_some()); // Second reset at tick 5 — should be debounced - let result = snapshots.execute_reset("test_room", 5, &mut world); - assert_eq!(result, None); + assert!(snapshots.plan_reset("test_room", 5).is_none()); // Third reset at tick 10 — should succeed - let result = snapshots.execute_reset("test_room", 10, &mut world); - assert_eq!(result, Some(1)); + assert!(snapshots.plan_reset("test_room", 10).is_some()); } #[test] - fn unknown_room_returns_none() { + fn plan_reset_debounce_exact_boundary() { let mut world = World::new(); + let entity = world.spawn(TilePosition::new(10, 20, 0)).id(); let mut snapshots = RoomSnapshots::default(); - let result = snapshots.execute_reset("nonexistent", 0, &mut world); - assert_eq!(result, None); + snapshots.record("test_room", entity, TilePosition::new(10, 20, 0), false); + + // Reset at tick 0 + assert!(snapshots.plan_reset("test_room", 0).is_some()); + + // Tick 9: exactly one tick before debounce expires — must be rejected + assert!( + snapshots.plan_reset("test_room", 9).is_none(), + "tick 9 should be rejected (debounce is 10 ticks)" + ); + + // Tick 10: exact debounce boundary — must be accepted + assert!( + snapshots.plan_reset("test_room", 10).is_some(), + "tick 10 should be accepted (debounce elapsed)" + ); + } + + #[test] + fn plan_reset_unknown_room_returns_none() { + let mut snapshots = RoomSnapshots::default(); + assert!(snapshots.plan_reset("nonexistent", 0).is_none()); } #[test] diff --git a/server/tests/content_loading.rs b/server/tests/content_loading.rs index afeb53ea9..ae10abb4b 100644 --- a/server/tests/content_loading.rs +++ b/server/tests/content_loading.rs @@ -4,8 +4,7 @@ //! Uses real content files from content/ directory for structural content, //! and a test fixture for isolated NPC profile spawning. //! -//! Also includes runtime validation (#489): boot the full plugin stack with real -//! content, tick 10 times over TCP, and assert a valid ObserverSnapshot. +//! Runtime validation (TCP boot + tick) is in content_runtime.rs. use bevy_app::prelude::*; use bevy_ecs::prelude::*; @@ -420,129 +419,5 @@ fn spawn_real_content_with_relationships_and_secrets() { assert_eq!(nils_want.primary, npc::WantKind::Power); } -// ----------------------------------------------------------------------- -// Test: Runtime validation — boot + tick 10 + snapshot (#489) -// -// Smoke test for production content. Boots the full plugin stack with real -// content over TCP, ticks 10 times, and asserts a valid ObserverSnapshot. -// Catches runtime panics from broken entity references, missing components, -// or content schema issues that pass YAML validation but fail at tick time. -// ----------------------------------------------------------------------- - -#[test] -fn content_runtime_boot_tick_10_snapshot() { - use settled_reach_server::bridge::framing::{read_framed, write_framed}; - use settled_reach_server::bridge::tcp::TcpBridge; - use settled_reach_server::bridge::types::*; - use settled_reach_server::bridge::{BridgePlugin, BridgeResource}; - use settled_reach_server::knowledge::KnowledgeGraph; - use settled_reach_server::perception::cognitive_delay::CognitiveDelay; - use settled_reach_server::perception::vision_cone::Facing; - use settled_reach_server::simulation::interaction::NearbyInteractionBuffer; - use settled_reach_server::simulation::listening::ListeningFocus; - use settled_reach_server::simulation::monologue::{ - MonologueBuffer, MonologueState, SprintAnomalyQueue, - }; - use settled_reach_server::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap}; - use settled_reach_server::simulation::stance::{MovementProfile, PlayerMoveCooldown}; - use std::io::{BufReader, BufWriter}; - use std::net::{TcpListener, TcpStream}; - use std::thread; - - let root = content_root(); - if !root.join("content.yaml").exists() { - eprintln!("Skipping: content directory not found at {:?}", root); - return; - } - - let listener = TcpListener::bind("127.0.0.1:0").expect("bind listener"); - let server_addr = listener.local_addr().expect("get local addr"); - - // Server thread: full plugin stack with real content - let server_handle = thread::spawn(move || { - let bridge = TcpBridge::accept_on(listener).expect("accept connection"); - - let mut app = App::new(); - app.add_plugins(SimulationPlugin); - app.add_plugins(BridgePlugin); - app.add_plugins(settled_reach_server::knowledge::KnowledgePlugin); - app.add_plugins(settled_reach_server::npc::NpcPlugin); - app.insert_resource(ContentConfig { - content_root: root, - ..Default::default() - }); - app.add_plugins(ContentPlugin); - app.insert_resource(BridgeResource::new(bridge)); - app.insert_resource(WalkabilityMap::new(32, 32, 1)); - - // Spawn player with all required observer pipeline components - let profile = MovementProfile::smuggler(); - app.world_mut().spawn(( - PlayerCharacter, - TilePosition::new(16, 16, 0), - Facing::default(), - KnowledgeGraph::new(), - NearbyInteractionBuffer::default(), - MonologueState::default(), - MonologueBuffer::default(), - SprintAnomalyQueue::default(), - CognitiveDelay::default(), - ListeningFocus::new(TilePosition::new(16, 16, 0)), - profile, - profile.initial_stance(), - PlayerMoveCooldown::default(), - )); - - // Tick 10 times — any panic here means content has a runtime bug - for _ in 0..10 { - app.update(); - } - }); - - // Client: connect and receive 10 snapshots - let stream = TcpStream::connect(server_addr).expect("client connect"); - let mut reader = BufReader::new(stream.try_clone().expect("clone for reader")); - let mut writer = BufWriter::new(stream); - - let mut last_snapshot = None; - for tick in 0..10 { - let payload = read_framed(&mut reader) - .unwrap_or_else(|e| panic!("read error at tick {}: {}", tick, e)) - .unwrap_or_else(|| panic!("unexpected EOF at tick {}", tick)); - - let snapshot: ObserverSnapshot = rmp_serde::from_slice(&payload) - .unwrap_or_else(|e| panic!("deserialization error at tick {}: {}", tick, e)); - - last_snapshot = Some(snapshot); - - // Send empty input for next tick - let empty: Vec = vec![]; - let input_payload = rmp_serde::to_vec(&empty).expect("serialize empty input"); - if let Err(_) = write_framed(&mut writer, &input_payload) { - // Server may have shut down after tick 10 — that's fine - break; - } - } - - drop(reader); - drop(writer); - - // Server thread must not have panicked - server_handle - .join() - .expect("server thread panicked — content triggered a runtime error during tick processing"); - - // Validate final snapshot - let snapshot = last_snapshot.expect("should have received at least one snapshot"); - assert_eq!( - snapshot.version, PROTOCOL_VERSION, - "snapshot protocol version mismatch" - ); - // Content-spawned NPCs should be visible (they all spawn at 0,0,0 by default) - // The player is at 16,16 — content NPCs are far away but the player entity itself - // should always be in the snapshot - assert!( - !snapshot.entities.is_empty(), - "snapshot should contain at least the player entity" - ); -} +// Runtime validation test (boot + tick 10 + snapshot) moved to +// server/tests/content_runtime.rs per architectural review. diff --git a/server/tests/content_runtime.rs b/server/tests/content_runtime.rs new file mode 100644 index 000000000..743755400 --- /dev/null +++ b/server/tests/content_runtime.rs @@ -0,0 +1,150 @@ +//! Runtime validation: boot full plugin stack with real content, tick 10 +//! times over TCP, assert valid ObserverSnapshot (#489). +//! +//! Separated from content_loading.rs (structural loading tests) per +//! architectural review — TCP runtime tests have different failure modes +//! and timeout characteristics. + +use bevy_app::prelude::*; +use std::net::{TcpListener, TcpStream}; +use std::path::PathBuf; +use std::thread; +use std::time::Duration; + +use settled_reach_server::bridge::framing::{read_framed, write_framed}; +use settled_reach_server::bridge::tcp::TcpBridge; +use settled_reach_server::bridge::types::*; +use settled_reach_server::bridge::{BridgePlugin, BridgeResource}; +use settled_reach_server::content::{ContentConfig, ContentPlugin}; +use settled_reach_server::knowledge::registry::EntityRegistry; +use settled_reach_server::knowledge::KnowledgeGraph; +use settled_reach_server::perception::cognitive_delay::CognitiveDelay; +use settled_reach_server::perception::vision_cone::Facing; +use settled_reach_server::simulation::interaction::NearbyInteractionBuffer; +use settled_reach_server::simulation::listening::ListeningFocus; +use settled_reach_server::simulation::monologue::{ + MonologueBuffer, MonologueState, SprintAnomalyQueue, +}; +use settled_reach_server::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap}; +use settled_reach_server::simulation::stance::{MovementProfile, PlayerMoveCooldown}; +use settled_reach_server::simulation::SimulationPlugin; + +fn content_root() -> PathBuf { + let manifest_dir = env!("CARGO_MANIFEST_DIR"); + PathBuf::from(manifest_dir).join("../content") +} + +/// Smoke test for production content. Boots the full plugin stack with real +/// content over TCP, ticks 10 times, and asserts a valid ObserverSnapshot. +/// Catches runtime panics from broken entity references, missing components, +/// or content schema issues that pass YAML validation but fail at tick time. +#[test] +fn content_runtime_boot_tick_10_snapshot() { + use std::io::{BufReader, BufWriter}; + + let root = content_root(); + if !root.join("content.yaml").exists() { + eprintln!("Skipping: content directory not found at {:?}", root); + return; + } + + let listener = TcpListener::bind("127.0.0.1:0").expect("bind listener"); + let server_addr = listener.local_addr().expect("get local addr"); + + // Server thread: full plugin stack with real content + let server_handle = thread::spawn(move || { + let bridge = TcpBridge::accept_on(listener).expect("accept connection"); + + let mut app = App::new(); + app.add_plugins(SimulationPlugin); + app.add_plugins(BridgePlugin); + app.add_plugins(settled_reach_server::knowledge::KnowledgePlugin); + app.add_plugins(settled_reach_server::npc::NpcPlugin); + app.insert_resource(ContentConfig { + content_root: root, + ..Default::default() + }); + app.add_plugins(ContentPlugin); + app.insert_resource(BridgeResource::new(bridge)); + app.insert_resource(WalkabilityMap::new(32, 32, 1)); + + // Spawn player with all required observer pipeline components + let profile = MovementProfile::smuggler(); + let mut registry = EntityRegistry::new(0); + let player = app + .world_mut() + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing::default(), + KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), + MonologueState::default(), + MonologueBuffer::default(), + SprintAnomalyQueue::default(), + CognitiveDelay::default(), + ListeningFocus::new(TilePosition::new(16, 16, 0)), + profile, + profile.initial_stance(), + PlayerMoveCooldown::default(), + )) + .id(); + registry.register(player); + app.insert_resource(registry); + + // Tick 10 times — any panic here means content has a runtime bug + for _ in 0..10 { + app.update(); + } + }); + + // Client: connect with read timeout and receive 10 snapshots + let stream = TcpStream::connect(server_addr).expect("client connect"); + stream + .set_read_timeout(Some(Duration::from_secs(10))) + .expect("set read timeout"); + let mut reader = BufReader::new(stream.try_clone().expect("clone for reader")); + let mut writer = BufWriter::new(stream); + + let mut last_snapshot = None; + for tick in 0..10 { + let payload = read_framed(&mut reader) + .unwrap_or_else(|e| panic!("read error at tick {}: {}", tick, e)) + .unwrap_or_else(|| panic!("unexpected EOF at tick {}", tick)); + + let snapshot: ObserverSnapshot = rmp_serde::from_slice(&payload) + .unwrap_or_else(|e| panic!("deserialization error at tick {}: {}", tick, e)); + + last_snapshot = Some(snapshot); + + // Send empty input for next tick + let empty: Vec = vec![]; + let input_payload = rmp_serde::to_vec(&empty).expect("serialize empty input"); + if write_framed(&mut writer, &input_payload).is_err() { + // Server may have shut down after tick 10 — that's fine + break; + } + } + + drop(reader); + drop(writer); + + // Server thread must not have panicked + server_handle + .join() + .expect("server thread panicked — content triggered a runtime error during tick processing"); + + // Validate final snapshot + let snapshot = last_snapshot.expect("should have received at least one snapshot"); + assert_eq!( + snapshot.version, PROTOCOL_VERSION, + "snapshot protocol version mismatch" + ); + // Content-spawned NPCs should be visible (they all spawn at 0,0,0 by default) + // The player is at 16,16 — content NPCs are far away but the player entity itself + // should always be in the snapshot + assert!( + !snapshot.entities.is_empty(), + "snapshot should contain at least the player entity" + ); +} From aadfe64cfb3a81efc324d4368f6bdb8db3c7772f Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 18 Feb 2026 09:45:30 +0100 Subject: [PATCH 3/3] chore(meta): update changelog for PR #32 review fixes Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a27d5620..bd74bc179 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,21 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] +### Added +- `gauntlet` feature flag (default-on) — allows stripping Gauntlet test world from release builds with `--no-default-features` +- `EXPECTED_ENTITY_COUNT` and `RESET_PLATE_STABLE_IDS` constants — entity counts derived from StableId ranges instead of hardcoded values +- Debounce exact-boundary test for room reset (tick 9 rejected, tick 10 accepted) +- Reset plate StableId verification in `stable_id_ranges_match_spec` +- Runtime content test (`content_runtime.rs`) separated from structural loading tests + +### Changed +- Room reset API consolidated to `plan_reset` only — `execute_reset` removed (was a maintenance trap; production uses Commands via `plan_reset`) +- `room_at()` documented with z-range and corridor overlap assumptions +- TCP runtime test now has 10s read timeout and registers player in EntityRegistry + +### Removed +- Dead `RoomMember` component from reset.rs (defined but never used) + ### Added - Protocol v8: dialogue_response field decoding (DialogueResponseEvent with line_id, text, speaker_entity_id) from server #305/D-028 - Test client binary scaffolding (#480) — standalone crate at `tooling/test-client/` with CLI (--connect, --replay, --text, --json, --quiet, --golden, --ticks), exit codes (0/1/2), golden file JSON diff, JSONL replay loader