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