Files
settled-reach/docs/workshops/test-architecture/hoshe-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

37 KiB

Hoshe — Round 3: Prioritized Test Backlog, Final Specifications

Workshop: QA Strategy & Test Architecture Round: 3 (Prioritization) Date: 2026-02-17 Inputs: All Round 1 + Round 2 outputs, round-1-notes.md, round-2-notes.md


1. Prioritized Test Backlog

Every test and infrastructure item proposed across all 5 tracks, ranked by priority. Format per the task description.

Sprint 8 P0 — Ship or the Gauntlet doesn't work

These items directly prevent recurrence of Sprint 6-7 bugs, enable deterministic testing, or unblock all other test infrastructure.

# Title Track Team Dependencies Effort Bug class prevented
1 Determinism Fix A: visible_ids HashSet -> BTreeSet + sort visible_tiles T2 server None 0.5d Bug #1 class (state divergence), non-deterministic sprint anomaly detection
2 Determinism Fix B: Sort visible entities in snapshot by entity_id T2 server None 0.25d Non-deterministic golden file comparison
3 Determinism Fix D: Sort movers by Entity bits in validate_movement T2 server None 0.25d Non-deterministic movement tie-breaking
4 Server --test-mode and --port 0 flags T3 server None 1d Unblocks Layer 3 tests, test client binary, Gauntlet headless testing
5 make pre-pr target T5 joint Existing make targets 0.5d All bug classes (developer discipline enforcement)
6 Pause guard: movement_discarded_while_paused T4 server None 0.25d Bug #3 (player moves while paused)
7 Pause guard: unpause_accepted_while_paused T4 server None 0.25d Bug #3 class (pause state corruption)
8 Pause guard: pause_unpause_roundtrip_with_movement T4 server None 0.25d Bug #3 class (full cycle regression)
9 Content cross-reference validation (9 checks) T5 joint None 1.5d Content scaling regressions (dangling refs, missing files)
10 Fixture staleness check in make pre-pr T5 joint #5 0.25d Bug #4 class (protocol drift undetected)

Sprint 8 P0 total: ~5d server + ~2d joint = ~7d combined effort

Sprint 8 P1 — Core test infrastructure, high-value coverage

