Files
settled-reach/docs/workshops/test-architecture/hoshe-round2.md
T
jpmschweitzerandClaude Opus 4.6 563a295a90 docs(docs): add frontmatter to test-architecture workshop
Standardized YAML frontmatter on all 20 files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 00:01:23 +01:00

30 KiB

title, description, type, status, workshop, agent, round, created
title description type status workshop agent round created
Hoshe — Round 2 Analysis: Coverage Validation, Content Validation, Pre-PR, Encoding Asymmetry Hoshe's round 2 coverage validation, content validation tooling, and encoding asymmetry analysis workshop archived test-architecture hoshe 2 2026-02-17

Hoshe — Round 2 Analysis: Coverage Validation, Content Validation, Pre-PR, Encoding Asymmetry

Workshop: QA Strategy & Test Architecture Track: Cross-review (T4 coverage) + T5 (content validation, CI) Date: 2026-02-17 Round: 2 (Synthesis)


1. Coverage Validation of Dudley's Server Proposals

I reviewed the Round 1 notes (sections 4.6, 4.7, 4.8), the existing tests in server/src/simulation/input.rs (lines 364-1027), server/src/knowledge/registry.rs (lines 83-161), and the live implementation code. Here's my assessment.

1.1 Pause Guard Tests — Gaps Found

Current state: One test exists: process_input_pause_sets_paused (input.rs:436). It verifies that Pause action sets TickRate::Paused. It does NOT verify that movement is discarded while paused.

Dudley proposed 6 tests. Assessment:

Dudley's proposed test Priority Gap? Assessment
movement_discarded_while_paused P0 YES — critical gap Bug #3 regression. The actual pause guard (input.rs:102-105) is if paused && input.action.is_movement() { continue; } — this works, but there's ZERO test coverage. If someone refactors the match arms or changes is_movement(), this breaks silently.
unpause_accepted_while_paused P0 YES — gap Must verify Unpause action reaches time.tick_rate = TickRate::Full even when paused == true. Current test only tests Pause, not Unpause-while-paused.
stance_toggle_allowed_while_paused P1 YES — gap The pause guard only skips is_movement(). Stance toggles should pass through. No test verifies this.
interact_allowed_while_paused P1 YES — gap Same logic — Interact is not movement, should be processed while paused. Especially important because D-052 says "UI stays responsive" during pause.
multiple_movements_in_paused_batch_all_discarded P1 YES — gap Tests that a batch of 3+ movements ALL get discarded, not just the first. Guards against early-exit bugs in the loop.
pause_unpause_roundtrip_with_movement P1 YES — gap Full cycle: Pause → move (discarded) → Unpause → move (accepted). Most important integration test of the set.

Verdict: All 6 are genuine gaps. The pause guard has ZERO direct test coverage today.

The existing test (process_input_pause_sets_paused) tests the Pause action side effect, not the pause guard itself. If someone deleted lines 102-105 (the guard), all existing tests would still pass. That's the gap.

Additional gaps I found that Dudley didn't propose:

Test Priority Rationale
set_tick_rate_while_paused P2 What happens if SetTickRate(Half) is sent while paused? Code at input.rs:165-168 sets the rate unconditionally — should this unpause or stay paused? Current behavior: sets to Half, which means paused() returns false on next tick. Might be intentional but needs explicit test.
perception_mode_while_paused P2 UsePerceptionMode is currently a no-op (input.rs:193-195), but it should pass through the pause guard since it's not movement. When it becomes functional, this test prevents regression.
interact_take_while_paused P2 Can the player Take an item while paused? The pause guard only blocks is_movement(). Take/Place go through. Is this intentional? Needs a test that documents the expected behavior either way.

1.2 EntityRegistry Lifecycle Tests — Gaps Found

Current state: 6 tests in registry.rs:83-161: sequential IDs, idempotent register, bidirectional lookup, unregister removes both directions, seed offset, unknown returns None.

Dudley proposed 5 tests. Assessment:

