Files
settled-reach/client/ui/debug_console.gd
T
jpmschweitzerandClaude Opus 4.6 e91bd1c7e4 fix(client): post-sprint polish — protocol v21, schedule workaround, UI fixes
- Bump client protocol version 20 → 21 to match server (#822)
- Fix render_priority parameter name (was _render_priority, unused prefix)
- Fix debug console type inference (var sub := → var sub: String =)
- Economics panel: add population row, improve key hint text
- Stance indicator: hide on gameplay_occluded (D-170 fullscreen apps)
- Remove 5 broken clothing items from manifest and delete their GLBs
  (boots_work, coveralls_basic, jacket_utility, pants_cargo, shirt_henley)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 23:00:40 +02:00

518 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 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 <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 _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 <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 _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)