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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 10:34:48 +01:00
co-authored by Claude Sonnet 4.6
parent 2f8218400d
commit 01b0583265
9 changed files with 354 additions and 7 deletions
+29
View File
@@ -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:
+3
View File
@@ -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] = []
+15
View File
@@ -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 ""
+48 -3
View File
@@ -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<PlayerInput> 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: