Files
settled-reach/docs/workshops/test-architecture/dudley-round3.md
T
jpmschweitzerandClaude Opus 4.6 a87c95a6eb docs(workshops): complete QA test architecture workshop
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>
2026-02-17 14:00:15 +01:00

38 KiB
Raw Blame History

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:

  1. gauntlet.yaml defines room order. Existing rooms are NEVER reordered. New rooms append.
  2. Within each room YAML, entity order is the spawn order. Existing entities are NEVER reordered. New entities append.
  3. Player is always spawned first (StableId 0).
  4. The GauntletEntity.wire_id constants 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_id break
  • 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:

  1. Iterate ALL entities in range (not just visible ones)
  2. For each non-visible entity, trace a line from observer to entity position
  3. 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 ✕ BLOCKED entries
  • 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.