Dudley's proposed test Priority Gap? Assessment
old_stable_id_not_resolvable_after_unregister P0 Partially covered unregister_removes_both_directions tests this — after unregister, to_entity(&id) returns None. But Dudley's concern is more subtle: after unregister + re-spawn a new entity, does the OLD StableId point to the NEW entity? This is NOT tested.
register_after_unregister_gets_new_id P1 YES — gap If entity E is registered (StableId=5), unregistered, then a NEW entity is registered, it should get StableId=6, not StableId=5. Guarantees ID monotonicity across the lifecycle.
register_respawn_no_stale_mapping P0 YES — critical gap bevy_ecs recycles Entity indices. If Entity(index=3, gen=1) is despawned and Entity(index=3, gen=2) is spawned, the registry must NOT return the old StableId for the new entity. The existing test doesn't test this because it doesn't use a real World with despawn+respawn.
concurrent_register_unregister P2 Not applicable EntityRegistry is a Resource accessed through ResMut (exclusive), so concurrent access is impossible in bevy_ecs. This test would be testing bevy's scheduler, not our code. Skip.
bulk_register_performance P2 Nice-to-have Registry uses BTreeMap — O(log N) for insert. At 80 active NPCs + 2000 background, we're talking ~2000 entries. Not a performance concern. Skip for now.

Additional gaps I found:

Test Priority Rationale
unregister_unknown_entity_is_noop P1 Calling unregister(e) on an entity that was never registered should not panic or corrupt state. The current code handles this (the if let Some guard in line 67), but there's no test.
register_with_pre_existing_stable_id_component P2 If an entity is spawned with StableEntityId(StableId(42)) component but NOT yet in the registry, calling register() assigns a NEW StableId, not 42. Is this correct? The component and registry could diverge. Needs at minimum a documented test.

1.3 Determinism Fixes — Coverage Assessment

Proposed fixes from Round 1:

Fix Coverage status Assessment
visible_ids: HashSet → BTreeSet Untested No test verifies that sprint anomaly detection selects a deterministic "first Contradicted match." The fix itself is correct (BTreeSet iterates in order), but a test should run the observer with 2 equidistant contradicted NPCs and assert the same one is selected on both runs.
Sort visible_tiles by (x, y, z) Untested No test checks ordering of visible_tiles in the ObserverSnapshot. Golden file tests will catch this implicitly, but a focused unit test is valuable: generate a snapshot with tiles added in random order, assert the output Vec is sorted.
Pin monologue system ordering Untested directly The determinism regression test (gauntlet_deterministic_replay) will catch this. But a focused test should verify that .after() constraints are respected — run 100 times, assert identical output.
Sort movers by Entity bits Untested Dudley notes "no test for which one wins" in equidistant movement. The fix (sort by bits) needs a test with two entities at the same distance attempting to move to the same tile, asserting deterministic winner.

Recommendation: Each determinism fix should ship with its own regression test, not just rely on the broad gauntlet_deterministic_replay test. The broad test is the safety net; individual tests are the documentation.

1.4 Bridge Deserialization (Section 4.8) — Assessment

Dudley raised the question: should batch deserialization skip-and-log bad inputs, or reject the entire batch?

My assessment: Keep batch-failure for now, but add a test that documents the behavior.

Rationale:

  • Both sides are co-versioned (D-020). A malformed input is a programming error, not user input.
  • The boundary value test matrix (my Round 1 deliverable) prevents the main class of encoding bugs.
  • Skip-and-log adds complexity and could mask real bugs during development.
  • The test I'd add: malformed_input_in_batch_rejects_entire_batch — send a Vec where one entry has an invalid action variant. Assert: server logs error, discards entire batch.

2. Content Cross-Reference Validation — Sprint 8 Specification

Context: make validate-content currently runs schema validation only (YAML structure against JSON Schema). make check-fact-ids validates fact_id references. Neither validates entity references, location slugs, or dialogue pool tags.

2.1 Architecture Decision

