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);
+ }
}