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>
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-modeflag (backlog #4) - Server
--port 0support (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 andtooling/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) |