Merge remote-tracking branch 'origin/server'

This commit is contained in:
2026-02-18 10:23:33 +01:00
21 changed files with 7949 additions and 23 deletions
+13
View File
@@ -12,6 +12,19 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
### Added
- whatsinagame starter kit — reusable multi-agent team bootstrap for any project (3-tier profiles, 18 skills, 16 agent archetypes, stakeholder personas, ticketing DB, decision tracking)
- Domain-action naming convention for skills documented in create-skill guide
- `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)
- 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
- Snapshot text renderer (#481) — `format_snapshot_text(&ObserverSnapshot)` pub-exported from server crate, entity labels as kind:entity_id sorted by distance, room name stub, 10 unit tests
+20
View File
@@ -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"
+7
View File
@@ -16,3 +16,10 @@ pathfinding = "4.11"
thiserror = "2"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
[features]
default = ["gauntlet"]
gauntlet = []
[dev-dependencies]
serde_json = "1"
+37
View File
@@ -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.
+4
View File
@@ -9,3 +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;
+12 -2
View File
@@ -114,8 +114,18 @@ 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 {
#[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);
}
tracing::info!(
"Simulation initialized (seed={}, test_mode={})",
+294
View File
@@ -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<ResMut<RoomSnapshots>>,
) {
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,
&registry,
&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<ResMut<RoomSnapshots>>,
target_entity_id: Option<u64>,
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::<CarriedBy>()
.remove::<InventorySlot>()
.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::<crate::knowledge::EntityRegistry>();
// Player
let player = world
.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)))
.id();
let player_sid = world
.resource_mut::<crate::knowledge::EntityRegistry>()
.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::<crate::knowledge::EntityRegistry>()
.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::<crate::knowledge::EntityRegistry>()
.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::<InputQueue>().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::<TilePosition>(item).is_none(),
"Item should be picked up"
);
assert_eq!(world.get::<CarriedBy>(item).unwrap().0, player_sid);
// Step 2: Reset via verb
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 1,
action: PlayerAction::Interact {
target_entity_id: Some(plate_sid.0),
verb: Some("Reset".into()),
},
});
world.resource_mut::<SimulationTime>().tick = 1;
schedule.run(&mut world);
// Item should be back on the ground at original position
let pos = world
.get::<TilePosition>(item)
.expect("Item should be restored to ground");
assert_eq!(
*pos,
TilePosition::new(5, 4, 0),
"Item at original position"
);
assert!(
world.get::<CarriedBy>(item).is_none(),
"CarriedBy removed after reset"
);
assert!(
world.get::<InventorySlot>(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::<crate::knowledge::EntityRegistry>();
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::<crate::knowledge::EntityRegistry>()
.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::<crate::knowledge::EntityRegistry>()
.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::<InputQueue>().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::<TilePosition>(npc).unwrap() = TilePosition::new(20, 20, 0);
// Second reset at tick 5 — should be debounced (< 10 ticks)
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 5,
action: PlayerAction::Interact {
target_entity_id: Some(plate_sid.0),
verb: Some("Reset".into()),
},
});
world.resource_mut::<SimulationTime>().tick = 5;
schedule.run(&mut world);
// NPC should still be at moved position (reset was debounced)
assert_eq!(
world.get::<TilePosition>(npc).unwrap().x,
20,
"NPC not reset — debounced"
);
// Third reset at tick 10 — should succeed
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 10,
action: PlayerAction::Interact {
target_entity_id: Some(plate_sid.0),
verb: Some("Reset".into()),
},
});
world.resource_mut::<SimulationTime>().tick = 10;
schedule.run(&mut world);
// NPC should be back at original position
assert_eq!(
world.get::<TilePosition>(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::<crate::knowledge::EntityRegistry>();
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::<crate::knowledge::EntityRegistry>()
.register(plate);
// No RoomSnapshots resource inserted — should be gracefully handled
world.resource_mut::<InputQueue>().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
}
}
+325
View File
@@ -0,0 +1,325 @@
//! 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<TilePosition>,
}
/// 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.
///
/// 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
&& 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);
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 {
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,
RESET_PLATE_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
);
}
}
}
}
+366
View File
@@ -0,0 +1,366 @@
//! Gauntlet test world — purpose-built rooms for systematic QA testing.
//!
//! NOT production content. This module provides deterministic room layouts
//! with precise entity placement for golden file testing, regression testing,
//! and manual QA sessions. Loaded instead of content/ when the server runs
//! the Gauntlet map.
//!
//! Module structure:
//! - `reset` — always compiled (used by simulation::input in production)
//! - `constants`, `rooms`, `setup_gauntlet` — gated behind "gauntlet" feature
//! (default-on; strip with --no-default-features for release builds)
//!
//! Architecture (per workshop-outcomes.md Section 4):
//! - Rooms are defined as Rust builder functions (hybrid YAML + Rust inject)
//! - WalkabilityMap covers the full Gauntlet bounds (0-116 x 0-124)
//! - Entities spawned in canonical order → StableId assignment is deterministic
//! - Additive-only: existing rooms/entities never reordered
//!
//! StableId ranges (from gestalt-round3.md):
//! 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)
#[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.
///
/// Creates the full WalkabilityMap (blocked by default), carves room
/// interiors and corridors, spawns the player and all room entities
/// in canonical StableId order.
///
/// NOTE: This bypasses the normal startup system scheduler — entities are
/// spawned directly into the World rather than via startup systems. This
/// is intentional for deterministic test setups but should be revisited
/// if Gauntlet is ever served by the production startup pipeline.
#[cfg(feature = "gauntlet")]
pub fn setup_gauntlet(app: &mut App) {
// Start with a fully blocked map, then carve rooms and corridors.
let mut walkability = WalkabilityMap::new_blocked(MAP_WIDTH, MAP_HEIGHT, 1);
// Carve room interiors (2-tile-thick walls → interior starts 2 tiles in)
carve_room_interior(&mut walkability, 38, 46, 24, 24); // Hub
carve_room_interior(&mut walkability, 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.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
.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::<TilePosition>(entity) {
snapshots.record("occlusion_corridor", entity, *pos, false);
}
}
}
// Inventory Warehouse entities (StableId 13-23): crates are floor items, NPC is not
for id in 13..=23 {
if let Some(entity) = registry.to_entity(&StableId(id)) {
if let Some(pos) = app.world().get::<TilePosition>(entity) {
let is_floor_item = app.world().get::<ItemName>(entity).is_some();
snapshots.record("inventory_warehouse", entity, *pos, is_floor_item);
}
}
}
// Pause Chamber entity (StableId 29): NPC only
if let Some(entity) = registry.to_entity(&StableId(29)) {
if let Some(pos) = app.world().get::<TilePosition>(entity) {
snapshots.record("pause_chamber", entity, *pos, false);
}
}
app.insert_resource(snapshots);
app.insert_resource(registry);
}
#[cfg(feature = "gauntlet")]
/// Carve the walkable interior of a room.
/// Room has 2-tile-thick walls; interior starts 2 tiles inside each boundary.
fn carve_room_interior(wm: &mut WalkabilityMap, ox: i32, oy: i32, w: i32, h: i32) {
for y in (oy + 2)..(oy + h - 2) {
for x in (ox + 2)..(ox + w - 2) {
wm.set_walkable(&TilePosition::new(x, y, 0), true);
}
}
}
#[cfg(feature = "gauntlet")]
/// Carve a corridor as a fully walkable rectangle.
/// Corridors are 6 tiles wide with walls on both sides — we carve the
/// interior 2 tiles in from each edge (leaving 2-tile walkable center).
/// For narrow corridors (width=6), interior is 2 tiles wide.
fn carve_corridor(wm: &mut WalkabilityMap, ox: i32, oy: i32, w: i32, h: i32) {
// Corridors are fully walkable rectangles (wall tiles are the
// surrounding room/map boundary). Carve interior with 1-tile margin.
for y in (oy + 1)..(oy + h - 1) {
for x in (ox + 1)..(ox + w - 1) {
wm.set_walkable(&TilePosition::new(x, y, 0), true);
}
}
}
#[cfg(all(test, feature = "gauntlet"))]
mod tests {
use super::*;
use crate::knowledge::registry::EntityRegistry;
#[test]
fn gauntlet_setup_creates_expected_entities() {
let mut app = App::new();
app.add_plugins(crate::simulation::SimulationPlugin);
app.add_plugins(crate::knowledge::KnowledgePlugin);
app.add_plugins(crate::npc::NpcPlugin);
setup_gauntlet(&mut app);
let registry = app.world().resource::<EntityRegistry>();
assert_eq!(
registry.len(),
constants::EXPECTED_ENTITY_COUNT,
"entity count should match EXPECTED_ENTITY_COUNT derived from StableId ranges"
);
}
#[test]
fn hub_center_is_walkable() {
let mut app = App::new();
app.add_plugins(crate::simulation::SimulationPlugin);
app.add_plugins(crate::knowledge::KnowledgePlugin);
app.add_plugins(crate::npc::NpcPlugin);
setup_gauntlet(&mut app);
let wm = app.world().resource::<WalkabilityMap>();
// Hub center at (50, 58) must be walkable
assert!(wm.can_move_to(&TilePosition::new(50, 58, 0)));
}
#[test]
fn occlusion_north_wall_blocks() {
let mut app = App::new();
app.add_plugins(crate::simulation::SimulationPlugin);
app.add_plugins(crate::knowledge::KnowledgePlugin);
app.add_plugins(crate::npc::NpcPlugin);
setup_gauntlet(&mut app);
let wm = app.world().resource::<WalkabilityMap>();
// North wall segment at absolute (90, 54) should be blocked
assert!(!wm.can_move_to(&TilePosition::new(90, 54, 0)));
// But the corridor interior at (90, 56) should be walkable
assert!(wm.can_move_to(&TilePosition::new(90, 56, 0)));
}
#[test]
fn corridor_connects_hub_to_occlusion() {
let mut app = App::new();
app.add_plugins(crate::simulation::SimulationPlugin);
app.add_plugins(crate::knowledge::KnowledgePlugin);
app.add_plugins(crate::npc::NpcPlugin);
setup_gauntlet(&mut app);
let wm = app.world().resource::<WalkabilityMap>();
// corridor-E center should be walkable
assert!(wm.can_move_to(&TilePosition::new(66, 57, 0)));
}
#[test]
fn stable_id_ranges_match_spec() {
let mut app = App::new();
app.add_plugins(crate::simulation::SimulationPlugin);
app.add_plugins(crate::knowledge::KnowledgePlugin);
app.add_plugins(crate::npc::NpcPlugin);
setup_gauntlet(&mut app);
let registry = app.world().resource::<EntityRegistry>();
// Player at StableId 0
use crate::knowledge::types::StableId;
assert!(registry.to_entity(&StableId(0)).is_some(), "Player at StableId 0");
// Hub signs at 1-4
for id in 1..=4 {
assert!(registry.to_entity(&StableId(id)).is_some(), "Hub sign at StableId {}", id);
}
// Fog Theater 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");
// 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
);
}
}
}
+207
View File
@@ -0,0 +1,207 @@
//! 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
//!
//! 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
//! - 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::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,
}
/// 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<EntitySnapshot>,
}
/// Resource holding initial entity state per room and debounce tracking.
#[derive(Resource, Debug, Default)]
pub struct RoomSnapshots {
snapshots: BTreeMap<String, RoomSnapshotData>,
last_reset_tick: BTreeMap<String, u64>,
}
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.
///
/// 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,
current_tick: u64,
) -> Option<Vec<(Entity, TilePosition, bool)>> {
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)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
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();
snapshots.record("test_room", entity, TilePosition::new(10, 20, 0), false);
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 plan_reset_includes_floor_items() {
let mut world = World::new();
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);
let changes = snapshots.plan_reset("warehouse", 0).unwrap();
assert_eq!(changes.len(), 1);
assert!(changes[0].2); // is a floor item
}
#[test]
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
assert!(snapshots.plan_reset("test_room", 0).is_some());
// Second reset at tick 5 — should be debounced
assert!(snapshots.plan_reset("test_room", 5).is_none());
// Third reset at tick 10 — should succeed
assert!(snapshots.plan_reset("test_room", 10).is_some());
}
#[test]
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();
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]
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));
}
}
+40
View File
@@ -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));
}
}
@@ -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));
}
+9
View File
@@ -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;
@@ -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));
}
}
@@ -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));
}
+5
View File
@@ -3,6 +3,8 @@
//! 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.
//!
//! Runtime validation (TCP boot + tick) is in content_runtime.rs.
use bevy_app::prelude::*;
use bevy_ecs::prelude::*;
@@ -416,3 +418,6 @@ fn spawn_real_content_with_relationships_and_secrets() {
.expect("Nils should have Want");
assert_eq!(nils_want.primary, npc::WantKind::Power);
}
// 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"
);
}
File diff suppressed because it is too large Load Diff
+399
View File
@@ -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::<WalkabilityMap>();
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::<RelationshipGraph>();
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<PlayerInput>> {
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<String, Value> = 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<String>) {
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<ObserverSnapshot> = None;
for tick_inputs in &inputs {
{
let mut queue = app
.world_mut()
.resource_mut::<settled_reach_server::simulation::input::InputQueue>();
for input in tick_inputs {
queue.push(input.clone());
}
}
app.update();
let buffer = app.world().resource::<SnapshotBuffer>();
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);
}
}
+132
View File
@@ -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::<u16>()
.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;
}
}
}
}
+217 -21
View File
@@ -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<String> {
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<String, Value> = 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<String>) {
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 <missing>",
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 <missing>, 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<Str
}
}
}
(e, a) if std::mem::discriminant(e) != std::mem::discriminant(a) => {
// 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("<missing>"));
}
#[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 <missing>"));
}
#[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("<missing>"));
}
#[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"));
}
}