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

31 KiB
Raw Blame History

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_dialoguedialogue_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:

# 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:

# 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:

# 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)
# 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:

# 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

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

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

# 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

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

# 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

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

# 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