Standardized YAML frontmatter on all 20 files. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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/*.yaml → relationships[].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.yaml → locations[]
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/**/*.yaml → location
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/**/*.yaml → lines[].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/*.yaml → triangle_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.yaml → npc_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/**/*.yaml → lines[].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-modeand--port 0are 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:
- Makefile target:
test-layer3: build-server
cd server && cargo test --test layer3 -- --ignored --nocapture
- Server startup protocol: The
--test-modeflag output format should be:
SETTLED_REACH_SERVER_READY port=9876
This is both grep-parseable and human-readable. The test parses the port number.
- 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]
- Cleanup robustness: Use
Dropguard 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();
}
}
- No TCP port conflicts:
--port 0assigns 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
- For Dudley:
SetTickRate(Half)while paused — should this unpause the simulation? Current code sets the rate unconditionally (input.rs:165-168), which meanspaused()returns false on next tick. Is this intentional or a bug? - 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.)
- For Tyre: The
make pre-prtarget — should it also includemake content-ron(YAML→RON conversion)? This would catch content that passes schema validation but fails RON conversion. - For Justine: Fixture staleness check in
make pre-pr— thefixturestarget runscargo test --test gen_fixtures -- --ignored, which requires a full server build. Should this be a separatemake pre-pr-fulltarget to keep the basic pre-PR fast?