using System; using System.Collections.Generic; using System.IO; using System.Text.Json; using System.Text.Json.Nodes; using AcDream.UI.Abstractions.Settings; namespace AcDream.UI.Abstractions.Panels.Settings; /// /// JSON-backed persistence for non-keybind settings (Display today; future /// tabs Audio / Gameplay / Chat / Character will be added to the same /// file). Path: %LOCALAPPDATA%\acdream\settings.json. Coexists /// with keybinds.json, which retains its own /// path. /// /// /// Schema (current version 1): /// /// { /// "version": 1, /// "display": { "resolution": "1920x1080", "fullscreen": false, ... } /// } /// /// Unknown top-level keys are preserved on save so future tab additions /// from a newer client don't get clobbered by an older client writing /// out only the sections it knows about. /// /// public sealed class SettingsStore { private const int CurrentSchemaVersion = 1; private readonly string _path; public SettingsStore(string path) { _path = path ?? throw new ArgumentNullException(nameof(path)); } /// Default path: %LOCALAPPDATA%\acdream\settings.json. public static string DefaultPath() => Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "acdream", "settings.json"); /// /// Load Display settings. Missing file → . /// Missing individual keys fall back to the corresponding default /// field, so a partial file (e.g. only resolution is set) is /// non-fatal. /// public DisplaySettings LoadDisplay() { if (!File.Exists(_path)) return DisplaySettings.Default; try { using var stream = File.OpenRead(_path); var doc = JsonDocument.Parse(stream); var root = doc.RootElement; if (!root.TryGetProperty("display", out var disp) || disp.ValueKind != JsonValueKind.Object) return DisplaySettings.Default; var d = DisplaySettings.Default; return new DisplaySettings( Resolution: ReadString (disp, "resolution", d.Resolution), Fullscreen: ReadBool (disp, "fullscreen", d.Fullscreen), VSync: ReadBool (disp, "vsync", d.VSync), FieldOfView: ReadFloat (disp, "fieldOfView", d.FieldOfView), Gamma: ReadFloat (disp, "gamma", d.Gamma), ShowFps: ReadBool (disp, "showFps", d.ShowFps), Quality: ReadQuality (disp, "quality", d.Quality)); } catch (Exception ex) { Console.WriteLine($"settings: failed to load {_path}: {ex.Message} — using defaults"); return DisplaySettings.Default; } } /// /// Save Display settings, preserving any other top-level keys the file /// already contains (e.g. an audio section written by a newer /// client). Unknown keys are round-tripped via raw JSON text so older /// builds don't silently drop them. /// public void SaveDisplay(DisplaySettings display) => SaveSection("display", BuildDisplayObject(display)); /// /// Load Audio settings. Same fall-back behaviour as /// : missing file → defaults, missing fields /// → per-field defaults, corrupt JSON → defaults. /// public AudioSettings LoadAudio() { if (!File.Exists(_path)) return AudioSettings.Default; try { using var stream = File.OpenRead(_path); var doc = JsonDocument.Parse(stream); var root = doc.RootElement; if (!root.TryGetProperty("audio", out var audio) || audio.ValueKind != JsonValueKind.Object) return AudioSettings.Default; var d = AudioSettings.Default; return new AudioSettings( Master: ReadFloat(audio, "master", d.Master), Music: ReadFloat(audio, "music", d.Music), Sfx: ReadFloat(audio, "sfx", d.Sfx), Ambient: ReadFloat(audio, "ambient", d.Ambient)); } catch (Exception ex) { Console.WriteLine($"settings: failed to load {_path}: {ex.Message} — using defaults"); return AudioSettings.Default; } } /// /// Save Audio settings, preserving every other top-level key /// (display, future gameplay/chat/character). Same round-trip /// guarantee as . /// public void SaveAudio(AudioSettings audio) => SaveSection("audio", BuildAudioObject(audio)); /// /// Load Gameplay settings (subset of retail CharacterOption flags). /// Same fall-back behaviour as . /// public GameplaySettings LoadGameplay() { if (!File.Exists(_path)) return GameplaySettings.Default; try { using var stream = File.OpenRead(_path); var doc = JsonDocument.Parse(stream); var root = doc.RootElement; if (!root.TryGetProperty("gameplay", out var gp) || gp.ValueKind != JsonValueKind.Object) return GameplaySettings.Default; var d = GameplaySettings.Default; return new GameplaySettings( AutoTarget: ReadBool(gp, "autoTarget", d.AutoTarget), AutoRepeatAttack: ReadBool(gp, "autoRepeatAttack", d.AutoRepeatAttack), ToggleRun: ReadBool(gp, "toggleRun", d.ToggleRun), AdvancedCombatUI: ReadBool(gp, "advancedCombatUI", d.AdvancedCombatUI), ShowTooltips: ReadBool(gp, "showTooltips", d.ShowTooltips), VividTargetingIndicator: ReadBool(gp, "vividTargetingIndicator", d.VividTargetingIndicator), SideBySideVitals: ReadBool(gp, "sideBySideVitals", d.SideBySideVitals), CoordinatesOnRadar: ReadBool(gp, "coordinatesOnRadar", d.CoordinatesOnRadar), SpellDuration: ReadBool(gp, "spellDuration", d.SpellDuration), AllowGive: ReadBool(gp, "allowGive", d.AllowGive), ShowHelm: ReadBool(gp, "showHelm", d.ShowHelm), ShowCloak: ReadBool(gp, "showCloak", d.ShowCloak), LockUI: ReadBool(gp, "lockUI", d.LockUI), UseMouseTurning: ReadBool(gp, "useMouseTurning", d.UseMouseTurning)); } catch (Exception ex) { Console.WriteLine($"settings: failed to load {_path}: {ex.Message} — using defaults"); return GameplaySettings.Default; } } /// Save Gameplay settings, preserving all other top-level keys. public void SaveGameplay(GameplaySettings gameplay) => SaveSection("gameplay", BuildGameplayObject(gameplay)); /// Load Chat settings. Same fall-back behaviour as . public ChatSettings LoadChat() { if (!File.Exists(_path)) return ChatSettings.Default; try { using var stream = File.OpenRead(_path); var doc = JsonDocument.Parse(stream); var root = doc.RootElement; if (!root.TryGetProperty("chat", out var chat) || chat.ValueKind != JsonValueKind.Object) return ChatSettings.Default; var d = ChatSettings.Default; return new ChatSettings( HearGeneralChat: ReadBool (chat, "hearGeneralChat", d.HearGeneralChat), HearTradeChat: ReadBool (chat, "hearTradeChat", d.HearTradeChat), HearLFGChat: ReadBool (chat, "hearLFGChat", d.HearLFGChat), HearRoleplayChat: ReadBool (chat, "hearRoleplayChat", d.HearRoleplayChat), HearSocietyChat: ReadBool (chat, "hearSocietyChat", d.HearSocietyChat), AppearOffline: ReadBool (chat, "appearOffline", d.AppearOffline), ShowTimestamps: ReadBool (chat, "showTimestamps", d.ShowTimestamps), FilterProfanity: ReadBool (chat, "filterProfanity", d.FilterProfanity), FontSize: ReadFloat(chat, "fontSize", d.FontSize)); } catch (Exception ex) { Console.WriteLine($"settings: failed to load {_path}: {ex.Message} — using defaults"); return ChatSettings.Default; } } /// Save Chat settings, preserving all other top-level keys. public void SaveChat(ChatSettings chat) => SaveSection("chat", BuildChatObject(chat)); /// /// Load per-character settings keyed by . /// Missing file or missing toon entry → . /// public CharacterSettings LoadCharacter(string toonKey) { if (toonKey is null) throw new ArgumentNullException(nameof(toonKey)); if (!File.Exists(_path)) return CharacterSettings.Default; try { var root = JsonNode.Parse(File.ReadAllText(_path)) as JsonObject; var toon = root?["character"]?[toonKey] as JsonObject; if (toon is null) return CharacterSettings.Default; var d = CharacterSettings.Default; return new CharacterSettings( DefaultChatChannel: toon["defaultChatChannel"]?.GetValue() ?? d.DefaultChatChannel, AutoAttack: toon["autoAttack"]?.GetValue() ?? d.AutoAttack, ConfirmSalvage: toon["confirmSalvage"]?.GetValue() ?? d.ConfirmSalvage, ShowPickupMessages: toon["showPickupMessages"]?.GetValue() ?? d.ShowPickupMessages); } catch (Exception ex) { Console.WriteLine($"settings: failed to load {_path}: {ex.Message} — using defaults"); return CharacterSettings.Default; } } /// /// Save per-character settings under . /// Preserves every other toon's settings + every other top-level /// section. Uses rather than the raw-text /// preservation pattern of because the /// per-toon write needs to mutate a nested map, not just replace a /// top-level key. /// public void SaveCharacter(string toonKey, CharacterSettings settings) { if (toonKey is null) throw new ArgumentNullException(nameof(toonKey)); if (settings is null) throw new ArgumentNullException(nameof(settings)); var dir = Path.GetDirectoryName(_path); if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); // Read existing file as a mutable JsonObject (or start fresh). JsonObject root; if (File.Exists(_path)) { try { root = JsonNode.Parse(File.ReadAllText(_path)) as JsonObject ?? new JsonObject(); } catch { root = new JsonObject(); } } else { root = new JsonObject(); } // Build the toon's payload. var toonObj = new JsonObject { ["autoAttack"] = settings.AutoAttack, ["confirmSalvage"] = settings.ConfirmSalvage, ["defaultChatChannel"] = settings.DefaultChatChannel, ["showPickupMessages"] = settings.ShowPickupMessages, }; // Slot it under character[toonKey], creating the character map if // necessary. Other toons in the map are preserved. if (root["character"] is not JsonObject characterMap) { characterMap = new JsonObject(); root["character"] = characterMap; } characterMap[toonKey] = toonObj; root["version"] = CurrentSchemaVersion; File.WriteAllText(_path, root.ToJsonString(new JsonSerializerOptions { WriteIndented = true })); } private static SortedDictionary BuildChatObject(ChatSettings c) => new(StringComparer.Ordinal) { ["appearOffline"] = c.AppearOffline, ["filterProfanity"] = c.FilterProfanity, ["fontSize"] = c.FontSize, ["hearGeneralChat"] = c.HearGeneralChat, ["hearLFGChat"] = c.HearLFGChat, ["hearRoleplayChat"] = c.HearRoleplayChat, ["hearSocietyChat"] = c.HearSocietyChat, ["hearTradeChat"] = c.HearTradeChat, ["showTimestamps"] = c.ShowTimestamps, }; private static SortedDictionary BuildGameplayObject(GameplaySettings g) => new(StringComparer.Ordinal) { ["advancedCombatUI"] = g.AdvancedCombatUI, ["allowGive"] = g.AllowGive, ["autoRepeatAttack"] = g.AutoRepeatAttack, ["autoTarget"] = g.AutoTarget, ["coordinatesOnRadar"] = g.CoordinatesOnRadar, ["lockUI"] = g.LockUI, ["showCloak"] = g.ShowCloak, ["showHelm"] = g.ShowHelm, ["showTooltips"] = g.ShowTooltips, ["sideBySideVitals"] = g.SideBySideVitals, ["spellDuration"] = g.SpellDuration, ["toggleRun"] = g.ToggleRun, ["useMouseTurning"] = g.UseMouseTurning, ["vividTargetingIndicator"] = g.VividTargetingIndicator, }; private static SortedDictionary BuildDisplayObject(DisplaySettings d) => new(StringComparer.Ordinal) { ["fieldOfView"] = d.FieldOfView, ["fullscreen"] = d.Fullscreen, ["gamma"] = d.Gamma, ["quality"] = d.Quality.ToString(), ["resolution"] = d.Resolution, ["showFps"] = d.ShowFps, ["vsync"] = d.VSync, }; private static SortedDictionary BuildAudioObject(AudioSettings a) => new(StringComparer.Ordinal) { ["ambient"] = a.Ambient, ["master"] = a.Master, ["music"] = a.Music, ["sfx"] = a.Sfx, }; /// /// Generic atomic-section save: writes the named section and preserves /// all other top-level keys from the existing file, replacing only the /// version + the targeted section. Avoids duplication between the /// per-section Save methods. /// private void SaveSection(string sectionName, SortedDictionary sectionPayload) { var dir = Path.GetDirectoryName(_path); if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); // Preserve any non-target top-level keys from the existing file. var preservedKeys = new SortedDictionary(StringComparer.Ordinal); if (File.Exists(_path)) { try { using var stream = File.OpenRead(_path); var doc = JsonDocument.Parse(stream); foreach (var prop in doc.RootElement.EnumerateObject()) { if (prop.Name == sectionName || prop.Name == "version") continue; preservedKeys[prop.Name] = prop.Value.GetRawText(); } } catch { // Corrupt file → fully overwrite; previous content is lost // but the user's session continues with the new save. preservedKeys.Clear(); } } var sb = new System.Text.StringBuilder(); sb.Append('{').AppendLine(); // Preserved keys come first (sorted by name) then the section, then // version last. Preserves alphabetical-style top-level ordering. foreach (var kv in preservedKeys) { sb.Append(" \"").Append(kv.Key).Append("\": ") .Append(kv.Value).Append(',').AppendLine(); } sb.Append(" \"").Append(sectionName).Append("\": ") .Append(JsonSerializer.Serialize(sectionPayload, new JsonSerializerOptions { WriteIndented = true }) .Replace("\n", "\n ")) .Append(',').AppendLine(); sb.Append(" \"version\": ").Append(CurrentSchemaVersion).AppendLine(); sb.Append('}').AppendLine(); File.WriteAllText(_path, sb.ToString()); } private static string ReadString(JsonElement obj, string name, string fallback) => obj.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.String ? (el.GetString() ?? fallback) : fallback; private static bool ReadBool(JsonElement obj, string name, bool fallback) => obj.TryGetProperty(name, out var el) && (el.ValueKind == JsonValueKind.True || el.ValueKind == JsonValueKind.False) ? el.GetBoolean() : fallback; private static float ReadFloat(JsonElement obj, string name, float fallback) => obj.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.Number ? el.GetSingle() : fallback; private static QualityPreset ReadQuality(JsonElement obj, string name, QualityPreset fallback) { if (!obj.TryGetProperty(name, out var el) || el.ValueKind != JsonValueKind.String) return fallback; var s = el.GetString(); return Enum.TryParse(s, ignoreCase: true, out var v) ? v : fallback; } }