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:
@@ -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 ""
|
||||
@@ -0,0 +1 @@
|
||||
uid://hardware_detector_sr
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user