From 01b0583265699718cdcf60a354615ad4196e43ae Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 13 Mar 2026 10:34:48 +0100 Subject: [PATCH 01/11] feat(ui): AI-Enhanced Dialogue toggle + hardware detection (#646, D-138) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- client/data/ui-strings.yaml | 5 + client/project.godot | 2 + client/scripts/autoloads/game_state.gd | 29 +++++ client/scripts/autoloads/input_mapper.gd | 3 + client/scripts/autoloads/sim_bridge.gd | 15 +++ client/scripts/protocol/protocol.gd | 51 +++++++- client/ui/hardware_detector.gd | 109 +++++++++++++++++ client/ui/hardware_detector.gd.uid | 1 + client/ui/settings_dialog.gd | 146 ++++++++++++++++++++++- 9 files changed, 354 insertions(+), 7 deletions(-) create mode 100644 client/ui/hardware_detector.gd create mode 100644 client/ui/hardware_detector.gd.uid 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) From d7a4e3268a16401c20b5180a896d7f16bbae06de Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 13 Mar 2026 10:41:07 +0100 Subject: [PATCH 02/11] feat(ui): PlatformInfo abstraction + battery detection + fixes (#646 D-138) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform abstraction: - New PlatformInfo autoload (extends Node, registered first in project.godot). Owns all OS queries: power state monitoring (30s poll), memory (on demand), file path resolution (user_data_dir, config_dir, benchmark_cache_path, install_dir, executable_path, cache_dir, model_dir). - PowerProfile enum (FULL/BATTERY/POWER_SAVER), power_profile_changed signal. Uses OS.callv("get_power_info") to defer resolution to runtime — avoids compile errors on Godot 4.6 headless builds without power API. HardwareDetector refactor: - check_ram() now delegates to PlatformInfo.refresh_memory() + free_memory_mb. All direct OS.get_memory_info() calls removed. - classify_power_state(int) and should_suspend_inference(int) delegate to PlatformInfo for single source of truth. - check_power_state() → {power_state, classification, should_suspend}. - Battery suspend/resume: on PowerProfile.BATTERY, stores pre-battery pref and sends ChangeSettings(false) to server. On FULL restore, sends ChangeSettings with saved pref if it was enabled. inference_suspended member tracks state. Settings dialog: - is_ai_inference_suspended() / set_ai_inference_suspended(bool) — testable API per Hoshe's test contract. Reads HardwareDetector.inference_suspended on open. - Toggle now disabled when battery-suspended OR hardware fails (not only fail). - get_ai_dialogue_label_text() wired to UIStrings ("settings.ai_dialogue_toggle") instead of hardcoded string. Cleanup: - Deleted ai_dialogue_detector.gd and .uid (orphaned duplicate, dead code). Co-Authored-By: Claude Sonnet 4.6 --- client/project.godot | 1 + client/scripts/autoloads/platform_info.gd | 153 ++++++++++++++++++ client/scripts/autoloads/platform_info.gd.uid | 1 + client/ui/hardware_detector.gd | 144 ++++++++++++++--- client/ui/settings_dialog.gd | 29 +++- 5 files changed, 298 insertions(+), 30 deletions(-) create mode 100644 client/scripts/autoloads/platform_info.gd create mode 100644 client/scripts/autoloads/platform_info.gd.uid diff --git a/client/project.godot b/client/project.godot index 48156fa63..234ff6d10 100644 --- a/client/project.godot +++ b/client/project.godot @@ -17,6 +17,7 @@ config/icon="res://icon.svg" [autoload] +PlatformInfo="*res://scripts/autoloads/platform_info.gd" Protocol="*res://scripts/protocol/protocol.gd" SimBridge="*res://scripts/autoloads/sim_bridge.gd" GameState="*res://scripts/autoloads/game_state.gd" diff --git a/client/scripts/autoloads/platform_info.gd b/client/scripts/autoloads/platform_info.gd new file mode 100644 index 000000000..afef5236b --- /dev/null +++ b/client/scripts/autoloads/platform_info.gd @@ -0,0 +1,153 @@ +extends Node +## Platform abstraction layer — D-138 §8. Central location for all OS queries. +## Owned by Stig (UI Developer). Registered FIRST in project.godot so other +## autoloads can read its properties in their own _ready(). +## +## Sections: +## Power: PowerProfile enum, 30s poll timer, power_profile_changed signal. +## Memory: free_memory_mb / total_memory_mb, refresh_memory() on demand. +## Paths: Read-once OS path constants, populated in _ready(). + + +# -- Power profile ------------------------------------------------------------ + +## High-level power classification. POWER_SAVER reserved for future OS API. +enum PowerProfile { + FULL = 0, # Plugged in (charged, charging, or no battery) — no restrictions + BATTERY = 1, # On battery — AI inference should be suspended per D-138 §8 Layer 3 + POWER_SAVER = 2 # System-level power-saver mode (future: no cross-platform API yet) +} + +## Emitted when the detected power profile changes. +signal power_profile_changed(old_profile: int, new_profile: int) + +## Current power profile. Updated by the 30-second poll timer. +var power_profile: PowerProfile = PowerProfile.FULL + +## How often (seconds) to re-poll OS for power state changes. +const POWER_POLL_INTERVAL := 30.0 + +# OS power_state integer constants (Godot 4 — same values as POWERSTATE_* enum). +const _POWER_STATE_UNKNOWN := 0 +const _POWER_STATE_ON_BATTERY := 1 +const _POWER_STATE_NO_BATTERY := 2 +const _POWER_STATE_CHARGING := 3 +const _POWER_STATE_CHARGED := 4 + + +# -- Memory ------------------------------------------------------------------- + +## Free physical RAM in megabytes. Call refresh_memory() before reading if staleness matters. +var free_memory_mb: float = 0.0 + +## Total physical RAM in megabytes. Populated once in _ready(); doesn't change at runtime. +var total_memory_mb: float = 0.0 + + +# -- File paths --------------------------------------------------------------- + +## Base user data directory (user://). +var user_data_dir: String = "" + +## Directory where settings.cfg is written. Same as user_data_dir in Godot 4. +var config_dir: String = "" + +## Full path to AI benchmark cache (ai-dialogue-config.json). +var benchmark_cache_path: String = "" + +## Directory where the game binary lives (install location). +var install_dir: String = "" + +## Full path to the game executable. +var executable_path: String = "" + +## OS-provided cache directory, or user_data_dir/cache as fallback. +var cache_dir: String = "" + +## Directory where the bundled Gemma 2 model lives (derived from install_dir). +var model_dir: String = "" + + +# -- Lifecycle ---------------------------------------------------------------- + +func _ready() -> void: + _init_paths() + refresh_memory() + _poll_power() + + var timer := Timer.new() + timer.wait_time = POWER_POLL_INTERVAL + timer.autostart = true + timer.timeout.connect(_poll_power) + add_child(timer) + + +func _init_paths() -> void: + user_data_dir = OS.get_user_data_dir() + config_dir = user_data_dir + benchmark_cache_path = "user://ai-dialogue-config.json" + executable_path = OS.get_executable_path() + install_dir = executable_path.get_base_dir() + model_dir = install_dir + "/models" + # get_cache_dir() may not exist on all Godot builds — use callv for safety. + var os_cache: String = "" + if OS.has_method("get_cache_dir"): + var result: Variant = OS.callv("get_cache_dir", []) + if result is String: + os_cache = result + cache_dir = os_cache if not os_cache.is_empty() else user_data_dir + "/cache" + + +# -- Memory API --------------------------------------------------------------- + +## Refresh free/total memory readings from the OS. Call before read if freshness matters. +func refresh_memory() -> void: + var mem := OS.get_memory_info() + # "available" = Linux MemAvailable / Windows ullAvailPhys (includes reclaimable pages). + # Fall back to "free" on platforms that don't provide "available". + free_memory_mb = float(mem.get("available", mem.get("free", 0))) / (1024.0 * 1024.0) + total_memory_mb = float(mem.get("physical", 0)) / (1024.0 * 1024.0) + + +# -- Power API ---------------------------------------------------------------- + +## Classify an OS power_state integer into a human-readable string. +## Mirrors HardwareDetector.classify_power_state() for external consumers. +## 1 (ON_BATTERY) → "battery", 2/3/4 (plugged) → "plugged", 0 → "unknown". +static func classify_power_state(power_state: int) -> String: + match power_state: + _POWER_STATE_ON_BATTERY: + return "battery" + _POWER_STATE_NO_BATTERY, _POWER_STATE_CHARGING, _POWER_STATE_CHARGED: + return "plugged" + _: + return "unknown" + + +## True only when the OS reports the device is on battery — used by Layer 3 suspend. +static func should_suspend_inference(power_state: int) -> bool: + return power_state == _POWER_STATE_ON_BATTERY + + +## Read current OS power state integer. Returns 0 (UNKNOWN) if unavailable. +## Uses callv() to defer method resolution to runtime — avoids compile errors on builds +## where OS.get_power_info() or OS.get_power_state() are not available (e.g. headless). +func current_power_state() -> int: + # Try Godot 4.x: OS.get_power_info() → {"power_state": int, "percent": int, ...} + if OS.has_method("get_power_info"): + var info: Variant = OS.callv("get_power_info", []) + if info is Dictionary: + return int(info.get("power_state", _POWER_STATE_UNKNOWN)) + # Fallback: unknown power state → treat as FULL (no inference suspension). + return _POWER_STATE_UNKNOWN + + +func _poll_power() -> void: + var state := current_power_state() + var new_profile: PowerProfile = PowerProfile.BATTERY \ + if state == _POWER_STATE_ON_BATTERY \ + else PowerProfile.FULL + if new_profile != power_profile: + var old_profile := power_profile + power_profile = new_profile + power_profile_changed.emit(int(old_profile), int(new_profile)) diff --git a/client/scripts/autoloads/platform_info.gd.uid b/client/scripts/autoloads/platform_info.gd.uid new file mode 100644 index 000000000..198a746ed --- /dev/null +++ b/client/scripts/autoloads/platform_info.gd.uid @@ -0,0 +1 @@ +uid://platform_info_sr diff --git a/client/ui/hardware_detector.gd b/client/ui/hardware_detector.gd index bfed93da6..9409d479a 100644 --- a/client/ui/hardware_detector.gd +++ b/client/ui/hardware_detector.gd @@ -2,10 +2,11 @@ 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). +## Layer 3 (TPT degradation + battery power state monitoring). ## +## Consumes PlatformInfo for all OS queries (memory, power state, paths). ## Loaded as HardwareDetector autoload. Tests access via get_node("/root/HardwareDetector"). -## Settings dialog reads classification strings to configure the toggle UI. +## Settings dialog reads classification strings and power-state methods to configure the UI. const RAM_PASS_THRESHOLD_MB := 2000.0 const RAM_MARGINAL_THRESHOLD_MB := 1600.0 @@ -13,13 +14,30 @@ 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" +## Benchmark cache path — reads from PlatformInfo for cross-platform correctness. +var BENCHMARK_CACHE_PATH: String: + get: + return PlatformInfo.benchmark_cache_path +# -- Battery suspension state ------------------------------------------------- + +## True when AI inference has been auto-suspended due to battery power. +## Separate from GameState.ai_enhanced_dialogue_enabled (player preference preserved). +var inference_suspended: bool = false + +# User's preference at the moment battery suspension activated (to restore on plug-in). +var _pre_battery_pref: bool = true + + +# -- Lifecycle ---------------------------------------------------------------- func _ready() -> void: load_ai_pref() + PlatformInfo.power_profile_changed.connect(_on_power_profile_changed) +# -- Layer 1: RAM classification ---------------------------------------------- + ## Layer 1 — RAM classification. ## Accepts free MB as input; returns "pass" | "marginal" | "fail". func classify_ram(free_mb: float) -> String: @@ -31,6 +49,15 @@ func classify_ram(free_mb: float) -> String: return "fail" +## Query RAM via PlatformInfo and return classification + raw MB value. +## Returns: {classification: String, free_mb: float} +func check_ram() -> Dictionary: + PlatformInfo.refresh_memory() + return {"classification": classify_ram(PlatformInfo.free_memory_mb), "free_mb": PlatformInfo.free_memory_mb} + + +# -- Layer 2: TPT benchmark classification ------------------------------------ + ## Layer 2 — TPT benchmark classification. ## Accepts tokens/sec as input; returns "green" | "yellow" | "red". func classify_tpt(tps: float) -> String: @@ -42,33 +69,13 @@ func classify_tpt(tps: float) -> String: 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): + var cache_path: String = PlatformInfo.benchmark_cache_path + if not FileAccess.file_exists(cache_path): return null - var f := FileAccess.open(BENCHMARK_CACHE_PATH, FileAccess.READ) + var f := FileAccess.open(cache_path, FileAccess.READ) if f == null: return null var json := JSON.new() @@ -81,6 +88,48 @@ func read_benchmark_cache() -> Variant: return {"tps": tps, "classification": classify_tpt(tps)} +# -- Layer 3: degradation + battery ------------------------------------------- + +## Layer 3 — Ongoing TPT 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" + + +## Layer 3 — Battery power state classification. +## Delegates to PlatformInfo for the actual integer → string mapping. +## power_state: OS power_state integer (0=unknown, 1=on_battery, 2=no_battery, 3=charging, 4=charged). +## Returns "battery" | "plugged" | "unknown". +func classify_power_state(power_state: int) -> String: + return PlatformInfo.classify_power_state(power_state) + + +## True only when the OS reports on-battery power — inference suspend trigger. +func should_suspend_inference(power_state: int) -> bool: + return PlatformInfo.should_suspend_inference(power_state) + + +## Query current OS power state and return {power_state: int, classification: String, should_suspend: bool}. +func check_power_state() -> Dictionary: + var state := PlatformInfo.current_power_state() + return { + "power_state": state, + "classification": classify_power_state(state), + "should_suspend": should_suspend_inference(state), + } + + +## True if AI inference is currently suspended due to battery power. +func is_inference_suspended() -> bool: + return inference_suspended + + +# -- Startup pref load -------------------------------------------------------- + ## 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). @@ -93,6 +142,49 @@ func load_ai_pref() -> void: GameState.ai_enhanced_dialogue_enabled = enabled +# -- Battery suspend/resume --------------------------------------------------- + +func _on_power_profile_changed(old_profile: int, new_profile: int) -> void: + if new_profile == PlatformInfo.PowerProfile.BATTERY: + _suspend_for_battery() + elif old_profile == PlatformInfo.PowerProfile.BATTERY: + _resume_from_battery() + + +## Suspend AI inference when transitioning to battery power. +## Preserves the player's preference so it can be restored on plug-in. +func _suspend_for_battery() -> void: + if inference_suspended: + return + _pre_battery_pref = GameState.ai_enhanced_dialogue_enabled + inference_suspended = true + if _pre_battery_pref: + # Only send to server if inference was actually running — no-op if already disabled. + _send_settings_change(false) + + +## Resume AI inference when plugging back in. +## Restores inference only if the player had it enabled before battery kicked in. +func _resume_from_battery() -> void: + if not inference_suspended: + return + inference_suspended = false + if _pre_battery_pref: + _send_settings_change(true) + + +func _send_settings_change(enabled: bool) -> void: + if SimBridge.state != SimBridge.ConnectionState.CONNECTED: + return + SimBridge.send_input({ + "action": InputMapper.Action.CHANGE_SETTINGS, + "action_data": {"ai_enhanced_dialogue": enabled}, + "timestamp_msec": Time.get_ticks_msec(), + }) + + +# -- Status message ----------------------------------------------------------- + ## 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. diff --git a/client/ui/settings_dialog.gd b/client/ui/settings_dialog.gd index f3724fe09..a9fd7bc27 100644 --- a/client/ui/settings_dialog.gd +++ b/client/ui/settings_dialog.gd @@ -36,6 +36,7 @@ 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 signal closed signal debug_console_toggled(enabled: bool) # #581: debug console enabled/disabled @@ -179,6 +180,8 @@ func _build_ui() -> void: # 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) @@ -211,7 +214,7 @@ func _build_ui() -> void: _ai_check_node = CheckButton.new() _ai_check_node.button_pressed = GameState.ai_enhanced_dialogue_enabled - _ai_check_node.disabled = (_ai_hw_status == "fail") + _ai_check_node.disabled = (_ai_hw_status == "fail") or _ai_inference_suspended ai_hbox.add_child(_ai_check_node) # Status message label — only shown when non-empty @@ -274,8 +277,9 @@ func _destroy_ui() -> void: # -- #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 "AI-Enhanced Dialogue" + return UIStrings.get_text("settings.ai_dialogue_toggle") ## Set the hardware detection status — drives toggle enabled/disabled state. @@ -289,9 +293,26 @@ func set_ai_dialogue_hardware_status(status: String) -> void: ## Returns true if the AI-Enhanced Dialogue toggle is currently enabled (not greyed out). -## Only "fail" (RAM below minimum) produces a disabled toggle. +## Disabled by RAM "fail" OR by active battery suspension (D-138 §8 Layer 3). func is_ai_dialogue_toggle_enabled() -> bool: - return _ai_hw_status != "fail" + return _ai_hw_status != "fail" and not _ai_inference_suspended + + +## 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 + + +## Update battery suspension display state. +## Called when PlatformInfo.power_profile_changed fires (via main.gd or HardwareDetector). +## Tests can inject suspended=true to verify UI reacts correctly. +func set_ai_inference_suspended(suspended: bool) -> void: + _ai_inference_suspended = suspended + # If the dialog is currently open and built, update the status label to reflect suspension. + # The next open() call will rebuild from scratch with the correct state anyway. + if _ai_check_node != null: + _ai_check_node.disabled = suspended or (_ai_hw_status == "fail") ## Persist the AI Dialogue enabled state to the local prefs file. From 12d25ce17f531973d750cdde3ccd5fc6af3522ab Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 13 Mar 2026 10:46:44 +0100 Subject: [PATCH 03/11] feat(ui): expand PlatformInfo to full D-141 Tier 1 interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 23 properties, 2 methods, 1 signal: - Power: raw_power_state + battery_percent added to _poll_power() - CPU: cpu_name (OS.get_processor_name), cpu_logical_cores - GPU: gpu_name, gpu_vendor, gpu_api_version, gpu_type (mapped from RenderingDevice.DeviceType), gpu_driver_info — all guarded for headless - Platform: platform_name, os_version, distribution, is_sandboxed - Display: screen_count, screen_size, screen_dpi, screen_refresh_rate, display_scale (macOS screen_get_scale / dpi÷96 fallback) - Locale: locale, locale_language - get_diagnostics() -> Dictionary for bug report inclusion - OS.get_distribution_name() and DisplayServer.screen_get_scale() use callv() pattern for headless build safety Co-Authored-By: Claude Sonnet 4.6 --- client/scripts/autoloads/platform_info.gd | 217 ++++++++++++++++++++-- 1 file changed, 204 insertions(+), 13 deletions(-) diff --git a/client/scripts/autoloads/platform_info.gd b/client/scripts/autoloads/platform_info.gd index afef5236b..267f3a6f2 100644 --- a/client/scripts/autoloads/platform_info.gd +++ b/client/scripts/autoloads/platform_info.gd @@ -1,12 +1,20 @@ extends Node -## Platform abstraction layer — D-138 §8. Central location for all OS queries. +## Platform abstraction layer — D-138 §8, D-141. Central location for all OS queries. ## Owned by Stig (UI Developer). Registered FIRST in project.godot so other ## autoloads can read its properties in their own _ready(). ## ## Sections: -## Power: PowerProfile enum, 30s poll timer, power_profile_changed signal. -## Memory: free_memory_mb / total_memory_mb, refresh_memory() on demand. -## Paths: Read-once OS path constants, populated in _ready(). +## Power: PowerProfile enum, 30s poll timer, power_profile_changed signal. +## raw_power_state / battery_percent updated each poll. +## Memory: free_memory_mb / total_memory_mb, refresh_memory() on demand. +## CPU: cpu_name / cpu_logical_cores — read once in _ready(). +## GPU: gpu_name / gpu_vendor / gpu_api_version / gpu_type / gpu_driver_info. +## Guarded for headless/server builds where RenderingServer has no device. +## Platform: platform_name / os_version / distribution / is_sandboxed. +## Display: screen_count / screen_size / screen_dpi / screen_refresh_rate / display_scale. +## Locale: locale / locale_language. +## Paths: Read-once OS path constants, populated in _ready(). +## Diag: get_diagnostics() — flat dict of all properties for bug reports. # -- Power profile ------------------------------------------------------------ @@ -24,6 +32,12 @@ signal power_profile_changed(old_profile: int, new_profile: int) ## Current power profile. Updated by the 30-second poll timer. var power_profile: PowerProfile = PowerProfile.FULL +## Raw OS power_state integer from the last poll. 0 = unknown, 1 = on battery, etc. +var raw_power_state: int = 0 + +## Battery charge percentage (0–100). -1 if not available or not on battery. +var battery_percent: int = -1 + ## How often (seconds) to re-poll OS for power state changes. const POWER_POLL_INTERVAL := 30.0 @@ -44,6 +58,75 @@ var free_memory_mb: float = 0.0 var total_memory_mb: float = 0.0 +# -- CPU ---------------------------------------------------------------------- + +## Human-readable processor name (e.g. "Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz"). +var cpu_name: String = "" + +## Number of logical CPU cores reported by the OS. +var cpu_logical_cores: int = 0 + + +# -- GPU ---------------------------------------------------------------------- + +## GPU display name (e.g. "NVIDIA GeForce RTX 3080"). +var gpu_name: String = "" + +## GPU vendor string (e.g. "NVIDIA Corporation"). +var gpu_vendor: String = "" + +## Graphics API version string (e.g. "OpenGL 4.6.0 ..."). +var gpu_api_version: String = "" + +## GPU type classification: "discrete" | "integrated" | "virtual" | "cpu" | "other". +var gpu_type: String = "other" + +## Low-level driver info strings from the OS (may be empty on some platforms). +var gpu_driver_info: PackedStringArray = PackedStringArray() + + +# -- Platform identity -------------------------------------------------------- + +## OS name reported by Godot (e.g. "Linux", "Windows", "macOS"). +var platform_name: String = "" + +## Full OS version string (e.g. "Ubuntu 22.04.3 LTS"). +var os_version: String = "" + +## Linux distribution name (e.g. "Ubuntu"); empty on non-Linux platforms. +var distribution: String = "" + +## True if the application is running inside a sandbox (Flatpak, Snap, macOS sandbox, etc.). +var is_sandboxed: bool = false + + +# -- Display ------------------------------------------------------------------ + +## Number of connected screens. +var screen_count: int = 1 + +## Size of the primary screen in pixels. +var screen_size: Vector2i = Vector2i(1920, 1080) + +## DPI of the primary screen. +var screen_dpi: int = 96 + +## Refresh rate of the primary screen in Hz. Falls back to 60.0 if OS reports < 0. +var screen_refresh_rate: float = 60.0 + +## UI scale factor. Uses OS-reported scale on macOS; falls back to dpi / 96.0 elsewhere. +var display_scale: float = 1.0 + + +# -- Locale ------------------------------------------------------------------- + +## Full locale string (e.g. "en_US"). +var locale: String = "" + +## Language portion of the locale (e.g. "en"). +var locale_language: String = "" + + # -- File paths --------------------------------------------------------------- ## Base user data directory (user://). @@ -72,6 +155,7 @@ var model_dir: String = "" func _ready() -> void: _init_paths() + _init_hardware() refresh_memory() _poll_power() @@ -83,12 +167,12 @@ func _ready() -> void: func _init_paths() -> void: - user_data_dir = OS.get_user_data_dir() - config_dir = user_data_dir + user_data_dir = OS.get_user_data_dir() + config_dir = user_data_dir benchmark_cache_path = "user://ai-dialogue-config.json" - executable_path = OS.get_executable_path() - install_dir = executable_path.get_base_dir() - model_dir = install_dir + "/models" + executable_path = OS.get_executable_path() + install_dir = executable_path.get_base_dir() + model_dir = install_dir + "/models" # get_cache_dir() may not exist on all Godot builds — use callv for safety. var os_cache: String = "" if OS.has_method("get_cache_dir"): @@ -98,6 +182,63 @@ func _init_paths() -> void: cache_dir = os_cache if not os_cache.is_empty() else user_data_dir + "/cache" +func _init_hardware() -> void: + # -- CPU + cpu_name = OS.get_processor_name() + cpu_logical_cores = OS.get_processor_count() + + # -- GPU (guarded: RenderingServer methods may return empty in headless mode) + gpu_name = RenderingServer.get_video_adapter_name() + gpu_vendor = RenderingServer.get_video_adapter_vendor() + gpu_api_version = RenderingServer.get_video_adapter_api_version() + gpu_driver_info = OS.get_video_adapter_driver_info() + if RenderingServer.has_method("get_video_adapter_type"): + var adapter_type: int = RenderingServer.callv("get_video_adapter_type", []) + gpu_type = _map_adapter_type(adapter_type) + + # -- Platform identity + platform_name = OS.get_name() + os_version = OS.get_version() + if OS.has_method("get_distribution_name"): + var dist: Variant = OS.callv("get_distribution_name", []) + if dist is String: + distribution = dist + is_sandboxed = OS.is_sandboxed() + + # -- Display + screen_count = DisplayServer.get_screen_count() + screen_size = DisplayServer.screen_get_size() + screen_dpi = DisplayServer.screen_get_dpi() + var raw_rate: float = DisplayServer.screen_get_refresh_rate() + screen_refresh_rate = raw_rate if raw_rate >= 0.0 else 60.0 + display_scale = _compute_display_scale() + + # -- Locale + locale = OS.get_locale() + locale_language = OS.get_locale_language() + + +## Map RenderingDevice.DeviceType int to a readable string. +## Values: 0=OTHER, 1=INTEGRATED, 2=DISCRETE, 3=VIRTUAL, 4=CPU. +static func _map_adapter_type(adapter_type: int) -> String: + match adapter_type: + 1: return "integrated" + 2: return "discrete" + 3: return "virtual" + 4: return "cpu" + _: return "other" + + +## Compute display scale for the primary screen. +## macOS reports via screen_get_scale(); all others fall back to dpi / 96.0. +func _compute_display_scale() -> float: + if platform_name == "macOS" and DisplayServer.has_method("screen_get_scale"): + var scale: Variant = DisplayServer.callv("screen_get_scale", []) + if scale is float and scale > 0.0: + return scale + return float(screen_dpi) / 96.0 + + # -- Memory API --------------------------------------------------------------- ## Refresh free/total memory readings from the OS. Call before read if freshness matters. @@ -112,7 +253,6 @@ func refresh_memory() -> void: # -- Power API ---------------------------------------------------------------- ## Classify an OS power_state integer into a human-readable string. -## Mirrors HardwareDetector.classify_power_state() for external consumers. ## 1 (ON_BATTERY) → "battery", 2/3/4 (plugged) → "plugged", 0 → "unknown". static func classify_power_state(power_state: int) -> String: match power_state: @@ -131,7 +271,7 @@ static func should_suspend_inference(power_state: int) -> bool: ## Read current OS power state integer. Returns 0 (UNKNOWN) if unavailable. ## Uses callv() to defer method resolution to runtime — avoids compile errors on builds -## where OS.get_power_info() or OS.get_power_state() are not available (e.g. headless). +## where OS.get_power_info() is not available (e.g. headless). func current_power_state() -> int: # Try Godot 4.x: OS.get_power_info() → {"power_state": int, "percent": int, ...} if OS.has_method("get_power_info"): @@ -143,11 +283,62 @@ func current_power_state() -> int: func _poll_power() -> void: - var state := current_power_state() + # Read raw state + battery percent from OS. + raw_power_state = _POWER_STATE_UNKNOWN + battery_percent = -1 + if OS.has_method("get_power_info"): + var info: Variant = OS.callv("get_power_info", []) + if info is Dictionary: + raw_power_state = int(info.get("power_state", _POWER_STATE_UNKNOWN)) + if raw_power_state == _POWER_STATE_ON_BATTERY: + battery_percent = int(info.get("percent", -1)) + var new_profile: PowerProfile = PowerProfile.BATTERY \ - if state == _POWER_STATE_ON_BATTERY \ + if raw_power_state == _POWER_STATE_ON_BATTERY \ else PowerProfile.FULL if new_profile != power_profile: var old_profile := power_profile power_profile = new_profile power_profile_changed.emit(int(old_profile), int(new_profile)) + + +# -- Diagnostics -------------------------------------------------------------- + +## Returns all platform properties as a flat dictionary for inclusion in bug reports. +func get_diagnostics() -> Dictionary: + return { + # Power + "power_profile": int(power_profile), + "raw_power_state": raw_power_state, + "battery_percent": battery_percent, + # Memory + "free_memory_mb": free_memory_mb, + "total_memory_mb": total_memory_mb, + # CPU + "cpu_name": cpu_name, + "cpu_logical_cores": cpu_logical_cores, + # GPU + "gpu_name": gpu_name, + "gpu_vendor": gpu_vendor, + "gpu_api_version": gpu_api_version, + "gpu_type": gpu_type, + "gpu_driver_info": Array(gpu_driver_info), + # Platform + "platform_name": platform_name, + "os_version": os_version, + "distribution": distribution, + "is_sandboxed": is_sandboxed, + # Display + "screen_count": screen_count, + "screen_size": {"x": screen_size.x, "y": screen_size.y}, + "screen_dpi": screen_dpi, + "screen_refresh_rate": screen_refresh_rate, + "display_scale": display_scale, + # Locale + "locale": locale, + "locale_language": locale_language, + # Paths + "user_data_dir": user_data_dir, + "install_dir": install_dir, + "cache_dir": cache_dir, + } From 41d16d01aefcc78e0ce9bde4108c75f7c3a6f5ea Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 13 Mar 2026 10:49:06 +0100 Subject: [PATCH 04/11] =?UTF-8?q?fix(ui):=20toggle=20stays=20clickable=20d?= =?UTF-8?q?uring=20battery=20suspend=20=E2=80=94=20D-138=20player=20autono?= =?UTF-8?q?my?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Player autonomy wins: AI Dialogue toggle is only greyed out for RAM fail (< 1.6 GB free). Battery suspend shows a warning label instead of disabling the toggle — player can override the auto-suspend at any time. Changes: - settings_dialog: toggle disabled = hw_status == "fail" only - settings_dialog: add _ai_battery_warning_label (STATUS_YELLOW) shown when _ai_inference_suspended; hidden when plugged back in via set_ai_inference_suspended() - settings_dialog: is_ai_dialogue_toggle_enabled() no longer checks _ai_inference_suspended - settings_dialog: set_ai_inference_suspended() updates warning label visibility, not toggle disabled state - ui-strings: add settings.ai_battery_warning "High battery usage" Co-Authored-By: Claude Sonnet 4.6 --- client/data/ui-strings.yaml | 1 + client/ui/settings_dialog.gd | 27 ++++++++++++++++++--------- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/client/data/ui-strings.yaml b/client/data/ui-strings.yaml index 492700c0f..425a896e7 100644 --- a/client/data/ui-strings.yaml +++ b/client/data/ui-strings.yaml @@ -199,6 +199,7 @@ settings: 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." + ai_battery_warning: "High battery usage" # ============================================================ # CHARACTER SELECTION (if applicable in v0.1) diff --git a/client/ui/settings_dialog.gd b/client/ui/settings_dialog.gd index a9fd7bc27..0f48940b7 100644 --- a/client/ui/settings_dialog.gd +++ b/client/ui/settings_dialog.gd @@ -37,6 +37,7 @@ var _container: VBoxContainer = null 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 signal closed signal debug_console_toggled(enabled: bool) # #581: debug console enabled/disabled @@ -214,7 +215,7 @@ func _build_ui() -> void: _ai_check_node = CheckButton.new() _ai_check_node.button_pressed = GameState.ai_enhanced_dialogue_enabled - _ai_check_node.disabled = (_ai_hw_status == "fail") or _ai_inference_suspended + _ai_check_node.disabled = (_ai_hw_status == "fail") ai_hbox.add_child(_ai_check_node) # Status message label — only shown when non-empty @@ -235,6 +236,14 @@ func _build_ui() -> void: 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) @@ -271,7 +280,8 @@ func _destroy_ui() -> void: if _container: _container.queue_free() _container = null - _ai_check_node = null # freed with _container + _ai_check_node = null # freed with _container + _ai_battery_warning_label = null # -- #646: AI Dialogue testable API ------------------------------------------- @@ -293,9 +303,9 @@ func set_ai_dialogue_hardware_status(status: String) -> void: ## Returns true if the AI-Enhanced Dialogue toggle is currently enabled (not greyed out). -## Disabled by RAM "fail" OR by active battery suspension (D-138 §8 Layer 3). +## 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" and not _ai_inference_suspended + return _ai_hw_status != "fail" ## Returns true if AI inference is currently auto-suspended due to battery power (D-138 §8 Layer 3). @@ -305,14 +315,13 @@ func is_ai_inference_suspended() -> bool: ## 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 UI reacts correctly. +## Tests can inject suspended=true to verify the warning label appears. func set_ai_inference_suspended(suspended: bool) -> void: _ai_inference_suspended = suspended - # If the dialog is currently open and built, update the status label to reflect suspension. - # The next open() call will rebuild from scratch with the correct state anyway. - if _ai_check_node != null: - _ai_check_node.disabled = suspended or (_ai_hw_status == "fail") + if _ai_battery_warning_label != null: + _ai_battery_warning_label.visible = suspended ## Persist the AI Dialogue enabled state to the local prefs file. From 73b4f37de58bc3b429ca0db64d68d37ff04917d4 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 13 Mar 2026 10:50:05 +0100 Subject: [PATCH 05/11] fix(ui): include all 7 path properties in PlatformInfo.get_diagnostics() config_dir, benchmark_cache_path, executable_path, model_dir were populated in _init_paths() but missing from the diagnostics dict. Co-Authored-By: Claude Sonnet 4.6 --- client/scripts/autoloads/platform_info.gd | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/client/scripts/autoloads/platform_info.gd b/client/scripts/autoloads/platform_info.gd index 267f3a6f2..3044ffbef 100644 --- a/client/scripts/autoloads/platform_info.gd +++ b/client/scripts/autoloads/platform_info.gd @@ -338,7 +338,11 @@ func get_diagnostics() -> Dictionary: "locale": locale, "locale_language": locale_language, # Paths - "user_data_dir": user_data_dir, - "install_dir": install_dir, - "cache_dir": cache_dir, + "user_data_dir": user_data_dir, + "config_dir": config_dir, + "benchmark_cache_path": benchmark_cache_path, + "install_dir": install_dir, + "executable_path": executable_path, + "cache_dir": cache_dir, + "model_dir": model_dir, } From 9108e5d9ed8f2bc12b66e87392ac1e5061e2231f Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 13 Mar 2026 10:54:54 +0100 Subject: [PATCH 06/11] feat(ui): add get_battery_warning_visible() getter to settings_dialog Testable API for Hoshe's battery warning visibility assertions (#646). Co-Authored-By: Claude Sonnet 4.6 --- client/ui/settings_dialog.gd | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/client/ui/settings_dialog.gd b/client/ui/settings_dialog.gd index 0f48940b7..b86889f4d 100644 --- a/client/ui/settings_dialog.gd +++ b/client/ui/settings_dialog.gd @@ -314,6 +314,13 @@ 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). From 623e2e2f166c6d4cc6c2bd0c057819499d7462c1 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 13 Mar 2026 10:55:34 +0100 Subject: [PATCH 07/11] docs(decisions): D-141 PlatformInfo OS abstraction, resolve Q-059 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D-141: client-side PlatformInfo autoload centralizing all OS queries. Q-059: resolved — full interface scope (23 properties, 7 categories). Co-Authored-By: Claude Opus 4.6 --- decisions/architecture.md | 10 +++++++++- decisions/questions-architecture.md | 11 ++++++++++- decisions/questions.md | 6 +++--- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/decisions/architecture.md b/decisions/architecture.md index 786011eba..8daf07591 100644 --- a/decisions/architecture.md +++ b/decisions/architecture.md @@ -482,6 +482,14 @@ Technical foundation decisions that constrain implementation: engine, client-ser - **Dissent:** None - **Cross-reference:** [D-114](scope.md#d-114-v02-proof-of-life--generator--graphics-not-hand-built-slice) (generator proof-of-life), [D-119](scope.md#d-119-generator-spike-confirmed-for-sprint-25--critical-path) (Sprint 25 generator spike) +### D-141: PlatformInfo — client-side OS abstraction autoload +- **Date:** 2026-03-13 +- **Decision:** All OS-dependent queries on the client are centralized in a single `PlatformInfo` autoload (`client/scripts/autoloads/platform_info.gd`), registered first in the autoload order. Individual systems (HardwareDetector, voice pipeline, settings UI) consume PlatformInfo properties and signals — they never call `OS.*` directly. PlatformInfo owns: power state (with `PowerProfile` enum: FULL, BATTERY, POWER_SAVER), memory queries, platform identity, and platform-dependent file paths. Power state is polled on a 30-second timer with a `power_profile_changed` signal; memory is refreshed on demand. The `PowerProfile` enum is the abstraction seam for future power-saver detection (GDExtension) without consumer code changes. PlatformInfo is **client-side only** — the client never relies on the server for hardware info, because the server may not be on the same hardware in multiplayer/remote hosting scenarios. Each side detects independently. +- **Rationale:** OS calls were scattered across HardwareDetector, AiDialogueDetector (duplicate), and SimBridge. A central abstraction prevents duplication, provides a single seam for platform-specific behavior, and keeps the client self-sufficient per D-010 (information boundaries) and future multiplayer readiness. +- **Raised by:** Team Leader (Jeroen) +- **Dissent:** Centralized server detection was considered and rejected — server may not share hardware with client in future multiplayer scenarios. +- **Cross-reference:** [D-138](content.md#d-138-llm-re-voicing-pipeline-for-npc-voice) (hardware detection for voice pipeline), [D-010](#d-010) (information boundaries), [Q-059](questions-architecture.md#q-059-platforminfo-full-interface-scope) (full interface scope — open) + --- -*38 decisions. Last updated: 2026-03-05 (D-133–D-137 added — Where's the Fun? Workshop)* +*39 decisions. Last updated: 2026-03-13 (D-141 added — PlatformInfo OS abstraction)* diff --git a/decisions/questions-architecture.md b/decisions/questions-architecture.md index 42b6e8aa9..cd78833a6 100644 --- a/decisions/questions-architecture.md +++ b/decisions/questions-architecture.md @@ -83,6 +83,15 @@ Technical foundation questions: engine, protocols, data structures, performance, - **Source:** Generator Architecture Workshop (#562) - **Assigned to:** Tyre + Miri +### Q-059: PlatformInfo full interface scope +- **Status:** Resolved → [D-141](architecture.md#d-141-platforminfo--client-side-os-abstraction-autoload) +- **Question:** What properties should `PlatformInfo` expose beyond power state, memory, and file paths? +- **Resolution:** 23 properties across 7 categories (power, memory, CPU, GPU, platform identity, display, locale), 1 signal (`power_profile_changed`), 2 methods (`refresh_memory`, `get_diagnostics`). Researched Unity SystemInfo, Unreal FPlatformMisc, SDL3. Skip: GPU VRAM (not available in Godot), CPU frequency, audio devices (AudioManager owns that), network connectivity (single-player), VM detection. Add properties only when a ticket needs them — no stubs. `get_diagnostics()` returns flat Dictionary for bug reports. +- **Date raised:** 2026-03-13 +- **Date resolved:** 2026-03-13 +- **Assigned to:** Tyre +- **Source:** Sprint 26 client work (#646, #659) + --- -*12 questions (6 resolved, 1 partially resolved, 5 open). Last updated: 2026-02-28.* +*13 questions (7 resolved, 1 partially resolved, 5 open). Last updated: 2026-03-13.* diff --git a/decisions/questions.md b/decisions/questions.md index 9ef9f9349..f83da1e4b 100644 --- a/decisions/questions.md +++ b/decisions/questions.md @@ -6,7 +6,7 @@ Tracked questions awaiting discussion or resolution. Split by domain, mirroring | File | Domain | Questions | |------|--------|-----------| -| [questions-architecture.md](questions-architecture.md) | Technical foundation | Q-001, Q-006, Q-009, Q-018, Q-019, Q-020, Q-021, Q-022, Q-023, Q-029, Q-030, Q-046 | +| [questions-architecture.md](questions-architecture.md) | Technical foundation | Q-001, Q-006, Q-009, Q-018, Q-019, Q-020, Q-021, Q-022, Q-023, Q-029, Q-030, Q-046, Q-059 | | [questions-perception.md](questions-perception.md) | Player observation | Q-003, Q-014, Q-016, Q-024, Q-025, Q-026, Q-051, Q-053, Q-054 | | [questions-content.md](questions-content.md) | Narrative, NPCs, setting | Q-010, Q-012, Q-013, Q-015, Q-017, Q-028, Q-031, Q-033, Q-040, Q-041, Q-042, Q-043, Q-044, Q-045, Q-047, Q-048, Q-049, Q-050, Q-052, Q-056, Q-057 | | [questions-scope.md](questions-scope.md) | Game concept, prototype | Q-002, Q-004, Q-005, Q-007, Q-008, Q-011, Q-027, Q-032, Q-034, Q-035, Q-036, Q-037, Q-038, Q-039, Q-058 | @@ -15,11 +15,11 @@ Tracked questions awaiting discussion or resolution. Split by domain, mirroring | Domain | Total | Resolved | Partial | Open | |--------|-------|----------|---------|------| -| Architecture | 12 | 6 | 1 | 5 | +| Architecture | 13 | 7 | 1 | 5 | | Perception | 9 | 5 | 1 | 3 | | Content | 21 | 5 | 2 | 14 | | Scope | 14 | 1 | 3 | 10 | -| **Total** | **56** | **17** | **7** | **32** | +| **Total** | **57** | **18** | **7** | **32** | *Updated 2026-03-05: Q-011 resolved (D-117/D-115/D-122), Q-034 partially resolved (D-117), Q-037 partially resolved (D-119), Q-033 partially resolved/reframed (D-122) — Where's the Fun? Workshop* From d3ed73bcb39b95b579e0bc234e8ead53dfb7e2c2 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 13 Mar 2026 10:55:44 +0100 Subject: [PATCH 08/11] test(client): Sprint 26 AI dialogue + PlatformInfo tests 67 tests covering #646 AI-Enhanced Dialogue toggle, hardware detection (RAM/TPT/degradation), PlatformInfo power state, battery suspend/resume, and settings dialog integration. Co-Authored-By: Claude Opus 4.6 --- client/tests/test_ai_dialogue_sprint26.gd | 894 ++++++++++++++++++++++ 1 file changed, 894 insertions(+) create mode 100644 client/tests/test_ai_dialogue_sprint26.gd diff --git a/client/tests/test_ai_dialogue_sprint26.gd b/client/tests/test_ai_dialogue_sprint26.gd new file mode 100644 index 000000000..984f20574 --- /dev/null +++ b/client/tests/test_ai_dialogue_sprint26.gd @@ -0,0 +1,894 @@ +## Sprint 26 — AI-Enhanced Dialogue toggle + hardware detection (#646) +## +## Test-first: written before Stig's implementation. All tests referencing +## HardwareDetector or unimplemented settings_dialog methods will be skipped +## via push_warning() until the implementation lands. +## +## Spec: D-138 (LLM re-voicing pipeline — hardware detection requirement) +## Workshop: docs/workshops/llm-voice-pipeline/workshop-outcomes.md §8 +## Ticket: #646 +class_name TestAiDialogueSprint26 +extends GdUnitTestSuite + + +var _original_ai_enabled: bool = true + + +func before_test() -> void: + _original_ai_enabled = GameState.get("ai_enhanced_dialogue_enabled") if \ + "ai_enhanced_dialogue_enabled" in GameState else true + + +func after_test() -> void: + if "ai_enhanced_dialogue_enabled" in GameState: + GameState.set("ai_enhanced_dialogue_enabled", _original_ai_enabled) + + +func _get_detector() -> Object: + var node := get_node_or_null("/root/HardwareDetector") + if node == null: + push_warning("TestAiDialogueSprint26: HardwareDetector autoload not found — test skipped (awaiting #646)") + return node + + +# -- GameState field ---------------------------------------------------------- + +func test_game_state_has_ai_enhanced_dialogue_field() -> void: + # D-138: toggle state must live in GameState so all subsystems can read it. + assert_bool("ai_enhanced_dialogue_enabled" in GameState).override_failure_message( + "GameState must have ai_enhanced_dialogue_enabled field (#646)" + ).is_true() + + +func test_game_state_ai_enhanced_dialogue_default_is_true() -> void: + # D-138: feature on by default — player can opt out, not opt in. + var enabled: Variant = GameState.get("ai_enhanced_dialogue_enabled") + assert_bool(enabled).override_failure_message( + "GameState.ai_enhanced_dialogue_enabled must default to true (D-138)" + ).is_true() + + +func test_game_state_ai_dialogue_toggle_can_be_set_false() -> void: + # Toggle must be writable — settings dialog needs to persist changes. + GameState.set("ai_enhanced_dialogue_enabled", false) + assert_bool(GameState.ai_enhanced_dialogue_enabled).override_failure_message( + "GameState.ai_enhanced_dialogue_enabled must be settable to false" + ).is_false() + + +func test_game_state_ai_dialogue_toggle_can_be_set_true() -> void: + # Re-enable after disable — full round-trip. + GameState.set("ai_enhanced_dialogue_enabled", false) + GameState.set("ai_enhanced_dialogue_enabled", true) + assert_bool(GameState.ai_enhanced_dialogue_enabled).override_failure_message( + "GameState.ai_enhanced_dialogue_enabled must be re-settable to true" + ).is_true() + + +# -- InputMapper.Action enum -------------------------------------------------- + +func test_input_mapper_has_change_settings_action() -> void: + # ChangeSettings dispatches through the existing PlayerInput pipeline. + assert_bool("CHANGE_SETTINGS" in InputMapper.Action).override_failure_message( + "InputMapper.Action must include CHANGE_SETTINGS variant (#646)" + ).is_true() + + +# -- Wire protocol (sim_bridge → server) -------------------------------------- + +func test_sim_bridge_maps_change_settings_to_wire_name() -> void: + # sim_bridge must not silently drop the action — empty string = dropped. + if not "CHANGE_SETTINGS" in InputMapper.Action: + push_warning("TestAiDialogueSprint26: CHANGE_SETTINGS action not found — test skipped") + return + var wire_name: String = SimBridge.action_enum_to_wire(InputMapper.Action.CHANGE_SETTINGS) + assert_str(wire_name).override_failure_message( + "CHANGE_SETTINGS must map to 'ChangeSettings' wire name" + ).is_equal("ChangeSettings") + + +func test_protocol_encode_change_settings_produces_valid_bytes() -> void: + # Protocol.encode_change_settings() is the helper for testability (mirrors encode_startup_message). + var bytes: PackedByteArray = Protocol.encode_change_settings(true) + assert_bool(bytes.size() > 0).override_failure_message( + "Protocol.encode_change_settings(true) must produce non-empty bytes" + ).is_true() + + +func test_protocol_change_settings_wire_has_action_name_key() -> void: + # Wire payload is Vec — each input map must have "action_name". + var bytes: PackedByteArray = Protocol.encode_change_settings(true) + var decoded = Messagepack.decode(bytes) + assert_that(decoded.status).is_null() + var inputs: Array = decoded.value + assert_bool(inputs.size() > 0).override_failure_message( + "ChangeSettings wire payload must be a non-empty array of PlayerInputs" + ).is_true() + assert_str(inputs[0].get("action_name", "")).override_failure_message( + "PlayerInput.action_name must be 'ChangeSettings'" + ).is_equal("ChangeSettings") + + +func test_protocol_change_settings_wire_enabled_true() -> void: + # action_data.ai_enhanced_dialogue = true when enabling. + var bytes: PackedByteArray = Protocol.encode_change_settings(true) + var decoded = Messagepack.decode(bytes) + assert_that(decoded.status).is_null() + var inputs: Array = decoded.value + var action_data: Dictionary = inputs[0].get("action_data", {}) + assert_bool(action_data.get("ai_enhanced_dialogue", false)).override_failure_message( + "ChangeSettings action_data.ai_enhanced_dialogue must be true when enabling" + ).is_true() + + +func test_protocol_change_settings_wire_enabled_false() -> void: + # action_data.ai_enhanced_dialogue = false when disabling. + var bytes: PackedByteArray = Protocol.encode_change_settings(false) + var decoded = Messagepack.decode(bytes) + assert_that(decoded.status).is_null() + var inputs: Array = decoded.value + var action_data: Dictionary = inputs[0].get("action_data", {}) + assert_bool(action_data.get("ai_enhanced_dialogue", true)).override_failure_message( + "ChangeSettings action_data.ai_enhanced_dialogue must be false when disabling" + ).is_false() + + +# -- HardwareDetector constants (D-138 §8) ------------------------------------ + +func test_hardware_detector_ram_pass_threshold_is_2000mb() -> void: + # D-138 §8 Layer 1: ≥ 2.0 GB free RAM → pass. + var det := _get_detector() + if det == null: + return + assert_float(float(det.RAM_PASS_THRESHOLD_MB)).override_failure_message( + "RAM_PASS_THRESHOLD_MB must be 2000.0 (D-138 §8)" + ).is_equal(2000.0) + + +func test_hardware_detector_ram_marginal_threshold_is_1600mb() -> void: + # D-138 §8 Layer 1: 1.6–2.0 GB → marginal (warn, let player proceed). + var det := _get_detector() + if det == null: + return + assert_float(float(det.RAM_MARGINAL_THRESHOLD_MB)).override_failure_message( + "RAM_MARGINAL_THRESHOLD_MB must be 1600.0 (D-138 §8)" + ).is_equal(1600.0) + + +func test_hardware_detector_tpt_green_threshold_is_6() -> void: + # D-138 §8 Layer 2: ≥ 6 t/s → green (enable silently). + var det := _get_detector() + if det == null: + return + assert_float(float(det.TPT_GREEN_THRESHOLD)).override_failure_message( + "TPT_GREEN_THRESHOLD must be 6.0 (D-138 §8)" + ).is_equal(6.0) + + +func test_hardware_detector_tpt_yellow_threshold_is_3() -> void: + # D-138 §8 Layer 2: 3–6 t/s → yellow (partial pre-voicing message). + var det := _get_detector() + if det == null: + return + assert_float(float(det.TPT_YELLOW_THRESHOLD)).override_failure_message( + "TPT_YELLOW_THRESHOLD must be 3.0 (D-138 §8)" + ).is_equal(3.0) + + +func test_hardware_detector_degradation_threshold_is_0_4() -> void: + # D-138 §8 Layer 3: sustained >40% TPT drop from baseline → yellow status. + var det := _get_detector() + if det == null: + return + assert_float(float(det.TPT_DEGRADATION_THRESHOLD)).override_failure_message( + "TPT_DEGRADATION_THRESHOLD must be 0.4 (D-138 §8 Layer 3)" + ).is_equal(0.4) + + +# -- Layer 1: classify_ram() -------------------------------------------------- + +func test_hardware_classify_ram_pass_above_2gb() -> void: + var det := _get_detector() + if det == null: + return + assert_str(det.classify_ram(2048.0)).override_failure_message( + "classify_ram(2048) must return 'pass' (D-138 §8)" + ).is_equal("pass") + + +func test_hardware_classify_ram_pass_at_exact_boundary() -> void: + # Exactly 2000 MB is a pass (≥ 2.0 GB). + var det := _get_detector() + if det == null: + return + assert_str(det.classify_ram(2000.0)).override_failure_message( + "classify_ram(2000) must return 'pass' — exact boundary" + ).is_equal("pass") + + +func test_hardware_classify_ram_marginal_between_1600_and_2000() -> void: + var det := _get_detector() + if det == null: + return + assert_str(det.classify_ram(1800.0)).override_failure_message( + "classify_ram(1800) must return 'marginal' (1.6–2.0 GB range)" + ).is_equal("marginal") + + +func test_hardware_classify_ram_marginal_at_lower_boundary() -> void: + # Exactly 1600 MB — marginal (≥ 1.6 GB but < 2.0 GB). + var det := _get_detector() + if det == null: + return + assert_str(det.classify_ram(1600.0)).override_failure_message( + "classify_ram(1600) must return 'marginal' — exact lower boundary" + ).is_equal("marginal") + + +func test_hardware_classify_ram_fail_below_1600() -> void: + var det := _get_detector() + if det == null: + return + assert_str(det.classify_ram(1024.0)).override_failure_message( + "classify_ram(1024) must return 'fail' (< 1.6 GB)" + ).is_equal("fail") + + +func test_hardware_classify_ram_fail_just_below_marginal_threshold() -> void: + # 1599 MB — just below marginal, must be fail not marginal. + var det := _get_detector() + if det == null: + return + assert_str(det.classify_ram(1599.0)).override_failure_message( + "classify_ram(1599) must return 'fail' — 1 MB below marginal threshold" + ).is_equal("fail") + + +# -- Layer 2: classify_tpt() -------------------------------------------------- + +func test_hardware_classify_tpt_green_above_6() -> void: + var det := _get_detector() + if det == null: + return + assert_str(det.classify_tpt(8.0)).override_failure_message( + "classify_tpt(8.0) must return 'green' (D-138 §8)" + ).is_equal("green") + + +func test_hardware_classify_tpt_green_at_exact_threshold() -> void: + # Exactly 6 t/s → green. + var det := _get_detector() + if det == null: + return + assert_str(det.classify_tpt(6.0)).override_failure_message( + "classify_tpt(6.0) must return 'green' — exact boundary" + ).is_equal("green") + + +func test_hardware_classify_tpt_yellow_between_3_and_6() -> void: + var det := _get_detector() + if det == null: + return + assert_str(det.classify_tpt(4.5)).override_failure_message( + "classify_tpt(4.5) must return 'yellow' (3–6 t/s range)" + ).is_equal("yellow") + + +func test_hardware_classify_tpt_yellow_at_lower_boundary() -> void: + # Exactly 3 t/s → yellow (≥ 3 t/s but < 6 t/s). + var det := _get_detector() + if det == null: + return + assert_str(det.classify_tpt(3.0)).override_failure_message( + "classify_tpt(3.0) must return 'yellow' — exact lower boundary" + ).is_equal("yellow") + + +func test_hardware_classify_tpt_red_below_3() -> void: + var det := _get_detector() + if det == null: + return + assert_str(det.classify_tpt(1.5)).override_failure_message( + "classify_tpt(1.5) must return 'red' (< 3 t/s)" + ).is_equal("red") + + +func test_hardware_classify_tpt_red_just_below_yellow_threshold() -> void: + # 2.9 t/s — just below yellow. + var det := _get_detector() + if det == null: + return + assert_str(det.classify_tpt(2.9)).override_failure_message( + "classify_tpt(2.9) must return 'red' — just below yellow threshold" + ).is_equal("red") + + +# -- Layer 3: classify_degradation() ----------------------------------------- + +func test_hardware_classify_degradation_ok_at_25_percent() -> void: + # 25% slower than baseline → ok (threshold is >40%). + var det := _get_detector() + if det == null: + return + var baseline: float = 8.0 + var current_avg: float = baseline * 0.75 + assert_str(det.classify_degradation(baseline, current_avg)).override_failure_message( + "25%% degradation must return 'ok' (threshold is >40%%)" + ).is_equal("ok") + + +func test_hardware_classify_degradation_yellow_at_55_percent() -> void: + # 55% slower — thermal throttling triggers yellow notification. + var det := _get_detector() + if det == null: + return + var baseline: float = 8.0 + var current_avg: float = baseline * 0.45 + assert_str(det.classify_degradation(baseline, current_avg)).override_failure_message( + "55%% degradation must return 'yellow' (D-138 §8 Layer 3)" + ).is_equal("yellow") + + +func test_hardware_classify_degradation_ok_at_exact_40_percent() -> void: + # Spec says ">40%" → at exactly 40% the result is still "ok". + var det := _get_detector() + if det == null: + return + var baseline: float = 10.0 + var current_avg: float = 6.0 # exactly 40% slower + assert_str(det.classify_degradation(baseline, current_avg)).override_failure_message( + "Exactly 40%% degradation must return 'ok' — spec says >40%% triggers yellow" + ).is_equal("ok") + + +# -- Settings dialog: toggle label and greyed-out state ----------------------- + +func test_settings_dialog_exposes_ai_dialogue_label_text_method() -> void: + # settings_dialog needs a testable API — hardcoded UI strings are easy to drift. + var scene := load("res://ui/settings_dialog.tscn") as PackedScene + if scene == null: + push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped") + return + var dialog := scene.instantiate() + auto_free(dialog) + add_child(dialog) + assert_bool(dialog.has_method("get_ai_dialogue_label_text")).override_failure_message( + "settings_dialog must expose get_ai_dialogue_label_text() for label drift detection" + ).is_true() + + +func test_settings_dialog_ai_dialogue_label_is_correct() -> void: + # D-138: label must be exactly "AI-Enhanced Dialogue" (Jeroen's wording). + var scene := load("res://ui/settings_dialog.tscn") as PackedScene + if scene == null: + push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped") + return + var dialog := scene.instantiate() + auto_free(dialog) + add_child(dialog) + if not dialog.has_method("get_ai_dialogue_label_text"): + push_warning("TestAiDialogueSprint26: get_ai_dialogue_label_text() not implemented — skipped") + return + assert_str(dialog.get_ai_dialogue_label_text()).override_failure_message( + "AI-Enhanced Dialogue toggle label must be exactly 'AI-Enhanced Dialogue' (D-138)" + ).is_equal("AI-Enhanced Dialogue") + + +func test_settings_dialog_toggle_disabled_when_hardware_fails() -> void: + # D-138 §8: RAM < 1.6 GB → feature disabled, toggle greyed out. + # Player receives message but cannot enable the feature. + var scene := load("res://ui/settings_dialog.tscn") as PackedScene + if scene == null: + push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped") + return + var dialog := scene.instantiate() + auto_free(dialog) + add_child(dialog) + if not dialog.has_method("set_ai_dialogue_hardware_status") \ + or not dialog.has_method("is_ai_dialogue_toggle_enabled"): + push_warning("TestAiDialogueSprint26: hardware status API not implemented — skipped") + return + dialog.set_ai_dialogue_hardware_status("fail") + assert_bool(dialog.is_ai_dialogue_toggle_enabled()).override_failure_message( + "Toggle must be disabled (greyed out) when hardware status is 'fail' (D-138 §8)" + ).is_false() + + +func test_settings_dialog_toggle_enabled_when_hardware_passes() -> void: + # "pass" → toggle available to interact with. + var scene := load("res://ui/settings_dialog.tscn") as PackedScene + if scene == null: + push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped") + return + var dialog := scene.instantiate() + auto_free(dialog) + add_child(dialog) + if not dialog.has_method("set_ai_dialogue_hardware_status") \ + or not dialog.has_method("is_ai_dialogue_toggle_enabled"): + push_warning("TestAiDialogueSprint26: hardware status API not implemented — skipped") + return + dialog.set_ai_dialogue_hardware_status("pass") + assert_bool(dialog.is_ai_dialogue_toggle_enabled()).override_failure_message( + "Toggle must be enabled when hardware status is 'pass'" + ).is_true() + + +func test_settings_dialog_toggle_enabled_when_hardware_marginal() -> void: + # D-138 §8: "marginal" → warn but let player proceed. Never force-disable. + var scene := load("res://ui/settings_dialog.tscn") as PackedScene + if scene == null: + push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped") + return + var dialog := scene.instantiate() + auto_free(dialog) + add_child(dialog) + if not dialog.has_method("set_ai_dialogue_hardware_status") \ + or not dialog.has_method("is_ai_dialogue_toggle_enabled"): + push_warning("TestAiDialogueSprint26: hardware status API not implemented — skipped") + return + dialog.set_ai_dialogue_hardware_status("marginal") + assert_bool(dialog.is_ai_dialogue_toggle_enabled()).override_failure_message( + "Toggle must remain enabled when status is 'marginal' — player can always override (D-138)" + ).is_true() + + +# -- Benchmark cache path (D-138 §8 Layer 2) ---------------------------------- + +func test_hardware_detector_benchmark_cache_path_is_correct() -> void: + # D-138 §8 Layer 2: cached in {user_data}/ai-dialogue-config.json. + var det := _get_detector() + if det == null: + return + assert_str(det.BENCHMARK_CACHE_PATH).override_failure_message( + "BENCHMARK_CACHE_PATH must be 'user://ai-dialogue-config.json' (D-138 §8)" + ).is_equal("user://ai-dialogue-config.json") + + +# -- Startup pref loading (regression guard) ---------------------------------- + +func test_hardware_detector_has_load_ai_pref_method_or_game_state_loads_on_startup() -> void: + # REGRESSION: Player preference must survive session restarts. + # Either HardwareDetector exposes load_ai_pref() so _ready() can restore + # GameState.ai_enhanced_dialogue_enabled, or another autoload must do it. + # This test fails until the startup-load path is implemented. + # Spec: D-138 §8 toggle persists via #627 (SQLite) + ConfigFile (client-local). + var det := _get_detector() + if det == null: + return + assert_bool(det.has_method("load_ai_pref")).override_failure_message( + "HardwareDetector must expose load_ai_pref() — called at startup to restore " + + "GameState.ai_enhanced_dialogue_enabled from user://settings.cfg. " + + "Without this, the toggle resets to true on every session restart." + ).is_true() + + +# ============================================================================= +# PlatformInfo (D-141) — power profile, constants, classification +# ============================================================================= +# +# Power state integer mapping (Godot 4, matches OS.POWERSTATE_* values): +# 0 = UNKNOWN, 1 = ON_BATTERY, 2 = NO_BATTERY, 3 = CHARGING, 4 = CHARGED +# +# Integer literals are used throughout — OS.POWERSTATE_* don't exist in this build. +# PlatformInfo._POWER_STATE_* are private; PlatformInfo.PowerProfile enum is public. + + +func _get_platform_info() -> Node: + var node := get_node_or_null("/root/PlatformInfo") + if node == null: + push_warning("TestAiDialogueSprint26: PlatformInfo autoload not found — test skipped (awaiting #659)") + return node + + +# -- PowerProfile enum (D-141) ------------------------------------------------ + +func test_platform_info_power_profile_full_is_0() -> void: + var pi := _get_platform_info() + if pi == null: + return + assert_int(pi.PowerProfile.FULL).override_failure_message( + "PowerProfile.FULL must be 0 (D-141)" + ).is_equal(0) + + +func test_platform_info_power_profile_battery_is_1() -> void: + var pi := _get_platform_info() + if pi == null: + return + assert_int(pi.PowerProfile.BATTERY).override_failure_message( + "PowerProfile.BATTERY must be 1 (D-141)" + ).is_equal(1) + + +func test_platform_info_power_profile_power_saver_is_2() -> void: + # POWER_SAVER = 2 is reserved for future OS API — no cross-platform detection yet. + var pi := _get_platform_info() + if pi == null: + return + assert_int(pi.PowerProfile.POWER_SAVER).override_failure_message( + "PowerProfile.POWER_SAVER must be 2 (D-141 — reserved for future API)" + ).is_equal(2) + + +func test_platform_info_power_poll_interval_is_30_seconds() -> void: + # 30-second poll timer — balances responsiveness vs CPU cost (D-141). + var pi := _get_platform_info() + if pi == null: + return + assert_float(float(pi.POWER_POLL_INTERVAL)).override_failure_message( + "POWER_POLL_INTERVAL must be 30.0 seconds (D-141)" + ).is_equal(30.0) + + +# -- classify_power_state() (D-141 / D-138 §8) -------------------------------- + +func test_platform_info_classify_on_battery_returns_battery() -> void: + # 1 = OS ON_BATTERY → "battery" — this is the suspend trigger. + var pi := _get_platform_info() + if pi == null: + return + assert_str(pi.classify_power_state(1)).override_failure_message( + "classify_power_state(1 = ON_BATTERY) must return 'battery'" + ).is_equal("battery") + + +func test_platform_info_classify_no_battery_returns_plugged() -> void: + # 2 = NO_BATTERY (desktop) → "plugged" — never suspend on a desktop. + var pi := _get_platform_info() + if pi == null: + return + assert_str(pi.classify_power_state(2)).override_failure_message( + "classify_power_state(2 = NO_BATTERY) must return 'plugged'" + ).is_equal("plugged") + + +func test_platform_info_classify_charging_returns_plugged() -> void: + var pi := _get_platform_info() + if pi == null: + return + assert_str(pi.classify_power_state(3)).override_failure_message( + "classify_power_state(3 = CHARGING) must return 'plugged'" + ).is_equal("plugged") + + +func test_platform_info_classify_charged_returns_plugged() -> void: + var pi := _get_platform_info() + if pi == null: + return + assert_str(pi.classify_power_state(4)).override_failure_message( + "classify_power_state(4 = CHARGED) must return 'plugged'" + ).is_equal("plugged") + + +func test_platform_info_classify_unknown_returns_unknown() -> void: + # 0 = UNKNOWN → do not suspend. Erring toward inference on ambiguous state. + # Also the default Godot returns on platforms without power API support. + var pi := _get_platform_info() + if pi == null: + return + assert_str(pi.classify_power_state(0)).override_failure_message( + "classify_power_state(0 = UNKNOWN) must return 'unknown' — do not suspend on ambiguous state" + ).is_equal("unknown") + + +# -- should_suspend_inference() (D-141 / D-138 §8) ---------------------------- + +func test_platform_info_should_suspend_on_battery() -> void: + var pi := _get_platform_info() + if pi == null: + return + assert_bool(pi.should_suspend_inference(1)).override_failure_message( + "should_suspend_inference(1 = ON_BATTERY) must return true (D-138 §8 Layer 3)" + ).is_true() + + +func test_platform_info_should_not_suspend_on_no_battery() -> void: + var pi := _get_platform_info() + if pi == null: + return + assert_bool(pi.should_suspend_inference(2)).override_failure_message( + "should_suspend_inference(2 = NO_BATTERY) must return false — desktop" + ).is_false() + + +func test_platform_info_should_not_suspend_when_charging() -> void: + var pi := _get_platform_info() + if pi == null: + return + assert_bool(pi.should_suspend_inference(3)).override_failure_message( + "should_suspend_inference(3 = CHARGING) must return false" + ).is_false() + + +func test_platform_info_should_not_suspend_when_charged() -> void: + var pi := _get_platform_info() + if pi == null: + return + assert_bool(pi.should_suspend_inference(4)).override_failure_message( + "should_suspend_inference(4 = CHARGED) must return false" + ).is_false() + + +func test_platform_info_should_not_suspend_on_unknown() -> void: + # UNKNOWN → do not suspend. Prevents false-positive suspension on platforms + # where Godot's power API returns 0 unconditionally (e.g. some headless builds). + var pi := _get_platform_info() + if pi == null: + return + assert_bool(pi.should_suspend_inference(0)).override_failure_message( + "should_suspend_inference(0 = UNKNOWN) must return false — do not suspend on unknown state" + ).is_false() + + +# ============================================================================= +# HardwareDetector — power state delegation + battery suspend/resume +# ============================================================================= + + +func test_hardware_detector_classify_power_state_delegates_to_platform_info() -> void: + # HardwareDetector.classify_power_state() must agree with PlatformInfo (D-141). + var det := _get_detector() + if det == null: + return + assert_str(det.classify_power_state(1)).override_failure_message( + "HardwareDetector.classify_power_state(1) must return 'battery' (delegates to PlatformInfo)" + ).is_equal("battery") + assert_str(det.classify_power_state(3)).override_failure_message( + "HardwareDetector.classify_power_state(3) must return 'plugged'" + ).is_equal("plugged") + assert_str(det.classify_power_state(0)).override_failure_message( + "HardwareDetector.classify_power_state(0) must return 'unknown'" + ).is_equal("unknown") + + +func test_hardware_detector_should_suspend_delegates_to_platform_info() -> void: + var det := _get_detector() + if det == null: + return + assert_bool(det.should_suspend_inference(1)).override_failure_message( + "HardwareDetector.should_suspend_inference(1) must return true (delegates to PlatformInfo)" + ).is_true() + assert_bool(det.should_suspend_inference(2)).override_failure_message( + "HardwareDetector.should_suspend_inference(2) must return false" + ).is_false() + + +func test_hardware_detector_check_power_state_returns_required_keys() -> void: + var det := _get_detector() + if det == null: + return + var result: Dictionary = det.check_power_state() + assert_bool(result.has("power_state")).override_failure_message( + "check_power_state() must include 'power_state' key (raw int)" + ).is_true() + assert_bool(result.has("classification")).override_failure_message( + "check_power_state() must include 'classification' key" + ).is_true() + assert_bool(result.has("should_suspend")).override_failure_message( + "check_power_state() must include 'should_suspend' key" + ).is_true() + + +func test_hardware_detector_check_power_state_fields_are_consistent() -> void: + # The three fields must be internally consistent — not independently computed. + var det := _get_detector() + if det == null: + return + var result: Dictionary = det.check_power_state() + var expected_class: String = det.classify_power_state(result["power_state"]) + var expected_suspend: bool = det.should_suspend_inference(result["power_state"]) + assert_str(result["classification"]).override_failure_message( + "check_power_state().classification must match classify_power_state(power_state)" + ).is_equal(expected_class) + assert_bool(result["should_suspend"]).override_failure_message( + "check_power_state().should_suspend must match should_suspend_inference(power_state)" + ).is_equal(expected_suspend) + + +func test_hardware_detector_inference_not_suspended_by_default() -> void: + var det := _get_detector() + if det == null: + return + # Note: this reads live state — if tests run on battery hardware the result + # may reflect a real suspension. We document this as expected. + assert_bool(det.is_inference_suspended()).override_failure_message( + "is_inference_suspended() must be false when hardware is FULL/plugged (D-138 §8)" + ).is_false() + + +func test_hardware_detector_suspend_sets_inference_suspended() -> void: + # Directly trigger the signal handler to simulate battery transition. + # Uses PlatformInfo.PowerProfile enum values — no OS.POWERSTATE_* dependency. + var det := _get_detector() + if det == null: + return + var pi := _get_platform_info() + if pi == null: + return + # Ensure a clean starting state. + det._on_power_profile_changed(pi.PowerProfile.BATTERY, pi.PowerProfile.FULL) + assert_bool(det.is_inference_suspended()).is_false() + # Simulate plug-out → battery. + det._on_power_profile_changed(pi.PowerProfile.FULL, pi.PowerProfile.BATTERY) + assert_bool(det.is_inference_suspended()).override_failure_message( + "inference_suspended must be true after FULL→BATTERY transition (D-138 §8 Layer 3)" + ).is_true() + # Cleanup — restore before next test. + det._on_power_profile_changed(pi.PowerProfile.BATTERY, pi.PowerProfile.FULL) + + +func test_hardware_detector_resume_clears_inference_suspended() -> void: + # Suspend then resume — verify suspended flag is cleared on plug-in. + var det := _get_detector() + if det == null: + return + var pi := _get_platform_info() + if pi == null: + return + det._on_power_profile_changed(pi.PowerProfile.FULL, pi.PowerProfile.BATTERY) + assert_bool(det.is_inference_suspended()).is_true() + det._on_power_profile_changed(pi.PowerProfile.BATTERY, pi.PowerProfile.FULL) + assert_bool(det.is_inference_suspended()).override_failure_message( + "is_inference_suspended() must be false after BATTERY→FULL transition (resume on plug-in)" + ).is_false() + + +func test_hardware_detector_battery_suspend_preserves_player_pref_false() -> void: + # If the player had disabled AI dialogue, battery suspend must NOT re-enable it on resume. + # _pre_battery_pref is saved as false → resume does NOT send ChangeSettings(true). + # We verify this indirectly: inference_suspended goes false and GameState is unchanged. + var det := _get_detector() + if det == null: + return + var pi := _get_platform_info() + if pi == null: + return + var saved_pref: bool = GameState.ai_enhanced_dialogue_enabled + GameState.ai_enhanced_dialogue_enabled = false + det._on_power_profile_changed(pi.PowerProfile.FULL, pi.PowerProfile.BATTERY) + det._on_power_profile_changed(pi.PowerProfile.BATTERY, pi.PowerProfile.FULL) + assert_bool(det.is_inference_suspended()).is_false() + assert_bool(GameState.ai_enhanced_dialogue_enabled).override_failure_message( + "GameState.ai_enhanced_dialogue_enabled must remain false after battery resume — " + + "player's opt-out must survive a battery suspend/resume cycle" + ).is_false() + # Restore. + GameState.ai_enhanced_dialogue_enabled = saved_pref + + +# ============================================================================= +# Settings dialog — battery-suspended state display (D-138 §8 Layer 3) +# ============================================================================= + + +func test_settings_dialog_inference_suspended_state_defaults_false() -> void: + var scene := load("res://ui/settings_dialog.tscn") as PackedScene + if scene == null: + push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped") + return + var dialog := scene.instantiate() + auto_free(dialog) + add_child(dialog) + assert_bool(dialog.is_ai_inference_suspended()).override_failure_message( + "is_ai_inference_suspended() must default to false" + ).is_false() + + +func test_settings_dialog_set_inference_suspended_true() -> void: + var scene := load("res://ui/settings_dialog.tscn") as PackedScene + if scene == null: + push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped") + return + var dialog := scene.instantiate() + auto_free(dialog) + add_child(dialog) + dialog.set_ai_inference_suspended(true) + assert_bool(dialog.is_ai_inference_suspended()).override_failure_message( + "is_ai_inference_suspended() must return true after set_ai_inference_suspended(true)" + ).is_true() + + +func test_settings_dialog_resume_clears_suspended_state() -> void: + # D-138 §8: resume when plugged in — suspended state clears. + var scene := load("res://ui/settings_dialog.tscn") as PackedScene + if scene == null: + push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped") + return + var dialog := scene.instantiate() + auto_free(dialog) + add_child(dialog) + dialog.set_ai_inference_suspended(true) + dialog.set_ai_inference_suspended(false) + assert_bool(dialog.is_ai_inference_suspended()).override_failure_message( + "is_ai_inference_suspended() must return false after set_ai_inference_suspended(false)" + ).is_false() + + +func test_settings_dialog_toggle_remains_enabled_when_battery_suspended() -> void: + # D-138: player autonomy is respected at every hardware decision. + # Battery suspend auto-pauses inference but must NOT grey the toggle — + # the player can click it to override the suspension. + # Only hardware "fail" (RAM < 1.6 GB) may disable the toggle. + var scene := load("res://ui/settings_dialog.tscn") as PackedScene + if scene == null: + push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped") + return + var dialog := scene.instantiate() + auto_free(dialog) + add_child(dialog) + dialog.set_ai_dialogue_hardware_status("pass") + dialog.set_ai_inference_suspended(true) + assert_bool(dialog.is_ai_dialogue_toggle_enabled()).override_failure_message( + "Toggle must remain clickable when battery-suspended — player can override (D-138)" + ).is_true() + + +func test_settings_dialog_toggle_enabled_after_resume() -> void: + # After resume (plug-in), toggle must be enabled again. + var scene := load("res://ui/settings_dialog.tscn") as PackedScene + if scene == null: + push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped") + return + var dialog := scene.instantiate() + auto_free(dialog) + add_child(dialog) + dialog.set_ai_dialogue_hardware_status("pass") + dialog.set_ai_inference_suspended(true) + dialog.set_ai_inference_suspended(false) + assert_bool(dialog.is_ai_dialogue_toggle_enabled()).override_failure_message( + "Toggle must be re-enabled after battery resume (set_ai_inference_suspended(false))" + ).is_true() + + +func test_settings_dialog_toggle_disabled_by_hardware_fail_even_when_suspended() -> void: + # Hardware "fail" disables the toggle regardless of battery state. + # RAM < 1.6 GB is the only hard disable — battery suspend is not. + var scene := load("res://ui/settings_dialog.tscn") as PackedScene + if scene == null: + push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped") + return + var dialog := scene.instantiate() + auto_free(dialog) + add_child(dialog) + dialog.set_ai_dialogue_hardware_status("fail") + dialog.set_ai_inference_suspended(true) + assert_bool(dialog.is_ai_dialogue_toggle_enabled()).override_failure_message( + "Toggle must be disabled when hardware is 'fail' — battery suspend state is irrelevant" + ).is_false() + + +func test_settings_dialog_warning_label_shown_when_battery_suspended() -> void: + # When inference is battery-suspended, a warning label must be visible + # so the player knows why inference isn't running (even though toggle is enabled). + var scene := load("res://ui/settings_dialog.tscn") as PackedScene + if scene == null: + push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped") + return + var dialog := scene.instantiate() + auto_free(dialog) + add_child(dialog) + if not dialog.has_method("get_battery_warning_visible"): + push_warning("TestAiDialogueSprint26: get_battery_warning_visible() not yet implemented — skipped") + return + dialog.set_ai_inference_suspended(true) + assert_bool(dialog.get_battery_warning_visible()).override_failure_message( + "Battery warning label must be visible when inference is suspended (toggle is on but paused)" + ).is_true() + + +func test_settings_dialog_warning_label_hidden_when_not_suspended() -> void: + # Warning label must not show when plugged in — no battery message needed. + var scene := load("res://ui/settings_dialog.tscn") as PackedScene + if scene == null: + push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped") + return + var dialog := scene.instantiate() + auto_free(dialog) + add_child(dialog) + if not dialog.has_method("get_battery_warning_visible"): + push_warning("TestAiDialogueSprint26: get_battery_warning_visible() not yet implemented — skipped") + return + dialog.set_ai_inference_suspended(false) + assert_bool(dialog.get_battery_warning_visible()).override_failure_message( + "Battery warning label must be hidden when inference is not suspended" + ).is_false() From 23e6890c4e2fec8c4216a7ad72b85b540f1814e2 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 13 Mar 2026 10:56:08 +0100 Subject: [PATCH 09/11] chore(meta): update changelog Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6aa32af5f..14e8a9130 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,9 +12,12 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). - SQLite settings storage — per-player persistent settings via rusqlite (bundled), IPC protocol v20 with ChangeSettings/RequestAllSettings/DeleteSetting commands (#627) - Composable behavior engine — three-layer action+modifier+context primitives replace flat culture×zone×role behavior strings (#633, D-139, Q-057 resolved) - Stronger few-shot examples for Friendly and RoutineDeviation tells (#651) +- AI-Enhanced Dialogue toggle — settings panel toggle with layered hardware detection (RAM/TPT/degradation), battery auto-suspend with player override, warning label (#646, D-138) +- PlatformInfo autoload — client-side OS abstraction centralizing all platform queries: power state, memory, CPU, GPU, display, locale, file paths, diagnostics helper (#659, D-141) ### Removed - v0.1 content loading system — server/src/content/ module (8200 lines), tooling/content-converter/, tooling/validate-content, content-ron/, content/_meta/ (#655, D-122) +- AiDialogueDetector — duplicate of HardwareDetector, replaced by PlatformInfo abstraction (#659) ### Changed - Protocol version bumped to 20 — ObserverSnapshot includes settings_response field (#627) From f51366b3f5abf9b595817e34638d7e9591fcaebe Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 13 Mar 2026 11:39:47 +0100 Subject: [PATCH 10/11] =?UTF-8?q?fix(ui):=20address=20PR=20#90=20review=20?= =?UTF-8?q?=E2=80=94=208=20items=20(W1=E2=80=93W4,=20S5=E2=80=93S6,=20A3?= =?UTF-8?q?=E2=80=93A4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit W1/A2: add SimBridge.CONNECTED guard before send_input in toggle handler W2/A1: assert PlatformInfo != null in HardwareDetector._ready(); add ordering comment in project.godot [autoload] section W3: replace FileAccess.open() with get_file_as_string() in read_benchmark_cache() — auto-closes, no leak W4/A3: add _extract_bool_setting() helper in game_state.gd that handles both {"Bool": true} and plain bool; push_warning on type mismatch S5: sync _pre_battery_pref from GameState after load_ai_pref() in HardwareDetector._ready() — closes race if system starts on battery S6: separate "red" TPT status into its own arm with STATUS_RED dot and label colour — three-colour mapping: green/yellow/red now distinct A4: replace DebugConsole.PREFS_PATH compile-time dependency in _save_ai_pref() with local SETTINGS_CFG_PATH constant S7 (battery CI guard) already applied by Hoshe in test file. Co-Authored-By: Claude Sonnet 4.6 --- client/project.godot | 2 ++ client/scripts/autoloads/game_state.gd | 17 +++++++++++++++-- client/ui/hardware_detector.gd | 8 +++++--- client/ui/settings_dialog.gd | 26 +++++++++++++++----------- 4 files changed, 37 insertions(+), 16 deletions(-) diff --git a/client/project.godot b/client/project.godot index 234ff6d10..4d65afa64 100644 --- a/client/project.godot +++ b/client/project.godot @@ -17,6 +17,8 @@ config/icon="res://icon.svg" [autoload] +; PlatformInfo MUST remain first — HardwareDetector (and other autoloads) depend on it +; being initialised before their own _ready() runs. Do not reorder. PlatformInfo="*res://scripts/autoloads/platform_info.gd" Protocol="*res://scripts/protocol/protocol.gd" SimBridge="*res://scripts/autoloads/sim_bridge.gd" diff --git a/client/scripts/autoloads/game_state.gd b/client/scripts/autoloads/game_state.gd index 9114716bd..d5a918df6 100644 --- a/client/scripts/autoloads/game_state.gd +++ b/client/scripts/autoloads/game_state.gd @@ -352,8 +352,8 @@ func apply_snapshot(snapshot: Dictionary) -> void: 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"]) + if val != null: + ai_enhanced_dialogue_enabled = _extract_bool_setting("ai_dialogue.enabled", val) else: settings_response = null @@ -403,3 +403,16 @@ func apply_snapshot(snapshot: Dictionary) -> void: boundary_positions[pos] = true elif not has_explicit_positions: visible_positions[pos] = true + + +# -- Helpers ------------------------------------------------------------------ + +## Extract a bool from a tagged-union {"Bool": true} or plain bool value. +## Handles both serde encoding styles; emits push_warning on unrecognised format. +static func _extract_bool_setting(key: String, val: Variant) -> bool: + if val is bool: + return val + if val is Dictionary and val.has("Bool"): + return bool(val["Bool"]) + push_warning("GameState: unexpected type for setting '%s': %s" % [key, str(val)]) + return false diff --git a/client/ui/hardware_detector.gd b/client/ui/hardware_detector.gd index 9409d479a..48f09cedf 100644 --- a/client/ui/hardware_detector.gd +++ b/client/ui/hardware_detector.gd @@ -32,7 +32,9 @@ var _pre_battery_pref: bool = true # -- Lifecycle ---------------------------------------------------------------- func _ready() -> void: + assert(PlatformInfo != null, "PlatformInfo must load before HardwareDetector") load_ai_pref() + _pre_battery_pref = GameState.ai_enhanced_dialogue_enabled PlatformInfo.power_profile_changed.connect(_on_power_profile_changed) @@ -75,11 +77,11 @@ func read_benchmark_cache() -> Variant: var cache_path: String = PlatformInfo.benchmark_cache_path if not FileAccess.file_exists(cache_path): return null - var f := FileAccess.open(cache_path, FileAccess.READ) - if f == null: + var text: String = FileAccess.get_file_as_string(cache_path) + if text.is_empty(): return null var json := JSON.new() - if json.parse(f.get_as_text()) != OK: + if json.parse(text) != OK: return null var data: Variant = json.get_data() if not data is Dictionary or not data.has("tps"): diff --git a/client/ui/settings_dialog.gd b/client/ui/settings_dialog.gd index b86889f4d..f6cbdaeff 100644 --- a/client/ui/settings_dialog.gd +++ b/client/ui/settings_dialog.gd @@ -16,6 +16,9 @@ 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 @@ -203,9 +206,9 @@ func _build_ui() -> void: match _ai_hw_status: "pass", "green": status_dot.color = STATUS_GREEN - "marginal", "yellow", "red": + "marginal", "yellow": status_dot.color = STATUS_YELLOW - _: # "fail" + _: # "red", "fail" status_dot.color = STATUS_RED ai_hbox.add_child(status_dot) @@ -227,9 +230,9 @@ func _build_ui() -> void: 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": + "marginal", "yellow": ai_status_label.add_theme_color_override("font_color", STATUS_YELLOW) - "fail": + "red", "fail": ai_status_label.add_theme_color_override("font_color", STATUS_RED) _: ai_status_label.add_theme_color_override("font_color", TEXT_COLOR) @@ -247,11 +250,12 @@ func _build_ui() -> void: _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(), - }) + 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) ) @@ -335,9 +339,9 @@ func set_ai_inference_suspended(suspended: bool) -> void: ## 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.load(SETTINGS_CFG_PATH) cfg.set_value("ai_dialogue", "enabled", enabled) - cfg.save(DebugConsole.PREFS_PATH) + cfg.save(SETTINGS_CFG_PATH) func _draw() -> void: From a1788dad3218ac674e8f9df3dae16a4339f0fb61 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 13 Mar 2026 11:40:12 +0100 Subject: [PATCH 11/11] fix(client): guard battery test against CI on-battery false-fail (S7) Co-Authored-By: Claude Opus 4.6 --- client/tests/test_ai_dialogue_sprint26.gd | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/client/tests/test_ai_dialogue_sprint26.gd b/client/tests/test_ai_dialogue_sprint26.gd index 984f20574..7d881cf5d 100644 --- a/client/tests/test_ai_dialogue_sprint26.gd +++ b/client/tests/test_ai_dialogue_sprint26.gd @@ -689,8 +689,12 @@ func test_hardware_detector_inference_not_suspended_by_default() -> void: var det := _get_detector() if det == null: return - # Note: this reads live state — if tests run on battery hardware the result - # may reflect a real suspension. We document this as expected. + # Skip if CI is running on battery hardware — power state 1 = ON_BATTERY. + # Integer literal used: OS.POWERSTATE_* constants don't exist in this build. + var pi := _get_platform_info() + if pi != null and pi.current_power_state() == 1: + push_warning("TestAiDialogueSprint26: system is on battery (state=1) — skipping default-suspended test") + return assert_bool(det.is_inference_suspended()).override_failure_message( "is_inference_suspended() must be false when hardware is FULL/plugged (D-138 §8)" ).is_false()