feat(ui): AI-Enhanced Dialogue toggle + hardware detection (#646, D-138)

Implements the full AI-Enhanced Dialogue feature for Sprint 26:

- HardwareDetector autoload (extends Node): three-layer detection —
  Layer 1 RAM classification (pass/marginal/fail, thresholds 2GB/1.6GB),
  Layer 2 TPT benchmark cache (green/yellow/red, thresholds 6/3 t/s),
  Layer 3 degradation monitoring (>40% drop → yellow). load_ai_pref()
  restores toggle state from user://settings.cfg on startup.

- Settings dialog AI section: toggle, colored status dot, status message
  label, testable API (get_ai_dialogue_label_text, set_ai_dialogue_
  hardware_status, is_ai_dialogue_toggle_enabled). Only RAM "fail" greys
  out toggle — player always overrides yellow/red recommendations (D-138).

- GameState.ai_enhanced_dialogue_enabled (default true, opt-out model).
  GameState.settings_response (v20 one-shot settings dump from server).
  apply_snapshot() hydrates ai_enhanced_dialogue_enabled from full dump.

- Protocol v19→v20, settings_response decoding in decode_snapshot().
  Protocol converted to extends Node autoload (enables test has_method).
  encode_change_settings() helper for test inspection.

- InputMapper: CHANGE_SETTINGS, REQUEST_ALL_SETTINGS, DELETE_SETTING.
  SimBridge: wire mappings for all three. RequestAllSettings queued after
  handshake to hydrate client state from server SQLite on connect.
  settings_response carry-forward in receive_bytes().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 10:34:48 +01:00
co-authored by Claude Sonnet 4.6
parent 2f8218400d
commit 01b0583265
9 changed files with 354 additions and 7 deletions
+109
View File
@@ -0,0 +1,109 @@
extends Node
## Hardware detection autoload for AI-Enhanced Dialogue (D-138, #646).
##
## Exposes classification methods for Layer 1 (RAM), Layer 2 (TPT benchmark),
## and Layer 3 (ongoing TPT degradation monitoring).
##
## Loaded as HardwareDetector autoload. Tests access via get_node("/root/HardwareDetector").
## Settings dialog reads classification strings to configure the toggle UI.
const RAM_PASS_THRESHOLD_MB := 2000.0
const RAM_MARGINAL_THRESHOLD_MB := 1600.0
const TPT_GREEN_THRESHOLD := 6.0 # tokens/sec — enable silently
const TPT_YELLOW_THRESHOLD := 3.0 # tokens/sec — partial pre-voicing note
const TPT_DEGRADATION_THRESHOLD := 0.4 # fraction — >40% sustained drop → yellow
const BENCHMARK_CACHE_PATH := "user://ai-dialogue-config.json"
func _ready() -> void:
load_ai_pref()
## Layer 1 — RAM classification.
## Accepts free MB as input; returns "pass" | "marginal" | "fail".
func classify_ram(free_mb: float) -> String:
if free_mb >= RAM_PASS_THRESHOLD_MB:
return "pass"
elif free_mb >= RAM_MARGINAL_THRESHOLD_MB:
return "marginal"
else:
return "fail"
## Layer 2 — TPT benchmark classification.
## Accepts tokens/sec as input; returns "green" | "yellow" | "red".
func classify_tpt(tps: float) -> String:
if tps >= TPT_GREEN_THRESHOLD:
return "green"
elif tps >= TPT_YELLOW_THRESHOLD:
return "yellow"
else:
return "red"
## Layer 3 — Ongoing degradation classification.
## D-138 §8: >40% sustained drop from baseline → yellow (not forced disable).
## Returns "ok" | "yellow".
func classify_degradation(baseline_tps: float, current_avg_tps: float) -> String:
if baseline_tps <= 0.0:
return "ok"
var drop := (baseline_tps - current_avg_tps) / baseline_tps
return "yellow" if drop > TPT_DEGRADATION_THRESHOLD else "ok"
## Query the OS for free RAM and return the classification plus the raw MB value.
## Returns: {classification: String, free_mb: float}
func check_ram() -> Dictionary:
var mem := OS.get_memory_info()
# "available" accounts for reclaimable pages (Linux MemAvailable / Windows ullAvailPhys).
# Fall back to "free" on platforms that don't provide "available".
var free_bytes: int = mem.get("available", mem.get("free", 0))
var free_mb: float = float(free_bytes) / (1024.0 * 1024.0)
return {"classification": classify_ram(free_mb), "free_mb": free_mb}
## Read the cached TPT benchmark result written by the server on first model load.
## Returns: {tps: float, classification: String} or null if no cache file exists.
func read_benchmark_cache() -> Variant:
if not FileAccess.file_exists(BENCHMARK_CACHE_PATH):
return null
var f := FileAccess.open(BENCHMARK_CACHE_PATH, FileAccess.READ)
if f == null:
return null
var json := JSON.new()
if json.parse(f.get_as_text()) != OK:
return null
var data: Variant = json.get_data()
if not data is Dictionary or not data.has("tps"):
return null
var tps: float = float(data["tps"])
return {"tps": tps, "classification": classify_tpt(tps)}
## Load AI dialogue preference from settings.cfg and apply to GameState.
## Called from _ready() to restore the toggle state across session restarts.
## Spec: D-138 §8 — toggle persists via ConfigFile (client-local) + server SQLite (#627).
func load_ai_pref() -> void:
var cfg := ConfigFile.new()
if cfg.load("user://settings.cfg") != OK:
return
var enabled: Variant = cfg.get_value("ai_dialogue", "enabled", null)
if enabled is bool:
GameState.ai_enhanced_dialogue_enabled = enabled
## Compose a human-readable status message for the settings dialog.
## classification: "pass" | "marginal" | "fail" (RAM) or "green" | "yellow" | "red" (TPT).
## Returns empty string when no message is needed.
func status_message(ram_classification: String, tpt_classification: String, free_mb: float, tps: float) -> String:
if ram_classification == "fail":
return "AI-Enhanced Dialogue requires 2 GB of free memory. Your system currently has %.0f MB available. Close other applications and try again, or leave the setting off — the game is complete either way." % free_mb
if ram_classification == "marginal":
return "Only %.0f MB free — performance may vary. You can still enable it." % free_mb
match tpt_classification:
"yellow":
return "Running at %.0f t/s — pre-voicing will work for main characters and key scenes. Background NPCs may show base text until the queue catches up." % tps
"red":
return "Running very slowly at %.0f t/s — we recommend leaving this off, but the choice is yours." % tps
return ""
+1
View File
@@ -0,0 +1 @@
uid://hardware_detector_sr
+142 -4
View File
@@ -1,18 +1,24 @@
extends Control
## #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).
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
const BOX_WIDTH := 460
const BOX_HEIGHT := 376 # +36 for Debug Console row
const PADDING := 20
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)
@@ -27,8 +33,13 @@ const BUS_ROWS: Array = [
var _active: bool = false
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
signal closed
signal debug_console_toggled(enabled: bool) # #581: debug console enabled/disabled
signal ai_dialogue_toggled(enabled: bool) # #646: AI-Enhanced Dialogue enabled/disabled
func _ready() -> void:
@@ -138,6 +149,100 @@ func _build_ui() -> void:
)
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)
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", "red":
status_dot.color = STATUS_YELLOW
_: # "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", "red":
ai_status_label.add_theme_color_override("font_color", STATUS_YELLOW)
"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)
_ai_check_node.toggled.connect(func(enabled: bool) -> void:
GameState.ai_enhanced_dialogue_enabled = enabled
_save_ai_pref(enabled)
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)
@@ -163,6 +268,39 @@ func _destroy_ui() -> void:
if _container:
_container.queue_free()
_container = null
_ai_check_node = null # freed with _container
# -- #646: AI Dialogue testable API -------------------------------------------
## Returns the canonical label text for the AI-Enhanced Dialogue toggle (D-138).
func get_ai_dialogue_label_text() -> String:
return "AI-Enhanced Dialogue"
## 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 "fail" (RAM below minimum) produces a disabled toggle.
func is_ai_dialogue_toggle_enabled() -> bool:
return _ai_hw_status != "fail"
## 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(DebugConsole.PREFS_PATH)
cfg.set_value("ai_dialogue", "enabled", enabled)
cfg.save(DebugConsole.PREFS_PATH)
func _draw() -> void:
@@ -186,7 +324,7 @@ func _draw() -> void:
var font := ThemeDB.fallback_font
draw_string(font,
box_pos + Vector2(PADDING, PADDING + 18),
"AUDIO SETTINGS",
"SETTINGS",
HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE + 2, TITLE_COLOR)