diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 51ca85f..6e102a5 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -910,6 +910,13 @@ public sealed class GameWindow : IDisposable var persistedAudio = settingsStore.LoadAudio(); var persistedGameplay = settingsStore.LoadGameplay(); var persistedChat = settingsStore.LoadChat(); + // Per-toon character settings keyed by name. We don't + // know which toon the user will pick until after + // CharacterList lands, so use a "default" bag for now. + // Future: swap to the actual toon name once a + // currentCharacter source is plumbed. + const string toonKey = "default"; + var persistedCharacter = settingsStore.LoadCharacter(toonKey); // Apply persisted audio to the engine BEFORE the panel // host starts pushing per-frame so the first frame uses @@ -1007,6 +1014,21 @@ public sealed class GameWindow : IDisposable { Console.WriteLine($"settings: chat save failed: {ex.Message}"); } + }, + persistedCharacter: persistedCharacter, + onSaveCharacter: character => + { + try + { + settingsStore.SaveCharacter(toonKey, character); + Console.WriteLine( + $"settings: character[{toonKey}] saved to " + + AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath()); + } + catch (Exception ex) + { + Console.WriteLine($"settings: character save failed: {ex.Message}"); + } }); _settingsPanel = new AcDream.UI.Abstractions.Panels.Settings.SettingsPanel(_settingsVm); _panelHost.Register(_settingsPanel); diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/CharacterSettings.cs b/src/AcDream.UI.Abstractions/Panels/Settings/CharacterSettings.cs new file mode 100644 index 0000000..0aa2342 --- /dev/null +++ b/src/AcDream.UI.Abstractions/Panels/Settings/CharacterSettings.cs @@ -0,0 +1,48 @@ +namespace AcDream.UI.Abstractions.Panels.Settings; + +/// +/// Per-character preferences persisted to settings.json under +/// character[toonName]. Settings on this tab are scoped to a +/// single toon; switching characters loads a different bag. +/// +/// +/// L.0 scope: local-only. The settings here describe how the +/// client UI behaves for the active toon — they don't yet flow to the +/// server. When server-sync ships, options like +/// would be pushed via the retail Player-Options packet. +/// +/// +/// +/// MVP shape — four settings only. Easy to grow when more per-toon +/// preferences land. Each is value-typed so equality and Cancel-revert +/// behave like the other tabs' records. +/// +/// +public sealed record CharacterSettings( + string DefaultChatChannel, // "Local" / "Allegiance" / "Fellowship" / "General" / etc. + bool AutoAttack, // Tap-to-attack continues swinging until target dies + bool ConfirmSalvage, // Prompt before salvaging valuable items + bool ShowPickupMessages) // "You picked up X" lines in chat +{ + /// Defaults applied to a fresh character (no settings.json + /// entry yet). Conservative — opt-in for AutoAttack, opt-in for + /// confirmation prompts, pickup messages on by default. + public static CharacterSettings Default { get; } = new( + DefaultChatChannel: "Local", + AutoAttack: false, + ConfirmSalvage: true, + ShowPickupMessages: true); + + /// Channel-name presets exposed in the dropdown. Order + /// roughly matches retail's chat-channel routing. + public static System.Collections.Generic.IReadOnlyList AvailableChannels { get; } = new[] + { + "Local", + "Allegiance", + "Fellowship", + "General", + "Trade", + "LFG", + "Roleplay", + }; +} diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs index fef6291..b0fec97 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs @@ -103,7 +103,7 @@ public sealed class SettingsPanel : IPanel } if (renderer.BeginTabItem("Character")) { - RenderPlaceholder(renderer, "Character"); + RenderCharacterTab(renderer); renderer.EndTabItem(); } renderer.EndTabBar(); @@ -180,20 +180,6 @@ public sealed class SettingsPanel : IPanel }); } - /// - /// Placeholder content shown for tabs whose implementation is still - /// pending. Reads as "Coming soon" plus a note about which sub-phase - /// is expected to fill it in. - /// - private static void RenderPlaceholder(IPanelRenderer renderer, string tabName) - { - renderer.TextWrapped($"{tabName} settings coming soon."); - renderer.Spacing(); - renderer.TextWrapped( - "This tab is part of the staged Settings interface rollout. " - + "Build order: Display → Audio → Gameplay → Chat → Character."); - } - /// /// Render the Display tab — resolution / fullscreen / vsync / /// FOV / gamma / show-FPS. FOV + Gamma are live-preview sliders; @@ -417,6 +403,41 @@ public sealed class SettingsPanel : IPanel + "Cancel reverts."); } + /// + /// Render the Character tab — per-toon preferences. The host owns + /// the toon-name key; the panel just edits whatever bag the host + /// loaded into . + /// + private void RenderCharacterTab(IPanelRenderer renderer) + { + var c = _vm.CharacterDraft; + + var channels = CharacterSettings.AvailableChannels.ToArray(); + int idx = System.Array.IndexOf(channels, c.DefaultChatChannel); + if (idx < 0) idx = 0; + if (renderer.Combo("Default chat channel", ref idx, channels)) + _vm.SetCharacter(c with { DefaultChatChannel = channels[idx] }); + + bool autoAttack = c.AutoAttack; + if (renderer.Checkbox("Auto-attack (continue swinging until target dies)", ref autoAttack)) + _vm.SetCharacter(c with { AutoAttack = autoAttack }); + + bool confirmSalvage = c.ConfirmSalvage; + if (renderer.Checkbox("Confirm before salvaging valuable items", ref confirmSalvage)) + _vm.SetCharacter(c with { ConfirmSalvage = confirmSalvage }); + + bool pickup = c.ShowPickupMessages; + if (renderer.Checkbox("Show pickup messages in chat", ref pickup)) + _vm.SetCharacter(c with { ShowPickupMessages = pickup }); + + renderer.Spacing(); + renderer.TextWrapped( + "Per-character preferences — saved per toon under " + + "settings.json's character[\"\"]. Local-only this " + + "phase; server-sync arrives later when the protocol " + + "round-trip lands."); + } + private void RenderSection(IPanelRenderer renderer, string label, InputAction[] actions) { // Movement defaults open; other sections collapsed for first-run UX. diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs index 277b0d0..11264fc 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.IO; using System.Text.Json; +using System.Text.Json.Nodes; namespace AcDream.UI.Abstractions.Panels.Settings; @@ -203,6 +204,90 @@ public sealed class SettingsStore 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) { diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs index 159f75d..c32fade 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs @@ -51,6 +51,11 @@ public sealed class SettingsVM private ChatSettings _chatDraft; private readonly Action _onSaveChat; + // L.0 — Character tab (per-toon, host-keyed by toon name). + private CharacterSettings _characterPersisted; + private CharacterSettings _characterDraft; + private readonly Action _onSaveCharacter; + /// The action currently being rebound, or null when idle. public InputAction? RebindInProgress { get; private set; } @@ -73,10 +78,11 @@ public sealed class SettingsVM /// rebinds are pending. public bool HasUnsavedChanges => !KeyBindingsEqual(_persisted, _draft) - || _displayPersisted != _displayDraft - || _audioPersisted != _audioDraft - || _gameplayPersisted != _gameplayDraft - || _chatPersisted != _chatDraft; + || _displayPersisted != _displayDraft + || _audioPersisted != _audioDraft + || _gameplayPersisted != _gameplayDraft + || _chatPersisted != _chatDraft + || _characterPersisted != _characterDraft; /// The current Display draft. Panel reads from here; /// mutation goes through . @@ -94,6 +100,11 @@ public sealed class SettingsVM /// mutation goes through . public ChatSettings ChatDraft => _chatDraft; + /// The current Character draft (per-toon — host owns the + /// toon-name key). Panel reads from here; mutation goes through + /// . + public CharacterSettings CharacterDraft => _characterDraft; + public SettingsVM( KeyBindings persisted, InputDispatcher dispatcher, @@ -105,24 +116,29 @@ public sealed class SettingsVM GameplaySettings persistedGameplay, Action onSaveGameplay, ChatSettings persistedChat, - Action onSaveChat) + Action onSaveChat, + CharacterSettings persistedCharacter, + Action onSaveCharacter) { - _persisted = persisted ?? throw new ArgumentNullException(nameof(persisted)); - _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); - _onSave = onSave ?? throw new ArgumentNullException(nameof(onSave)); - _displayPersisted = persistedDisplay ?? throw new ArgumentNullException(nameof(persistedDisplay)); - _onSaveDisplay = onSaveDisplay ?? throw new ArgumentNullException(nameof(onSaveDisplay)); - _audioPersisted = persistedAudio ?? throw new ArgumentNullException(nameof(persistedAudio)); - _onSaveAudio = onSaveAudio ?? throw new ArgumentNullException(nameof(onSaveAudio)); - _gameplayPersisted = persistedGameplay ?? throw new ArgumentNullException(nameof(persistedGameplay)); - _onSaveGameplay = onSaveGameplay ?? throw new ArgumentNullException(nameof(onSaveGameplay)); - _chatPersisted = persistedChat ?? throw new ArgumentNullException(nameof(persistedChat)); - _onSaveChat = onSaveChat ?? throw new ArgumentNullException(nameof(onSaveChat)); - _draft = CloneBindings(persisted); - _displayDraft = persistedDisplay; - _audioDraft = persistedAudio; - _gameplayDraft = persistedGameplay; - _chatDraft = persistedChat; + _persisted = persisted ?? throw new ArgumentNullException(nameof(persisted)); + _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + _onSave = onSave ?? throw new ArgumentNullException(nameof(onSave)); + _displayPersisted = persistedDisplay ?? throw new ArgumentNullException(nameof(persistedDisplay)); + _onSaveDisplay = onSaveDisplay ?? throw new ArgumentNullException(nameof(onSaveDisplay)); + _audioPersisted = persistedAudio ?? throw new ArgumentNullException(nameof(persistedAudio)); + _onSaveAudio = onSaveAudio ?? throw new ArgumentNullException(nameof(onSaveAudio)); + _gameplayPersisted = persistedGameplay ?? throw new ArgumentNullException(nameof(persistedGameplay)); + _onSaveGameplay = onSaveGameplay ?? throw new ArgumentNullException(nameof(onSaveGameplay)); + _chatPersisted = persistedChat ?? throw new ArgumentNullException(nameof(persistedChat)); + _onSaveChat = onSaveChat ?? throw new ArgumentNullException(nameof(onSaveChat)); + _characterPersisted = persistedCharacter ?? throw new ArgumentNullException(nameof(persistedCharacter)); + _onSaveCharacter = onSaveCharacter ?? throw new ArgumentNullException(nameof(onSaveCharacter)); + _draft = CloneBindings(persisted); + _displayDraft = persistedDisplay; + _audioDraft = persistedAudio; + _gameplayDraft = persistedGameplay; + _chatDraft = persistedChat; + _characterDraft = persistedCharacter; } /// @@ -170,6 +186,16 @@ public sealed class SettingsVM _chatDraft = value ?? throw new ArgumentNullException(nameof(value)); } + /// + /// Replace the entire Character draft with . + /// Per-toon — the host knows which toon's bag we're editing because + /// it owned the toonKey when constructing the VM. + /// + public void SetCharacter(CharacterSettings value) + { + _characterDraft = value ?? throw new ArgumentNullException(nameof(value)); + } + /// /// Begin rebinding . The supplied /// binding will be removed when the new @@ -277,11 +303,12 @@ public sealed class SettingsVM /// public void ResetAllToDefaults() { - _draft = KeyBindings.RetailDefaults(); - _displayDraft = DisplaySettings.Default; - _audioDraft = AudioSettings.Default; - _gameplayDraft = GameplaySettings.Default; - _chatDraft = ChatSettings.Default; + _draft = KeyBindings.RetailDefaults(); + _displayDraft = DisplaySettings.Default; + _audioDraft = AudioSettings.Default; + _gameplayDraft = GameplaySettings.Default; + _chatDraft = ChatSettings.Default; + _characterDraft = CharacterSettings.Default; } /// @@ -299,11 +326,13 @@ public sealed class SettingsVM _onSaveAudio(_audioDraft); _onSaveGameplay(_gameplayDraft); _onSaveChat(_chatDraft); - _persisted = CloneBindings(_draft); - _displayPersisted = _displayDraft; - _audioPersisted = _audioDraft; - _gameplayPersisted = _gameplayDraft; - _chatPersisted = _chatDraft; + _onSaveCharacter(_characterDraft); + _persisted = CloneBindings(_draft); + _displayPersisted = _displayDraft; + _audioPersisted = _audioDraft; + _gameplayPersisted = _gameplayDraft; + _chatPersisted = _chatDraft; + _characterPersisted = _characterDraft; } /// @@ -313,11 +342,12 @@ public sealed class SettingsVM /// public void Cancel() { - _draft = CloneBindings(_persisted); - _displayDraft = _displayPersisted; - _audioDraft = _audioPersisted; - _gameplayDraft = _gameplayPersisted; - _chatDraft = _chatPersisted; + _draft = CloneBindings(_persisted); + _displayDraft = _displayPersisted; + _audioDraft = _audioPersisted; + _gameplayDraft = _gameplayPersisted; + _chatDraft = _chatPersisted; + _characterDraft = _characterPersisted; CancelRebind(); } diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/CharacterSettingsTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/CharacterSettingsTests.cs new file mode 100644 index 0000000..7c4adf0 --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/CharacterSettingsTests.cs @@ -0,0 +1,48 @@ +using AcDream.UI.Abstractions.Panels.Settings; + +namespace AcDream.UI.Abstractions.Tests.Panels.Settings; + +/// L.0: default-pin tests. +public sealed class CharacterSettingsTests +{ + [Fact] + public void Default_values_are_conservative() + { + var d = CharacterSettings.Default; + Assert.Equal("Local", d.DefaultChatChannel); + Assert.False(d.AutoAttack); + Assert.True(d.ConfirmSalvage); + Assert.True(d.ShowPickupMessages); + } + + [Fact] + public void AvailableChannels_includes_retail_routing_targets() + { + var list = CharacterSettings.AvailableChannels; + Assert.Contains("Local", list); + Assert.Contains("Allegiance", list); + Assert.Contains("Fellowship", list); + Assert.Contains("General", list); + Assert.Contains("Trade", list); + Assert.Contains("LFG", list); + Assert.Contains("Roleplay", list); + } + + [Fact] + public void Equality_is_value_based() + { + var a = CharacterSettings.Default; + var b = CharacterSettings.Default with { AutoAttack = true }; + var c = CharacterSettings.Default with { AutoAttack = true }; + Assert.NotEqual(a, b); + Assert.Equal(b, c); + } + + [Fact] + public void With_expression_clones_one_field() + { + var d = CharacterSettings.Default with { DefaultChatChannel = "Allegiance" }; + Assert.Equal("Allegiance", d.DefaultChatChannel); + Assert.False(d.AutoAttack); + } +} diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs index 72a1ba4..7e8c1ef 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs @@ -34,7 +34,8 @@ public sealed class SettingsPanelTests DisplaySettings.Default, _ => { }, AudioSettings.Default, _ => { }, GameplaySettings.Default, _ => { }, - ChatSettings.Default, _ => { }); + ChatSettings.Default, _ => { }, + CharacterSettings.Default, _ => { }); var panel = new SettingsPanel(vm); return (panel, vm, kb, dispatcher); } @@ -226,20 +227,72 @@ public sealed class SettingsPanelTests Assert.DoesNotContain("Hotbar", headers); } + // -- Character tab content ------------------------------------------- + [Fact] - public void Placeholder_tabs_render_coming_soon_text_when_active() + public void Character_tab_when_active_renders_channel_combo_plus_checkboxes() { - // Character is still a placeholder (last on the build order). - // Display / Audio / Gameplay / Chat have shipped — they have - // real widgets, not "coming soon" text. var (panel, _, _, _) = Build(); var r = new FakePanelRenderer { ActiveTabLabel = "Character" }; panel.Render(new PanelContext(0.016f, new NullBus()), r); - var wrapped = r.Calls.Where(c => c.Method == "TextWrapped") + var combos = r.Calls.Where(c => c.Method == "Combo") .Select(c => (string)c.Args[0]!).ToList(); - Assert.Contains(wrapped, t => t.Contains("Character settings coming soon")); + Assert.Contains("Default chat channel", combos); + + var checks = r.Calls.Where(c => c.Method == "Checkbox") + .Select(c => (string)c.Args[0]!).ToList(); + Assert.Contains(checks, l => l.StartsWith("Auto-attack")); + Assert.Contains(checks, l => l.StartsWith("Confirm before salvaging")); + Assert.Contains(checks, l => l.StartsWith("Show pickup messages")); + } + + [Fact] + public void Character_tab_does_not_render_when_a_different_tab_is_active() + { + var (panel, _, _, _) = Build(); + var r = new FakePanelRenderer { ActiveTabLabel = "Display" }; + + panel.Render(new PanelContext(0.016f, new NullBus()), r); + + var combos = r.Calls.Where(c => c.Method == "Combo") + .Select(c => (string)c.Args[0]!).ToList(); + Assert.DoesNotContain("Default chat channel", combos); + } + + [Fact] + public void Character_tab_channel_combo_uses_AvailableChannels_list() + { + var (panel, _, _, _) = Build(); + var r = new FakePanelRenderer { ActiveTabLabel = "Character" }; + + panel.Render(new PanelContext(0.016f, new NullBus()), r); + + var ch = r.Calls.First(c => c.Method == "Combo" && (string)c.Args[0]! == "Default chat channel"); + var items = (string[])ch.Args[2]!; + Assert.Contains("Local", items); + Assert.Contains("Allegiance", items); + Assert.Contains("Fellowship", items); + } + + [Fact] + public void All_six_tabs_are_now_implemented_no_placeholder_text_remains() + { + // After the L.0 build order finishes, no tab should render the + // "Coming soon" placeholder line. If a future commit re-adds a + // placeholder tab without updating this test, it will fail. + var (panel, _, _, _) = Build(); + + foreach (var tabLabel in new[] { "Keybinds", "Display", "Audio", "Gameplay", "Chat", "Character" }) + { + var r = new FakePanelRenderer { ActiveTabLabel = tabLabel }; + panel.Render(new PanelContext(0.016f, new NullBus()), r); + + var wrapped = r.Calls.Where(c => c.Method == "TextWrapped") + .Select(c => (string)c.Args[0]!).ToList(); + Assert.DoesNotContain(wrapped, t => t.Contains("coming soon")); + } } // -- Display tab content --------------------------------------------- diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs index dac57aa..edc24b2 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs @@ -284,4 +284,87 @@ public sealed class SettingsStoreTests : System.IDisposable Assert.False(store.LoadChat().HearTradeChat); Assert.Equal(14f, store.LoadChat().FontSize); } + + // -- Character section round-trip (per-toon) -------------------------- + + [Fact] + public void LoadCharacter_returns_defaults_when_file_is_missing() + { + var store = new SettingsStore(_tempPath); + Assert.Equal(CharacterSettings.Default, store.LoadCharacter("default")); + } + + [Fact] + public void LoadCharacter_returns_defaults_when_toonKey_not_in_file() + { + // File exists with a different toon's data; asking for "+Acdream" + // returns defaults rather than the other toon's data. + var store = new SettingsStore(_tempPath); + store.SaveCharacter("Bob", CharacterSettings.Default with { AutoAttack = true }); + + var loaded = store.LoadCharacter("+Acdream"); + Assert.Equal(CharacterSettings.Default, loaded); + } + + [Fact] + public void SaveCharacter_then_LoadCharacter_round_trips_all_fields() + { + var store = new SettingsStore(_tempPath); + var original = new CharacterSettings( + DefaultChatChannel: "Allegiance", + AutoAttack: true, + ConfirmSalvage: false, + ShowPickupMessages: false); + + store.SaveCharacter("+Acdream", original); + Assert.Equal(original, store.LoadCharacter("+Acdream")); + } + + [Fact] + public void SaveCharacter_preserves_other_toons_within_character_section() + { + // Two different toons, each with distinct settings — saving one + // must not clobber the other. + var store = new SettingsStore(_tempPath); + var alice = CharacterSettings.Default with { DefaultChatChannel = "Allegiance" }; + var bob = CharacterSettings.Default with { DefaultChatChannel = "Fellowship", AutoAttack = true }; + + store.SaveCharacter("Alice", alice); + store.SaveCharacter("Bob", bob); + + Assert.Equal(alice, store.LoadCharacter("Alice")); + Assert.Equal(bob, store.LoadCharacter("Bob")); + } + + [Fact] + public void SaveCharacter_preserves_other_top_level_sections() + { + // Display/audio survive when SaveCharacter writes its nested map. + var store = new SettingsStore(_tempPath); + store.SaveDisplay(DisplaySettings.Default with { Resolution = "2560x1440" }); + store.SaveAudio(AudioSettings.Default with { Master = 0.4f }); + store.SaveCharacter("+Acdream", CharacterSettings.Default with { AutoAttack = true }); + + Assert.Equal("2560x1440", store.LoadDisplay().Resolution); + Assert.Equal(0.4f, store.LoadAudio().Master); + Assert.True(store.LoadCharacter("+Acdream").AutoAttack); + } + + [Fact] + public void All_five_sections_coexist_in_one_settings_json() + { + var store = new SettingsStore(_tempPath); + store.SaveDisplay(DisplaySettings.Default with { Resolution = "2560x1440" }); + store.SaveAudio(AudioSettings.Default with { Master = 0.5f }); + store.SaveGameplay(GameplaySettings.Default with { LockUI = true }); + store.SaveChat(ChatSettings.Default with { HearTradeChat = false }); + store.SaveCharacter("+Acdream", + CharacterSettings.Default with { DefaultChatChannel = "Fellowship" }); + + Assert.Equal("2560x1440", store.LoadDisplay().Resolution); + Assert.Equal(0.5f, store.LoadAudio().Master); + Assert.True(store.LoadGameplay().LockUI); + Assert.False(store.LoadChat().HearTradeChat); + Assert.Equal("Fellowship", store.LoadCharacter("+Acdream").DefaultChatChannel); + } } diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs index 14b2341..1bedd6c 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs @@ -16,8 +16,8 @@ namespace AcDream.UI.Abstractions.Tests.Panels.Settings; /// public sealed class SettingsVMTests { - private static (SettingsVM vm, FakeKeyboardSource kb, InputDispatcher dispatcher, KeyBindings persisted, System.Collections.Generic.List savedHistory, System.Collections.Generic.List savedDisplayHistory, System.Collections.Generic.List savedAudioHistory, System.Collections.Generic.List savedGameplayHistory, System.Collections.Generic.List savedChatHistory) - Build(KeyBindings? persisted = null, DisplaySettings? persistedDisplay = null, AudioSettings? persistedAudio = null, GameplaySettings? persistedGameplay = null, ChatSettings? persistedChat = null) + private static (SettingsVM vm, FakeKeyboardSource kb, InputDispatcher dispatcher, KeyBindings persisted, System.Collections.Generic.List savedHistory, System.Collections.Generic.List savedDisplayHistory, System.Collections.Generic.List savedAudioHistory, System.Collections.Generic.List savedGameplayHistory, System.Collections.Generic.List savedChatHistory, System.Collections.Generic.List savedCharacterHistory) + Build(KeyBindings? persisted = null, DisplaySettings? persistedDisplay = null, AudioSettings? persistedAudio = null, GameplaySettings? persistedGameplay = null, ChatSettings? persistedChat = null, CharacterSettings? persistedCharacter = null) { persisted ??= MakeMinimalBindings(); var kb = new FakeKeyboardSource(); @@ -28,6 +28,7 @@ public sealed class SettingsVMTests var savedAudioHistory = new System.Collections.Generic.List(); var savedGameplayHistory = new System.Collections.Generic.List(); var savedChatHistory = new System.Collections.Generic.List(); + var savedCharacterHistory = new System.Collections.Generic.List(); var vm = new SettingsVM( persisted, dispatcher, b => savedHistory.Add(b), @@ -38,8 +39,10 @@ public sealed class SettingsVMTests persistedGameplay ?? GameplaySettings.Default, g => savedGameplayHistory.Add(g), persistedChat ?? ChatSettings.Default, - c => savedChatHistory.Add(c)); - return (vm, kb, dispatcher, persisted, savedHistory, savedDisplayHistory, savedAudioHistory, savedGameplayHistory, savedChatHistory); + c => savedChatHistory.Add(c), + persistedCharacter ?? CharacterSettings.Default, + ch => savedCharacterHistory.Add(ch)); + return (vm, kb, dispatcher, persisted, savedHistory, savedDisplayHistory, savedAudioHistory, savedGameplayHistory, savedChatHistory, savedCharacterHistory); } private static KeyBindings MakeMinimalBindings() @@ -54,7 +57,7 @@ public sealed class SettingsVMTests [Fact] public void Constructor_clones_persisted_into_draft() { - var (vm, _, _, persisted, _, _, _, _, _) = Build(); + var (vm, _, _, persisted, _, _, _, _, _, _) = Build(); Assert.Equal(persisted.All.Count, vm.Draft.All.Count); Assert.False(vm.HasUnsavedChanges); } @@ -62,7 +65,7 @@ public sealed class SettingsVMTests [Fact] public void BeginRebind_enters_capture_mode() { - var (vm, _, dispatcher, _, _, _, _, _, _) = Build(); + var (vm, _, dispatcher, _, _, _, _, _, _, _) = Build(); var original = vm.Draft.ForAction(InputAction.MovementForward).First(); vm.BeginRebind(InputAction.MovementForward, original); @@ -75,7 +78,7 @@ public sealed class SettingsVMTests [Fact] public void BeginRebind_then_chord_with_no_conflict_applies_rebind() { - var (vm, kb, _, _, _, _, _, _, _) = Build(); + var (vm, kb, _, _, _, _, _, _, _, _) = Build(); var original = vm.Draft.ForAction(InputAction.MovementForward).First(); vm.BeginRebind(InputAction.MovementForward, original); @@ -93,7 +96,7 @@ public sealed class SettingsVMTests [Fact] public void BeginRebind_then_Escape_cancels_with_no_change() { - var (vm, kb, _, _, _, _, _, _, _) = Build(); + var (vm, kb, _, _, _, _, _, _, _, _) = Build(); var original = vm.Draft.ForAction(InputAction.MovementForward).First(); vm.BeginRebind(InputAction.MovementForward, original); @@ -110,7 +113,7 @@ public sealed class SettingsVMTests [Fact] public void BeginRebind_with_conflict_surfaces_PendingConflict() { - var (vm, kb, _, _, _, _, _, _, _) = Build(); + var (vm, kb, _, _, _, _, _, _, _, _) = Build(); var original = vm.Draft.ForAction(InputAction.MovementForward).First(); // Bind chord that conflicts with MovementTurnLeft (which has Key.A). @@ -130,7 +133,7 @@ public sealed class SettingsVMTests [Fact] public void ResolveConflict_replace_true_removes_conflict_and_applies_rebind() { - var (vm, kb, _, _, _, _, _, _, _) = Build(); + var (vm, kb, _, _, _, _, _, _, _, _) = Build(); var original = vm.Draft.ForAction(InputAction.MovementForward).First(); vm.BeginRebind(InputAction.MovementForward, original); @@ -151,7 +154,7 @@ public sealed class SettingsVMTests [Fact] public void ResolveConflict_replace_false_cancels_rebind() { - var (vm, kb, _, _, _, _, _, _, _) = Build(); + var (vm, kb, _, _, _, _, _, _, _, _) = Build(); var original = vm.Draft.ForAction(InputAction.MovementForward).First(); vm.BeginRebind(InputAction.MovementForward, original); @@ -173,7 +176,7 @@ public sealed class SettingsVMTests { // Build a draft that's been mutated for MovementForward; ensure // ResetActionToDefault restores W (and Up-arrow per retail). - var (vm, kb, _, _, _, _, _, _, _) = Build(KeyBindings.RetailDefaults()); + var (vm, kb, _, _, _, _, _, _, _, _) = Build(KeyBindings.RetailDefaults()); var original = vm.Draft.ForAction(InputAction.MovementForward).First(); vm.BeginRebind(InputAction.MovementForward, original); // F7 is unbound in retail-default (only Ctrl+F7 is acdream debug); @@ -193,7 +196,7 @@ public sealed class SettingsVMTests [Fact] public void ResetAllToDefaults_replaces_entire_draft() { - var (vm, _, _, _, _, _, _, _, _) = Build(); + var (vm, _, _, _, _, _, _, _, _, _) = Build(); vm.ResetAllToDefaults(); // Should now include retail-default size set (~149 bindings). @@ -204,7 +207,7 @@ public sealed class SettingsVMTests [Fact] public void Save_invokes_callback_with_draft() { - var (vm, kb, _, _, savedHistory, _, _, _, _) = Build(); + var (vm, kb, _, _, savedHistory, _, _, _, _, _) = Build(); var original = vm.Draft.ForAction(InputAction.MovementForward).First(); vm.BeginRebind(InputAction.MovementForward, original); kb.EmitKeyDown(Key.Q, ModifierMask.None); @@ -220,7 +223,7 @@ public sealed class SettingsVMTests [Fact] public void Cancel_reverts_draft_to_persisted() { - var (vm, kb, _, _, _, _, _, _, _) = Build(); + var (vm, kb, _, _, _, _, _, _, _, _) = Build(); var original = vm.Draft.ForAction(InputAction.MovementForward).First(); vm.BeginRebind(InputAction.MovementForward, original); kb.EmitKeyDown(Key.Q, ModifierMask.None); @@ -236,7 +239,7 @@ public sealed class SettingsVMTests [Fact] public void Cancel_during_active_capture_clears_dispatcher_capture_state() { - var (vm, _, dispatcher, _, _, _, _, _, _) = Build(); + var (vm, _, dispatcher, _, _, _, _, _, _, _) = Build(); var original = vm.Draft.ForAction(InputAction.MovementForward).First(); vm.BeginRebind(InputAction.MovementForward, original); @@ -249,7 +252,7 @@ public sealed class SettingsVMTests [Fact] public void HasUnsavedChanges_false_initially_and_after_save_sync() { - var (vm, _, _, _, _, _, _, _, _) = Build(); + var (vm, _, _, _, _, _, _, _, _, _) = Build(); Assert.False(vm.HasUnsavedChanges); } @@ -259,7 +262,7 @@ public sealed class SettingsVMTests public void DisplayDraft_initial_value_matches_persisted() { var custom = DisplaySettings.Default with { FieldOfView = 90f, ShowFps = true }; - var (vm, _, _, _, _, _, _, _, _) = Build(persistedDisplay: custom); + var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedDisplay: custom); Assert.Equal(custom, vm.DisplayDraft); Assert.False(vm.HasUnsavedChanges); } @@ -267,7 +270,7 @@ public sealed class SettingsVMTests [Fact] public void SetDisplay_marks_unsaved_changes() { - var (vm, _, _, _, _, _, _, _, _) = Build(); + var (vm, _, _, _, _, _, _, _, _, _) = Build(); vm.SetDisplay(vm.DisplayDraft with { ShowFps = true }); Assert.True(vm.HasUnsavedChanges); } @@ -275,7 +278,7 @@ public sealed class SettingsVMTests [Fact] public void Save_invokes_display_callback_with_draft() { - var (vm, _, _, _, _, savedDisplayHistory, _, _, _) = Build(); + var (vm, _, _, _, _, savedDisplayHistory, _, _, _, _) = Build(); vm.SetDisplay(vm.DisplayDraft with { Resolution = "2560x1440", FieldOfView = 100f }); vm.Save(); @@ -290,7 +293,7 @@ public sealed class SettingsVMTests public void Cancel_reverts_display_draft_to_persisted() { var custom = DisplaySettings.Default with { FieldOfView = 90f }; - var (vm, _, _, _, _, _, _, _, _) = Build(persistedDisplay: custom); + var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedDisplay: custom); vm.SetDisplay(vm.DisplayDraft with { FieldOfView = 30f, ShowFps = true }); Assert.True(vm.HasUnsavedChanges); @@ -304,7 +307,7 @@ public sealed class SettingsVMTests public void ResetAllToDefaults_resets_display_to_default() { var custom = DisplaySettings.Default with { FieldOfView = 30f, ShowFps = true }; - var (vm, _, _, _, _, _, _, _, _) = Build(persistedDisplay: custom); + var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedDisplay: custom); Assert.NotEqual(DisplaySettings.Default, vm.DisplayDraft); vm.ResetAllToDefaults(); @@ -319,7 +322,7 @@ public sealed class SettingsVMTests // After Save the persisted snapshot equals the draft, so Cancel // is a no-op. This guards the Save/Cancel ordering — a regression // would surface as Cancel reverting to pre-Save values. - var (vm, _, _, _, _, _, _, _, _) = Build(); + var (vm, _, _, _, _, _, _, _, _, _) = Build(); vm.SetDisplay(vm.DisplayDraft with { ShowFps = true }); vm.Save(); Assert.False(vm.HasUnsavedChanges); @@ -336,7 +339,7 @@ public sealed class SettingsVMTests public void AudioDraft_initial_value_matches_persisted() { var custom = AudioSettings.Default with { Master = 0.3f, Music = 0.1f }; - var (vm, _, _, _, _, _, _, _, _) = Build(persistedAudio: custom); + var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedAudio: custom); Assert.Equal(custom, vm.AudioDraft); Assert.False(vm.HasUnsavedChanges); } @@ -344,7 +347,7 @@ public sealed class SettingsVMTests [Fact] public void SetAudio_marks_unsaved_changes() { - var (vm, _, _, _, _, _, _, _, _) = Build(); + var (vm, _, _, _, _, _, _, _, _, _) = Build(); vm.SetAudio(vm.AudioDraft with { Master = 0.5f }); Assert.True(vm.HasUnsavedChanges); } @@ -352,7 +355,7 @@ public sealed class SettingsVMTests [Fact] public void Save_invokes_audio_callback_with_draft() { - var (vm, _, _, _, _, _, savedAudioHistory, _, _) = Build(); + var (vm, _, _, _, _, _, savedAudioHistory, _, _, _) = Build(); vm.SetAudio(vm.AudioDraft with { Master = 0.4f, Sfx = 0.6f }); vm.Save(); @@ -367,7 +370,7 @@ public sealed class SettingsVMTests public void Cancel_reverts_audio_draft_to_persisted() { var custom = AudioSettings.Default with { Music = 0.2f }; - var (vm, _, _, _, _, _, _, _, _) = Build(persistedAudio: custom); + var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedAudio: custom); vm.SetAudio(vm.AudioDraft with { Music = 0.9f, Master = 0.3f }); Assert.True(vm.HasUnsavedChanges); @@ -381,7 +384,7 @@ public sealed class SettingsVMTests public void ResetAllToDefaults_resets_audio_to_default() { var custom = AudioSettings.Default with { Master = 0.1f }; - var (vm, _, _, _, _, _, _, _, _) = Build(persistedAudio: custom); + var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedAudio: custom); Assert.NotEqual(AudioSettings.Default, vm.AudioDraft); vm.ResetAllToDefaults(); @@ -396,7 +399,7 @@ public sealed class SettingsVMTests public void GameplayDraft_initial_value_matches_persisted() { var custom = GameplaySettings.Default with { AutoTarget = false, LockUI = true }; - var (vm, _, _, _, _, _, _, _, _) = Build(persistedGameplay: custom); + var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedGameplay: custom); Assert.Equal(custom, vm.GameplayDraft); Assert.False(vm.HasUnsavedChanges); } @@ -404,7 +407,7 @@ public sealed class SettingsVMTests [Fact] public void SetGameplay_marks_unsaved_changes() { - var (vm, _, _, _, _, _, _, _, _) = Build(); + var (vm, _, _, _, _, _, _, _, _, _) = Build(); vm.SetGameplay(vm.GameplayDraft with { LockUI = true }); Assert.True(vm.HasUnsavedChanges); } @@ -412,7 +415,7 @@ public sealed class SettingsVMTests [Fact] public void Save_invokes_gameplay_callback_with_draft() { - var (vm, _, _, _, _, _, _, savedGameplayHistory, _) = Build(); + var (vm, _, _, _, _, _, _, savedGameplayHistory, _, _) = Build(); vm.SetGameplay(vm.GameplayDraft with { AutoTarget = false, @@ -433,7 +436,7 @@ public sealed class SettingsVMTests public void Cancel_reverts_gameplay_draft_to_persisted() { var custom = GameplaySettings.Default with { LockUI = true }; - var (vm, _, _, _, _, _, _, _, _) = Build(persistedGameplay: custom); + var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedGameplay: custom); vm.SetGameplay(vm.GameplayDraft with { LockUI = false, ShowHelm = false }); Assert.True(vm.HasUnsavedChanges); @@ -447,7 +450,7 @@ public sealed class SettingsVMTests public void ResetAllToDefaults_resets_gameplay_to_default() { var custom = GameplaySettings.Default with { AutoTarget = false, LockUI = true }; - var (vm, _, _, _, _, _, _, _, _) = Build(persistedGameplay: custom); + var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedGameplay: custom); Assert.NotEqual(GameplaySettings.Default, vm.GameplayDraft); vm.ResetAllToDefaults(); @@ -462,7 +465,7 @@ public sealed class SettingsVMTests public void ChatDraft_initial_value_matches_persisted() { var custom = ChatSettings.Default with { HearTradeChat = false, FontSize = 14f }; - var (vm, _, _, _, _, _, _, _, _) = Build(persistedChat: custom); + var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedChat: custom); Assert.Equal(custom, vm.ChatDraft); Assert.False(vm.HasUnsavedChanges); } @@ -470,7 +473,7 @@ public sealed class SettingsVMTests [Fact] public void SetChat_marks_unsaved_changes() { - var (vm, _, _, _, _, _, _, _, _) = Build(); + var (vm, _, _, _, _, _, _, _, _, _) = Build(); vm.SetChat(vm.ChatDraft with { FontSize = 16f }); Assert.True(vm.HasUnsavedChanges); } @@ -478,7 +481,7 @@ public sealed class SettingsVMTests [Fact] public void Save_invokes_chat_callback_with_draft() { - var (vm, _, _, _, _, _, _, _, savedChatHistory) = Build(); + var (vm, _, _, _, _, _, _, _, savedChatHistory, _) = Build(); vm.SetChat(vm.ChatDraft with { HearTradeChat = false, ShowTimestamps = false }); vm.Save(); @@ -493,7 +496,7 @@ public sealed class SettingsVMTests public void Cancel_reverts_chat_draft_to_persisted() { var custom = ChatSettings.Default with { HearLFGChat = false }; - var (vm, _, _, _, _, _, _, _, _) = Build(persistedChat: custom); + var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedChat: custom); vm.SetChat(vm.ChatDraft with { HearLFGChat = true, AppearOffline = true }); Assert.True(vm.HasUnsavedChanges); @@ -507,7 +510,7 @@ public sealed class SettingsVMTests public void ResetAllToDefaults_resets_chat_to_default() { var custom = ChatSettings.Default with { HearGeneralChat = false, FontSize = 18f }; - var (vm, _, _, _, _, _, _, _, _) = Build(persistedChat: custom); + var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedChat: custom); Assert.NotEqual(ChatSettings.Default, vm.ChatDraft); vm.ResetAllToDefaults(); @@ -515,4 +518,70 @@ public sealed class SettingsVMTests Assert.Equal(ChatSettings.Default, vm.ChatDraft); Assert.True(vm.HasUnsavedChanges); } + + // -- Character tab state ---------------------------------------------- + + [Fact] + public void CharacterDraft_initial_value_matches_persisted() + { + var custom = CharacterSettings.Default with { AutoAttack = true, DefaultChatChannel = "Allegiance" }; + var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedCharacter: custom); + Assert.Equal(custom, vm.CharacterDraft); + Assert.False(vm.HasUnsavedChanges); + } + + [Fact] + public void SetCharacter_marks_unsaved_changes() + { + var (vm, _, _, _, _, _, _, _, _, _) = Build(); + vm.SetCharacter(vm.CharacterDraft with { AutoAttack = true }); + Assert.True(vm.HasUnsavedChanges); + } + + [Fact] + public void Save_invokes_character_callback_with_draft() + { + var (vm, _, _, _, _, _, _, _, _, savedCharacterHistory) = Build(); + vm.SetCharacter(vm.CharacterDraft with + { + DefaultChatChannel = "Fellowship", + AutoAttack = true, + ConfirmSalvage = false, + }); + + vm.Save(); + + Assert.Single(savedCharacterHistory); + Assert.Equal("Fellowship", savedCharacterHistory[0].DefaultChatChannel); + Assert.True(savedCharacterHistory[0].AutoAttack); + Assert.False(savedCharacterHistory[0].ConfirmSalvage); + Assert.False(vm.HasUnsavedChanges); + } + + [Fact] + public void Cancel_reverts_character_draft_to_persisted() + { + var custom = CharacterSettings.Default with { AutoAttack = true }; + var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedCharacter: custom); + vm.SetCharacter(vm.CharacterDraft with { AutoAttack = false, DefaultChatChannel = "Trade" }); + Assert.True(vm.HasUnsavedChanges); + + vm.Cancel(); + + Assert.Equal(custom, vm.CharacterDraft); + Assert.False(vm.HasUnsavedChanges); + } + + [Fact] + public void ResetAllToDefaults_resets_character_to_default() + { + var custom = CharacterSettings.Default with { AutoAttack = true, DefaultChatChannel = "Trade" }; + var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedCharacter: custom); + Assert.NotEqual(CharacterSettings.Default, vm.CharacterDraft); + + vm.ResetAllToDefaults(); + + Assert.Equal(CharacterSettings.Default, vm.CharacterDraft); + Assert.True(vm.HasUnsavedChanges); + } }