refactor(ui): migrate 6 meta screens to MetaScreen pattern (Workstream 2)

Relocates main_menu, character_creation, settings_dialog, debug_console,
bug_report_dialog, loading_screen from flat client/ui/ into structured
client/ui/meta/screens/<name>/. All six now extend MetaScreen instead
of Control; the base handles open/close lifecycle, visibility,
captures_input, and — for overlays — the sim-pause contract.

Screen policies set per Tyre's proposal:
- settings_dialog: pauses_sim=false, PUSHES onto MetaStack
- debug_console: pauses_sim=true, PUSHES (D-088 routing via base)
- bug_report_dialog: pauses_sim=true, PUSHES
- loading_screen: closable_by_escape=false, PUSHES
- main_menu, character_creation: scene-roots, extend MetaScreen for
  the lifecycle contract only, do NOT push onto the stack

character_creation stays at its current surface (tabs, descriptor,
creation_confirmed signal unchanged). Tab consolidation and
CharacterProfile migration happen in Workstreams 5 and 6.

Knock-on changes:
- main.tscn ModalLayer CanvasLayer renamed to MetaLayer; main.gd
  @onready refs updated; constants.gd comment updated; test_client_p3
  and test_ui_framework_sprint15 assertions updated; test_monologue_display
  and .tscn header comments updated.
- OPEN_MENU handler now pushes settings_dialog onto MetaStack before
  calling open(). Full ESC priority chain lands in Workstream 4.
- atlas_app.gd: _unhandled_key_input signature widened from
  InputEventKey to InputEvent with an is-check, per Godot 4 API. Pre-
  existing narrowing was silently tolerated until main.tscn started
  fully instantiating under the new pattern.
- test_client_p3: entity_renderer type annotations corrected from
  ColorRect to Sprite2D (stale since a prior refactor); facing
  indicator rotation assertion switched to angle_difference() for
  modular-safe comparison.

Verification:
- gdlint client/scripts/ client/ui/ — zero problems
- godot --headless --path client --quit — no SCRIPT ERROR
- test_client_p3: 24/24 pass
- test_ui_framework_sprint15: 54/54 pass
- test_implant_nav_stack: 52/52 pass
- test_implant_registry: 42/42 pass
- test_implant_app_lifecycle: 36/36 pass