Extend tooling/validate-content with a second pass, not a separate script. The schema validation pass runs first (fails fast on malformed YAML). The cross-reference pass runs second (requires all files to be parseable).

make validate-content
  │
  ├── Pass 1: Schema validation (existing, unchanged)
  │     → Each YAML file against its JSON Schema
  │     → FAILS FAST on schema errors (no point cross-referencing broken files)
  │
  └── Pass 2: Cross-reference validation (NEW)
        → Build index of all defined entities, locations, fact_ids, pools
        → Walk all files, check every reference resolves
        → Report ALL errors (don't fail on first)

2.2 Cross-Reference Checks — Complete Specification

I audited the content directory structure. Here are all cross-reference relationships that exist in the content:

Check 1: NPC canonical_id uniqueness

What: Every NPC profile YAML has a canonical_id field (e.g., "npc:kael-davan"). These MUST be globally unique.

Where: content/campaigns/**/npcs/*.yaml

How: Build Set<canonical_id> from all NPC profiles. Error on duplicate.

Error format:

XREF ERROR: duplicate canonical_id "npc:kael-davan"
  Defined in: campaigns/main/.../npcs/kael-davan.yaml
  Duplicate in: campaigns/main/.../npcs/kael-davan-copy.yaml

Check 2: NPC relationship target resolution

What: Each NPC profile has a relationships array where each entry has a target field (e.g., "npc:nils-davan"). Every target MUST match a defined canonical_id.

Where: content/campaigns/**/npcs/*.yamlrelationships[].target

How: For each target value, check membership in the canonical_id set.

Error format:

XREF ERROR: unresolved relationship target "npc:unknown-person"
  In: campaigns/main/.../npcs/kael-davan.yaml
  Relationship to: "npc:unknown-person" (kind: "colleague")
  Known canonical_ids: npc:kael-davan, npc:nils-davan, ... (21 defined)

Check 3: Location slug resolution

What: Each district.yaml lists location slugs in its locations array (e.g., ["the-terminal", "the-last-shift", "maintenance-corridors"]). Each slug MUST correspond to a location YAML file at locations/{slug}.yaml in the same district.

Where: content/campaigns/**/district.yamllocations[]

How: For each slug in locations, check that locations/{slug}.yaml exists in the same directory.

Error format:

XREF ERROR: location slug "the-docks" not found
  In: campaigns/main/.../transit/district.yaml
  Expected file: campaigns/main/.../transit/locations/the-docks.yaml
  Available locations: the-terminal, the-last-shift, maintenance-corridors

Check 4: Dialogue pool location resolution

What: Each dialogue YAML file has a location field (e.g., location: the-terminal). This MUST match a location slug defined in the parent district's district.yaml.

Where: content/campaigns/**/dialogue/**/*.yamllocation

How: Walk up the directory tree to find the parent district's district.yaml. Check location value against the district's locations array.

Error format:

XREF ERROR: dialogue location "the-warehouse" not in district
  In: campaigns/main/.../dialogue/the-terminal/kael-davan.yaml
  Location: "the-warehouse"
  District locations: the-terminal, the-last-shift, maintenance-corridors

Check 5: Dialogue knowledge_grant.fact_id resolution

What: Some dialogue lines have a knowledge_grant with a fact_id (e.g., fact_id: investigation.manifest_discrepancy). These MUST be valid fact IDs.

Where: content/campaigns/**/dialogue/**/*.yamllines[].knowledge_grant.fact_id

How: Reuse the canonical fact_id set from check-fact-ids logic. This is a superset of what check-fact-ids already does, but integrated into the same pass.

Note: This subsumes make check-fact-ids for dialogue files. We keep check-fact-ids as a standalone check because it also covers monologue prerequisites.facts[].fact_id.

Error format:

XREF ERROR: unknown fact_id "investigation.unknown_fact"
  In: campaigns/main/.../dialogue/the-terminal/kael-davan.yaml
  Line: kael-davan_d_099
  Canonical fact_ids: 42 defined in content/global/knowledge/

Check 6: NPC triangle_membership resolution

What: NPC profiles list triangle_membership (e.g., ["hub-power", "worried-partner"]). Each MUST match a triangle YAML file in the same district.

Where: content/campaigns/**/npcs/*.yamltriangle_membership[]

How: Check that triangles/{slug}.yaml exists in the same district.

Error format:

XREF ERROR: triangle "unknown-triangle" not found
  In: campaigns/main/.../npcs/kael-davan.yaml
  Expected file: campaigns/main/.../triangles/unknown-triangle.yaml
  Available triangles: bar-tensions, hub-power, informant-question, worried-knowledge, worried-partner

Check 7: District npc_count accuracy

What: Each district.yaml has npc_count: N. This SHOULD match the actual number of NPC profile YAML files in the npcs/ subdirectory.

Where: content/campaigns/**/district.yamlnpc_count

How: Count files in npcs/*.yaml in the same district. Compare against declared npc_count.

Severity: WARNING, not ERROR. The count might intentionally differ during content development. But a mismatch should be visible.

Warning format:

XREF WARNING: npc_count mismatch in transit district
  Declared: 17
  Actual NPC files: 20
  In: campaigns/main/.../transit/district.yaml

Check 8: Dialogue line ID uniqueness within pool

What: Each dialogue line has an id field (e.g., kael-davan_d_001). IDs MUST be unique within each dialogue file.

Where: content/campaigns/**/dialogue/**/*.yamllines[].id

How: Build Set<id> per file. Error on duplicate.

Error format:

XREF ERROR: duplicate dialogue line id "kael-davan_d_015"
  In: campaigns/main/.../dialogue/the-terminal/kael-davan.yaml
  First occurrence: line 167
  Duplicate: line 215

2.3 Implementation Plan

# Additions to tooling/validate-content (after schema validation pass)

def cross_reference_validation(campaigns_dir: Path) -> int:
    """Pass 2: Cross-reference validation across content files."""
    errors = 0
    warnings = 0

    # Phase 1: Build indices
    canonical_ids: dict[str, Path] = {}     # canonical_id → defining file
    location_files: dict[Path, set] = {}    # district path → set of location slugs
    triangle_files: dict[Path, set] = {}    # district path → set of triangle slugs
    fact_ids: set[str] = set()              # canonical fact_ids

    # Phase 2: Walk and validate references
    # ... (checks 1-8 as specified above)

    return errors

Integration with existing script:

def main() -> int:
    # ... existing schema validation (Pass 1) ...

    if errors > 0:
        print(f"\nSchema validation failed — skipping cross-reference checks")
        return 1

    # Pass 2: Cross-reference validation
    xref_errors = cross_reference_validation(campaigns_dir)
    errors += xref_errors

    print(f"\nValidated {validated} files, {skipped} skipped, {errors} errors, {warnings} warnings")
    return 1 if errors else 0

2.4 Summary Table

Check Severity What Fields checked
1 ERROR canonical_id uniqueness npcs/*.yaml → canonical_id
2 ERROR relationship target resolution npcs/*.yaml → relationships[].target
3 ERROR location slug existence district.yaml → locations[]
4 ERROR dialogue location matches district dialogue/**/*.yaml → location
5 ERROR knowledge_grant.fact_id validity dialogue/**/*.yaml → lines[].knowledge_grant.fact_id
6 ERROR triangle membership existence npcs/*.yaml → triangle_membership[]
7 WARNING npc_count matches file count district.yaml → npc_count
8 ERROR dialogue line ID uniqueness dialogue/**/*.yaml → lines[].id

Estimated effort: 1-2 days. The script structure is straightforward — build indices in one pass, validate references in a second pass. The hardest part is the directory-tree walk logic for finding parent districts.


3. Manual Testing Protocol — make pre-pr Target

Context: No automated CI (lead decision). Developers need a clear checklist before submitting PRs.

3.1 make pre-pr Target Specification

# Pre-PR checklist: run before submitting any PR
# Chains all checks in dependency order. Fails fast on first error.
pre-pr: lint build test validate-content check-fact-ids fixtures-check
	@echo ""
	@echo "Pre-PR checks PASSED. Safe to push."

# Verify fixtures are not stale (protocol changes require regeneration)
fixtures-check: fixtures
	@if git diff --quiet client/tests/fixtures/; then \
		echo "Fixtures: up to date"; \
	else \
		echo "FIXTURES STALE — run 'make fixtures' and commit the updated files"; \
		git diff --stat client/tests/fixtures/; \
		exit 1; \
	fi

Execution order (sequential, fails fast):

Step Target What it does Duration Catches
1 lint lint-server + lint-client ~30s Clippy warnings, fmt violations, GDScript errors
2 build build-server + build-client ~60s Compilation errors both sides
3 test test-server + test-client ~30s All unit + integration tests
4 validate-content YAML schema + cross-references ~5s Broken content files
5 check-fact-ids Fact ID resolution ~2s Dangling fact references
6 fixtures-check Regenerate fixtures + git diff ~10s Stale protocol fixtures

Total: ~2.5 minutes. Fast enough to run before every PR push.

3.2 Branch-Specific Variants

Not all checks apply to all branches. Content-only PRs don't need server builds.

# Server branch pre-PR (no content checks needed)
pre-pr-server: lint-server build-server test-server fixtures-check
	@echo "Server pre-PR checks PASSED."

# Client branch pre-PR (no server build needed)
pre-pr-client: lint-client build-client test-client
	@echo "Client pre-PR checks PASSED."

# Copy/content branch pre-PR (no builds needed)
pre-pr-content: validate-content check-fact-ids
	@echo "Content pre-PR checks PASSED."

3.3 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"

4. Encoding Asymmetry — Cross-Language Decode Tests

4.1 The Problem

From my Round 1 analysis: GDScript's messagepack.gd encoder uses int_16 (signed, header 0xd1) for positive values 256-32767. Rust's rmp_serde uses uint_16 (unsigned, header 0xcd) for the same values. Both are spec-valid, but they produce different bytes.

This means:

  • The GDScript decoder must accept uint_16 (what Rust sends)
  • The Rust decoder must accept int_16 (what GDScript sends)
  • Both must produce the same logical value from either encoding

4.2 Concrete Cross-Language Tests

Test A: GDScript decoder accepts Rust-style unsigned encodings

These tests belong in client/tests/test_msgpack_boundaries.gd:

# Verify GDScript decoder handles unsigned encodings (Rust-style)
# for values that GDScript would encode as signed

func test_decode_uint16_256() -> void:
    # Rust encodes 256 as uint_16: [0xcd, 0x01, 0x00]
    var bytes = PackedByteArray([0xcd, 0x01, 0x00])
    var result = Messagepack.decode(bytes)
    assert_that(result.status).is_null()
    assert_that(result.value).is_equal(256)

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

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

func test_decode_uint32_2147483647() -> void:
    # Rust encodes 2147483647 as uint_32: [0xce, 0x7f, 0xff, 0xff, 0xff]
    var bytes = PackedByteArray([0xce, 0x7f, 0xff, 0xff, 0xff])
    var result = Messagepack.decode(bytes)
    assert_that(result.status).is_null()
    assert_that(result.value).is_equal(2147483647)

Why these specific values: 256 and 32767 are in the int_16/uint_16 overlap zone. 65536 and 2147483647 are in the int_32/uint_32 overlap zone. These are the exact values where GDScript and Rust encode differently.

Test B: Rust decoder accepts GDScript-style signed encodings

These tests belong in server/tests/serialization.rs:

#[test]
fn rust_decodes_gdscript_signed_encoding_for_positive_values() {
    // GDScript encodes 256 as int_16: [0xd1, 0x01, 0x00]
    // Rust must decode this into u64 correctly
    let bytes: Vec<u8> = vec![0xd1, 0x01, 0x00];
    let value: u64 = rmp_serde::from_slice(&bytes)
        .expect("Rust must accept int_16-encoded positive value as u64");
    assert_eq!(value, 256);
}

#[test]
fn rust_decodes_gdscript_signed_encoding_for_tick() {
    // GDScript sends tick=500 encoded as int_16 inside a PlayerInput map
    // Verify the full struct deserializes correctly
    let tick_500_int16 = vec![0xd1, 0x01, 0xf4]; // int_16(500)
    let value: u64 = rmp_serde::from_slice(&tick_500_int16)
        .expect("tick=500 as int_16 must deserialize into u64");
    assert_eq!(value, 500);
}

#[test]
fn rust_decodes_all_overlap_zone_values() {
    // Values where GDScript uses signed and Rust uses unsigned
    let overlap_values: 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(2147483647)
    ];

    for (bytes, expected) in overlap_values {
        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);
    }
}

