class_name DebugConsole extends Control ## 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 _open: bool = false 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: _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 _open: # Consume all keyboard events — prevent movement/action leaking through get_viewport().set_input_as_handled() if event.keycode == KEY_ESCAPE: _close() 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 _open: _close() else: _open_console() func _open_console() -> void: _open = true visible = true mouse_filter = Control.MOUSE_FILTER_STOP _input_line.clear() _input_line.grab_focus() _history_idx = -1 pause_requested.emit() # D-088: pause sim while typing debug commands func _close() -> void: _open = false visible = false mouse_filter = Control.MOUSE_FILTER_IGNORE _input_line.release_focus() unpause_requested.emit() # D-088: resume sim when console closes func is_open() -> bool: return _open # -- 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 ", 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 [z] or tp ", 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 ", ERROR_COLOR) return _send_debug({"ForceTriangleActivation": parts[1]}) "npc": if parts.size() < 2 or not parts[1].is_valid_int(): _append_text("usage: npc ", 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 [commodity_id] [ticks]\n" + " econ param [system_a] [system_b]\n" + " econ inspect " ), 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 [commodity_id] [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 [commodity_id] [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 [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 [ticks] commodity_id = args[1] if args.size() < 4: _append_text( "usage: econ inject [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 [system_a] [system_b] func _econ_param(parts: Array) -> void: var args := parts.slice(2) if args.size() < 2: _append_text( "usage: econ param [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 — request all 7 D-181 signals for a system func _econ_inspect(parts: Array) -> void: if parts.size() < 3: _append_text("usage: econ inspect ", 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 _open and _enabled: _open_console() # -- 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 — fast-forward N ticks\n" + " contaminate — skip to contamination phase\n" + " tp [z] — teleport to tile position\n" + " tp — teleport to named location\n" + " activate — force contamination activate\n" + " triangle — force triangle activation\n" + " npc — 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 [commodity] [ticks]\n" + " — fire an EconEvent at a system\n" + " econ param [sys_a] [sys_b]\n" + " — set tâtonnement parameter\n" + " econ inspect — 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 _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)