Files
settled-reach/docs/workshops/test-architecture/stig-round3.md
T
jpmschweitzerandClaude Opus 4.6 a87c95a6eb docs(workshops): complete QA test architecture workshop
3-round workshop with 7 agents (Tyre, Dudley, Stig, Hoshe,
Justine, Gestalt, Ozzie) plus Qatux documenting. Produced:

- 59-item prioritized test backlog (60 tickets under epic #455)
- Gauntlet test world spec: 7 rooms + hub, 48 entities
- Test client binary spec (tooling/test-client/)
- Determinism fixes (3 patches, ~22 lines)
- Server --test-mode + --port 0 design
- Content cross-reference validation (9 checks)
- make pre-pr pipeline (6-step)
- 38 client tests prioritized
- Anti-tedium features (reset plate, hub teleport, WRONG button)
- Human tester walkthrough
- CI pipeline design (deferred but documented)

Sprint 8 scope: ~17.75 team-days across 26 tickets.

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

638 lines
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Stig — Round 3: Final Client Test Spec, Anti-Tedium UI, Checklist, Fog Constants
**Workshop:** QA Strategy & Test Architecture
**Round:** 3 (Prioritization)
**Date:** 2026-02-17
---
## 1. Client Test Suite — Final 38 Tests
My original 32 + Tyre's 6 additions + my 3 from Round 2 = 41 candidates. Deduplicated to 38 (my "fog hidden state" test is subsumed by the existing 3 fog tests, and my 2 anti-tedium tests fold into the UI category).
### P0 — Sprint 8 (ship with Gauntlet infrastructure)
Must exist before any other client testing is meaningful. These guard shipped bug fixes.
| # | Function Name | Category | Asserts | Setup |
|---|--------------|----------|---------|-------|
| 1 | `test_monologue_not_lost_on_snapshot_overwrite` | UI: Monologue | `receive_bytes()` with monologue, then `receive_bytes()` without → `_last_snapshot` still carries monologue (carry-forward logic) | SimBridge in live mode mock. Two PackedByteArrays: first with monologue dict, second without. |
| 2 | `test_camera_static_during_pause` | Camera | Apply snapshot with `tick_rate: "Paused"`, inject MoveNorth, call `_process()``camera.global_position` unchanged | Scene instance from main.tscn. Set `GameState.game_time.tick_rate = "Paused"` before process. |
### P1 — Sprint 8-9 (information boundary + core rendering)
These enforce the perception/fog contract that makes the game work.
| # | Function Name | Category | Asserts | Setup |
|---|--------------|----------|---------|-------|
| 3 | `test_fog_visibility_forward_tile` | Fog state | `FogState._vis_bytes` at Forward-sector position == `FogState.VIS_FORWARD` (255) | Set `GameState.visible_positions` + `visibility_sectors` with one Forward tile, call `FogState.update_from_state()`. |
| 4 | `test_fog_visibility_peripheral_tile` | Fog state | `_vis_bytes` at Peripheral-sector position == `FogState.VIS_PERIPHERAL` (180) | Same as above with Peripheral sector. |
| 5 | `test_fog_exploration_persistence` | Fog state | Frame 1: tile visible (`EXP_VISIBLE`=255). Frame 2: tile not visible → `_exp_bytes` == `FogState.EXP_EXPLORED` (128) | Two `update_from_state()` calls with different visible_positions. |
| 6 | `test_fog_hidden_tile_value` | Fog state | Tile never in visible_positions → `_vis_bytes` == `FogState.VIS_HIDDEN` (0) | Default state after `_resize()`. |
| 7 | `test_entity_removed_when_leaving_visibility` | Entity rendering | `update_entities()` with entity, then without → `entity_nodes` empty, `get_node_or_null("Entity_N")` returns null (freed, not hidden) | EntityRenderer instance, two update calls. |
| 8 | `test_entity_created_on_first_appearance` | Entity rendering | Empty renderer → `update_entities()` with 1 entity → `entity_nodes.size() == 1`, child exists in tree | EntityRenderer instance. |
| 9 | `test_pending_recognition_blob_rendering` | Entity rendering | `GameState.pending_recognitions` with 1 entry → FogEntities node shows a blob child (not a full entity sprite) | Main scene or FogEntities subscene. Apply snapshot with `pending_recognitions` array. |
### P2 — Sprint 9 (camera, entity visuals, UI)
Comprehensive coverage of rendering transforms.
| # | Function Name | Category | Asserts | Setup |
|---|--------------|----------|---------|-------|
| 10 | `test_camera_position_after_ready` | Camera | `camera.global_position == player_position * TILE_SIZE` after `_ready()` | Scene instantiate from main.tscn. SimBridge test mode. |
| 11 | `test_camera_anchored_flag_after_ready` | Camera | `_camera_anchored == true` | Same as above. |
| 12 | `test_camera_smoothing_off_after_ready` | Camera | `camera.position_smoothing_enabled == false` after `_ready()` | Same as above. |
| 13 | `test_camera_smoothing_reenabled_after_process` | Camera | After `_process(0.016)`: `camera.position_smoothing_enabled == true` | Scene instance, one process call. |
| 14 | `test_camera_follows_player_movement` | Camera | After MoveNorth + `_process()`: `camera.global_position == new player_position * TILE_SIZE` | Scene instance. Inject MoveNorth via SimBridge test queue. |
| 15 | `test_camera_position_after_rapid_snapshots` | Camera | Three `receive_bytes()` calls, one `_process()` → camera at LAST snapshot's player position. Verify convergence (not just no crash). | SimBridge live mock. Three snapshot byte arrays with different player positions. |
| 16 | `test_camera_position_after_hub_teleport` | Camera | After teleport (large position jump): camera snaps immediately (no slow lerp from old position) | Scene instance. Set `_camera_anchored = false` to trigger re-anchor. |
| 17 | `test_entity_peripheral_alpha` | Entity rendering | Entity with `visibility: "Peripheral"``modulate.a == Constants.PERIPHERAL_ALPHA` (0.5) | EntityRenderer instance, one entity with Peripheral visibility. |
| 18 | `test_entity_forward_full_alpha` | Entity rendering | Entity with `visibility: "Forward"``modulate.a == 1.0` | EntityRenderer instance, one entity with Forward visibility. |
| 19 | `test_entity_color_player` | Entity rendering | `kind.variant == "Player"``color == Constants.ENTITY_COLOR_PLAYER` | EntityRenderer instance with Player entity. |
| 20 | `test_entity_color_npc_unknown` | Entity rendering | `kind.variant == "Npc"``color == Constants.ENTITY_COLOR_UNKNOWN` | EntityRenderer instance with Npc entity. |
| 21 | `test_entity_color_object` | Entity rendering | `kind.variant == "Object"``color == Constants.ENTITY_COLOR_OBJECT` | EntityRenderer instance with Object entity. |
| 22 | `test_entity_modulate_remembered` | Entity rendering | Entity with visibility "Remembered" → visually distinct from "Visible" (different modulate or shader param) | EntityRenderer instance. Requires Remembered visibility in snapshot (not yet on wire — gate test on implementation). |
| 23 | `test_recognition_transition_animation` | Entity lerp | Entity moves from `pending_recognitions` to `entities` between ticks → visual transitions from blob to full entity over ~0.3s | FogEntities + EntityRenderer. Two sequential snapshots: first with pending_recognition, second with entity. |
| 24 | `test_monologue_consumed_once_per_tick` | UI: Monologue | Set `current_monologue`, call `_consume_monologue()` twice with same tick → `show_monologue()` called once | Main scene instance. Set `GameState.current_monologue` and `current_tick`. |
| 25 | `test_interaction_list_shows_verbs` | UI: Interaction | `nearby_interactions` with 2 verbs → interaction list visible, shows both verbs sorted by priority | Main scene or InteractionList subscene. Set GameState.nearby_interactions. |
| 26 | `test_interaction_list_hidden_when_empty` | UI: Interaction | `nearby_interactions == []` → interaction list not visible | Same, with empty array. |
| 27 | `test_dialogue_shows_on_snapshot` | UI: Dialogue | Set `current_dialogue``dialogue_box.is_dialogue_active() == true` | Main scene instance. Set GameState.current_dialogue. |
| 28 | `test_dialogue_dismissed_sends_end_input` | UI: Dialogue | Emit `dialogue_dismissed` signal → SimBridge receives `DialogueEnd` input | Main scene instance. Emit signal, check SimBridge test queue. |
| 29 | `test_dialogue_option_sends_response` | UI: Dialogue | Emit `option_selected(1, "text")` → SimBridge receives `DialogueResponse` with `index=1` | Main scene instance. Emit signal, check SimBridge test queue. |
| 30 | `test_stance_indicator_matches_game_state` | UI: Stance | Set `player_stance = "Sprint"`, call `update_from_state()` → indicator displays Sprint | StanceIndicator subscene or main scene. |
| 31 | `test_tick_rate_hud_indicator` | UI: HUD | `game_time.tick_rate` changes → HUD element reflects Full/Half/Paused | HUD subscene. Set GameState.game_time. |
| 32 | `test_inventory_full_visual_state` | UI: Inventory | 9/9 inventory → visual feedback (e.g., "FULL" indicator or slot highlight change) | InventoryGrid subscene. Set GameState.player_inventory with 9 items. |
| 33 | `test_sprint_interaction_suppression` | UI: Interaction | `player_stance == "Sprint"` → interaction list hidden/empty regardless of nearby_interactions content | Main scene or InteractionList. Set stance to Sprint, set nearby_interactions with data. |
### P3 — Sprint 10+ (constants checks, animation polish, anti-tedium)
Low regression risk or dependent on unimplemented features.
| # | Function Name | Category | Asserts | Setup |
|---|--------------|----------|---------|-------|
| 34 | `test_fog_overlay_z_index` | Z-layer | FogOverlay node `z_index == Constants.Z_FOG` (900) | Main scene instance, traverse scene tree. |
| 35 | `test_fog_entities_z_index` | Z-layer | FogEntities node `z_index == Constants.Z_FOG_ENTITIES` (950) | Same. |
| 36 | `test_insert_overlay_canvas_layer` | Z-layer | InsertOverlay CanvasLayer `.layer == Constants.CANVAS_INSERT` (10) | Same. |
| 37 | `test_ui_layer_canvas_layer` | Z-layer | UILayer CanvasLayer `.layer == Constants.CANVAS_UI` (20) | Same. |
| 38 | `test_entity_lerp_target_set_on_update` | Entity lerp | After `update_entities()`: `_entity_targets[id] == Vector2(x * TILE_SIZE + ENTITY_OFFSET, y * TILE_SIZE + ENTITY_OFFSET)` | EntityRenderer instance, one entity. |
**Deferred (not in the 38):**
- `test_entity_snap_on_first_appearance` — covered by #8 (entity created) + #38 (target set). Snap-vs-lerp is implicitly tested.
- `test_entity_lerp_converges` — animation polish, hard to assert deterministically across frame timings. Manual visual verification.
- `test_bug_report_captures_snapshot` — depends on BugReport autoload (Sprint 9+). Add when feature ships.
- `test_gauntlet_progress_hidden_when_not_gauntlet` — depends on GauntletProgress overlay (Sprint 9+).
### Summary by Category
| Category | Count | Sprint Range |
|----------|-------|-------------|
| Camera | 7 | 8-9 |
| Entity rendering | 7 (+ 2 Tyre) = 9 | 8-10 |
| Fog state | 4 | 8-9 |
| Z-layer | 4 | 10+ |
| UI: Monologue | 2 | 8-9 |
| UI: Interaction | 3 | 9 |
| UI: Dialogue | 3 | 9 |
| UI: Other (stance, tick rate, inventory, sprint suppress) | 4 | 9 |
| Entity lerp | 2 | 9-10 |
| **Total** | **38** | |
---
## 2. Anti-Tedium UI — Build-Ready Specs
### 2.1 Room Reset Trigger
**Scene tree changes:**
```
# No new nodes. Reuses existing systems:
# - TileRenderer: new tile type
# - InteractionList: shows "Reset Room" verb
# - MonologueDisplay: shows feedback text
```
**tile_renderer.gd changes:**
```gdscript
# Add to TILE_TYPE_MAP:
"reset_plate": TileType.RESET_PLATE
# Add enum value:
enum TileType { FLOOR = 0, WALL = 1, DOOR = 2, OBJECT = 3, RESET_PLATE = 4 }
```
Atlas tile (4,0): floor-colored base with 1px amber (`INSERT_COLOR_HOVER`) inset border. Subtle — visible when you're looking, invisible when you're not.
**Input handling:** None. The reset plate is an interaction target. When the player stands on it, the server includes it in `nearby_interactions` with verb `"ResetRoom"`. The existing interaction list + Interact input flow handles it. No new client input code.
**Wire format:** Standard `Interact` action with `action_data: { target_entity_id: <plate_id>, verb: "ResetRoom" }`. Server handles the reset. Client just renders the next snapshot (which shows tick-0 state).
**Visual feedback:** Server sends a monologue in the post-reset snapshot: `"Systems recalibrated."` (1.5s). The existing MonologueDisplay renders it. Additionally, a 0.15s amber screen flash:
```gdscript
# In main.gd, after applying snapshot:
if _detected_room_reset(snapshot):
_flash_overlay(Constants.INSERT_COLOR_HOVER, 0.15)
func _flash_overlay(color: Color, duration: float) -> void:
# Uses a ColorRect on InsertOverlay, tween alpha 0.1 -> 0.0
var flash := $InsertOverlay/FlashRect # pre-existing ColorRect, normally transparent
flash.color = Color(color, 0.1)
var tween := create_tween()
tween.tween_property(flash, "color:a", 0.0, duration)
```
**Room reset detection:** Compare `snapshot.tick` — if it decreased or a special `room_reset: true` flag is present in the snapshot, trigger the flash. Simplest: server sets `room_reset: true` in the snapshot after a reset. Client checks once, fires flash, done.
**New scene node:** `InsertOverlay/FlashRect` — a `ColorRect` covering the viewport, `color = Color.TRANSPARENT`, `mouse_filter = IGNORE`. Used by both reset flash and hub teleport fade.
### 2.2 Hub Teleport
**Input handling:**
```gdscript
# input_mapper.gd — add to Action enum:
TELEPORT_HUB = 14 # (next available value)
# input_mapper.gd — add to _input mapping:
if event.is_action_pressed("teleport_hub"):
queue_action(Action.TELEPORT_HUB)
```
```gdscript
# sim_bridge.gd — add to _action_enum_to_wire():
InputMapper.Action.TELEPORT_HUB: return "TeleportToHub"
```
**Godot input map:** Add `teleport_hub` action mapped to `KEY_HOME` in `project.godot` or via code in `_ready()`.
**Visual feedback — fade transition:**
```gdscript
# main.gd — after detecting large position jump (teleport):
func _detect_teleport(old_pos: Vector2, new_pos: Vector2) -> bool:
return old_pos.distance_to(new_pos) > 5.0 # > 5 tiles = teleport, not walk
# On teleport detected:
func _teleport_transition() -> void:
_camera_anchored = false # Force re-anchor (snap, no smooth)
var fade := $InsertOverlay/FlashRect
fade.color = Color(0, 0, 0, 1.0) # Instant black
var tween := create_tween()
tween.tween_property(fade, "color:a", 0.0, 0.3) # Fade back in over 0.3s
```
**Camera behavior:** Setting `_camera_anchored = false` triggers the re-anchor path in `_process()` — camera snaps to new position instantly (smoothing disabled for 1 frame, same as startup). No lerp from old position.
**Gauntlet-only gate:** The `TELEPORT_HUB` action enum exists in InputMapper always, but the Godot input mapping is only added when `gauntlet_mode == true`. Or simpler: server rejects `TeleportToHub` in non-Gauntlet maps (server-side gate, client sends regardless).
### 2.3 WRONG Button (F12)
**New autoload:** `client/scripts/autoloads/bug_report.gd`
```gdscript
extends Node
const MAX_HISTORY: int = 60
var input_history: Array[Dictionary] = [] # Ring buffer of {tick, action, timestamp}
var _report_dir: String = "user://bug-reports"
func _ready() -> void:
# Create base directory
DirAccess.make_dir_recursive_absolute(
ProjectSettings.globalize_path(_report_dir))
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("bug_report"):
capture_bug_report()
get_viewport().set_input_as_handled()
func record_input(input: Dictionary) -> void:
input_history.append(input)
if input_history.size() > MAX_HISTORY:
input_history.pop_front()
func capture_bug_report() -> void:
# 1. Pause
SimBridge.send_input({
"action": InputMapper.Action.PAUSE,
"timestamp_msec": Time.get_ticks_msec(),
})
# 2. Capture data
var snapshot_json := JSON.stringify(GameState.current_snapshot, "\t")
var scene_dump := _dump_scene_tree(get_tree().root)
var inputs_json := JSON.stringify(input_history, "\t")
var screenshot := get_viewport().get_texture().get_image()
# 3. Show prompt (modal)
var prompt := $"/root/Main/ModalLayer/BugReportPrompt"
prompt.show_prompt(func(description: String):
_save_report(snapshot_json, scene_dump, inputs_json, screenshot, description)
# 4. Unpause
SimBridge.send_input({
"action": InputMapper.Action.UNPAUSE,
"timestamp_msec": Time.get_ticks_msec(),
})
)
func _save_report(snapshot: String, scene: String, inputs: String,
screenshot: Image, description: String) -> void:
var timestamp := Time.get_datetime_string_from_system().replace(":", "-")
var dir_path := _report_dir + "/gauntlet-" + timestamp
DirAccess.make_dir_recursive_absolute(
ProjectSettings.globalize_path(dir_path))
_write_file(dir_path + "/snapshot.json", snapshot)
_write_file(dir_path + "/scene_tree.txt", scene)
_write_file(dir_path + "/inputs.json", inputs)
_write_file(dir_path + "/description.txt", description)
screenshot.save_png(ProjectSettings.globalize_path(dir_path + "/screenshot.png"))
func _write_file(path: String, content: String) -> void:
var file := FileAccess.open(path, FileAccess.WRITE)
if file:
file.store_string(content)
func _dump_scene_tree(node: Node, depth: int = 0) -> String:
var indent := " ".repeat(depth)
var line := "%s%s" % [indent, node.name]
if node is CanvasItem:
line += " visible=%s mod=%s" % [node.visible, node.modulate]
if node is Node2D:
line += " pos=%s" % node.position
if node is Control:
line += " pos=%s sz=%s" % [node.position, node.size]
var result := line + "\n"
for child in node.get_children():
result += _dump_scene_tree(child, depth + 1)
return result
```
**Input map addition:** `bug_report` action → `KEY_F12`
**Modal prompt scene:** `ModalLayer/BugReportPrompt`
```
BugReportPrompt (PanelContainer, visible=false)
VBoxContainer
Label "What's wrong? (one line)"
LineEdit (placeholder: "Describe the issue...")
HBoxContainer
Button "Save"
Button "Cancel"
```
Style: dark panel (`Color(0.08, 0.08, 0.12, 0.95)`), text in `INSERT_COLOR_TEXT`, centered on screen. Modal — blocks input to game while visible.
**Integration with InputMapper:** Call `BugReport.record_input(input_dict)` from `InputMapper.queue_action()` to feed the ring buffer. One line addition.
**Confirmation feedback:** After save, show monologue `"Noted."` (1.0s) via existing MonologueDisplay.
### 2.4 Room Timer + Progress Overlay
**New scene node:** `UILayer/GauntletProgress`
```
GauntletProgress (PanelContainer, visible=false)
VBoxContainer
RoomLabel (Label) — "OCCLUSION CORRIDOR"
RunLabel (Label) — "Run #12 · 0:47"
ProgressBar — 4/7
ProgressLabel (Label) — "4/7"
```
**Position:** Anchored top-right, 8px margin. `anchor_left = 1.0, anchor_right = 1.0, anchor_top = 0.0`. Grows leftward from the right edge.
**Style:**
- Panel: `Color(0.05, 0.05, 0.08, 0.7)`, 4px corner radius
- Room name: `INSERT_COLOR_TEXT` (#c8d0e0), 12px, bold
- Run/timer: `INSERT_COLOR_TEXT`, 10px, regular
- Progress bar fill: `INSERT_COLOR_ACTIVE` (#6bc9a6)
- Progress bar empty: `Color(0.2, 0.2, 0.25)`
- Max width: 220px
**Script:** `client/scripts/ui/gauntlet_progress.gd`
```gdscript
extends PanelContainer
var gauntlet_mode: bool = false
var _current_room: String = ""
var _room_enter_time: float = 0.0
var _run_counts: Dictionary = {} # room_name -> int
var _room_totals: Dictionary = {} # room_name -> total checklist items
var _room_checked: Dictionary = {} # room_name -> checked count
func _ready() -> void:
gauntlet_mode = OS.get_environment("SR_GAUNTLET") == "1"
visible = false
func update_from_state() -> void:
if not gauntlet_mode:
visible = false
return
var room := _detect_room(GameState.player_position)
if room.is_empty():
visible = false
return
visible = true
if room != _current_room:
_current_room = room
_room_enter_time = Time.get_ticks_msec() / 1000.0
_run_counts[room] = _run_counts.get(room, 0) + 1
var elapsed := Time.get_ticks_msec() / 1000.0 - _room_enter_time
var mins := int(elapsed) / 60
var secs := int(elapsed) % 60
$VBoxContainer/RoomLabel.text = room.to_upper()
$VBoxContainer/RunLabel.text = "Run #%d · %d:%02d" % [
_run_counts.get(room, 1), mins, secs]
var total: int = _room_totals.get(room, 0)
var checked: int = _room_checked.get(room, 0)
if total > 0:
$VBoxContainer/ProgressBar.value = float(checked) / float(total)
$VBoxContainer/ProgressLabel.text = "%d/%d" % [checked, total]
else:
$VBoxContainer/ProgressBar.value = 0.0
$VBoxContainer/ProgressLabel.text = ""
func _detect_room(_player_pos: Vector2) -> String:
# TODO: Load room bounds from Gauntlet content, match against player position
return ""
```
**Visibility gate:** `SR_GAUNTLET=1` environment variable. Not visible in normal gameplay.
**Data persistence:** `_run_counts` saved to `user://gauntlet-stats.json` on room change and on `_notification(NOTIFICATION_WM_CLOSE_REQUEST)`.
---
## 3. Checklist YAML — Final Merged Schema
Merges my Round 1 co-located approach with Ozzie's 4 additions: auto/manual type tags, `if_wrong` field, structured conditions, cross-room items.
### Per-Room Checklist Schema
```yaml
# content/gauntlet/rooms/{room_id}/checklist.yaml
room: occlusion_corridor
description: "Tests LOS, shadowcasting, vision cone, and perception modes"
checks:
- id: occ_01_hidden_not_visible
description: "NPC behind wall is NOT visible in Visual mode"
type: auto # auto = snapshot-verifiable, manual = human judgment
step: "Stand at corridor entrance (45,3), face East"
condition:
player_near: [45, 3]
player_facing: East
entity: hidden-1
expected: blocked # blocked | visible | remembered | recognizing
if_wrong: |
LOS leaking through wall. Check symmetric shadowcasting
in server/src/perception/shadowcast.rs.
Verify wall at designated blocking position exists in room YAML.
- id: occ_02_guard_visible
description: "NPC in front of wall IS visible with full alpha"
type: auto
step: "Same position — guard should be in clear LOS"
condition:
player_near: [45, 3]
entity: guard-1
expected: visible
expected_sector: Forward
if_wrong: |
Guard not visible at distance 3 in clear LOS.
Check entity spawn position in room YAML.
Check vision cone range in server/src/perception/query.rs.
- id: occ_03_peripheral_dimmed
description: "Entity at edge of vision cone is dimmed (Peripheral sector)"
type: auto
step: "Walk south to (45,8), observe entity alpha"
condition:
player_near: [45, 8]
entity: guard-1
expected_sector: Peripheral
if_wrong: |
Peripheral dimming not applied. Check Constants.PERIPHERAL_ALPHA
and entity_renderer.gd modulate.a assignment.
- id: occ_04_sensor_detects_hidden
description: "Sensor perception mode detects NPC behind wall"
type: auto
step: "Switch to Sensor perception mode"
condition:
perception_mode: Sensor
entity: hidden-1
expected: visible
if_wrong: |
Sensor mode not detecting through walls. Check perception mode
compute_geometry in server/src/perception/modes/.
- id: occ_05_cognitive_delay_timing
description: "Cognitive delay for recognition takes approximately 0.6s"
type: manual # Subjective — tester judges timing
step: "Walk to fog boundary, wait for recognition"
guidance: |
Watch pending_recognitions — elapsed should reach ~6 ticks
(0.6s at 10 tps). The monologue should fire DURING the delay,
not after it completes.
if_wrong: |
Cognitive delay timing off. Check D-060 values in
server/src/perception/recognition.rs.
- id: occ_06_fog_corridor_state
description: "Fog shows corridor tiles as visible, room behind wall as unexplored"
type: auto
step: "Same position as step 1"
condition:
player_near: [45, 3]
fog_visible_count_min: 10
if_wrong: |
Fog not rendering corridor correctly.
Check FogState.update_from_state() and fog shader uniforms.
- id: occ_07_fog_count_decreases_in_narrow
description: "Fog visible count decreases as corridor narrows"
type: manual
step: "Walk deeper into corridor, observe fog count"
guidance: |
Visible tile count should drop as walls close in.
Compare fog count at (45,3) vs (45,10).
if_wrong: |
Fog not responding to geometry. Check shadowcasting range
vs corridor width.
```
### Schema Field Reference
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `room` | string | yes | Room ID (matches room YAML) |
| `description` | string | yes | Human-readable room purpose |
| `checks` | array | yes | Checklist items |
| `checks[].id` | string | yes | Unique ID (prefix with room abbreviation) |
| `checks[].description` | string | yes | What to verify (one sentence) |
| `checks[].type` | `auto` or `manual` | yes | `auto`: test client evaluates from snapshot. `manual`: human judgment |
| `checks[].step` | string | yes | Action the tester takes |
| `checks[].condition` | object | for `auto` | Structured condition for auto-evaluation |
| `checks[].guidance` | string | for `manual` | Instructions for human judgment |
| `checks[].if_wrong` | string | yes | Likely causes + file references |
### Condition Grammar
```yaml
condition:
player_near: [x, y] # Player within 2 tiles of position
player_facing: Direction # N/NE/E/SE/S/SW/W/NW
entity: entity-name # Gauntlet entity constant name
expected: blocked|visible|remembered|recognizing
expected_sector: Forward|Peripheral
perception_mode: Visual|Sensor|...
fog_visible_count_min: N # Minimum visible tile count
fog_visible_count_max: N # Maximum visible tile count
inventory_count: N # Exact inventory count
inventory_count_min: N
dialogue_active: true|false
monologue_contains: "substring"
```
The test client evaluates conditions against the current ObserverSnapshot. The Godot client does NOT evaluate conditions — it only displays the progress count (see section 5).
### Cross-Room Checklist
```yaml
# content/gauntlet/cross_room_checks.yaml
cross_room_checks:
- id: xr_01_sprint_exit_buffer
description: "Sprint from Crowd Plaza into Occlusion Corridor — interaction buffer cleared"
rooms: [crowd_plaza, occlusion_corridor]
type: manual
step: "Sprint through Plaza, enter Corridor. Check interaction list is empty during sprint."
if_wrong: |
Sprint suppression not clearing buffer on room transition.
Check D-055 interaction buffer clear in input.rs.
- id: xr_02_fog_into_dialogue
description: "Start dialogue while partially fogged — fog state preserved"
rooms: [fog_theater, dialogue_room]
type: manual
step: "Walk from Fog Theater to Dialogue Room. Initiate dialogue. Fog should not reset."
if_wrong: |
Fog state being cleared on dialogue open.
Check fog_state.gd update_from_state() is not skipping during dialogue.
- id: xr_03_full_inventory_interact
description: "Full inventory + interaction — Take verb greyed, server still offers it"
rooms: [inventory_warehouse, interaction_gallery]
type: auto
condition:
inventory_count: 9
entity: interaction_gallery_crate
expected: visible
if_wrong: |
Server should offer Take even when inventory full (available=true).
Client should grey it out. Check interaction list rendering.
```
### `make checklist` Implementation
```makefile
checklist:
@python3 tooling/gen_checklist.py \
--rooms content/gauntlet/rooms/ \
--cross content/gauntlet/cross_room_checks.yaml \
--output docs/qa/gauntlet-checklist.md
@echo "Checklist: docs/qa/gauntlet-checklist.md"
```
**Output format:** Same as Round 2 spec — markdown with `- [ ]` checkboxes, grouped by room, steps as `###` headings, `if_wrong` in blockquotes under each item. Cross-room items get their own section at the bottom.
---
## 4. Fog Constants — Definition and Migration
### Constants to add to `client/scripts/autoloads/fog_state.gd`
```gdscript
# Fog texture byte values — visibility and exploration layers.
# Used by fog shader to distinguish visual treatment per tile.
# Test assertions reference these: assert_that(byte).is_equal(FogState.VIS_FORWARD)
const VIS_HIDDEN: int = 0 # Not in LOS — fully fogged
const VIS_PERIPHERAL: int = 180 # In LOS, peripheral sector — light fog dimming
const VIS_FORWARD: int = 255 # In LOS, forward sector — clear vision
const EXP_UNEXPLORED: int = 0 # Never seen — total darkness
const EXP_EXPLORED: int = 128 # Previously seen, now out of LOS — deep fog
const EXP_VISIBLE: int = 255 # Currently in LOS — clear (written each frame)
```
### Files to update
**`client/scripts/autoloads/fog_state.gd`** — 5 replacements:
| Line | Current | Replacement |
|------|---------|-------------|
| 68 | `_vis_bytes.fill(0)` | `_vis_bytes.fill(VIS_HIDDEN)` |
| 75 | `_vis_bytes[py * _width + px] = 255 if sector == "Forward" else 180` | `_vis_bytes[py * _width + px] = VIS_FORWARD if sector == "Forward" else VIS_PERIPHERAL` |
| 87 | `if _exp_bytes[idx] > 128:` | `if _exp_bytes[idx] > EXP_EXPLORED:` |
| 88 | `_exp_bytes[idx] = 128` | `_exp_bytes[idx] = EXP_EXPLORED` |
| 94 | `_exp_bytes[py * _width + px] = 255` | `_exp_bytes[py * _width + px] = EXP_VISIBLE` |
**`client/tests/test_fog_shader.gd`** — use constants in any assertions that reference byte values.
**No other files affected.** The fog shader (`shaders/fog.gdshader`) reads textures, not GDScript constants. The byte values flowing into the texture are the same — just named now.
---
## 5. Answer: Checklist Overlay in Godot Client?
**Ozzie's question (R2-OQ-06):** Can the Godot client render a checklist progress overlay, or is checklist tracking test-client-only?
**Answer: The Godot client shows a lightweight progress overlay. Full auto-tracking is test-client-only.**
Split:
| Feature | Godot Client | Test Client Binary |
|---------|-------------|-------------------|
| Room name display | Yes — from room bounds | Yes |
| Run counter | Yes — client-local | Yes |
| Timer | Yes — client-local | Yes |
| Progress bar (X/Y) | Yes — loads total count from checklist YAML | Yes |
| Auto-evaluate conditions | **No** — too complex for GDScript, snapshot structure differs | **Yes** — Rust, same crate as ObserverSnapshot types |
| Auto/manual confirm | **No** | **Yes** |
| Per-item checklist display | **No** — just the bar | **Yes** — full `[✓]/[ ]/[?]` list |
| if_wrong guidance | **No** | **Yes** |
**Why the split:** The Godot client's GameState holds the snapshot as a Dictionary. Evaluating structured conditions (`player_near`, `entity expected: blocked`) against Dictionary data is fragile GDScript — no type safety, no access to entity name constants. The test client binary has the same types as the server (`ObserverSnapshot`, entity ID constants), making condition evaluation clean and type-safe.
The Godot client's progress overlay (section 2.4) gets its checked count from the test client via a simple mechanism: the test client writes `gauntlet-stats.json` with per-room progress, the Godot client reads it. Or more simply: the Godot client shows total items per room (loaded from YAML at startup) but doesn't track checked items. The progress bar shows "0/7" until the tester manually marks items done (or it stays as a room-entry indicator only).
**Recommendation:** Ship the Godot overlay with room name + timer + run counter only (Sprint 9). Progress bar comes when the test client ships and can feed it data (Sprint 9-10). Don't block the overlay on checklist auto-tracking.
---
## Summary
| Deliverable | Count/Status |
|-------------|-------------|
| Client tests: P0 | 2 (monologue overwrite, camera pause) |
| Client tests: P1 | 7 (fog ×4, entity lifecycle ×2, recognition blob) |
| Client tests: P2 | 24 (camera ×5, entity ×5, UI ×12, lerp ×1, teleport ×1) |
| Client tests: P3 | 5 (z-layer ×4, lerp target ×1) |
| Client tests: total | **38** |
| Anti-tedium features | 4 specced (reset, teleport, WRONG, progress) |
| New autoloads | 1 (bug_report.gd, ~80 lines) |
| New scene nodes | 3 (FlashRect, BugReportPrompt, GauntletProgress) |
| Input additions | 2 (TELEPORT_HUB, bug_report) |
| Checklist YAML schema | Complete with 7 condition types |
| Fog constants | 6 constants, 5 line replacements in fog_state.gd |
| Ozzie's question | Answered: lightweight overlay in Godot, full tracking in test client |