Test C: Fixture-based cross-language roundtrip for overlap values

Add to gen_fixtures.rs:

#[test]
#[ignore]
fn generate_encoding_asymmetry_fixtures() {
    // Generate snapshots with tick values in the signed/unsigned overlap zones
    // GDScript will encode these differently than Rust — but both must decode correctly

    let overlap_ticks = vec![
        (256, "snapshot_tick_256"),
        (500, "snapshot_tick_500"),
        (999, "snapshot_tick_999"),
        (32767, "snapshot_tick_32767"),
        (65536, "snapshot_tick_65536"),
    ];

    for (tick, name) in overlap_ticks {
        let snapshot = fixture_snapshot(tick, vec![]);
        write_fixture(name, &rmp_serde::to_vec_named(&snapshot).unwrap());
    }
}

And in client/tests/test_msgpack_boundaries.gd:

func test_decode_overlap_fixtures() -> void:
    # Rust-generated snapshots with tick values in the encoding overlap zone
    var overlap_ticks = [
        ["snapshot_tick_256", 256],
        ["snapshot_tick_500", 500],
        ["snapshot_tick_999", 999],
        ["snapshot_tick_32767", 32767],
        ["snapshot_tick_65536", 65536],
    ]
    for pair in overlap_ticks:
        var bytes = _load_fixture(pair[0])
        var snapshot = Protocol.decode_snapshot(bytes)
        assert_that(snapshot).is_not_null()
        assert_that(snapshot.tick).is_equal(pair[1])

