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" + ); +}