diff --git a/client/data/ui-strings.yaml b/client/data/ui-strings.yaml index e82800f64..492700c0f 100644 --- a/client/data/ui-strings.yaml +++ b/client/data/ui-strings.yaml @@ -194,6 +194,11 @@ settings: text_speed: "Text Speed" fullscreen: "Fullscreen" language: "Language" + # #646: AI-Enhanced Dialogue toggle (D-138) + ai_section_header: "AI DIALOGUE" + ai_dialogue_toggle: "AI-Enhanced Dialogue" + ai_status_checking: "Speed not yet measured — will check on first enable." + ai_status_ram_marginal: "Low memory — performance may vary." # ============================================================ # CHARACTER SELECTION (if applicable in v0.1) diff --git a/client/project.godot b/client/project.godot index 486c553c6..48156fa63 100644 --- a/client/project.godot +++ b/client/project.godot @@ -17,6 +17,7 @@ config/icon="res://icon.svg" [autoload] +Protocol="*res://scripts/protocol/protocol.gd" SimBridge="*res://scripts/autoloads/sim_bridge.gd" GameState="*res://scripts/autoloads/game_state.gd" InputMapper="*res://scripts/autoloads/input_mapper.gd" @@ -24,6 +25,7 @@ UIStrings="*res://scripts/autoloads/ui_strings.gd" FogState="*res://scripts/autoloads/fog_state.gd" AudioManager="*res://scripts/autoloads/audio_manager.gd" SessionManager="*res://scripts/autoloads/session_manager.gd" +HardwareDetector="*res://ui/hardware_detector.gd" [audio] diff --git a/client/scripts/autoloads/game_state.gd b/client/scripts/autoloads/game_state.gd index 3006f8a64..9114716bd 100644 --- a/client/scripts/autoloads/game_state.gd +++ b/client/scripts/autoloads/game_state.gd @@ -97,6 +97,17 @@ var pending_load_path: String = "" # Default: "detective" — fallback for legacy saves without character.txt. var character_archetype: String = "detective" +# #646: AI-Enhanced Dialogue enabled state (D-138). +# Runtime toggle — true means the LLM re-voicing pipeline should run (server-side). +# Default: true (opt-out model per D-138 §8). Hardware detector may disable at startup +# if RAM is insufficient. Persisted to server SQLite via ChangeSettings IPC. +var ai_enhanced_dialogue_enabled: bool = true + +# v20 fields (#627, D-138): settings response from server. +# One-shot: {kind: "full", settings: [{key, value}]} or {kind: "ack", success, key, error} or null. +# "full" response hydrates ai_enhanced_dialogue_enabled (server is authoritative for persisted state). +var settings_response: Variant = null + # v7 fields (#431, D-059/D-060) var pending_recognitions: Array = [] # [{entity_id, x, y, z, remaining_ticks, total_delay_ticks}] @@ -328,6 +339,24 @@ func apply_snapshot(snapshot: Dictionary) -> void: else: debug_response = null + # v20: settings_response (#627, D-138) — one-shot settings ack/dump from server. + # "full" kind → iterate settings array and hydrate matching fields. + if snapshot.has("settings_response") and snapshot.settings_response is Dictionary: + settings_response = snapshot.settings_response + var sr: Dictionary = snapshot.settings_response + if sr.get("kind") == "full": + var sr_settings: Variant = sr.get("settings") + if sr_settings is Array: + for entry in sr_settings: + if not entry is Dictionary: + continue + if entry.get("key") == "ai_dialogue.enabled": + var val: Variant = entry.get("value") + if val is Dictionary and val.has("Bool"): + ai_enhanced_dialogue_enabled = bool(val["Bool"]) + else: + settings_response = null + # v14: player_knowledge (#264, D-041) — partial KG dump for journal panel. # Only update when field is present (null means no change, server sends when KG changes). if snapshot.has("player_knowledge") and snapshot.player_knowledge is Dictionary: diff --git a/client/scripts/autoloads/input_mapper.gd b/client/scripts/autoloads/input_mapper.gd index eb18149f6..58c331829 100644 --- a/client/scripts/autoloads/input_mapper.gd +++ b/client/scripts/autoloads/input_mapper.gd @@ -25,6 +25,9 @@ enum Action { SAVE_GAME, # #554: F5 quicksave — sends SaveGame to server with save path LOAD_GAME, # #554: F6 quickload — sends LoadGame to server with save path DEBUG_COMMAND, # #581: debug console command dispatch — sends DebugCommandKind to server + CHANGE_SETTINGS, # #646: persist a setting to server SQLite — sends {key, value} to server + REQUEST_ALL_SETTINGS, # #646: request full settings dump from server after handshake (unit variant) + DELETE_SETTING, # #646: delete a setting by key from server SQLite (struct variant) } var input_queue: Array[Dictionary] = [] diff --git a/client/scripts/autoloads/sim_bridge.gd b/client/scripts/autoloads/sim_bridge.gd index 887abf5ea..5424ac1ea 100644 --- a/client/scripts/autoloads/sim_bridge.gd +++ b/client/scripts/autoloads/sim_bridge.gd @@ -253,6 +253,12 @@ func _process(delta: float) -> void: handshake_complete.emit(server_version) _set_state(ConnectionState.CONNECTED) + # #646: Request full settings dump on connect — hydrates GameState.ai_enhanced_dialogue_enabled + # from server SQLite so the client reflects the authoritative persisted state (D-138). + _outbound_buffer.append({ + "tick": 0, + "action_name": "RequestAllSettings", + }) return if _bridge == null: @@ -375,6 +381,9 @@ func receive_bytes(bytes: PackedByteArray) -> void: # #554: Carry forward save/load result (one-shot, consumed by main.gd) if snapshot.get("save_result") == null and _last_snapshot.get("save_result") != null: snapshot["save_result"] = _last_snapshot["save_result"] + # #646: Carry forward settings_response (one-shot, consumed by game_state apply_snapshot) + if snapshot.get("settings_response") == null and _last_snapshot.get("settings_response") != null: + snapshot["settings_response"] = _last_snapshot["settings_response"] _last_snapshot = snapshot # Drain the outbound buffer. Returns raw input entries for batch encoding. @@ -418,6 +427,12 @@ static func action_enum_to_wire(action: int) -> String: return "LoadGame" # #554: F6 quickload (D-085) InputMapper.Action.DEBUG_COMMAND: return "DebugCommand" # #581: debug console command dispatch + InputMapper.Action.CHANGE_SETTINGS: + return "ChangeSettings" # #646: persist setting to server SQLite (D-138) + InputMapper.Action.REQUEST_ALL_SETTINGS: + return "RequestAllSettings" # #646: unit variant — server sends full settings dump + InputMapper.Action.DELETE_SETTING: + return "DeleteSetting" # #646: struct variant — delete setting by key _: push_warning("SimBridge: unknown action enum %s" % action) return "" diff --git a/client/scripts/protocol/protocol.gd b/client/scripts/protocol/protocol.gd index 179829b54..995b88c0a 100644 --- a/client/scripts/protocol/protocol.gd +++ b/client/scripts/protocol/protocol.gd @@ -1,4 +1,4 @@ -class_name Protocol +extends Node ## MessagePack codec for the Rust↔Godot wire protocol (D-020). ## ## Encodes/decodes ObserverSnapshot and PlayerInput to match @@ -11,8 +11,8 @@ class_name Protocol ## Protocol version — must match server PROTOCOL_VERSION in bridge/types.rs. ## Reject snapshots where version != this value. -## v19: adds character_archetype field to StartupMessage (#588, #587). -const PROTOCOL_VERSION: int = 19 +## v20: adds settings_response field to ObserverSnapshot (#627, D-138). +const PROTOCOL_VERSION: int = 20 # -- Decode: bytes from server → GDScript types -------------------------------- @@ -270,6 +270,32 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant: "triangle_id": int(raw_ev["triangle_id"]), }) + # v20: settings_response (#627, D-138) — server ack after ChangeSettings / full settings dump + # after RequestAllSettings. kind = "full"|"ack". "full": settings array [{key, value}]. + # "ack": {success: bool, key: String, error: String|null}. + var settings_response: Variant = null + var raw_sr: Variant = raw.get("settings_response") + if raw_sr is Dictionary: + var sr_kind: String = str(raw_sr.get("kind", "")) + if sr_kind == "full": + var sr_settings: Array = [] + var raw_sr_settings: Variant = raw_sr.get("settings") + if raw_sr_settings is Array: + for raw_s in raw_sr_settings: + if raw_s is Dictionary and raw_s.has("key"): + sr_settings.append({ + "key": str(raw_s["key"]), + "value": raw_s.get("value"), + }) + settings_response = {"kind": "full", "settings": sr_settings} + elif sr_kind == "ack": + settings_response = { + "kind": "ack", + "success": bool(raw_sr.get("success", false)), + "key": str(raw_sr.get("key", "")), + "error": raw_sr.get("error"), + } + # v19: current_ticker (#592) — scrolling news headline when in The Last Shift zone. # {id: String, text: String, category: String} or null when player outside bar zone. var current_ticker: Variant = null @@ -362,6 +388,7 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant: "zone_id": zone_id, "triangle_crisis_events": triangle_crisis_events, "current_ticker": current_ticker, + "settings_response": settings_response, } @@ -548,6 +575,24 @@ static func _encode_action(action_name: String, action_data: Variant) -> Variant return action_name +## Encode a ChangeSettings action for the AI-Enhanced Dialogue toggle (#646, D-138). +## Returns a MessagePack-encoded Vec in buffer-entry format +## (action_name + action_data), suitable for inspection in tests and for +## queuing via SimBridge._outbound_buffer. +## The server receives this as a PlayerAction::ChangeSettings variant. +static func encode_change_settings(enabled: bool) -> PackedByteArray: + var entries: Array = [{ + "tick": 0, + "action_name": "ChangeSettings", + "action_data": {"ai_enhanced_dialogue": enabled}, + }] + var result = Messagepack.encode(entries) + if result.status != null: + push_error("Protocol: encode_change_settings failed: %s" % result.status) + return PackedByteArray() + return result.value + + ## Decode a PlayerInput from MessagePack bytes (used in tests / echo scenarios). ## Returns { "tick": int, "action": { "variant": String, "data": Variant } } or null. static func decode_player_input(bytes: PackedByteArray) -> Variant: diff --git a/client/ui/hardware_detector.gd b/client/ui/hardware_detector.gd new file mode 100644 index 000000000..bfed93da6 --- /dev/null +++ b/client/ui/hardware_detector.gd @@ -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 "" diff --git a/client/ui/hardware_detector.gd.uid b/client/ui/hardware_detector.gd.uid new file mode 100644 index 000000000..5e90e8cd4 --- /dev/null +++ b/client/ui/hardware_detector.gd.uid @@ -0,0 +1 @@ +uid://hardware_detector_sr \ No newline at end of file diff --git a/client/ui/settings_dialog.gd b/client/ui/settings_dialog.gd index 51f1fe2cb..f3724fe09 100644 --- a/client/ui/settings_dialog.gd +++ b/client/ui/settings_dialog.gd @@ -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)