--- title: "Hoshe — Round 3: Prioritized Test Backlog, Final Specifications" description: "Hoshe's prioritized test backlog and final specifications for serialization and CI tests" type: workshop status: archived workshop: test-architecture agent: "hoshe" round: 3 created: 2026-02-17 --- # 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 ```python 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 ```python # 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: ```makefile 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 ```rust 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 ```makefile 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** ```gdscript # 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** ```rust #[test] fn rust_decodes_gdscript_signed_overlap_values() { let cases: Vec<(Vec, 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** ```rust // 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 ```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) ```markdown ## 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) |