3-round workshop with 7 agents (Tyre, Dudley, Stig, Hoshe, Justine, Gestalt, Ozzie) plus Qatux documenting. Produced: - 59-item prioritized test backlog (60 tickets under epic #455) - Gauntlet test world spec: 7 rooms + hub, 48 entities - Test client binary spec (tooling/test-client/) - Determinism fixes (3 patches, ~22 lines) - Server --test-mode + --port 0 design - Content cross-reference validation (9 checks) - make pre-pr pipeline (6-step) - 38 client tests prioritized - Anti-tedium features (reset plate, hub teleport, WRONG button) - Human tester walkthrough - CI pipeline design (deferred but documented) Sprint 8 scope: ~17.75 team-days across 26 tickets. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
38 KiB
Dudley — Round 3: Final Server Specs & Sprint 8 Deliverables
Workshop: QA Strategy & Test Architecture Round: 3 (Prioritization & Final Specs) Date: 2026-02-17 Inputs: All Round 2 outputs, round-2-notes.md, live codebase
1. Determinism Fixes — Final Patch Spec
Three fixes (A, B, D). Fix C was already done (corrected in Round 2). Each fix includes exact code, the affected lines, and a focused regression test.
Fix A: Deterministic visible_tiles ordering + BTreeSet for visible_positions
Files: server/src/perception/query.rs, server/src/perception/observer/mod.rs
Problem: VisibilityGeometry.visible_positions is HashSet<(i32,i32)> (line 24, query.rs). visible_tiles inherits non-deterministic ordering from apply_vision_cone which iterates the shadowcast's HashSet. The visible_ids in filter_visible_entities is also HashSet<u64>. These feed into the ObserverSnapshot and affect sprint anomaly detection order.
Patch 1a: server/src/perception/query.rs
// Line 8 — BEFORE:
use std::collections::{HashMap, HashSet};
// AFTER:
use std::collections::{BTreeSet, HashMap};
// Line 24 — BEFORE:
pub visible_positions: HashSet<(i32, i32)>,
// AFTER:
pub visible_positions: BTreeSet<(i32, i32)>,
// Lines 67-83 (inside NaturalVision::compute_geometry) — BEFORE:
let visible_tiles = cone_tiles
.iter()
.map(|&(x, y, sector)| {
let tile_kind = if walkability.can_move_to(&TilePosition::new(x, y, z)) {
TileKind::Floor
} else {
TileKind::Wall
};
VisibleTile {
x,
y,
z,
visibility: sector,
tile_kind,
}
})
.collect();
let visible_positions = cone_tiles.iter().map(|&(x, y, _)| (x, y)).collect();
// AFTER:
let mut visible_tiles: Vec<_> = cone_tiles
.iter()
.map(|&(x, y, sector)| {
let tile_kind = if walkability.can_move_to(&TilePosition::new(x, y, z)) {
TileKind::Floor
} else {
TileKind::Wall
};
VisibleTile {
x,
y,
z,
visibility: sector,
tile_kind,
}
})
.collect();
// Deterministic tile ordering for snapshot stability (D-010 principle 4)
visible_tiles.sort_by_key(|t| (t.x, t.y));
let visible_positions: BTreeSet<(i32, i32)> =
cone_tiles.iter().map(|&(x, y, _)| (x, y)).collect();
Note: sector_lookup: HashMap<(i32,i32), VisibilitySector> on line 25 stays as HashMap — point-lookup only via .get(), never iterated. Confirmed safe per Tyre OQ-4 answer.
Patch 1b: server/src/perception/observer/mod.rs
// Line 10 — BEFORE:
use std::collections::HashSet;
// AFTER:
use std::collections::BTreeSet;
// Line 217 — BEFORE:
) -> (Vec<VisibleEntity>, HashSet<u64>) {
let mut entities = Vec::new();
let mut visible_ids: HashSet<u64> = HashSet::new();
// AFTER:
) -> (Vec<VisibleEntity>, BTreeSet<u64>) {
let mut entities = Vec::new();
let mut visible_ids: BTreeSet<u64> = BTreeSet::new();
// Line 284-285 — BEFORE:
fn collect_remembered_entities(
observer_kg: &KnowledgeGraph,
visible_ids: &HashSet<u64>,
visible_positions: &HashSet<(i32, i32)>,
// AFTER:
fn collect_remembered_entities(
observer_kg: &KnowledgeGraph,
visible_ids: &BTreeSet<u64>,
visible_positions: &BTreeSet<(i32, i32)>,
Fix A Regression Test
// server/src/perception/observer/tests.rs (or new test file)
/// Verify visible_tiles in snapshot are sorted by (x, y).
/// Regression test for determinism Fix A.
#[test]
fn snapshot_visible_tiles_are_sorted() {
// Setup: create world with WalkabilityMap, player, geometry
let mut world = bevy_ecs::world::World::new();
// ... (standard test world setup with WalkabilityMap, player, geometry) ...
// After compute_observer_snapshot:
let snapshot = world.resource::<SnapshotBuffer>().snapshot.as_ref().unwrap();
for window in snapshot.visible_tiles.windows(2) {
assert!(
(window[0].x, window[0].y) <= (window[1].x, window[1].y),
"visible_tiles not sorted: ({},{}) > ({},{})",
window[0].x, window[0].y, window[1].x, window[1].y,
);
}
}
/// Verify sprint anomaly detection picks deterministic entity
/// when multiple Contradicted entities are equidistant.
#[test]
fn sprint_anomaly_picks_lowest_stable_id() {
// Setup: player sprinting, 2 NPCs both Contradicted in KG,
// both visible. BTreeSet iterates in ascending order,
// so the NPC with the lower StableId wins.
// Assert: anomaly_queue contains the lower-ID NPC.
}
Fix B: Sort visible entities in snapshot by entity_id
File: server/src/perception/observer/mod.rs
Problem: filter_visible_entities builds entities: Vec<VisibleEntity> by iterating all_entities.iter() (line 221). Bevy query iteration order is archetype-based and not stable across runs. The entity list in ObserverSnapshot is therefore non-deterministic.
Patch 2: server/src/perception/observer/mod.rs
// After line 126 (after collect_remembered_entities call, before sprint anomaly block)
// In compute_observer_snapshot, INSERT:
// Sort entities by wire ID for deterministic snapshot ordering (D-010 principle 4)
entities.sort_by_key(|e| e.entity_id);
Exact insertion point: between the collect_remembered_entities(...) call (line 119-126) and the sprint anomaly detection block (line 128 if stance_opt.map(...)).
Fix B Regression Test
/// Verify entities in snapshot are sorted by entity_id.
/// Regression test for determinism Fix B.
#[test]
fn snapshot_entities_sorted_by_id() {
// Setup: 3+ NPCs visible to player
// After compute_observer_snapshot:
let snapshot = world.resource::<SnapshotBuffer>().snapshot.as_ref().unwrap();
for window in snapshot.entities.windows(2) {
assert!(
window[0].entity_id <= window[1].entity_id,
"entities not sorted: {} > {}",
window[0].entity_id, window[1].entity_id,
);
}
}
Fix D: Sort movers in validate_movement for deterministic collision resolution
File: server/src/simulation/movement.rs
Problem: validate_movement iterates movers.iter_mut() (line 288). When two entities move to the same tile in the same tick, the "first-come-first-served" collision resolution depends on bevy query iteration order, which is non-deterministic.
Patch 3: server/src/simulation/movement.rs
// Lines 288-316 — BEFORE:
for (entity, intent, mut position, presence) in movers.iter_mut() {
let target = &intent.target;
let layer = presence.copied().unwrap_or_default();
let slot = (*target, layer);
if !map.can_move_to(target) {
tracing::trace!("Entity {:?} blocked by terrain at {:?}", entity, target);
} else if occupied.contains_key(&slot) {
tracing::trace!(
"Entity {:?} blocked by entity at {:?} (layer {:?})",
entity,
target,
layer
);
} else {
tracing::trace!(
"Entity {:?} moving from {:?} to {:?} (layer {:?})",
entity,
*position,
target,
layer
);
// Free old layer slot, claim new one
occupied.remove(&(*position, layer));
*position = *target;
occupied.insert(slot, entity);
}
commands.entity(entity).remove::<MoveIntent>();
}
// AFTER:
// Collect and sort movers for deterministic collision resolution (D-010 principle 4).
// Entity::to_bits() provides stable ordering within a single run.
// For cross-session determinism (save/load), use registry.to_stable() instead.
let mut mover_list: Vec<_> = movers.iter_mut().collect();
mover_list.sort_by_key(|(entity, _, _, _)| entity.to_bits());
for (entity, intent, mut position, presence) in mover_list {
let target = &intent.target;
let layer = presence.copied().unwrap_or_default();
let slot = (*target, layer);
if !map.can_move_to(target) {
tracing::trace!("Entity {:?} blocked by terrain at {:?}", entity, target);
} else if occupied.contains_key(&slot) {
tracing::trace!(
"Entity {:?} blocked by entity at {:?} (layer {:?})",
entity,
target,
layer
);
} else {
tracing::trace!(
"Entity {:?} moving from {:?} to {:?} (layer {:?})",
entity,
*position,
target,
layer
);
occupied.remove(&(*position, layer));
*position = *target;
occupied.insert(slot, entity);
}
commands.entity(entity).remove::<MoveIntent>();
}
Fix D Regression Test
/// Verify that same-tile collision resolution is deterministic.
/// The entity with the lower Entity::to_bits() value wins.
/// Regression test for determinism Fix D.
#[test]
fn validate_movement_deterministic_collision_winner() {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(WalkabilityMap::new(10, 10, 1));
let entity_a = world
.spawn((
TilePosition::new(5, 4, 0),
MoveIntent {
target: TilePosition::new(5, 5, 0),
},
))
.id();
let entity_b = world
.spawn((
TilePosition::new(5, 6, 0),
MoveIntent {
target: TilePosition::new(5, 5, 0),
},
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(validate_movement);
schedule.run(&mut world);
let pos_a = *world.get::<TilePosition>(entity_a).unwrap();
let pos_b = *world.get::<TilePosition>(entity_b).unwrap();
// The entity with lower to_bits() should win the tile
let expected_winner = if entity_a.to_bits() < entity_b.to_bits() {
entity_a
} else {
entity_b
};
let expected_loser = if expected_winner == entity_a {
entity_b
} else {
entity_a
};
assert_eq!(
*world.get::<TilePosition>(expected_winner).unwrap(),
TilePosition::new(5, 5, 0),
"lower-bits entity should win the contested tile"
);
assert_ne!(
*world.get::<TilePosition>(expected_loser).unwrap(),
TilePosition::new(5, 5, 0),
"higher-bits entity should remain at original position"
);
}
Strengthens existing test: The current validate_movement_two_movers_same_target_first_wins (line 556) asserts "exactly one wins" but not which one. After Fix D, the winner is deterministic — the entity with lower Entity::to_bits() wins. The new test above replaces that weaker assertion.
Summary: All Determinism Fixes
| Fix | File(s) | Lines changed | Regression test |
|---|---|---|---|
| A | query.rs, observer/mod.rs |
~15 | snapshot_visible_tiles_are_sorted, sprint_anomaly_picks_lowest_stable_id |
| B | observer/mod.rs |
2 | snapshot_entities_sorted_by_id |
| C | (none — already done) | 0 | — |
| D | movement.rs |
~5 | validate_movement_deterministic_collision_winner |
Total: ~22 lines of production code, 4 regression tests.
2. Server --test-mode Final Spec
CLI Interface
settled-reach-server [OPTIONS] [ADDRESS]
Options:
--test-mode Enable test mode (Gauntlet content, fixed seed, LISTENING signal)
--port <PORT> Bind to specific port (0 = OS-assigned). Overrides ADDRESS.
--seed <SEED> RNG seed (default: 0, test-mode default: 42)
Legacy:
ADDRESS First positional arg (e.g., "127.0.0.1:9876"). Overridden by --port.
SR_ADDR Env var fallback. Overridden by --port and ADDRESS.
Default: 127.0.0.1:9876
Complete main.rs Replacement
// server/src/main.rs — Sprint 8 version with --test-mode + --port support
use bevy_app::prelude::*;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use settled_reach_server::bridge::tcp::TcpBridge;
use settled_reach_server::bridge::{BridgePlugin, BridgeResource, ServerRunning};
use settled_reach_server::knowledge::KnowledgePlugin;
use settled_reach_server::npc::NpcPlugin;
use settled_reach_server::simulation::SimulationPlugin;
fn main() {
let args: Vec<String> = std::env::args().collect();
let test_mode = args.iter().any(|a| a == "--test-mode");
let port_flag = args
.iter()
.position(|a| a == "--port")
.and_then(|i| args.get(i + 1))
.and_then(|s| s.parse::<u16>().ok());
let seed_flag = args
.iter()
.position(|a| a == "--seed")
.and_then(|i| args.get(i + 1))
.and_then(|s| s.parse::<u64>().ok());
// Tracing: quieter in test mode to reduce stdout noise
let default_filter = if test_mode {
"settled_reach_server=warn"
} else {
"settled_reach_server=debug"
};
tracing_subscriber::registry()
.with(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| default_filter.into()),
)
.with(tracing_subscriber::fmt::layer().with_writer(std::io::stderr))
.init();
// Resolve bind address
let addr = if let Some(port) = port_flag {
format!("127.0.0.1:{}", port)
} else {
args.iter()
.skip(1)
.find(|a| !a.starts_with("--"))
.cloned()
.or_else(|| std::env::var("SR_ADDR").ok())
.unwrap_or_else(|| "127.0.0.1:9876".to_string())
};
// Bind FIRST, print port, THEN accept.
// Critical for --port 0: the OS assigns a random port at bind time.
// The LISTENING:{port} line is the handshake signal for the test client.
let listener = std::net::TcpListener::bind(&addr).unwrap_or_else(|e| {
eprintln!("Failed to bind {}: {}", addr, e);
std::process::exit(1);
});
let actual_port = listener.local_addr().unwrap().port();
// LISTENING signal to stdout. The test client parses this to discover the port.
// All tracing goes to stderr (see .with_writer above), so stdout is clean.
println!("LISTENING:{}", actual_port);
use std::io::Write;
std::io::stdout().flush().ok();
tracing::info!("Waiting for client connection on port {}", actual_port);
let bridge = TcpBridge::accept_on(listener).unwrap_or_else(|e| {
tracing::error!("Failed to accept: {}", e);
std::process::exit(1);
});
tracing::info!("Client connected, initializing simulation");
// RNG seed: test-mode defaults to 42 for deterministic replay
let seed = seed_flag.unwrap_or(if test_mode { 42 } else { 0 });
let mut app = App::new();
app.add_plugins(SimulationPlugin);
app.add_plugins(BridgePlugin);
app.add_plugins(KnowledgePlugin);
app.add_plugins(NpcPlugin);
app.add_plugins(settled_reach_server::content::ContentPlugin);
app.insert_resource(BridgeResource::new(bridge));
if test_mode {
// Gauntlet content: deferred until Gauntlet loader exists.
// For now, fall back to the proof room setup.
setup_proof_room(&mut app, seed);
} else {
setup_proof_room(&mut app, seed);
}
tracing::info!("Simulation initialized (seed={}, test_mode={})", seed, test_mode);
let target_frame_time = std::time::Duration::from_millis(50);
loop {
let frame_start = std::time::Instant::now();
app.update();
if !app.world().resource::<ServerRunning>().0 {
break;
}
let elapsed = frame_start.elapsed();
if elapsed < target_frame_time {
std::thread::sleep(target_frame_time - elapsed);
}
}
tracing::info!("Simulation server shutting down");
}
/// Proof room: 32x32 map, wall at (16,14), player at (16,16), 3 NPCs.
/// Extracted from current inline setup for reuse by both test-mode and normal mode.
fn setup_proof_room(app: &mut App, seed: u64) {
use settled_reach_server::knowledge::registry::EntityRegistry;
use settled_reach_server::knowledge::KnowledgeGraph;
use settled_reach_server::npc::relationships::{RelationshipEdge, RelationshipGraph};
use settled_reach_server::npc::*;
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::stance::{MovementProfile, PlayerMoveCooldown};
use settled_reach_server::simulation::time::DayPhase;
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);
// ... (existing player + 3 NPC spawn code, unchanged from current main.rs lines 72-225)
// Omitted here for brevity — the extraction is mechanical.
// The seed parameter will be passed to SimRng::new(seed) once that resource
// is inserted by SimulationPlugin (currently hardcoded to 0 in SimulationPlugin).
app.insert_resource(registry);
}
Key Design Decisions
| Decision | Rationale |
|---|---|
| Tracing to stderr, LISTENING to stdout | Test client parses stdout cleanly without tracing noise |
accept_on(listener) instead of accept(&addr) |
Already exists at tcp.rs:67. Separates bind from accept for port discovery |
--port overrides positional arg |
Flag-based parsing is unambiguous. Positional arg preserved for backward compat |
setup_proof_room() extracted |
Reusable by both modes. Gauntlet content loads into the same slot when ready |
--seed flag exposed |
Test client can verify determinism with different seeds |
Test Client Port Discovery Protocol
Server stdout: LISTENING:54321\n
Test client: parse port → connect("127.0.0.1:54321")
The test client reads lines from the server's stdout pipe until it sees LISTENING:(\d+). Timeout: 5 seconds. If no LISTENING line arrives, the test fails with the server's stderr output for debugging.
3. Gauntlet Loader MVP Spec (Sprint 8 Scope)
Sprint 8 Rooms: 5 of 14
Based on Gestalt's priority ranking (INV-T04, INV-T01, INV-T03, INV-T05) and the systems we can test NOW:
| # | Room | Why Sprint 8 | Systems tested |
|---|---|---|---|
| 1 | Hub | Central teleport target, room naming, corridor flow | Room naming, coordinates, basic spawning |
| 2 | Pause Chamber | Bug #3 regression, INV-T04 (rank #1) | Pause guard, state transitions, TickRate |
| 3 | Inventory Warehouse | D-065 Take/Place, 9-slot limit | Inventory, pickup, CarriedBy, slot assignment |
| 4 | Occlusion Corridor | D-035 LOS, D-015 vision cone, shadowcasting | Visibility, perception, fog |
| 5 | Interaction Gallery | D-057/D-060 verb system, sprint suppression | Phase 1/2 verbs, ObjectType, interaction buffer |
Deferred to Sprint 9: Crowd Plaza (needs cognitive delay visual), Fog Theater (needs 5-layer fog), Dialogue Room (needs dialogue dispatch), Sound Lab/Eavesdrop/Confrontation (needs audio systems), Decay Observatory (needs decay system), Sprint Gauntlet (needs anomaly monologue content), Shift Change (needs density stress content), Zone Gate (reserved).
Content Pack Structure
content/gauntlet/
gauntlet.yaml # Master file: rooms, spawn order, map dimensions
rooms/
hub.yaml # Hub room: layout, reset plate, corridor exits
pause_chamber.yaml # Pause room: entities, tick rate test scenarios
inventory_warehouse.yaml # Inventory room: 10 pickup items, crates
occlusion_corridor.yaml # Occlusion room: walls, NPCs at LOS boundaries
interaction_gallery.yaml # Interaction room: one per ObjectType + multi-verb NPC
Master File: gauntlet.yaml
# content/gauntlet/gauntlet.yaml
# Gauntlet test world — Sprint 8 MVP
# Additive only: existing rooms never modified. New rooms appended.
name: gauntlet
version: 1
seed: 42
map_dimensions:
width: 128
height: 128
z_levels: 1
# Canonical room ordering — determines entity spawn order → StableId assignment.
# DO NOT REORDER existing rooms. Append new rooms at the end.
rooms:
- hub
- pause_chamber
- inventory_warehouse
- occlusion_corridor
- interaction_gallery
Room File: pause_chamber.yaml
# content/gauntlet/rooms/pause_chamber.yaml
# Tests: TickRate toggle, pause guard (Bug #3), state transitions
room_id: pause_chamber
bounds:
top_left: [32, 0]
bottom_right: [47, 15]
observer_position: [40, 8] # Fixed position for golden file snapshots
walls:
# Perimeter walls (bounds are inclusive)
- type: perimeter
entities:
- id: pause_npc_1
kind: npc
position: [40, 6]
interactable: true
components:
want: { primary: Safety, intensity: 3 }
contentment: 10
tolerance: { stress: 10, threshold: 60 }
- id: pause_crate_1
kind: object
position: [38, 8]
object_type: Container
interactable: true
reset_plate: [40, 15] # Bottom edge of room
Room File: inventory_warehouse.yaml
# content/gauntlet/rooms/inventory_warehouse.yaml
# Tests: Pickup, CarriedBy, 9-slot limit, Take/Place verbs
room_id: inventory_warehouse
bounds:
top_left: [48, 0]
bottom_right: [63, 15]
observer_position: [56, 8]
walls:
- type: perimeter
entities:
# 10 pickup items — first 9 fit, 10th tests overflow rejection
- id: inv_item_1
kind: object
position: [50, 4]
object_type: Pickup
item_name: "Manifest Alpha"
interactable: true
- id: inv_item_2
kind: object
position: [52, 4]
object_type: Pickup
item_name: "Docking Token"
interactable: true
- id: inv_item_3
kind: object
position: [54, 4]
object_type: Pickup
item_name: "Sensor Array"
interactable: true
- id: inv_item_4
kind: object
position: [56, 4]
object_type: Pickup
item_name: "Cargo Key"
interactable: true
- id: inv_item_5
kind: object
position: [58, 4]
object_type: Pickup
item_name: "Power Cell"
interactable: true
- id: inv_item_6
kind: object
position: [50, 8]
object_type: Pickup
item_name: "Data Chip"
interactable: true
- id: inv_item_7
kind: object
position: [52, 8]
object_type: Pickup
item_name: "Repair Kit"
interactable: true
- id: inv_item_8
kind: object
position: [54, 8]
object_type: Pickup
item_name: "Transit Pass"
interactable: true
- id: inv_item_9
kind: object
position: [56, 8]
object_type: Pickup
item_name: "Field Journal"
interactable: true
- id: inv_item_10
kind: object
position: [58, 8]
object_type: Pickup
item_name: "Overflow Item"
interactable: true
# Non-pickup objects for mixed interaction testing
- id: inv_crate_1
kind: object
position: [50, 12]
object_type: Container
interactable: true
reset_plate: [56, 15]
Gauntlet Constants Module
// server/src/test_world/constants.rs
use crate::simulation::movement::TilePosition;
pub struct GauntletRoom {
pub name: &'static str,
pub bounds: (TilePosition, TilePosition),
pub observer_position: TilePosition,
}
pub struct GauntletEntity {
pub name: &'static str,
pub room: &'static str,
/// Wire ID (StableId.0) assigned by deterministic spawn order.
/// Player = 0, then entities in room order (gauntlet.yaml rooms[]),
/// within each room in entity order (room.yaml entities[]).
pub wire_id: u64,
pub position: TilePosition,
}
// --- Rooms ---
pub const HUB: GauntletRoom = GauntletRoom {
name: "hub",
bounds: (TilePosition::new(0, 0, 0), TilePosition::new(31, 31, 0)),
observer_position: TilePosition::new(16, 16, 0),
};
pub const PAUSE_CHAMBER: GauntletRoom = GauntletRoom {
name: "pause_chamber",
bounds: (TilePosition::new(32, 0, 0), TilePosition::new(47, 15, 0)),
observer_position: TilePosition::new(40, 8, 0),
};
pub const INVENTORY_WAREHOUSE: GauntletRoom = GauntletRoom {
name: "inventory_warehouse",
bounds: (TilePosition::new(48, 0, 0), TilePosition::new(63, 15, 0)),
observer_position: TilePosition::new(56, 8, 0),
};
pub const OCCLUSION_CORRIDOR: GauntletRoom = GauntletRoom {
name: "occlusion_corridor",
bounds: (TilePosition::new(64, 0, 0), TilePosition::new(79, 15, 0)),
observer_position: TilePosition::new(72, 8, 0),
};
pub const INTERACTION_GALLERY: GauntletRoom = GauntletRoom {
name: "interaction_gallery",
bounds: (TilePosition::new(80, 0, 0), TilePosition::new(95, 15, 0)),
observer_position: TilePosition::new(88, 8, 0),
};
pub const ROOMS: &[&GauntletRoom] = &[
&HUB,
&PAUSE_CHAMBER,
&INVENTORY_WAREHOUSE,
&OCCLUSION_CORRIDOR,
&INTERACTION_GALLERY,
];
/// Look up the room a position falls in.
pub fn room_at(pos: &TilePosition) -> Option<&'static GauntletRoom> {
ROOMS.iter().find(|r| {
pos.x >= r.bounds.0.x && pos.x <= r.bounds.1.x
&& pos.y >= r.bounds.0.y && pos.y <= r.bounds.1.y
&& pos.z >= r.bounds.0.z && pos.z <= r.bounds.1.z
}).copied()
}
// --- Entities (wire IDs assigned by spawn order) ---
// Player is always wire_id 0.
// Room entities follow in gauntlet.yaml room order × room.yaml entity order.
pub const PLAYER: GauntletEntity = GauntletEntity {
name: "player",
room: "hub",
wire_id: 0,
position: TilePosition::new(16, 16, 0),
};
pub const PAUSE_NPC_1: GauntletEntity = GauntletEntity {
name: "pause_npc_1",
room: "pause_chamber",
wire_id: 1, // First entity after player
position: TilePosition::new(40, 6, 0),
};
// ... etc for all entities, assigned sequentially
Gauntlet Loader System
// server/src/test_world/loader.rs
/// Load the Gauntlet test world from YAML content pack.
/// Called when --test-mode is active and content/gauntlet/ exists.
///
/// Spawn order guarantees:
/// 1. Player entity (always StableId 0)
/// 2. Room entities in gauntlet.yaml rooms[] order
/// 3. Within each room, entities in room.yaml entities[] order
///
/// This order is the determinism contract for StableId assignment.
pub fn setup_gauntlet_world(app: &mut App, seed: u64) {
// 1. Load gauntlet.yaml → room list, map dimensions
// 2. Create WalkabilityMap(width, height, z_levels)
// 3. Spawn player entity at HUB.observer_position
// 4. For each room in canonical order:
// a. Load room.yaml
// b. Apply walls to WalkabilityMap
// c. Spawn entities in listed order
// d. Register each entity in EntityRegistry
// e. If --test-mode: spawn RoomResetTrigger at reset_plate position
// 5. Insert RoomSnapshots resource (capture tick-0 state per room)
// 6. Insert SimRng::new(seed)
}
Implementation note: The loader should be a Rust function that reads YAML via serde_yaml, not a bevy plugin startup system. This keeps it synchronous and testable — the function takes &mut App and populates it before the game loop starts. The YAML structure above maps directly to the ECS components we already have.
4. Remaining Questions Answered
Q7: SetTickRate while paused — reject or unpause?
Question (Hoshe R2-OQ-01): SetTickRate(Half) while paused — should this unpause? Current code at input.rs:165-168 sets the rate unconditionally, meaning paused() returns false on next tick.
Answer: This is a bug. SetTickRate should be rejected while paused.
The current behavior allows a client to bypass the pause guard by sending SetTickRate(Half) instead of Unpause. The pause state should only be exited by an explicit Unpause action.
Fix:
// server/src/simulation/input.rs, line 165-168 — BEFORE:
PlayerAction::SetTickRate(rate) => {
time.tick_rate = rate;
tracing::debug!("Tick rate set to {:?} by player input", rate);
}
// AFTER:
PlayerAction::SetTickRate(rate) => {
if paused {
tracing::debug!("SetTickRate({:?}) rejected while paused", rate);
} else {
time.tick_rate = rate;
tracing::debug!("Tick rate set to {:?} by player input", rate);
}
}
Rationale: Pause is a player-initiated state lock. The only valid exit is Unpause. SetTickRate is a tick-speed adjustment for gameplay pacing (Full/Half), not a pause override. If the player wants to go from Paused to Half, they send Unpause then SetTickRate(Half).
Test:
#[test]
fn set_tick_rate_rejected_while_paused() {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(InputQueue::default());
world.insert_resource(SimulationTime {
tick_rate: TickRate::Paused,
..Default::default()
});
world.init_resource::<EntityRegistry>();
world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)));
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::SetTickRate(TickRate::Half),
});
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_player_input);
schedule.run(&mut world);
assert_eq!(
world.resource::<SimulationTime>().tick_rate,
TickRate::Paused,
"SetTickRate should not override Paused state"
);
}
Q8: Entity index recycling — how does registry handle despawn+respawn?
Question (Hoshe R2-OQ-02): When bevy recycles an Entity index, does the registry handle old StableId not being unregistered?
Answer: The current registry is safe because bevy Entity includes a generation counter.
In bevy_ecs, Entity is a combination of (index, generation). When an entity is despawned, the index is recycled but the generation increments. So Entity(index=3, gen=1) and Entity(index=3, gen=2) are different keys in the BTreeMap. The registry uses Entity as the BTreeMap key, not the raw index.
However, there IS a correctness concern: if an entity is despawned but unregister() is never called, the old (Entity(3,gen1) → StableId(5)) mapping persists. When bevy respawns Entity(3,gen2) and we call register(Entity(3,gen2)), it correctly gets a NEW StableId (since Entity(3,gen2) is a different key). But the OLD mapping StableId(5) → Entity(3,gen1) still exists, pointing to a dead entity.
The fix is discipline, not code change: unregister() MUST be called on despawn. The Gauntlet's room reset system avoids despawn entirely (it resets components, not entities), so this is not a Sprint 8 risk. For Sprint 10+ save/load, add a DespawnCleanup system that runs registry.unregister() for all despawned entities.
Test for the concern (Sprint 8):
#[test]
fn register_new_entity_after_unregister_gets_new_stable_id() {
let mut world = bevy_ecs::world::World::new();
let e1 = world.spawn_empty().id();
let mut registry = EntityRegistry::new(0);
let id1 = registry.register(e1);
assert_eq!(id1, StableId(0));
registry.unregister(e1);
// Simulate bevy recycling the index with new generation
let e2 = world.spawn_empty().id();
let id2 = registry.register(e2);
// New entity gets StableId(1), NOT StableId(0) — IDs are monotonic
assert_eq!(id2, StableId(1));
// Old StableId(0) is gone
assert_eq!(registry.to_entity(&id1), None);
// New entity has new StableId
assert_eq!(registry.to_entity(&id2), Some(e2));
}
Q1: Canonical room ordering — does entity spawn order matter for StableId determinism?
Question (Gestalt R2-OQ-09): Room ordering in Gauntlet YAML — canonical ordering affects entity StableId assignment.
Answer: Yes, entity spawn order determines StableId assignment and must be canonical.
The EntityRegistry assigns StableIds sequentially via next_id (registry.rs:47-48). The order entities are registered determines their wire IDs. If room ordering changes, all wire IDs shift, breaking golden file comparisons and all test assertions that reference wire IDs by value.
The contract:
gauntlet.yamldefines room order. Existing rooms are NEVER reordered. New rooms append.- Within each room YAML, entity order is the spawn order. Existing entities are NEVER reordered. New entities append.
- Player is always spawned first (StableId 0).
- The
GauntletEntity.wire_idconstants are derived from this ordering.
If someone reorders rooms or entities in YAML:
- Golden files break (changed wire IDs in snapshots)
- Test assertions referencing
PAUSE_NPC_1.wire_idbreak - The fixture staleness check (
make fixtures-check) catches this
This is the same additive-only constraint as the workshop brief's "existing rooms stay frozen." It applies to entity lists within rooms too.
Q4: blocked_entities feasibility — can compute_observer_snapshot include blocked entities with blocking wall position?
Question (Ozzie via UQ-03): Can the ObserverSnapshot include entities that are NOT visible, along with what wall blocks them?
Answer: Feasible but not trivial. Sprint 9 scope, gated behind --test-mode.
What it requires:
The current filter_visible_entities (observer/mod.rs:207-276) only processes entities whose position is in visible_positions. Blocked entities are simply skipped. To include them, we need:
- Iterate ALL entities in range (not just visible ones)
- For each non-visible entity, trace a line from observer to entity position
- Find the blocking wall — the first position on the line that is non-walkable
The line trace is essentially a raycast on the tile grid. We already have shadowcasting, but it doesn't expose per-entity blocking info — it computes a visibility set, not per-entity raycasts.
Implementation sketch:
/// A blocked entity with its blocking wall position.
/// Only included in --test-mode snapshots.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BlockedEntity {
pub entity_id: u64,
pub entity_x: f32,
pub entity_y: f32,
pub entity_z: i32,
pub blocked_by_x: i32,
pub blocked_by_y: i32,
pub kind: EntityKind,
}
// In ObserverSnapshot, add (gated behind #[serde(default)]):
#[serde(default)]
pub blocked_entities: Vec<BlockedEntity>,
Cost estimate: For each non-visible entity within the forward range (~20 tiles), trace a Bresenham line (~20 tile checks per entity). With 15 NPCs, worst case is ~15 × 20 = 300 tile lookups per tick. This is negligible (microseconds) compared to shadowcasting.
Why Sprint 9: The feature requires:
- New wire protocol field (protocol version bump)
- Bresenham line trace utility (doesn't exist yet)
- Test client code to display
✕ BLOCKEDentries - Tests verifying the blocking wall is correct
None of this blocks Sprint 8 testing. The text renderer can show "Entities: 5 visible" without blocked entities. The blocked entity display is a debugging enhancement for the human tester experience.
Recommendation: Add the blocked_entities field as Vec<BlockedEntity> with #[serde(default)] (always empty in non-test-mode). Populate it in Sprint 9 when the test client's enhanced display is built.
Summary
| # | Deliverable | Status | Sprint 8? |
|---|---|---|---|
| 1 | Determinism fixes A, B, D with regression tests | Final spec, copy-pasteable | YES |
| 2 | Server --test-mode + --port 0 |
Final spec, main.rs replacement | YES |
| 3 | Gauntlet loader MVP (5 rooms) | YAML structure + constants module + loader design | YES |
| 4a | Q7: SetTickRate while paused → reject | Bug fix + test | YES |
| 4b | Q8: Entity index recycling → safe (generation counter) | Analysis + test | YES (test only) |
| 4c | Q1: Canonical room ordering → required | Design constraint documented | YES (constraint) |
| 4d | Q4: blocked_entities → feasible, Sprint 9 | Design + cost estimate | Sprint 9 |
Addendum: Lead Override — Test Client Crate Location
Override (received during Round 3): The test client binary moves from server/src/bin/test_client.rs to tooling/test-client/ as a separate workspace crate.
Bridge Type Pub Export Verification
The lead's action item for Dudley: "ensure bridge types are pub-exported for the tools crate."
Result: No server crate changes needed. All bridge types are already publicly exported.
Verification trace:
| File | Line | Export | Status |
|---|---|---|---|
server/src/lib.rs |
4 | pub mod bridge; |
Public |
server/src/bridge/mod.rs |
9 | pub mod framing; |
Public |
server/src/bridge/mod.rs |
11 | pub mod tcp; |
Public |
server/src/bridge/mod.rs |
12 | pub mod types; |
Public |
server/src/bridge/mod.rs |
13 | pub use types::*; |
Re-exported at bridge level |
server/src/bridge/framing.rs |
15 | pub fn write_framed(...) |
Public |
server/src/bridge/framing.rs |
36 | pub fn read_framed(...) |
Public |
The tooling/test-client/ crate adds settled-reach-server as a workspace dependency and imports:
// tooling/test-client/src/main.rs
use settled_reach_server::bridge::{
ObserverSnapshot, PlayerInput, PlayerAction, // via pub use types::*
framing::{write_framed, read_framed},
tcp::TcpBridge, // if reusing; or raw TcpStream with framing functions
};
No changes to server/ required for this override.