Merge remote-tracking branch 'origin/client'
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -194,6 +194,12 @@ 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."
|
||||
ai_battery_warning: "High battery usage"
|
||||
|
||||
# ============================================================
|
||||
# CHARACTER SELECTION (if applicable in v0.1)
|
||||
|
||||
@@ -17,6 +17,10 @@ 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"
|
||||
GameState="*res://scripts/autoloads/game_state.gd"
|
||||
InputMapper="*res://scripts/autoloads/input_mapper.gd"
|
||||
@@ -24,6 +28,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]
|
||||
|
||||
|
||||
@@ -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 != null:
|
||||
ai_enhanced_dialogue_enabled = _extract_bool_setting("ai_dialogue.enabled", val)
|
||||
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:
|
||||
@@ -374,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
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
extends Node
|
||||
## 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.
|
||||
## 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 ------------------------------------------------------------
|
||||
|
||||
## 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
|
||||
|
||||
## 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
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
# -- 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://).
|
||||
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()
|
||||
_init_hardware()
|
||||
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"
|
||||
|
||||
|
||||
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.
|
||||
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.
|
||||
## 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() 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"):
|
||||
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:
|
||||
# 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 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,
|
||||
"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,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://platform_info_sr
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,898 @@
|
||||
## 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<PlayerInput> — 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
|
||||
# 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()
|
||||
|
||||
|
||||
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()
|
||||
@@ -0,0 +1,203 @@
|
||||
extends Node
|
||||
## Hardware detection autoload for AI-Enhanced Dialogue (D-138, #646).
|
||||
##
|
||||
## Exposes classification methods for Layer 1 (RAM), Layer 2 (TPT benchmark),
|
||||
## 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 and power-state methods to configure the 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
|
||||
|
||||
## 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:
|
||||
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)
|
||||
|
||||
|
||||
# -- Layer 1: RAM classification ----------------------------------------------
|
||||
|
||||
## 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"
|
||||
|
||||
|
||||
## 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:
|
||||
if tps >= TPT_GREEN_THRESHOLD:
|
||||
return "green"
|
||||
elif tps >= TPT_YELLOW_THRESHOLD:
|
||||
return "yellow"
|
||||
else:
|
||||
return "red"
|
||||
|
||||
|
||||
## 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:
|
||||
var cache_path: String = PlatformInfo.benchmark_cache_path
|
||||
if not FileAccess.file_exists(cache_path):
|
||||
return null
|
||||
var text: String = FileAccess.get_file_as_string(cache_path)
|
||||
if text.is_empty():
|
||||
return null
|
||||
var json := JSON.new()
|
||||
if json.parse(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)}
|
||||
|
||||
|
||||
# -- 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).
|
||||
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
|
||||
|
||||
|
||||
# -- 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.
|
||||
func status_message(ram_classification: String, tpt_classification: String, free_mb: float, tps: float) -> String:
|
||||
if ram_classification == "fail":
|
||||
return "AI-Enhanced Dialogue requires 2 GB of free memory. Your system currently has %.0f MB available. Close other applications and try again, or leave the setting off — the game is complete either way." % free_mb
|
||||
if ram_classification == "marginal":
|
||||
return "Only %.0f MB free — performance may vary. You can still enable it." % free_mb
|
||||
match tpt_classification:
|
||||
"yellow":
|
||||
return "Running at %.0f t/s — pre-voicing will work for main characters and key scenes. Background NPCs may show base text until the queue catches up." % tps
|
||||
"red":
|
||||
return "Running very slowly at %.0f t/s — we recommend leaving this off, but the choice is yours." % tps
|
||||
return ""
|
||||
@@ -0,0 +1 @@
|
||||
uid://hardware_detector_sr
|
||||
@@ -1,18 +1,27 @@
|
||||
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
|
||||
## 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
|
||||
const ROW_HEIGHT := 36
|
||||
|
||||
# Bus display labels → bus name strings (must match AudioManager BUS_* constants)
|
||||
@@ -27,8 +36,15 @@ 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
|
||||
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
|
||||
signal ai_dialogue_toggled(enabled: bool) # #646: AI-Enhanced Dialogue enabled/disabled
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -138,6 +154,111 @@ 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)
|
||||
# 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)
|
||||
_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":
|
||||
status_dot.color = STATUS_YELLOW
|
||||
_: # "red", "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":
|
||||
ai_status_label.add_theme_color_override("font_color", STATUS_YELLOW)
|
||||
"red", "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)
|
||||
|
||||
# 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)
|
||||
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)
|
||||
)
|
||||
|
||||
# Spacer
|
||||
var spacer := Control.new()
|
||||
spacer.custom_minimum_size = Vector2(0, 8)
|
||||
@@ -163,6 +284,64 @@ func _destroy_ui() -> void:
|
||||
if _container:
|
||||
_container.queue_free()
|
||||
_container = null
|
||||
_ai_check_node = null # freed with _container
|
||||
_ai_battery_warning_label = null
|
||||
|
||||
|
||||
# -- #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 UIStrings.get_text("settings.ai_dialogue_toggle")
|
||||
|
||||
|
||||
## 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 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"
|
||||
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
## 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).
|
||||
## Tests can inject suspended=true to verify the warning label appears.
|
||||
func set_ai_inference_suspended(suspended: bool) -> void:
|
||||
_ai_inference_suspended = suspended
|
||||
if _ai_battery_warning_label != null:
|
||||
_ai_battery_warning_label.visible = suspended
|
||||
|
||||
|
||||
## 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(SETTINGS_CFG_PATH)
|
||||
cfg.set_value("ai_dialogue", "enabled", enabled)
|
||||
cfg.save(SETTINGS_CFG_PATH)
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
@@ -186,7 +365,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)
|
||||
|
||||
|
||||
|
||||
@@ -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)*
|
||||
|
||||
@@ -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.*
|
||||
|
||||
@@ -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*
|
||||
|
||||
|
||||
Reference in New Issue
Block a user