Files
settled-reach/client/ui/meta/screens/debug_console/debug_console.gd
T
jpmschweitzer 1b8e227413 refactor(ui): PR #134 review — MetaScreen/ESC chain tightening, D-192 reword
Addresses Tyre's 9 architecture items from the sprint-36 client review.

- decisions/architecture.md (Tyre #1): D-192 now says "deprecate; removal
  tracked in #868" instead of "remove". The branch does not remove the
  version field or guard — that belongs in the coordinated server+client
  PR. The decision text now matches the code on this branch.
- meta_stack.gd (#2): handle_escape() on a screen with
  closable_by_escape=false now consumes the event unconditionally. Was
  returning whatever on_escape() returned, which default-returned false
  and leaked ESC into main.gd's implant/settings chain — opening the
  settings dialog behind the loading screen.
- debug_console.gd (#4): drop the direct KEY_ESCAPE branch in
  _unhandled_input. ESC now falls through to main.gd → MetaStack, which
  finds the console on top of the stack and closes it via the normal
  path. Other keys are still consumed so movement/action can't leak.
- main.gd (#6, #10): extract the ESC priority chain into
  _handle_menu_key() so "MetaStack → implant → settings" is a named
  thing. Add a comment near connect_to_sim explaining that
  GameState.bookmark_catalog survives the Option A scene transition via
  the autoload.
- main_menu.gd (#7): header comment documenting the double LoadingScreen
  lifecycle — safe today because main_menu.tscn and main.tscn never
  co-exist, noted for future promotion to autoload if that changes.
- meta_screen.gd (#8): apply captures_input symmetrically in open()/
  close() — was set in open() only, so a screen changing the flag
  between open+close kept the opened value forever.
- meta_screen.gd (#9): on_escape() docstring clarifies the tri-state
  (consume-and-hold / consume-and-close / ignore) — and that
  closable_by_escape=false is the screen-wide way to say
  "consume-and-hold".
- bug_report_dialog.gd (#11): capture_cancelled now emits from
  on_close() (covers any close path — ESC, MetaStack pop, programmatic
  close) rather than only on_escape(). A new _completed flag
  distinguishes completion from cancel so the two signals stay
  mutually exclusive.
2026-04-21 12:02:09 +02:00

512 lines
15 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
class_name DebugConsole
extends MetaScreen
## In-game debug console (#581). Tilde key (`) toggles open/closed.
## Semi-transparent panel anchored to bottom ~40% of screen.
## Dispatches DebugCommandKind variants to server via SimBridge.
## Settings-toggled; enabled state persisted in user://settings.cfg.
## D-088: triggers Overlay pause while open — sim must not advance during debug input.
signal pause_requested # D-088: pause sim while console is open
signal unpause_requested # D-088: unpause sim when console closes
const PREFS_PATH := "user://settings.cfg"
const PREFS_SECTION := "debug"
const PREFS_KEY_ENABLED := "console_enabled"
const MAX_LOG_LINES := 50
const BG_COLOR := Color(0.04, 0.04, 0.06, 0.92)
const BORDER_COLOR := Color("#4a9ebb")
const TEXT_COLOR := Color("#c8d0e0")
const SUCCESS_COLOR := Color("#6bc9a6")
const ERROR_COLOR := Color("#d45d5d")
const INPUT_COLOR := Color("#e8c547")
var _enabled: bool = true
var _log_lines: Array[String] = []
var _panel: PanelContainer = null
var _output_log: RichTextLabel = null
var _input_line: LineEdit = null
var _history: Array[String] = []
var _history_idx: int = -1
func _ready() -> void:
pauses_sim = true
_load_prefs()
visible = false
mouse_filter = Control.MOUSE_FILTER_IGNORE
set_anchors_preset(Control.PRESET_FULL_RECT)
_build_ui()
get_viewport().size_changed.connect(_update_panel_layout)
func _build_ui() -> void:
_panel = PanelContainer.new()
_panel.mouse_filter = Control.MOUSE_FILTER_STOP
_panel.anchor_left = 0.0
_panel.anchor_top = 0.6
_panel.anchor_right = 1.0
_panel.anchor_bottom = 1.0
_panel.offset_left = 0.0
_panel.offset_top = 0.0
_panel.offset_right = 0.0
_panel.offset_bottom = 0.0
var bg_style := StyleBoxFlat.new()
bg_style.bg_color = BG_COLOR
bg_style.border_color = BORDER_COLOR
bg_style.border_width_top = 1
bg_style.content_margin_left = 8.0
bg_style.content_margin_right = 8.0
bg_style.content_margin_top = 6.0
bg_style.content_margin_bottom = 6.0
_panel.add_theme_stylebox_override("panel", bg_style)
add_child(_panel)
var vbox := VBoxContainer.new()
vbox.add_theme_constant_override("separation", 4)
_panel.add_child(vbox)
_output_log = RichTextLabel.new()
_output_log.bbcode_enabled = true
_output_log.size_flags_vertical = Control.SIZE_EXPAND_FILL
_output_log.scroll_following = true
_output_log.selection_enabled = true
_output_log.add_theme_color_override("default_color", TEXT_COLOR)
_output_log.add_theme_font_size_override("normal_font_size", 13)
vbox.add_child(_output_log)
var sep := HSeparator.new()
vbox.add_child(sep)
_input_line = LineEdit.new()
_input_line.placeholder_text = "enter command (help for list)"
_input_line.clear_button_enabled = false
_input_line.add_theme_font_size_override("font_size", 13)
_input_line.add_theme_color_override("font_color", INPUT_COLOR)
_input_line.text_submitted.connect(_on_input_submitted)
_input_line.gui_input.connect(_on_input_key)
vbox.add_child(_input_line)
func _update_panel_layout() -> void:
# Anchors handle resize automatically; no manual size calc needed.
pass
# -- Input handling --
func _unhandled_input(event: InputEvent) -> void:
if not _enabled:
return
if not event is InputEventKey or not event.pressed or event.echo:
return
if event.keycode == KEY_QUOTELEFT:
get_viewport().set_input_as_handled()
_toggle()
return
if is_open():
# Consume keyboard events so movement/action don't leak to main.gd,
# but let ESC fall through to OPEN_MENU → MetaStack.handle_escape().
# MetaStack finds this console at the top of the stack and closes it.
if event.keycode != KEY_ESCAPE:
get_viewport().set_input_as_handled()
func _on_input_key(event: InputEvent) -> void:
if not event is InputEventKey or not event.pressed or event.echo:
return
if event.keycode == KEY_UP:
_history_up()
get_viewport().set_input_as_handled()
elif event.keycode == KEY_DOWN:
_history_down()
get_viewport().set_input_as_handled()
func _toggle() -> void:
if is_open():
close()
else:
MetaStack.push(self)
open()
func on_open() -> void:
_input_line.clear()
_input_line.grab_focus()
_history_idx = -1
pause_requested.emit() # D-088: pause sim while typing debug commands
func on_close() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
_input_line.release_focus()
unpause_requested.emit() # D-088: resume sim when console closes
# -- Command input --
func _on_input_submitted(text: String) -> void:
var trimmed := text.strip_edges()
_input_line.clear()
_history_idx = -1
if trimmed.is_empty():
return
if _history.is_empty() or _history[0] != trimmed:
_history.push_front(trimmed)
if _history.size() > 20:
_history.pop_back()
_append_text("> " + trimmed, TEXT_COLOR)
_dispatch(trimmed)
func _dispatch(line: String) -> void:
var parts := line.split(" ", false)
if parts.is_empty():
return
var cmd := parts[0].to_lower()
match cmd:
"help":
_print_help()
"ticks":
if parts.size() < 2 or not parts[1].is_valid_int():
_append_text("usage: ticks <n>", ERROR_COLOR)
return
var n := int(parts[1])
if n <= 0:
_append_text("ticks: n must be > 0", ERROR_COLOR)
return
_send_debug({"AdvanceTicks": n})
"contaminate":
_send_debug("SkipToContamination")
"tp":
if parts.size() < 2:
_append_text("usage: tp <x> <y> [z] or tp <location_name>", ERROR_COLOR)
return
if parts.size() >= 3 and parts[1].is_valid_int() and parts[2].is_valid_int():
var z := 0
if parts.size() >= 4:
if parts[3].is_valid_int():
z = int(parts[3])
else:
_append_text("tp: invalid z '%s' — defaulting to 0" % parts[3], ERROR_COLOR)
_send_debug(
{"TeleportToPosition": {"x": int(parts[1]), "y": int(parts[2]), "z": z}}
)
else:
var loc := " ".join(PackedStringArray(parts.slice(1)))
_send_debug({"TeleportToLocation": loc})
"activate":
_send_debug("ForceContaminationActivate")
"triangle":
if parts.size() < 2:
_append_text("usage: triangle <id>", ERROR_COLOR)
return
_send_debug({"ForceTriangleActivation": parts[1]})
"npc":
if parts.size() < 2 or not parts[1].is_valid_int():
_append_text("usage: npc <entity_id>", ERROR_COLOR)
return
_send_debug({"InspectNpc": int(parts[1])})
"triangles":
_send_debug("ListTriangles")
"pop":
_send_debug("ListPopulation")
"status":
_send_debug("GetContaminationStatus")
"econ":
_dispatch_econ(parts)
_:
_append_text("unknown command: '%s' (type 'help')" % cmd, ERROR_COLOR)
# #825: Economics debug console commands (D-178, D-180, D-181).
# Three subcommands: inject (fire EconEvent), param (tweak α/β/friction), inspect (read signals).
# TODO: Server-side DebugCommandKind variants (InjectEconEvent, SetEconParam, GetEconState)
# ship with #823. Until then, _send_debug will transmit the payload but the server will
# respond with an "unknown command" error. Wire format is ready; server handler is not.
func _dispatch_econ(parts: Array) -> void:
if parts.size() < 2:
_append_text(
(
"usage:\n"
+ " econ inject <system_id> [commodity_id] <shock|boost> <magnitude> [ticks]\n"
+ " econ param <alpha|beta|friction> <value> [system_a] [system_b]\n"
+ " econ inspect <system_id>"
),
ERROR_COLOR,
)
return
var sub: String = parts[1].to_lower()
match sub:
"inject":
_econ_inject(parts)
"param":
_econ_param(parts)
"inspect":
_econ_inspect(parts)
_:
_append_text("econ: unknown subcommand '%s'" % sub, ERROR_COLOR)
# econ inject <system_id> [commodity_id] <shock|boost> <magnitude> [ticks]
# Minimal form: econ inject Sol shock 0.5
# Full form: econ inject Sol fusion_fuel boost 1.2 100
func _econ_inject(parts: Array) -> void:
# parts[0]="econ", parts[1]="inject", rest is args
var args := parts.slice(2)
if args.size() < 3:
_append_text(
"usage: econ inject <system_id> [commodity_id] <shock|boost> <magnitude> [ticks]",
ERROR_COLOR,
)
return
# Parse: detect whether commodity_id is present by checking if args[1] is an effect keyword
var system_id: String = args[0]
var commodity_id: String = ""
var effect: String = ""
var magnitude_str: String = ""
var duration_ticks: int = 0
var ticks_str := ""
if args[1].to_lower() in ["shock", "boost"]:
# No commodity_id: econ inject <system> <effect> <magnitude> [ticks]
effect = args[1].to_lower()
magnitude_str = args[2]
if args.size() >= 4:
ticks_str = args[3]
elif args.size() == 3:
# 3 args but args[1] isn't shock/boost — bad effect keyword, not a commodity
_append_text(
"econ inject: effect must be 'shock' or 'boost', got '%s'" % args[1], ERROR_COLOR
)
return
else:
# With commodity_id: econ inject <system> <commodity> <effect> <magnitude> [ticks]
commodity_id = args[1]
if args.size() < 4:
_append_text(
"usage: econ inject <system_id> <commodity_id> <shock|boost> <magnitude> [ticks]",
ERROR_COLOR,
)
return
effect = args[2].to_lower()
if not effect in ["shock", "boost"]:
_append_text(
"econ inject: effect must be 'shock' or 'boost', got '%s'" % effect, ERROR_COLOR
)
return
magnitude_str = args[3]
if args.size() >= 5:
ticks_str = args[4]
if not ticks_str.is_empty():
if not ticks_str.is_valid_int():
_append_text("econ inject: invalid ticks '%s'" % ticks_str, ERROR_COLOR)
return
duration_ticks = int(ticks_str)
if not magnitude_str.is_valid_float():
_append_text("econ inject: invalid magnitude '%s'" % magnitude_str, ERROR_COLOR)
return
var magnitude: float = float(magnitude_str)
var payload := {
"system_id": system_id,
"effect": effect,
"magnitude": magnitude,
}
if not commodity_id.is_empty():
payload["commodity_id"] = commodity_id
if duration_ticks > 0:
payload["duration_ticks"] = duration_ticks
_append_text(
(
"injecting %s on %s%s (mag=%.2f, ticks=%d)"
% [
effect,
system_id,
" / " + commodity_id if not commodity_id.is_empty() else "",
magnitude,
duration_ticks
]
),
TEXT_COLOR,
)
_send_debug({"InjectEconEvent": payload})
# econ param <alpha|beta|friction> <value> [system_a] [system_b]
func _econ_param(parts: Array) -> void:
var args := parts.slice(2)
if args.size() < 2:
_append_text(
"usage: econ param <alpha|beta|friction> <value> [system_a] [system_b]",
ERROR_COLOR,
)
return
var param_name: String = args[0].to_lower()
if not param_name in ["alpha", "beta", "friction"]:
_append_text(
"econ param: must be alpha, beta, or friction — got '%s'" % param_name, ERROR_COLOR
)
return
if not args[1].is_valid_float():
_append_text("econ param: invalid value '%s'" % args[1], ERROR_COLOR)
return
var value: float = float(args[1])
var payload := {"param": param_name, "value": value}
if args.size() >= 3:
payload["system_a"] = args[2]
if args.size() >= 4:
payload["system_b"] = args[3]
var scope := "global"
if payload.has("system_a") and payload.has("system_b"):
scope = "%s%s" % [payload["system_a"], payload["system_b"]]
elif payload.has("system_a"):
scope = payload["system_a"]
_append_text("setting %s = %.4f (%s)" % [param_name, value, scope], TEXT_COLOR)
_send_debug({"SetEconParam": payload})
# econ inspect <system_id> — request all 7 D-181 signals for a system
func _econ_inspect(parts: Array) -> void:
if parts.size() < 3:
_append_text("usage: econ inspect <system_id>", ERROR_COLOR)
return
var system_id: String = parts[2]
_append_text("inspecting economy: %s" % system_id, TEXT_COLOR)
_send_debug({"GetEconState": system_id})
func _send_debug(kind: Variant) -> void:
var err := (
SimBridge
. send_input(
{
"action": InputMapper.Action.DEBUG_COMMAND,
"action_data": kind,
"timestamp_msec": Time.get_ticks_msec(),
}
)
)
if err != OK:
_append_text("send error: %s" % error_string(err), ERROR_COLOR)
# -- Response display --
## Append a server debug response to the output log. Auto-opens console if closed
## (only if console is enabled — respect user's settings toggle).
func append_response(response: Dictionary) -> void:
var success: bool = response.get("success", false)
var text: String = response.get("text", "")
var color := SUCCESS_COLOR if success else ERROR_COLOR
_append_text(text, color)
if not is_open() and _enabled:
MetaStack.push(self)
open()
# -- Log rendering --
func _append_text(text: String, color: Color) -> void:
var escaped := text.replace("[", "[lb]").replace("]", "[rb]")
_log_lines.append("[color=%s]%s[/color]" % [color.to_html(false), escaped])
if _log_lines.size() > MAX_LOG_LINES:
_log_lines = _log_lines.slice(_log_lines.size() - MAX_LOG_LINES)
if _output_log:
_output_log.text = "\n".join(_log_lines)
func _print_help() -> void:
_append_text(
(
"Commands:\n"
+ " ticks <n> — fast-forward N ticks\n"
+ " contaminate — skip to contamination phase\n"
+ " tp <x> <y> [z] — teleport to tile position\n"
+ " tp <location> — teleport to named location\n"
+ " activate — force contamination activate\n"
+ " triangle <id> — force triangle activation\n"
+ " npc <entity_id> — inspect NPC state\n"
+ " triangles — list all triangles\n"
+ " pop — list active NPCs\n"
+ " status — contamination status\n"
+ "\n"
+ "Economics (D-178/D-180/D-181):\n"
+ " econ inject <sys> [commodity] <shock|boost> <mag> [ticks]\n"
+ " — fire an EconEvent at a system\n"
+ " econ param <alpha|beta|friction> <val> [sys_a] [sys_b]\n"
+ " — set tâtonnement parameter\n"
+ " econ inspect <sys> — show all 7 signals for a system\n"
+ "\n"
+ " help — this list"
),
TEXT_COLOR
)
# -- Command history --
func _history_up() -> void:
if _history.is_empty():
return
_history_idx = mini(_history_idx + 1, _history.size() - 1)
_input_line.text = _history[_history_idx]
_input_line.caret_column = _input_line.text.length()
func _history_down() -> void:
if _history_idx <= 0:
_history_idx = -1
_input_line.clear()
return
_history_idx -= 1
_input_line.text = _history[_history_idx]
_input_line.caret_column = _input_line.text.length()
# -- Settings --
func set_enabled(enabled: bool) -> void:
_enabled = enabled
if not _enabled and is_open():
close()
_save_prefs()
func is_enabled() -> bool:
return _enabled
func _load_prefs() -> void:
var cfg := ConfigFile.new()
if cfg.load(PREFS_PATH) != OK:
return
_enabled = cfg.get_value(PREFS_SECTION, PREFS_KEY_ENABLED, true)
func _save_prefs() -> void:
var cfg := ConfigFile.new()
cfg.load(PREFS_PATH) # load existing (may have other sections like "audio")
cfg.set_value(PREFS_SECTION, PREFS_KEY_ENABLED, _enabled)
var err := cfg.save(PREFS_PATH)
if err != OK:
push_warning("DebugConsole: failed to save prefs (%d)" % err)