feat(ui): Gameplay tab — 14 retail CharacterOption-derived toggles

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) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-26 18:05:07 +02:00
parent 53b1878c5c
commit b7165e5b17
9 changed files with 539 additions and 59 deletions

View file

@ -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()
{