From bc073c891f8e42c3546a61d8d0485865445321b9 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 18 Feb 2026 10:53:05 +0100 Subject: [PATCH 1/5] feat(client): add gauntlet room timer + personal bests (#496) GauntletHUD in UILayer shows TIMER: MM:SS (PB: MM:SS). Timer starts on room entry, resets on room change, records personal bests to user://gauntlet-stats.json. Session summary printed on disconnect. Hidden in non-gauntlet mode (anti-tedium guard). Adds room_id and gauntlet_mode fields to GameState, parsed from ObserverSnapshot. Main.gd wires update_from_state() and finalize(). Co-Authored-By: Claude Opus 4.6 --- client/scenes/main.tscn | 11 +- client/scripts/autoloads/game_state.gd | 14 ++ client/scripts/main.gd | 21 +++ client/ui/gauntlet_hud.gd | 180 +++++++++++++++++++++++++ client/ui/gauntlet_hud.tscn | 17 +++ 5 files changed, 241 insertions(+), 2 deletions(-) create mode 100644 client/ui/gauntlet_hud.gd create mode 100644 client/ui/gauntlet_hud.tscn diff --git a/client/scenes/main.tscn b/client/scenes/main.tscn index e2caed8cb..6073bd572 100644 --- a/client/scenes/main.tscn +++ b/client/scenes/main.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=17 format=3 uid="uid://bswrmh7w8dbgm"] +[gd_scene load_steps=19 format=3 uid="uid://bswrmh7w8dbgm"] [ext_resource type="Script" path="res://scripts/main.gd" id="1_main"] [ext_resource type="Script" path="res://scripts/rendering/world_renderer.gd" id="2_world"] @@ -16,6 +16,8 @@ [ext_resource type="PackedScene" path="res://ui/world_radial.tscn" id="14_radial"] [ext_resource type="PackedScene" path="res://ui/dialogue_box.tscn" id="15_dialogue"] [ext_resource type="Script" path="res://scripts/rendering/fog_entities.gd" id="16_fogent"] +[ext_resource type="PackedScene" path="res://ui/gauntlet_hud.tscn" id="17_gauntlet"] +[ext_resource type="PackedScene" path="res://ui/bug_report_dialog.tscn" id="18_bugreport"] [node name="Game" type="Node2D"] script = ExtResource("1_main") @@ -131,6 +133,9 @@ layer = 20 ; D-053: Stance indicator — top-right, color-coded [node name="StanceIndicator" parent="UILayer" instance=ExtResource("13_stance")] +; #496: Gauntlet HUD — room timer + personal bests, hidden in non-gauntlet mode +[node name="GauntletHUD" parent="UILayer" instance=ExtResource("17_gauntlet")] + ; D-065: Inventory grid — 3x3, bottom-right, 40x40px, 1-9 hotkeys [node name="InventoryGrid" parent="UILayer" instance=ExtResource("12_inv")] @@ -140,6 +145,8 @@ script = ExtResource("10_cursor") ; --- Modal layer (CanvasLayer 30) --- ; Full-screen overlays: pause menu, inventory modal, death screen. -; Empty for Sprint 6 — exists so the layer is reserved in the tree. [node name="ModalLayer" type="CanvasLayer" parent="."] layer = 30 + +; #495: WRONG button (F12) — bug report capture dialog +[node name="BugReportDialog" parent="ModalLayer" instance=ExtResource("18_bugreport")] diff --git a/client/scripts/autoloads/game_state.gd b/client/scripts/autoloads/game_state.gd index cc5856d91..78866ba2f 100644 --- a/client/scripts/autoloads/game_state.gd +++ b/client/scripts/autoloads/game_state.gd @@ -36,6 +36,10 @@ var current_dialogue: Variant = null # {npc_name, npc_entity_id, speech, option # InputMapper suppresses movement when this is true. var dialogue_active: bool = false +# v8 fields (#496): Gauntlet mode — room timer + personal bests +var room_id: Variant = null # String room_id from snapshot, null in non-gauntlet mode +var gauntlet_mode: bool = false # true when snapshot includes gauntlet_mode flag + # v7 fields (#431, D-059/D-060) var pending_recognitions: Array = [] # [{entity_id, x, y, z, remaining_ticks, total_delay_ticks}] @@ -118,6 +122,16 @@ func apply_snapshot(snapshot: Dictionary) -> void: else: pending_recognitions = [] + # v8: gauntlet mode (#496) — room_id and gauntlet_mode + if snapshot.has("gauntlet_mode") and snapshot.gauntlet_mode == true: + gauntlet_mode = true + else: + gauntlet_mode = false + if snapshot.has("room_id") and snapshot.room_id is String: + room_id = snapshot.room_id + else: + room_id = null + # v2: visible_tiles with visibility sectors # Derives visible_positions when not explicitly provided (real server mode) if snapshot.has("visible_tiles") and snapshot.visible_tiles is Array and snapshot.visible_tiles.size() > 0: diff --git a/client/scripts/main.gd b/client/scripts/main.gd index a6f56ae33..e92f30849 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -12,6 +12,8 @@ extends Node2D @onready var inventory_grid = $UILayer/InventoryGrid # D-065: z-layer 7 @onready var stance_indicator = $UILayer/StanceIndicator # D-053: z-layer 7 @onready var cursor_renderer = $UILayer/CursorRenderer # D-056: z-layer 7 +@onready var gauntlet_hud = $UILayer/GauntletHUD # #496: room timer + personal bests +@onready var bug_report_dialog = $ModalLayer/BugReportDialog # #495: F12 WRONG button var _last_dialogue_npc_id: int = -1 # D-064: NPC entity_id for WalkAway input var _camera_anchored: bool = false @@ -49,6 +51,10 @@ func _ready() -> void: dialogue_box.dialogue_dismissed.connect(_on_dialogue_dismissed) dialogue_box.confrontation_monologue.connect(_on_confrontation_monologue) + # #496: Print gauntlet session summary on disconnect + if gauntlet_hud: + SimBridge.connection_state_changed.connect(_on_connection_state_changed) + func _process(_delta: float) -> void: # Main game loop: poll snapshot, apply state, flush input @@ -88,6 +94,10 @@ func _process(_delta: float) -> void: if fog_entities and fog_entities.has_method("update_from_state"): fog_entities.update_from_state() + # #496: Update gauntlet HUD (room timer + personal bests) + if gauntlet_hud and gauntlet_hud.has_method("update_from_state"): + gauntlet_hud.update_from_state() + # Show monologue if server sent one this tick (#414) _consume_monologue() @@ -109,6 +119,11 @@ func _process(_delta: float) -> void: # Send queued input to simulation var inputs = InputMapper.flush_queue() for input in inputs: + # #495: F12 WRONG button — client-only, trigger bug report capture + if input.action == InputMapper.Action.BUG_REPORT: + if bug_report_dialog and not bug_report_dialog.is_active(): + bug_report_dialog.start_capture() + continue if input.action == InputMapper.Action.INTERACT: # D-057: prefer interaction list (multi-verb), fall back to prompt (v0.1) var target_id: int = -1 @@ -197,3 +212,9 @@ func _on_dialogue_dismissed() -> void: "verb": "WalkAway", }, }) + + +# #496: Finalize gauntlet stats on disconnect +func _on_connection_state_changed(old_state: SimBridge.ConnectionState, new_state: SimBridge.ConnectionState) -> void: + if new_state == SimBridge.ConnectionState.DISCONNECTED and gauntlet_hud: + gauntlet_hud.finalize() diff --git a/client/ui/gauntlet_hud.gd b/client/ui/gauntlet_hud.gd new file mode 100644 index 000000000..aaf13f711 --- /dev/null +++ b/client/ui/gauntlet_hud.gd @@ -0,0 +1,180 @@ +extends Control + +## #496: Gauntlet HUD — room timer + personal bests. +## Shows TIMER: MM:SS (PB: MM:SS) in top-right, below StanceIndicator. +## Hidden in non-gauntlet mode. Stats persisted to tests/gauntlet-stats.json. + +const BG_COLOR := Color(0.05, 0.05, 0.08, 0.5) +const TIMER_COLOR := Color("#c8d0e0") # Default insert text +const PB_COLOR := Color("#6bc9a6") # Friendly green — personal best +const NEW_PB_COLOR := Color("#e8c547") # Amber flash on new PB +const FONT_SIZE := 13 +const PADDING := Vector2(10, 6) +const STATS_PATH := "user://gauntlet-stats.json" + +var _timer_seconds: float = 0.0 +var _timer_running: bool = false +var _current_room_id: Variant = null +var _personal_bests: Dictionary = {} # room_id -> float (seconds) +var _session_rooms: Dictionary = {} # room_id -> {attempts: int, best: float} +var _new_pb_flash: float = 0.0 # Countdown for PB flash effect + + +func _ready() -> void: + mouse_filter = Control.MOUSE_FILTER_IGNORE + visible = false + _load_stats() + + +func _process(delta: float) -> void: + if not visible: + return + if _timer_running: + _timer_seconds += delta + queue_redraw() + if _new_pb_flash > 0.0: + _new_pb_flash -= delta + queue_redraw() + + +func update_from_state() -> void: + if not GameState.gauntlet_mode: + if visible: + visible = false + return + + if not visible: + visible = true + queue_redraw() + + var new_room_id: Variant = GameState.room_id + if new_room_id == null: + # Gauntlet mode but no room yet — stop timer, wait + _timer_running = false + return + + if new_room_id != _current_room_id: + _on_room_change(new_room_id) + + +func _on_room_change(new_room_id: String) -> void: + # Record completion of previous room + if _current_room_id != null and _timer_running: + _record_room_completion(_current_room_id, _timer_seconds) + + # Start timer for new room + _current_room_id = new_room_id + _timer_seconds = 0.0 + _timer_running = true + + # Track session stats + if not _session_rooms.has(new_room_id): + _session_rooms[new_room_id] = {"attempts": 0, "best": INF} + _session_rooms[new_room_id]["attempts"] += 1 + + queue_redraw() + + +func _record_room_completion(completed_room_id: String, seconds: float) -> void: + var old_pb: float = _personal_bests.get(completed_room_id, INF) + if seconds < old_pb: + _personal_bests[completed_room_id] = seconds + _new_pb_flash = 2.0 # Flash for 2 seconds + _save_stats() + + # Update session tracking + if _session_rooms.has(completed_room_id): + var entry: Dictionary = _session_rooms[completed_room_id] + if seconds < entry["best"]: + entry["best"] = seconds + + +func _draw() -> void: + var font := ThemeDB.fallback_font + + var timer_text := "TIMER: " + _format_time(_timer_seconds) + var pb_text := "" + if _current_room_id != null and _personal_bests.has(_current_room_id): + pb_text = " (PB: " + _format_time(_personal_bests[_current_room_id]) + ")" + + var full_text := timer_text + pb_text + var text_size := font.get_string_size(full_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE) + var box_size := text_size + PADDING * 2 + + # Background + draw_rect(Rect2(Vector2.ZERO, box_size), BG_COLOR) + + # Timer text + var y_offset := PADDING.y + text_size.y + var timer_size := font.get_string_size(timer_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE) + draw_string(font, Vector2(PADDING.x, y_offset), timer_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, TIMER_COLOR) + + # PB text (different color) + if not pb_text.is_empty(): + var pb_color: Color = NEW_PB_COLOR if _new_pb_flash > 0.0 else PB_COLOR + draw_string(font, Vector2(PADDING.x + timer_size.x, y_offset), pb_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, pb_color) + + +static func _format_time(seconds: float) -> String: + var total_secs := int(seconds) + var mins := total_secs / 60 + var secs := total_secs % 60 + return "%02d:%02d" % [mins, secs] + + +func _load_stats() -> void: + if not FileAccess.file_exists(STATS_PATH): + return + var file := FileAccess.open(STATS_PATH, FileAccess.READ) + if file == null: + return + var json := JSON.new() + var err := json.parse(file.get_as_text()) + file.close() + if err == OK and json.data is Dictionary: + _personal_bests = json.data + + +func _save_stats() -> void: + var file := FileAccess.open(STATS_PATH, FileAccess.WRITE) + if file == null: + push_warning("GauntletHUD: cannot write stats to %s" % STATS_PATH) + return + file.store_string(JSON.stringify(_personal_bests, "\t")) + file.close() + + +func print_session_summary() -> void: + if _session_rooms.is_empty(): + return + print("=== Gauntlet Session Summary ===") + for room_id in _session_rooms: + var entry: Dictionary = _session_rooms[room_id] + var best_str := _format_time(entry["best"]) if entry["best"] != INF else "--:--" + var pb_str := _format_time(_personal_bests[room_id]) if _personal_bests.has(room_id) else "--:--" + print(" Room %s: %d attempts, session best %s, all-time PB %s" % [ + room_id, entry["attempts"], best_str, pb_str]) + print("================================") + + +# Record current room if timer is running (called on disconnect) +func finalize() -> void: + if _current_room_id != null and _timer_running: + _record_room_completion(_current_room_id, _timer_seconds) + _timer_running = false + print_session_summary() + + +# -- Public API --------------------------------------------------------------- + +func get_timer_seconds() -> float: + return _timer_seconds + +func is_timer_running() -> bool: + return _timer_running + +func get_current_room_id() -> Variant: + return _current_room_id + +func get_personal_best(for_room_id: String) -> float: + return _personal_bests.get(for_room_id, INF) diff --git a/client/ui/gauntlet_hud.tscn b/client/ui/gauntlet_hud.tscn new file mode 100644 index 000000000..bd90467e8 --- /dev/null +++ b/client/ui/gauntlet_hud.tscn @@ -0,0 +1,17 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://ui/gauntlet_hud.gd" id="1_gauntlet"] + +; #496: Gauntlet HUD — room timer + personal bests, top-right below StanceIndicator +[node name="GauntletHUD" type="Control"] +layout_mode = 3 +anchors_preset = 1 +anchor_left = 1.0 +anchor_right = 1.0 +offset_left = -160.0 +offset_top = 48.0 +offset_right = -16.0 +offset_bottom = 74.0 +grow_horizontal = 0 +mouse_filter = 2 +script = ExtResource("1_gauntlet") From d7755698b257625885ce4f86d28036380ea8591d Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 18 Feb 2026 10:53:15 +0100 Subject: [PATCH 2/5] feat(client): add WRONG button F12 bug report capture (#495) F12 pauses simulation, shows modal LineEdit prompt, saves three files to user://bug-reports/gauntlet-t{tick}-{timestamp}/: snapshot.json (full ObserverSnapshot), render.txt (simplified client-side text render), description.txt (tester notes + tick/room/stance metadata). Esc cancels without saving. Double-activation guard prevents stacking. BUG_REPORT action added to InputMapper with wire guard in SimBridge (client-only, never sent to server). Dialog on ModalLayer (CL 30). Co-Authored-By: Claude Opus 4.6 --- client/project.godot | 5 + client/scripts/autoloads/input_mapper.gd | 3 + client/scripts/autoloads/sim_bridge.gd | 4 + client/ui/bug_report_dialog.gd | 192 +++++++++++++++++++++++ client/ui/bug_report_dialog.tscn | 14 ++ 5 files changed, 218 insertions(+) create mode 100644 client/ui/bug_report_dialog.gd create mode 100644 client/ui/bug_report_dialog.tscn diff --git a/client/project.godot b/client/project.godot index 633e15686..717e5c35f 100644 --- a/client/project.godot +++ b/client/project.godot @@ -106,6 +106,11 @@ stance_down={ "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":88,"key_label":0,"unicode":120,"location":0,"echo":false,"script":null) ] } +bug_report={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194343,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} [rendering] diff --git a/client/scripts/autoloads/input_mapper.gd b/client/scripts/autoloads/input_mapper.gd index 0b99cb386..0f8d70188 100644 --- a/client/scripts/autoloads/input_mapper.gd +++ b/client/scripts/autoloads/input_mapper.gd @@ -14,6 +14,7 @@ enum Action { MOVE_SOUTH, MOVE_SOUTHWEST, MOVE_WEST, MOVE_NORTHWEST, INTERACT, USE_PERCEPTION_MODE, OPEN_MENU, PAUSE, UNPAUSE, TOGGLE_STANCE_UP, TOGGLE_STANCE_DOWN, + BUG_REPORT, # #495: F12 WRONG button — client-only, not sent to server } var input_queue: Array[Dictionary] = [] @@ -74,6 +75,8 @@ func _unhandled_input(event: InputEvent) -> void: action = Action.TOGGLE_STANCE_UP elif event.is_action_pressed("stance_down"): action = Action.TOGGLE_STANCE_DOWN + elif event.is_action_pressed("bug_report"): + action = Action.BUG_REPORT if action != -1: input_queue.append({ diff --git a/client/scripts/autoloads/sim_bridge.gd b/client/scripts/autoloads/sim_bridge.gd index 6f839a7bd..d35f2bfd5 100644 --- a/client/scripts/autoloads/sim_bridge.gd +++ b/client/scripts/autoloads/sim_bridge.gd @@ -258,6 +258,10 @@ static func _action_enum_to_wire(action: int) -> String: # Client-only action, not part of wire protocol push_warning("SimBridge: OPEN_MENU is client-only, not sent to server") return "" + InputMapper.Action.BUG_REPORT: + # Client-only action (#495), not part of wire protocol + push_warning("SimBridge: BUG_REPORT is client-only, not sent to server") + return "" _: push_warning("SimBridge: unknown action enum %s" % action) return "" diff --git a/client/ui/bug_report_dialog.gd b/client/ui/bug_report_dialog.gd new file mode 100644 index 000000000..61f1854a9 --- /dev/null +++ b/client/ui/bug_report_dialog.gd @@ -0,0 +1,192 @@ +extends Control + +## #495: WRONG button (F12) MVP — bug report capture dialog. +## On F12: pause sim, show one-line prompt, save snapshot + render + description, unpause. +## Output: user://bug-reports/gauntlet-t{tick}-{timestamp}/ + +signal capture_completed +signal capture_cancelled + +const BG_COLOR := Color(0.05, 0.05, 0.08, 0.85) +const BORDER_COLOR := Color("#4a9ebb") +const TEXT_COLOR := Color("#c8d0e0") +const FONT_SIZE := 14 +const LABEL_FONT_SIZE := 13 +const BOX_WIDTH := 500 +const BOX_HEIGHT := 120 +const PADDING := 16 + +var _line_edit: LineEdit = null +var _active: bool = false + + +func _ready() -> void: + visible = false + mouse_filter = Control.MOUSE_FILTER_STOP + + +func start_capture() -> void: + if _active: + return + _active = true + visible = true + + # Pause the simulation + SimBridge.send_input({ + "action": InputMapper.Action.PAUSE, + "timestamp_msec": Time.get_ticks_msec(), + }) + + # Create the LineEdit dynamically + _line_edit = LineEdit.new() + _line_edit.placeholder_text = "Describe the issue..." + _line_edit.size = Vector2(BOX_WIDTH - PADDING * 2, 30) + _line_edit.position = Vector2( + (get_viewport_rect().size.x - BOX_WIDTH) / 2.0 + PADDING, + (get_viewport_rect().size.y - BOX_HEIGHT) / 2.0 + 50 + ) + _line_edit.add_theme_font_size_override("font_size", FONT_SIZE) + _line_edit.text_submitted.connect(_on_text_submitted) + add_child(_line_edit) + _line_edit.grab_focus() + + +func _on_text_submitted(text: String) -> void: + _save_report(text) + _close() + capture_completed.emit() + + +func _unhandled_input(event: InputEvent) -> void: + if not _active: + return + if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE: + _close() + capture_cancelled.emit() + get_viewport().set_input_as_handled() + + +func _close() -> void: + _active = false + visible = false + if _line_edit: + _line_edit.queue_free() + _line_edit = null + + # Unpause the simulation + SimBridge.send_input({ + "action": InputMapper.Action.UNPAUSE, + "timestamp_msec": Time.get_ticks_msec(), + }) + + +func _save_report(description: String) -> void: + var tick := GameState.current_tick + var timestamp := Time.get_datetime_string_from_system().replace(":", "-").replace("T", "_") + var dir_name := "gauntlet-t%d-%s" % [tick, timestamp] + var base_path := "user://bug-reports/" + dir_name + + # Ensure directory exists + DirAccess.make_dir_recursive_absolute(base_path) + + # 1. snapshot.json — full current snapshot as JSON + var snapshot_path := base_path + "/snapshot.json" + var snapshot_file := FileAccess.open(snapshot_path, FileAccess.WRITE) + if snapshot_file: + snapshot_file.store_string(JSON.stringify(GameState.current_snapshot, "\t")) + snapshot_file.close() + + # 2. render.txt — simplified client-side text render of snapshot + var render_path := base_path + "/render.txt" + var render_file := FileAccess.open(render_path, FileAccess.WRITE) + if render_file: + render_file.store_string(_render_snapshot_text()) + render_file.close() + + # 3. description.txt — tester description + metadata + var desc_path := base_path + "/description.txt" + var desc_file := FileAccess.open(desc_path, FileAccess.WRITE) + if desc_file: + desc_file.store_string("Description: %s\n" % description) + desc_file.store_string("Tick: %d\n" % tick) + desc_file.store_string("Room: %s\n" % str(GameState.room_id if GameState.room_id else "none")) + desc_file.store_string("Stance: %s\n" % GameState.player_stance) + desc_file.store_string("Facing: %s\n" % GameState.player_facing) + desc_file.store_string("Position: %s\n" % str(GameState.player_position)) + desc_file.store_string("Timestamp: %s\n" % Time.get_datetime_string_from_system()) + desc_file.close() + + print("BugReport: saved to %s" % base_path) + + +## Simplified client-side text render of the current snapshot. +## MVP version — full fidelity via server's format_snapshot_text() is a stretch goal. +func _render_snapshot_text() -> String: + var lines: PackedStringArray = [] + lines.append("=== Snapshot t%d ===" % GameState.current_tick) + lines.append("Player: %s facing %s (%s)" % [ + str(GameState.player_position), GameState.player_facing, GameState.player_stance]) + + if GameState.game_time.size() > 0: + lines.append("Time: day %s, %s, %s" % [ + str(GameState.game_time.get("day", "?")), + str(GameState.game_time.get("day_phase", "?")), + str(GameState.game_time.get("tick_rate", "?"))]) + + lines.append("") + lines.append("Entities (%d):" % GameState.visible_entities.size()) + for entity in GameState.visible_entities: + var kind_str: String = "" + if entity.has("kind") and entity.kind is Dictionary: + kind_str = entity.kind.get("variant", "?") + elif entity.has("kind") and entity.kind is String: + kind_str = entity.kind + var vis: String = entity.get("visibility", "?") + lines.append(" #%s %s at (%s, %s) [%s]" % [ + str(entity.get("entity_id", "?")), + kind_str, + str(entity.get("x", "?")), + str(entity.get("y", "?")), + vis]) + + lines.append("") + lines.append("Visible tiles: %d" % GameState.visible_tiles.size()) + + if GameState.current_monologue != null: + lines.append("Monologue: %s" % str(GameState.current_monologue.get("text", ""))) + if GameState.current_dialogue != null: + lines.append("Dialogue: %s says '%s'" % [ + str(GameState.current_dialogue.get("npc_name", "?")), + str(GameState.current_dialogue.get("speech", ""))]) + + return "\n".join(lines) + + +func _draw() -> void: + if not _active: + return + var viewport_size := get_viewport_rect().size + # Full-screen dim + draw_rect(Rect2(Vector2.ZERO, viewport_size), BG_COLOR) + + # Center box + var box_pos := Vector2( + (viewport_size.x - BOX_WIDTH) / 2.0, + (viewport_size.y - BOX_HEIGHT) / 2.0 + ) + var box_rect := Rect2(box_pos, Vector2(BOX_WIDTH, BOX_HEIGHT)) + draw_rect(box_rect, Color(0.08, 0.08, 0.12, 0.95)) + draw_rect(box_rect, BORDER_COLOR, false, 1.0) + + # Title + var font := ThemeDB.fallback_font + draw_string(font, + box_pos + Vector2(PADDING, 24), + "WRONG — Describe the issue (Enter to save, Esc to cancel):", + HORIZONTAL_ALIGNMENT_LEFT, -1, LABEL_FONT_SIZE, TEXT_COLOR) + + +# -- Public API --------------------------------------------------------------- + +func is_active() -> bool: + return _active diff --git a/client/ui/bug_report_dialog.tscn b/client/ui/bug_report_dialog.tscn new file mode 100644 index 000000000..b4e8c8cab --- /dev/null +++ b/client/ui/bug_report_dialog.tscn @@ -0,0 +1,14 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://ui/bug_report_dialog.gd" id="1_bugreport"] + +; #495: WRONG button (F12) — bug report capture dialog, ModalLayer +[node name="BugReportDialog" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +script = ExtResource("1_bugreport") From a2926743420b42df1d3a34abf49369a024d9c084 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 18 Feb 2026 10:53:26 +0100 Subject: [PATCH 3/5] feat(client): add 24 gauntlet + bug report tests (#495, #496) Replace stub F12 tests with BugReportDialog integration tests (dialog exists, activates on action, pause/unpause, wire guard, text render with entities/monologue/dialogue, empty snapshot edge case). Add 16 GauntletHUD tests (format_time, visibility toggle, timer lifecycle, room change reset, personal bests record/overwrite/preserve, null room, timer paused when hidden, finalize, session attempts, snapshot roundtrip). Anti-tedium assertions now falsifiable against real GameState.room_id and gauntlet_mode properties. Co-Authored-By: Claude Opus 4.6 --- client/tests/test_anti_tedium.gd | 501 ++++++++++++++++++++++++++----- 1 file changed, 424 insertions(+), 77 deletions(-) diff --git a/client/tests/test_anti_tedium.gd b/client/tests/test_anti_tedium.gd index a97703a32..f9c493d09 100644 --- a/client/tests/test_anti_tedium.gd +++ b/client/tests/test_anti_tedium.gd @@ -14,6 +14,8 @@ extends GdUnitTestSuite var _instance: Node = null +var GauntletHUDScript = load("res://ui/gauntlet_hud.gd") +var BugReportDialogScript = load("res://ui/bug_report_dialog.gd") func before_test() -> void: @@ -28,6 +30,8 @@ func before_test() -> void: GameState.current_dialogue = null GameState.game_time = {} GameState.pending_recognitions = [] + GameState.room_id = null + GameState.gauntlet_mode = false func after_test() -> void: @@ -77,91 +81,85 @@ func _make_snapshot_bytes(overrides: Dictionary = {}) -> PackedByteArray: return result.value -# -- Test 1: F12 Bug Report Capture ------------------------------------------- -# Regression guard: F12 press must not crash or cause unintended side effects. -# When #495 lands, this test verifies the full capture flow: -# 1. Game pauses (tick_rate → Paused) -# 2. Capture dialog appears -# 3. Three files saved to tests/bug-reports/YYYY-MM-DD_HH-MM-SS/ -# 4. Game unpauses -# -# Until #495: verifies F12 is inert — no crash, no state corruption. +func _make_gauntlet_hud() -> Control: + var hud = Control.new() + hud.set_script(GauntletHUDScript) + auto_free(hud) + add_child(hud) + hud._personal_bests = {} # Clear any stats loaded from disk + return hud -func test_f12_press_no_crash_without_handler() -> void: - # Load main scene — full game tree + +func _make_bug_report_dialog() -> Control: + var dialog = Control.new() + dialog.set_script(BugReportDialogScript) + auto_free(dialog) + add_child(dialog) + return dialog + + +# -- Test 1: F12 Bug Report Capture ------------------------------------------- +# #495: BUG_REPORT action triggers the WRONG button capture flow. +# Verifies: dialog exists, activates on action, no state corruption. +# Note: Input.parse_input_event + _unhandled_input is unreliable in headless +# test mode. Tests drive the input queue directly for deterministic coverage. + +func test_bug_report_dialog_exists_in_scene() -> void: + # Verify the BugReportDialog node is present and hidden by default. var scene := load("res://scenes/main.tscn") _instance = scene.instantiate() auto_free(_instance) add_child(_instance) - - # Process one frame to let _ready() and initial snapshot settle. - # The test snapshot at tick 1 includes a monologue that gets consumed here. _instance._process(0.016) - # Record state AFTER initialization — this is the stable baseline. - var tick_rate_before: String = GameState.game_time.get("tick_rate", "Full") + var dialog: Node = _find_node_recursive(_instance, "BugReportDialog") + assert_that(dialog).override_failure_message( + "BugReportDialog node should exist in scene tree" + ).is_not_null() + if dialog is CanvasItem: + assert_that((dialog as CanvasItem).visible).override_failure_message( + "BugReportDialog should be hidden by default" + ).is_false() + + +func test_bug_report_activates_on_action() -> void: + # Inject BUG_REPORT action directly into the queue and verify main.gd + # triggers the dialog. This tests the full main._process() handling path. + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + _instance._process(0.016) + + # Record baseline state var mono_before: Variant = GameState.current_monologue var dialogue_before: Variant = GameState.current_dialogue - # Simulate F12 key press via the Godot input system. - # InputMapper._unhandled_input() checks action bindings — F12 is not bound - # to any action yet, so the event should pass through harmlessly. - var event := InputEventKey.new() - event.keycode = KEY_F12 - event.pressed = true - event.key_label = KEY_F12 - Input.parse_input_event(event) + # Inject BUG_REPORT action into InputMapper queue + InputMapper.input_queue.append({ + "action": InputMapper.Action.BUG_REPORT, + "timestamp_msec": Time.get_ticks_msec(), + }) - # Process a frame to let the event propagate + # Process a frame — main.gd flushes the queue and triggers dialog _instance._process(0.016) - # Assert: no state corruption from unhandled F12 - assert_that(GameState.game_time.get("tick_rate", "Full")).override_failure_message( - "F12 press should not change tick_rate (no handler yet)" - ).is_equal(tick_rate_before) + # Assert: dialog should be active + var dialog: Node = _find_node_recursive(_instance, "BugReportDialog") + assert_that(dialog).is_not_null() + if dialog and dialog.has_method("is_active"): + assert_that(dialog.is_active()).override_failure_message( + "BugReportDialog should be active after BUG_REPORT action" + ).is_true() + + # Assert: no unintended state corruption assert_that(GameState.current_monologue).override_failure_message( - "F12 press should not spawn a monologue" + "BUG_REPORT should not spawn a monologue" ).is_equal(mono_before) assert_that(GameState.current_dialogue).override_failure_message( - "F12 press should not spawn a dialogue" + "BUG_REPORT should not spawn a dialogue" ).is_equal(dialogue_before) - # Release the key - var release := InputEventKey.new() - release.keycode = KEY_F12 - release.pressed = false - release.key_label = KEY_F12 - Input.parse_input_event(release) - - -func test_f12_does_not_queue_input_action() -> void: - # Verify F12 does not produce any action in InputMapper's queue. - # When #495 adds the "bug_report" action, this test will be updated - # to verify the correct action IS queued. - InputMapper.input_queue.clear() - - var event := InputEventKey.new() - event.keycode = KEY_F12 - event.pressed = true - event.key_label = KEY_F12 - Input.parse_input_event(event) - - # Give InputMapper a frame to process - InputMapper._process(0.016) - - # F12 is not bound to any InputMapper.Action — queue should remain empty - assert_that(InputMapper.input_queue.size()).override_failure_message( - "F12 should not produce any input action (no binding exists yet)" - ).is_equal(0) - - # Cleanup - var release := InputEventKey.new() - release.keycode = KEY_F12 - release.pressed = false - release.key_label = KEY_F12 - Input.parse_input_event(release) - InputMapper.flush_queue() - # -- Test 2: Gauntlet Progress UI Hidden in Non-Gauntlet Mode ----------------- # When #496 lands, it adds a room timer and personal-bests overlay. @@ -214,17 +212,14 @@ func test_snapshot_without_room_id_shows_no_gauntlet_ui() -> void: # Apply to GameState — gauntlet-related state should not exist GameState.apply_snapshot(snapshot) - # Guard: when #496 adds GameState.room_id / gauntlet_mode properties, - # these assertions become falsifiable — they'll catch any code path that - # sets gauntlet state from a non-gauntlet snapshot. Currently Object.get() - # returns null for nonexistent properties, so this passes trivially until - # the properties are defined. - assert_that(GameState.get("room_id")).override_failure_message( - "GameState.room_id should not exist or be null in non-gauntlet mode" - ).is_null() - assert_that(GameState.get("gauntlet_mode")).override_failure_message( - "GameState.gauntlet_mode should not exist or be null in non-gauntlet mode" + # #496: GameState now has room_id (Variant, null) and gauntlet_mode (bool, false). + # A non-gauntlet snapshot must leave these at their defaults. + assert_that(GameState.room_id).override_failure_message( + "GameState.room_id should be null in non-gauntlet mode" ).is_null() + assert_that(GameState.gauntlet_mode).override_failure_message( + "GameState.gauntlet_mode should be false in non-gauntlet mode" + ).is_false() func test_gauntlet_ui_stays_hidden_after_multiple_snapshots() -> void: @@ -253,6 +248,358 @@ func test_gauntlet_ui_stays_hidden_after_multiple_snapshots() -> void: ).is_false() +# -- GauntletHUD Feature Tests (#496) ---------------------------------------- +# Timer lifecycle, personal bests, room change, visibility. +# Spec: sprint-9/client.md #496. Ticket: db/connectors/ticket show 496. + +func test_format_time_zero() -> void: + # Static utility: 0 seconds → "00:00" + var hud := _make_gauntlet_hud() + assert_that(hud._format_time(0.0)).is_equal("00:00") + + +func test_format_time_sub_minute() -> void: + # 47.9 seconds (truncates, no rounding) → "00:47" + var hud := _make_gauntlet_hud() + assert_that(hud._format_time(47.9)).is_equal("00:47") + + +func test_format_time_over_minute() -> void: + # 98.3 seconds → 1 min 38 sec → "01:38" + var hud := _make_gauntlet_hud() + assert_that(hud._format_time(98.3)).is_equal("01:38") + + +func test_gauntlet_hud_shows_when_gauntlet_mode_active() -> void: + # HUD must become visible when gauntlet_mode is true in the snapshot. + var hud := _make_gauntlet_hud() + assert_that(hud.visible).is_false() + GameState.gauntlet_mode = true + GameState.room_id = "room_1" + hud.update_from_state() + assert_that(hud.visible).override_failure_message( + "GauntletHUD must be visible when gauntlet_mode is true" + ).is_true() + + +func test_gauntlet_hud_hides_when_gauntlet_mode_deactivates() -> void: + # HUD must hide when gauntlet_mode transitions from true to false. + var hud := _make_gauntlet_hud() + GameState.gauntlet_mode = true + GameState.room_id = "room_1" + hud.update_from_state() + assert_that(hud.visible).is_true() + # Switch to non-gauntlet + GameState.gauntlet_mode = false + GameState.room_id = null + hud.update_from_state() + assert_that(hud.visible).override_failure_message( + "GauntletHUD must hide when gauntlet_mode becomes false" + ).is_false() + + +func test_gauntlet_hud_timer_starts_on_room_entry() -> void: + # Timer starts running and tracks room_id after room entry. + var hud := _make_gauntlet_hud() + GameState.gauntlet_mode = true + GameState.room_id = "room_1" + hud.update_from_state() + assert_that(hud.is_timer_running()).override_failure_message( + "Timer should be running after room entry" + ).is_true() + assert_that(hud.get_current_room_id()).is_equal("room_1") + + +func test_gauntlet_hud_timer_increments_with_delta() -> void: + # Timer accumulates real time via _process(delta). + var hud := _make_gauntlet_hud() + GameState.gauntlet_mode = true + GameState.room_id = "room_1" + hud.update_from_state() + hud._process(0.5) + assert_that(hud.get_timer_seconds()).override_failure_message( + "Timer should increment by delta (0.5s)" + ).is_equal_approx(0.5, 0.01) + hud._process(1.0) + assert_that(hud.get_timer_seconds()).override_failure_message( + "Timer should accumulate (0.5 + 1.0 = 1.5s)" + ).is_equal_approx(1.5, 0.01) + + +func test_gauntlet_hud_timer_resets_on_room_change() -> void: + # Changing room_id resets the timer to 0 and starts counting for the new room. + var hud := _make_gauntlet_hud() + GameState.gauntlet_mode = true + GameState.room_id = "room_1" + hud.update_from_state() + hud._process(5.0) + assert_that(hud.get_timer_seconds() > 4.0).is_true() + # Change room + GameState.room_id = "room_2" + hud.update_from_state() + assert_that(hud.get_timer_seconds()).override_failure_message( + "Timer should reset to 0 on room change" + ).is_equal_approx(0.0, 0.01) + assert_that(hud.get_current_room_id()).is_equal("room_2") + assert_that(hud.is_timer_running()).is_true() + + +func test_gauntlet_hud_records_personal_best() -> void: + # Completing a room (by entering a new room) records a personal best. + var hud := _make_gauntlet_hud() + GameState.gauntlet_mode = true + GameState.room_id = "room_1" + hud.update_from_state() + hud._process(10.0) + # Entering room_2 triggers _record_room_completion for room_1 + GameState.room_id = "room_2" + hud.update_from_state() + assert_that(hud.get_personal_best("room_1")).override_failure_message( + "PB for room_1 should be ~10.0s after first completion" + ).is_equal_approx(10.0, 0.1) + + +func test_gauntlet_hud_non_pb_does_not_overwrite() -> void: + # A worse time must not overwrite an existing personal best. + var hud := _make_gauntlet_hud() + hud._personal_bests["room_1"] = 5.0 # Existing PB of 5 seconds + GameState.gauntlet_mode = true + GameState.room_id = "room_1" + hud.update_from_state() + hud._process(10.0) # Worse time than existing PB + GameState.room_id = "room_2" + hud.update_from_state() + assert_that(hud.get_personal_best("room_1")).override_failure_message( + "PB should remain 5.0 when new time (10.0) is worse" + ).is_equal_approx(5.0, 0.01) + + +func test_gauntlet_hud_pb_overwrites_when_faster() -> void: + # A faster time must overwrite an existing personal best. + var hud := _make_gauntlet_hud() + hud._personal_bests["room_1"] = 15.0 # Existing PB of 15 seconds + GameState.gauntlet_mode = true + GameState.room_id = "room_1" + hud.update_from_state() + hud._process(8.0) # Better time + GameState.room_id = "room_2" + hud.update_from_state() + assert_that(hud.get_personal_best("room_1")).override_failure_message( + "PB should update to 8.0 when new time beats old PB (15.0)" + ).is_equal_approx(8.0, 0.1) + + +func test_gauntlet_hud_null_room_in_gauntlet_mode() -> void: + # Gauntlet mode active but no room_id yet — HUD visible, timer stopped. + var hud := _make_gauntlet_hud() + GameState.gauntlet_mode = true + GameState.room_id = null + hud.update_from_state() + assert_that(hud.visible).override_failure_message( + "HUD should be visible in gauntlet mode even without room_id" + ).is_true() + assert_that(hud.is_timer_running()).override_failure_message( + "Timer should NOT run when room_id is null" + ).is_false() + + +func test_gauntlet_hud_timer_paused_when_not_visible() -> void: + # Timer should not accumulate when HUD is not visible (non-gauntlet mode). + var hud := _make_gauntlet_hud() + GameState.gauntlet_mode = true + GameState.room_id = "room_1" + hud.update_from_state() + hud._process(2.0) + assert_that(hud.get_timer_seconds()).is_equal_approx(2.0, 0.01) + # Switch to non-gauntlet (hides HUD) + GameState.gauntlet_mode = false + hud.update_from_state() + hud._process(5.0) # Should NOT accumulate + assert_that(hud.get_timer_seconds()).override_failure_message( + "Timer must not accumulate when HUD is hidden" + ).is_equal_approx(2.0, 0.01) + + +func test_gauntlet_hud_finalize_records_current_room() -> void: + # finalize() records the current room's time and stops the timer. + # Called on disconnect via _on_connection_state_changed. + var hud := _make_gauntlet_hud() + GameState.gauntlet_mode = true + GameState.room_id = "room_1" + hud.update_from_state() + hud._process(7.5) + hud.finalize() + assert_that(hud.is_timer_running()).override_failure_message( + "Timer should stop after finalize" + ).is_false() + assert_that(hud.get_personal_best("room_1")).override_failure_message( + "Finalize should record current room's time as PB" + ).is_equal_approx(7.5, 0.1) + + +func test_gauntlet_hud_session_tracks_attempts() -> void: + # Each room entry increments the attempt counter in _session_rooms. + var hud := _make_gauntlet_hud() + GameState.gauntlet_mode = true + GameState.room_id = "room_1" + hud.update_from_state() + hud._process(3.0) + # Re-enter same room (via room change and back) + GameState.room_id = "room_2" + hud.update_from_state() + GameState.room_id = "room_1" + hud.update_from_state() + assert_that(hud._session_rooms.has("room_1")).is_true() + assert_that(hud._session_rooms["room_1"]["attempts"]).override_failure_message( + "Room should show 2 attempts after two entries" + ).is_equal(2) + + +func test_gauntlet_snapshot_roundtrip_via_apply() -> void: + # Snapshot with gauntlet fields applied to GameState, consumed by HUD. + # End-to-end: bytes → decode → apply → update_from_state. + var bytes := _make_snapshot_bytes({ + "tick": 10, + "gauntlet_mode": true, + "room_id": "warehouse_01", + }) + SimBridge.receive_bytes(bytes) + GameState.apply_snapshot(SimBridge._last_snapshot) + assert_that(GameState.gauntlet_mode).is_true() + assert_that(GameState.room_id).is_equal("warehouse_01") + var hud := _make_gauntlet_hud() + hud.update_from_state() + assert_that(hud.visible).is_true() + assert_that(hud.get_current_room_id()).is_equal("warehouse_01") + assert_that(hud.is_timer_running()).is_true() + + +# -- BugReportDialog Feature Tests (#495) ------------------------------------ +# Pause/unpause lifecycle, wire guard, text render, state machine. +# Spec: sprint-9/client.md #495. Ticket: db/connectors/ticket show 495. + +func test_bug_report_sends_pause_on_open() -> void: + # start_capture() must send Pause to the server. + SimBridge.connect_to_sim() + SimBridge._test_input_queue.clear() + var dialog := _make_bug_report_dialog() + dialog.start_capture() + assert_that(dialog.is_active()).is_true() + assert_that(SimBridge._test_input_queue.has("Pause")).override_failure_message( + "Opening bug report should send Pause to server" + ).is_true() + + +func test_bug_report_sends_unpause_on_close() -> void: + # _close() must send Unpause to the server. + SimBridge.connect_to_sim() + var dialog := _make_bug_report_dialog() + dialog.start_capture() + SimBridge._test_input_queue.clear() + dialog._close() + assert_that(dialog.is_active()).is_false() + assert_that(SimBridge._test_input_queue.has("Unpause")).override_failure_message( + "Closing bug report should send Unpause to server" + ).is_true() + + +func test_bug_report_not_double_activatable() -> void: + # Calling start_capture() twice must not double-activate or send duplicate Pause. + SimBridge.connect_to_sim() + var dialog := _make_bug_report_dialog() + dialog.start_capture() + assert_that(dialog.is_active()).is_true() + SimBridge._test_input_queue.clear() + dialog.start_capture() # Second call — should be blocked + assert_that(SimBridge._test_input_queue.size()).override_failure_message( + "Double-activation should be blocked (no second Pause sent)" + ).is_equal(0) + + +func test_bug_report_action_not_on_wire() -> void: + # BUG_REPORT is client-only — must not produce a wire-format action name. + var wire_name := SimBridge._action_enum_to_wire(InputMapper.Action.BUG_REPORT) + assert_that(wire_name).override_failure_message( + "BUG_REPORT must not produce a wire action name (client-only)" + ).is_equal("") + + +func test_bug_report_render_text_contains_tick_and_entities() -> void: + # _render_snapshot_text() must include tick number and entity data from GameState. + GameState.current_tick = 42 + GameState.player_position = Vector2(5.0, 7.0) + GameState.player_facing = "South" + GameState.player_stance = "Crouch" + GameState.visible_entities = [{ + "entity_id": 1, "x": 5.0, "y": 7.0, "z": 0, + "kind": {"variant": "Player", "data": null}, "visibility": "Forward", + }] + GameState.visible_tiles = [] + GameState.game_time = {"day": 1, "day_phase": "Evening", "tick_rate": "Full"} + GameState.current_monologue = null + GameState.current_dialogue = null + var dialog := _make_bug_report_dialog() + var text: String = dialog._render_snapshot_text() + assert_that(text.contains("t42")).override_failure_message( + "Render text should contain tick number" + ).is_true() + assert_that(text.contains("Entities (1)")).override_failure_message( + "Render text should show entity count" + ).is_true() + assert_that(text.contains("Player")).override_failure_message( + "Render text should contain entity kind" + ).is_true() + assert_that(text.contains("South")).override_failure_message( + "Render text should contain player facing" + ).is_true() + + +func test_bug_report_render_text_with_monologue_and_dialogue() -> void: + # _render_snapshot_text() should include active monologue and dialogue. + GameState.current_tick = 10 + GameState.player_position = Vector2(3.0, 3.0) + GameState.player_facing = "North" + GameState.player_stance = "Walk" + GameState.visible_entities = [] + GameState.visible_tiles = [] + GameState.game_time = {"day": 0, "day_phase": "Morning", "tick_rate": "Full"} + GameState.current_monologue = {"text": "Something is wrong here."} + GameState.current_dialogue = {"npc_name": "Kael", "speech": "Who are you?"} + var dialog := _make_bug_report_dialog() + var text: String = dialog._render_snapshot_text() + assert_that(text.contains("Something is wrong here.")).override_failure_message( + "Render text should include monologue text" + ).is_true() + assert_that(text.contains("Kael")).override_failure_message( + "Render text should include dialogue NPC name" + ).is_true() + assert_that(text.contains("Who are you?")).override_failure_message( + "Render text should include dialogue speech" + ).is_true() + + +func test_bug_report_render_text_empty_snapshot() -> void: + # Edge case: F12 pressed before any snapshot data arrives. + GameState.current_tick = 0 + GameState.player_position = Vector2.ZERO + GameState.player_facing = "North" + GameState.player_stance = "Walk" + GameState.visible_entities = [] + GameState.visible_tiles = [] + GameState.game_time = {} + GameState.current_monologue = null + GameState.current_dialogue = null + var dialog := _make_bug_report_dialog() + var text: String = dialog._render_snapshot_text() + # Should not crash, should produce some output + assert_that(text.length() > 0).override_failure_message( + "Render text should not be empty even with no snapshot data" + ).is_true() + assert_that(text.contains("Entities (0)")).override_failure_message( + "Empty snapshot should show 0 entities" + ).is_true() + + # -- Helper: recursive node search -------------------------------------------- func _find_node_recursive(root: Node, target_name: String) -> Node: From 9e027c5e9d2679fa8b31aee227854f7e9d71e795 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 18 Feb 2026 10:53:42 +0100 Subject: [PATCH 4/5] chore(meta): update changelog for sprint 9 client work Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f11246d4..9395ce5d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,11 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). - review-pr skill: explicit verdict rules — critical/warning → REQUEST_CHANGES, suggestion-only → APPROVE ### Added +- Gauntlet room timer + personal bests (#496) — GauntletHUD shows TIMER: MM:SS (PB: MM:SS), starts on room entry, resets on room change, persists stats to user://gauntlet-stats.json, session summary on disconnect, hidden in non-gauntlet mode +- WRONG button F12 MVP (#495) — bug report capture: pause sim, show modal prompt, save snapshot.json + render.txt + description.txt to user://bug-reports/, Esc to cancel +- GameState.room_id and gauntlet_mode fields — parsed from ObserverSnapshot, enabling gauntlet UI +- BUG_REPORT action in InputMapper (F12 binding) with SimBridge wire guard (client-only) +- 24 new anti-tedium tests — GauntletHUD lifecycle (16: timer, PB, visibility, room change, session tracking) + BugReportDialog (8: pause/unpause, wire guard, text render, edge cases) - whatsinagame starter kit — reusable multi-agent team bootstrap for any project (3-tier profiles, 18 skills, 16 agent archetypes, stakeholder personas, ticketing DB, decision tracking) - Domain-action naming convention for skills documented in create-skill guide - `gauntlet` feature flag (default-on) — allows stripping Gauntlet test world from release builds with `--no-default-features` From dd8718c762474d107cbb45427fd0a13e2371bcf0 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 18 Feb 2026 11:06:56 +0100 Subject: [PATCH 5/5] =?UTF-8?q?fix(client):=20address=20PR=20#34=20review?= =?UTF-8?q?=20=E2=80=94=208=20items=20from=20Hoshe=20and=20Tyre?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cast Variant to String via str() before passing to _on_room_change - Clear _current_room_id on null room transition (fixes re-entry skip) - Add push_error for failed dir creation and file writes in _save_report - Fix docstring: tests/gauntlet-stats.json → user://dev/gauntlet-stats.json - Namespace stats path to user://dev/ to avoid save data collision - Replace print() with push_warning in _save_report (codebase consistency) - Downgrade client-only wire guard from push_warning to silent return Co-Authored-By: Claude Opus 4.6 --- client/scripts/autoloads/sim_bridge.gd | 8 ++------ client/ui/bug_report_dialog.gd | 18 ++++++++++++++++-- client/ui/gauntlet_hud.gd | 10 ++++++---- 3 files changed, 24 insertions(+), 12 deletions(-) diff --git a/client/scripts/autoloads/sim_bridge.gd b/client/scripts/autoloads/sim_bridge.gd index d35f2bfd5..9400132ca 100644 --- a/client/scripts/autoloads/sim_bridge.gd +++ b/client/scripts/autoloads/sim_bridge.gd @@ -255,13 +255,9 @@ static func _action_enum_to_wire(action: int) -> String: InputMapper.Action.TOGGLE_STANCE_UP: return "ToggleStanceUp" InputMapper.Action.TOGGLE_STANCE_DOWN: return "ToggleStanceDown" InputMapper.Action.OPEN_MENU: - # Client-only action, not part of wire protocol - push_warning("SimBridge: OPEN_MENU is client-only, not sent to server") - return "" + return "" # Client-only action, not part of wire protocol InputMapper.Action.BUG_REPORT: - # Client-only action (#495), not part of wire protocol - push_warning("SimBridge: BUG_REPORT is client-only, not sent to server") - return "" + return "" # Client-only action (#495), not part of wire protocol _: push_warning("SimBridge: unknown action enum %s" % action) return "" diff --git a/client/ui/bug_report_dialog.gd b/client/ui/bug_report_dialog.gd index 61f1854a9..40ee7a669 100644 --- a/client/ui/bug_report_dialog.gd +++ b/client/ui/bug_report_dialog.gd @@ -87,7 +87,12 @@ func _save_report(description: String) -> void: var base_path := "user://bug-reports/" + dir_name # Ensure directory exists - DirAccess.make_dir_recursive_absolute(base_path) + var dir_err := DirAccess.make_dir_recursive_absolute(base_path) + if dir_err != OK: + push_error("BugReport: failed to create directory %s (error %d)" % [base_path, dir_err]) + return + + var files_saved := 0 # 1. snapshot.json — full current snapshot as JSON var snapshot_path := base_path + "/snapshot.json" @@ -95,6 +100,9 @@ func _save_report(description: String) -> void: if snapshot_file: snapshot_file.store_string(JSON.stringify(GameState.current_snapshot, "\t")) snapshot_file.close() + files_saved += 1 + else: + push_error("BugReport: failed to write %s" % snapshot_path) # 2. render.txt — simplified client-side text render of snapshot var render_path := base_path + "/render.txt" @@ -102,6 +110,9 @@ func _save_report(description: String) -> void: if render_file: render_file.store_string(_render_snapshot_text()) render_file.close() + files_saved += 1 + else: + push_error("BugReport: failed to write %s" % render_path) # 3. description.txt — tester description + metadata var desc_path := base_path + "/description.txt" @@ -115,8 +126,11 @@ func _save_report(description: String) -> void: desc_file.store_string("Position: %s\n" % str(GameState.player_position)) desc_file.store_string("Timestamp: %s\n" % Time.get_datetime_string_from_system()) desc_file.close() + files_saved += 1 + else: + push_error("BugReport: failed to write %s" % desc_path) - print("BugReport: saved to %s" % base_path) + push_warning("BugReport: saved %d/3 files to %s" % [files_saved, base_path]) ## Simplified client-side text render of the current snapshot. diff --git a/client/ui/gauntlet_hud.gd b/client/ui/gauntlet_hud.gd index aaf13f711..d4d47c163 100644 --- a/client/ui/gauntlet_hud.gd +++ b/client/ui/gauntlet_hud.gd @@ -2,7 +2,7 @@ extends Control ## #496: Gauntlet HUD — room timer + personal bests. ## Shows TIMER: MM:SS (PB: MM:SS) in top-right, below StanceIndicator. -## Hidden in non-gauntlet mode. Stats persisted to tests/gauntlet-stats.json. +## Hidden in non-gauntlet mode. Stats persisted to user://dev/gauntlet-stats.json. const BG_COLOR := Color(0.05, 0.05, 0.08, 0.5) const TIMER_COLOR := Color("#c8d0e0") # Default insert text @@ -10,7 +10,7 @@ const PB_COLOR := Color("#6bc9a6") # Friendly green — personal best const NEW_PB_COLOR := Color("#e8c547") # Amber flash on new PB const FONT_SIZE := 13 const PADDING := Vector2(10, 6) -const STATS_PATH := "user://gauntlet-stats.json" +const STATS_PATH := "user://dev/gauntlet-stats.json" var _timer_seconds: float = 0.0 var _timer_running: bool = false @@ -49,12 +49,14 @@ func update_from_state() -> void: var new_room_id: Variant = GameState.room_id if new_room_id == null: - # Gauntlet mode but no room yet — stop timer, wait + # Gauntlet mode but no room yet — stop timer, clear tracked room + # so re-entry to the same room after null triggers a restart. _timer_running = false + _current_room_id = null return if new_room_id != _current_room_id: - _on_room_change(new_room_id) + _on_room_change(str(new_room_id)) func _on_room_change(new_room_id: String) -> void: