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>
638 lines
31 KiB
Markdown
638 lines
31 KiB
Markdown
# 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 |
|