Workstream 1 foundation (84105916) remains unchanged. Workstreams 3-8
follow: protocol layer, Option A sequencing, 3-tab restructure,
Bookmark tab, location picker, Skills stub.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-19 21:58:24 +02:00
co-authored by Claude Opus 4.6
parent 84105916cd
commit f24d08f756
19 changed files with 86 additions and 130 deletions
@@ -0,0 +1,464 @@
extends MetaScreen
## #507: WRONG button — full 60-tick capture: ring buffer, snapshot history, replay seed.
## Upgrade of the Sprint 9 MVP (#495).
##
## On F12: pause sim, show one-line prompt, save all ring buffer data, unpause.
## Output: user://bug-reports/gauntlet-t{tick}-{timestamp}/
## - snapshot.json — single-tick point-in-time (MVP compat)
## - render.txt — client-side text render
## - description.txt — tester notes + room/tick/seed metadata
## - inputs.jsonl — last 60 ticks of PlayerInput (replay-compatible JSONL)
## - snapshots.jsonl — last 60 ticks of ObserverSnapshot (one JSON per line)
## - seed.txt — RNG seed for deterministic replay
## - screenshot.png — viewport capture taken before dialog opened
##
## Ring buffer: pre-allocated RING_SIZE arrays at startup. record_tick() is the
## public API for main.gd. _push_tick_inputs() / _push_tick_snapshot() are the
## internal implementations, exposed for unit testing (test_bug_report_ring_buffer.gd).
##
## Spec: inputs.jsonl is compatible with tooling/test-client --replay (replay.rs).
## Format: one JSON array per line, each array = Vec<PlayerInput> for that tick.
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
# #507: Ring buffer capacity — 60 ticks of history
const RING_SIZE := 60
var _line_edit: LineEdit = null
var _captured_screenshot: Image = null
# #507: Pre-allocated ring buffers (no per-tick allocation after _ready).
# Input ring: replay-format PlayerInput arrays, one per tick.
# Snapshot ring: ObserverSnapshot JSON strings, one per tick.
# Separate heads and counts so each buffer can be tested independently.
# Memory ceiling: 60 snapshot JSON strings (each ~2-8KB depending on entity count)
# + 60 input arrays (negligible). Worst case ~480KB resident. Acceptable for a
# debug tool that is always active during Gauntlet play.
var _input_ring: Array = [] # Array[Array] — each slot: Array of {tick, action} dicts
var _input_head: int = 0 # Next write index (0..RING_SIZE-1)
var _input_count: int = 0 # Filled slot count (0..RING_SIZE)
var _snapshot_ring: Array = [] # Array[String] — each slot: JSON-serialized ObserverSnapshot
var _snapshot_head: int = 0
var _snapshot_count: int = 0
func _ready() -> void:
pauses_sim = true
visible = false
mouse_filter = Control.MOUSE_FILTER_IGNORE
# Pre-allocate ring buffers — resize then fill sentinels.
# The ring array itself never grows after _ready. Each write replaces the GDScript
# reference in an existing slot (not a new allocation of the ring), though the input
# Array stored per slot is a fresh ref each tick.
_input_ring.resize(RING_SIZE)
_snapshot_ring.resize(RING_SIZE)
for i in range(RING_SIZE):
_input_ring[i] = []
_snapshot_ring[i] = ""
# ---------------------------------------------------------------------------
# Public API for main.gd: record one tick's data
# ---------------------------------------------------------------------------
## Record one tick. Called from main.gd on every server tick (snapshot arrival).
## - tick: current server tick number
## - snapshot_json: JSON.stringify(GameState.current_snapshot)
## - mapper_inputs: Array of InputMapper dicts (BUG_REPORT/OPEN_MENU excluded).
## These are in raw InputMapper format and will be converted to replay format.
func record_tick(tick: int, snapshot_json: String, mapper_inputs: Array) -> void:
# Convert mapper inputs to replay-compatible format, then push both buffers.
var replay_inputs: Array = []
for inp in mapper_inputs:
var ri := _to_replay_format(inp, tick)
if not ri.is_empty():
replay_inputs.append(ri)
_push_tick_inputs(tick, replay_inputs)
_push_tick_snapshot(snapshot_json)
# ---------------------------------------------------------------------------
# Internal ring buffer operations (also exposed for tests)
# ---------------------------------------------------------------------------
## Push replay-format inputs for one tick. inputs is Array of {tick, action} dicts.
## Overwrites oldest entry when buffer is full (circular eviction).
@warning_ignore("unused_parameter")
func _push_tick_inputs(_tick: int, inputs: Array) -> void:
_input_ring[_input_head] = inputs
_input_head = (_input_head + 1) % RING_SIZE
if _input_count < RING_SIZE:
_input_count += 1
## Push a JSON-serialized ObserverSnapshot string for one tick.
func _push_tick_snapshot(snapshot_json: String) -> void:
_snapshot_ring[_snapshot_head] = snapshot_json
_snapshot_head = (_snapshot_head + 1) % RING_SIZE
if _snapshot_count < RING_SIZE:
_snapshot_count += 1
## Format the input ring buffer as JSONL for writing to inputs.jsonl.
## Returns a String with one JSON array per line, oldest to newest.
## Each line: Array of {tick, action} replay-format PlayerInput objects.
func _format_inputs_jsonl() -> String:
var lines: PackedStringArray = []
var start := (_input_head - _input_count + RING_SIZE) % RING_SIZE
for i in range(_input_count):
var idx := (start + i) % RING_SIZE
lines.append(JSON.stringify(_input_ring[idx]))
return "\n".join(lines)
## Format the snapshot ring buffer as JSONL for writing to snapshots.jsonl.
## Returns a String with one JSON string per line, oldest to newest.
func _format_snapshots_jsonl() -> String:
var lines: PackedStringArray = []
var start := (_snapshot_head - _snapshot_count + RING_SIZE) % RING_SIZE
for i in range(_snapshot_count):
var idx := (start + i) % RING_SIZE
lines.append(_snapshot_ring[idx])
return "\n".join(lines)
## Return the RNG seed for seed.txt. Never returns null.
## Uses GameState.rng_seed if available; falls back to "unavailable" string.
## Note: rng_seed is u64 on the server. JSON encodes u64 as a number, which
## loses precision above 2^53 via float intermediary. When the server field
## lands, consider string-encoding the seed to preserve all 64 bits.
func _get_current_seed() -> Variant:
if GameState.rng_seed != null:
return GameState.rng_seed
return "unavailable"
## Convert one InputMapper dict to replay-compatible PlayerInput dict.
## Returns empty dict for client-only actions (BUG_REPORT, OPEN_MENU).
## Replay format: {"tick": N, "action": "MoveNorth"} or
## {"tick": N, "action": {"Interact": {"target_entity_id": ..., "verb": ...}}}
func _to_replay_format(input: Dictionary, tick: int) -> Dictionary:
var action_enum: int = input.get("action", -1)
var wire: String = SimBridge.action_enum_to_wire(action_enum)
if wire.is_empty():
return {} # Client-only action (BUG_REPORT, OPEN_MENU)
var result := {"tick": tick}
var action_data: Variant = input.get("action_data")
match wire:
"Interact":
# Rust PlayerAction::Interact { target_entity_id, verb }
result["action"] = {"Interact": action_data if action_data is Dictionary else {}}
"SetFacing":
# Rust PlayerAction::SetFacing(String) — wrap direction string
var facing := ""
if action_data is Dictionary:
facing = str(action_data.get("facing", ""))
result["action"] = {"SetFacing": facing}
_:
# Simple enum variants: "MoveNorth", "Pause", "TeleportToHub", etc.
result["action"] = wire
return result
# ---------------------------------------------------------------------------
# Test-accessible accessors (ring buffer introspection)
# ---------------------------------------------------------------------------
func _get_buffer_capacity() -> int:
return RING_SIZE
func _get_snapshot_buffer_capacity() -> int:
return RING_SIZE
func _get_input_buffer() -> Array:
return _input_ring
func _get_filled_input_count() -> int:
return _input_count
func _get_filled_snapshot_count() -> int:
return _snapshot_count
# ---------------------------------------------------------------------------
# UI / capture flow
# ---------------------------------------------------------------------------
func start_capture() -> void:
if is_open():
return
# Capture screenshot BEFORE showing the dialog overlay
_captured_screenshot = get_viewport().get_texture().get_image()
MetaStack.push(self)
open()
func on_open() -> void:
_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_close() -> void:
if _line_edit:
_line_edit.release_focus()
_line_edit.queue_free()
_line_edit = null
_captured_screenshot = null
func on_escape() -> bool:
capture_cancelled.emit()
return false # let MetaStack close
func _on_text_submitted(text: String) -> void:
_save_report(text)
close()
capture_completed.emit()
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
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 — single-tick point-in-time (MVP compat, #495)
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()
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"
var render_file := FileAccess.open(render_path, FileAccess.WRITE)
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
# Room: uses GameState.room_id (v0.1: room name is the map identifier)
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.store_string("RingBufferTicks: %d\n" % _input_count)
desc_file.close()
files_saved += 1
else:
push_error("BugReport: failed to write %s" % desc_path)
# 4. inputs.jsonl — last N ticks of PlayerInput (replay-compatible)
# One JSON array per line. Empty array = idle tick.
# Compatible with tooling/test-client --replay (replay.rs).
var inputs_path := base_path + "/inputs.jsonl"
var inputs_file := FileAccess.open(inputs_path, FileAccess.WRITE)
if inputs_file:
inputs_file.store_string(_format_inputs_jsonl())
inputs_file.close()
files_saved += 1
else:
push_error("BugReport: failed to write %s" % inputs_path)
# 5. snapshots.jsonl — last N ticks of ObserverSnapshot, oldest to newest.
var snaps_path := base_path + "/snapshots.jsonl"
var snaps_file := FileAccess.open(snaps_path, FileAccess.WRITE)
if snaps_file:
snaps_file.store_string(_format_snapshots_jsonl())
snaps_file.close()
files_saved += 1
else:
push_error("BugReport: failed to write %s" % snaps_path)
# 6. seed.txt — RNG seed for deterministic replay.
# Server must include "rng_seed" (u64) in ObserverSnapshot for this to be populated.
# If absent: includes a note on the required protocol change.
var seed_path := base_path + "/seed.txt"
var seed_file := FileAccess.open(seed_path, FileAccess.WRITE)
if seed_file:
var seed_val: Variant = _get_current_seed()
seed_file.store_string(str(seed_val) + "\n")
if seed_val == "unavailable":
(
seed_file
. store_string(
"# Server protocol change required: add 'rng_seed' (u64) field to ObserverSnapshot.\n"
)
)
seed_file.close()
files_saved += 1
else:
push_error("BugReport: failed to write %s" % seed_path)
# 7. screenshot.png — viewport capture taken before dialog opened
if _captured_screenshot:
var screenshot_path := base_path + "/screenshot.png"
var img_err := _captured_screenshot.save_png(screenshot_path)
if img_err == OK:
files_saved += 1
else:
push_error("BugReport: failed to write %s (error %d)" % [screenshot_path, img_err])
print(
(
"BugReport: saved %d/7 files to %s (ring: %d ticks)"
% [files_saved, base_path, _input_count]
)
)
## 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 is_open():
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 is_open()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,510 @@
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 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 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)
@@ -0,0 +1,86 @@
extends MetaScreen
## #257: Loading screen overlay — blocks input during save/load round-trip.
## Shown when LOAD_GAME fires; hidden when save_result arrives (success or failure).
## Full-screen, dark overlay with centered status text.
## #724: Shows client version (from project.yaml) and protocol version below the status text.
const BG_COLOR := Color(0.0, 0.0, 0.0, 0.75)
const TEXT_COLOR := Color("#c8d0e0")
const VERSION_COLOR := Color("#667788")
const FONT_SIZE := 18
const VERSION_FONT_SIZE := 11
var _label: Label = null
var _version_label: Label = null
func _ready() -> void:
closable_by_escape = false
visible = false
mouse_filter = Control.MOUSE_FILTER_IGNORE
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_build_ui()
func _build_ui() -> void:
var bg := ColorRect.new()
bg.color = BG_COLOR
bg.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
bg.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(bg)
_label = Label.new()
_label.text = UIStrings.get_text("notifications.loading")
_label.add_theme_font_size_override("font_size", FONT_SIZE)
_label.add_theme_color_override("font_color", TEXT_COLOR)
_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_label.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_label)
# client_ver and proto_ver are independent — project.yaml version is the client release,
# Protocol.PROTOCOL_VERSION is the wire protocol. Mismatches between builds are visible
# only to the observer reading the loading-screen label; a future ticket will surface them.
var client_ver := _read_client_version()
var proto_ver: int = Protocol.PROTOCOL_VERSION
_version_label = Label.new()
_version_label.text = "v%s · protocol %d" % [client_ver, proto_ver]
_version_label.add_theme_font_size_override("font_size", VERSION_FONT_SIZE)
_version_label.add_theme_color_override("font_color", VERSION_COLOR)
_version_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_version_label.anchor_left = 0.0
_version_label.anchor_right = 1.0
_version_label.anchor_top = 1.0
_version_label.anchor_bottom = 1.0
_version_label.offset_top = -32.0
_version_label.offset_bottom = -8.0
_version_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_version_label)
func _read_client_version() -> String:
var yaml_path := ProjectSettings.globalize_path("res://") + "/../project.yaml"
if not FileAccess.file_exists(yaml_path):
return "?.?.?"
var file := FileAccess.open(yaml_path, FileAccess.READ)
if file == null:
return "?.?.?"
var content := file.get_as_text()
file.close()
for line: String in content.split("\n"):
if line.begins_with("version:"):
var parts := line.split(":", false, 1)
if parts.size() >= 2:
return parts[1].strip_edges()
return "?.?.?"
func show_loading() -> void:
MetaStack.push(self)
open()
## Hide the loading overlay. success=false is reserved for future failure-state UI.
func hide_loading(_success: bool = true) -> void:
close()
@@ -0,0 +1,170 @@
extends MetaScreen
## #258: Main menu — New Game / Continue / Load Game / Quit.
## New Game: opens character creation screen, then starts game.
## Continue: loads most recent save directory.
## Load Game: shows sorted save list for manual selection (#257).
const GAME_SCENE := "res://scenes/main.tscn"
const CHARACTER_CREATION_SCENE := "res://scenes/character_creation.tscn"
const BG_COLOR := Color(0.05, 0.05, 0.08, 1.0)
const TITLE_COLOR := Color("#c8d0e0")
const SUBTITLE_COLOR := Color("#8890a0")
const BTN_NORMAL_COLOR := Color("#e8c547")
const BTN_DISABLED_COLOR := Color("#4a5060")
const FONT_SIZE_TITLE := 36
const FONT_SIZE_SUBTITLE := 14
const FONT_SIZE_BTN := 15
var _char_creation: Control = null
var _list_built: bool = false
@onready var _new_game_btn: Button = $VBox/NewGameBtn
@onready var _continue_btn: Button = $VBox/ContinueBtn
@onready var _load_game_btn: Button = $VBox/LoadGameBtn
@onready var _quit_btn: Button = $VBox/QuitBtn
@onready var _load_panel: Control = $LoadGamePanel
@onready var _saves_list: VBoxContainer = $LoadGamePanel/VBox/SavesScroll/SavesList
@onready var _load_back_btn: Button = $LoadGamePanel/VBox/BackBtn
func _ready() -> void:
_new_game_btn.pressed.connect(_on_new_game)
_continue_btn.pressed.connect(_on_continue)
_load_game_btn.pressed.connect(_on_load_game_browse)
_quit_btn.pressed.connect(_on_quit)
_load_back_btn.pressed.connect(_on_load_back)
_load_panel.visible = false
_refresh_continue_state()
func _refresh_continue_state() -> void:
var saves := SessionManager.list_game_dirs()
_continue_btn.disabled = saves.is_empty()
_load_game_btn.disabled = saves.is_empty()
func _on_new_game() -> void:
GameState.pending_load_path = ""
_show_character_creation()
func _show_character_creation() -> void:
if _char_creation != null and is_instance_valid(_char_creation):
_char_creation.queue_free()
var scene := load(CHARACTER_CREATION_SCENE) as PackedScene
if scene == null:
push_error("MainMenu: failed to load character_creation.tscn — skipping to game")
_start_game_with_defaults()
return
_char_creation = scene.instantiate()
add_child(_char_creation)
_char_creation.creation_confirmed.connect(_on_creation_confirmed)
_char_creation.creation_cancelled.connect(_on_creation_cancelled)
func _on_creation_confirmed(descriptor) -> void:
if _char_creation != null and is_instance_valid(_char_creation):
_char_creation.queue_free()
_char_creation = null
GameState.character_visual_descriptor = descriptor
_start_game_with_defaults()
func _on_creation_cancelled() -> void:
if _char_creation != null and is_instance_valid(_char_creation):
_char_creation.queue_free()
_char_creation = null
func _start_game_with_defaults() -> void:
var game_id := SessionManager.new_game()
if game_id.is_empty():
push_error("MainMenu: new_game() failed to create save directory — cannot start")
return
get_tree().change_scene_to_file(GAME_SCENE)
func _on_continue() -> void:
GameState.pending_load_path = ""
var saves := SessionManager.list_game_dirs()
if saves.is_empty():
return
SessionManager.resume_game(saves[0].game_id)
get_tree().change_scene_to_file(GAME_SCENE)
func _on_load_game_browse() -> void:
if not _list_built:
_build_saves_list()
_list_built = true
_load_panel.visible = true
func _on_load_back() -> void:
_load_panel.visible = false
_list_built = false
func _on_quit() -> void:
get_tree().quit()
## Build the saves list panel from existing save directories, sorted by date (newest first).
func _build_saves_list() -> void:
for child in _saves_list.get_children():
child.queue_free()
var saves := SessionManager.list_game_dirs()
if saves.is_empty():
var lbl := Label.new()
lbl.text = UIStrings.get_text("menu.load_game_empty")
lbl.add_theme_color_override("font_color", Color(BTN_DISABLED_COLOR))
lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_saves_list.add_child(lbl)
return
for save in saves:
var btn := Button.new()
btn.text = _format_save_entry(save)
btn.add_theme_font_size_override("font_size", FONT_SIZE_BTN)
var has_save_file: bool = not save.get("newest_save", "").is_empty()
if has_save_file:
btn.add_theme_color_override("font_color", BTN_NORMAL_COLOR)
btn.pressed.connect(_on_save_selected.bind(save))
else:
btn.add_theme_color_override("font_color", BTN_DISABLED_COLOR)
btn.disabled = true
_saves_list.add_child(btn)
func _on_save_selected(save: Dictionary) -> void:
var game_id: String = save.get("game_id", "")
var save_file: String = save.get("newest_save", "")
if save_file.is_empty():
push_error("MainMenu: save entry '%s' has no newest_save — load cancelled" % game_id)
return
SessionManager.resume_game(game_id)
GameState.pending_load_path = "user://saves/" + game_id + "/" + save_file
get_tree().change_scene_to_file(GAME_SCENE)
## Format a save entry for display. game_id format: YYYYMMDD-HHMMSS-hex6.
static func _format_save_entry(save: Dictionary) -> String:
var game_id: String = save.get("game_id", "")
var parts := game_id.split("-")
if parts.size() >= 2 and parts[0].length() == 8 and parts[1].length() == 6:
var d := parts[0]
var t := parts[1]
return (
"%s-%s-%s %s:%s:%s"
% [
d.substr(0, 4),
d.substr(4, 2),
d.substr(6, 2),
t.substr(0, 2),
t.substr(2, 2),
t.substr(4, 2),
]
)
return game_id
@@ -0,0 +1,375 @@
extends MetaScreen
## #528: Audio settings dialog — 5-bus volume sliders.
## #646: AI-Enhanced Dialogue toggle + hardware detection status (D-138).
## Opens on OPEN_MENU (ESC) from main.gd. Closes on OPEN_MENU again or CLOSE button.
## Volumes persist via AudioManager._save_prefs() on each slider change.
## AI Dialogue toggle persists via ConfigFile (client-local) + ChangeSettings IPC (server SQLite).
signal debug_console_toggled(enabled: bool) # #581: debug console enabled/disabled
signal ai_dialogue_toggled(enabled: bool) # #646: AI-Enhanced Dialogue enabled/disabled
const BG_COLOR := Color(0.05, 0.05, 0.08, 0.90)
const BORDER_COLOR := Color("#4a9ebb")
const TEXT_COLOR := Color(0.878, 0.969, 0.98, 1)
const TITLE_COLOR := Color("#4a9ebb")
const STATUS_GREEN := Color("#6bc9a6")
const STATUS_YELLOW := Color("#e8c547")
const STATUS_RED := Color("#c84040")
const FONT_SIZE := 14
const FONT_SIZE_SMALL := 11
## Shared client preferences file — avoids compile-time dependency on DebugConsole for path.
const SETTINGS_CFG_PATH := "user://settings.cfg"
const BOX_WIDTH := 460
const BOX_HEIGHT := 500 # +36 debug console, +88 AI dialogue section
const PADDING := 20
const ROW_HEIGHT := 36
# Bus display labels → bus name strings (must match AudioManager BUS_* constants)
const BUS_ROWS: Array = [
["Music", "Music"],
["Ambient", "Ambient"],
["World SFX", "WorldSFX"],
["Player Actions", "PlayerActions"],
["UI Sounds", "UISounds"],
]
var _container: VBoxContainer = null
# #646: AI Dialogue hardware status and toggle node ref — used by testable API methods
var _ai_hw_status: String = "" # "pass" | "marginal" | "fail" | "" (not yet checked)
var _ai_check_node: CheckButton = null
var _ai_inference_suspended: bool = false # D-138 §8 Layer 3 battery auto-suspend state
var _ai_battery_warning_label: Label = null # shown when on battery; toggle stays enabled
func _ready() -> void:
visible = false
mouse_filter = Control.MOUSE_FILTER_IGNORE
func on_open() -> void:
_build_ui()
queue_redraw()
func on_close() -> void:
_destroy_ui()
queue_redraw()
func _build_ui() -> void:
var vp_size := get_viewport_rect().size
var box_pos := Vector2((vp_size.x - BOX_WIDTH) / 2.0, (vp_size.y - BOX_HEIGHT) / 2.0)
_container = VBoxContainer.new()
_container.position = box_pos + Vector2(PADDING, PADDING + 28)
_container.custom_minimum_size = Vector2(BOX_WIDTH - PADDING * 2, 0)
_container.add_theme_constant_override("separation", 4)
add_child(_container)
for row_data in BUS_ROWS:
var label_text: String = row_data[0]
var bus_name: String = row_data[1]
var hbox := HBoxContainer.new()
hbox.custom_minimum_size = Vector2(0, ROW_HEIGHT)
_container.add_child(hbox)
var label := Label.new()
label.text = label_text
label.custom_minimum_size = Vector2(150, 0)
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
label.add_theme_font_size_override("font_size", FONT_SIZE)
label.add_theme_color_override("font_color", TEXT_COLOR)
hbox.add_child(label)
var slider := HSlider.new()
slider.min_value = -40.0
slider.max_value = 0.0
slider.step = 0.5
slider.value = AudioManager.get_volume(bus_name)
slider.size_flags_horizontal = Control.SIZE_EXPAND_FILL
hbox.add_child(slider)
var db_label := Label.new()
db_label.text = _format_db(slider.value)
db_label.custom_minimum_size = Vector2(70, 0)
db_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
db_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
db_label.add_theme_font_size_override("font_size", FONT_SIZE)
db_label.add_theme_color_override("font_color", TEXT_COLOR)
hbox.add_child(db_label)
slider.value_changed.connect(
func(value: float) -> void:
AudioManager.set_volume(bus_name, value)
db_label.text = _format_db(value)
)
# #581: Debug Console toggle
var debug_hbox := HBoxContainer.new()
debug_hbox.custom_minimum_size = Vector2(0, ROW_HEIGHT)
_container.add_child(debug_hbox)
var debug_label := Label.new()
debug_label.text = "Debug Console"
debug_label.custom_minimum_size = Vector2(150, 0)
debug_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
debug_label.add_theme_font_size_override("font_size", FONT_SIZE)
debug_label.add_theme_color_override("font_color", TEXT_COLOR)
debug_hbox.add_child(debug_label)
var debug_check := CheckButton.new()
# Query live DebugConsole node if available; fall back to prefs file
var console_node := get_node_or_null("/root/Main/MetaLayer/DebugConsole")
if console_node and console_node.has_method("is_enabled"):
debug_check.button_pressed = console_node.is_enabled()
else:
var cfg := ConfigFile.new()
debug_check.button_pressed = true
if cfg.load(DebugConsole.PREFS_PATH) == OK:
debug_check.button_pressed = cfg.get_value(
DebugConsole.PREFS_SECTION, DebugConsole.PREFS_KEY_ENABLED, true
)
debug_check.toggled.connect(func(enabled: bool) -> void: debug_console_toggled.emit(enabled))
debug_hbox.add_child(debug_check)
# #646: AI Dialogue section divider
var ai_divider := Control.new()
ai_divider.custom_minimum_size = Vector2(0, 8)
_container.add_child(ai_divider)
var ai_section_label := Label.new()
ai_section_label.text = "AI DIALOGUE"
ai_section_label.add_theme_font_size_override("font_size", FONT_SIZE - 2)
ai_section_label.add_theme_color_override("font_color", TITLE_COLOR)
_container.add_child(ai_section_label)
# #646: Detect hardware and set status
var ram_result := HardwareDetector.check_ram()
var tpt_cache: Variant = HardwareDetector.read_benchmark_cache()
var ram_class: String = ram_result["classification"]
var tpt_class: String = "green"
var tpt_tps: float = 0.0
if tpt_cache != null:
tpt_class = tpt_cache["classification"]
tpt_tps = tpt_cache["tps"]
# Composite status: RAM fail overrides TPT; RAM marginal keeps its own category
var hw_status: String
if ram_class == "fail":
hw_status = "fail"
elif ram_class == "marginal":
hw_status = "marginal"
else:
# RAM passes — status follows TPT if benchmarked, else "pass"
hw_status = "pass" if tpt_cache == null else tpt_class
set_ai_dialogue_hardware_status(hw_status)
# D-138 §8 Layer 3: reflect current battery-suspension state from HardwareDetector.
_ai_inference_suspended = HardwareDetector.inference_suspended
var ai_hbox := HBoxContainer.new()
ai_hbox.custom_minimum_size = Vector2(0, ROW_HEIGHT)
_container.add_child(ai_hbox)
var ai_label := Label.new()
ai_label.text = get_ai_dialogue_label_text()
ai_label.custom_minimum_size = Vector2(200, 0)
ai_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
ai_label.add_theme_font_size_override("font_size", FONT_SIZE)
ai_label.add_theme_color_override("font_color", TEXT_COLOR)
ai_hbox.add_child(ai_label)
# Status dot — colored square indicating hardware classification
var status_dot := ColorRect.new()
status_dot.custom_minimum_size = Vector2(10, 10)
status_dot.size_flags_vertical = Control.SIZE_SHRINK_CENTER
match _ai_hw_status:
"pass", "green":
status_dot.color = STATUS_GREEN
"marginal", "yellow":
status_dot.color = STATUS_YELLOW
_: # "red", "fail"
status_dot.color = STATUS_RED
ai_hbox.add_child(status_dot)
var dot_spacer := Control.new()
dot_spacer.custom_minimum_size = Vector2(8, 0)
ai_hbox.add_child(dot_spacer)
_ai_check_node = CheckButton.new()
_ai_check_node.button_pressed = GameState.ai_enhanced_dialogue_enabled
_ai_check_node.disabled = (_ai_hw_status == "fail")
ai_hbox.add_child(_ai_check_node)
# Status message label — only shown when non-empty
var status_msg: String = HardwareDetector.status_message(
ram_class, tpt_class, ram_result["free_mb"], tpt_tps
)
var ai_status_label := Label.new()
ai_status_label.text = status_msg
ai_status_label.add_theme_font_size_override("font_size", FONT_SIZE_SMALL)
ai_status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
ai_status_label.custom_minimum_size = Vector2(BOX_WIDTH - PADDING * 2, 0)
match _ai_hw_status:
"marginal", "yellow":
ai_status_label.add_theme_color_override("font_color", STATUS_YELLOW)
"red", "fail":
ai_status_label.add_theme_color_override("font_color", STATUS_RED)
_:
ai_status_label.add_theme_color_override("font_color", TEXT_COLOR)
ai_status_label.visible = not status_msg.is_empty()
_container.add_child(ai_status_label)
# Battery warning label — visible when on battery power; toggle stays enabled per D-138.
_ai_battery_warning_label = Label.new()
_ai_battery_warning_label.text = UIStrings.get_text("settings.ai_battery_warning")
_ai_battery_warning_label.add_theme_font_size_override("font_size", FONT_SIZE_SMALL)
_ai_battery_warning_label.add_theme_color_override("font_color", STATUS_YELLOW)
_ai_battery_warning_label.visible = _ai_inference_suspended
_container.add_child(_ai_battery_warning_label)
_ai_check_node.toggled.connect(
func(enabled: bool) -> void:
GameState.ai_enhanced_dialogue_enabled = enabled
_save_ai_pref(enabled)
if SimBridge.state == SimBridge.ConnectionState.CONNECTED:
(
SimBridge
. send_input(
{
"action": InputMapper.Action.CHANGE_SETTINGS,
"action_data": {"ai_enhanced_dialogue": enabled},
"timestamp_msec": Time.get_ticks_msec(),
}
)
)
ai_dialogue_toggled.emit(enabled)
)
# Spacer
var spacer := Control.new()
spacer.custom_minimum_size = Vector2(0, 8)
_container.add_child(spacer)
# Close button
var close_btn := Button.new()
close_btn.text = "CLOSE"
close_btn.add_theme_font_size_override("font_size", FONT_SIZE)
close_btn.pressed.connect(close)
_container.add_child(close_btn)
# Quit to Menu button (#258: D-085 session management)
var quit_btn := Button.new()
quit_btn.text = UIStrings.get_text("menu.quit_to_menu")
quit_btn.add_theme_font_size_override("font_size", FONT_SIZE)
quit_btn.add_theme_color_override("font_color", Color("#c87040"))
quit_btn.pressed.connect(_on_quit_to_menu)
_container.add_child(quit_btn)
func _destroy_ui() -> void:
if _container:
_container.queue_free()
_container = null
_ai_check_node = null # freed with _container
_ai_battery_warning_label = null
# -- #646: AI Dialogue testable API -------------------------------------------
## Returns the canonical label text for the AI-Enhanced Dialogue toggle (D-138).
## Reads from UIStrings yaml (settings.ai_dialogue_toggle) for localization support.
func get_ai_dialogue_label_text() -> String:
return UIStrings.get_text("settings.ai_dialogue_toggle")
## Set the hardware detection status — drives toggle enabled/disabled state.
## Accepts: "pass" | "marginal" | "fail" (RAM) or "green" | "yellow" | "red" (TPT).
## Only "fail" (insufficient RAM) disables the toggle. All others leave it enabled —
## D-138: no hard minimum spec floor; player can always override recommendations.
func set_ai_dialogue_hardware_status(status: String) -> void:
_ai_hw_status = status
if _ai_check_node != null:
_ai_check_node.disabled = (status == "fail")
## Returns true if the AI-Enhanced Dialogue toggle is currently enabled (not greyed out).
## Only disabled by RAM "fail" — battery suspension shows a warning but never greys the toggle.
func is_ai_dialogue_toggle_enabled() -> bool:
return _ai_hw_status != "fail"
## Returns true if AI inference is currently auto-suspended due to battery power (D-138 §8 Layer 3).
## Set by set_ai_inference_suspended() when HardwareDetector detects battery profile.
func is_ai_inference_suspended() -> bool:
return _ai_inference_suspended
## Returns true if the battery warning label is currently visible.
func get_battery_warning_visible() -> bool:
if _ai_battery_warning_label == null:
return false
return _ai_battery_warning_label.visible
## Update battery suspension display state.
## Shows/hides the battery warning label; toggle stays enabled — player autonomy wins (D-138).
## Called when PlatformInfo.power_profile_changed fires (via main.gd or HardwareDetector).
## Tests can inject suspended=true to verify the warning label appears.
func set_ai_inference_suspended(suspended: bool) -> void:
_ai_inference_suspended = suspended
if _ai_battery_warning_label != null:
_ai_battery_warning_label.visible = suspended
## Persist the AI Dialogue enabled state to the local prefs file.
## Uses the same settings.cfg as DebugConsole — different section ("ai_dialogue").
func _save_ai_pref(enabled: bool) -> void:
var cfg := ConfigFile.new()
cfg.load(SETTINGS_CFG_PATH)
cfg.set_value("ai_dialogue", "enabled", enabled)
cfg.save(SETTINGS_CFG_PATH)
func _draw() -> void:
if not is_open():
return
var viewport_size := get_viewport_rect().size
# Dim overlay
draw_rect(Rect2(Vector2.ZERO, viewport_size), BG_COLOR)
# Dialog 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, PADDING + 18),
"SETTINGS",
HORIZONTAL_ALIGNMENT_LEFT,
-1,
FONT_SIZE + 2,
TITLE_COLOR
)
func _on_quit_to_menu() -> void:
close()
SessionManager.quit_to_menu()
static func _format_db(db: float) -> String:
if db <= -40.0:
return "mute"
return "%d dB" % int(db)