refactor(simulation): address PR #32 review — 14 items from Hoshe + Tyre

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 <noreply@anthropic.com>
This commit is contained in:
2026-02-18 09:45:17 +01:00
co-authored by Claude Opus 4.6
parent e66352e0ea
commit 09eb6d7582
8 changed files with 281 additions and 251 deletions
+4
View File
@@ -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"
+3
View File
@@ -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;
+6
View File
@@ -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);
}
+19
View File
@@ -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() {
+48 -8
View File
@@ -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::<EntityRegistry>();
// 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
);
}
}
}
+48 -115
View File
@@ -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<usize> {
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::<CarriedBy>();
entity_mut.remove::<InventorySlot>();
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::<TilePosition>(entity).unwrap() = TilePosition::new(50, 50, 0);
assert_eq!(world.get::<TilePosition>(entity).unwrap().x, 50);
// Reset
let restored = snapshots.execute_reset("test_room", 0, &mut world);
assert_eq!(restored, Some(1));
assert_eq!(world.get::<TilePosition>(entity).unwrap().x, 10);
assert_eq!(world.get::<TilePosition>(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::<crate::knowledge::EntityRegistry>();
// 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::<TilePosition>();
world
.entity_mut(item)
.insert((CarriedBy(crate::knowledge::types::StableId(0)), InventorySlot(0)));
assert!(world.get::<TilePosition>(item).is_none());
assert!(world.get::<CarriedBy>(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::<TilePosition>(item).unwrap(),
&TilePosition::new(5, 5, 0)
);
assert!(world.get::<CarriedBy>(item).is_none());
assert!(world.get::<InventorySlot>(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]
+3 -128
View File
@@ -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<PlayerInput> = 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.
+150
View File
@@ -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<PlayerInput> = 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"
);
}