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