# Title Track Team Dependencies Effort Bug class prevented
11 Determinism regression test: gauntlet_deterministic_replay T2 server #1, #2, #3 1d All non-determinism bugs (master regression guard)
12 Per-fix determinism unit tests (Fix A: equidistant NPCs, Fix D: same-tile movers) T2 server #1, #3 0.5d Per-fix regression guards
13 Remaining pause guard tests (stance_toggle, interact, batch_discard) T4 server None 0.5d Bug #3 class (pause guard edge cases)
14 EntityRegistry lifecycle tests (stale mapping, register-after-unregister, unregister-unknown) T4 server None 0.5d Entity ID corruption, knowledge graph corruption
15 Boundary value tests: GDScript encode-only (41 values) T4 client None 1d Bug #4 class (format boundary encoding errors)
16 Boundary value tests: Rust encode-only + roundtrip T4 server None 0.5d Bug #4 class (Rust-side encoding errors)
17 gen_fixtures.rs boundary extension (14 raw + 5 snapshot fixtures) T4 joint #16 0.5d Bug #4 class (cross-language decode failures)
18 Encoding asymmetry tests (4 directions: Rust->GDScript, GDScript->Rust, fixture + raw) T4 joint #17 1d Encoding divergence between GDScript int_16 and Rust uint_16 for values 256-32767
19 make fixtures-client target (GDScript-generated fixtures for Rust) T4 joint #18 0.5d Reverse-direction protocol validation
20 Fog byte value constants (VIS_HIDDEN/PERIPHERAL/FORWARD, EXP_UNEXPLORED/EXPLORED/VISIBLE) T3 client None 0.25d Magic number drift, fog assertion targets
21 Client P0 tests: monologue not lost on overwrite (Bug #5), camera static during pause (Bug #2) T3 client None 0.5d Bug #2, Bug #5 regression
22 Client P1 tests: fog shader state (4 tests), entity lifecycle, pending recognition blob T3 client #20 1d Information boundary enforcement, memory leaks
23 malformed_input_in_batch_rejects_entire_batch test T4 server None 0.25d Documents batch-failure behavior (UQ-01 resolution)
24 Test client binary scaffolding + CLI (tooling/test-client/) T3 server #4 0.5d Unblocks text renderer, Layer 3, golden file comparison. Separate workspace crate, depends on server crate for shared types (ObserverSnapshot, PlayerInput, read_framed, write_framed). Lead override: NOT server/src/bin/.
25 Text renderer library (server/src/bridge/text_renderer.rs) T3 server #24 0.5-1d Unblocks human tester workflow, WRONG button. Pub-exported from server crate for tooling/test-client/ consumption.

Sprint 8 P1 total: ~5.5d server + ~3.25d client + ~2d joint = ~10.75d combined effort

Sprint 9 P0 — Full test pipeline operational

# Title Track Team Dependencies Effort Bug class prevented
26 Layer 3 subprocess test T4 server #4, #24 1d Bug #1 (subprocess I/O blocking), binary startup issues
27 Test client replay loading + tick-scheduled sending T3 server #24 0.5d Unblocks scripted Gauntlet testing (tooling/test-client/)
28 Test client golden file comparison (JSON diff) T3 server #24, #25 0.5d Determinism regression, snapshot format drift (tooling/test-client/)
29 Golden file test suite: gauntlet_tick_10_matches_golden T5 server #11, #28 0.5d Any code change that alters snapshot output
30 make golden-diff + make golden-update targets T5 joint #29 0.25d Developer workflow for golden file updates
31 Gauntlet first 3 rooms (Inventory Warehouse, Occlusion Corridor, Pause Chamber) T1 server #4 2-3d Unblocks all room-specific testing
32 Gauntlet room constants module (server/src/test_world/constants.rs) T1 server #31 0.5d Stable coordinate references for all tests
33 Content runtime validation: boot + tick 10 + snapshot T5 server None 0.5d Content that passes schema validation but panics at runtime
34 Room reset trigger mechanism T1 server #31 1d Unblocks sustainable human testing workflow
35 Hub teleport action (PlayerAction::TeleportToHub) T1 server #31 0.5d Unblocks efficient room navigation

Sprint 9 P0 total: ~7-8d server + ~0.25d joint

Sprint 9 P1 — Polish, UX, extended coverage

# Title Track Team Dependencies Effort Bug class prevented
36 Client P2 tests: remaining camera (5), entity alpha/color (4), UI (7) T3 client None 2d Camera smoothing, entity rendering, UI elements
37 Client P3 tests: z-layer ordering (4), entity lerp (3), Tyre additions (5) T3 client None 1.5d Z-layer constants, animation, recognition transition
38 Client anti-tedium tests: bug report capture, progress hidden when not gauntlet T3 client None 0.5d Anti-tedium feature regression
39 WRONG button (F12) MVP: snapshot + text render + description T1 client + server #25, #34 1.5d Tester productivity (bug report capture)
40 Room timer + personal bests T1 client #31 0.5d Tester engagement
41 Checklist YAML schema + make checklist generation T1 joint #31 0.5d Tester workflow standardization
42 Gauntlet next 3-4 rooms (Interaction Gallery, Fog Theater, Crowd Plaza, Dialogue Room) T1 server #31 2-3d Extended system coverage
43 Performance baseline tooling (make perf-baseline) T5 joint #31 1d Performance regression detection
44 Content scaling test (baseline + extra NPC comparative) T5 server #33 1d Content additions breaking existing functionality
45 Hub teleport client UX (Home key, fade transition) T1 client #35 0.5d Anti-tedium UX
46 Room reset client UX (reset plate tile, interaction verb, amber flash) T1 client #34 0.5d Anti-tedium UX
47 Auto-checklist progress tracking (client-side snapshot evaluation) T1 client #41 1d Auto-tracking verification items

Sprint 9 P1 total: ~3-4d server + ~8d client + ~1.5d joint

Later (Sprint 10+)

# Title Track Team Dependencies Effort Bug class prevented
48 Gauntlet rooms: Sprint Gauntlet, Eavesdrop Alcove, Confrontation Stage T1 server #31 3d Interaction combination coverage
49 Gauntlet rooms: Sound Lab, Decay Observatory, Shift Change T1 server Sound system, knowledge decay 3d Audio + decay + stress coverage
50 Cross-room transition test scenarios (T1-T8 from Gestalt) T1 server #42, #48 2d System combination bugs at room boundaries
51 WRONG button full captures (input history ring buffer, snapshot history, replay seed) T1 client + server #39 1.5d Full bug reproduction capability
52 Map-agnostic invariant tests (36 invariants from Gestalt) T1 server Dynamic map system 3d Any-map structural guarantees
53 Fuzzy tests for dynamic/procedural maps T5 server Dynamic map system 1d Procedural generation correctness
54 CI pipeline (Gitea Actions YAML, 3-tier) T5 joint Lead greenlight 1d Automated merge gating
55 F3 debug overlay (deferred per Stig — WRONG button covers same data) T1 client #39 1.5d Real-time state inspection
56 Zone Gate room implementation T1 server Multi-map system 1d Zone transition correctness
57 Content scaling stress test (100 ticks, max-NPC pack, tick budget) T5 server #31, #43 1d Performance at content scale
58 blocked_entities debug field on ObserverSnapshot T3 server Feasibility TBD (R2-OQ-05) 0.5d LOS debugging in test client
59 Bidirectional relationship consistency warning (Check 9 in content validation) T5 joint #9 0.25d Asymmetric relationship detection

Summary by Sprint

Sprint P0 items P1 items Total effort
Sprint 8 10 items (~7d) 15 items (~10.75d) ~17.75d
Sprint 9 10 items (~7.5d) 12 items (~12.5d) ~20d
Sprint 10+ 12 items (~18.75d) ~18.75d

Bug Catalogue Coverage Verification

Every Sprint 6-7 bug class is covered by Sprint 8 P0/P1:

Bug Root Cause Backlog item(s)
#1 Server never sends snapshots read_framed() blocking #4 (--test-mode), #26 (Layer 3 test)
#2 Camera doesn't center at startup No snapshot until keystroke #21 (client P0: camera static during pause)
#3 Player moves while paused Pause guard not filtering #6, #7, #8, #13 (pause guard suite)
#4 MessagePack -128 for 128 Signed int8 boundary #15, #16, #17, #18 (boundary value matrix)
#5 Monologue lost on overwrite Snapshot buffer drops one-shots #21 (client P0: monologue not lost)
#6 Snapshot overwrite warning spam Server ticks faster than client #1, #2 (determinism fixes make tick rate predictable), #29 (golden file catches drift)

2. Content Validation Final Spec

Merged from Hoshe (8 checks, R2 section 2) and Justine (7 checks, R2 section 1). Reconciled per round-2-notes.md section 6.

Architecture

Extend tooling/validate-content (Python) with a second pass after schema validation. No Rust dependency. Content authors validate without compiling the server.

make validate-content
  |
  +-- Pass 1: Schema validation (existing, unchanged)
  |     -> Each YAML file against its JSON Schema
  |     -> FAILS FAST on schema errors
  |
  +-- Pass 2: Cross-reference validation (NEW)
        -> Build index of all defined entities
        -> Walk all files, check every reference resolves
        -> Report ALL errors (don't fail on first)

The 9 Checks

# Check name Severity Source files Target Error message template Source
1 canonical_id_uniqueness ERROR npcs/*.yaml canonical_id field XREF ERROR: duplicate canonical_id "{id}" -- Defined in: {file1} -- Duplicate in: {file2} Hoshe
2 relationship_target_resolution ERROR npcs/*.yaml -> relationships[].target Other NPC canonical_ids XREF ERROR: unresolved relationship target "{target}" -- In: {file} -- Known canonical_ids: {list} Hoshe + Justine
3 location_slug_resolution ERROR district.yaml -> locations[] locations/{slug}.yaml existence XREF ERROR: location slug "{slug}" not found -- In: {district_file} -- Expected file: {expected_path} Hoshe + Justine
4 dialogue_location_resolution ERROR dialogue/**/*.yaml -> location District locations[] XREF ERROR: dialogue location "{location}" not in district -- In: {file} -- District locations: {list} Hoshe + Justine
5 fact_id_resolution ERROR dialogue/**/*.yaml -> lines[].knowledge_grant.fact_id, npcs/*.yaml -> information.knows[], monologue/**/*.yaml -> prerequisites.facts[].fact_id Knowledge catalogs XREF ERROR: unknown fact_id "{id}" -- In: {file} -- Canonical fact_ids: {count} defined Hoshe + Justine (absorbs check-fact-ids)
6 triangle_membership_resolution ERROR npcs/*.yaml -> triangle_membership[] triangles/{slug}.yaml existence XREF ERROR: triangle "{slug}" not found -- In: {file} -- Available triangles: {list} Hoshe + Justine
7 npc_count_accuracy WARNING district.yaml -> npc_count Actual count of npcs/*.yaml files XREF WARNING: npc_count mismatch -- Declared: {n} -- Actual NPC files: {m} -- In: {file} Hoshe
8 dialogue_line_id_uniqueness ERROR dialogue/**/*.yaml -> lines[].id Per-file uniqueness XREF ERROR: duplicate dialogue line id "{id}" -- In: {file} -- First: line {n1} -- Duplicate: line {n2} Hoshe
9 bidirectional_relationship_consistency WARNING npcs/*.yaml -> relationships[].target Reciprocal relationship exists XREF WARNING: {npc_a} has relationship to {npc_b} but no reciprocal found Justine

Implementation Skeleton

class ContentIndex:
    """Builds an index of all defined entities for cross-referencing."""

    def __init__(self, content_dir: Path):
        self.npcs: dict[str, Path] = {}          # canonical_id -> defining file
        self.locations: dict[Path, set] = {}      # district path -> set of location slugs
        self.triangles: dict[Path, set] = {}      # district path -> set of triangle slugs
        self.fact_ids: set[str] = set()           # from knowledge catalogs
        self.dialogue_files: list[tuple[Path, dict]] = []  # (file, parsed yaml)
        self.npc_files: list[tuple[Path, dict]] = []       # (file, parsed yaml)
        self.district_files: list[tuple[Path, dict]] = []  # (file, parsed yaml)

    def build(self):
        """Scan all content files and populate the index."""
        self._scan_npcs()          # Populates self.npcs
        self._scan_locations()     # Populates self.locations
        self._scan_triangles()     # Populates self.triangles
        self._scan_knowledge()     # Populates self.fact_ids
        self._scan_districts()     # Populates self.district_files
        self._scan_dialogue()      # Populates self.dialogue_files

    def validate_references(self) -> tuple[int, int]:
        """Check all cross-references. Returns (error_count, warning_count)."""
        errors = 0
        warnings = 0

        errors += self._check_1_canonical_id_uniqueness()
        errors += self._check_2_relationship_targets()
        errors += self._check_3_location_slugs()
        errors += self._check_4_dialogue_locations()
        errors += self._check_5_fact_ids()
        errors += self._check_6_triangle_membership()
        warnings += self._check_7_npc_count()
        errors += self._check_8_dialogue_line_ids()
        warnings += self._check_9_bidirectional_relationships()

        return errors, warnings

Integration with Existing Script

# In tooling/validate-content main()
def main() -> int:
    # ... existing schema validation (Pass 1) ...

    if schema_errors > 0:
        print(f"\nSchema validation failed ({schema_errors} errors) -- skipping cross-references")
        return 1

    # Pass 2: Cross-reference validation
    index = ContentIndex(campaigns_dir)
    index.build()
    xref_errors, xref_warnings = index.validate_references()

    total_errors = schema_errors + xref_errors
    print(f"\nValidated {file_count} files: {total_errors} errors, {xref_warnings} warnings")
    return 1 if total_errors > 0 else 0

Phased Rollout

Phase Checks Sprint Notes
1 #1, #2, #3, #6 (NPC + triangle + district) Sprint 8 Core entity graph validation
2 #4, #5, #8 (dialogue + fact_ids) Sprint 8 Absorbs check-fact-ids functionality
3 #7, #9 (warnings) Sprint 9 Advisory checks, non-blocking

check-fact-ids Transition

Once Check 5 is implemented in the Python validator, check-fact-ids becomes a thin backward-compatibility wrapper:

check-fact-ids:
    @echo "NOTE: fact_id validation is now part of validate-content"
    @tooling/validate-content --fact-ids-only

Keep the wrapper until all pre-commit hooks and developer muscle memory have been updated.


3. Layer 3 Test Final Spec

Incorporating Dudley's cross-review from R2 section 7: (1) use to_vec not to_vec_named for input serialization, (2) tolerate initial tick=0 snapshot before input is processed.

Test Identity

Name:     server_subprocess_sends_snapshot_on_connect
File:     server/tests/layer3.rs
Marker:   #[test] #[ignore]  -- run via `cargo test --test layer3 -- --ignored`
Makefile: make test-layer3
Duration: <10 seconds
Tier:     Nightly + pre-merge (manual via `make test-layer3`)

Prerequisites

  • Server --test-mode flag (backlog #4)
  • Server --port 0 support (backlog #4)
  • Server prints LISTENING:{port} to stdout after TCP bind, before accept (Dudley R2 section 2)
  • Server crate pub-exports bridge types: ObserverSnapshot, PlayerInput, PlayerAction, EntityKind, PROTOCOL_VERSION, read_framed, write_framed (lead override: needed for both this test and tooling/test-client/)

Test Code

use std::process::{Command, Stdio};
use std::io::{BufRead, BufReader as StdBufReader, Write};
use std::net::TcpStream;
use std::time::Duration;

use settled_reach_server::bridge::framing::{read_framed, write_framed};
use settled_reach_server::bridge::types::*;

/// Drop guard: kill server on test exit (including panic).
struct ServerGuard(std::process::Child);
impl Drop for ServerGuard {
    fn drop(&mut self) {
        self.0.kill().ok();
        self.0.wait().ok();
    }
}

#[test]
#[ignore] // Layer 3: slow, run with --ignored
fn server_subprocess_sends_snapshot_on_connect() {
    // 1. Build server binary
    let status = Command::new("cargo")
        .args(["build", "--bin", "settled-reach-server"])
        .status()
        .expect("cargo build failed");
    assert!(status.success(), "server build failed");

    // 2. Launch server as subprocess
    let mut server = Command::new("target/debug/settled-reach-server")
        .args(["--test-mode", "--port", "0"])
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .expect("failed to launch server");
    let _guard = ServerGuard(server);  // kill on drop

    // 3. Parse port from stdout (5-second timeout)
    let stdout = server.stdout.take().unwrap();
    let mut lines = StdBufReader::new(stdout).lines();
    let deadline = std::time::Instant::now() + Duration::from_secs(5);
    let port: u16 = loop {
        if std::time::Instant::now() > deadline {
            panic!("Layer 3 FAILED: server did not print LISTENING within 5 seconds");
        }
        match lines.next() {
            Some(Ok(line)) => {
                if let Some(port_str) = line.strip_prefix("LISTENING:") {
                    break port_str.trim().parse().expect("parse port number");
                }
            }
            Some(Err(e)) => panic!("error reading server stdout: {}", e),
            None => panic!("server stdout closed before printing LISTENING"),
        }
    };

    // 4. Connect as client
    let stream = TcpStream::connect(format!("127.0.0.1:{}", port))
        .expect("connect to server");
    stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap();

    let mut reader = std::io::BufReader::new(stream.try_clone().unwrap());
    let mut writer = stream;

    // 5. Send input -- use to_vec (not to_vec_named) to match GDScript encoding
    let inputs = vec![PlayerInput {
        tick: 0,
        action: PlayerAction::MoveNorth,
    }];
    let payload = rmp_serde::to_vec(&inputs).expect("serialize input");
    write_framed(&mut writer, &payload).expect("send input");
    writer.flush().ok();

    // 6. Read snapshot(s) -- tolerate initial tick=0 snapshot before input is processed
    //    (Dudley R2 adjustment: server may send snapshot before reading client input)
    let response = read_framed(&mut reader)
        .expect("read snapshot")
        .expect("not EOF -- server must send snapshot");

    let snapshot: ObserverSnapshot =
        rmp_serde::from_slice(&response).expect("deserialize snapshot");

    // 7. Assertions -- these hold for EITHER the initial snapshot or post-input snapshot
    assert_eq!(snapshot.version, PROTOCOL_VERSION,
        "snapshot version mismatch -- protocol incompatibility");
    // tick=0 is valid (initial snapshot before input processed)
    assert!(snapshot.tick <= 1,
        "unexpected tick {} -- expected 0 or 1", snapshot.tick);
    assert!(!snapshot.entities.is_empty(),
        "snapshot must contain at least the player entity");

    let player = snapshot.entities.iter()
        .find(|e| matches!(e.kind, EntityKind::Player))
        .expect("no Player entity in snapshot");
    assert!(player.x > 0.0, "player x must be positive (in proof room)");
    assert!(player.y > 0.0, "player y must be positive (in proof room)");

    // 8. Cleanup -- ServerGuard kills on drop
}

What Layer 3 Catches That Layer 2 Doesn't

Failure mode Layer 2 (in-process) Layer 3 (subprocess)
Binary startup panic Not tested Caught (server process exits non-zero)
Subprocess I/O blocking (Bug #1) Cannot reproduce (shared memory) Caught (real TCP, real blocking)
Port binding failure Mocked Real OS port allocation
Protocol version compiled into binary Always matches (same compilation) Catches stale binary vs test expectation
Server exit on client disconnect Not tested Tested (ServerGuard kills, server should exit cleanly)

Makefile Target

test-layer3: build-server
    cd server && cargo test --test layer3 -- --ignored --nocapture

4. Boundary Value Test Final Spec

The 41-value matrix from Round 1, with placement decisions refined by Dudley's OQ-2 answer (asymmetric encoding is safe -- rmp_serde accepts int_16-encoded positive values for u64 fields).

Resolved question: Is the encoding asymmetry a bug?

No. Dudley traced through rmp-serde 1.3.1 (R2 section 4): Marker::I16 -> visit_i16 -> visit_i64 -> u64::try_from. Positive int_16 values deserialize correctly into u64. Negative values correctly rejected. The asymmetry is spec-valid and safe.

Implication: We don't need to "fix" either encoder. We DO need explicit tests documenting that both decoders accept the other side's encoding. These tests are the insurance policy.

Test Placement Matrix

Test type Location Values tested When run Purpose
GDScript encode-only client/tests/test_msgpack_boundaries.gd All 41 BV values: verify header byte + payload Every commit (via make test-client) Catches GDScript encoder regressions (Bug #4 class)
GDScript encode-decode roundtrip client/tests/test_msgpack_boundaries.gd All 41 BV values: encode -> decode -> assert equal Every commit Catches roundtrip failures
Rust encode-decode roundtrip server/tests/serialization.rs All 41 BV values as i64: to_vec -> from_slice -> assert_eq Every commit (via make test-server) Catches Rust-side encoding regressions
Rust-generated fixtures decoded by GDScript gen_fixtures.rs generates -> test_msgpack_boundaries.gd verifies 14 raw int fixtures + 5 snapshot fixtures at boundary ticks Every PR (via make pre-pr) Cross-language: Rust encoder -> GDScript decoder
GDScript-generated fixtures decoded by Rust test_gen_client_fixtures.gd generates -> serialization.rs verifies Inputs at ticks 256, 32767, 65536 Every PR (via make pre-pr) Cross-language: GDScript encoder -> Rust decoder
Asymmetry-specific raw bytes Both test_msgpack_boundaries.gd and serialization.rs Hand-crafted bytes: uint_16 decoded by GDScript, int_16 decoded by Rust Every commit Directly tests the encoding overlap zone

The 41 Boundary Values (unchanged from R1)

Positive boundaries (25 values):

ID Value Expected format Header Payload
BV-P01 0 pos fixint 0x00 -
BV-P02 1 pos fixint 0x01 -
BV-P03 126 pos fixint 0x7e -
BV-P04 127 pos fixint 0x7f -
BV-P05 128 uint 8 0xcc 0x80
BV-P06 129 uint 8 0xcc 0x81
BV-P07 254 uint 8 0xcc 0xfe
BV-P08 255 uint 8 0xcc 0xff
BV-P09 256 int 16 (GDScript) / uint 16 (Rust) 0xd1 / 0xcd varies
BV-P10 257 int 16 / uint 16 0xd1 / 0xcd varies
BV-P11 32766 int 16 / uint 16 0xd1 / 0xcd varies
BV-P12 32767 int 16 / uint 16 0xd1 / 0xcd varies
BV-P13 32768 uint 16 0xcd 0x80 0x00
BV-P14 32769 uint 16 0xcd 0x80 0x01
BV-P15 65534 uint 16 0xcd 0xff 0xfe
BV-P16 65535 uint 16 0xcd 0xff 0xff
BV-P17 65536 int 32 (GDScript) / uint 32 (Rust) 0xd2 / 0xce varies
BV-P18 65537 int 32 / uint 32 0xd2 / 0xce varies
BV-P19 2147483646 int 32 / uint 32 0xd2 / 0xce varies
BV-P20 2147483647 int 32 / uint 32 0xd2 / 0xce varies
BV-P21 2147483648 uint 32 0xce 0x80 0x00 0x00 0x00
BV-P22 4294967294 uint 32 0xce 0xff 0xff 0xff 0xfe
BV-P23 4294967295 uint 32 0xce 0xff 0xff 0xff 0xff
BV-P24 4294967296 int 64 0xd3 0x00...0x01 0x00...0x00
BV-P25 9223372036854775807 int 64 0xd3 0x7f 0xff...0xff

Negative boundaries (16 values):

ID Value Expected format Header Payload
BV-N01 -1 neg fixint 0xff -
BV-N02 -31 neg fixint 0xe1 -
BV-N03 -32 neg fixint 0xe0 -
BV-N04 -33 int 8 0xd0 0xdf
BV-N05 -34 int 8 0xd0 0xde
BV-N06 -127 int 8 0xd0 0x81
BV-N07 -128 int 8 0xd0 0x80
BV-N08 -129 int 16 0xd1 0xff 0x7f
BV-N09 -130 int 16 0xd1 0xff 0x7e
BV-N10 -32767 int 16 0xd1 0x80 0x01
BV-N11 -32768 int 16 0xd1 0x80 0x00
BV-N12 -32769 int 32 0xd2 0xff 0xff 0x7f 0xff
BV-N13 -2147483647 int 32 0xd2 0x80 0x00 0x00 0x01
BV-N14 -2147483648 int 32 0xd2 0x80 0x00 0x00 0x00
BV-N15 -2147483649 int 64 0xd3 0xff...0xff 0x7f 0xff...0xff
BV-N16 -9223372036854775808 int 64 0xd3 0x80 0x00...0x00

Encoding Overlap Zone Tests (new from R2)

These 4 tests specifically target the asymmetric encoding between GDScript (int_16 for 256-32767) and Rust (uint_16 for same range). Each direction must pass.

Direction 1: GDScript decoder accepts Rust-style unsigned encodings

# test_msgpack_boundaries.gd
func test_decode_rust_uint16_256() -> void:
    var bytes = PackedByteArray([0xcd, 0x01, 0x00])  # uint_16(256)
    var result = Messagepack.decode(bytes)
    assert_that(result.value).is_equal(256)

func test_decode_rust_uint16_32767() -> void:
    var bytes = PackedByteArray([0xcd, 0x7f, 0xff])  # uint_16(32767)
    var result = Messagepack.decode(bytes)
    assert_that(result.value).is_equal(32767)

func test_decode_rust_uint32_65536() -> void:
    var bytes = PackedByteArray([0xce, 0x00, 0x01, 0x00, 0x00])  # uint_32(65536)
    var result = Messagepack.decode(bytes)
    assert_that(result.value).is_equal(65536)

func test_decode_rust_uint32_2147483647() -> void:
    var bytes = PackedByteArray([0xce, 0x7f, 0xff, 0xff, 0xff])  # uint_32(2^31-1)
    var result = Messagepack.decode(bytes)
    assert_that(result.value).is_equal(2147483647)

Direction 2: Rust decoder accepts GDScript-style signed encodings

#[test]
fn rust_decodes_gdscript_signed_overlap_values() {
    let cases: Vec<(Vec<u8>, u64)> = vec![
        (vec![0xd1, 0x01, 0x00], 256),              // int_16(256)
        (vec![0xd1, 0x7f, 0xff], 32767),             // int_16(32767)
        (vec![0xd2, 0x00, 0x01, 0x00, 0x00], 65536), // int_32(65536)
        (vec![0xd2, 0x7f, 0xff, 0xff, 0xff], 2147483647), // int_32(2^31-1)
    ];
    for (bytes, expected) in cases {
        let value: u64 = rmp_serde::from_slice(&bytes)
            .unwrap_or_else(|e| panic!(
                "Rust failed to decode signed-encoded {} from {:?}: {}", expected, bytes, e
            ));
        assert_eq!(value, expected);
    }
}

Direction 3: Rust-generated fixtures with overlap-zone ticks

// In gen_fixtures.rs
fn generate_encoding_asymmetry_fixtures() {
    let ticks = [(256, "snapshot_tick_256"), (500, "snapshot_tick_500"),
                 (32767, "snapshot_tick_32767"), (65536, "snapshot_tick_65536")];
    for (tick, name) in ticks {
        let snapshot = fixture_snapshot(tick, vec![]);
        write_fixture(name, &rmp_serde::to_vec_named(&snapshot).unwrap());
    }
}

Direction 4: GDScript-generated fixtures decoded by Rust

New make fixtures-client target runs GDScript test that generates .msgpack files at server/tests/fixtures/gdscript/. Rust test verifies decode.

Fixture Regeneration After visible_tiles Sorting

Per Stig R2 (OQ-1): two test files reference visible_tiles[0] by index (test_protocol.gd:299-301, test_rendering.gd:66-69). After determinism Fix A sorts visible_tiles, fixture regeneration via make fixtures will update these. The tests themselves don't need code changes -- just regenerated fixture data. Flag for whoever implements Fix A: run make fixtures and commit the updated fixture files.


5. make pre-pr Final Spec

Reconciled from Hoshe (6-step, R2 section 3) and Justine (5-step, R2 section 2). The proposals are compatible -- Justine's chain is a subset of Hoshe's. This spec uses Hoshe's branch-specific variants with Justine's Makefile structure.

Resolved Tensions

Question Resolution
Fixture staleness: BLOCKER or WARNING? BLOCKER (Tyre's argument from R2 section 4.3). Stale fixtures = false positive client tests. exit 1 on stale fixtures.
Include check-fact-ids separately? Yes, until Phase 2 of content validation absorbs it. Then becomes alias.
Include content-ron (YAML->RON)? No. RON conversion is a content loader concern, not a pre-PR concern. If RON conversion fails, the boot-and-tick test (backlog #33) catches it.
pre-pr-full vs pre-pr? No split. The fixture staleness check adds ~10-15s. Total under 3 minutes. Not worth a separate target.
PR tier time budget? <3 minutes for make pre-pr (local, incremental). <15 minutes for future CI (Tyre's revision, covers clean-cache worst case).

Definitive Makefile

# ================================================================
# Pre-PR verification -- run before submitting any PR
# ================================================================

pre-pr: pre-pr-lint pre-pr-build pre-pr-test pre-pr-validate pre-pr-fixtures
	@echo ""
	@echo "=== PRE-PR: ALL CHECKS PASSED ==="
	@echo "Safe to create PR."

pre-pr-lint: lint-server lint-client
	@echo "--- Lint: PASS ---"

pre-pr-build: build-server build-client
	@echo "--- Build: PASS ---"

pre-pr-test: test-server test-client
	@echo "--- Tests: PASS ---"

pre-pr-validate: validate-content check-fact-ids
	@echo "--- Content validation: PASS ---"

pre-pr-fixtures:
	@echo "Checking fixture staleness..."
	@cd server && cargo test --test gen_fixtures -- --ignored 2>/dev/null
	@if git diff --quiet client/tests/fixtures/; then \
		echo "--- Fixtures: UP TO DATE ---"; \
	else \
		echo ""; \
		echo "--- FIXTURES STALE ---"; \
		echo "  Protocol changed but fixtures not regenerated."; \
		echo "  Stale fixtures make all client tests FALSE POSITIVES."; \
		echo ""; \
		echo "  Changed files:"; \
		git diff --stat client/tests/fixtures/; \
		echo ""; \
		echo "  Fix: commit the updated fixtures with your protocol change."; \
		exit 1; \
	fi

# ================================================================
# Branch-specific variants (faster, scope-appropriate)
# ================================================================

pre-pr-server: lint-server build-server test-server pre-pr-fixtures
	@echo "=== Server pre-PR: PASSED ==="

pre-pr-client: lint-client build-client test-client
	@echo "=== Client pre-PR: PASSED ==="

pre-pr-content: validate-content check-fact-ids
	@echo "=== Content pre-PR: PASSED ==="

Execution Chain

pre-pr (~2.5 min incremental, ~3 min clean)
  |
  +-- 1. lint-server + lint-client         (~15s)    Fast syntax/style check
  +-- 2. build-server + build-client       (~30-90s) Compilation errors
  +-- 3. test-server + test-client         (~15-30s) Logic errors
  +-- 4. validate-content + check-fact-ids (~5s)     Content correctness
  +-- 5. fixture staleness check           (~10-15s) Protocol drift

Fail-fast behavior: Make's default && chaining. If lint fails, build is skipped. If build fails, tests are skipped. Error messages from each step are visible in terminal.

Developer Documentation (add to docs/DEVOPS.md)

## Pre-PR Checklist

Before pushing a PR, run:

    make pre-pr

This runs all checks in order: lint -> build -> test -> content validation -> fixture staleness.

For branch-specific checks:
- Server changes: `make pre-pr-server`
- Client changes: `make pre-pr-client`
- Content changes: `make pre-pr-content`

If `fixtures-check` fails, your protocol changes require fixture regeneration:

    make fixtures
    git add client/tests/fixtures/
    git commit -m "chore(fixtures): regenerate for protocol vN"

Total runtime: ~2.5 minutes (incremental build). Under 3 minutes clean.

Future CI Mapping

When the lead greenlights CI, the make pre-pr chain maps directly to the PR tier:

make pre-pr step CI job Duration
lint-server + lint-client commit-checks job ~15s
build-server + build-client pr-checks job (parallel) ~90s
test-server + test-client pr-checks job (parallel) ~30s
validate-content + check-fact-ids commit-checks job ~5s
fixture staleness pr-checks job (after build) ~15s

No new work needed -- just wrap make pre-pr in a Gitea Actions workflow.


Appendix: Open Questions Deferred to Implementation

These questions were raised in Round 2 but do not block the backlog. They should be resolved during implementation by the assigned team.

ID Question Raised by Assigned to When to resolve
R2-OQ-01 SetTickRate(Half) while paused -- should this unpause? Hoshe Dudley During pause guard test implementation (#13)
R2-OQ-02 Entity respawn + registry stale mapping Hoshe Dudley During EntityRegistry test implementation (#14)
R2-OQ-05 blocked_entities debug field feasibility Ozzie Dudley During test client binary work (#24)
R2-OQ-09 Gauntlet room ordering in YAML (StableId assignment) Gestalt Dudley During Gauntlet first rooms (#31)
R2-OQ-10 Per-room reset sufficient, or need full restart? Gestalt Dudley During room reset implementation (#34)
R2-OQ-11 Client tests headless stability Justine Stig During client test expansion (#21, #22)
OQ-11 Cross-room checklist YAML location Ozzie Gestalt During checklist spec (#41)