Files
settled-reach/docs/workshops/test-architecture/hoshe-round2.md
T
jpmschweitzerandClaude Sonnet 4.6 c056ea1a1c feat(content): migrate line IDs to NPC-scoped namespace (D-035)
Line IDs in all dialogue and monologue pool files renamed from the
old location-scoped format (e.g. the-terminal_d_039) to the new
NPC-scoped format (e.g. kael-davan_d_001) per the D-035 Sprint 15
amendment.

Changes:
- 20 dialogue pool files across 3 locations renamed
- 14 monologue pool files (detective + smuggler) renamed
- Schema descriptions updated in dialogue/monologue schema files
- Authoring style guide and design docs updated with new examples
- Multi-location NPCs (kael-davan, pc-detective, pc-smuggler) given
  globally unique cross-file sequences to satisfy XREF uniqueness check

The location-scoped scheme already caused a collision (the-terminal_d_039
appearing in multiple NPC files) and would not scale to procedurally
generated NPC populations (D-029). Zero content changes — pure ID
substitution.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 21:04:43 +01:00

652 lines
30 KiB
Markdown

# 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<PlayerInput> 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
```python
# 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:**
```python
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
```makefile
# 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.
```makefile
# 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`:
```markdown
## Pre-PR Checklist
Before pushing a PR, run:
make pre-pr
This runs all checks in order: lint → build → test → content validation → fixture staleness.
For branch-specific checks:
- Server changes: `make pre-pr-server`
- Client changes: `make pre-pr-client`
- Content changes: `make pre-pr-content`
If `fixtures-check` fails, your protocol changes require fixture regeneration:
make fixtures
git add client/tests/fixtures/
git commit -m "chore(fixtures): regenerate for protocol vN"
```
---
## 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`:
```gdscript
# 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`:
```rust
#[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`:
```rust
#[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`:
```gdscript
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.
```gdscript
# 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)
```
```rust
// 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:**
```makefile
test-layer3: build-server
cd server && cargo test --test layer3 -- --ignored --nocapture
```
2. **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.
3. **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]
```
4. **Cleanup robustness:** Use `Drop` guard on the child process to ensure cleanup even on panic:
```rust
struct ServerGuard(std::process::Child);
impl Drop for ServerGuard {
fn drop(&mut self) {
self.0.kill().ok();
self.0.wait().ok();
}
}
```
5. **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?