Standardized YAML frontmatter on all 20 files. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
24 KiB
title, description, type, status, workshop, agent, round, created
| title | description | type | status | workshop | agent | round | created |
|---|---|---|---|---|---|---|---|
| Tyre — Round 2 Analysis: Test Client Binary, Cross-Review, Open Questions | Tyre's test client binary architecture, cross-review of all round 1 outputs, and open question resolution | workshop | archived | test-architecture | tyre | 2 | 2026-02-17 |
Tyre — Round 2 Analysis: Test Client Binary, Cross-Review, Open Questions
Workshop: QA Strategy & Test Architecture Round: 2 (Cross-review and integration) Date: 2026-02-17 Inputs: Stig Round 1 (32 client tests, text renderer), Dudley Round 1 (Gauntlet, determinism, replay, serialization), Hoshe Round 1 (boundary values, Layer 3, CI), Justine Round 1 (CI pipeline, perf regression, golden files)
1. Test Client Binary Architecture
The lead overruled my Round 1 recommendation of a server-side text renderer. The reasoning is sound: a separate binary avoids server bloat, keeps the testing landscape clean, and — critically — exercises the real TCP boundary. I argued against it on simplicity grounds, but the architectural purity argument wins. Let me design it properly.
Where It Lives
server/src/bin/test_client.rs — a second binary target in the server crate.
Add to server/Cargo.toml:
[[bin]]
name = "settled-reach-test-client"
path = "src/bin/test_client.rs"
Rationale for same crate, separate binary:
| Option | Pros | Cons |
|---|---|---|
server/src/bin/test_client.rs |
Shares bridge types + framing code. Zero new crate management. Built with cargo build --bin settled-reach-test-client. |
"In the server crate" may look like server bloat at first glance. |
New workspace crate test-client/ |
Clean separation. Can't accidentally use server internals. | New crate boilerplate, workspace config, dependency management. Types need to be either duplicated or extracted to a shared crate. |
tooling/test-client/ |
Consistent with project layout. | Same dependency issues as workspace crate. Not under cargo workspace. |
The second binary target wins because:
- Shared types are the point. The test client needs
ObserverSnapshot,PlayerInput,read_framed,write_framed— all defined in the server crate's library. A separate crate would need to depend on the server crate anyway (or we'd extract aprotocolcrate, which is premature). - No server bloat. Rust compiles each binary target independently. Code in
test_client.rsdoesn't end up in thesettled-reach-serverbinary. Dead code elimination handles shared library code that only the test client calls. - Build integration.
cargo buildbuilds both binaries.cargo testruns tests in both.cargo nextestpicks up integration tests that use either binary.
How It Connects
┌────────────────────┐ TCP ┌────────────────────────┐
│ settled-reach- │◄───────────►│ settled-reach-test- │
│ server │ framed │ client │
│ │ msgpack │ │
│ --test-mode │ │ --connect host:port │
│ --port 0 │ │ --text (stdout render) │
│ --seed 42 │ │ --ticks 50 │
└────────────────────┘ │ --replay inputs.jsonl │
│ --golden tick50.json │
└────────────────────────┘
Connection protocol:
- Test client connects to server's TCP port
- Each tick: client sends
Vec<PlayerInput>viawrite_framed, server responds withObserverSnapshotviasend_bridge_snapshot - Test client deserializes snapshot, formats text output, optionally compares against golden file
- After
--ticks N, client disconnects cleanly
CLI interface:
settled-reach-test-client [OPTIONS]
Connection:
--connect <host:port> Connect to running server (default: 127.0.0.1:9876)
Input:
--replay <file.jsonl> Send inputs from file (one JSON PlayerInput per line)
--interactive Read inputs from stdin (future: human-in-the-loop testing)
Output:
--text Render each snapshot as structured text to stdout
--json Dump each snapshot as JSON to stdout (for golden files)
--quiet No output (assertions only, for CI)
Assertions:
--golden <file.json> Compare final snapshot against golden file, exit 1 on diff
--ticks <N> Disconnect after N ticks (default: unlimited)
Debugging:
--dump-raw Hex dump raw MessagePack bytes before deserializing
Text Output Format
=== Tick 42 | Player (15,10) facing East | Stance: Walk | TickRate: Full ===
Game time: Day 0, 04:12 (Morning)
Entities (5):
npc:100 (18,10) Forward rel:Neutral vis:Visible
npc:101 (20,10) Forward rel:Unknown vis:Remembered
obj:200 (16,9) Forward rel:n/a vis:Visible
npc:102 (12,8) Periph rel:Hostile vis:Visible
npc:103 (22,14) Periph rel:Friendly vis:Visible
Tiles: 31 visible
Pending recognitions: 1 [npc:104 at (19,12) 3/8 ticks]
Interactions (2):
npc:100 [Talk(1), ExamineNpc(2)] distance=3
obj:200 [Read(1), Observe(2)] distance=1
Inventory: 2/9 [item:300(slot-0), item:301(slot-3)]
Monologue: "Something about this manifest doesn't add up."
===
Format rules:
- Entities identified by
kind:entity_id(e.g.,npc:100,obj:200). No display names on the wire — see OQ-5 answer below. - One line per entity, sorted by distance from player (nearest first).
===tick separators for cleandiffoutput.- Positions as integer tile coords (the f32 render offset is irrelevant for testing).
- All fields directly from ObserverSnapshot — no additional server data needed.
Text Renderer Implementation
// server/src/bridge/text_renderer.rs (library code, used by test client binary)
/// Format an ObserverSnapshot as structured text for human verification.
/// The test client binary calls this; the server binary does not.
pub fn format_snapshot_text(snapshot: &ObserverSnapshot) -> String {
let mut out = String::with_capacity(2048);
// Header
writeln!(out, "=== Tick {} | Player ({},{}) facing {:?} | Stance: {:?} | TickRate: {:?} ===",
snapshot.tick,
snapshot.entities.iter().find(|e| matches!(e.kind, EntityKind::Player))
.map(|p| p.x as i32).unwrap_or(-1),
snapshot.entities.iter().find(|e| matches!(e.kind, EntityKind::Player))
.map(|p| p.y as i32).unwrap_or(-1),
snapshot.player_facing,
snapshot.player_stance,
snapshot.game_time.tick_rate,
).ok();
// ... entities, tiles, interactions, inventory, monologue
writeln!(out, "===").ok();
out
}
This lives in the server crate's library (not in any binary). The test client binary calls it. Integration tests can also call it for debug output. The server binary never references it — zero bloat.
Integration with cargo nextest (Layer 3)
The test client binary enables proper Layer 3 subprocess testing. Integration tests spawn both binaries:
// server/tests/layer3_subprocess.rs
#[test]
#[ignore] // Slow — nightly/pre-merge only
fn server_and_test_client_subprocess_roundtrip() {
// 1. Build both binaries
// 2. Launch server: settled-reach-server --test-mode --port 0 --seed 42
// 3. Parse port from server stdout
// 4. Launch test client: settled-reach-test-client --connect 127.0.0.1:{port}
// --replay gauntlet_basic.jsonl --ticks 10 --golden tick10.json --quiet
// 5. Test client exits 0 if golden file matches, 1 if not
// 6. Kill server, assert clean exit
}
This is the test that would have caught Bug #1 (blocking TCP read stalling bevy Update). The test client is a real subprocess exercising the real wire protocol — not an in-process mock.
Difficulty Tier
Feasible. ~2-3 days total.
- Binary scaffolding + CLI parsing: 0.5 day
- Text renderer function: 0.5-1 day
- Replay file loading + tick-scheduled sending: 0.5 day
- Golden file comparison (JSON deserialize + field diff): 0.5 day
- Layer 3 integration test wiring: 0.5 day
2. Stig's 32 Client Test Proposals — Architectural Validation
Stig proposed 32 tests across 6 categories. All structural scene tree assertions, no pixel comparison. Let me evaluate each category for soundness, redundancies, and gaps.
Camera System (7 tests) — APPROVED
All 7 are architecturally sound:
- Position after ready, anchored flag, smoothing lifecycle, follows movement, static during pause, handles rapid snapshots
No redundancies. Each tests a distinct camera behavior. The "static during pause" test directly prevents Bug #2 class regressions (camera doesn't update when no snapshots arrive).
One refinement: The "handles rapid snapshots" test should verify that the camera smoothing doesn't overshoot or oscillate when snapshots arrive faster than the lerp completes. Assert convergence within a frame budget, not just "doesn't crash."
Entity Rendering (7 tests) — APPROVED with note
- Peripheral alpha == 0.5, forward alpha == 1.0, D-033 colors by kind, lifecycle (create/remove)
Sound. These directly test the information-to-visual mapping that is the client's core responsibility.
Refinement: The lifecycle test should explicitly verify that entities leaving LOS are removed from the scene tree (not just hidden). Godot nodes that are hidden but not freed accumulate memory. Assert get_node_or_null() returns null after LOS exit, not just visible == false.
Z-Layer Ordering (4 tests) — APPROVED, low priority
- Fog rect z=900, fog entities z=950, insert canvas=10, UI canvas=20
These are effectively constant checks. They verify the z-layer conventions documented in the rendering architecture. Worth having as a safety net, but low regression risk — z-layer values don't change accidentally.
No redundancy with entity rendering tests. Z-layers are about ordering between categories (fog vs entities vs UI), not about individual entity rendering.
Fog Shader State (3 tests) — APPROVED, P1
- Visibility texture updates from positions, exploration persistence (255 -> 128), peripheral dimming (180)
Critical for information boundaries. The fog system is the primary visual enforcement of D-010's asymmetric information principle. If fog breaks, the player sees things they shouldn't.
The byte values (255/180/128/0) should be named constants in the fog shader code. Stig's open question to Hoshe is correct — these are assertion targets and should be documented as such. Recommend: const FOG_VISIBLE: int = 255, const FOG_PERIPHERAL: int = 180, const FOG_EXPLORED: int = 128, const FOG_UNEXPLORED: int = 0.
UI Elements (8 tests) — APPROVED, P0 for monologue
- Monologue consumed once per tick, monologue not lost on overwrite (Bug #5), interaction list, dialogue, inventory grid, stance indicator
Bug #5 regression guard (monologue not lost on overwrite) is the highest-priority client test in the entire suite. This was a real bug that the carry-forward fix addressed, and the test should be the first one written.
Sound overall. The interaction list test should verify sorting order (nearest first, matching server nearby_interactions order).
Entity Lerp (3 tests) — APPROVED
- Target set on update, snap on first appearance, convergence after N frames
Sound. The "snap on first appearance" test is subtle and important — entities should appear at their correct position immediately, not lerp from (0,0). This was a visual glitch pattern in early development.
Summary: Missing Coverage
Stig's 32 tests cover the client's core rendering and UI responsibilities well. Gaps I'd add:
| # | Missing Test | Category | Why |
|---|---|---|---|
| 33 | Pending recognition blob rendering | Entity rendering | Cognitive delay (D-060) renders entities as grey blobs. Test that pending_recognitions entries appear as blobs, not full entities. Bug #7 class prevention. |
| 34 | Recognition transition animation | Entity lerp | When recognition completes (entity moves from pending_recognitions to entities), verify visual transition from blob to full entity over ~0.3s. |
| 35 | Tick rate HUD indicator | UI elements | GameTime.tick_rate changes should update the HUD. Test Full/Half/Paused display. |
| 36 | Inventory full visual state | UI elements | When inventory is 9/9, verify visual feedback (e.g., slot highlight change, "Full" indicator). |
| 37 | Sprint interaction suppression | UI elements | During Sprint stance, interaction buffer is suppressed (D-055). Verify interaction list is hidden/empty during Sprint. |
| 38 | Entity modulate for Remembered state | Entity rendering | Entities with observation: Remembered should render differently from Visible (e.g., translucent, desaturated). Test the modulate/shader difference. |
Total with additions: 38 tests. All structural, all scene tree assertions. No pixel comparison.
Priority Ranking for Implementation
| Priority | Tests | Count | Rationale |
|---|---|---|---|
| P0 | Monologue not lost on overwrite (Bug #5) | 1 | Direct regression guard for shipped fix |
| P0 | Camera static during pause (Bug #2) | 1 | Direct regression guard |
| P1 | Fog shader state (all 3) | 3 | Information boundary enforcement |
| P1 | Entity lifecycle (create/remove) | 1 | Memory + correctness |
| P1 | Pending recognition blob | 1 | D-060 cognitive delay visual |
| P2 | Remaining camera tests (5) | 5 | Camera behavior suite |
| P2 | Entity alpha + color (4) | 4 | Visual fidelity |
| P2 | UI elements (remaining 7) | 7 | HUD and interaction |
| P3 | Z-layer ordering (4) | 4 | Constants checks |
| P3 | Entity lerp (3) | 3 | Animation refinement |
| P3 | Added tests 34-38 | 5 | New coverage |
3. OQ-4: WalkabilityMap.chunks HashMap -> BTreeMap?
Answer: No. Leave it as HashMap.
Dudley asked this because WalkabilityMap.chunks: HashMap<ChunkCoord, ChunkData> could become a determinism hazard if we ever iterate chunks (e.g., for save/load serialization).
Current usage audit (confirmed in Round 1):
can_move_to(pos)— point lookup viaself.chunks.get(&chunk_coord)→ safeset(pos, walkable)— point insertion viaself.chunks.entry(coord).or_insert_with(...)→ safeWalkabilityMap::new()— constructs with pre-allocated chunks → deterministic (insertion order = grid iteration order, but irrelevant since we never iterate)- Nowhere in the codebase does any code call
.iter(),.keys(), or.values()onchunks
Why not convert preemptively:
- HashMap is faster for point lookups. The WalkabilityMap is queried in the movement validation hot path (
validate_movementcallscan_move_tofor every mover every tick). HashMap lookup is O(1) amortized vs BTreeMap's O(log N). With N=25 chunks, that's ~1 comparison vs ~5 comparisons. Per-call it's nanoseconds, but it's called hundreds of times per tick during pathfinding. - No iteration today, no iteration planned. Save/load is Sprint 10+ scope. When it arrives, the save system should collect chunks into a
BTreeMapor sortedVecfor serialization — the WalkabilityMap's internal HashMap doesn't need to change. - The determinism hygiene rule covers this. The rule is: "HashMap/HashSet for point lookups only, BTreeMap/BTreeSet where iteration order affects output." WalkabilityMap falls cleanly in the first category.
Action: Document in the determinism hygiene section that WalkabilityMap.chunks is a known-safe HashMap usage. If anyone adds iteration over chunks, they must convert to BTreeMap first.
4. OQ-5: Entity display_name — Wire Protocol or Server-Side Lookup?
Answer: Neither for now. Use kind:entity_id labels in the test client.
The lead's hint is correct: since the test client is Rust code in the same crate, it has access to content data. But accessing content data from the test client creates two sources of truth for entity identity (wire ID vs content canonical_id) and requires the test client to load content — which adds variables to the testing landscape (the exact thing the lead wants to avoid).
For the test client text renderer:
Entities are labeled as kind:entity_id, e.g., npc:100, obj:200, player:0. This is:
- Unambiguous: the wire entity_id is the canonical identifier in the snapshot
- Zero-cost: no additional data needed, no content loading
- Stable: doesn't break when content names change
- Sufficient: Gauntlet test assertions use named constants that map to known IDs
// In text renderer
fn entity_label(entity: &VisibleEntity) -> String {
let kind = match entity.kind {
EntityKind::Player => "player",
EntityKind::Npc => "npc",
EntityKind::Object => "obj",
EntityKind::Terrain => "terrain",
};
format!("{}:{}", kind, entity.entity_id)
}
For test assertions, the Gauntlet coordinate constants provide the human-readable mapping:
// server/src/content/gauntlet/constants.rs
pub const GUARD_1: GauntletEntity = GauntletEntity {
wire_id: 100, // Assigned by EntityRegistry in deterministic spawn order
name: "guard-1",
position: TilePosition::new(18, 10, 0),
};
Test code reads naturally: assert_entity_visible(&snapshot, GUARD_1.wire_id, GUARD_1.position).
When to add display_name to the wire protocol:
Add display_name: Option<String> to VisibleEntity when the Godot client needs to render entity name labels in the UI (e.g., hovering over an NPC shows their name). That's a gameplay feature (likely Sprint 9-10), not a testing requirement. When it ships, the test client gets it for free.
Why not add it now:
- The lead explicitly wants to avoid adding variables to the testing landscape. A wire protocol change is a variable.
- display_name involves information boundary decisions: does the player see the name before recognition completes? Before first conversation? These are gameplay questions, not testing questions.
kind:entity_idis honest — it shows exactly what the wire protocol contains, which is what we're testing.
5. Cross-Review: Hoshe's CI Tier Proposal
Hoshe proposed three tiers: Commit (lint + content, <2min), PR (build + test + fixtures, <10min), Nightly (Layer 3 + golden + perf, <30min). Justine proposed a compatible pipeline with parallel server/client jobs, fixture artifacts, and performance baselines. The lead confirmed CI is deferred (manual make ci stays), so this is future design validation.
Tier Structure: APPROVED
The three-tier model (Commit / PR / Nightly) is the right design. Every tier has a clear purpose:
| Tier | Purpose | Gate? | Hoshe's Budget | My Assessment |
|---|---|---|---|---|
| Commit | Fast syntax + content feedback | No (advisory) | <2 min | Correct. Lint + content validation catches the most common errors with the lowest cost. |
| PR | Merge gate — build + test + fixture | Yes (blocks merge) | <10 min | Correct, but budget <15 min — full rebuilds from cache miss take 5-7 min for server alone. |
| Nightly | Deep integration + regression | No (alerts) | <30 min | Correct. Layer 3 subprocess + golden files + perf belong here. |
Specific Feedback
Commit tier — good as-is. make lint-server, make lint-client, make validate-content, make check-fact-ids. Fast, catches the obvious stuff.
PR tier — one addition. Hoshe's fixture staleness check (make fixtures && git diff --exit-code client/tests/fixtures/) is excellent. This catches protocol changes where the developer forgot to regenerate fixtures. Justine's parallel server/client jobs with fixture artifact passing is the correct CI implementation.
Add to PR tier: Content cross-reference validation (the new validate_cross_references() function from the lead's confirmed decisions). This should run after build, before tests. Budget impact: <2s, negligible.
PR tier time budget: Revise from <10 min to <15 min. Rationale:
- Server build (clean cache): ~5-7 min on a self-hosted runner
- Server tests (cargo nextest): ~1-2 min
- Client build: ~30s (Godot project scan)
- Client tests (gdUnit4 headless): ~1-2 min
- Fixture generation + staleness check: ~10s
- Content validation: ~5s
- With caching: ~3-5 min total. Without caching: ~12 min.
- 15 min budget covers the worst case without caching.
Nightly tier — one addition. Add a content scaling test: boot the server with a stress content pack (Crowd Plaza density: 15+ NPCs), tick 100 times, verify no tick exceeds the p95 budget. Hoshe's "Level 5 stress" test from T5-H3 belongs here.
Merge-Blocking Policy — Alignment with Justine
Hoshe and Justine are aligned on the merge policy:
| Check | Hoshe | Justine | My Assessment |
|---|---|---|---|
| Test failures | BLOCKER | BLOCKER | Correct |
| Lint failures | BLOCKER | BLOCKER | Correct |
| Content validation | BLOCKER | BLOCKER | Correct |
| Golden file diff | Not explicit | WARNING | Should be WARNING, not blocker. Golden files change legitimately when behavior changes. Require reviewer ack. |
| Fixture staleness | Not explicit | WARNING | Should be BLOCKER. Stale fixtures mean the client is testing against outdated protocol data. This is a real correctness bug, not a warning. |
| Performance delta | Not explicit | INFO | Correct — CI variance makes perf unreliable as a gate. |
Fixture staleness should be a BLOCKER, not a WARNING. If make fixtures && git diff --exit-code fails, it means the server's wire format changed but the fixtures weren't regenerated. The client tests are running against stale data — any passing client test is a false positive. This is the exact class of bug (protocol mismatch) that the entire serialization testing track is designed to prevent.
CI Readiness When We Wire It Up
Hoshe correctly identified that there is no CI pipeline at all — no .gitea/workflows/. The Gitea instance at git.schweitz.internal supports Gitea Actions (GitHub Actions compatible), and we have self-hosted infrastructure. When the lead greenlights CI:
- Start with the PR tier only (merge gate). This gives the most value for the least setup.
- Add Commit tier for fast feedback on feature branches.
- Add Nightly tier last — it requires the Gauntlet test world to exist first.
Estimated wiring effort: ~1 day for a Gitea Actions workflow that runs make ci. The Makefile targets already exist.
Summary: Round 2 Deliverables
| # | Deliverable | Status |
|---|---|---|
| 1 | Test client binary architecture (location, CLI, text format, Layer 3 integration) | Complete |
| 2 | Stig's 32 client tests validated + 6 additions = 38 total with priority ranking | Complete |
| 3 | OQ-4: WalkabilityMap HashMap stays (point-lookup only, no iteration) | Complete |
| 4 | OQ-5: No wire protocol change now. Use kind:entity_id labels. Add display_name when client needs name labels. |
Complete |
| 5 | Hoshe's CI tier proposal cross-reviewed. APPROVED with: 15min PR budget, fixture staleness → BLOCKER, content scaling in nightly. | Complete |
Integration Points with Other Round 2 Outputs
- Dudley (Task #10): The test client binary depends on
--test-mode+--port 0server flags (confirmed for Sprint 8). Dudley's server-side test infrastructure and the test client binary are complementary — the server provides therun_gauntlet()headless helper for unit/integration tests, the test client binary provides Layer 3 subprocess testing. - Stig (Task #11): The 6 additional client tests I proposed should be reviewed by Stig for client-side feasibility. The pending recognition blob test (#33) depends on the cognitive delay visual implementation.
- Hoshe (Task #12): Fixture staleness as BLOCKER (not WARNING) is a cross-review finding. Hoshe should confirm this aligns with the Layer 1/2/3 testing strategy.
- Justine (Task #13): Performance baseline strategy is sound. The self-hosted runner recommendation is critical for stable perf numbers.