diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index a9a3e39..51ca85f 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -909,6 +909,7 @@ public sealed class GameWindow : IDisposable
var persistedDisplay = settingsStore.LoadDisplay();
var persistedAudio = settingsStore.LoadAudio();
var persistedGameplay = settingsStore.LoadGameplay();
+ var persistedChat = settingsStore.LoadChat();
// Apply persisted audio to the engine BEFORE the panel
// host starts pushing per-frame so the first frame uses
@@ -987,6 +988,25 @@ public sealed class GameWindow : IDisposable
{
Console.WriteLine($"settings: gameplay save failed: {ex.Message}");
}
+ },
+ persistedChat: persistedChat,
+ onSaveChat: chat =>
+ {
+ try
+ {
+ settingsStore.SaveChat(chat);
+ Console.WriteLine(
+ "settings: chat saved to "
+ + AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath());
+ // Channel filters affect client-side display
+ // only this phase. ChatPanel will read them
+ // off SettingsVM.ChatDraft when filtering is
+ // wired into the chat-line render path.
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"settings: chat 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/ChatSettings.cs b/src/AcDream.UI.Abstractions/Panels/Settings/ChatSettings.cs
new file mode 100644
index 0000000..74972fc
--- /dev/null
+++ b/src/AcDream.UI.Abstractions/Panels/Settings/ChatSettings.cs
@@ -0,0 +1,44 @@
+namespace AcDream.UI.Abstractions.Panels.Settings;
+
+///
+/// Chat-related preferences persisted to settings.json. Mixes
+/// retail's CharacterOptions2 chat-channel filter bits (Hear*Chat
+/// + TimeStamp + FilterLanguage + AppearOffline) with a few visual
+/// preferences (font size) that don't have a retail bitfield.
+/// See docs/research/named-retail/acclient.h:3451+ for the
+/// retail bit values.
+///
+///
+/// L.0 scope: local-only like the rest of L.0. The Hear*Chat
+/// flags affect client-side display filtering of the existing
+/// channels — the server still streams every line; the client decides
+/// what to render. Server-sync arrives in a later phase that flips the
+/// retail-faithful "tell server which channels I'm subscribed to"
+/// switch.
+///
+///
+public sealed record ChatSettings(
+ // CharacterOptions2 (32-bit) channel filters.
+ bool HearGeneralChat, // 0x100 — General channel
+ bool HearTradeChat, // 0x200 — Trade channel
+ bool HearLFGChat, // 0x400 — LFG channel
+ bool HearRoleplayChat, // 0x800 — RP channel
+ bool HearSocietyChat, // 0x80000 — Society chat (CD/EW/RB)
+ bool AppearOffline, // 0x1000 — hide /who status
+ bool ShowTimestamps, // 0x40 — TimeStamp prefix on chat lines
+ bool FilterProfanity, // 0x20000 — FilterLanguage (Turbine's profanity filter)
+ // Visual / UX (no retail bitfield).
+ float FontSize) // chat panel font, 10..20 pt
+{
+ /// Sensible starting values matching the retail "all on" stance.
+ public static ChatSettings Default { get; } = new(
+ HearGeneralChat: true,
+ HearTradeChat: true,
+ HearLFGChat: true,
+ HearRoleplayChat: true,
+ HearSocietyChat: true,
+ AppearOffline: false,
+ ShowTimestamps: true,
+ FilterProfanity: true,
+ FontSize: 12f);
+}
diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs
index 6b30883..fef6291 100644
--- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs
+++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs
@@ -98,7 +98,7 @@ public sealed class SettingsPanel : IPanel
}
if (renderer.BeginTabItem("Chat"))
{
- RenderPlaceholder(renderer, "Chat");
+ RenderChatTab(renderer);
renderer.EndTabItem();
}
if (renderer.BeginTabItem("Character"))
@@ -356,6 +356,67 @@ public sealed class SettingsPanel : IPanel
+ "follow-up phase.");
}
+ ///
+ /// Render the Chat tab — channel filters (Hear*Chat), display
+ /// preferences (timestamps / profanity filter / appear offline),
+ /// and a font-size slider. Channel filters affect client-side
+ /// display only this phase — the server still sends every line,
+ /// the client decides what to render.
+ ///
+ private void RenderChatTab(IPanelRenderer renderer)
+ {
+ var c = _vm.ChatDraft;
+
+ renderer.Text("Channel filters");
+ renderer.Separator();
+
+ bool general = c.HearGeneralChat;
+ if (renderer.Checkbox("General", ref general))
+ _vm.SetChat(c with { HearGeneralChat = general });
+
+ bool trade = c.HearTradeChat;
+ if (renderer.Checkbox("Trade", ref trade))
+ _vm.SetChat(c with { HearTradeChat = trade });
+
+ bool lfg = c.HearLFGChat;
+ if (renderer.Checkbox("LFG (looking for group)", ref lfg))
+ _vm.SetChat(c with { HearLFGChat = lfg });
+
+ bool rp = c.HearRoleplayChat;
+ if (renderer.Checkbox("Roleplay", ref rp))
+ _vm.SetChat(c with { HearRoleplayChat = rp });
+
+ bool society = c.HearSocietyChat;
+ if (renderer.Checkbox("Society (CD / EW / RB)", ref society))
+ _vm.SetChat(c with { HearSocietyChat = society });
+
+ renderer.Spacing();
+ renderer.Text("Display");
+ renderer.Separator();
+
+ bool timestamps = c.ShowTimestamps;
+ if (renderer.Checkbox("Show timestamps", ref timestamps))
+ _vm.SetChat(c with { ShowTimestamps = timestamps });
+
+ bool profanity = c.FilterProfanity;
+ if (renderer.Checkbox("Filter profanity", ref profanity))
+ _vm.SetChat(c with { FilterProfanity = profanity });
+
+ bool offline = c.AppearOffline;
+ if (renderer.Checkbox("Appear offline (hide from /who)", ref offline))
+ _vm.SetChat(c with { AppearOffline = offline });
+
+ float fontSize = c.FontSize;
+ if (renderer.SliderFloat("Font size (pt)", ref fontSize, 10f, 20f))
+ _vm.SetChat(c with { FontSize = fontSize });
+
+ renderer.Spacing();
+ renderer.TextWrapped(
+ "Channel filters hide messages from the chat window without "
+ + "changing your server-side subscriptions. Save persists; "
+ + "Cancel reverts.");
+ }
+
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 8a25aa4..277b0d0 100644
--- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs
+++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs
@@ -167,6 +167,56 @@ public sealed class SettingsStore
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));
+
+ 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)
{
diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs
index 4368ca7..159f75d 100644
--- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs
+++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs
@@ -46,6 +46,11 @@ public sealed class SettingsVM
private GameplaySettings _gameplayDraft;
private readonly Action _onSaveGameplay;
+ // L.0 — Chat tab (CharacterOptions2 channel filters + visual prefs).
+ private ChatSettings _chatPersisted;
+ private ChatSettings _chatDraft;
+ private readonly Action _onSaveChat;
+
/// The action currently being rebound, or null when idle.
public InputAction? RebindInProgress { get; private set; }
@@ -70,7 +75,8 @@ public sealed class SettingsVM
=> !KeyBindingsEqual(_persisted, _draft)
|| _displayPersisted != _displayDraft
|| _audioPersisted != _audioDraft
- || _gameplayPersisted != _gameplayDraft;
+ || _gameplayPersisted != _gameplayDraft
+ || _chatPersisted != _chatDraft;
/// The current Display draft. Panel reads from here;
/// mutation goes through .
@@ -84,6 +90,10 @@ public sealed class SettingsVM
/// mutation goes through .
public GameplaySettings GameplayDraft => _gameplayDraft;
+ /// The current Chat draft. Panel reads from here;
+ /// mutation goes through .
+ public ChatSettings ChatDraft => _chatDraft;
+
public SettingsVM(
KeyBindings persisted,
InputDispatcher dispatcher,
@@ -93,7 +103,9 @@ public sealed class SettingsVM
AudioSettings persistedAudio,
Action onSaveAudio,
GameplaySettings persistedGameplay,
- Action onSaveGameplay)
+ Action onSaveGameplay,
+ ChatSettings persistedChat,
+ Action onSaveChat)
{
_persisted = persisted ?? throw new ArgumentNullException(nameof(persisted));
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
@@ -104,10 +116,13 @@ public sealed class SettingsVM
_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;
}
///
@@ -144,6 +159,17 @@ public sealed class SettingsVM
_gameplayDraft = value ?? throw new ArgumentNullException(nameof(value));
}
+ ///
+ /// Replace the entire Chat draft with .
+ /// Local-only this phase — values persist on Save but the Hear*Chat
+ /// flags affect client-side display filtering, not server-side
+ /// channel subscriptions.
+ ///
+ public void SetChat(ChatSettings value)
+ {
+ _chatDraft = value ?? throw new ArgumentNullException(nameof(value));
+ }
+
///
/// Begin rebinding . The supplied
/// binding will be removed when the new
@@ -255,6 +281,7 @@ public sealed class SettingsVM
_displayDraft = DisplaySettings.Default;
_audioDraft = AudioSettings.Default;
_gameplayDraft = GameplaySettings.Default;
+ _chatDraft = ChatSettings.Default;
}
///
@@ -271,10 +298,12 @@ public sealed class SettingsVM
_onSaveDisplay(_displayDraft);
_onSaveAudio(_audioDraft);
_onSaveGameplay(_gameplayDraft);
+ _onSaveChat(_chatDraft);
_persisted = CloneBindings(_draft);
_displayPersisted = _displayDraft;
_audioPersisted = _audioDraft;
_gameplayPersisted = _gameplayDraft;
+ _chatPersisted = _chatDraft;
}
///
@@ -288,6 +317,7 @@ public sealed class SettingsVM
_displayDraft = _displayPersisted;
_audioDraft = _audioPersisted;
_gameplayDraft = _gameplayPersisted;
+ _chatDraft = _chatPersisted;
CancelRebind();
}
diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/ChatSettingsTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/ChatSettingsTests.cs
new file mode 100644
index 0000000..12ec900
--- /dev/null
+++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/ChatSettingsTests.cs
@@ -0,0 +1,43 @@
+using AcDream.UI.Abstractions.Panels.Settings;
+
+namespace AcDream.UI.Abstractions.Tests.Panels.Settings;
+
+///
+/// L.0: default-pin tests.
+///
+public sealed class ChatSettingsTests
+{
+ [Fact]
+ public void Default_values_are_all_channels_on_with_timestamps_and_filter()
+ {
+ var d = ChatSettings.Default;
+ Assert.True(d.HearGeneralChat);
+ Assert.True(d.HearTradeChat);
+ Assert.True(d.HearLFGChat);
+ Assert.True(d.HearRoleplayChat);
+ Assert.True(d.HearSocietyChat);
+ Assert.False(d.AppearOffline);
+ Assert.True(d.ShowTimestamps);
+ Assert.True(d.FilterProfanity);
+ Assert.Equal(12f, d.FontSize);
+ }
+
+ [Fact]
+ public void Equality_is_value_based()
+ {
+ var a = ChatSettings.Default;
+ var b = ChatSettings.Default with { HearTradeChat = false };
+ var c = ChatSettings.Default with { HearTradeChat = false };
+ Assert.NotEqual(a, b);
+ Assert.Equal(b, c);
+ }
+
+ [Fact]
+ public void With_expression_clones_one_field()
+ {
+ var d = ChatSettings.Default with { FontSize = 16f };
+ Assert.Equal(16f, d.FontSize);
+ Assert.True(d.HearGeneralChat);
+ Assert.True(d.ShowTimestamps);
+ }
+}
diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs
index 78450b1..72a1ba4 100644
--- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs
+++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs
@@ -33,7 +33,8 @@ public sealed class SettingsPanelTests
persisted, dispatcher, _ => { },
DisplaySettings.Default, _ => { },
AudioSettings.Default, _ => { },
- GameplaySettings.Default, _ => { });
+ GameplaySettings.Default, _ => { },
+ ChatSettings.Default, _ => { });
var panel = new SettingsPanel(vm);
return (panel, vm, kb, dispatcher);
}
@@ -228,17 +229,17 @@ public sealed class SettingsPanelTests
[Fact]
public void Placeholder_tabs_render_coming_soon_text_when_active()
{
- // Chat is still a placeholder (next in build order). Display,
- // Audio, and Gameplay have shipped — they have real widgets,
- // not "coming soon" text.
+ // 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 = "Chat" };
+ var r = new FakePanelRenderer { ActiveTabLabel = "Character" };
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.Contains(wrapped, t => t.Contains("Chat settings coming soon"));
+ Assert.Contains(wrapped, t => t.Contains("Character settings coming soon"));
}
// -- Display tab content ---------------------------------------------
@@ -359,6 +360,48 @@ public sealed class SettingsPanelTests
Assert.DoesNotContain("Lock UI (disable panel drag/resize)", checks);
}
+ // -- Chat tab content ------------------------------------------------
+
+ [Fact]
+ public void Chat_tab_when_active_renders_channel_filter_checkboxes_and_font_slider()
+ {
+ var (panel, _, _, _) = Build();
+ var r = new FakePanelRenderer { ActiveTabLabel = "Chat" };
+
+ panel.Render(new PanelContext(0.016f, new NullBus()), r);
+
+ var checks = r.Calls.Where(c => c.Method == "Checkbox")
+ .Select(c => (string)c.Args[0]!).ToList();
+ Assert.Contains("General", checks);
+ Assert.Contains("Trade", checks);
+ Assert.Contains("LFG (looking for group)", checks);
+ Assert.Contains("Roleplay", checks);
+ Assert.Contains("Society (CD / EW / RB)", checks);
+ Assert.Contains("Show timestamps", checks);
+ Assert.Contains("Filter profanity", checks);
+ Assert.Contains("Appear offline (hide from /who)", checks);
+
+ var sliders = r.Calls.Where(c => c.Method == "SliderFloat")
+ .Select(c => (string)c.Args[0]!).ToList();
+ Assert.Contains("Font size (pt)", sliders);
+ }
+
+ [Fact]
+ public void Chat_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 checks = r.Calls.Where(c => c.Method == "Checkbox")
+ .Select(c => (string)c.Args[0]!).ToList();
+ // The tab labels "General", "Trade" etc only appear inside the
+ // Chat tab. Confirm none of them rendered.
+ Assert.DoesNotContain("General", checks);
+ Assert.DoesNotContain("Trade", checks);
+ }
+
[Fact]
public void Audio_sliders_are_clamped_to_zero_one_range()
{
diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs
index cdb0baa..dac57aa 100644
--- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs
+++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs
@@ -240,4 +240,48 @@ public sealed class SettingsStoreTests : System.IDisposable
Assert.Equal(0.5f, store.LoadAudio().Master);
Assert.True(store.LoadGameplay().LockUI);
}
+
+ // -- Chat section round-trip ------------------------------------------
+
+ [Fact]
+ public void LoadChat_returns_defaults_when_file_is_missing()
+ {
+ var store = new SettingsStore(_tempPath);
+ Assert.Equal(ChatSettings.Default, store.LoadChat());
+ }
+
+ [Fact]
+ public void SaveChat_then_LoadChat_round_trips_all_fields()
+ {
+ var store = new SettingsStore(_tempPath);
+ var original = new ChatSettings(
+ HearGeneralChat: false,
+ HearTradeChat: false,
+ HearLFGChat: false,
+ HearRoleplayChat: true,
+ HearSocietyChat: true,
+ AppearOffline: true,
+ ShowTimestamps: false,
+ FilterProfanity: false,
+ FontSize: 16f);
+
+ store.SaveChat(original);
+ Assert.Equal(original, store.LoadChat());
+ }
+
+ [Fact]
+ public void All_four_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, FontSize = 14f });
+
+ 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(14f, store.LoadChat().FontSize);
+ }
}
diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs
index 940e449..14b2341 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)
- Build(KeyBindings? persisted = null, DisplaySettings? persistedDisplay = null, AudioSettings? persistedAudio = null, GameplaySettings? persistedGameplay = 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)
+ Build(KeyBindings? persisted = null, DisplaySettings? persistedDisplay = null, AudioSettings? persistedAudio = null, GameplaySettings? persistedGameplay = null, ChatSettings? persistedChat = null)
{
persisted ??= MakeMinimalBindings();
var kb = new FakeKeyboardSource();
@@ -27,6 +27,7 @@ public sealed class SettingsVMTests
var savedDisplayHistory = new System.Collections.Generic.List();
var savedAudioHistory = new System.Collections.Generic.List();
var savedGameplayHistory = new System.Collections.Generic.List();
+ var savedChatHistory = new System.Collections.Generic.List();
var vm = new SettingsVM(
persisted, dispatcher,
b => savedHistory.Add(b),
@@ -35,8 +36,10 @@ public sealed class SettingsVMTests
persistedAudio ?? AudioSettings.Default,
a => savedAudioHistory.Add(a),
persistedGameplay ?? GameplaySettings.Default,
- g => savedGameplayHistory.Add(g));
- return (vm, kb, dispatcher, persisted, savedHistory, savedDisplayHistory, savedAudioHistory, savedGameplayHistory);
+ g => savedGameplayHistory.Add(g),
+ persistedChat ?? ChatSettings.Default,
+ c => savedChatHistory.Add(c));
+ return (vm, kb, dispatcher, persisted, savedHistory, savedDisplayHistory, savedAudioHistory, savedGameplayHistory, savedChatHistory);
}
private static KeyBindings MakeMinimalBindings()
@@ -51,7 +54,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);
}
@@ -59,7 +62,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);
@@ -72,7 +75,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);
@@ -90,7 +93,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);
@@ -107,7 +110,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).
@@ -127,7 +130,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);
@@ -148,7 +151,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);
@@ -170,7 +173,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);
@@ -190,7 +193,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).
@@ -201,7 +204,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);
@@ -217,7 +220,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);
@@ -233,7 +236,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);
@@ -246,7 +249,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);
}
@@ -256,7 +259,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);
}
@@ -264,7 +267,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);
}
@@ -272,7 +275,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();
@@ -287,7 +290,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);
@@ -301,7 +304,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();
@@ -316,7 +319,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);
@@ -333,7 +336,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);
}
@@ -341,7 +344,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);
}
@@ -349,7 +352,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();
@@ -364,7 +367,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);
@@ -378,7 +381,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();
@@ -393,7 +396,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);
}
@@ -401,7 +404,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);
}
@@ -409,7 +412,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,
@@ -430,7 +433,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);
@@ -444,7 +447,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();
@@ -452,4 +455,64 @@ public sealed class SettingsVMTests
Assert.Equal(GameplaySettings.Default, vm.GameplayDraft);
Assert.True(vm.HasUnsavedChanges);
}
+
+ // -- Chat tab state ---------------------------------------------------
+
+ [Fact]
+ public void ChatDraft_initial_value_matches_persisted()
+ {
+ var custom = ChatSettings.Default with { HearTradeChat = false, FontSize = 14f };
+ var (vm, _, _, _, _, _, _, _, _) = Build(persistedChat: custom);
+ Assert.Equal(custom, vm.ChatDraft);
+ Assert.False(vm.HasUnsavedChanges);
+ }
+
+ [Fact]
+ public void SetChat_marks_unsaved_changes()
+ {
+ var (vm, _, _, _, _, _, _, _, _) = Build();
+ vm.SetChat(vm.ChatDraft with { FontSize = 16f });
+ Assert.True(vm.HasUnsavedChanges);
+ }
+
+ [Fact]
+ public void Save_invokes_chat_callback_with_draft()
+ {
+ var (vm, _, _, _, _, _, _, _, savedChatHistory) = Build();
+ vm.SetChat(vm.ChatDraft with { HearTradeChat = false, ShowTimestamps = false });
+
+ vm.Save();
+
+ Assert.Single(savedChatHistory);
+ Assert.False(savedChatHistory[0].HearTradeChat);
+ Assert.False(savedChatHistory[0].ShowTimestamps);
+ Assert.False(vm.HasUnsavedChanges);
+ }
+
+ [Fact]
+ public void Cancel_reverts_chat_draft_to_persisted()
+ {
+ var custom = ChatSettings.Default with { HearLFGChat = false };
+ var (vm, _, _, _, _, _, _, _, _) = Build(persistedChat: custom);
+ vm.SetChat(vm.ChatDraft with { HearLFGChat = true, AppearOffline = true });
+ Assert.True(vm.HasUnsavedChanges);
+
+ vm.Cancel();
+
+ Assert.Equal(custom, vm.ChatDraft);
+ Assert.False(vm.HasUnsavedChanges);
+ }
+
+ [Fact]
+ public void ResetAllToDefaults_resets_chat_to_default()
+ {
+ var custom = ChatSettings.Default with { HearGeneralChat = false, FontSize = 18f };
+ var (vm, _, _, _, _, _, _, _, _) = Build(persistedChat: custom);
+ Assert.NotEqual(ChatSettings.Default, vm.ChatDraft);
+
+ vm.ResetAllToDefaults();
+
+ Assert.Equal(ChatSettings.Default, vm.ChatDraft);
+ Assert.True(vm.HasUnsavedChanges);
+ }
}