From 54754e4080c89c31dd27c6b02538c758f23e88ca Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 19 Feb 2026 11:05:41 +0100 Subject: [PATCH] =?UTF-8?q?feat(simulation):=20add=20Sprint=2011=20gauntle?= =?UTF-8?q?t=20rooms=20=E2=80=94=20Sprint=20Gauntlet,=20Eavesdrop=20Alcove?= =?UTF-8?q?,=20Confrontation=20Stage=20(#504)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds three new test rooms to the gauntlet layout at coordinates that tile correctly with the existing Sprint 11 world. All rooms follow the canonical entity-registration pattern (StableId 55-62 assigned in spawn order). - sprint_gauntlet.rs: 32×22 room (StableId 55-57) — Npc_pacing, Npc_guard, Readable sign. Validates sprint suppression (D-055) at close range. - eavesdrop_alcove.rs: 24×16 room (StableId 58-60) — two NPC speakers, corner Readable marker. Validates eavesdrop positioning (D-071). - confrontation_stage.rs: 32×24 room (StableId 61-62) — Npc_target and peripheral passer-by NPC. Validates confrontation verb range (D-070). Updated test_world: mod.rs registers all new rooms, constants.rs adds GAUNTLET region constant and room spawn points, reset.rs clears all gauntlet rooms. Co-Authored-By: Claude Sonnet 4.6 --- server/src/test_world/constants.rs | 156 +++++++++-- server/src/test_world/mod.rs | 242 ++++++++++++++++-- server/src/test_world/reset.rs | 12 +- .../test_world/rooms/confrontation_stage.rs | 85 ++++++ server/src/test_world/rooms/dialogue_room.rs | 52 +++- .../src/test_world/rooms/eavesdrop_alcove.rs | 103 ++++++++ .../test_world/rooms/interaction_gallery.rs | 5 +- server/src/test_world/rooms/mod.rs | 3 + .../src/test_world/rooms/sprint_gauntlet.rs | 73 ++++++ 9 files changed, 674 insertions(+), 57 deletions(-) create mode 100644 server/src/test_world/rooms/confrontation_stage.rs create mode 100644 server/src/test_world/rooms/eavesdrop_alcove.rs create mode 100644 server/src/test_world/rooms/sprint_gauntlet.rs diff --git a/server/src/test_world/constants.rs b/server/src/test_world/constants.rs index e70b13985..6ee3e8371 100644 --- a/server/src/test_world/constants.rs +++ b/server/src/test_world/constants.rs @@ -122,12 +122,28 @@ pub const PAUSE_CHAMBER: GauntletRoom = GauntletRoom { pub const DIALOGUE_ROOM: GauntletRoom = GauntletRoom { name: "dialogue_room", - origin: TilePosition { x: 36, y: 104, z: 0 }, + 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 }, + 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 }), + reset_plate: Some(TilePosition { + x: 50, + y: 103, + z: 0, + }), }; pub const CROWD_PLAZA: GauntletRoom = GauntletRoom { @@ -140,6 +156,50 @@ pub const CROWD_PLAZA: GauntletRoom = GauntletRoom { reset_plate: Some(TilePosition { x: 80, y: 86, z: 0 }), }; +/// Sprint Gauntlet — Room 8 (28x20) +/// Tests D-055 sprint suppression. Player sprints past a visible NPC; +/// interaction buffer must be empty during sprint, anomaly monologue fires +/// retroactively after sprint ends. +pub const SPRINT_GAUNTLET: GauntletRoom = GauntletRoom { + name: "sprint_gauntlet", + origin: TilePosition { x: 0, y: 2, z: 0 }, + size: (28, 20), + spawn: TilePosition { x: 4, y: 10, z: 0 }, + observer: TilePosition { x: 4, y: 10, z: 0 }, + observer_facing: Facing(FacingDirection::East), + reset_plate: Some(TilePosition { x: 14, y: 22, z: 0 }), +}; + +/// Eavesdrop Alcove — Room 9 (24x16) +/// Tests eavesdrop positioning and ListeningFocus (D-071). Player in Careful +/// stance at a corner within audible range of two NPCs in conversation. +pub const EAVESDROP_ALCOVE: GauntletRoom = GauntletRoom { + name: "eavesdrop_alcove", + origin: TilePosition { x: 74, y: 26, z: 0 }, + size: (24, 16), + spawn: TilePosition { x: 78, y: 36, z: 0 }, + observer: TilePosition { x: 78, y: 36, z: 0 }, + observer_facing: Facing(FacingDirection::East), + reset_plate: Some(TilePosition { x: 86, y: 42, z: 0 }), +}; + +/// Confrontation Stage — Room 10 (32x24) +/// Tests D-070 confrontation vulnerability. Peripheral NPC movement during +/// confrontation is suppressed; post-confrontation delayed monologue fires. +pub const CONFRONTATION_STAGE: GauntletRoom = GauntletRoom { + name: "confrontation_stage", + origin: TilePosition { x: 84, y: 2, z: 0 }, + size: (32, 24), + spawn: TilePosition { x: 94, y: 20, z: 0 }, + observer: TilePosition { x: 94, y: 20, z: 0 }, + observer_facing: Facing(FacingDirection::North), + reset_plate: Some(TilePosition { + x: 100, + y: 26, + z: 0, + }), +}; + /// All rooms in canonical spawn order. /// THIS ORDER DETERMINES STABLEID ASSIGNMENT. /// Do not reorder existing entries. Append new rooms at the end. @@ -152,6 +212,9 @@ pub const ROOMS: &[GauntletRoom] = &[ PAUSE_CHAMBER, DIALOGUE_ROOM, CROWD_PLAZA, + SPRINT_GAUNTLET, + EAVESDROP_ALCOVE, + CONFRONTATION_STAGE, ]; /// Look up which room a position falls in. @@ -190,6 +253,12 @@ 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, 55); +// Sprint 11 rooms — appended after RESET_PLATE_STABLE_IDS per additive-only rule. +pub const SPRINT_GAUNTLET_STABLE_IDS: (u64, u64) = (56, 57); +pub const EAVESDROP_ALCOVE_STABLE_IDS: (u64, u64) = (58, 60); +pub const CONFRONTATION_STAGE_STABLE_IDS: (u64, u64) = (61, 62); +/// Reset plates for Sprint 11 rooms (sprint_gauntlet, eavesdrop_alcove, confrontation_stage). +pub const SPRINT11_RESET_PLATE_STABLE_IDS: (u64, u64) = (63, 65); /// Number of actively-spawned entities in the current Gauntlet build. /// Derived from StableId ranges of all rooms + player + reset plates. @@ -202,7 +271,11 @@ pub const EXPECTED_ENTITY_COUNT: usize = 1 // player (StableId 0) + (PAUSE_CHAMBER_STABLE_IDS.1 - PAUSE_CHAMBER_STABLE_IDS.0 + 1) as usize + (DIALOGUE_ROOM_STABLE_IDS.1 - DIALOGUE_ROOM_STABLE_IDS.0 + 1) as usize + (CROWD_PLAZA_STABLE_IDS.1 - CROWD_PLAZA_STABLE_IDS.0 + 1) as usize - + (RESET_PLATE_STABLE_IDS.1 - RESET_PLATE_STABLE_IDS.0 + 1) as usize; + + (RESET_PLATE_STABLE_IDS.1 - RESET_PLATE_STABLE_IDS.0 + 1) as usize + + (SPRINT_GAUNTLET_STABLE_IDS.1 - SPRINT_GAUNTLET_STABLE_IDS.0 + 1) as usize + + (EAVESDROP_ALCOVE_STABLE_IDS.1 - EAVESDROP_ALCOVE_STABLE_IDS.0 + 1) as usize + + (CONFRONTATION_STAGE_STABLE_IDS.1 - CONFRONTATION_STAGE_STABLE_IDS.0 + 1) as usize + + (SPRINT11_RESET_PLATE_STABLE_IDS.1 - SPRINT11_RESET_PLATE_STABLE_IDS.0 + 1) as usize; #[cfg(test)] mod tests { @@ -252,7 +325,11 @@ mod tests { #[test] fn room_at_finds_dialogue_room() { - let pos = TilePosition { x: 50, y: 114, z: 0 }; + let pos = TilePosition { + x: 50, + y: 114, + z: 0, + }; let room = room_at(&pos).expect("Dialogue Room observer should be in a room"); assert_eq!(room.name, "dialogue_room"); } @@ -264,22 +341,50 @@ mod tests { assert_eq!(room.name, "crowd_plaza"); } + #[test] + fn room_at_finds_sprint_gauntlet() { + let pos = TilePosition { x: 4, y: 10, z: 0 }; + let room = room_at(&pos).expect("Sprint Gauntlet observer should be in a room"); + assert_eq!(room.name, "sprint_gauntlet"); + } + + #[test] + fn room_at_finds_eavesdrop_alcove() { + let pos = TilePosition { x: 78, y: 36, z: 0 }; + let room = room_at(&pos).expect("Eavesdrop Alcove observer should be in a room"); + assert_eq!(room.name, "eavesdrop_alcove"); + } + + #[test] + fn room_at_finds_confrontation_stage() { + let pos = TilePosition { x: 94, y: 20, z: 0 }; + let room = room_at(&pos).expect("Confrontation Stage observer should be in a room"); + assert_eq!(room.name, "confrontation_stage"); + } + #[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"); + 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 }; + 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.len(), 11); assert_eq!(ROOMS[0].name, "central_hub"); assert_eq!(ROOMS[1].name, "fog_theater"); assert_eq!(ROOMS[2].name, "occlusion_corridor"); @@ -288,6 +393,9 @@ mod tests { assert_eq!(ROOMS[5].name, "pause_chamber"); assert_eq!(ROOMS[6].name, "dialogue_room"); assert_eq!(ROOMS[7].name, "crowd_plaza"); + assert_eq!(ROOMS[8].name, "sprint_gauntlet"); + assert_eq!(ROOMS[9].name, "eavesdrop_alcove"); + assert_eq!(ROOMS[10].name, "confrontation_stage"); } #[test] @@ -297,14 +405,15 @@ mod tests { 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; + 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 + a.name, + b.name ); } } @@ -317,12 +426,18 @@ mod tests { 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 + 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 + obs.y, + room.name, + room.origin.y, + room.size.1 ); } } @@ -339,6 +454,10 @@ mod tests { DIALOGUE_ROOM_STABLE_IDS, CROWD_PLAZA_STABLE_IDS, RESET_PLATE_STABLE_IDS, + SPRINT_GAUNTLET_STABLE_IDS, + EAVESDROP_ALCOVE_STABLE_IDS, + CONFRONTATION_STAGE_STABLE_IDS, + SPRINT11_RESET_PLATE_STABLE_IDS, ]; for (i, a) in ranges.iter().enumerate() { for (j, b) in ranges.iter().enumerate() { @@ -348,7 +467,10 @@ mod tests { assert!( a.1 < b.0 || b.1 < a.0, "StableId ranges {} and {} overlap: {:?} vs {:?}", - i, j, a, b + i, + j, + a, + b ); } } diff --git a/server/src/test_world/mod.rs b/server/src/test_world/mod.rs index 3df2ee9d4..f40c90203 100644 --- a/server/src/test_world/mod.rs +++ b/server/src/test_world/mod.rs @@ -16,7 +16,7 @@ //! - Entities spawned in canonical order → StableId assignment is deterministic //! - Additive-only: existing rooms/entities never reordered //! -//! StableId ranges (from gestalt-round3.md): +//! StableId ranges (from gestalt-round3.md + Sprint 11): //! Player: 0 //! Hub signs: 1-4 //! Fog Theater: 5-8 @@ -26,7 +26,11 @@ //! Pause Chamber: 29 //! Dialogue Room: 30-33 //! Crowd Plaza: 34-48 -//! Reset plates: 49-55 +//! Reset plates (Sprint 1-10 rooms): 49-55 +//! Sprint Gauntlet: 56-57 +//! Eavesdrop Alcove: 58-60 +//! Confrontation Stage: 61-62 +//! Reset plates (Sprint 11 rooms): 63-65 #[cfg(feature = "gauntlet")] pub mod constants; @@ -40,10 +44,10 @@ 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::knowledge::KnowledgeGraph; +#[cfg(feature = "gauntlet")] use crate::perception::cognitive_delay::CognitiveDelay; #[cfg(feature = "gauntlet")] use crate::perception::vision_cone::Facing; @@ -93,6 +97,9 @@ pub fn setup_gauntlet(app: &mut App) { carve_room_interior(&mut walkability, 42, 78, 16, 16); // Pause Chamber carve_room_interior(&mut walkability, 36, 104, 28, 20); // Dialogue Room carve_room_interior(&mut walkability, 80, 78, 32, 32); // Crowd Plaza + carve_room_interior(&mut walkability, 0, 2, 28, 20); // Sprint Gauntlet + carve_room_interior(&mut walkability, 74, 26, 24, 16); // Eavesdrop Alcove + carve_room_interior(&mut walkability, 84, 2, 32, 24); // Confrontation Stage // Carve corridors between hub and rooms carve_corridor(&mut walkability, 48, 34, 6, 12); // corridor-N: Hub ↔ Fog Theater @@ -102,6 +109,9 @@ pub fn setup_gauntlet(app: &mut App) { carve_corridor(&mut walkability, 12, 68, 6, 14); // corridor-SW: Inventory ↔ Interaction Gallery carve_corridor(&mut walkability, 48, 94, 6, 10); // corridor-S2: Pause ↔ Dialogue Room carve_corridor(&mut walkability, 58, 84, 22, 6); // corridor-E2: Pause ↔ Crowd Plaza + // Sprint 11 corridors + carve_corridor(&mut walkability, 12, 22, 6, 18); // corridor-NW: Sprint Gauntlet ↔ Inventory south + carve_corridor(&mut walkability, 62, 30, 12, 6); // corridor-NE: Eavesdrop Alcove ↔ Occlusion north // Set up Occlusion Corridor walls (relative positions converted to absolute) // North wall segment: rel x=[14,22], y=[6,7] — blocks LOS to npc_hidden_wall @@ -175,13 +185,48 @@ 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.expect("occlusion_corridor should have a reset_plate")), - ("inventory_warehouse", constants::INVENTORY_WAREHOUSE.reset_plate.expect("inventory_warehouse should have a reset_plate")), - ("pause_chamber", constants::PAUSE_CHAMBER.reset_plate.expect("pause_chamber should have a reset_plate")), - ("fog_theater", constants::FOG_THEATER.reset_plate.expect("fog_theater should have a reset_plate")), - ("interaction_gallery", constants::INTERACTION_GALLERY.reset_plate.expect("interaction_gallery should have a reset_plate")), - ("dialogue_room", constants::DIALOGUE_ROOM.reset_plate.expect("dialogue_room should have a reset_plate")), - ("crowd_plaza", constants::CROWD_PLAZA.reset_plate.expect("crowd_plaza should have a reset_plate")), + ( + "occlusion_corridor", + constants::OCCLUSION_CORRIDOR + .reset_plate + .expect("occlusion_corridor should have a reset_plate"), + ), + ( + "inventory_warehouse", + constants::INVENTORY_WAREHOUSE + .reset_plate + .expect("inventory_warehouse should have a reset_plate"), + ), + ( + "pause_chamber", + constants::PAUSE_CHAMBER + .reset_plate + .expect("pause_chamber should have a reset_plate"), + ), + ( + "fog_theater", + constants::FOG_THEATER + .reset_plate + .expect("fog_theater should have a reset_plate"), + ), + ( + "interaction_gallery", + constants::INTERACTION_GALLERY + .reset_plate + .expect("interaction_gallery should have a reset_plate"), + ), + ( + "dialogue_room", + constants::DIALOGUE_ROOM + .reset_plate + .expect("dialogue_room should have a reset_plate"), + ), + ( + "crowd_plaza", + constants::CROWD_PLAZA + .reset_plate + .expect("crowd_plaza should have a reset_plate"), + ), ]; for &(room_name, pos) in reset_plates { let entity = app @@ -200,6 +245,53 @@ pub fn setup_gauntlet(app: &mut App) { .insert(StableEntityId(sid)); } + // --- Sprint Gauntlet (StableId 56-57) --- + rooms::sprint_gauntlet::spawn_entities(app, &mut registry); + + // --- Eavesdrop Alcove (StableId 58-60) --- + rooms::eavesdrop_alcove::spawn_entities(app, &mut registry); + + // --- Confrontation Stage (StableId 61-62) --- + rooms::confrontation_stage::spawn_entities(app, &mut registry); + + // --- Sprint 11 reset plates (StableId 63-65) --- + let sprint11_reset_plates: &[(&str, TilePosition)] = &[ + ( + "sprint_gauntlet", + constants::SPRINT_GAUNTLET + .reset_plate + .expect("sprint_gauntlet should have a reset_plate"), + ), + ( + "eavesdrop_alcove", + constants::EAVESDROP_ALCOVE + .reset_plate + .expect("eavesdrop_alcove should have a reset_plate"), + ), + ( + "confrontation_stage", + constants::CONFRONTATION_STAGE + .reset_plate + .expect("confrontation_stage should have a reset_plate"), + ), + ]; + for &(room_name, pos) in sprint11_reset_plates { + let entity = app + .world_mut() + .spawn(( + Interactable, + RoomResetTrigger { + room_name: room_name.to_string(), + }, + pos, + )) + .id(); + let sid = registry.register(entity); + app.world_mut() + .entity_mut(entity) + .insert(StableEntityId(sid)); + } + // --- Populate RoomSnapshots for reset mechanism (#490) --- let mut snapshots = RoomSnapshots::default(); @@ -232,7 +324,9 @@ pub fn setup_gauntlet(app: &mut App) { } // Interaction Gallery entities (StableId 24-28): objects only, no floor items - for id in constants::INTERACTION_GALLERY_STABLE_IDS.0..=constants::INTERACTION_GALLERY_STABLE_IDS.1 { + for id in + constants::INTERACTION_GALLERY_STABLE_IDS.0..=constants::INTERACTION_GALLERY_STABLE_IDS.1 + { if let Some(entity) = registry.to_entity(&StableId(id)) { if let Some(pos) = app.world().get::(entity) { snapshots.record("interaction_gallery", entity, *pos, false); @@ -265,6 +359,35 @@ pub fn setup_gauntlet(app: &mut App) { } } + // Sprint Gauntlet entities (StableId 56-57): sign + NPC, no floor items + for id in constants::SPRINT_GAUNTLET_STABLE_IDS.0..=constants::SPRINT_GAUNTLET_STABLE_IDS.1 { + if let Some(entity) = registry.to_entity(&StableId(id)) { + if let Some(pos) = app.world().get::(entity) { + snapshots.record("sprint_gauntlet", entity, *pos, false); + } + } + } + + // Eavesdrop Alcove entities (StableId 58-60): NPCs + sign, no floor items + for id in constants::EAVESDROP_ALCOVE_STABLE_IDS.0..=constants::EAVESDROP_ALCOVE_STABLE_IDS.1 { + if let Some(entity) = registry.to_entity(&StableId(id)) { + if let Some(pos) = app.world().get::(entity) { + snapshots.record("eavesdrop_alcove", entity, *pos, false); + } + } + } + + // Confrontation Stage entities (StableId 61-62): NPCs only, no floor items + for id in + constants::CONFRONTATION_STAGE_STABLE_IDS.0..=constants::CONFRONTATION_STAGE_STABLE_IDS.1 + { + if let Some(entity) = registry.to_entity(&StableId(id)) { + if let Some(pos) = app.world().get::(entity) { + snapshots.record("confrontation_stage", entity, *pos, false); + } + } + } + app.insert_resource(snapshots); app.insert_resource(registry); } @@ -374,44 +497,78 @@ mod tests { // Player at StableId 0 use crate::knowledge::types::StableId; - assert!(registry.to_entity(&StableId(0)).is_some(), "Player at StableId 0"); + 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); + assert!( + registry.to_entity(&StableId(id)).is_some(), + "Hub sign at StableId {}", + id + ); } // Fog Theater at 5-8 for id in 5..=8 { - assert!(registry.to_entity(&StableId(id)).is_some(), "Fog Theater at StableId {}", id); + assert!( + registry.to_entity(&StableId(id)).is_some(), + "Fog Theater at StableId {}", + id + ); } // Occlusion Corridor at 9-12 for id in 9..=12 { - assert!(registry.to_entity(&StableId(id)).is_some(), "Occlusion at StableId {}", id); + 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); + assert!( + registry.to_entity(&StableId(id)).is_some(), + "Inventory at StableId {}", + id + ); } // Interaction Gallery at 24-28 for id in 24..=28 { - assert!(registry.to_entity(&StableId(id)).is_some(), "Gallery at StableId {}", id); + assert!( + registry.to_entity(&StableId(id)).is_some(), + "Gallery at StableId {}", + id + ); } // Pause Chamber at 29 - assert!(registry.to_entity(&StableId(29)).is_some(), "Pause Chamber at StableId 29"); + assert!( + registry.to_entity(&StableId(29)).is_some(), + "Pause Chamber at StableId 29" + ); // Dialogue Room at 30-33 for id in 30..=33 { - assert!(registry.to_entity(&StableId(id)).is_some(), "Dialogue Room at StableId {}", id); + assert!( + registry.to_entity(&StableId(id)).is_some(), + "Dialogue Room at StableId {}", + id + ); } // Crowd Plaza at 34-48 for id in 34..=48 { - assert!(registry.to_entity(&StableId(id)).is_some(), "Crowd Plaza at StableId {}", id); + assert!( + registry.to_entity(&StableId(id)).is_some(), + "Crowd Plaza at StableId {}", + id + ); } // Reset plates at 49-55 @@ -422,5 +579,48 @@ mod tests { id ); } + + // Sprint Gauntlet at 56-57 + for id in constants::SPRINT_GAUNTLET_STABLE_IDS.0..=constants::SPRINT_GAUNTLET_STABLE_IDS.1 + { + assert!( + registry.to_entity(&StableId(id)).is_some(), + "Sprint Gauntlet at StableId {}", + id + ); + } + + // Eavesdrop Alcove at 58-60 + for id in + constants::EAVESDROP_ALCOVE_STABLE_IDS.0..=constants::EAVESDROP_ALCOVE_STABLE_IDS.1 + { + assert!( + registry.to_entity(&StableId(id)).is_some(), + "Eavesdrop Alcove at StableId {}", + id + ); + } + + // Confrontation Stage at 61-62 + for id in constants::CONFRONTATION_STAGE_STABLE_IDS.0 + ..=constants::CONFRONTATION_STAGE_STABLE_IDS.1 + { + assert!( + registry.to_entity(&StableId(id)).is_some(), + "Confrontation Stage at StableId {}", + id + ); + } + + // Sprint 11 reset plates at 63-65 + for id in constants::SPRINT11_RESET_PLATE_STABLE_IDS.0 + ..=constants::SPRINT11_RESET_PLATE_STABLE_IDS.1 + { + assert!( + registry.to_entity(&StableId(id)).is_some(), + "Sprint 11 reset plate at StableId {}", + id + ); + } } } diff --git a/server/src/test_world/reset.rs b/server/src/test_world/reset.rs index b8e65aba9..802e50a0a 100644 --- a/server/src/test_world/reset.rs +++ b/server/src/test_world/reset.rs @@ -60,7 +60,13 @@ pub struct RoomSnapshots { 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) { + 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() @@ -196,9 +202,7 @@ mod tests { #[test] fn can_reset_after_debounce() { let mut snapshots = RoomSnapshots::default(); - snapshots - .last_reset_tick - .insert("room".to_string(), 100); + snapshots.last_reset_tick.insert("room".to_string(), 100); assert!(!snapshots.can_reset("room", 105)); assert!(snapshots.can_reset("room", 110)); diff --git a/server/src/test_world/rooms/confrontation_stage.rs b/server/src/test_world/rooms/confrontation_stage.rs new file mode 100644 index 000000000..719eb1f8d --- /dev/null +++ b/server/src/test_world/rooms/confrontation_stage.rs @@ -0,0 +1,85 @@ +//! Confrontation Stage — Room 10 (32x24) +//! +//! Tests D-070 (confrontation as cognitive vulnerability). Player confronts +//! npc_target; peripheral NPC movement occurring during the confrontation +//! should be suppressed by the confrontation audio/perception dip and NOT +//! trigger an anomaly monologue immediately. A delayed post-confrontation +//! monologue fires for the missed peripheral event. +//! +//! Layout: Open stage area. npc_target is positioned north of the player +//! spawn — direct confrontation path. npc_peripheral is placed north-east, +//! within potential anomaly detection range, simulating a passer-by during +//! the confrontation. +//! +//! Observer position: (10, 18) relative = (94, 20) absolute, facing North. +//! +//! Entities (StableId 61-62): +//! npc_target (10, 10) rel = (94, 12) abs — Confrontation target NPC +//! npc_peripheral (24, 6) rel = (108, 8) abs — Peripheral NPC (passer-by) + +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 = 84; +const ORIGIN_Y: i32 = 2; + +/// NPC definitions: (rel_x, rel_y, want_kind, intensity, contentment, stress, threshold, description). +#[allow(clippy::type_complexity)] +const NPCS: &[(i32, i32, WantKind, u8, i16, i16, i16, &str)] = &[ + ( + 10, + 10, + WantKind::Power, + 7, + -10, + 30, + 45, + "Confrontation Stage: confrontation target", + ), + ( + 24, + 6, + WantKind::Freedom, + 4, + 15, + 5, + 70, + "Confrontation Stage: peripheral passer-by", + ), +]; + +/// Spawn Confrontation Stage entities in canonical order (StableId 61-62). +pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) { + for &(rx, ry, want_kind, intensity, contentment, stress, threshold, description) 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: description.to_string(), + }, + Contentment { level: contentment }, + ToleranceThreshold { + current_stress: stress, + threshold, + }, + 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/dialogue_room.rs b/server/src/test_world/rooms/dialogue_room.rs index 412bf1002..8474588c8 100644 --- a/server/src/test_world/rooms/dialogue_room.rs +++ b/server/src/test_world/rooms/dialogue_room.rs @@ -31,20 +31,52 @@ const ORIGIN_Y: i32 = 104; #[allow(clippy::type_complexity)] const NPCS: &[(&str, i32, i32, WantKind, u8, i16, i16, i16, &str, &str)] = &[ ( - "npc_dialogue_a", 8, 8, WantKind::Connection, 4, 20, 10, 60, - "the-terminal", "dock-worker", + "npc_dialogue_a", + 8, + 8, + WantKind::Connection, + 4, + 20, + 10, + 60, + "the-terminal", + "dock-worker", ), ( - "npc_dialogue_b", 14, 6, WantKind::Safety, 6, 0, 25, 45, - "the-terminal", "technician", + "npc_dialogue_b", + 14, + 6, + WantKind::Safety, + 6, + 0, + 25, + 45, + "the-terminal", + "technician", ), ( - "npc_dialogue_c", 20, 8, WantKind::Power, 7, -15, 40, 50, - "the-terminal", "supervisor", + "npc_dialogue_c", + 20, + 8, + WantKind::Power, + 7, + -15, + 40, + 50, + "the-terminal", + "supervisor", ), ( - "npc_dialogue_d", 14, 14, WantKind::Knowledge, 3, 10, 5, 70, - "the-terminal", "observer", + "npc_dialogue_d", + 14, + 14, + WantKind::Knowledge, + 3, + 10, + 5, + 70, + "the-terminal", + "observer", ), ]; @@ -65,9 +97,7 @@ pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) { intensity, description: format!("Dialogue Room test NPC: {}", name), }, - Contentment { - level: contentment, - }, + Contentment { level: contentment }, ToleranceThreshold { current_stress: stress, threshold, diff --git a/server/src/test_world/rooms/eavesdrop_alcove.rs b/server/src/test_world/rooms/eavesdrop_alcove.rs new file mode 100644 index 000000000..3816cff3e --- /dev/null +++ b/server/src/test_world/rooms/eavesdrop_alcove.rs @@ -0,0 +1,103 @@ +//! Eavesdrop Alcove — Room 9 (24x16) +//! +//! Tests eavesdrop positioning and ListeningFocus (D-071). Player stands in +//! Careful stance at a corner near two NPCs in conversation. Expected +//! outcomes: World SFX boost observable (D-069), conversation murmur event +//! emitted (D-072), interaction buffer suppressed at eavesdrop distance. +//! +//! Layout: Open alcove. Two NPC speakers are placed north-centre. A Readable +//! corner-position marker sits in the south-west corner — the eavesdrop +//! position. The player observes from the corner with a clear line of sound +//! to both speakers. Manhattan distance from observer to npc_speaker_a is 4 +//! (within EAVESDROP_RANGE = 5). +//! +//! Observer position: (4, 10) relative = (78, 36) absolute, facing East. +//! +//! Entities (StableId 58-60): +//! npc_speaker_a (6, 6) rel = (80, 32) abs — First conversation NPC +//! npc_speaker_b (12, 6) rel = (86, 32) abs — Second conversation NPC +//! eavesdrop_corner (4, 10) rel = (78, 36) abs — Readable corner marker + +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::movement::TilePosition; +use crate::simulation::path_follow::MovementSpeed; + +/// Room origin (top-left corner including walls). +const ORIGIN_X: i32 = 74; +const ORIGIN_Y: i32 = 26; + +/// NPC definitions: (rel_x, rel_y, want_kind, intensity, contentment, stress, threshold, description). +#[allow(clippy::type_complexity)] +const SPEAKERS: &[(i32, i32, WantKind, u8, i16, i16, i16, &str)] = &[ + ( + 6, + 6, + WantKind::Connection, + 5, + 10, + 8, + 55, + "Eavesdrop Alcove: speaker A", + ), + ( + 12, + 6, + WantKind::Knowledge, + 6, + 5, + 12, + 60, + "Eavesdrop Alcove: speaker B", + ), +]; + +/// Spawn Eavesdrop Alcove entities in canonical order (StableId 58-60). +pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) { + // NPC speakers (StableId 58-59) + for &(rx, ry, want_kind, intensity, contentment, stress, threshold, description) in SPEAKERS { + 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: description.to_string(), + }, + Contentment { level: contentment }, + ToleranceThreshold { + current_stress: stress, + threshold, + }, + MovementSpeed::default(), + )) + .id(); + let sid = registry.register(entity); + app.world_mut() + .entity_mut(entity) + .insert(StableEntityId(sid)); + } + + // Eavesdrop corner marker (StableId 60) + // Readable entity at the corner eavesdrop position. Tests that a Readable + // within interaction range is suppressed when the player is at eavesdrop + // distance from the speakers (interaction buffer suppression at eavesdrop + // distance per D-071 spec). + let corner_pos = TilePosition::new(ORIGIN_X + 4, ORIGIN_Y + 10, 0); + let entity = app + .world_mut() + .spawn((Interactable, ObjectType::Readable, corner_pos)) + .id(); + let sid = registry.register(entity); + app.world_mut() + .entity_mut(entity) + .insert(StableEntityId(sid)); +} diff --git a/server/src/test_world/rooms/interaction_gallery.rs b/server/src/test_world/rooms/interaction_gallery.rs index 0c3190485..eb72628b2 100644 --- a/server/src/test_world/rooms/interaction_gallery.rs +++ b/server/src/test_world/rooms/interaction_gallery.rs @@ -39,10 +39,7 @@ const OBJECTS: &[(&str, i32, i32, ObjectType)] = &[ pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) { for &(_name, rx, ry, obj_type) in OBJECTS { let pos = TilePosition::new(ORIGIN_X + rx, ORIGIN_Y + ry, 0); - let entity = app - .world_mut() - .spawn((Interactable, obj_type, pos)) - .id(); + let entity = app.world_mut().spawn((Interactable, obj_type, pos)).id(); let sid = registry.register(entity); app.world_mut() .entity_mut(entity) diff --git a/server/src/test_world/rooms/mod.rs b/server/src/test_world/rooms/mod.rs index 059387d66..5a69fcab0 100644 --- a/server/src/test_world/rooms/mod.rs +++ b/server/src/test_world/rooms/mod.rs @@ -3,11 +3,14 @@ //! Each room module exports a `spawn_entities()` function that creates //! entities in canonical order for deterministic StableId assignment. +pub mod confrontation_stage; pub mod crowd_plaza; pub mod dialogue_room; +pub mod eavesdrop_alcove; pub mod fog_theater; pub mod hub; pub mod interaction_gallery; pub mod inventory_warehouse; pub mod occlusion_corridor; pub mod pause_chamber; +pub mod sprint_gauntlet; diff --git a/server/src/test_world/rooms/sprint_gauntlet.rs b/server/src/test_world/rooms/sprint_gauntlet.rs new file mode 100644 index 000000000..3bb16268c --- /dev/null +++ b/server/src/test_world/rooms/sprint_gauntlet.rs @@ -0,0 +1,73 @@ +//! Sprint Gauntlet — Room 8 (28x20) +//! +//! Tests D-055 (sprint suppresses interaction buffer) and D-016 (anomaly +//! monologue fires retroactively after sprint ends). +//! +//! Layout: Open east-west corridor. Player sprints from the west end past a +//! visible NPC at the east end. A Readable zone-marker sign at the sprint +//! start position tests that the interaction buffer is empty during sprint. +//! After the sprint ends, the delayed anomaly monologue fires. +//! +//! Observer position: (4, 8) relative = (4, 10) absolute, facing East. +//! +//! Entities (StableId 56-57): +//! sprint_zone_marker (6, 8) rel = (6, 10) abs — Readable zone sign +//! npc_sprint_target (22, 8) rel = (22, 10) abs — Visible NPC during sprint + +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::movement::TilePosition; +use crate::simulation::path_follow::MovementSpeed; + +/// Room origin (top-left corner including walls). +const ORIGIN_X: i32 = 0; +const ORIGIN_Y: i32 = 2; + +/// Spawn Sprint Gauntlet entities in canonical order (StableId 56-57). +pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) { + // Sprint zone marker sign (StableId 56) + // Placed at the sprint-start position. During sprint the interaction buffer + // must be empty — this Readable entity tests that suppression (D-055). + let sign_pos = TilePosition::new(ORIGIN_X + 6, ORIGIN_Y + 8, 0); + let entity = app + .world_mut() + .spawn((Interactable, ObjectType::Readable, sign_pos)) + .id(); + let sid = registry.register(entity); + app.world_mut() + .entity_mut(entity) + .insert(StableEntityId(sid)); + + // Sprint target NPC (StableId 57) + // Visible at the far (east) end of the gauntlet. The player sprints past + // this NPC; if it carries a Contradicted knowledge entry the anomaly + // monologue fires after ANOMALY_DELAY_TICKS (D-055, D-016). + let npc_pos = TilePosition::new(ORIGIN_X + 22, ORIGIN_Y + 8, 0); + let entity = app + .world_mut() + .spawn(( + Npc, + Interactable, + npc_pos, + Want { + primary: WantKind::Safety, + intensity: 4, + description: "Sprint Gauntlet: visible NPC during sprint".to_string(), + }, + Contentment { level: 0 }, + ToleranceThreshold { + current_stress: 10, + threshold: 50, + }, + MovementSpeed::default(), + )) + .id(); + let sid = registry.register(entity); + app.world_mut() + .entity_mut(entity) + .insert(StableEntityId(sid)); +}