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