Test D: GDScript-encoded values decoded by Rust

This is the reverse direction — GDScript encodes, Rust decodes. This requires generating fixtures from the GDScript side.

Proposal: Add a make fixtures-client target that runs a GDScript test which generates .msgpack fixtures in server/tests/fixtures/gdscript/. The server test suite then verifies these decode correctly.

# client/tests/test_gen_client_fixtures.gd (run via make fixtures-client)
func test_generate_client_fixtures() -> void:
    var dir = "res://../../server/tests/fixtures/gdscript/"
    DirAccess.make_dir_recursive_absolute(dir)

    # Encode tick=256 (GDScript uses int_16, Rust uses uint_16)
    var input_256 = Protocol.encode_player_input(256, "MoveNorth")
    _write_fixture(dir + "input_tick_256.msgpack", input_256)

    var input_32767 = Protocol.encode_player_input(32767, "MoveNorth")
    _write_fixture(dir + "input_tick_32767.msgpack", input_32767)

    var input_65536 = Protocol.encode_player_input(65536, "MoveNorth")
    _write_fixture(dir + "input_tick_65536.msgpack", input_65536)
// server/tests/serialization.rs (new test)
#[test]
fn gdscript_encoded_inputs_deserialize() {
    let fixture_dir = std::path::Path::new("tests/fixtures/gdscript");
    if !fixture_dir.exists() {
        eprintln!("GDScript fixtures not generated — run 'make fixtures-client' first");
        return; // Skip, don't fail
    }

    for entry in std::fs::read_dir(fixture_dir).expect("read fixture dir") {
        let path = entry.unwrap().path();
        if path.extension().and_then(|e| e.to_str()) != Some("msgpack") {
            continue;
        }
        let bytes = std::fs::read(&path).unwrap();
        let input: PlayerInput = rmp_serde::from_slice(&bytes)
            .unwrap_or_else(|e| panic!("Failed to decode GDScript fixture {:?}: {}", path, e));
        assert!(input.tick > 0, "tick should be positive");
    }
}

