From b7165e5b17ad6090ce5daabd16d33c3efed05774 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 26 Apr 2026 18:05:07 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20Gameplay=20tab=20=E2=80=94=2014=20r?= =?UTF-8?q?etail=20CharacterOption-derived=20toggles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase L.0 (cont.) — third tab on the Settings shell, in the Easy-wins build order. Subset of retail's CharacterOption + CharacterOptions2 bitfield flags ported as bools (see acclient.h:3404+ enum). Local- only this phase per the brainstorm — server sync deferred to a later phase that will marshal the draft into the retail CharacterOption packet. GameplaySettings record exposes 14 named flags grouped by usage: · Combat: AutoTarget, AutoRepeatAttack, ToggleRun, AdvancedCombatUI, VividTargetingIndicator · Display: ShowTooltips, SideBySideVitals, CoordinatesOnRadar, SpellDuration, ShowHelm, ShowCloak · Interface: AllowGive, LockUI, UseMouseTurning Retail names + bit values are documented in field-level comments so the future server-sync phase has a 1:1 mapping. Defaults are typical-user starting points (NOT bit-exact to retail's 0x50C4A54A / 0x948700 masks); class-level remarks call out that defaults will be re-anchored to retail values once the wire-format is the load-bearing source. SettingsStore grows LoadGameplay / SaveGameplay using the existing SaveSection generic helper (added in the audio commit). All three non-keybind sections (display, audio, gameplay) now coexist in settings.json with non-destructive cross-section saves — verified by a new "all three sections coexist" round-trip test. SettingsVM grows the parallel gameplay state machine (gameplayPersisted / gameplayDraft / SetGameplay / onSaveGameplay). HasUnsavedChanges, Save, Cancel, ResetAllToDefaults all cover gameplay too. Constructor signature adds two more params; existing call sites (App startup + tests) updated. SettingsPanel.RenderGameplayTab replaces the L.0-shell placeholder — 14 Checkbox calls grouped under three Text+Separator headers, plus a footer note explaining the local-only-this-phase scope. The "Coming soon" placeholder test was retargeted from "Gameplay" to "Chat" since Gameplay is no longer a placeholder. GameWindow construction site loads gameplay on startup + writes via the SettingsStore on Save. Server-sync packet wiring is left as a TODO comment in the onSaveGameplay callback (next phase, after the protocol round-trip is in place). 14 new tests: · GameplaySettings record (3) — defaults pinned, value equality, with-expressions · SettingsStore gameplay (4) — missing-file → defaults, round-trip, partial-file fallback, all-three-sections coexist · SettingsVM gameplay (5) — initial draft, SetGameplay marks dirty, Save invokes callback, Cancel reverts, ResetAllToDefaults covers · SettingsPanel gameplay tab (2) — 8 spot-checked Checkboxes render only when active dotnet build green (0 warnings); dotnet test 1,276 / 1,276 green (243 Core.Net + 360 UI.Abstractions + 673 Core). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 23 +++- .../Panels/Settings/GameplaySettings.cs | 61 +++++++++ .../Panels/Settings/SettingsPanel.cs | 86 +++++++++++- .../Panels/Settings/SettingsStore.cs | 63 +++++++++ .../Panels/Settings/SettingsVM.cs | 75 ++++++++--- .../Panels/Settings/GameplaySettingsTests.cs | 54 ++++++++ .../Panels/Settings/SettingsPanelTests.cs | 51 ++++++- .../Panels/Settings/SettingsStoreTests.cs | 60 +++++++++ .../Panels/Settings/SettingsVMTests.cs | 125 ++++++++++++++---- 9 files changed, 539 insertions(+), 59 deletions(-) create mode 100644 src/AcDream.UI.Abstractions/Panels/Settings/GameplaySettings.cs create mode 100644 tests/AcDream.UI.Abstractions.Tests/Panels/Settings/GameplaySettingsTests.cs diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 7d1214f..a9a3e39 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -906,8 +906,9 @@ public sealed class GameWindow : IDisposable // which keeps its own load/save path. var settingsStore = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore( AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath()); - var persistedDisplay = settingsStore.LoadDisplay(); - var persistedAudio = settingsStore.LoadAudio(); + var persistedDisplay = settingsStore.LoadDisplay(); + var persistedAudio = settingsStore.LoadAudio(); + var persistedGameplay = settingsStore.LoadGameplay(); // Apply persisted audio to the engine BEFORE the panel // host starts pushing per-frame so the first frame uses @@ -968,6 +969,24 @@ public sealed class GameWindow : IDisposable { Console.WriteLine($"settings: audio save failed: {ex.Message}"); } + }, + persistedGameplay: persistedGameplay, + onSaveGameplay: gameplay => + { + try + { + settingsStore.SaveGameplay(gameplay); + Console.WriteLine( + "settings: gameplay saved to " + + AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath()); + // Local-only this phase. Server-sync packet + // (CharacterOption bitmask) goes in here when + // the protocol round-trip is in place. + } + catch (Exception ex) + { + Console.WriteLine($"settings: gameplay 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/GameplaySettings.cs b/src/AcDream.UI.Abstractions/Panels/Settings/GameplaySettings.cs new file mode 100644 index 0000000..4b1d43e --- /dev/null +++ b/src/AcDream.UI.Abstractions/Panels/Settings/GameplaySettings.cs @@ -0,0 +1,61 @@ +namespace AcDream.UI.Abstractions.Panels.Settings; + +/// +/// Gameplay-related preferences persisted to settings.json. +/// Mirrors a subset of retail's CharacterOption + CharacterOptions2 +/// bitfield flags (see docs/research/named-retail/acclient.h:3404+). +/// Retail names are kept verbatim so future server-sync packs these +/// into the wire-format bitmask without renaming. +/// +/// +/// L.0 scope: local-only. The brainstorm explicitly deferred +/// server sync — on Save these values are persisted to settings.json +/// only. A later phase will marshal them into the retail +/// CharacterOption packet (0x...) when the protocol work +/// for player-options round-trip is in place. +/// +/// +/// +/// Defaults below are chosen as the typical-user starting point, NOT +/// pinned bit-exact to retail's 0x50C4A54A / 0x948700 +/// masks (those will become the defaults once server-sync ships and +/// the bitmask round-trip is the load-bearing wire format). +/// +/// +public sealed record GameplaySettings( + // CharacterOption (32-bit) subset — most-used gameplay toggles. + bool AutoTarget, // 0x2000 — combat: auto-acquire target on attack + bool AutoRepeatAttack, // 0x2 — combat: keep attacking after first hit + bool ToggleRun, // 0x400 — run-mode is tap-once vs hold-to-run + bool AdvancedCombatUI, // 0x1000 — show extra combat tooltips/panels + bool ShowTooltips, // 0x100 — show item tooltips on hover + bool VividTargetingIndicator, // 0x8000 — bright targeting reticle + bool SideBySideVitals, // 0x200000 — health/stam/mana side-by-side vs stacked + bool CoordinatesOnRadar, // 0x400000 — show NS/EW coords on radar + bool SpellDuration, // 0x800000 — show remaining duration on enchantment icons + bool AllowGive, // 0x40 — accept items handed by other players + // CharacterOptions2 (32-bit) subset. + bool ShowHelm, // 0x100000 — render helm overlay on character + bool ShowCloak, // 0x800000 — render cloak on character + bool LockUI, // 0x1000000 — disable panel drag/resize + bool UseMouseTurning) // 0x400000 — turn character when right-mouse drags +{ + /// Sensible starting values for first launch. NOT bit-exact + /// to retail's Default_CharacterOption = 0x50C4A54A + + /// Default_CharacterOptions2 = 0x948700 — see class remarks. + public static GameplaySettings Default { get; } = new( + AutoTarget: true, + AutoRepeatAttack: true, + ToggleRun: true, + AdvancedCombatUI: false, + ShowTooltips: true, + VividTargetingIndicator: false, + SideBySideVitals: false, + CoordinatesOnRadar: false, + SpellDuration: true, + AllowGive: true, + ShowHelm: true, + ShowCloak: true, + LockUI: false, + UseMouseTurning: false); +} diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs index 7d8c78e..6b30883 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs @@ -93,7 +93,7 @@ public sealed class SettingsPanel : IPanel } if (renderer.BeginTabItem("Gameplay")) { - RenderPlaceholder(renderer, "Gameplay"); + RenderGameplayTab(renderer); renderer.EndTabItem(); } if (renderer.BeginTabItem("Chat")) @@ -272,6 +272,90 @@ public sealed class SettingsPanel : IPanel + "values to settings.json; Cancel reverts to the saved values."); } + /// + /// Render the Gameplay tab — ~14 toggles ported from retail's + /// CharacterOption + CharacterOptions2 bitfields. Local-only this + /// phase (no server sync). Grouped into Combat / Display / Interface + /// for first-run discoverability. + /// + private void RenderGameplayTab(IPanelRenderer renderer) + { + var g = _vm.GameplayDraft; + + renderer.Text("Combat"); + renderer.Separator(); + + bool autoTarget = g.AutoTarget; + if (renderer.Checkbox("Auto-target on attack", ref autoTarget)) + _vm.SetGameplay(g with { AutoTarget = autoTarget }); + + bool autoRepeat = g.AutoRepeatAttack; + if (renderer.Checkbox("Auto-repeat attacks", ref autoRepeat)) + _vm.SetGameplay(g with { AutoRepeatAttack = autoRepeat }); + + bool toggleRun = g.ToggleRun; + if (renderer.Checkbox("Run mode is toggle (vs hold)", ref toggleRun)) + _vm.SetGameplay(g with { ToggleRun = toggleRun }); + + bool advCombat = g.AdvancedCombatUI; + if (renderer.Checkbox("Show advanced combat UI", ref advCombat)) + _vm.SetGameplay(g with { AdvancedCombatUI = advCombat }); + + bool vivid = g.VividTargetingIndicator; + if (renderer.Checkbox("Vivid targeting indicator", ref vivid)) + _vm.SetGameplay(g with { VividTargetingIndicator = vivid }); + + renderer.Spacing(); + renderer.Text("Display"); + renderer.Separator(); + + bool tooltips = g.ShowTooltips; + if (renderer.Checkbox("Show item tooltips", ref tooltips)) + _vm.SetGameplay(g with { ShowTooltips = tooltips }); + + bool sideBySide = g.SideBySideVitals; + if (renderer.Checkbox("Side-by-side vital orbs", ref sideBySide)) + _vm.SetGameplay(g with { SideBySideVitals = sideBySide }); + + bool coords = g.CoordinatesOnRadar; + if (renderer.Checkbox("Show coordinates on radar", ref coords)) + _vm.SetGameplay(g with { CoordinatesOnRadar = coords }); + + bool spellDur = g.SpellDuration; + if (renderer.Checkbox("Show spell duration on enchantments", ref spellDur)) + _vm.SetGameplay(g with { SpellDuration = spellDur }); + + bool helm = g.ShowHelm; + if (renderer.Checkbox("Show helm on character", ref helm)) + _vm.SetGameplay(g with { ShowHelm = helm }); + + bool cloak = g.ShowCloak; + if (renderer.Checkbox("Show cloak on character", ref cloak)) + _vm.SetGameplay(g with { ShowCloak = cloak }); + + renderer.Spacing(); + renderer.Text("Interface"); + renderer.Separator(); + + bool allowGive = g.AllowGive; + if (renderer.Checkbox("Accept items handed by other players", ref allowGive)) + _vm.SetGameplay(g with { AllowGive = allowGive }); + + bool lockUI = g.LockUI; + if (renderer.Checkbox("Lock UI (disable panel drag/resize)", ref lockUI)) + _vm.SetGameplay(g with { LockUI = lockUI }); + + bool mouseTurn = g.UseMouseTurning; + if (renderer.Checkbox("Use mouse turning", ref mouseTurn)) + _vm.SetGameplay(g with { UseMouseTurning = mouseTurn }); + + renderer.Spacing(); + renderer.TextWrapped( + "Local-only this phase — values persist to settings.json but " + + "don't yet sync to the server. Server sync arrives in a " + + "follow-up phase."); + } + 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 ad07c35..8a25aa4 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs @@ -123,6 +123,69 @@ public sealed class SettingsStore public void SaveAudio(AudioSettings audio) => SaveSection("audio", BuildAudioObject(audio)); + /// + /// Load Gameplay settings (subset of retail CharacterOption flags). + /// Same fall-back behaviour as . + /// + public GameplaySettings LoadGameplay() + { + if (!File.Exists(_path)) return GameplaySettings.Default; + try + { + using var stream = File.OpenRead(_path); + var doc = JsonDocument.Parse(stream); + var root = doc.RootElement; + if (!root.TryGetProperty("gameplay", out var gp) + || gp.ValueKind != JsonValueKind.Object) + return GameplaySettings.Default; + + var d = GameplaySettings.Default; + return new GameplaySettings( + AutoTarget: ReadBool(gp, "autoTarget", d.AutoTarget), + AutoRepeatAttack: ReadBool(gp, "autoRepeatAttack", d.AutoRepeatAttack), + ToggleRun: ReadBool(gp, "toggleRun", d.ToggleRun), + AdvancedCombatUI: ReadBool(gp, "advancedCombatUI", d.AdvancedCombatUI), + ShowTooltips: ReadBool(gp, "showTooltips", d.ShowTooltips), + VividTargetingIndicator: ReadBool(gp, "vividTargetingIndicator", d.VividTargetingIndicator), + SideBySideVitals: ReadBool(gp, "sideBySideVitals", d.SideBySideVitals), + CoordinatesOnRadar: ReadBool(gp, "coordinatesOnRadar", d.CoordinatesOnRadar), + SpellDuration: ReadBool(gp, "spellDuration", d.SpellDuration), + AllowGive: ReadBool(gp, "allowGive", d.AllowGive), + ShowHelm: ReadBool(gp, "showHelm", d.ShowHelm), + ShowCloak: ReadBool(gp, "showCloak", d.ShowCloak), + LockUI: ReadBool(gp, "lockUI", d.LockUI), + UseMouseTurning: ReadBool(gp, "useMouseTurning", d.UseMouseTurning)); + } + catch (Exception ex) + { + Console.WriteLine($"settings: failed to load {_path}: {ex.Message} — using defaults"); + return GameplaySettings.Default; + } + } + + /// Save Gameplay settings, preserving all other top-level keys. + public void SaveGameplay(GameplaySettings gameplay) + => SaveSection("gameplay", BuildGameplayObject(gameplay)); + + private static SortedDictionary BuildGameplayObject(GameplaySettings g) + => new(StringComparer.Ordinal) + { + ["advancedCombatUI"] = g.AdvancedCombatUI, + ["allowGive"] = g.AllowGive, + ["autoRepeatAttack"] = g.AutoRepeatAttack, + ["autoTarget"] = g.AutoTarget, + ["coordinatesOnRadar"] = g.CoordinatesOnRadar, + ["lockUI"] = g.LockUI, + ["showCloak"] = g.ShowCloak, + ["showHelm"] = g.ShowHelm, + ["showTooltips"] = g.ShowTooltips, + ["sideBySideVitals"] = g.SideBySideVitals, + ["spellDuration"] = g.SpellDuration, + ["toggleRun"] = g.ToggleRun, + ["useMouseTurning"] = g.UseMouseTurning, + ["vividTargetingIndicator"] = g.VividTargetingIndicator, + }; + private static SortedDictionary BuildDisplayObject(DisplaySettings d) => new(StringComparer.Ordinal) { diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs index d90bf0a..4368ca7 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs @@ -41,6 +41,11 @@ public sealed class SettingsVM private AudioSettings _audioDraft; private readonly Action _onSaveAudio; + // L.0 — Gameplay tab (subset of retail CharacterOption flags). + private GameplaySettings _gameplayPersisted; + private GameplaySettings _gameplayDraft; + private readonly Action _onSaveGameplay; + /// The action currently being rebound, or null when idle. public InputAction? RebindInProgress { get; private set; } @@ -63,8 +68,9 @@ public sealed class SettingsVM /// rebinds are pending. public bool HasUnsavedChanges => !KeyBindingsEqual(_persisted, _draft) - || _displayPersisted != _displayDraft - || _audioPersisted != _audioDraft; + || _displayPersisted != _displayDraft + || _audioPersisted != _audioDraft + || _gameplayPersisted != _gameplayDraft; /// The current Display draft. Panel reads from here; /// mutation goes through . @@ -74,6 +80,10 @@ public sealed class SettingsVM /// mutation goes through . public AudioSettings AudioDraft => _audioDraft; + /// The current Gameplay draft. Panel reads from here; + /// mutation goes through . + public GameplaySettings GameplayDraft => _gameplayDraft; + public SettingsVM( KeyBindings persisted, InputDispatcher dispatcher, @@ -81,18 +91,23 @@ public sealed class SettingsVM DisplaySettings persistedDisplay, Action onSaveDisplay, AudioSettings persistedAudio, - Action onSaveAudio) + Action onSaveAudio, + GameplaySettings persistedGameplay, + Action onSaveGameplay) { - _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)); - _draft = CloneBindings(persisted); - _displayDraft = persistedDisplay; - _audioDraft = persistedAudio; + _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)); + _draft = CloneBindings(persisted); + _displayDraft = persistedDisplay; + _audioDraft = persistedAudio; + _gameplayDraft = persistedGameplay; } /// @@ -117,6 +132,18 @@ public sealed class SettingsVM _audioDraft = value ?? throw new ArgumentNullException(nameof(value)); } + /// + /// Replace the entire Gameplay draft with . + /// Local-only this phase — values persist on Save but don't yet + /// flow to the server. When server-sync ships, the host's + /// onSaveGameplay callback will marshal the draft into the + /// retail CharacterOption wire bitmask. + /// + public void SetGameplay(GameplaySettings value) + { + _gameplayDraft = value ?? throw new ArgumentNullException(nameof(value)); + } + /// /// Begin rebinding . The supplied /// binding will be removed when the new @@ -224,9 +251,10 @@ public sealed class SettingsVM /// public void ResetAllToDefaults() { - _draft = KeyBindings.RetailDefaults(); - _displayDraft = DisplaySettings.Default; - _audioDraft = AudioSettings.Default; + _draft = KeyBindings.RetailDefaults(); + _displayDraft = DisplaySettings.Default; + _audioDraft = AudioSettings.Default; + _gameplayDraft = GameplaySettings.Default; } /// @@ -242,9 +270,11 @@ public sealed class SettingsVM _onSave(_draft); _onSaveDisplay(_displayDraft); _onSaveAudio(_audioDraft); - _persisted = CloneBindings(_draft); - _displayPersisted = _displayDraft; - _audioPersisted = _audioDraft; + _onSaveGameplay(_gameplayDraft); + _persisted = CloneBindings(_draft); + _displayPersisted = _displayDraft; + _audioPersisted = _audioDraft; + _gameplayPersisted = _gameplayDraft; } /// @@ -254,9 +284,10 @@ public sealed class SettingsVM /// public void Cancel() { - _draft = CloneBindings(_persisted); - _displayDraft = _displayPersisted; - _audioDraft = _audioPersisted; + _draft = CloneBindings(_persisted); + _displayDraft = _displayPersisted; + _audioDraft = _audioPersisted; + _gameplayDraft = _gameplayPersisted; CancelRebind(); } diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/GameplaySettingsTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/GameplaySettingsTests.cs new file mode 100644 index 0000000..b21f444 --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/GameplaySettingsTests.cs @@ -0,0 +1,54 @@ +using AcDream.UI.Abstractions.Panels.Settings; + +namespace AcDream.UI.Abstractions.Tests.Panels.Settings; + +/// +/// L.0: default-pin tests + value-equality +/// guarantees. Defaults are intentionally NOT bit-exact to retail's +/// 0x50C4A54A mask — see GameplaySettings remarks for rationale. +/// +public sealed class GameplaySettingsTests +{ + [Fact] + public void Default_values_are_typical_user_friendly() + { + // These defaults are reviewed in the L.0 brainstorm — typical-user + // starting point, not retail-bitmask. A change to any of these + // should be a deliberate decision, not a drive-by. + var d = GameplaySettings.Default; + Assert.True(d.AutoTarget); + Assert.True(d.AutoRepeatAttack); + Assert.True(d.ToggleRun); + Assert.False(d.AdvancedCombatUI); + Assert.True(d.ShowTooltips); + Assert.False(d.VividTargetingIndicator); + Assert.False(d.SideBySideVitals); + Assert.False(d.CoordinatesOnRadar); + Assert.True(d.SpellDuration); + Assert.True(d.AllowGive); + Assert.True(d.ShowHelm); + Assert.True(d.ShowCloak); + Assert.False(d.LockUI); + Assert.False(d.UseMouseTurning); + } + + [Fact] + public void Equality_is_value_based() + { + var a = GameplaySettings.Default; + var b = GameplaySettings.Default with { AutoTarget = false }; + var c = GameplaySettings.Default with { AutoTarget = false }; + Assert.NotEqual(a, b); + Assert.Equal(b, c); + } + + [Fact] + public void With_expression_clones_one_field() + { + var d = GameplaySettings.Default with { LockUI = true }; + Assert.True(d.LockUI); + // Other fields untouched. + Assert.Equal(GameplaySettings.Default.AutoTarget, d.AutoTarget); + Assert.Equal(GameplaySettings.Default.ShowHelm, d.ShowHelm); + } +} diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs index 4181f88..78450b1 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs @@ -32,7 +32,8 @@ public sealed class SettingsPanelTests var vm = new SettingsVM( persisted, dispatcher, _ => { }, DisplaySettings.Default, _ => { }, - AudioSettings.Default, _ => { }); + AudioSettings.Default, _ => { }, + GameplaySettings.Default, _ => { }); var panel = new SettingsPanel(vm); return (panel, vm, kb, dispatcher); } @@ -227,17 +228,17 @@ public sealed class SettingsPanelTests [Fact] public void Placeholder_tabs_render_coming_soon_text_when_active() { - // Gameplay is still a placeholder (next in build order). Display - // and Audio have shipped — they have real widgets, not "coming - // soon" text. + // Chat is still a placeholder (next in build order). Display, + // Audio, and Gameplay have shipped — they have real widgets, + // not "coming soon" text. var (panel, _, _, _) = Build(); - var r = new FakePanelRenderer { ActiveTabLabel = "Gameplay" }; + var r = new FakePanelRenderer { ActiveTabLabel = "Chat" }; 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("Gameplay settings coming soon")); + Assert.Contains(wrapped, t => t.Contains("Chat settings coming soon")); } // -- Display tab content --------------------------------------------- @@ -320,6 +321,44 @@ public sealed class SettingsPanelTests Assert.DoesNotContain("Music", sliders); } + // -- Gameplay tab content -------------------------------------------- + + [Fact] + public void Gameplay_tab_when_active_renders_expected_checkboxes() + { + var (panel, _, _, _) = Build(); + var r = new FakePanelRenderer { ActiveTabLabel = "Gameplay" }; + + 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(); + // Spot check the major retail-named toggles. Don't assert exact + // count — adding new toggles shouldn't break this test. + Assert.Contains("Auto-target on attack", checks); + Assert.Contains("Auto-repeat attacks", checks); + Assert.Contains("Run mode is toggle (vs hold)", checks); + Assert.Contains("Show item tooltips", checks); + Assert.Contains("Show helm on character", checks); + Assert.Contains("Show cloak on character", checks); + Assert.Contains("Lock UI (disable panel drag/resize)", checks); + Assert.Contains("Use mouse turning", checks); + } + + [Fact] + public void Gameplay_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(); + Assert.DoesNotContain("Auto-target on attack", checks); + Assert.DoesNotContain("Lock UI (disable panel drag/resize)", 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 bef09fc..cdb0baa 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs @@ -180,4 +180,64 @@ public sealed class SettingsStoreTests : System.IDisposable Assert.Equal(0.1f, store.LoadAudio().Music); Assert.True(store.LoadDisplay().ShowFps); } + + // -- Gameplay section round-trip -------------------------------------- + + [Fact] + public void LoadGameplay_returns_defaults_when_file_is_missing() + { + var store = new SettingsStore(_tempPath); + Assert.Equal(GameplaySettings.Default, store.LoadGameplay()); + } + + [Fact] + public void SaveGameplay_then_LoadGameplay_round_trips_all_fields() + { + var store = new SettingsStore(_tempPath); + var original = GameplaySettings.Default with + { + AutoTarget = false, + AdvancedCombatUI = true, + ShowHelm = false, + LockUI = true, + UseMouseTurning = true, + }; + + store.SaveGameplay(original); + var loaded = store.LoadGameplay(); + + Assert.Equal(original, loaded); + } + + [Fact] + public void LoadGameplay_falls_back_per_field_when_keys_missing() + { + File.WriteAllText(_tempPath, """ + { + "version": 1, + "gameplay": { "lockUI": true } + } + """); + var store = new SettingsStore(_tempPath); + + var loaded = store.LoadGameplay(); + + Assert.True(loaded.LockUI); + Assert.Equal(GameplaySettings.Default.AutoTarget, loaded.AutoTarget); + Assert.Equal(GameplaySettings.Default.ShowHelm, loaded.ShowHelm); + } + + [Fact] + public void All_three_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 }); + + // All three load correctly from the same file. + Assert.Equal("2560x1440", store.LoadDisplay().Resolution); + Assert.Equal(0.5f, store.LoadAudio().Master); + Assert.True(store.LoadGameplay().LockUI); + } } diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs index 25e5c69..940e449 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) - Build(KeyBindings? persisted = null, DisplaySettings? persistedDisplay = null, AudioSettings? persistedAudio = 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) + Build(KeyBindings? persisted = null, DisplaySettings? persistedDisplay = null, AudioSettings? persistedAudio = null, GameplaySettings? persistedGameplay = null) { persisted ??= MakeMinimalBindings(); var kb = new FakeKeyboardSource(); @@ -26,14 +26,17 @@ public sealed class SettingsVMTests var savedHistory = new System.Collections.Generic.List(); var savedDisplayHistory = new System.Collections.Generic.List(); var savedAudioHistory = new System.Collections.Generic.List(); + var savedGameplayHistory = new System.Collections.Generic.List(); var vm = new SettingsVM( persisted, dispatcher, b => savedHistory.Add(b), persistedDisplay ?? DisplaySettings.Default, d => savedDisplayHistory.Add(d), persistedAudio ?? AudioSettings.Default, - a => savedAudioHistory.Add(a)); - return (vm, kb, dispatcher, persisted, savedHistory, savedDisplayHistory, savedAudioHistory); + a => savedAudioHistory.Add(a), + persistedGameplay ?? GameplaySettings.Default, + g => savedGameplayHistory.Add(g)); + return (vm, kb, dispatcher, persisted, savedHistory, savedDisplayHistory, savedAudioHistory, savedGameplayHistory); } private static KeyBindings MakeMinimalBindings() @@ -48,7 +51,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); } @@ -56,7 +59,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); @@ -69,7 +72,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); @@ -87,7 +90,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); @@ -104,7 +107,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). @@ -124,7 +127,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); @@ -145,7 +148,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); @@ -167,7 +170,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); @@ -187,7 +190,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). @@ -198,7 +201,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); @@ -214,7 +217,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); @@ -230,7 +233,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); @@ -243,7 +246,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); } @@ -253,7 +256,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); } @@ -261,7 +264,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); } @@ -269,7 +272,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(); @@ -284,7 +287,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); @@ -298,7 +301,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(); @@ -313,7 +316,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); @@ -330,7 +333,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); } @@ -338,7 +341,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); } @@ -346,7 +349,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(); @@ -361,7 +364,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); @@ -375,7 +378,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(); @@ -383,4 +386,70 @@ public sealed class SettingsVMTests Assert.Equal(AudioSettings.Default, vm.AudioDraft); Assert.True(vm.HasUnsavedChanges); } + + // -- Gameplay tab state ----------------------------------------------- + + [Fact] + public void GameplayDraft_initial_value_matches_persisted() + { + var custom = GameplaySettings.Default with { AutoTarget = false, LockUI = true }; + var (vm, _, _, _, _, _, _, _) = Build(persistedGameplay: custom); + Assert.Equal(custom, vm.GameplayDraft); + Assert.False(vm.HasUnsavedChanges); + } + + [Fact] + public void SetGameplay_marks_unsaved_changes() + { + var (vm, _, _, _, _, _, _, _) = Build(); + vm.SetGameplay(vm.GameplayDraft with { LockUI = true }); + Assert.True(vm.HasUnsavedChanges); + } + + [Fact] + public void Save_invokes_gameplay_callback_with_draft() + { + var (vm, _, _, _, _, _, _, savedGameplayHistory) = Build(); + vm.SetGameplay(vm.GameplayDraft with + { + AutoTarget = false, + ShowTooltips = false, + UseMouseTurning = true, + }); + + vm.Save(); + + Assert.Single(savedGameplayHistory); + Assert.False(savedGameplayHistory[0].AutoTarget); + Assert.False(savedGameplayHistory[0].ShowTooltips); + Assert.True(savedGameplayHistory[0].UseMouseTurning); + Assert.False(vm.HasUnsavedChanges); + } + + [Fact] + public void Cancel_reverts_gameplay_draft_to_persisted() + { + var custom = GameplaySettings.Default with { LockUI = true }; + var (vm, _, _, _, _, _, _, _) = Build(persistedGameplay: custom); + vm.SetGameplay(vm.GameplayDraft with { LockUI = false, ShowHelm = false }); + Assert.True(vm.HasUnsavedChanges); + + vm.Cancel(); + + Assert.Equal(custom, vm.GameplayDraft); + Assert.False(vm.HasUnsavedChanges); + } + + [Fact] + public void ResetAllToDefaults_resets_gameplay_to_default() + { + var custom = GameplaySettings.Default with { AutoTarget = false, LockUI = true }; + var (vm, _, _, _, _, _, _, _) = Build(persistedGameplay: custom); + Assert.NotEqual(GameplaySettings.Default, vm.GameplayDraft); + + vm.ResetAllToDefaults(); + + Assert.Equal(GameplaySettings.Default, vm.GameplayDraft); + Assert.True(vm.HasUnsavedChanges); + } }