From d7755698b257625885ce4f86d28036380ea8591d Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 18 Feb 2026 10:53:15 +0100 Subject: [PATCH] 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")