4.3 Summary of Encoding Asymmetry Test Coverage

Direction What Test location When
Rust → GDScript (fixture) Rust encodes snapshots with overlap-zone ticks, GDScript decodes test_msgpack_boundaries.gd Every PR
GDScript → Rust (fixture) GDScript encodes inputs with overlap-zone ticks, Rust decodes serialization.rs Every PR
Rust → GDScript (raw bytes) Hand-crafted uint_16/uint_32 bytes, GDScript decodes test_msgpack_boundaries.gd Every commit
GDScript → Rust (raw bytes) Hand-crafted int_16/int_32 bytes, Rust decodes serialization.rs Every commit

5. Layer 3 Test Refinement

Based on the lead decisions:

  • --test-mode and --port 0 are greenlit for Sprint 8
  • No CI pipeline, so Layer 3 runs manually via make test-layer3

5.1 Refinements from Lead Decisions

Original spec (from Round 1): server_subprocess_sends_snapshot_on_connect — launches server binary as child process, sends input, reads snapshot.

Refinements:

  1. Makefile target:
test-layer3: build-server
	cd server && cargo test --test layer3 -- --ignored --nocapture
  1. Server startup protocol: The --test-mode flag output format should be:
SETTLED_REACH_SERVER_READY port=9876

This is both grep-parseable and human-readable. The test parses the port number.

  1. Timeout handling: Add a 10-second overall timeout on the test. If the server doesn't print the ready line within 5 seconds, fail with:
Layer 3 FAILED: server did not become ready within 5 seconds
  Stderr: [last 10 lines of server stderr]
  1. Cleanup robustness: Use Drop guard on the child process to ensure cleanup even on panic:
struct ServerGuard(std::process::Child);
impl Drop for ServerGuard {
    fn drop(&mut self) {
        self.0.kill().ok();
        self.0.wait().ok();
    }
}
  1. No TCP port conflicts: --port 0 assigns a random port. The test reads the port from stdout. No hardcoded port numbers.

5.2 Waiting for Dudley's Cross-Review

Dudley's Round 2 may suggest changes to the --test-mode flag behavior (what content to load, what seed to use, whether to auto-exit). I'll incorporate those in Round 3 if needed.


Summary of Deliverables

Deliverable Section Status
Dudley's pause guard coverage audit (6 gaps + 3 additional) 1.1 Complete
Dudley's EntityRegistry coverage audit (3 real gaps + 2 additional) 1.2 Complete
Determinism fix coverage assessment 1.3 Complete
Bridge deserialization recommendation 1.4 Complete
Content cross-reference validation spec (8 checks) 2 Complete
make pre-pr target specification 3 Complete
Cross-language encoding asymmetry tests (4 directions) 4 Complete
Layer 3 test refinement 5 Complete

Open Questions for Round 3

  1. For Dudley: SetTickRate(Half) while paused — should this unpause the simulation? Current code sets the rate unconditionally (input.rs:165-168), which means paused() returns false on next tick. Is this intentional or a bug?
  2. For Dudley: Entity respawn + registry — when bevy recycles an Entity index, does the current registry correctly handle the case where the old Entity's StableId was NOT unregistered before the new Entity spawns? (Registry keys use Entity, which includes generation — so this might be safe, but needs verification.)
  3. For Tyre: The make pre-pr target — should it also include make content-ron (YAML→RON conversion)? This would catch content that passes schema validation but fails RON conversion.
  4. For Justine: Fixture staleness check in make pre-pr — the fixtures target runs cargo test --test gen_fixtures -- --ignored, which requires a full server build. Should this be a separate make pre-pr-full target to keep the basic pre-PR fast?