refactor(settings): OP9 — retire the dead F11 settings surface + fully-superseded GameplaySettings

Campaign OP slice OP9 code retirement (docs/plans/2026-08-10-options-panel-campaign.md
§OP9). The retail four-tab Options panel (OP1-OP8) is now acdream's one
in-client settings surface (D1) — this commit deletes the pre-retail-UI
surfaces it fully superseded. Pure retirement: no behavior change to
anything live, verified by dispositioning every one of the 15 src files
and 8 test files that referenced the deleted types before touching any
of them.

1. SettingsVM + SettingsPanel (the old F11 IPanel surface, unrendered
   since Campaign V slice V11's ImGui deletion) deleted outright, plus
   their two dedicated test files. IPanel/IPanelRenderer stay — ChatPanel/
   DebugPanel/VitalsPanel still implement IPanel, so the contract does NOT
   become unreferenced.

2. RuntimeSettingsController's SettingsVM binding seam deleted:
   CreateViewModel, CreateViewModelBinding, RuntimeSettingsViewModelBinding,
   the _viewModel field, UnbindViewModel, and every _viewModel? notification
   call (ToggleFrameRate, LoadCharacterContext, RestoreDefaultCharacterContext,
   SyncChatFromServerOptions). CreateViewModelBinding had zero production
   callers (test-only). HasDraftPreview/DisplayPreview/AudioPreview stay on
   IRuntimeSettingsPreviewSource (WorldRenderFrameBuilder and
   SettingsParticleRangeSource still consume it) but now trivially mirror
   the committed Display/Audio snapshot — HasDraftPreview was already
   always false in production. GameWindowLifetime's matching
   "settings view model" shutdown stage is deleted.

3. IDevToolsGameplayCommands + DevToolsGameplayCommands deleted from
   GameplayInputCommandController.cs. All three members were dead:
   ToggleSettingsPanel() had ZERO dispatch sites (ToggleOptionsPanel always
   routed to _retained, never to _devTools); ToggleDebugPanel()/
   FocusChatInput() had dispatch sites (F1/Ctrl+F1, Tab) but empty no-op
   bodies (their ImGui DebugPanel/ChatPanel targets were already gone).
   The two live dispatch sites are kept as inert `return true;` cases
   (still consuming the key, matching the prior no-op's "handled"
   contract) instead of falling through to a lower-priority scope.
   SessionPlayerComposition.cs's `new DevToolsGameplayCommands()`
   construction is removed. No `ToggleSettingsPanel` InputAction exists
   (only `ToggleOptionsPanel`, rebound at OP3) — nothing to remove there
   or from KeyBindings.RetailDefaults()/keymap fixtures.

4. GameplaySettings deleted entirely (the type, SettingsStore's
   LoadGameplay/SaveGameplay/BuildGameplayObject, RuntimeSettingsController's
   Gameplay property/SetAcceptLootPermits). Verified all 13 remaining
   members (ToggleRun, AdvancedCombatUI, ShowTooltips,
   VividTargetingIndicator, SideBySideVitals, CoordinatesOnRadar,
   SpellDuration, AllowGive, ShowHelm, ShowCloak, LockUI, UseMouseTurning,
   AcceptLootPermits — the three combat ones already died at OP4/AP-196)
   were ALREADY bound through CharacterOptionTable/
   CharacterOptionsPageController's server-bit seam at OP4 before deleting
   the client-local mirror — no (c)-case genuinely-client-local member
   was found; disposition (b) covers 100% of the surface. SetUiLocked
   rewritten to compare only against the last value actually pushed to
   _runtimeTargets (MUST-FIX 4's guard), with no second store left to
   read or write. LiveSessionRuntimeFactory's SetAcceptLootPermits binding
   now sends the wire option only (the GameplaySettings write-behind call
   removed as dead output). CharacterSettings/DisplaySettings/
   AudioSettings/ChatSettings and their SettingsStore Load/Save surfaces
   are UNTOUCHED per the campaign contract.

   Per-file disposition (15 src + 8 test files that referenced
   GameplaySettings before this commit):
   - GameplaySettings.cs, SettingsVM.cs, SettingsPanel.cs: the types
     themselves — deleted.
   - SettingsStore.cs, RuntimeSettingsController.cs,
     LiveSessionRuntimeFactory.cs: real usage — API deleted/rewritten.
   - RetailUiRuntime.cs, InteractionRetainedUiComposition.cs,
     SessionPlayerComposition.cs, CombatUiController.cs,
     LiveCombatAttackOperations.cs, LivePresentationComposition.cs,
     FrameRootComposition.cs, RuntimeCharacterState.cs,
     CombatCameraTargetSource.cs: doc-comment-only or interface-name
     substring matches (ICombatGameplaySettingsSource) — left as accurate
     historical record, no forward reference to the deleted type.
   - Tests: RuntimeSettingsControllerTests.cs and SettingsStoreTests.cs
     rewritten (Gameplay-specific tests deleted; SaveDisplay/SaveAudio/
     SaveChat tests re-targeted off the now-public methods instead of the
     retired SettingsVM draft/Save() indirection); GameplaySettingsTests.cs/
     SettingsVMTests.cs/SettingsPanelTests.cs deleted; the remaining three
     (CharacterOptionCombatSettingsSourceTests.cs,
     CombatCameraTargetSourceTests.cs, LiveCombatAttackOperationsTests.cs)
     were comment/interface-name-only, untouched.

5. Register: AP-196 (OP4's partial GameplaySettings retirement, which left
   five fields as write-behind mirrors) is fully retired now that the
   record is gone outright — marked ~~AP-196~~ RETIRED with its retirement
   note, active-row count 143 -> 142. No other row cited the deleted types
   directly (AP-194/AP-193 cite CharacterOptionTable.cs, not
   GameplaySettings.cs).

6. Settings.json migration honesty: SettingsStore no longer reads or
   writes the "gameplay" top-level key, so an existing file carrying one
   from a pre-OP9 build is neither parsed nor dropped — the existing
   SaveSection raw-JSON-text preservation mechanism (unknown top-level
   keys survive every subsequent save) carries it forward untouched.
   Two new targeted tests
   (LeftoverGameplaySection_FromAnOlderSettingsJson_DoesNotBreakOtherLoads,
   LeftoverGameplaySection_SurvivesAnUnrelatedSave) pin this.

InputAction.ToggleOptionsPanel's stale doc comment (still describing the
retired ImGui SettingsPanel) and a handful of other dangling doc
references (DisplaySettings.cs, ChatOpacityLink.cs,
SettingsDevToolsComposition.cs, InputDispatcherCaptureTests.cs) are
reworded to point at the current retail Options panel / OP8
KeyboardConfigController.

Build: dotnet build -c Release green, 0 errors. Tests: dotnet test -c
Release --no-build — 13,075 passed / 4 skipped / 0 failed (13,079 total),
down from the stated baseline of 13,155 passed / 4 skipped / 0 failed
(13,159 total) — the -80 delta is exactly the deleted SettingsVM/
SettingsPanel/GameplaySettings test surface (three whole files plus the
Gameplay-specific cases trimmed from RuntimeSettingsControllerTests.cs/
SettingsStoreTests.cs), with zero regressions elsewhere.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-11 13:18:53 +02:00
parent 1a57f96efe
commit 371197a345
22 changed files with 228 additions and 3260 deletions

File diff suppressed because one or more lines are too long

View file

@ -1194,7 +1194,6 @@ internal sealed class SessionPlayerCompositionPhase
var commands = new GameplayInputCommandController( var commands = new GameplayInputCommandController(
new RetainedGameplayWindowCommands( new RetainedGameplayWindowCommands(
interaction.RetainedUi?.Runtime), interaction.RetainedUi?.Runtime),
new DevToolsGameplayCommands(),
runtimeDiagnostics, runtimeDiagnostics,
new PlayerModeGameplayCommands( new PlayerModeGameplayCommands(
d.PlayerMode, d.PlayerMode,

View file

@ -8,10 +8,14 @@ namespace AcDream.App.Composition;
/// ImGui developer-tools frontend that used to compose here (VitalsPanel, /// ImGui developer-tools frontend that used to compose here (VitalsPanel,
/// ChatPanel, DebugPanel, SettingsPanel via <c>AcDream.UI.ImGui</c>) was /// ChatPanel, DebugPanel, SettingsPanel via <c>AcDream.UI.ImGui</c>) was
/// removed at Campaign V slice V11 along with the OpenGL backend it /// removed at Campaign V slice V11 along with the OpenGL backend it
/// required — see docs/plans/2026-07-27-vulkan-campaign.md. A follow-up /// required — see docs/plans/2026-07-27-vulkan-campaign.md. The
/// re-homes the Settings and Debug panels onto the retained UI through a new /// ImGui-era SettingsPanel's promised re-home landed as Campaign OP's retail
/// <c>IPanelRenderer</c> implementation; until then keybind remapping falls /// Options panel (<c>OptionsPanelController</c> et al., the retained
/// back to editing keybinds.json. /// <c>UiHost</c>/<c>UiRoot</c> tree — D1) instead of a new
/// <c>IPanelRenderer</c> implementation, and its OP9 closeout retired the
/// unrendered ImGui-era SettingsPanel/SettingsVM outright. Keybind remapping
/// is Campaign OP slice OP8's Configure Keyboard screen, persisting to
/// keybinds.json (not retail's <c>.keymap</c> format — register row AP-202).
/// </summary> /// </summary>
internal sealed record SettingsDevToolsResult( internal sealed record SettingsDevToolsResult(
AcDream.UI.Abstractions.Settings.QualitySettings ResolvedQuality); AcDream.UI.Abstractions.Settings.QualitySettings ResolvedQuality);

View file

@ -41,37 +41,6 @@ internal sealed class RetainedGameplayWindowCommands(RetailUiRuntime? runtime)
_runtime?.ToggleWindow(WindowNames.Options); _runtime?.ToggleWindow(WindowNames.Options);
} }
internal interface IDevToolsGameplayCommands
{
void ToggleDebugPanel();
void FocusChatInput();
void ToggleSettingsPanel();
}
/// <summary>
/// The ImGui developer-tools frontend these commands used to forward to was
/// removed at Campaign V slice V11. A follow-up re-homes the Settings and
/// Debug panels onto the retained UI through a new <c>IPanelRenderer</c>
/// implementation; until then these are no-ops so the frozen gameplay-action
/// priority graph keeps a single, always-valid handoff target.
/// </summary>
internal sealed class DevToolsGameplayCommands : IDevToolsGameplayCommands
{
public void ToggleDebugPanel()
{
}
public void FocusChatInput()
{
}
public void ToggleSettingsPanel()
{
}
}
internal interface IPlayerModeGameplayCommands internal interface IPlayerModeGameplayCommands
{ {
bool IsPlayerMode { get; } bool IsPlayerMode { get; }
@ -163,7 +132,6 @@ internal interface IGameplayInputCommandTarget
internal sealed class GameplayInputCommandController : IGameplayInputCommandTarget internal sealed class GameplayInputCommandController : IGameplayInputCommandTarget
{ {
private readonly IRetainedGameplayWindowCommands _retained; private readonly IRetainedGameplayWindowCommands _retained;
private readonly IDevToolsGameplayCommands _devTools;
private readonly IRuntimeDiagnosticCommands _diagnostics; private readonly IRuntimeDiagnosticCommands _diagnostics;
private readonly IPlayerModeGameplayCommands _playerMode; private readonly IPlayerModeGameplayCommands _playerMode;
private readonly IItemTargetModeCommands _targetMode; private readonly IItemTargetModeCommands _targetMode;
@ -175,7 +143,6 @@ internal sealed class GameplayInputCommandController : IGameplayInputCommandTarg
public GameplayInputCommandController( public GameplayInputCommandController(
IRetainedGameplayWindowCommands retained, IRetainedGameplayWindowCommands retained,
IDevToolsGameplayCommands devTools,
IRuntimeDiagnosticCommands diagnostics, IRuntimeDiagnosticCommands diagnostics,
IPlayerModeGameplayCommands playerMode, IPlayerModeGameplayCommands playerMode,
IItemTargetModeCommands targetMode, IItemTargetModeCommands targetMode,
@ -186,7 +153,6 @@ internal sealed class GameplayInputCommandController : IGameplayInputCommandTarg
Action? toggleAudioMute = null) Action? toggleAudioMute = null)
{ {
_retained = retained ?? throw new ArgumentNullException(nameof(retained)); _retained = retained ?? throw new ArgumentNullException(nameof(retained));
_devTools = devTools ?? throw new ArgumentNullException(nameof(devTools));
_diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics)); _diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics));
_playerMode = playerMode ?? throw new ArgumentNullException(nameof(playerMode)); _playerMode = playerMode ?? throw new ArgumentNullException(nameof(playerMode));
_targetMode = targetMode ?? throw new ArgumentNullException(nameof(targetMode)); _targetMode = targetMode ?? throw new ArgumentNullException(nameof(targetMode));
@ -224,7 +190,14 @@ internal sealed class GameplayInputCommandController : IGameplayInputCommandTarg
_toggleAudioMute?.Invoke(); _toggleAudioMute?.Invoke();
return true; return true;
case InputAction.AcdreamToggleDebugPanel: case InputAction.AcdreamToggleDebugPanel:
_devTools.ToggleDebugPanel(); // OP9: IDevToolsGameplayCommands.ToggleDebugPanel() retired —
// its ImGui-era DebugPanel target was already gone (Campaign V
// slice V11) and no retained-UI replacement exists, so the
// no-op body it forwarded to carried no behavior to preserve.
// The key is still consumed here (matching that no-op's
// "handled" contract) rather than falling through to whatever
// a lower-priority input scope would otherwise do with
// F1/Ctrl+F1.
return true; return true;
case InputAction.AcdreamToggleFlyMode: case InputAction.AcdreamToggleFlyMode:
_playerMode.ToggleFlyOrChase(); _playerMode.ToggleFlyOrChase();
@ -233,15 +206,20 @@ internal sealed class GameplayInputCommandController : IGameplayInputCommandTarg
_playerMode.TogglePlayerMode(); _playerMode.TogglePlayerMode();
return true; return true;
case InputAction.ToggleChatEntry: case InputAction.ToggleChatEntry:
_devTools.FocusChatInput(); // OP9: IDevToolsGameplayCommands.FocusChatInput() retired —
// same shape as AcdreamToggleDebugPanel above (its ImGui
// ChatPanel target was already gone). Tab is still consumed
// here, matching the prior no-op's "handled" contract.
return true; return true;
case InputAction.ToggleOptionsPanel: case InputAction.ToggleOptionsPanel:
// Campaign OP slice OP3 (D1): F11 opens the RETAIL Options // Campaign OP slice OP3 (D1): F11 opens the RETAIL Options
// panel now, not the old (unrendered since Campaign V slice // panel now, not the old (unrendered since Campaign V slice
// V11 — DevToolsGameplayCommands' own doc) ImGui-era Settings // V11) ImGui-era Settings panel. That panel's own
// panel. IDevToolsGameplayCommands.ToggleSettingsPanel() is a // IDevToolsGameplayCommands.ToggleSettingsPanel() member was
// SEPARATE action retired in OP9; its no-op wiring elsewhere // never dispatched from anywhere (this switch always routed
// is untouched by this change (#358's lesson). // ToggleOptionsPanel to _retained, never to _devTools) and
// was retired outright at OP9 along with the rest of
// IDevToolsGameplayCommands (#358's lesson).
_retained.ToggleOptionsPanel(); _retained.ToggleOptionsPanel();
return true; return true;
case InputAction.CombatToggleCombat: case InputAction.CombatToggleCombat:

View file

@ -328,8 +328,8 @@ internal sealed class LiveSessionRuntimeFactory
// is a ONE-SHOT assignment taken pre-login, at composition // is a ONE-SHOT assignment taken pre-login, at composition
// time, from RuntimeCharacterOptionsState's constructor- // time, from RuntimeCharacterOptionsState's constructor-
// default word. Routing the real value through SetUiLocked // default word. Routing the real value through SetUiLocked
// here — the exact seam ToggleUiLock/SaveGameplay already // here — the exact seam ToggleUiLock already uses to push
// use to push RuntimeSettingsTargets.ApplyUiLock — converges // RuntimeSettingsTargets.ApplyUiLock — converges
// the retained window lock to server truth on every fresh // the retained window lock to server truth on every fresh
// PlayerDescription, matching the radar's own convergence // PlayerDescription, matching the radar's own convergence
// instead of only updating on the next manual /lockui toggle. // instead of only updating on the next manual /lockui toggle.
@ -411,8 +411,8 @@ internal sealed class LiveSessionRuntimeFactory
// immediately, exactly like the Character-tab panel row. // immediately, exactly like the Character-tab panel row.
// SetUiLocked still runs too, for its existing // SetUiLocked still runs too, for its existing
// _runtimeTargets?.ApplyUiLock(locked) immediate visual push // _runtimeTargets?.ApplyUiLock(locked) immediate visual push
// (host.Root.UiLocked) and its legacy GameplaySettings mirror // (host.Root.UiLocked) — the legacy GameplaySettings mirror it
// (retired in OP9). // used to also maintain was retired outright at OP9.
ToggleUiLock: () => ToggleUiLock: () =>
{ {
bool locked = !_domain.Character.Options.GetOptionBit( bool locked = !_domain.Character.Options.GetOptionBit(
@ -491,16 +491,15 @@ internal sealed class LiveSessionRuntimeFactory
// wire, even though retail auto-saves this id (0x0005 // wire, even though retail auto-saves this id (0x0005
// immediately). Now routes through the SAME // immediately). Now routes through the SAME
// SendSingleCharacterOption seam the Character-tab panel row // SendSingleCharacterOption seam the Character-tab panel row
// uses. // uses. OP9 retired the GameplaySettings mirror this used to
// ALSO maintain (RuntimeSettingsController.SetAcceptLootPermits)
// — the server bit read below is the sole authority now.
AcceptLootPermits: () => AcceptLootPermits: () =>
_domain.Character.Options.GetOptionBit( _domain.Character.Options.GetOptionBit(
CharacterOptionId.AcceptLootPermits), CharacterOptionId.AcceptLootPermits),
SetAcceptLootPermits: value => SetAcceptLootPermits: value =>
{
SendSingleCharacterOption( SendSingleCharacterOption(
(uint)CharacterOptionId.AcceptLootPermits, value); (uint)CharacterOptionId.AcceptLootPermits, value),
_interaction.Settings.SetAcceptLootPermits(value);
},
DisplayConsent: session.SendDisplayConsent, DisplayConsent: session.SendDisplayConsent,
ClearConsent: session.SendClearConsent, ClearConsent: session.SendClearConsent,
RemoveConsent: session.SendRemoveConsent, RemoveConsent: session.SendRemoveConsent,

View file

@ -414,7 +414,6 @@ internal static class GameWindowShutdownManifest
]), ]),
new ResourceShutdownStage("physical ingress cleanup", new ResourceShutdownStage("physical ingress cleanup",
[ [
Soft("settings view model", () => ingress.Settings.UnbindViewModel()),
Soft("retained gameplay", () => DisposeRetainedGameplay(ingress.RetainedGameplay)), Soft("retained gameplay", () => DisposeRetainedGameplay(ingress.RetainedGameplay)),
Soft("gameplay actions", () => DisposeGameplayActions(ingress.GameplayActions)), Soft("gameplay actions", () => DisposeGameplayActions(ingress.GameplayActions)),
Soft("retained UI input", ingress.RetailUi.DeactivateInput), Soft("retained UI input", ingress.RetailUi.DeactivateInput),

View file

@ -1,5 +1,4 @@
using AcDream.Core.Net.Messages; using AcDream.Core.Net.Messages;
using AcDream.UI.Abstractions.Input;
using AcDream.UI.Abstractions.Panels.Settings; using AcDream.UI.Abstractions.Panels.Settings;
using AcDream.UI.Abstractions.Settings; using AcDream.UI.Abstractions.Settings;
@ -15,8 +14,6 @@ internal interface IRuntimeSettingsStorage
AudioSettings LoadAudio(); AudioSettings LoadAudio();
GameplaySettings LoadGameplay();
ChatSettings LoadChat(); ChatSettings LoadChat();
CharacterSettings LoadCharacter(string toonKey); CharacterSettings LoadCharacter(string toonKey);
@ -29,8 +26,6 @@ internal interface IRuntimeSettingsStorage
void SaveAudio(AudioSettings audio); void SaveAudio(AudioSettings audio);
void SaveGameplay(GameplaySettings gameplay);
void SaveChat(ChatSettings chat); void SaveChat(ChatSettings chat);
void SaveCharacter(string toonKey, CharacterSettings character); void SaveCharacter(string toonKey, CharacterSettings character);
@ -57,8 +52,6 @@ internal sealed class JsonRuntimeSettingsStorage : IRuntimeSettingsStorage
public AudioSettings LoadAudio() => _store.LoadAudio(); public AudioSettings LoadAudio() => _store.LoadAudio();
public GameplaySettings LoadGameplay() => _store.LoadGameplay();
public ChatSettings LoadChat() => _store.LoadChat(); public ChatSettings LoadChat() => _store.LoadChat();
public CharacterSettings LoadCharacter(string toonKey) => public CharacterSettings LoadCharacter(string toonKey) =>
@ -70,9 +63,6 @@ internal sealed class JsonRuntimeSettingsStorage : IRuntimeSettingsStorage
public void SaveAudio(AudioSettings audio) => _store.SaveAudio(audio); public void SaveAudio(AudioSettings audio) => _store.SaveAudio(audio);
public void SaveGameplay(GameplaySettings gameplay) =>
_store.SaveGameplay(gameplay);
public void SaveChat(ChatSettings chat) => _store.SaveChat(chat); public void SaveChat(ChatSettings chat) => _store.SaveChat(chat);
public void SaveCharacter(string toonKey, CharacterSettings character) => public void SaveCharacter(string toonKey, CharacterSettings character) =>
@ -85,39 +75,10 @@ internal sealed class JsonRuntimeSettingsStorage : IRuntimeSettingsStorage
internal sealed record RuntimeSettingsSnapshot( internal sealed record RuntimeSettingsSnapshot(
DisplaySettings Display, DisplaySettings Display,
AudioSettings Audio, AudioSettings Audio,
GameplaySettings Gameplay,
ChatSettings Chat, ChatSettings Chat,
CharacterSettings Character, CharacterSettings Character,
QualitySettings Quality); QualitySettings Quality);
/// <summary>
/// Expected-owner lease for the optional developer settings view model.
/// Failed optional composition can withdraw only the instance it installed.
/// </summary>
internal sealed class RuntimeSettingsViewModelBinding : IDisposable
{
private readonly RuntimeSettingsController _owner;
private bool _disposed;
public RuntimeSettingsViewModelBinding(
RuntimeSettingsController owner,
SettingsVM viewModel)
{
_owner = owner ?? throw new ArgumentNullException(nameof(owner));
ViewModel = viewModel ?? throw new ArgumentNullException(nameof(viewModel));
}
public SettingsVM ViewModel { get; }
public void Dispose()
{
if (_disposed)
return;
_owner.UnbindViewModel(ViewModel);
_disposed = true;
}
}
internal interface IRuntimeSettingsStartupTarget internal interface IRuntimeSettingsStartupTarget
{ {
void ApplyDisplay(DisplaySettings display); void ApplyDisplay(DisplaySettings display);
@ -186,16 +147,15 @@ internal sealed class RuntimeSettingsController :
private readonly Func<QualityPreset, QualitySettings> _resolveQuality; private readonly Func<QualityPreset, QualitySettings> _resolveQuality;
private readonly Action<string> _log; private readonly Action<string> _log;
private IRuntimeSettingsTargets? _runtimeTargets; private IRuntimeSettingsTargets? _runtimeTargets;
private SettingsVM? _viewModel;
private CharacterSettings _defaultCharacter; private CharacterSettings _defaultCharacter;
private bool _startupDisplayApplied; private bool _startupDisplayApplied;
private bool _startupAudioApplied; private bool _startupAudioApplied;
private bool _startupApplied; private bool _startupApplied;
private bool _uiLockConverged = true;
// MUST-FIX 4 (OP4 review-fix round, 2026-08-11, blast M3): the last // MUST-FIX 4 (OP4 review-fix round, 2026-08-11, blast M3): the last
// `locked` value actually pushed to `_runtimeTargets.ApplyUiLock` — // `locked` value actually pushed to `_runtimeTargets.ApplyUiLock` —
// the guard `SetUiLocked` compares against, decoupled from whatever // the guard `SetUiLocked` compares against. Originally decoupled from
// `Gameplay.LockUI`'s own persisted/draft snapshot currently holds. // the retired client-local `GameplaySettings.LockUI` mirror (OP9
// deleted that record outright); this is now the ONLY store.
private bool? _lastAppliedUiLocked; private bool? _lastAppliedUiLocked;
public RuntimeSettingsController( public RuntimeSettingsController(
@ -209,7 +169,6 @@ internal sealed class RuntimeSettingsController :
Display = _storage.LoadDisplay(); Display = _storage.LoadDisplay();
Audio = _storage.LoadAudio(); Audio = _storage.LoadAudio();
Gameplay = _storage.LoadGameplay();
Chat = _storage.LoadChat(); Chat = _storage.LoadChat();
_defaultCharacter = _storage.LoadCharacter(DefaultToonKey); _defaultCharacter = _storage.LoadCharacter(DefaultToonKey);
Character = _defaultCharacter; Character = _defaultCharacter;
@ -217,7 +176,6 @@ internal sealed class RuntimeSettingsController :
Startup = new RuntimeSettingsSnapshot( Startup = new RuntimeSettingsSnapshot(
Display, Display,
Audio, Audio,
Gameplay,
Chat, Chat,
Character, Character,
ResolvedQuality); ResolvedQuality);
@ -233,19 +191,24 @@ internal sealed class RuntimeSettingsController :
public AudioSettings Audio { get; private set; } public AudioSettings Audio { get; private set; }
public GameplaySettings Gameplay { get; private set; }
public ChatSettings Chat { get; private set; } public ChatSettings Chat { get; private set; }
public CharacterSettings Character { get; private set; } public CharacterSettings Character { get; private set; }
public QualitySettings ResolvedQuality { get; private set; } public QualitySettings ResolvedQuality { get; private set; }
public bool HasDraftPreview => _viewModel is not null; // OP9: the optional developer-tools draft-preview view model
// (SettingsVM) was retired — it had zero production construction
// sites (only tests ever called CreateViewModel). HasDraftPreview is
// therefore always false in production and DisplayPreview/AudioPreview
// always mirror the committed Display/Audio snapshot; the properties
// stay on IRuntimeSettingsPreviewSource because WorldRenderFrameBuilder
// and SettingsParticleRangeSource still consume the interface.
public bool HasDraftPreview => false;
public DisplaySettings DisplayPreview => _viewModel?.DisplayDraft ?? Display; public DisplaySettings DisplayPreview => Display;
public AudioSettings AudioPreview => _viewModel?.AudioDraft ?? Audio; public AudioSettings AudioPreview => Audio;
public void ApplyStartup(IRuntimeSettingsStartupTarget target) public void ApplyStartup(IRuntimeSettingsStartupTarget target)
{ {
@ -316,94 +279,23 @@ internal sealed class RuntimeSettingsController :
.UnbindRuntimeTargets(_expected); .UnbindRuntimeTargets(_expected);
} }
public SettingsVM CreateViewModel( /// <summary>
KeyBindings persistedBindings, /// OP9: rewritten off the retired client-local <c>GameplaySettings.LockUI</c>
InputDispatcher dispatcher, /// mirror (MUST-FIX 4, OP4 review-fix round, 2026-08-11, blast M3) — the
Action<KeyBindings> saveBindings) /// guard now compares directly against <see cref="_lastAppliedUiLocked"/>,
{ /// the last value actually pushed to <see cref="_runtimeTargets"/>, with
ArgumentNullException.ThrowIfNull(persistedBindings); /// no persisted store of its own left to read or write: the server bit
ArgumentNullException.ThrowIfNull(dispatcher); /// (<c>RuntimeCharacterOptionsState</c>, read through
ArgumentNullException.ThrowIfNull(saveBindings); /// <c>CharacterOptionId.LockUI</c>) is the sole authority, exactly as
if (_viewModel is not null) /// D7's Group-C re-point already made it at OP4.
throw new InvalidOperationException("A settings view model is already bound."); /// </summary>
_viewModel = new SettingsVM(
persistedBindings,
dispatcher,
saveBindings,
Display,
SaveDisplay,
Audio,
SaveAudio,
Gameplay,
SaveGameplay,
Chat,
SaveChat,
Character,
SaveCharacter);
return _viewModel;
}
public RuntimeSettingsViewModelBinding CreateViewModelBinding(
KeyBindings persistedBindings,
InputDispatcher dispatcher,
Action<KeyBindings> saveBindings) =>
new(
this,
CreateViewModel(persistedBindings, dispatcher, saveBindings));
public void UnbindViewModel(SettingsVM? expected = null)
{
if (expected is null || ReferenceEquals(_viewModel, expected))
_viewModel = null;
}
public void SetUiLocked(bool locked) public void SetUiLocked(bool locked)
{ {
// MUST-FIX 4 (OP4 review-fix round, 2026-08-11, blast M3): the if (_lastAppliedUiLocked == locked)
// guard used to compare `locked` against `Gameplay.LockUI` — valid
// only while `ToggleUiLock` computed `locked` AS `!Gameplay.LockUI`
// (pre-OP4). OP4 re-pointed `ToggleUiLock` to derive `locked` from
// the SERVER bit (`RuntimeCharacterOptionsState.GetOptionBit`)
// instead, a DIFFERENT store that can already equal the persisted
// `Gameplay.LockUI` without this method ever having pushed that
// value to `_runtimeTargets` — the guard must compare against what
// was ACTUALLY applied, not a value from an unrelated store.
if (_lastAppliedUiLocked == locked && _uiLockConverged)
return; return;
_uiLockConverged = false;
Gameplay = Gameplay with { LockUI = locked };
_runtimeTargets?.ApplyUiLock(locked); _runtimeTargets?.ApplyUiLock(locked);
_lastAppliedUiLocked = locked; _lastAppliedUiLocked = locked;
_viewModel?.SetGameplay(
_viewModel.GameplayDraft with { LockUI = locked });
try
{
_storage.SaveGameplay(Gameplay);
_viewModel?.ApplyExternalGameplayChange(gameplay => gameplay with
{
LockUI = locked,
});
_uiLockConverged = true;
}
catch (Exception ex)
{
_log($"settings: radar lock save failed: {ex.Message}");
}
}
public void SetAcceptLootPermits(bool enabled)
{
Gameplay = Gameplay with { AcceptLootPermits = enabled };
_viewModel?.SetGameplay(
_viewModel.GameplayDraft with { AcceptLootPermits = enabled });
_storage.SaveGameplay(Gameplay);
_viewModel?.ApplyExternalGameplayChange(gameplay => gameplay with
{
AcceptLootPermits = enabled,
});
} }
/// <summary> /// <summary>
@ -415,10 +307,6 @@ internal sealed class RuntimeSettingsController :
public void ToggleFrameRate() public void ToggleFrameRate()
{ {
Display = Display with { ShowFps = !Display.ShowFps }; Display = Display with { ShowFps = !Display.ShowFps };
_viewModel?.ApplyExternalDisplayChange(display => display with
{
ShowFps = Display.ShowFps,
});
try try
{ {
@ -434,7 +322,7 @@ internal sealed class RuntimeSettingsController :
/// Campaign OP slice OP3: the five client-local preferences the "Use /// Campaign OP slice OP3: the five client-local preferences the "Use
/// Mouse Turning Settings" Gameplay-tab macro reads/writes. Read /// Mouse Turning Settings" Gameplay-tab macro reads/writes. Read
/// directly through storage (no startup-snapshot cache, unlike /// directly through storage (no startup-snapshot cache, unlike
/// <see cref="Display"/>/<see cref="Gameplay"/>/<see cref="Chat"/>) — /// <see cref="Display"/>/<see cref="Chat"/>) —
/// this section has no UI surface of its own yet (Campaign OP slice /// this section has no UI surface of its own yet (Campaign OP slice
/// OP6's Config tab), so there is nothing today that needs a cached, /// OP6's Config tab), so there is nothing today that needs a cached,
/// change-notified copy. /// change-notified copy.
@ -468,14 +356,12 @@ internal sealed class RuntimeSettingsController :
ArgumentException.ThrowIfNullOrWhiteSpace(characterName); ArgumentException.ThrowIfNullOrWhiteSpace(characterName);
ActiveToonKey = characterName; ActiveToonKey = characterName;
Character = _storage.LoadCharacter(characterName); Character = _storage.LoadCharacter(characterName);
_viewModel?.LoadCharacterContext(Character);
_log($"settings: loaded character[{characterName}] preferences"); _log($"settings: loaded character[{characterName}] preferences");
} }
public void RestoreDefaultCharacterContext() public void RestoreDefaultCharacterContext()
{ {
Character = _defaultCharacter; Character = _defaultCharacter;
_viewModel?.LoadCharacterContext(Character);
} }
public void ResetActiveCharacterKey() => ActiveToonKey = DefaultToonKey; public void ResetActiveCharacterKey() => ActiveToonKey = DefaultToonKey;
@ -500,7 +386,7 @@ internal sealed class RuntimeSettingsController :
/// same shape as the already-public <see cref="SaveCameraTurning"/>, /// same shape as the already-public <see cref="SaveCameraTurning"/>,
/// now also called directly by <c>ConfigOptionsPageController</c>'s /// now also called directly by <c>ConfigOptionsPageController</c>'s
/// Display-backed rows (resolution/fullscreen/vsync/FOV/gamma/quality /// Display-backed rows (resolution/fullscreen/vsync/FOV/gamma/quality
/// family) rather than only through the (soon-retired) SettingsVM /// family) rather than only through the (OP9-retired) SettingsVM
/// callback wiring.</summary> /// callback wiring.</summary>
public void SaveDisplay(DisplaySettings display) public void SaveDisplay(DisplaySettings display)
{ {
@ -540,24 +426,6 @@ internal sealed class RuntimeSettingsController :
} }
} }
private void SaveGameplay(GameplaySettings gameplay)
{
try
{
_storage.SaveGameplay(gameplay);
Gameplay = gameplay;
_uiLockConverged = false;
_runtimeTargets?.ApplyUiLock(gameplay.LockUI);
_lastAppliedUiLocked = gameplay.LockUI;
_uiLockConverged = true;
_log($"settings: gameplay saved to {_storage.Location}");
}
catch (Exception ex)
{
_log($"settings: gameplay save failed: {ex.Message}");
}
}
/// <summary>Widened from private to public at Campaign OP slice OP6, /// <summary>Widened from private to public at Campaign OP slice OP6,
/// same reason as <see cref="SaveDisplay"/> — the Config tab's Chat /// same reason as <see cref="SaveDisplay"/> — the Config tab's Chat
/// Font Face/Size rows (store-only, no diff against the five wired /// Font Face/Size rows (store-only, no diff against the five wired
@ -580,7 +448,7 @@ internal sealed class RuntimeSettingsController :
// CH3 (2026-08-09): retail toggles a Hear*Chat option and pushes // CH3 (2026-08-09): retail toggles a Hear*Chat option and pushes
// SetSingleCharacterOption (0x0005) in the same step (mirrors // SetSingleCharacterOption (0x0005) in the same step (mirrors
// SaveGameplay's ApplyUiLock push above) — ACE's handler both flips // SetUiLocked's ApplyUiLock push above) — ACE's handler both flips
// the option AND joins/leaves the matching Turbine room. // the option AND joins/leaves the matching Turbine room.
PublishHearOptionChange( PublishHearOptionChange(
previous.HearGeneralChat, chat.HearGeneralChat, previous.HearGeneralChat, chat.HearGeneralChat,
@ -627,11 +495,10 @@ internal sealed class RuntimeSettingsController :
/// </summary> /// </summary>
public void SyncChatFromServerOptions(uint options2) public void SyncChatFromServerOptions(uint options2)
{ {
// Applied identically to BOTH the persisted snapshot and the live // Reseed function applied to the persisted snapshot below so an
// draft (mirrors ApplyExternalGameplayChange's own idempotent-update // unsaved edit to an unrelated field (font size, timestamps, ...)
// shape) so an unsaved draft edit to an unrelated field (font size, // survives the reseed instead of being clobbered by a value
// timestamps, ...) survives the reseed instead of being clobbered by // computed once against a stale snapshot.
// a value computed once against the persisted snapshot.
ChatSettings Reseed(ChatSettings current) => current with ChatSettings Reseed(ChatSettings current) => current with
{ {
HearGeneralChat = (options2 HearGeneralChat = (options2
@ -651,7 +518,6 @@ internal sealed class RuntimeSettingsController :
return; return;
Chat = synced; Chat = synced;
_viewModel?.ApplyExternalChatChange(Reseed);
try try
{ {
_storage.SaveChat(synced); _storage.SaveChat(synced);

View file

@ -115,7 +115,10 @@ public enum InputAction
/// <summary>Toggle the world / map panel (retail F10).</summary> /// <summary>Toggle the world / map panel (retail F10).</summary>
ToggleWorldPanel, ToggleWorldPanel,
/// <summary>Toggle the options / settings panel (retail F11) — opens /// <summary>Toggle the options / settings panel (retail F11) — opens
/// our SettingsPanel in K.3.</summary> /// the retail Options panel (Campaign OP, D1). Superseded the K.3-era
/// ImGui SettingsPanel this action originally opened; that surface was
/// never rendered after Campaign V slice V11 and was retired outright
/// at OP9.</summary>
ToggleOptionsPanel, ToggleOptionsPanel,
/// <summary>Toggle the inventory panel (retail F12).</summary> /// <summary>Toggle the inventory panel (retail F12).</summary>
ToggleInventoryPanel, ToggleInventoryPanel,

View file

@ -12,12 +12,17 @@ namespace AcDream.UI.Abstractions.Panels.Settings;
/// each other while dragging. /// each other while dragging.
/// ///
/// <para> /// <para>
/// Shared by two independent consumers so both apply the identical link math: /// Consumed directly by <c>RetailWindowOpacityController</c> (AcDream.App — the
/// <c>RetailWindowOpacityController</c> (AcDream.App — the live per-window /// live per-window mechanism, mutating its own two float fields). The retail
/// mechanism, mutating its own two float fields) and <see cref="SettingsPanel"/>'s /// Options panel's Chat tab (<c>ChatOptionsPageController</c>, Campaign OP slice
/// Chat tab (the draft slider UI, mutating a <see cref="ChatSettings"/> record). /// OP5) drives that SAME controller rather than calling these methods a second
/// Pure functions — no window/render/state dependency — so both layers can call /// time, so the panel's two sliders and every retained window's live fade always
/// them without violating the App→Abstractions dependency direction. /// apply the identical link math through one path. (The OP9-retired
/// <c>SettingsPanel</c>'s own Chat tab used to call these methods directly as a
/// second consumer, mutating a draft <see cref="ChatSettings"/> record — that
/// draft mechanism is gone, not the link math.) Pure functions — no
/// window/render/state dependency — so any future caller can use them without
/// violating the App→Abstractions dependency direction.
/// </para> /// </para>
/// </summary> /// </summary>
public static class ChatOpacityLink public static class ChatOpacityLink

View file

@ -31,9 +31,9 @@ public enum ParticleRange
/// did not change when these three take effect, only how they're reached. /// did not change when these three take effect, only how they're reached.
/// ///
/// <para> /// <para>
/// Records are immutable; mutation goes through /// Records are immutable; mutation goes through <c>with</c>-expressions —
/// <see cref="SettingsVM.SetDisplay"/> which assigns a new instance via /// e.g. <c>RuntimeSettingsController.SaveDisplay</c>'s callers, or the
/// <c>with</c>-expressions. /// OP9-retired <c>SettingsVM.SetDisplay</c>'s draft assignment before that.
/// </para> /// </para>
/// </summary> /// </summary>
public sealed record DisplaySettings( public sealed record DisplaySettings(

View file

@ -1,72 +0,0 @@
namespace AcDream.UI.Abstractions.Panels.Settings;
/// <summary>
/// Gameplay-related preferences persisted to <c>settings.json</c>.
/// Mirrors a subset of retail's <c>CharacterOption</c> + <c>CharacterOptions2</c>
/// bitfield flags (see <c>docs/research/named-retail/acclient.h:3404+</c>).
/// Retail names are kept verbatim so future server-sync packs these
/// into the wire-format bitmask without renaming.
///
/// <para>
/// L.0 scope: <b>local-only</b>. The brainstorm explicitly deferred
/// server sync — on Save these values are persisted to <c>settings.json</c>
/// only. A later phase will marshal them into the retail
/// <c>CharacterOption</c> packet (<c>0x...</c>) when the protocol work
/// for player-options round-trip is in place.
/// </para>
///
/// <para>
/// Defaults below are chosen as the typical-user starting point, NOT
/// pinned bit-exact to retail's <c>0x50C4A54A</c> / <c>0x948700</c>
/// masks (those will become the defaults once server-sync ships and
/// the bitmask round-trip is the load-bearing wire format).
/// </para>
/// </summary>
public sealed record GameplaySettings(
// CharacterOption (32-bit) subset — most-used gameplay toggles.
//
// OP4 review-fix round (2026-08-11, MUST-FIX 3 / blast M2): AutoTarget,
// AutoRepeatAttack, and ViewCombatTarget were REMOVED from this record
// — the Combat panel's three LEDs now read/write the SAME canonical
// server-bit seam (RuntimeCharacterOptionsState via
// CharacterOptionCombatSettingsSource) the Character tab's rows for
// these same retail PlayerOptions already used, closing the
// "two writable copies" divergence (register row AP-196). This record
// remains the client-local persistence/draft store for every OTHER
// gameplay preference that has no such server-authoritative seam.
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
bool AcceptLootPermits = false) // 0x80000 — accept corpse-looting permissions
{
/// <summary>Sensible starting values for first launch. NOT bit-exact
/// to retail's <c>Default_CharacterOption = 0x50C4A54A</c> +
/// <c>Default_CharacterOptions2 = 0x948700</c> — see class remarks.</summary>
public static GameplaySettings Default { get; } = new(
ToggleRun: true,
AdvancedCombatUI: false,
ShowTooltips: true,
VividTargetingIndicator: true,
SideBySideVitals: false,
// Retail default character-options mask 0x50C4A54A includes
// CoordinatesOnRadar (0x00400000).
CoordinatesOnRadar: true,
SpellDuration: true,
AllowGive: true,
ShowHelm: true,
ShowCloak: true,
LockUI: false,
UseMouseTurning: false,
// Default_CharacterOption 0x50C4A54A leaves bit 0x80000 clear.
AcceptLootPermits: false);
}

View file

@ -1,561 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using AcDream.UI.Abstractions.Input;
using AcDream.UI.Abstractions.Settings;
namespace AcDream.UI.Abstractions.Panels.Settings;
/// <summary>
/// In-game Settings panel — F11 toggle (or View → Settings on the main
/// menu bar). Hidden by default. Tabbed: Keybinds (Phase K), then
/// Display / Audio / Gameplay / Chat / Character (filling in over the
/// L.x sub-phases).
///
/// <para>
/// Top of the panel: Save / Cancel / Reset-all action buttons (global
/// across all tabs). When <see cref="SettingsVM.PendingConflict"/> is
/// non-null, a confirmation prompt is rendered above those buttons
/// (Yes — Reassign / No — Keep existing).
/// </para>
///
/// <para>
/// Below the action row a tab bar selects between the six categories.
/// Only the Keybinds tab is implemented today; the other five render
/// "Coming soon" placeholders so the structure the user approved in the
/// design brainstorm is visible immediately.
/// </para>
/// </summary>
public sealed class SettingsPanel : IPanel
{
private readonly SettingsVM _vm;
public SettingsPanel(SettingsVM vm)
{
_vm = vm ?? throw new System.ArgumentNullException(nameof(vm));
}
/// <inheritdoc />
public string Id => "acdream.settings";
/// <inheritdoc />
public string Title => "Settings";
/// <inheritdoc />
/// <remarks>Hidden by default — opened via F11 / View menu.</remarks>
public bool IsVisible { get; set; } = false;
/// <inheritdoc />
public void Render(PanelContext ctx, IPanelRenderer renderer)
{
if (!renderer.Begin(Title))
{
renderer.End();
return;
}
// Conflict prompt — modal-ish row at top of the panel.
if (_vm.PendingConflict is { } conflict)
{
renderer.TextWrapped(
$"'{ChordLabel(conflict.NewChord)}' is already bound to "
+ $"{conflict.ConflictingAction}. Reassign it to "
+ $"{conflict.NewAction}?");
if (renderer.Button("Yes — Reassign")) _vm.ResolveConflict(replace: true);
renderer.SameLine();
if (renderer.Button("No — Keep existing")) _vm.ResolveConflict(replace: false);
renderer.Separator();
}
// Top action buttons. Global across all tabs.
if (renderer.Button("Save changes")) _vm.Save();
renderer.SameLine();
if (renderer.Button("Cancel changes")) _vm.Cancel();
renderer.SameLine();
if (renderer.Button("Reset all to retail defaults")) _vm.ResetAllToDefaults();
renderer.Separator();
if (renderer.BeginTabBar("settings.tabs"))
{
if (renderer.BeginTabItem("Keybinds"))
{
RenderKeybindsTab(renderer);
renderer.EndTabItem();
}
if (renderer.BeginTabItem("Display"))
{
RenderDisplayTab(renderer);
renderer.EndTabItem();
}
if (renderer.BeginTabItem("Audio"))
{
RenderAudioTab(renderer);
renderer.EndTabItem();
}
if (renderer.BeginTabItem("Gameplay"))
{
RenderGameplayTab(renderer);
renderer.EndTabItem();
}
if (renderer.BeginTabItem("Chat"))
{
RenderChatTab(renderer);
renderer.EndTabItem();
}
if (renderer.BeginTabItem("Character"))
{
RenderCharacterTab(renderer);
renderer.EndTabItem();
}
renderer.EndTabBar();
}
renderer.End();
}
/// <summary>
/// Render the Keybinds tab — eight collapsing-header sections matching
/// the retail keymap categories. Phase K shipped this content; the
/// only thing that changed is the wrapping tab item.
/// </summary>
private void RenderKeybindsTab(IPanelRenderer renderer)
{
RenderSection(renderer, "Movement", new[]
{
InputAction.MovementForward, InputAction.MovementBackup,
InputAction.MovementTurnLeft, InputAction.MovementTurnRight,
InputAction.MovementStrafeLeft, InputAction.MovementStrafeRight,
InputAction.MovementJump, InputAction.MovementStop,
InputAction.MovementWalkMode, InputAction.MovementRunLock,
});
RenderSection(renderer, "Postures", new[]
{
InputAction.Ready, InputAction.Sitting,
InputAction.Crouch, InputAction.Sleeping,
});
RenderSection(renderer, "Camera", new[]
{
InputAction.CameraActivateAlternateMode, InputAction.CameraInstantMouseLook,
InputAction.CameraRotateLeft, InputAction.CameraRotateRight,
InputAction.CameraRotateUp, InputAction.CameraRotateDown,
InputAction.CameraMoveToward, InputAction.CameraMoveAway,
InputAction.CameraViewDefault, InputAction.CameraViewFirstPerson,
InputAction.CameraViewLookDown, InputAction.CameraViewMapMode,
});
RenderSection(renderer, "Combat", new[]
{
InputAction.CombatToggleCombat,
InputAction.CombatDecreaseAttackPower, InputAction.CombatIncreaseAttackPower,
InputAction.CombatLowAttack, InputAction.CombatMediumAttack, InputAction.CombatHighAttack,
InputAction.CombatAimLow, InputAction.CombatAimMedium, InputAction.CombatAimHigh,
InputAction.CombatPrevSpellTab, InputAction.CombatNextSpellTab,
InputAction.CombatPrevSpell, InputAction.CombatNextSpell, InputAction.CombatCastCurrentSpell,
});
RenderSection(renderer, "UI panels", new[]
{
InputAction.ToggleHelp, InputAction.ToggleAllegiancePanel,
InputAction.ToggleFellowshipPanel, InputAction.ToggleSpellbookPanel,
InputAction.ToggleSpellComponentsPanel, InputAction.ToggleAttributesPanel,
InputAction.ToggleSkillsPanel, InputAction.ToggleWorldPanel,
InputAction.ToggleOptionsPanel, InputAction.ToggleInventoryPanel,
InputAction.SelectionExamine, InputAction.UseSelected,
InputAction.EscapeKey, InputAction.LOGOUT,
});
RenderSection(renderer, "Chat", new[]
{
InputAction.ToggleChatEntry, InputAction.EnterChatMode,
InputAction.ToggleFloatingChatWindow1, InputAction.ToggleFloatingChatWindow2,
InputAction.ToggleFloatingChatWindow3, InputAction.ToggleFloatingChatWindow4,
});
RenderSection(renderer, "Hotbar", new[]
{
InputAction.UseQuickSlot_1, InputAction.UseQuickSlot_2, InputAction.UseQuickSlot_3,
InputAction.UseQuickSlot_4, InputAction.UseQuickSlot_5, InputAction.UseQuickSlot_6,
InputAction.UseQuickSlot_7, InputAction.UseQuickSlot_8, InputAction.UseQuickSlot_9,
InputAction.SelectQuickSlot_1, InputAction.SelectQuickSlot_2, InputAction.SelectQuickSlot_3,
InputAction.SelectQuickSlot_4, InputAction.SelectQuickSlot_5, InputAction.SelectQuickSlot_6,
InputAction.SelectQuickSlot_7, InputAction.SelectQuickSlot_8, InputAction.SelectQuickSlot_9,
InputAction.UseQuickSlot_14, InputAction.UseQuickSlot_15, InputAction.UseQuickSlot_16,
InputAction.UseQuickSlot_17, InputAction.UseQuickSlot_18,
InputAction.CreateShortcut,
});
RenderSection(renderer, "Emotes", new[]
{
InputAction.Cry, InputAction.Laugh, InputAction.Wave,
InputAction.Cheer, InputAction.PointState,
});
}
/// <summary>
/// Render the Display tab — resolution / fullscreen / vsync /
/// FOV / gamma / show-FPS. FOV + Gamma are live-preview sliders;
/// the others apply on Save (matches the brainstorm UX agreement —
/// resolution change live would be too jarring).
/// </summary>
private void RenderDisplayTab(IPanelRenderer renderer)
{
var d = _vm.DisplayDraft;
// Resolution dropdown. Index falls back to the highest available
// option when the persisted resolution isn't one of the presets
// (e.g. user hand-edited settings.json with a non-standard size).
var resolutions = DisplaySettings.AvailableResolutions.ToArray();
int idx = System.Array.IndexOf(resolutions, d.Resolution);
if (idx < 0) idx = resolutions.Length - 1;
if (renderer.Combo("Resolution", ref idx, resolutions))
_vm.SetDisplay(d with { Resolution = resolutions[idx] });
bool fullscreen = d.Fullscreen;
if (renderer.Checkbox("Fullscreen", ref fullscreen))
_vm.SetDisplay(d with { Fullscreen = fullscreen });
bool vsync = d.VSync;
if (renderer.Checkbox("V-Sync", ref vsync))
_vm.SetDisplay(d with { VSync = vsync });
float fov = d.FieldOfView;
if (renderer.SliderFloat("Field of View", ref fov, 30f, 120f))
_vm.SetDisplay(d with { FieldOfView = fov });
float gamma = d.Gamma;
if (renderer.SliderFloat("Gamma", ref gamma, 0.5f, 2.0f))
_vm.SetDisplay(d with { Gamma = gamma });
bool showFps = d.ShowFps;
if (renderer.Checkbox("Show FPS", ref showFps))
_vm.SetDisplay(d with { ShowFps = showFps });
// A.5 T22.5: Quality preset dropdown. Drives streaming radii, MSAA,
// anisotropic level, A2C, and max completions-per-frame as a unit.
// Resolution + anisotropic + A2C + completions apply immediately via
// ReapplyQualityPreset; MSAA samples require a restart (GL context
// cannot change sample count at runtime).
var presets = s_qualityPresetNames;
int qIdx = (int)d.Quality;
if (qIdx < 0 || qIdx >= presets.Length) qIdx = (int)QualityPreset.High;
if (renderer.Combo("Quality", ref qIdx, presets))
_vm.SetDisplay(d with { Quality = (QualityPreset)qIdx });
int particleRangeIndex = (int)d.ParticleRange;
if (particleRangeIndex < 0 || particleRangeIndex >= s_particleRangeNames.Length)
particleRangeIndex = (int)DisplaySettings.Default.ParticleRange;
if (renderer.Combo("Particle Range", ref particleRangeIndex, s_particleRangeNames))
_vm.SetDisplay(d with { ParticleRange = (ParticleRange)particleRangeIndex });
renderer.Spacing();
renderer.TextWrapped(
"Resolution / Fullscreen / V-Sync apply on Save. FOV + Gamma "
+ "preview live as you drag; Cancel reverts to the saved value. "
+ "Quality preset applies streaming radius, anisotropic, and A2C "
+ "immediately on Save; MSAA sample count requires a restart. "
+ "Particle Range defaults to Extended, which doubles authored "
+ "effect distance; Retail restores the exact client cutoff.");
}
/// <summary>
/// Render the Audio tab — Master, SFX and Ambient volume sliders, all with
/// live preview against the running engine.
///
/// <para>
/// Ambient was hidden until Campaign A slice A5 because nothing drove it;
/// the region ambient soundscape now does, so the knob moves something and
/// is exposed. The Music slider is gone entirely: retail has no music
/// system, so there was never anything for it to turn down.
/// </para>
/// </summary>
private void RenderAudioTab(IPanelRenderer renderer)
{
var a = _vm.AudioDraft;
float master = a.Master;
if (renderer.SliderFloat("Master", ref master, 0f, 1f))
_vm.SetAudio(a with { Master = master });
float sfx = a.Sfx;
if (renderer.SliderFloat("SFX", ref sfx, 0f, 1f))
_vm.SetAudio(a with { Sfx = sfx });
float ambient = a.Ambient;
if (renderer.SliderFloat("Ambient", ref ambient, 0f, 1f))
_vm.SetAudio(a with { Ambient = ambient });
renderer.Spacing();
renderer.TextWrapped(
"Volume changes preview live as you drag. Save persists the "
+ "values to settings.json; Cancel reverts to the saved values. "
+ "Music + Ambient mixing arrives with R5 MIDI playback.");
}
/// <summary>
/// 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.
/// </summary>
private void RenderGameplayTab(IPanelRenderer renderer)
{
var g = _vm.GameplayDraft;
renderer.Text("Combat");
renderer.Separator();
// OP4 review-fix round (2026-08-11, MUST-FIX 3 / blast M2):
// Auto-target/Auto-repeat/Keep-in-view were removed from
// GameplaySettings — they now read/write the canonical server bit
// (RuntimeCharacterOptionsState) through CombatUiController, not
// this client-local draft. This panel has no production
// construction site (D.2b's retained UiHost/UiRoot stack is the
// one presentation surface — see CLAUDE.md's UI strategy section);
// the three checkboxes are simply retired rather than re-pointed
// to a seam this dat-free ImGui-shaped panel has no way to reach.
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.");
}
/// <summary>
/// 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.
/// </summary>
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.Text("Window transparency");
renderer.Separator();
// Campaign CH slice CH6c: retail's two linked opacity sliders
// (gmChatOptionsUI::InitOptions @0x0049FC60's DualHash pair). Dragging
// Background above Active drags Active UP to match; dragging Active below
// Background drags Background DOWN — ChatOpacityLink is the shared port of
// ChatInterface::SetDefaultOpacity/SetActiveOpacity (0x004F3BC0/0x004F3C40)
// that both this draft and the live RetailWindowOpacityController use, so the
// sliders track each other exactly like retail's options page.
float defaultOpacity = c.DefaultOpacity;
if (renderer.SliderFloat("Background opacity (unfocused)", ref defaultOpacity, 0f, 1f))
{
var (def, active) = ChatOpacityLink.SetDefault(c.ActiveOpacity, defaultOpacity);
_vm.SetChat(c with { DefaultOpacity = def, ActiveOpacity = active });
}
float activeOpacity = c.ActiveOpacity;
if (renderer.SliderFloat("Active opacity (typing / focused)", ref activeOpacity, 0f, 1f))
{
var (def, active) = ChatOpacityLink.SetActive(c.DefaultOpacity, activeOpacity);
_vm.SetChat(c with { DefaultOpacity = def, ActiveOpacity = active });
}
renderer.Spacing();
renderer.TextWrapped(
"Channel filters hide messages from the chat window without "
+ "changing your server-side subscriptions. Window transparency "
+ "applies to every retained window (chat, floaties, vitals, "
+ "toolbar, inventory...) and fades whichever window doesn't "
+ "currently have keyboard focus; Active opacity can never be "
+ "lower than Background — dragging one past the other drags "
+ "the other along. Save persists; Cancel reverts.");
}
/// <summary>
/// Render the Character tab — per-toon preferences. The host owns
/// the toon-name key; the panel just edits whatever bag the host
/// loaded into <see cref="SettingsVM.CharacterDraft"/>.
/// </summary>
private void RenderCharacterTab(IPanelRenderer renderer)
{
var c = _vm.CharacterDraft;
var channels = CharacterSettings.AvailableChannels.ToArray();
int idx = System.Array.IndexOf(channels, c.DefaultChatChannel);
if (idx < 0) idx = 0;
if (renderer.Combo("Default chat channel", ref idx, channels))
_vm.SetCharacter(c with { DefaultChatChannel = channels[idx] });
bool autoAttack = c.AutoAttack;
if (renderer.Checkbox("Auto-attack (continue swinging until target dies)", ref autoAttack))
_vm.SetCharacter(c with { AutoAttack = autoAttack });
bool confirmSalvage = c.ConfirmSalvage;
if (renderer.Checkbox("Confirm before salvaging valuable items", ref confirmSalvage))
_vm.SetCharacter(c with { ConfirmSalvage = confirmSalvage });
bool pickup = c.ShowPickupMessages;
if (renderer.Checkbox("Show pickup messages in chat", ref pickup))
_vm.SetCharacter(c with { ShowPickupMessages = pickup });
renderer.Spacing();
renderer.TextWrapped(
"Per-character preferences — saved per toon under "
+ "settings.json's character[\"<toonName>\"]. Local-only this "
+ "phase; server-sync arrives later when the protocol "
+ "round-trip lands.");
}
// A.5 T22.5: preset label array parallel to QualityPreset enum values.
// Order must match the enum (Low=0, Medium=1, High=2, Ultra=3).
private static readonly string[] s_qualityPresetNames =
{ "Low", "Medium", "High", "Ultra" };
private static readonly string[] s_particleRangeNames =
{ "Retail", "Extended" };
private void RenderSection(IPanelRenderer renderer, string label, InputAction[] actions)
{
// Movement defaults open; other sections collapsed for first-run UX.
bool defaultOpen = label == "Movement";
if (!renderer.CollapsingHeader(label, defaultOpen))
return;
foreach (var action in actions)
{
renderer.Text(action.ToString());
renderer.SameLine();
// Current binding(s) summary.
var binds = _vm.Draft.ForAction(action).ToList();
string summary = binds.Count == 0
? "(unbound)"
: string.Join(", ", binds.Select(b => ChordLabel(b.Chord)));
renderer.Text(summary);
renderer.SameLine();
// Rebind button — when a rebind is in progress for THIS
// action, the label changes to a "press a key..." prompt.
// The "##{action}" suffix gives ImGui a stable per-row id
// so multiple "Rebind" buttons don't collide.
string buttonLabel = (_vm.RebindInProgress == action)
? $"Press a key... (Esc to cancel)##{action}"
: $"Rebind##{action}";
if (renderer.Button(buttonLabel))
{
if (binds.Count > 0 && _vm.RebindInProgress is null)
_vm.BeginRebind(action, binds[0]);
}
renderer.SameLine();
if (renderer.Button($"Reset##{action}"))
_vm.ResetActionToDefault(action);
}
}
/// <summary>
/// Render a chord as <c>"Shift+Ctrl+A"</c> / <c>"W"</c> / etc. for the
/// row summary + conflict prompt. Joins held modifiers with <c>+</c>
/// then the trigger key name.
/// </summary>
private static string ChordLabel(KeyChord chord)
{
var parts = new List<string>();
if ((chord.Modifiers & ModifierMask.Shift) != 0) parts.Add("Shift");
if ((chord.Modifiers & ModifierMask.Ctrl) != 0) parts.Add("Ctrl");
if ((chord.Modifiers & ModifierMask.Alt) != 0) parts.Add("Alt");
if ((chord.Modifiers & ModifierMask.Win) != 0) parts.Add("Win");
parts.Add(chord.Key.ToString());
return string.Join("+", parts);
}
}

View file

@ -11,10 +11,11 @@ namespace AcDream.UI.Abstractions.Panels.Settings;
public readonly record struct UiWindowPosition(float X, float Y); public readonly record struct UiWindowPosition(float X, float Y);
/// <summary> /// <summary>
/// JSON-backed persistence for non-keybind settings (Display today; future /// JSON-backed persistence for non-keybind settings (Display / Audio / Chat /
/// tabs Audio / Gameplay / Chat / Character will be added to the same /// Character; the OP9-retired <c>Gameplay</c> section is no longer read or
/// file). The graphical host supplies a canonical portable configuration /// written — see the class's Schema note below for how a leftover
/// path. Coexists /// <c>"gameplay"</c> block from an older settings.json is tolerated). The
/// graphical host supplies a canonical portable configuration path. Coexists
/// with <c>keybinds.json</c>, which retains its own /// with <c>keybinds.json</c>, which retains its own
/// <see cref="Input.KeyBindings.LoadOrDefault"/> path. /// <see cref="Input.KeyBindings.LoadOrDefault"/> path.
/// ///
@ -29,7 +30,11 @@ public readonly record struct UiWindowPosition(float X, float Y);
/// </code> /// </code>
/// Unknown top-level keys are preserved on save so future tab additions /// Unknown top-level keys are preserved on save so future tab additions
/// from a newer client don't get clobbered by an older client writing /// from a newer client don't get clobbered by an older client writing
/// out only the sections it knows about. /// out only the sections it knows about — the SAME round-trip preservation
/// (<see cref="SaveSection"/>'s raw-JSON-text carry-forward) that keeps an
/// EXISTING file's now-orphaned <c>"gameplay"</c> block intact forever: no
/// <c>Load*</c> method here reads it, so it is neither parsed nor dropped,
/// just silently carried along untouched on every subsequent save.
/// </para> /// </para>
/// </summary> /// </summary>
public sealed class SettingsStore public sealed class SettingsStore
@ -149,49 +154,6 @@ public sealed class SettingsStore
public void SaveAudio(AudioSettings audio) public void SaveAudio(AudioSettings audio)
=> SaveSection("audio", BuildAudioObject(audio)); => SaveSection("audio", BuildAudioObject(audio));
/// <summary>
/// Load Gameplay settings (subset of retail CharacterOption flags).
/// Same fall-back behaviour as <see cref="LoadDisplay"/>.
/// </summary>
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(
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),
AcceptLootPermits: ReadBool(gp, "acceptLootPermits", d.AcceptLootPermits));
}
catch (Exception ex)
{
Console.WriteLine($"settings: failed to load {_path}: {ex.Message} — using defaults");
return GameplaySettings.Default;
}
}
/// <summary>Save Gameplay settings, preserving all other top-level keys.</summary>
public void SaveGameplay(GameplaySettings gameplay)
=> SaveSection("gameplay", BuildGameplayObject(gameplay));
/// <summary>Load Chat settings. Same fall-back behaviour as <see cref="LoadDisplay"/>.</summary> /// <summary>Load Chat settings. Same fall-back behaviour as <see cref="LoadDisplay"/>.</summary>
public ChatSettings LoadChat() public ChatSettings LoadChat()
{ {
@ -624,24 +586,6 @@ public sealed class SettingsStore
["showTimestamps"] = c.ShowTimestamps, ["showTimestamps"] = c.ShowTimestamps,
}; };
private static SortedDictionary<string, object> BuildGameplayObject(GameplaySettings g)
=> new(StringComparer.Ordinal)
{
["advancedCombatUI"] = g.AdvancedCombatUI,
["acceptLootPermits"] = g.AcceptLootPermits,
["allowGive"] = g.AllowGive,
["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<string, object> BuildDisplayObject(DisplaySettings d) private static SortedDictionary<string, object> BuildDisplayObject(DisplaySettings d)
=> new(StringComparer.Ordinal) => new(StringComparer.Ordinal)
{ {

View file

@ -1,451 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AcDream.UI.Abstractions.Input;
namespace AcDream.UI.Abstractions.Panels.Settings;
/// <summary>
/// K.3 ViewModel for <see cref="SettingsPanel"/>. Owns a <b>draft</b>
/// copy of the current <see cref="KeyBindings"/>; rebinds modify the
/// draft. <see cref="Save"/> commits draft via the supplied callback
/// (which writes to disk + replaces the live dispatcher's table);
/// <see cref="Cancel"/> reverts the draft to the persisted state.
///
/// <para>
/// Click-to-rebind UX: caller invokes <see cref="BeginRebind"/> with the
/// action being rebound + the binding being replaced. The VM enters
/// modal capture on the dispatcher; when the user presses a chord (or
/// Esc), the dispatcher reports it via <see cref="OnChordCaptured"/>.
/// If the new chord conflicts with another action's binding (same
/// activation type), <see cref="PendingConflict"/> surfaces a prompt
/// the panel renders as Yes / No buttons; <see cref="ResolveConflict"/>
/// dispatches the user's choice.
/// </para>
/// </summary>
public sealed class SettingsVM
{
private KeyBindings _persisted;
private KeyBindings _draft;
private readonly InputDispatcher _dispatcher;
private readonly Action<KeyBindings> _onSave;
// L.0 — Display tab. Treated as a single immutable record; mutation
// through SetDisplay clones via with-expressions on the panel side.
private DisplaySettings _displayPersisted;
private DisplaySettings _displayDraft;
private readonly Action<DisplaySettings> _onSaveDisplay;
// L.0 — Audio tab. Same shape as Display.
private AudioSettings _audioPersisted;
private AudioSettings _audioDraft;
private readonly Action<AudioSettings> _onSaveAudio;
// L.0 — Gameplay tab (subset of retail CharacterOption flags).
private GameplaySettings _gameplayPersisted;
private GameplaySettings _gameplayDraft;
private readonly Action<GameplaySettings> _onSaveGameplay;
// L.0 — Chat tab (CharacterOptions2 channel filters + visual prefs).
private ChatSettings _chatPersisted;
private ChatSettings _chatDraft;
private readonly Action<ChatSettings> _onSaveChat;
// L.0 — Character tab (per-toon, host-keyed by toon name).
private CharacterSettings _characterPersisted;
private CharacterSettings _characterDraft;
private readonly Action<CharacterSettings> _onSaveCharacter;
/// <summary>The action currently being rebound, or null when idle.</summary>
public InputAction? RebindInProgress { get; private set; }
/// <summary>The original binding being replaced (so we can preserve
/// activation type on the new chord and roll back on cancel).</summary>
public Binding? RebindOriginal { get; private set; }
/// <summary>The action+chord conflict pending confirmation, or null.
/// Populated when <see cref="OnChordCaptured"/> finds the captured
/// chord already bound to another action; cleared by
/// <see cref="ResolveConflict"/>.</summary>
public ConflictPrompt? PendingConflict { get; private set; }
/// <summary>The current working draft. Panel renders bindings from
/// here; mutates via the rebind / reset methods.</summary>
public KeyBindings Draft => _draft;
/// <summary>True iff the draft differs structurally from the
/// persisted snapshot. Used to grey out the Save button when no
/// rebinds are pending.</summary>
public bool HasUnsavedChanges
=> !KeyBindingsEqual(_persisted, _draft)
|| _displayPersisted != _displayDraft
|| _audioPersisted != _audioDraft
|| _gameplayPersisted != _gameplayDraft
|| _chatPersisted != _chatDraft
|| _characterPersisted != _characterDraft;
/// <summary>The current Display draft. Panel reads from here;
/// mutation goes through <see cref="SetDisplay"/>.</summary>
public DisplaySettings DisplayDraft => _displayDraft;
/// <summary>The current Audio draft. Panel reads from here;
/// mutation goes through <see cref="SetAudio"/>.</summary>
public AudioSettings AudioDraft => _audioDraft;
/// <summary>The current Gameplay draft. Panel reads from here;
/// mutation goes through <see cref="SetGameplay"/>.</summary>
public GameplaySettings GameplayDraft => _gameplayDraft;
/// <summary>The current Chat draft. Panel reads from here;
/// mutation goes through <see cref="SetChat"/>.</summary>
public ChatSettings ChatDraft => _chatDraft;
/// <summary>The current Character draft (per-toon — host owns the
/// toon-name key). Panel reads from here; mutation goes through
/// <see cref="SetCharacter"/>.</summary>
public CharacterSettings CharacterDraft => _characterDraft;
public SettingsVM(
KeyBindings persisted,
InputDispatcher dispatcher,
Action<KeyBindings> onSave,
DisplaySettings persistedDisplay,
Action<DisplaySettings> onSaveDisplay,
AudioSettings persistedAudio,
Action<AudioSettings> onSaveAudio,
GameplaySettings persistedGameplay,
Action<GameplaySettings> onSaveGameplay,
ChatSettings persistedChat,
Action<ChatSettings> onSaveChat,
CharacterSettings persistedCharacter,
Action<CharacterSettings> onSaveCharacter)
{
_persisted = persisted ?? throw new ArgumentNullException(nameof(persisted));
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
_onSave = onSave ?? throw new ArgumentNullException(nameof(onSave));
_displayPersisted = persistedDisplay ?? throw new ArgumentNullException(nameof(persistedDisplay));
_onSaveDisplay = onSaveDisplay ?? throw new ArgumentNullException(nameof(onSaveDisplay));
_audioPersisted = persistedAudio ?? throw new ArgumentNullException(nameof(persistedAudio));
_onSaveAudio = onSaveAudio ?? throw new ArgumentNullException(nameof(onSaveAudio));
_gameplayPersisted = persistedGameplay ?? throw new ArgumentNullException(nameof(persistedGameplay));
_onSaveGameplay = onSaveGameplay ?? throw new ArgumentNullException(nameof(onSaveGameplay));
_chatPersisted = persistedChat ?? throw new ArgumentNullException(nameof(persistedChat));
_onSaveChat = onSaveChat ?? throw new ArgumentNullException(nameof(onSaveChat));
_characterPersisted = persistedCharacter ?? throw new ArgumentNullException(nameof(persistedCharacter));
_onSaveCharacter = onSaveCharacter ?? throw new ArgumentNullException(nameof(onSaveCharacter));
_draft = CloneBindings(persisted);
_displayDraft = persistedDisplay;
_audioDraft = persistedAudio;
_gameplayDraft = persistedGameplay;
_chatDraft = persistedChat;
_characterDraft = persistedCharacter;
}
/// <summary>
/// Replace the entire Display draft with <paramref name="value"/>.
/// Panel calls this with a <c>DisplayDraft with { Field = newValue }</c>
/// so each widget edits exactly one field at a time.
/// </summary>
public void SetDisplay(DisplaySettings value)
{
_displayDraft = value ?? throw new ArgumentNullException(nameof(value));
}
/// <summary>
/// Apply one externally persisted display-option change to both snapshots.
/// Existing unsaved edits to other fields remain drafts rather than being
/// accidentally promoted to persisted state.
/// </summary>
public void ApplyExternalDisplayChange(Func<DisplaySettings, DisplaySettings> update)
{
ArgumentNullException.ThrowIfNull(update);
_displayPersisted = update(_displayPersisted)
?? throw new InvalidOperationException("Display update returned null.");
_displayDraft = update(_displayDraft)
?? throw new InvalidOperationException("Display update returned null.");
}
/// <summary>
/// Replace the entire Audio draft with <paramref name="value"/>.
/// Live audio preview is achieved at the host layer by pushing
/// <see cref="AudioDraft"/> into the running OpenAL engine each frame
/// — this method only mutates VM state. Cancel reverts the draft and
/// the host's next-frame push restores the pre-edit engine volumes.
/// </summary>
public void SetAudio(AudioSettings value)
{
_audioDraft = value ?? throw new ArgumentNullException(nameof(value));
}
/// <summary>
/// Replace the entire Gameplay draft with <paramref name="value"/>.
/// Local-only this phase — values persist on Save but don't yet
/// flow to the server. When server-sync ships, the host's
/// <c>onSaveGameplay</c> callback will marshal the draft into the
/// retail <c>CharacterOption</c> wire bitmask.
/// </summary>
public void SetGameplay(GameplaySettings value)
{
_gameplayDraft = value ?? throw new ArgumentNullException(nameof(value));
}
/// <summary>
/// Apply one externally persisted gameplay-option change to both snapshots.
/// Existing unsaved edits to unrelated fields remain drafts rather than
/// being accidentally promoted to persisted state.
/// </summary>
public void ApplyExternalGameplayChange(
Func<GameplaySettings, GameplaySettings> update)
{
ArgumentNullException.ThrowIfNull(update);
_gameplayPersisted = update(_gameplayPersisted)
?? throw new InvalidOperationException("Gameplay update returned null.");
_gameplayDraft = update(_gameplayDraft)
?? throw new InvalidOperationException("Gameplay update returned null.");
}
/// <summary>
/// Replace the entire Chat draft with <paramref name="value"/>.
/// Local-only this phase — values persist on Save but the Hear*Chat
/// flags affect client-side display filtering, not server-side
/// channel subscriptions.
/// </summary>
public void SetChat(ChatSettings value)
{
_chatDraft = value ?? throw new ArgumentNullException(nameof(value));
}
/// <summary>
/// Apply one externally learned Chat change (server truth — CH3,
/// 2026-08-09) to both snapshots, matching
/// <see cref="ApplyExternalGameplayChange"/>'s shape. Existing unsaved
/// edits to unrelated fields remain drafts rather than being accidentally
/// promoted to persisted state.
/// </summary>
public void ApplyExternalChatChange(Func<ChatSettings, ChatSettings> update)
{
ArgumentNullException.ThrowIfNull(update);
_chatPersisted = update(_chatPersisted)
?? throw new InvalidOperationException("Chat update returned null.");
_chatDraft = update(_chatDraft)
?? throw new InvalidOperationException("Chat update returned null.");
}
/// <summary>
/// Replace the entire Character draft with <paramref name="value"/>.
/// Per-toon — the host knows which toon's bag we're editing because
/// it owned the toonKey when constructing the VM.
/// </summary>
public void SetCharacter(CharacterSettings value)
{
_characterDraft = value ?? throw new ArgumentNullException(nameof(value));
}
/// <summary>
/// Replace BOTH the persisted snapshot and the live draft for the
/// Character bag. Used when the active toon changes (e.g. on
/// EnterWorld with a non-default character) — the host loads that
/// toon's settings from disk and pushes them into the VM here so
/// <see cref="HasUnsavedChanges"/> doesn't flag the swap as a
/// pending edit. Differs from <see cref="SetCharacter"/>, which
/// updates draft only.
/// </summary>
public void LoadCharacterContext(CharacterSettings persisted)
{
_characterPersisted = persisted ?? throw new ArgumentNullException(nameof(persisted));
_characterDraft = persisted;
}
/// <summary>
/// Begin rebinding <paramref name="action"/>. The supplied
/// <paramref name="original"/> binding will be removed when the new
/// chord is applied. The dispatcher enters modal capture mode; the
/// next chord pressed (or Esc) feeds back into
/// <see cref="OnChordCaptured"/>.
/// </summary>
public void BeginRebind(InputAction action, Binding original)
{
RebindInProgress = action;
RebindOriginal = original;
_dispatcher.BeginCapture(OnChordCaptured);
}
private void OnChordCaptured(KeyChord chord)
{
// Sentinel: dispatcher reports default(KeyChord) on Esc cancel.
if (chord.Equals(default(KeyChord)))
{
RebindInProgress = null;
RebindOriginal = null;
return;
}
// Conflict check: scan the draft for a binding that matches the
// captured chord + same activation type, but on a DIFFERENT
// action. (Same-action bindings are fine — that's already in
// _draft for this action and gets removed when we apply.)
var existing = _draft.Find(
chord,
RebindOriginal!.Value.Activation,
RebindOriginal.Value.Scope);
if (existing is not null && existing.Value.Action != RebindInProgress!.Value)
{
PendingConflict = new ConflictPrompt(
NewAction: RebindInProgress.Value,
NewChord: chord,
OriginalBinding: RebindOriginal.Value,
ConflictingAction: existing.Value.Action,
ConflictingBinding: existing.Value);
return;
}
ApplyRebind(chord);
}
/// <summary>
/// Resolve a <see cref="PendingConflict"/>: <paramref name="replace"/>=
/// true removes the conflicting binding and applies the new chord;
/// false cancels the rebind entirely (original binding intact).
/// </summary>
public void ResolveConflict(bool replace)
{
if (PendingConflict is null) return;
var c = PendingConflict.Value;
if (replace)
{
_draft.Remove(c.ConflictingBinding);
ApplyRebind(c.NewChord);
}
else
{
RebindInProgress = null;
RebindOriginal = null;
}
PendingConflict = null;
}
private void ApplyRebind(KeyChord chord)
{
_draft.Remove(RebindOriginal!.Value);
_draft.Add(new Binding(
chord,
RebindInProgress!.Value,
RebindOriginal.Value.Activation,
RebindOriginal.Value.Scope));
RebindInProgress = null;
RebindOriginal = null;
}
/// <summary>
/// Cancel any in-progress rebind / pending conflict and clear the
/// dispatcher's capture state. Does NOT revert the draft — for that
/// see <see cref="Cancel"/>.
/// </summary>
public void CancelRebind()
{
if (_dispatcher.IsCapturing) _dispatcher.CancelCapture();
RebindInProgress = null;
RebindOriginal = null;
PendingConflict = null;
}
/// <summary>
/// Restore the draft's bindings for <paramref name="action"/> to the
/// retail defaults. Other actions' draft bindings are untouched.
/// </summary>
public void ResetActionToDefault(InputAction action)
{
var defaults = KeyBindings.RetailDefaults();
foreach (var b in _draft.ForAction(action).ToList())
_draft.Remove(b);
foreach (var b in defaults.ForAction(action))
_draft.Add(b);
}
/// <summary>
/// Replace the keybinds draft with <see cref="KeyBindings.RetailDefaults"/>
/// AND the display draft with <see cref="DisplaySettings.Default"/>.
/// "Reset all" applies to every tab — it's the user's escape hatch
/// when they've gotten lost.
/// </summary>
public void ResetAllToDefaults()
{
_draft = KeyBindings.RetailDefaults();
_displayDraft = DisplaySettings.Default;
_audioDraft = AudioSettings.Default;
_gameplayDraft = GameplaySettings.Default;
_chatDraft = ChatSettings.Default;
_characterDraft = CharacterSettings.Default;
}
/// <summary>
/// Commit both keybinds + display drafts via the onSave callbacks
/// supplied at construction. After save the drafts become the new
/// persisted snapshots — <see cref="HasUnsavedChanges"/> resets to
/// false. Each callback is invoked exactly once per Save; if the
/// caller wants atomicity across both files it has to handle it
/// outside the VM.
/// </summary>
public void Save()
{
_onSave(_draft);
_onSaveDisplay(_displayDraft);
_onSaveAudio(_audioDraft);
_onSaveGameplay(_gameplayDraft);
_onSaveChat(_chatDraft);
_onSaveCharacter(_characterDraft);
_persisted = CloneBindings(_draft);
_displayPersisted = _displayDraft;
_audioPersisted = _audioDraft;
_gameplayPersisted = _gameplayDraft;
_chatPersisted = _chatDraft;
_characterPersisted = _characterDraft;
}
/// <summary>
/// Revert all drafts to their persisted snapshots and clear any
/// in-flight rebind state. Used by the panel's "Cancel" button and
/// when the user closes the settings window without saving.
/// </summary>
public void Cancel()
{
_draft = CloneBindings(_persisted);
_displayDraft = _displayPersisted;
_audioDraft = _audioPersisted;
_gameplayDraft = _gameplayPersisted;
_chatDraft = _chatPersisted;
_characterDraft = _characterPersisted;
CancelRebind();
}
// ── helpers ───────────────────────────────────────────────────────
private static KeyBindings CloneBindings(KeyBindings src)
{
var clone = new KeyBindings();
foreach (var b in src.All) clone.Add(b);
return clone;
}
private static bool KeyBindingsEqual(KeyBindings a, KeyBindings b)
{
if (a.All.Count != b.All.Count) return false;
for (int i = 0; i < a.All.Count; i++)
if (!a.All[i].Equals(b.All[i])) return false;
return true;
}
}
/// <summary>
/// K.3 conflict-prompt payload surfaced when the user binds a chord
/// already in use. The panel renders the <see cref="NewAction"/> +
/// <see cref="ConflictingAction"/> labels in a confirmation prompt;
/// <see cref="SettingsVM.ResolveConflict"/> dispatches the user's
/// answer.
/// </summary>
public readonly record struct ConflictPrompt(
InputAction NewAction,
KeyChord NewChord,
Binding OriginalBinding,
InputAction ConflictingAction,
Binding ConflictingBinding);

View file

@ -11,13 +11,12 @@ public sealed class GameplayInputCommandControllerTests
{ {
[Theory] [Theory]
[InlineData(InputAction.ToggleInventoryPanel, "inventory")] [InlineData(InputAction.ToggleInventoryPanel, "inventory")]
[InlineData(InputAction.AcdreamToggleDebugPanel, "debug")]
[InlineData(InputAction.AcdreamToggleFlyMode, "fly-or-chase")] [InlineData(InputAction.AcdreamToggleFlyMode, "fly-or-chase")]
[InlineData(InputAction.AcdreamTogglePlayerMode, "player-mode")] [InlineData(InputAction.AcdreamTogglePlayerMode, "player-mode")]
[InlineData(InputAction.ToggleChatEntry, "chat")]
// Campaign OP slice OP3 (D1): F11 now opens the retail Options panel // Campaign OP slice OP3 (D1): F11 now opens the retail Options panel
// through IRetainedGameplayWindowCommands, not the retired // through IRetainedGameplayWindowCommands, not the OP9-retired
// IDevToolsGameplayCommands.ToggleSettingsPanel() no-op. // IDevToolsGameplayCommands.ToggleSettingsPanel() no-op (which was
// never dispatched from this switch in the first place).
[InlineData(InputAction.ToggleOptionsPanel, "options")] [InlineData(InputAction.ToggleOptionsPanel, "options")]
[InlineData(InputAction.CombatToggleCombat, "combat")] [InlineData(InputAction.CombatToggleCombat, "combat")]
[InlineData(InputAction.ToggleFloatingChatWindow1, "chat-window-1")] [InlineData(InputAction.ToggleFloatingChatWindow1, "chat-window-1")]
@ -36,6 +35,26 @@ public sealed class GameplayInputCommandControllerTests
Assert.Equal([expected], harness.Calls); Assert.Equal([expected], harness.Calls);
} }
// OP9: AcdreamToggleDebugPanel/ToggleChatEntry retired the
// IDevToolsGameplayCommands seam they used to forward to — both
// targets (the ImGui-era DebugPanel/ChatPanel) were already gone
// (Campaign V slice V11), so the seam's own body was an unconditional
// no-op. The action is still consumed (handled == true, matching the
// prior no-op's contract) but claims no typed-owner call.
[Theory]
[InlineData(InputAction.AcdreamToggleDebugPanel)]
[InlineData(InputAction.ToggleChatEntry)]
public void RetiredDevToolsCommand_IsConsumedWithoutClaimingATypedOwner(
InputAction action)
{
var harness = new Harness();
bool handled = harness.Controller.Handle(action);
Assert.True(handled);
Assert.Empty(harness.Calls);
}
[Fact] [Fact]
public void DiagnosticCommand_PrecedesRemainingCommandSwitch() public void DiagnosticCommand_PrecedesRemainingCommandSwitch()
{ {
@ -89,7 +108,6 @@ public sealed class GameplayInputCommandControllerTests
public Harness() public Harness()
{ {
Retained = new FakeRetained(Calls); Retained = new FakeRetained(Calls);
DevTools = new FakeDevTools(Calls);
Diagnostics = new FakeDiagnostics(Calls); Diagnostics = new FakeDiagnostics(Calls);
Player = new FakePlayerMode(Calls); Player = new FakePlayerMode(Calls);
TargetMode = new FakeTargetMode(Calls); TargetMode = new FakeTargetMode(Calls);
@ -99,7 +117,6 @@ public sealed class GameplayInputCommandControllerTests
Window = new FakeWindow(Calls); Window = new FakeWindow(Calls);
Controller = new GameplayInputCommandController( Controller = new GameplayInputCommandController(
Retained, Retained,
DevTools,
Diagnostics, Diagnostics,
Player, Player,
TargetMode, TargetMode,
@ -111,7 +128,6 @@ public sealed class GameplayInputCommandControllerTests
public List<string> Calls { get; } = []; public List<string> Calls { get; } = [];
public FakeRetained Retained { get; } public FakeRetained Retained { get; }
public FakeDevTools DevTools { get; }
public FakeDiagnostics Diagnostics { get; } public FakeDiagnostics Diagnostics { get; }
public FakePlayerMode Player { get; } public FakePlayerMode Player { get; }
public FakeTargetMode TargetMode { get; } public FakeTargetMode TargetMode { get; }
@ -133,14 +149,6 @@ public sealed class GameplayInputCommandControllerTests
public void ToggleOptionsPanel() => calls.Add("options"); public void ToggleOptionsPanel() => calls.Add("options");
} }
private sealed class FakeDevTools(List<string> calls)
: IDevToolsGameplayCommands
{
public void ToggleDebugPanel() => calls.Add("debug");
public void FocusChatInput() => calls.Add("chat");
public void ToggleSettingsPanel() => calls.Add("settings");
}
private sealed class FakeDiagnostics(List<string> calls) private sealed class FakeDiagnostics(List<string> calls)
: IRuntimeDiagnosticCommands : IRuntimeDiagnosticCommands
{ {

View file

@ -386,7 +386,7 @@ public sealed class GameWindowSlice8BoundaryTests
"_factory.CreateBackendNeutralTerrain("); "_factory.CreateBackendNeutralTerrain(");
AssertAppearsInOrder( AssertAppearsInOrder(
shutdown, shutdown,
"Soft(\"settings view model\", () => ingress.Settings.UnbindViewModel())", "new ResourceShutdownStage(\"physical ingress cleanup\"",
"new ResourceShutdownStage(\"frame borrowers\"", "new ResourceShutdownStage(\"frame borrowers\"",
"Hard(\"frame-root bindings\", () => frame.FrameBindings?.Dispose())", "Hard(\"frame-root bindings\", () => frame.FrameBindings?.Dispose())",
"Hard(\"retail UI\", () => DisposeRetailUi(live.RetailUi))", "Hard(\"retail UI\", () => DisposeRetailUi(live.RetailUi))",

View file

@ -4,10 +4,8 @@ using AcDream.App.Rendering;
using AcDream.App.Settings; using AcDream.App.Settings;
using AcDream.Core.Net.Messages; using AcDream.Core.Net.Messages;
using AcDream.UI.Abstractions; using AcDream.UI.Abstractions;
using AcDream.UI.Abstractions.Input;
using AcDream.UI.Abstractions.Panels.Settings; using AcDream.UI.Abstractions.Panels.Settings;
using AcDream.UI.Abstractions.Settings; using AcDream.UI.Abstractions.Settings;
using Silk.NET.Input;
namespace AcDream.App.Tests.Settings; namespace AcDream.App.Tests.Settings;
@ -39,14 +37,12 @@ public sealed class RuntimeSettingsControllerTests
Assert.Equal(1, storage.DisplayLoads); Assert.Equal(1, storage.DisplayLoads);
Assert.Equal(1, storage.AudioLoads); Assert.Equal(1, storage.AudioLoads);
Assert.Equal(1, storage.GameplayLoads);
Assert.Equal(1, storage.ChatLoads); Assert.Equal(1, storage.ChatLoads);
Assert.Equal(1, storage.CharacterLoads); Assert.Equal(1, storage.CharacterLoads);
Assert.Equal(1, resolveCount); Assert.Equal(1, resolveCount);
Assert.Equal("default", storage.LastLoadedCharacter); Assert.Equal("default", storage.LastLoadedCharacter);
Assert.Same(storage.DisplayValue, controller.Startup.Display); Assert.Same(storage.DisplayValue, controller.Startup.Display);
Assert.Same(storage.AudioValue, controller.Startup.Audio); Assert.Same(storage.AudioValue, controller.Startup.Audio);
Assert.Same(storage.GameplayValue, controller.Startup.Gameplay);
Assert.Same(storage.ChatValue, controller.Startup.Chat); Assert.Same(storage.ChatValue, controller.Startup.Chat);
Assert.Same(storage.DefaultCharacterValue, controller.Startup.Character); Assert.Same(storage.DefaultCharacterValue, controller.Startup.Character);
Assert.Equal(resolved, controller.Startup.Quality); Assert.Equal(resolved, controller.Startup.Quality);
@ -75,31 +71,6 @@ public sealed class RuntimeSettingsControllerTests
controller.BindRuntimeTargets(new FakeRuntimeTargets(events))); controller.BindRuntimeTargets(new FakeRuntimeTargets(events)));
} }
[Fact]
public void ViewModelBindingReleasesOnlyItsExpectedInstance()
{
var controller = CreateController();
using InputDispatcher dispatcher = CreateDispatcher();
RuntimeSettingsViewModelBinding first = controller.CreateViewModelBinding(
new KeyBindings(),
dispatcher,
static _ => { });
first.Dispose();
RuntimeSettingsViewModelBinding second = controller.CreateViewModelBinding(
new KeyBindings(),
dispatcher,
static _ => { });
first.Dispose();
Assert.Throws<InvalidOperationException>(() =>
controller.CreateViewModelBinding(
new KeyBindings(),
dispatcher,
static _ => { }));
second.Dispose();
}
[Fact] [Fact]
public void StartupRetryResumesAfterLastSuccessfulStage() public void StartupRetryResumesAfterLastSuccessfulStage()
{ {
@ -262,63 +233,6 @@ public sealed class RuntimeSettingsControllerTests
Assert.False(cmd.Value); Assert.False(cmd.Value);
} }
[Fact]
public void SettingsViewModelSavePreservesSectionAndTargetOrder()
{
var events = new List<string>();
var storage = new FakeStorage(events);
var resolved = new QualitySettings(5, 15, 4, 16, true, 6);
var controller = new RuntimeSettingsController(
storage,
_ => resolved,
static _ => { });
storage.ClearEvents();
var targets = new FakeRuntimeTargets(events);
controller.BindRuntimeTargets(targets);
using InputDispatcher dispatcher = CreateDispatcher();
SettingsVM viewModel = controller.CreateViewModel(
new KeyBindings(),
dispatcher,
_ => events.Add("save-bindings"));
viewModel.SetDisplay(viewModel.DisplayDraft with
{
Resolution = "1920x1080",
Quality = QualityPreset.Ultra,
});
viewModel.SetAudio(viewModel.AudioDraft with { Master = 0.25f });
viewModel.SetGameplay(viewModel.GameplayDraft with { LockUI = true });
viewModel.SetChat(viewModel.ChatDraft with { ShowTimestamps = true });
viewModel.SetCharacter(viewModel.CharacterDraft with { AutoAttack = true });
viewModel.Save();
Assert.Equal(
[
"save-bindings",
"save-display",
"target-display",
"target-quality",
"save-audio",
// Campaign OP slice OP6 (2026-08-11): SaveAudio now pushes the
// saved snapshot into the live engine, same shape as
// save-display's own target-display push a few lines up.
"target-audio",
"save-gameplay",
"target-ui-lock:True",
"save-chat",
"target-chat-opacity:1:1",
"save-character:default",
],
events);
Assert.Equal("1920x1080", controller.Display.Resolution);
Assert.Equal(0.25f, controller.Audio.Master);
Assert.True(controller.Gameplay.LockUI);
Assert.True(controller.Chat.ShowTimestamps);
Assert.True(controller.Character.AutoAttack);
Assert.Equal(resolved, controller.ResolvedQuality);
}
// OP6 rework (2026-08-11, review S5 / M2): pins the EFFECTIVE volume // OP6 rework (2026-08-11, review S5 / M2): pins the EFFECTIVE volume
// ApplyAudio actually computes, not just that some target was called. // ApplyAudio actually computes, not just that some target was called.
// The FakeRuntimeTargets-based "target-audio" assertion above (and its // The FakeRuntimeTargets-based "target-audio" assertion above (and its
@ -369,6 +283,10 @@ public sealed class RuntimeSettingsControllerTests
// CH3 (2026-08-09): SaveChat must publish SetSingleCharacterOption // CH3 (2026-08-09): SaveChat must publish SetSingleCharacterOption
// (0x0005) for exactly the Hear*Chat bits that actually changed — // (0x0005) for exactly the Hear*Chat bits that actually changed —
// touching one checkbox must not resend the other four. // touching one checkbox must not resend the other four.
//
// OP9 rework: SaveChat was widened to public at Campaign OP slice
// OP6, so this test now calls it directly instead of routing
// through the retired SettingsVM draft/Save() indirection.
var storage = new FakeStorage(); var storage = new FakeStorage();
var controller = new RuntimeSettingsController( var controller = new RuntimeSettingsController(
storage, storage,
@ -376,31 +294,24 @@ public sealed class RuntimeSettingsControllerTests
static _ => { }); static _ => { });
var targets = new FakeRuntimeTargets([]); var targets = new FakeRuntimeTargets([]);
controller.BindRuntimeTargets(targets); controller.BindRuntimeTargets(targets);
using InputDispatcher dispatcher = CreateDispatcher();
SettingsVM viewModel = controller.CreateViewModel(
new KeyBindings(),
dispatcher,
static _ => { });
// N4 (CH3 Opus review): ChatSettings.Default now matches ACE's real // N4 (CH3 Opus review): ChatSettings.Default now matches ACE's real
// CharacterOptions2.Default — Roleplay/Society start FALSE (only // CharacterOptions2.Default — Roleplay/Society start FALSE (only
// General/Trade/LFG start true). Flip Roleplay ON first so the // General/Trade/LFG start true). Flip Roleplay ON first so the
// second edit below can flip it back off. // second edit below can flip it back off.
Assert.False(viewModel.ChatDraft.HearRoleplayChat); Assert.False(controller.Chat.HearRoleplayChat);
viewModel.SetChat(viewModel.ChatDraft with { HearRoleplayChat = true }); controller.SaveChat(controller.Chat with { HearRoleplayChat = true });
viewModel.Save();
Assert.Equal( Assert.Equal(
[((uint)CharacterOptionId.ListenToRoleplayChat, true)], [((uint)CharacterOptionId.ListenToRoleplayChat, true)],
targets.SingleOptionCalls); targets.SingleOptionCalls);
targets.SingleOptionCalls.Clear(); targets.SingleOptionCalls.Clear();
viewModel.SetChat(viewModel.ChatDraft with controller.SaveChat(controller.Chat with
{ {
HearRoleplayChat = false, HearRoleplayChat = false,
HearSocietyChat = true, HearSocietyChat = true,
}); });
viewModel.Save();
Assert.Equal( Assert.Equal(
[ [
@ -416,15 +327,9 @@ public sealed class RuntimeSettingsControllerTests
var controller = CreateController(); var controller = CreateController();
var targets = new FakeRuntimeTargets([]); var targets = new FakeRuntimeTargets([]);
controller.BindRuntimeTargets(targets); controller.BindRuntimeTargets(targets);
using InputDispatcher dispatcher = CreateDispatcher();
SettingsVM viewModel = controller.CreateViewModel(
new KeyBindings(),
dispatcher,
static _ => { });
// Touch a Chat field that is NOT a Hear*Chat membership bit. // Touch a Chat field that is NOT a Hear*Chat membership bit.
viewModel.SetChat(viewModel.ChatDraft with { ShowTimestamps = false }); controller.SaveChat(controller.Chat with { ShowTimestamps = false });
viewModel.Save();
Assert.Empty(targets.SingleOptionCalls); Assert.Empty(targets.SingleOptionCalls);
} }
@ -434,23 +339,17 @@ public sealed class RuntimeSettingsControllerTests
{ {
// Campaign CH slice CH6c: unlike the Hear* options (local-only, no // Campaign CH slice CH6c: unlike the Hear* options (local-only, no
// wire), opacity is ALWAYS pushed on Save (not diffed) so the linking // wire), opacity is ALWAYS pushed on Save (not diffed) so the linking
// invariant self-heals; the point of this test is that clicking Save // invariant self-heals; the point of this test is that calling
// is enough — no restart, no separate "apply" step. // SaveChat is enough — no restart, no separate "apply" step.
var controller = CreateController(); var controller = CreateController();
var targets = new FakeRuntimeTargets([]); var targets = new FakeRuntimeTargets([]);
controller.BindRuntimeTargets(targets); controller.BindRuntimeTargets(targets);
using InputDispatcher dispatcher = CreateDispatcher();
SettingsVM viewModel = controller.CreateViewModel(
new KeyBindings(),
dispatcher,
static _ => { });
viewModel.SetChat(viewModel.ChatDraft with controller.SaveChat(controller.Chat with
{ {
DefaultOpacity = 0.3f, DefaultOpacity = 0.3f,
ActiveOpacity = 0.6f, ActiveOpacity = 0.6f,
}); });
viewModel.Save();
Assert.Equal([(0.3f, 0.6f)], targets.ChatOpacityCalls); Assert.Equal([(0.3f, 0.6f)], targets.ChatOpacityCalls);
Assert.Equal(0.3f, controller.Chat.DefaultOpacity); Assert.Equal(0.3f, controller.Chat.DefaultOpacity);
@ -486,7 +385,7 @@ public sealed class RuntimeSettingsControllerTests
} }
[Fact] [Fact]
public void SyncChatFromServerOptionsReseedsPersistedAndDraft() public void SyncChatFromServerOptionsReseedsPersisted()
{ {
// Research doc §5.2: ACE's CharacterOptions2.Default omits // Research doc §5.2: ACE's CharacterOptions2.Default omits
// HearRoleplayChat/HearSocietyChat. N4 (CH3 Opus review) aligned // HearRoleplayChat/HearSocietyChat. N4 (CH3 Opus review) aligned
@ -507,13 +406,7 @@ public sealed class RuntimeSettingsControllerTests
storage, storage,
static preset => QualitySettings.From(preset), static preset => QualitySettings.From(preset),
static _ => { }); static _ => { });
using InputDispatcher dispatcher = CreateDispatcher();
SettingsVM viewModel = controller.CreateViewModel(
new KeyBindings(),
dispatcher,
static _ => { });
Assert.True(controller.Chat.HearRoleplayChat); Assert.True(controller.Chat.HearRoleplayChat);
Assert.True(viewModel.ChatDraft.HearRoleplayChat);
controller.SyncChatFromServerOptions(0x00948700u); // ACE's real default controller.SyncChatFromServerOptions(0x00948700u); // ACE's real default
@ -522,28 +415,9 @@ public sealed class RuntimeSettingsControllerTests
Assert.True(controller.Chat.HearLFGChat); Assert.True(controller.Chat.HearLFGChat);
Assert.False(controller.Chat.HearRoleplayChat); Assert.False(controller.Chat.HearRoleplayChat);
Assert.False(controller.Chat.HearSocietyChat); Assert.False(controller.Chat.HearSocietyChat);
Assert.False(viewModel.ChatDraft.HearRoleplayChat);
Assert.False(viewModel.ChatDraft.HearSocietyChat);
Assert.Same(controller.Chat, storage.ChatValue); Assert.Same(controller.Chat, storage.ChatValue);
} }
[Fact]
public void SyncChatFromServerOptionsPreservesUnsavedUnrelatedDraftEdits()
{
var controller = CreateController();
using InputDispatcher dispatcher = CreateDispatcher();
SettingsVM viewModel = controller.CreateViewModel(
new KeyBindings(),
dispatcher,
static _ => { });
viewModel.SetChat(viewModel.ChatDraft with { FontSize = 18f });
controller.SyncChatFromServerOptions(0x00948700u);
Assert.Equal(18f, viewModel.ChatDraft.FontSize);
Assert.False(viewModel.ChatDraft.HearRoleplayChat);
}
[Fact] [Fact]
public void SyncChatFromServerOptionsIsANoOpWhenUnchanged() public void SyncChatFromServerOptionsIsANoOpWhenUnchanged()
{ {
@ -569,126 +443,31 @@ public sealed class RuntimeSettingsControllerTests
} }
[Fact] [Fact]
public void DraftPreviewAndExternalCommandsShareCanonicalState() public void DraftPreviewAlwaysMirrorsCommittedState()
{ {
var events = new List<string>(); // OP9: the optional developer-tools draft-preview view model
var storage = new FakeStorage(events); // (SettingsVM) was retired — it had zero production construction
var controller = CreateController(storage, events: events); // sites. HasDraftPreview is now hardcoded false and
storage.ClearEvents(); // DisplayPreview/AudioPreview always mirror the committed
var targets = new FakeRuntimeTargets(events); // Display/Audio snapshot. This pins the NEW (trivial) contract that
controller.BindRuntimeTargets(targets); // WorldRenderFrameBuilder and SettingsParticleRangeSource still
using InputDispatcher dispatcher = CreateDispatcher(); // consume through IRuntimeSettingsPreviewSource.
SettingsVM viewModel = controller.CreateViewModel( var controller = CreateController();
new KeyBindings(),
dispatcher,
static _ => { });
viewModel.SetDisplay(viewModel.DisplayDraft with
{
FieldOfView = 91f,
ParticleRange = ParticleRange.Retail,
});
viewModel.SetAudio(viewModel.AudioDraft with { Sfx = 0.33f });
viewModel.SetGameplay(viewModel.GameplayDraft with { ShowTooltips = false });
Assert.True(controller.HasDraftPreview); Assert.False(controller.HasDraftPreview);
Assert.Equal(controller.Display, controller.DisplayPreview);
Assert.Equal(controller.Audio, controller.AudioPreview);
controller.SaveDisplay(controller.Display with { FieldOfView = 91f });
controller.SaveAudio(controller.Audio with { Sfx = 0.33f });
Assert.False(controller.HasDraftPreview);
Assert.Equal(91f, controller.DisplayPreview.FieldOfView); Assert.Equal(91f, controller.DisplayPreview.FieldOfView);
Assert.Equal(ParticleRange.Retail, controller.DisplayPreview.ParticleRange);
Assert.Equal(0.33f, controller.AudioPreview.Sfx); Assert.Equal(0.33f, controller.AudioPreview.Sfx);
controller.ToggleFrameRate();
controller.SetUiLocked(true);
controller.SetAcceptLootPermits(true);
Assert.True(controller.Display.ShowFps);
Assert.True(controller.DisplayPreview.ShowFps);
Assert.Equal(91f, controller.DisplayPreview.FieldOfView);
Assert.True(controller.Gameplay.LockUI);
Assert.True(controller.Gameplay.AcceptLootPermits);
Assert.False(viewModel.GameplayDraft.ShowTooltips);
Assert.True(viewModel.GameplayDraft.LockUI);
Assert.True(viewModel.GameplayDraft.AcceptLootPermits);
Assert.Contains("target-ui-lock:True", events);
Assert.Equal(2, storage.GameplaySaves);
Assert.Equal(1, storage.DisplaySaves);
} }
[Fact] [Fact]
public void DraftCancelRestoresPreviewAndCombatTogglePreservesUnrelatedDrafts() public void CharacterContextSwitchesActiveToonAndReloadsSettings()
{
var storage = new FakeStorage();
var controller = CreateController(storage);
using InputDispatcher dispatcher = CreateDispatcher();
SettingsVM viewModel = controller.CreateViewModel(
new KeyBindings(),
dispatcher,
static _ => { });
viewModel.SetDisplay(viewModel.DisplayDraft with { FieldOfView = 99f });
viewModel.SetGameplay(viewModel.GameplayDraft with
{
ShowTooltips = false,
CoordinatesOnRadar = false,
});
controller.SetUiLocked(true);
controller.SetAcceptLootPermits(true);
Assert.False(viewModel.GameplayDraft.ShowTooltips);
Assert.False(viewModel.GameplayDraft.CoordinatesOnRadar);
Assert.True(viewModel.GameplayDraft.LockUI);
Assert.True(viewModel.GameplayDraft.AcceptLootPermits);
Assert.Equal(99f, controller.DisplayPreview.FieldOfView);
viewModel.Cancel();
Assert.Equal(controller.Display.FieldOfView, controller.DisplayPreview.FieldOfView);
Assert.Equal(controller.Gameplay.ShowTooltips, viewModel.GameplayDraft.ShowTooltips);
Assert.Equal(
controller.Gameplay.CoordinatesOnRadar,
viewModel.GameplayDraft.CoordinatesOnRadar);
Assert.True(viewModel.GameplayDraft.LockUI);
Assert.True(viewModel.GameplayDraft.AcceptLootPermits);
viewModel.SetGameplay(viewModel.GameplayDraft with { ShowHelm = false });
viewModel.Save();
Assert.True(storage.GameplayValue.LockUI);
Assert.True(storage.GameplayValue.AcceptLootPermits);
Assert.False(storage.GameplayValue.ShowHelm);
}
[Fact]
public void FailedExternalGameplayPersistenceDoesNotPromoteViewModelBaseline()
{
var storage = new FakeStorage { ThrowOnGameplaySave = true };
var logs = new List<string>();
var controller = new RuntimeSettingsController(
storage,
static preset => QualitySettings.From(preset),
logs.Add);
using InputDispatcher dispatcher = CreateDispatcher();
SettingsVM viewModel = controller.CreateViewModel(
new KeyBindings(),
dispatcher,
static _ => { });
controller.SetUiLocked(true);
Assert.Throws<IOException>(() => controller.SetAcceptLootPermits(true));
Assert.True(viewModel.GameplayDraft.LockUI);
Assert.True(viewModel.GameplayDraft.AcceptLootPermits);
viewModel.Cancel();
Assert.Equal(GameplaySettings.Default.LockUI, viewModel.GameplayDraft.LockUI);
Assert.Equal(
GameplaySettings.Default.AcceptLootPermits,
viewModel.GameplayDraft.AcceptLootPermits);
Assert.Contains(logs, line =>
line.Contains("radar lock save failed", StringComparison.Ordinal));
}
[Fact]
public void CharacterContextLoadsWithoutViewModelAndSynchronizesWhenBound()
{ {
var storage = new FakeStorage(); var storage = new FakeStorage();
storage.Characters["Alice"] = CharacterSettings.Default with storage.Characters["Alice"] = CharacterSettings.Default with
@ -707,76 +486,23 @@ public sealed class RuntimeSettingsControllerTests
Assert.Equal("Alice", controller.ActiveToonKey); Assert.Equal("Alice", controller.ActiveToonKey);
Assert.Equal("Trade", controller.Character.DefaultChatChannel); Assert.Equal("Trade", controller.Character.DefaultChatChannel);
using InputDispatcher dispatcher = CreateDispatcher();
SettingsVM viewModel = controller.CreateViewModel(
new KeyBindings(),
dispatcher,
static _ => { });
controller.LoadCharacterContext("Bob"); controller.LoadCharacterContext("Bob");
Assert.Equal("Bob", controller.ActiveToonKey); Assert.Equal("Bob", controller.ActiveToonKey);
Assert.False(viewModel.CharacterDraft.ConfirmSalvage); Assert.False(controller.Character.ConfirmSalvage);
controller.RestoreDefaultCharacterContext(); controller.RestoreDefaultCharacterContext();
controller.ResetActiveCharacterKey(); controller.ResetActiveCharacterKey();
Assert.Equal("default", controller.ActiveToonKey); Assert.Equal("default", controller.ActiveToonKey);
Assert.Same(storage.DefaultCharacterValue, controller.Character); Assert.Same(storage.DefaultCharacterValue, controller.Character);
Assert.Same(storage.DefaultCharacterValue, viewModel.CharacterDraft);
} }
[Fact] [Fact]
public void CharacterSaveUsesActiveToonAndDefaultSaveBecomesResetContext() public void RuntimeTargetLoansCanBeWithdrawnAndReboundPrecisely()
{
var storage = new FakeStorage();
var controller = CreateController(storage);
using InputDispatcher dispatcher = CreateDispatcher();
SettingsVM viewModel = controller.CreateViewModel(
new KeyBindings(),
dispatcher,
static _ => { });
var newDefault = CharacterSettings.Default with { AutoAttack = true };
viewModel.SetCharacter(newDefault);
viewModel.Save();
controller.LoadCharacterContext("Alice");
var alice = CharacterSettings.Default with { DefaultChatChannel = "Trade" };
viewModel.SetCharacter(alice);
viewModel.Save();
Assert.Same(newDefault, storage.Characters["default"]);
Assert.Same(alice, storage.Characters["Alice"]);
controller.RestoreDefaultCharacterContext();
Assert.Same(newDefault, controller.Character);
Assert.Same(newDefault, viewModel.CharacterDraft);
}
[Fact]
public void ViewModelAndRuntimeTargetLoansCanBeWithdrawnAndReboundPrecisely()
{ {
var events = new List<string>(); var events = new List<string>();
var controller = CreateController(events: events); var controller = CreateController(events: events);
using InputDispatcher dispatcher = CreateDispatcher();
SettingsVM viewModel = controller.CreateViewModel(
new KeyBindings(),
dispatcher,
static _ => { });
SettingsVM foreign = CreateStandaloneViewModel(dispatcher);
controller.UnbindViewModel(foreign);
Assert.True(controller.HasDraftPreview);
Assert.Throws<InvalidOperationException>(() =>
controller.CreateViewModel(
new KeyBindings(),
dispatcher,
static _ => { }));
controller.UnbindViewModel(viewModel);
Assert.False(controller.HasDraftPreview);
controller.CreateViewModel(
new KeyBindings(),
dispatcher,
static _ => { });
var first = new FakeRuntimeTargets(events); var first = new FakeRuntimeTargets(events);
controller.BindRuntimeTargets(first); controller.BindRuntimeTargets(first);
@ -822,20 +548,14 @@ public sealed class RuntimeSettingsControllerTests
logs.Add); logs.Add);
storage.ClearEvents(); storage.ClearEvents();
controller.BindRuntimeTargets(new FakeRuntimeTargets(events)); controller.BindRuntimeTargets(new FakeRuntimeTargets(events));
using InputDispatcher dispatcher = CreateDispatcher();
SettingsVM viewModel = controller.CreateViewModel(
new KeyBindings(),
dispatcher,
static _ => { });
DisplaySettings original = controller.Display; DisplaySettings original = controller.Display;
viewModel.SetDisplay(viewModel.DisplayDraft with
controller.SaveDisplay(controller.Display with
{ {
Resolution = "2560x1440", Resolution = "2560x1440",
Quality = QualityPreset.Ultra, Quality = QualityPreset.Ultra,
}); });
viewModel.Save();
Assert.Same(original, controller.Display); Assert.Same(original, controller.Display);
Assert.DoesNotContain("target-display", events); Assert.DoesNotContain("target-display", events);
Assert.DoesNotContain("target-quality", events); Assert.DoesNotContain("target-quality", events);
@ -857,20 +577,14 @@ public sealed class RuntimeSettingsControllerTests
{ {
ThrowOnDisplay = true, ThrowOnDisplay = true,
}); });
using InputDispatcher dispatcher = CreateDispatcher();
SettingsVM viewModel = controller.CreateViewModel(
new KeyBindings(),
dispatcher,
static _ => { });
DisplaySettings original = controller.Display; DisplaySettings original = controller.Display;
viewModel.SetDisplay(viewModel.DisplayDraft with
controller.SaveDisplay(controller.Display with
{ {
Resolution = "3840x2160", Resolution = "3840x2160",
Quality = QualityPreset.Ultra, Quality = QualityPreset.Ultra,
}); });
viewModel.Save();
Assert.Equal(1, storage.DisplaySaves); Assert.Equal(1, storage.DisplaySaves);
Assert.Same(original, controller.Display); Assert.Same(original, controller.Display);
Assert.DoesNotContain("target-quality", events); Assert.DoesNotContain("target-quality", events);
@ -883,9 +597,7 @@ public sealed class RuntimeSettingsControllerTests
var storage = new FakeStorage var storage = new FakeStorage
{ {
ThrowOnAudioSave = true, ThrowOnAudioSave = true,
ThrowOnGameplaySave = true,
ThrowOnChatSave = true, ThrowOnChatSave = true,
ThrowOnCharacterSave = true,
}; };
var logs = new List<string>(); var logs = new List<string>();
var controller = new RuntimeSettingsController( var controller = new RuntimeSettingsController(
@ -893,29 +605,15 @@ public sealed class RuntimeSettingsControllerTests
static preset => QualitySettings.From(preset), static preset => QualitySettings.From(preset),
logs.Add); logs.Add);
AudioSettings originalAudio = controller.Audio; AudioSettings originalAudio = controller.Audio;
GameplaySettings originalGameplay = controller.Gameplay;
ChatSettings originalChat = controller.Chat; ChatSettings originalChat = controller.Chat;
CharacterSettings originalCharacter = controller.Character;
using InputDispatcher dispatcher = CreateDispatcher();
SettingsVM viewModel = controller.CreateViewModel(
new KeyBindings(),
dispatcher,
static _ => { });
viewModel.SetAudio(viewModel.AudioDraft with { Master = 0.1f });
viewModel.SetGameplay(viewModel.GameplayDraft with { LockUI = true });
viewModel.SetChat(viewModel.ChatDraft with { ShowTimestamps = true });
viewModel.SetCharacter(viewModel.CharacterDraft with { AutoAttack = true });
viewModel.Save(); controller.SaveAudio(controller.Audio with { Master = 0.1f });
controller.SaveChat(controller.Chat with { ShowTimestamps = true });
Assert.Same(originalAudio, controller.Audio); Assert.Same(originalAudio, controller.Audio);
Assert.Same(originalGameplay, controller.Gameplay);
Assert.Same(originalChat, controller.Chat); Assert.Same(originalChat, controller.Chat);
Assert.Same(originalCharacter, controller.Character);
Assert.Contains(logs, line => line.Contains("audio save failed", StringComparison.Ordinal)); Assert.Contains(logs, line => line.Contains("audio save failed", StringComparison.Ordinal));
Assert.Contains(logs, line => line.Contains("gameplay save failed", StringComparison.Ordinal));
Assert.Contains(logs, line => line.Contains("chat save failed", StringComparison.Ordinal)); Assert.Contains(logs, line => line.Contains("chat save failed", StringComparison.Ordinal));
Assert.Contains(logs, line => line.Contains("character save failed", StringComparison.Ordinal));
} }
[Fact] [Fact]
@ -956,135 +654,74 @@ public sealed class RuntimeSettingsControllerTests
} }
[Fact] [Fact]
public void SetUiLocked_AppliesEvenWhenGameplayLockUIAlreadyMatches_IfNeverActuallyApplied() public void SetUiLocked_AppliesOnFirstCallThenNoOpsOnRepeatedSameValue()
{ {
// MUST-FIX 4 (OP4 review-fix round, 2026-08-11, blast M3): the // OP9: SetUiLocked no longer reads or writes a persisted
// guard used to compare the requested value against // GameplaySettings mirror (MUST-FIX 4, OP4 review-fix round,
// `Gameplay.LockUI` — valid only while `ToggleUiLock` derived // 2026-08-11, blast M3, superseded) — RuntimeCharacterOptionsState
// `locked` AS `!Gameplay.LockUI`. OP4 re-pointed `ToggleUiLock` to // (the server bit) is the sole authority now, so there is no longer
// read the SERVER bit (RuntimeCharacterOptionsState) instead, a // a second store that could disagree with what was actually
// DIFFERENT store that can already equal a value this controller // applied. The guard's own idempotency (compare against the last
// never actually pushed to `_runtimeTargets`. A pre-existing save // value ACTUALLY pushed to _runtimeTargets) is the only behavior
// seeds Gameplay.LockUI = true; the runtime target has never seen // left to pin here.
// `true` — the FIRST SetUiLocked(true) call must still apply.
var events = new List<string>(); var events = new List<string>();
var storage = new FakeStorage(events) var controller = CreateController(events: events);
{
GameplayValue = GameplaySettings.Default with { LockUI = true },
};
var controller = new RuntimeSettingsController(
storage,
static preset => QualitySettings.From(preset),
static _ => { });
Assert.True(controller.Gameplay.LockUI); // already true, but never applied
var targets = new FakeRuntimeTargets(events); var targets = new FakeRuntimeTargets(events);
controller.BindRuntimeTargets(targets); controller.BindRuntimeTargets(targets);
controller.SetUiLocked(true); controller.SetUiLocked(true);
Assert.Equal(1, targets.UiLockCalls); Assert.Equal(1, targets.UiLockCalls);
// A second call with the SAME value now correctly no-ops — this
// time it really was applied.
controller.SetUiLocked(true); controller.SetUiLocked(true);
Assert.Equal(1, targets.UiLockCalls); Assert.Equal(1, targets.UiLockCalls);
controller.SetUiLocked(false);
Assert.Equal(2, targets.UiLockCalls);
} }
[Fact] [Fact]
public void UiLockTargetFailureCanRetryTheSameRequestedValue() public void UiLockTargetFailureCanRetryTheSameRequestedValue()
{ {
var events = new List<string>(); var events = new List<string>();
var storage = new FakeStorage(events); var controller = CreateController(events: events);
var controller = CreateController(storage, events);
storage.ClearEvents();
var targets = new FakeRuntimeTargets(events) var targets = new FakeRuntimeTargets(events)
{ {
RemainingUiLockFailures = 1, RemainingUiLockFailures = 1,
}; };
controller.BindRuntimeTargets(targets); controller.BindRuntimeTargets(targets);
using InputDispatcher dispatcher = CreateDispatcher();
SettingsVM viewModel = controller.CreateViewModel(
new KeyBindings(),
dispatcher,
static _ => { });
Assert.Throws<InvalidOperationException>(() => controller.SetUiLocked(true)); Assert.Throws<InvalidOperationException>(() => controller.SetUiLocked(true));
Assert.True(controller.Gameplay.LockUI);
Assert.Equal(GameplaySettings.Default.LockUI, viewModel.GameplayDraft.LockUI);
Assert.Equal(0, storage.GameplaySaves);
Assert.Equal(["target-ui-lock:True"], events); Assert.Equal(["target-ui-lock:True"], events);
// A retry with the SAME requested value must re-apply — the guard
// only advances _lastAppliedUiLocked on SUCCESS (the field write in
// SetUiLocked runs after the ApplyUiLock call, which threw above).
events.Clear(); events.Clear();
controller.SetUiLocked(true); controller.SetUiLocked(true);
Assert.True(controller.Gameplay.LockUI); Assert.Equal(["target-ui-lock:True"], events);
Assert.True(viewModel.GameplayDraft.LockUI); Assert.Equal(2, targets.UiLockCalls);
viewModel.Cancel();
Assert.True(viewModel.GameplayDraft.LockUI);
Assert.Equal(1, storage.GameplaySaves);
Assert.Equal(["target-ui-lock:True", "save-gameplay"], events);
} }
[Fact] [Fact]
public void UiLockPersistenceFailureCanRetryTheSameRequestedValue() public void UnboundRuntimeTargetsConsumeUiLockCallsSilently()
{ {
// OP9: SetUiLocked no longer persists anything of its own, so the
// only observable effect of an unbound call is the ABSENCE of any
// "target-" event — there is no longer a Gameplay-side write to
// assert against.
var events = new List<string>(); var events = new List<string>();
var storage = new FakeStorage(events) var controller = CreateController(events: events);
{
RemainingGameplaySaveFailures = 1,
};
var logs = new List<string>();
var controller = new RuntimeSettingsController(
storage,
static preset => QualitySettings.From(preset),
logs.Add);
storage.ClearEvents();
controller.BindRuntimeTargets(new FakeRuntimeTargets(events));
using InputDispatcher dispatcher = CreateDispatcher();
SettingsVM viewModel = controller.CreateViewModel(
new KeyBindings(),
dispatcher,
static _ => { });
controller.SetUiLocked(true);
Assert.True(controller.Gameplay.LockUI);
Assert.True(viewModel.GameplayDraft.LockUI);
Assert.Equal(1, storage.GameplaySaves);
Assert.Contains(logs, line =>
line.Contains("radar lock save failed", StringComparison.Ordinal));
events.Clear();
controller.SetUiLocked(true);
Assert.True(controller.Gameplay.LockUI);
viewModel.Cancel();
Assert.True(viewModel.GameplayDraft.LockUI);
Assert.Equal(2, storage.GameplaySaves);
Assert.Equal(["target-ui-lock:True", "save-gameplay"], events);
}
[Fact]
public void UnbindingLoansStopsCallsButStateAndPersistenceContinue()
{
var events = new List<string>();
var storage = new FakeStorage(events);
var controller = CreateController(storage, events: events);
storage.ClearEvents();
controller.BindRuntimeTargets(new FakeRuntimeTargets(events)); controller.BindRuntimeTargets(new FakeRuntimeTargets(events));
controller.UnbindRuntimeTargets(); controller.UnbindRuntimeTargets();
controller.SetUiLocked(true); controller.SetUiLocked(true);
controller.ReapplyQualityPreset(QualityPreset.Ultra); controller.ReapplyQualityPreset(QualityPreset.Ultra);
Assert.True(controller.Gameplay.LockUI);
Assert.Equal( Assert.Equal(
QualitySettings.From(QualityPreset.Ultra), QualitySettings.From(QualityPreset.Ultra),
controller.ResolvedQuality); controller.ResolvedQuality);
Assert.DoesNotContain(events, value => value.StartsWith("target-", StringComparison.Ordinal)); Assert.DoesNotContain(events, value => value.StartsWith("target-", StringComparison.Ordinal));
Assert.Equal(1, storage.GameplaySaves);
} }
[Theory] [Theory]
@ -1121,32 +758,6 @@ public sealed class RuntimeSettingsControllerTests
static _ => { }); static _ => { });
} }
private static InputDispatcher CreateDispatcher()
{
InputDispatcher dispatcher = InputDispatcher.CreateDetached(
new NullKeyboardSource(),
new NullMouseSource(),
new KeyBindings());
dispatcher.Attach();
return dispatcher;
}
private static SettingsVM CreateStandaloneViewModel(InputDispatcher dispatcher) =>
new(
new KeyBindings(),
dispatcher,
static _ => { },
DisplaySettings.Default,
static _ => { },
AudioSettings.Default,
static _ => { },
GameplaySettings.Default,
static _ => { },
ChatSettings.Default,
static _ => { },
CharacterSettings.Default,
static _ => { });
private sealed class FakeStartupTarget(List<string> events) private sealed class FakeStartupTarget(List<string> events)
: IRuntimeSettingsStartupTarget : IRuntimeSettingsStartupTarget
{ {
@ -1356,8 +967,6 @@ public sealed class RuntimeSettingsControllerTests
public AudioSettings AudioValue { get; set; } = AudioSettings.Default; public AudioSettings AudioValue { get; set; } = AudioSettings.Default;
public GameplaySettings GameplayValue { get; set; } = GameplaySettings.Default;
public ChatSettings ChatValue { get; set; } = ChatSettings.Default; public ChatSettings ChatValue { get; set; } = ChatSettings.Default;
public CharacterSettings DefaultCharacterValue { get; set; } = public CharacterSettings DefaultCharacterValue { get; set; } =
@ -1377,16 +986,12 @@ public sealed class RuntimeSettingsControllerTests
public int AudioLoads { get; private set; } public int AudioLoads { get; private set; }
public int GameplayLoads { get; private set; }
public int ChatLoads { get; private set; } public int ChatLoads { get; private set; }
public int CharacterLoads { get; private set; } public int CharacterLoads { get; private set; }
public int DisplaySaves { get; private set; } public int DisplaySaves { get; private set; }
public int GameplaySaves { get; private set; }
public int ChatSaves { get; private set; } public int ChatSaves { get; private set; }
public string? LastLoadedCharacter { get; private set; } public string? LastLoadedCharacter { get; private set; }
@ -1395,10 +1000,6 @@ public sealed class RuntimeSettingsControllerTests
public bool ThrowOnAudioSave { get; init; } public bool ThrowOnAudioSave { get; init; }
public bool ThrowOnGameplaySave { get; init; }
public int RemainingGameplaySaveFailures { get; set; }
public bool ThrowOnChatSave { get; init; } public bool ThrowOnChatSave { get; init; }
public bool ThrowOnCharacterSave { get; init; } public bool ThrowOnCharacterSave { get; init; }
@ -1415,12 +1016,6 @@ public sealed class RuntimeSettingsControllerTests
return AudioValue; return AudioValue;
} }
public GameplaySettings LoadGameplay()
{
GameplayLoads++;
return GameplayValue;
}
public ChatSettings LoadChat() public ChatSettings LoadChat()
{ {
ChatLoads++; ChatLoads++;
@ -1453,20 +1048,6 @@ public sealed class RuntimeSettingsControllerTests
AudioValue = audio; AudioValue = audio;
} }
public void SaveGameplay(GameplaySettings gameplay)
{
GameplaySaves++;
_events.Add("save-gameplay");
if (RemainingGameplaySaveFailures > 0)
{
RemainingGameplaySaveFailures--;
throw new IOException("gameplay persistence failed");
}
if (ThrowOnGameplaySave)
throw new IOException("gameplay persistence failed");
GameplayValue = gameplay;
}
public void SaveChat(ChatSettings chat) public void SaveChat(ChatSettings chat)
{ {
ChatSaves++; ChatSaves++;
@ -1532,27 +1113,4 @@ public sealed class RuntimeSettingsControllerTests
return _refreshRate is > 0; return _refreshRate is > 0;
} }
} }
private sealed class NullKeyboardSource : IKeyboardSource
{
#pragma warning disable CS0067
public event Action<Key, ModifierMask>? KeyDown;
public event Action<Key, ModifierMask>? KeyUp;
#pragma warning restore CS0067
public bool IsHeld(Key key) => false;
public ModifierMask CurrentModifiers => ModifierMask.None;
}
private sealed class NullMouseSource : IMouseSource
{
#pragma warning disable CS0067
public event Action<MouseButton, ModifierMask>? MouseDown;
public event Action<MouseButton, ModifierMask>? MouseUp;
public event Action<float, float>? MouseMove;
public event Action<float>? Scroll;
#pragma warning restore CS0067
public bool IsHeld(MouseButton button) => false;
public bool WantCaptureMouse => false;
public bool WantCaptureKeyboard => false;
}
} }

View file

@ -6,7 +6,9 @@ namespace AcDream.UI.Abstractions.Tests.Input;
/// <summary> /// <summary>
/// K.3: <see cref="InputDispatcher.BeginCapture"/> is the modal-rebind /// K.3: <see cref="InputDispatcher.BeginCapture"/> is the modal-rebind
/// hook used by <c>SettingsPanel</c>. While capture is active, the next /// hook used by Campaign OP slice OP8's <c>KeyboardConfigController</c>
/// (the OP9-retired ImGui-era <c>SettingsPanel</c> used it before that).
/// While capture is active, the next
/// non-modifier chord is reported via the supplied callback and the /// non-modifier chord is reported via the supplied callback and the
/// dispatcher does NOT fire normal action events for that chord. Esc /// dispatcher does NOT fire normal action events for that chord. Esc
/// cancels capture (callback receives a sentinel <c>default</c> chord). /// cancels capture (callback receives a sentinel <c>default</c> chord).

View file

@ -1,52 +0,0 @@
using AcDream.UI.Abstractions.Panels.Settings;
namespace AcDream.UI.Abstractions.Tests.Panels.Settings;
/// <summary>
/// L.0: <see cref="GameplaySettings"/> default-pin tests + value-equality
/// guarantees. Defaults are intentionally NOT bit-exact to retail's
/// <c>0x50C4A54A</c> mask — see GameplaySettings remarks for rationale.
/// </summary>
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.ToggleRun);
Assert.False(d.AdvancedCombatUI);
Assert.True(d.ShowTooltips);
Assert.True(d.VividTargetingIndicator); // retail default mask includes 0x00008000
Assert.False(d.SideBySideVitals);
Assert.True(d.CoordinatesOnRadar); // retail default mask includes 0x00400000
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 { ToggleRun = false };
var c = GameplaySettings.Default with { ToggleRun = 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.ToggleRun, d.ToggleRun);
Assert.Equal(GameplaySettings.Default.ShowHelm, d.ShowHelm);
}
}

View file

@ -1,569 +0,0 @@
using System.Linq;
using AcDream.UI.Abstractions.Input;
using AcDream.UI.Abstractions.Panels.Settings;
using AcDream.UI.Abstractions.Tests.Input;
using Silk.NET.Input;
namespace AcDream.UI.Abstractions.Tests.Panels.Settings;
/// <summary>
/// K.3: <see cref="SettingsPanel"/> renders the rebind UI on top of
/// <see cref="SettingsVM"/>. These tests use <see cref="FakePanelRenderer"/>
/// to assert the panel emits the expected widget calls — top action
/// buttons, section headers, conflict prompt when one is pending, and
/// the "Rebind" button forwarding to the VM.
/// </summary>
public sealed class SettingsPanelTests
{
private sealed class NullBus : ICommandBus
{
public void Publish<T>(T command) where T : notnull { }
}
private static (SettingsPanel panel, SettingsVM vm, FakeKeyboardSource kb, InputDispatcher dispatcher)
Build()
{
var kb = new FakeKeyboardSource();
var mouse = new FakeMouseSource();
var persisted = new KeyBindings();
persisted.Add(new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward));
persisted.Add(new Binding(new KeyChord(Key.A, ModifierMask.None), InputAction.MovementTurnLeft));
var dispatcher = InputDispatcher.CreateDetached(kb, mouse, persisted);
dispatcher.Attach();
var vm = new SettingsVM(
persisted, dispatcher, _ => { },
DisplaySettings.Default, _ => { },
AudioSettings.Default, _ => { },
GameplaySettings.Default, _ => { },
ChatSettings.Default, _ => { },
CharacterSettings.Default, _ => { });
var panel = new SettingsPanel(vm);
return (panel, vm, kb, dispatcher);
}
[Fact]
public void Render_emits_Save_Cancel_ResetAll_buttons_at_top()
{
var (panel, _, _, _) = Build();
var r = new FakePanelRenderer();
panel.Render(new PanelContext(0.016f, new NullBus()), r);
var buttonLabels = r.Calls.Where(c => c.Method == "Button")
.Select(c => (string)c.Args[0]!).ToList();
Assert.Contains(buttonLabels, l => l == "Save changes");
Assert.Contains(buttonLabels, l => l == "Cancel changes");
Assert.Contains(buttonLabels, l => l == "Reset all to retail defaults");
}
[Fact]
public void Render_emits_section_headers_for_each_category()
{
var (panel, _, _, _) = Build();
var r = new FakePanelRenderer { CollapsingHeaderNextReturn = false };
panel.Render(new PanelContext(0.016f, new NullBus()), r);
var headers = r.Calls.Where(c => c.Method == "CollapsingHeader")
.Select(c => (string)c.Args[0]!).ToList();
Assert.Contains("Movement", headers);
Assert.Contains("Postures", headers);
Assert.Contains("Camera", headers);
Assert.Contains("Combat", headers);
Assert.Contains("UI panels", headers);
Assert.Contains("Chat", headers);
Assert.Contains("Hotbar", headers);
Assert.Contains("Emotes", headers);
}
[Fact]
public void Render_shows_unbound_for_actions_with_no_draft_bindings()
{
var (panel, _, _, _) = Build();
var r = new FakePanelRenderer { CollapsingHeaderNextReturn = true };
panel.Render(new PanelContext(0.016f, new NullBus()), r);
// The minimal Build() table doesn't bind MovementBackup → expect "(unbound)"
// text somewhere in the call stream.
var texts = r.Calls.Where(c => c.Method == "Text")
.Select(c => (string)c.Args[0]!).ToList();
Assert.Contains(texts, t => t.Contains("(unbound)"));
}
[Fact]
public void Clicking_Rebind_button_calls_BeginRebind_on_VM()
{
var (panel, vm, _, dispatcher) = Build();
// First render — capture the rebind-button labels generated for
// bound actions. The panel uses "Rebind##{action}" so each action
// has a unique imgui ID.
var r1 = new FakePanelRenderer { CollapsingHeaderNextReturn = true };
panel.Render(new PanelContext(0.016f, new NullBus()), r1);
var rebindLabels = r1.Calls.Where(c => c.Method == "Button"
&& ((string)c.Args[0]!).StartsWith("Rebind##"))
.Select(c => (string)c.Args[0]!).ToList();
Assert.NotEmpty(rebindLabels);
// Second render — simulate clicking the first Rebind button by
// making the renderer return true for every Button call. Since
// we click the first Rebind button it will invoke BeginRebind on
// some bound action.
var r2 = new FakePanelRenderer { CollapsingHeaderNextReturn = true, ButtonNextReturn = true };
panel.Render(new PanelContext(0.016f, new NullBus()), r2);
// Either RebindInProgress is set (some action) OR HasUnsavedChanges
// changed (Save/Cancel/Reset clicked instead). Since ButtonNextReturn
// returns true for ALL buttons, multiple actions fire on this single
// render — the more relevant assertion is that the dispatcher entered
// capture mode at SOME point during the render. (ButtonNextReturn is
// a single shared return value across all buttons so multiple may
// have "clicked"; the panel's logic must still route through the VM.)
Assert.True(dispatcher.IsCapturing || vm.PendingConflict is not null
|| vm.RebindInProgress is not null
|| true /* Save/Cancel/Reset may have intervened first; this test
only proves the renderer-button path doesn't NRE */);
}
[Fact]
public void Render_with_PendingConflict_displays_conflict_prompt_buttons()
{
var (panel, vm, kb, _) = Build();
// Force a conflict by binding MovementForward → A (already
// MovementTurnLeft).
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
vm.BeginRebind(InputAction.MovementForward, original);
kb.EmitKeyDown(Key.A, ModifierMask.None);
Assert.NotNull(vm.PendingConflict);
var r = new FakePanelRenderer { CollapsingHeaderNextReturn = true };
panel.Render(new PanelContext(0.016f, new NullBus()), r);
var buttonLabels = r.Calls.Where(c => c.Method == "Button")
.Select(c => (string)c.Args[0]!).ToList();
Assert.Contains(buttonLabels, l => l == "Yes — Reassign");
Assert.Contains(buttonLabels, l => l == "No — Keep existing");
}
[Fact]
public void Hidden_panel_short_circuits_when_Begin_returns_false()
{
var (panel, _, _, _) = Build();
var r = new FakePanelRenderer { BeginReturns = false };
panel.Render(new PanelContext(0.016f, new NullBus()), r);
// Begin + End balanced even when Begin returned false.
Assert.Contains(r.Calls, c => c.Method == "Begin");
Assert.Contains(r.Calls, c => c.Method == "End");
// Section headers should NOT have been emitted.
Assert.DoesNotContain(r.Calls, c => c.Method == "CollapsingHeader");
}
[Fact]
public void IsVisible_defaults_false()
{
var (panel, _, _, _) = Build();
Assert.False(panel.IsVisible);
}
[Fact]
public void Id_is_acdream_settings()
{
var (panel, _, _, _) = Build();
Assert.Equal("acdream.settings", panel.Id);
}
// -- Tabbed shell -----------------------------------------------------
[Fact]
public void Render_opens_tab_bar_with_six_tab_items()
{
var (panel, _, _, _) = Build();
var r = new FakePanelRenderer();
panel.Render(new PanelContext(0.016f, new NullBus()), r);
// BeginTabBar exactly once, EndTabBar exactly once.
Assert.Single(r.Calls, c => c.Method == "BeginTabBar");
Assert.Single(r.Calls, c => c.Method == "EndTabBar");
// The six tab labels approved in the design brainstorm.
var tabLabels = r.Calls.Where(c => c.Method == "BeginTabItem")
.Select(c => (string)c.Args[0]!).ToList();
Assert.Equal(
new[] { "Keybinds", "Display", "Audio", "Gameplay", "Chat", "Character" },
tabLabels);
}
[Fact]
public void Keybinds_tab_renders_section_headers_when_active()
{
var (panel, _, _, _) = Build();
// Default ActiveTabLabel = null → FakePanelRenderer treats the
// first tab item ("Keybinds") as active.
var r = new FakePanelRenderer { CollapsingHeaderNextReturn = false };
panel.Render(new PanelContext(0.016f, new NullBus()), r);
var headers = r.Calls.Where(c => c.Method == "CollapsingHeader")
.Select(c => (string)c.Args[0]!).ToList();
Assert.Contains("Movement", headers);
Assert.Contains("Hotbar", headers);
Assert.Contains("Emotes", headers);
}
[Fact]
public void Inactive_tabs_do_not_render_keybind_section_headers()
{
var (panel, _, _, _) = Build();
// Force "Display" to be the active tab — the Keybinds content
// must NOT render.
var r = new FakePanelRenderer { ActiveTabLabel = "Display" };
panel.Render(new PanelContext(0.016f, new NullBus()), r);
var headers = r.Calls.Where(c => c.Method == "CollapsingHeader")
.Select(c => (string)c.Args[0]!).ToList();
Assert.DoesNotContain("Movement", headers);
Assert.DoesNotContain("Hotbar", headers);
}
// -- Character tab content -------------------------------------------
[Fact]
public void Character_tab_when_active_renders_channel_combo_plus_checkboxes()
{
var (panel, _, _, _) = Build();
var r = new FakePanelRenderer { ActiveTabLabel = "Character" };
panel.Render(new PanelContext(0.016f, new NullBus()), r);
var combos = r.Calls.Where(c => c.Method == "Combo")
.Select(c => (string)c.Args[0]!).ToList();
Assert.Contains("Default chat channel", combos);
var checks = r.Calls.Where(c => c.Method == "Checkbox")
.Select(c => (string)c.Args[0]!).ToList();
Assert.Contains(checks, l => l.StartsWith("Auto-attack"));
Assert.Contains(checks, l => l.StartsWith("Confirm before salvaging"));
Assert.Contains(checks, l => l.StartsWith("Show pickup messages"));
}
[Fact]
public void Character_tab_does_not_render_when_a_different_tab_is_active()
{
var (panel, _, _, _) = Build();
var r = new FakePanelRenderer { ActiveTabLabel = "Display" };
panel.Render(new PanelContext(0.016f, new NullBus()), r);
var combos = r.Calls.Where(c => c.Method == "Combo")
.Select(c => (string)c.Args[0]!).ToList();
Assert.DoesNotContain("Default chat channel", combos);
}
[Fact]
public void Character_tab_channel_combo_uses_AvailableChannels_list()
{
var (panel, _, _, _) = Build();
var r = new FakePanelRenderer { ActiveTabLabel = "Character" };
panel.Render(new PanelContext(0.016f, new NullBus()), r);
var ch = r.Calls.First(c => c.Method == "Combo" && (string)c.Args[0]! == "Default chat channel");
var items = (string[])ch.Args[2]!;
Assert.Contains("Local", items);
Assert.Contains("Allegiance", items);
Assert.Contains("Fellowship", items);
}
[Fact]
public void All_six_tabs_are_now_implemented_no_placeholder_text_remains()
{
// After the L.0 build order finishes, no tab should render the
// "Coming soon" placeholder line. If a future commit re-adds a
// placeholder tab without updating this test, it will fail.
var (panel, _, _, _) = Build();
foreach (var tabLabel in new[] { "Keybinds", "Display", "Audio", "Gameplay", "Chat", "Character" })
{
var r = new FakePanelRenderer { ActiveTabLabel = tabLabel };
panel.Render(new PanelContext(0.016f, new NullBus()), r);
var wrapped = r.Calls.Where(c => c.Method == "TextWrapped")
.Select(c => (string)c.Args[0]!).ToList();
Assert.DoesNotContain(wrapped, t => t.Contains("coming soon"));
}
}
// -- Display tab content ---------------------------------------------
[Fact]
public void Display_tab_when_active_renders_resolution_combo_plus_sliders()
{
var (panel, _, _, _) = Build();
var r = new FakePanelRenderer { ActiveTabLabel = "Display" };
panel.Render(new PanelContext(0.016f, new NullBus()), r);
var combos = r.Calls.Where(c => c.Method == "Combo").Select(c => (string)c.Args[0]!).ToList();
var checks = r.Calls.Where(c => c.Method == "Checkbox").Select(c => (string)c.Args[0]!).ToList();
var sliders = r.Calls.Where(c => c.Method == "SliderFloat").Select(c => (string)c.Args[0]!).ToList();
Assert.Contains("Resolution", combos);
Assert.Contains("Fullscreen", checks);
Assert.Contains("V-Sync", checks);
Assert.Contains("Show FPS", checks);
Assert.Contains("Field of View", sliders);
Assert.Contains("Gamma", sliders);
}
[Fact]
public void Display_tab_does_not_render_when_a_different_tab_is_active()
{
var (panel, _, _, _) = Build();
var r = new FakePanelRenderer { ActiveTabLabel = "Audio" };
panel.Render(new PanelContext(0.016f, new NullBus()), r);
var combos = r.Calls.Where(c => c.Method == "Combo").Select(c => (string)c.Args[0]!).ToList();
Assert.DoesNotContain("Resolution", combos);
}
[Fact]
public void Display_tab_resolution_combo_uses_AvailableResolutions_list()
{
var (panel, _, _, _) = Build();
var r = new FakePanelRenderer { ActiveTabLabel = "Display" };
panel.Render(new PanelContext(0.016f, new NullBus()), r);
var resCall = r.Calls.First(c => c.Method == "Combo" && (string)c.Args[0]! == "Resolution");
var items = (string[])resCall.Args[2]!;
Assert.Contains("1920x1080", items);
Assert.Contains("3840x2160", items);
}
// -- Audio tab content -----------------------------------------------
[Fact]
public void Audio_tab_when_active_renders_implemented_volume_sliders()
{
// The rule is "no slider that does nothing", and Campaign A changed
// what qualifies. Ambient is now surfaced because slice A5 gave it a
// region ambient system to drive. Music is gone entirely — retail has
// no music system, so slice A6 deleted the field rather than leaving a
// knob that could never do anything.
var (panel, _, _, _) = Build();
var r = new FakePanelRenderer { ActiveTabLabel = "Audio" };
panel.Render(new PanelContext(0.016f, new NullBus()), r);
var sliders = r.Calls.Where(c => c.Method == "SliderFloat")
.Select(c => (string)c.Args[0]!).ToList();
Assert.Contains("Master", sliders);
Assert.Contains("SFX", sliders);
Assert.Contains("Ambient", sliders);
Assert.DoesNotContain("Music", sliders);
}
[Fact]
public void Audio_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 sliders = r.Calls.Where(c => c.Method == "SliderFloat")
.Select(c => (string)c.Args[0]!).ToList();
Assert.DoesNotContain("Master", sliders);
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.
//
// OP4 review-fix round (2026-08-11, MUST-FIX 3 / blast M2):
// "Auto-target on attack" / "Auto-repeat attacks" were retired
// from this panel — those two options (plus "Keep combat target
// in view") now read/write the canonical server bit through
// CombatUiController, not the client-local GameplaySettings
// record this dat-free panel edits.
Assert.DoesNotContain("Auto-target on attack", checks);
Assert.DoesNotContain("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("Run mode is toggle (vs hold)", checks);
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()
{
var (panel, _, _, _) = Build();
var r = new FakePanelRenderer { ActiveTabLabel = "Audio" };
panel.Render(new PanelContext(0.016f, new NullBus()), r);
var masterCall = r.Calls.First(c => c.Method == "SliderFloat" && (string)c.Args[0]! == "Master");
Assert.Equal(0f, (float)masterCall.Args[2]!);
Assert.Equal(1f, (float)masterCall.Args[3]!);
}
// -- Campaign CH slice CH6c: chat tab opacity sliders -----------------
[Fact]
public void Chat_tab_when_active_renders_two_linked_opacity_sliders()
{
var (panel, vm, _, _) = Build();
var r = new FakePanelRenderer { ActiveTabLabel = "Chat" };
panel.Render(new PanelContext(0.016f, new NullBus()), r);
var sliders = r.Calls.Where(c => c.Method == "SliderFloat")
.Select(c => (string)c.Args[0]!).ToList();
Assert.Contains("Background opacity (unfocused)", sliders);
Assert.Contains("Active opacity (typing / focused)", sliders);
var bgCall = r.Calls.First(
c => c.Method == "SliderFloat" && (string)c.Args[0]! == "Background opacity (unfocused)");
Assert.Equal(vm.ChatDraft.DefaultOpacity, (float)bgCall.Args[1]!);
Assert.Equal(0f, (float)bgCall.Args[2]!);
Assert.Equal(1f, (float)bgCall.Args[3]!);
var activeCall = r.Calls.First(
c => c.Method == "SliderFloat" && (string)c.Args[0]! == "Active opacity (typing / focused)");
Assert.Equal(vm.ChatDraft.ActiveOpacity, (float)activeCall.Args[1]!);
}
[Fact]
public void Chat_tab_opacity_sliders_do_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 sliders = r.Calls.Where(c => c.Method == "SliderFloat")
.Select(c => (string)c.Args[0]!).ToList();
Assert.DoesNotContain("Background opacity (unfocused)", sliders);
Assert.DoesNotContain("Active opacity (typing / focused)", sliders);
}
[Fact]
public void Chat_tab_dragging_active_opacity_below_background_drags_background_down_in_draft()
{
// FakePanelRenderer applies ONE injected value to every SliderFloat call
// in the same Render pass and each branch's `_vm.SetChat` starts from the
// ORIGINAL pre-render draft — so with SliderFloatNextReturn set, the
// LAST-rendered opacity slider ("Active", rendered after "Background")
// determines the final draft. That exercises ChatOpacityLink.SetActive's
// drag-background-down path end-to-end through the real panel code,
// starting from ChatSettings.Default (DefaultOpacity=1.0, ActiveOpacity=1.0
// as of the CH6c review fix).
var (panel, vm, _, _) = Build();
var r = new FakePanelRenderer
{
ActiveTabLabel = "Chat",
SliderFloatNextReturn = true,
SliderFloatNextValue = 0.2f,
};
panel.Render(new PanelContext(0.016f, new NullBus()), r);
Assert.Equal(0.2f, vm.ChatDraft.DefaultOpacity);
Assert.Equal(0.2f, vm.ChatDraft.ActiveOpacity);
}
[Fact]
public void Save_Cancel_buttons_render_outside_the_tab_bar()
{
// The global Save / Cancel / Reset-all row must come BEFORE
// BeginTabBar so it stays visible on every tab. Any change that
// accidentally moves the buttons inside a tab item should fail
// here.
var (panel, _, _, _) = Build();
var r = new FakePanelRenderer();
panel.Render(new PanelContext(0.016f, new NullBus()), r);
int saveIdx = r.Calls.FindIndex(c => c.Method == "Button"
&& (string)c.Args[0]! == "Save changes");
int tabBarIdx = r.Calls.FindIndex(c => c.Method == "BeginTabBar");
Assert.True(saveIdx >= 0);
Assert.True(tabBarIdx >= 0);
Assert.True(saveIdx < tabBarIdx,
$"Save button (index {saveIdx}) must render before BeginTabBar (index {tabBarIdx}).");
}
}

View file

@ -206,64 +206,58 @@ public sealed class SettingsStoreTests : System.IDisposable
Assert.True(store.LoadDisplay().ShowFps); Assert.True(store.LoadDisplay().ShowFps);
} }
// -- Gameplay section round-trip -------------------------------------- // -- Gameplay section: OP9 migration honesty ---------------------------
//
// Campaign OP slice OP9 (docs/plans/2026-08-10-options-panel-campaign.md
// §OP9) retired the Gameplay tab's client-local GameplaySettings record
// and Load/SaveGameplay outright — all 13 fields were already re-pointed
// to the server-bit seam (RuntimeCharacterOptionsState) at OP4. An
// EXISTING settings.json written by a pre-OP9 build can still carry a
// "gameplay" top-level block; these two tests pin that SettingsStore
// tolerates it (no Load* method reads it, so it neither throws nor is
// silently dropped) rather than requiring a migration step.
[Fact] [Fact]
public void LoadGameplay_returns_defaults_when_file_is_missing() public void LeftoverGameplaySection_FromAnOlderSettingsJson_DoesNotBreakOtherLoads()
{
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
{
AdvancedCombatUI = true,
ShowHelm = false,
LockUI = true,
UseMouseTurning = true,
AcceptLootPermits = false,
};
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, """ File.WriteAllText(_tempPath, """
{ {
"version": 1, "version": 2,
"gameplay": { "lockUI": true } "display": { "resolution": "1366x768" },
"gameplay": { "lockUI": true, "toggleRun": false }
} }
"""); """);
var store = new SettingsStore(_tempPath); var store = new SettingsStore(_tempPath);
var loaded = store.LoadGameplay(); Assert.Equal("1366x768", store.LoadDisplay().Resolution);
Assert.Equal(AudioSettings.Default, store.LoadAudio());
Assert.True(loaded.LockUI); Assert.Equal(ChatSettings.Default, store.LoadChat());
Assert.Equal(GameplaySettings.Default.ToggleRun, loaded.ToggleRun);
Assert.Equal(GameplaySettings.Default.ShowHelm, loaded.ShowHelm);
} }
[Fact] [Fact]
public void All_three_sections_coexist_in_one_settings_json() public void LeftoverGameplaySection_SurvivesAnUnrelatedSave()
{ {
// The generic SaveSection round-trip preservation (raw-JSON-text
// carry-forward of unknown top-level keys, exercised elsewhere by
// SaveDisplay_preserves_unknown_top_level_keys) is exactly what
// keeps an orphaned "gameplay" block alive forever once nothing
// reads or writes it — this pins that mechanism for the specific
// section OP9 just retired.
File.WriteAllText(_tempPath, """
{
"version": 2,
"gameplay": { "lockUI": true, "showHelm": false }
}
""");
var store = new SettingsStore(_tempPath); 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. store.SaveDisplay(DisplaySettings.Default with { Resolution = "1920x1080" });
Assert.Equal("2560x1440", store.LoadDisplay().Resolution);
Assert.Equal(0.5f, store.LoadAudio().Master); var raw = File.ReadAllText(_tempPath);
Assert.True(store.LoadGameplay().LockUI); Assert.Contains("\"gameplay\"", raw);
Assert.Contains("\"lockUI\": true", raw);
Assert.Contains("\"showHelm\": false", raw);
Assert.Contains("1920x1080", raw);
} }
// -- Chat section round-trip ------------------------------------------ // -- Chat section round-trip ------------------------------------------
@ -387,17 +381,15 @@ public sealed class SettingsStoreTests : System.IDisposable
} }
[Fact] [Fact]
public void All_four_sections_coexist_in_one_settings_json() public void All_three_sections_coexist_in_one_settings_json()
{ {
var store = new SettingsStore(_tempPath); var store = new SettingsStore(_tempPath);
store.SaveDisplay(DisplaySettings.Default with { Resolution = "2560x1440" }); store.SaveDisplay(DisplaySettings.Default with { Resolution = "2560x1440" });
store.SaveAudio(AudioSettings.Default with { Master = 0.5f }); store.SaveAudio(AudioSettings.Default with { Master = 0.5f });
store.SaveGameplay(GameplaySettings.Default with { LockUI = true });
store.SaveChat(ChatSettings.Default with { HearTradeChat = false, FontSize = 14f }); store.SaveChat(ChatSettings.Default with { HearTradeChat = false, FontSize = 14f });
Assert.Equal("2560x1440", store.LoadDisplay().Resolution); Assert.Equal("2560x1440", store.LoadDisplay().Resolution);
Assert.Equal(0.5f, store.LoadAudio().Master); Assert.Equal(0.5f, store.LoadAudio().Master);
Assert.True(store.LoadGameplay().LockUI);
Assert.False(store.LoadChat().HearTradeChat); Assert.False(store.LoadChat().HearTradeChat);
Assert.Equal(14f, store.LoadChat().FontSize); Assert.Equal(14f, store.LoadChat().FontSize);
} }
@ -468,19 +460,17 @@ public sealed class SettingsStoreTests : System.IDisposable
} }
[Fact] [Fact]
public void All_five_sections_coexist_in_one_settings_json() public void All_four_sections_coexist_in_one_settings_json()
{ {
var store = new SettingsStore(_tempPath); var store = new SettingsStore(_tempPath);
store.SaveDisplay(DisplaySettings.Default with { Resolution = "2560x1440" }); store.SaveDisplay(DisplaySettings.Default with { Resolution = "2560x1440" });
store.SaveAudio(AudioSettings.Default with { Master = 0.5f }); store.SaveAudio(AudioSettings.Default with { Master = 0.5f });
store.SaveGameplay(GameplaySettings.Default with { LockUI = true });
store.SaveChat(ChatSettings.Default with { HearTradeChat = false }); store.SaveChat(ChatSettings.Default with { HearTradeChat = false });
store.SaveCharacter("+Acdream", store.SaveCharacter("+Acdream",
CharacterSettings.Default with { DefaultChatChannel = "Fellowship" }); CharacterSettings.Default with { DefaultChatChannel = "Fellowship" });
Assert.Equal("2560x1440", store.LoadDisplay().Resolution); Assert.Equal("2560x1440", store.LoadDisplay().Resolution);
Assert.Equal(0.5f, store.LoadAudio().Master); Assert.Equal(0.5f, store.LoadAudio().Master);
Assert.True(store.LoadGameplay().LockUI);
Assert.False(store.LoadChat().HearTradeChat); Assert.False(store.LoadChat().HearTradeChat);
Assert.Equal("Fellowship", store.LoadCharacter("+Acdream").DefaultChatChannel); Assert.Equal("Fellowship", store.LoadCharacter("+Acdream").DefaultChatChannel);
} }
@ -489,7 +479,7 @@ public sealed class SettingsStoreTests : System.IDisposable
public void WindowPositions_RoundTripPerCharacterAndPreserveSettings() public void WindowPositions_RoundTripPerCharacterAndPreserveSettings()
{ {
var store = new SettingsStore(_tempPath); var store = new SettingsStore(_tempPath);
store.SaveGameplay(GameplaySettings.Default with { LockUI = true }); store.SaveDisplay(DisplaySettings.Default with { Resolution = "2560x1440" });
store.SaveWindowPosition("Alice", "radar", new UiWindowPosition(321.5f, 18f)); store.SaveWindowPosition("Alice", "radar", new UiWindowPosition(321.5f, 18f));
store.SaveWindowPosition("Bob", "radar", new UiWindowPosition(44f, 55f)); store.SaveWindowPosition("Bob", "radar", new UiWindowPosition(44f, 55f));
@ -498,14 +488,14 @@ public sealed class SettingsStoreTests : System.IDisposable
Assert.Equal(new UiWindowPosition(44f, 55f), Assert.Equal(new UiWindowPosition(44f, 55f),
store.LoadWindowPosition("Bob", "radar")); store.LoadWindowPosition("Bob", "radar"));
Assert.Null(store.LoadWindowPosition("Alice", "inventory")); Assert.Null(store.LoadWindowPosition("Alice", "inventory"));
Assert.True(store.LoadGameplay().LockUI); Assert.Equal("2560x1440", store.LoadDisplay().Resolution);
} }
[Fact] [Fact]
public void WindowLayouts_RoundTripPerCharacterResolutionAndPreserveSettings() public void WindowLayouts_RoundTripPerCharacterResolutionAndPreserveSettings()
{ {
var store = new SettingsStore(_tempPath); var store = new SettingsStore(_tempPath);
store.SaveGameplay(GameplaySettings.Default with { LockUI = true }); store.SaveDisplay(DisplaySettings.Default with { Resolution = "2560x1440" });
var alice1080 = new UiWindowLayout(10f, 20f, 500f, 300f, true, false, true); var alice1080 = new UiWindowLayout(10f, 20f, 500f, 300f, true, false, true);
var alice1440 = new UiWindowLayout(30f, 40f, 650f, 420f, false, true, false); var alice1440 = new UiWindowLayout(30f, 40f, 650f, 420f, false, true, false);
var bob1080 = new UiWindowLayout(50f, 60f, 310f, 132f, true, false, false); var bob1080 = new UiWindowLayout(50f, 60f, 310f, 132f, true, false, false);
@ -518,7 +508,7 @@ public sealed class SettingsStoreTests : System.IDisposable
Assert.Equal(alice1440, store.LoadWindowLayout("Alice", "2560x1440", "chat", default)); Assert.Equal(alice1440, store.LoadWindowLayout("Alice", "2560x1440", "chat", default));
Assert.Equal(bob1080, store.LoadWindowLayout("Bob", "1920x1080", "toolbar", default)); Assert.Equal(bob1080, store.LoadWindowLayout("Bob", "1920x1080", "toolbar", default));
Assert.Null(store.LoadWindowLayout("Alice", "1920x1080", "inventory", default)); Assert.Null(store.LoadWindowLayout("Alice", "1920x1080", "inventory", default));
Assert.True(store.LoadGameplay().LockUI); Assert.Equal("2560x1440", store.LoadDisplay().Resolution);
} }
[Fact] [Fact]

View file

@ -1,682 +0,0 @@
using System.IO;
using System.Linq;
using AcDream.UI.Abstractions.Input;
using AcDream.UI.Abstractions.Panels.Settings;
using AcDream.UI.Abstractions.Tests.Input;
using Silk.NET.Input;
namespace AcDream.UI.Abstractions.Tests.Panels.Settings;
/// <summary>
/// K.3: <see cref="SettingsVM"/> owns the click-to-rebind state machine
/// for the Settings panel. It holds a <b>draft</b> copy of the active
/// <see cref="KeyBindings"/>; rebinds modify the draft. Save commits to
/// the supplied callback (which writes to disk + replaces the live
/// dispatcher's table); Cancel reverts the draft.
/// </summary>
public sealed class SettingsVMTests
{
private static (SettingsVM vm, FakeKeyboardSource kb, InputDispatcher dispatcher, KeyBindings persisted, System.Collections.Generic.List<KeyBindings> savedHistory, System.Collections.Generic.List<DisplaySettings> savedDisplayHistory, System.Collections.Generic.List<AudioSettings> savedAudioHistory, System.Collections.Generic.List<GameplaySettings> savedGameplayHistory, System.Collections.Generic.List<ChatSettings> savedChatHistory, System.Collections.Generic.List<CharacterSettings> savedCharacterHistory)
Build(KeyBindings? persisted = null, DisplaySettings? persistedDisplay = null, AudioSettings? persistedAudio = null, GameplaySettings? persistedGameplay = null, ChatSettings? persistedChat = null, CharacterSettings? persistedCharacter = null)
{
persisted ??= MakeMinimalBindings();
var kb = new FakeKeyboardSource();
var mouse = new FakeMouseSource();
var dispatcher = InputDispatcher.CreateDetached(kb, mouse, persisted);
dispatcher.Attach();
var savedHistory = new System.Collections.Generic.List<KeyBindings>();
var savedDisplayHistory = new System.Collections.Generic.List<DisplaySettings>();
var savedAudioHistory = new System.Collections.Generic.List<AudioSettings>();
var savedGameplayHistory = new System.Collections.Generic.List<GameplaySettings>();
var savedChatHistory = new System.Collections.Generic.List<ChatSettings>();
var savedCharacterHistory = new System.Collections.Generic.List<CharacterSettings>();
var vm = new SettingsVM(
persisted, dispatcher,
b => savedHistory.Add(b),
persistedDisplay ?? DisplaySettings.Default,
d => savedDisplayHistory.Add(d),
persistedAudio ?? AudioSettings.Default,
a => savedAudioHistory.Add(a),
persistedGameplay ?? GameplaySettings.Default,
g => savedGameplayHistory.Add(g),
persistedChat ?? ChatSettings.Default,
c => savedChatHistory.Add(c),
persistedCharacter ?? CharacterSettings.Default,
ch => savedCharacterHistory.Add(ch));
return (vm, kb, dispatcher, persisted, savedHistory, savedDisplayHistory, savedAudioHistory, savedGameplayHistory, savedChatHistory, savedCharacterHistory);
}
private static KeyBindings MakeMinimalBindings()
{
var b = new KeyBindings();
b.Add(new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward));
b.Add(new Binding(new KeyChord(Key.A, ModifierMask.None), InputAction.MovementTurnLeft));
b.Add(new Binding(new KeyChord(Key.S, ModifierMask.None), InputAction.MovementStop));
return b;
}
[Fact]
public void Constructor_clones_persisted_into_draft()
{
var (vm, _, _, persisted, _, _, _, _, _, _) = Build();
Assert.Equal(persisted.All.Count, vm.Draft.All.Count);
Assert.False(vm.HasUnsavedChanges);
}
[Fact]
public void BeginRebind_enters_capture_mode()
{
var (vm, _, dispatcher, _, _, _, _, _, _, _) = Build();
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
vm.BeginRebind(InputAction.MovementForward, original);
Assert.True(dispatcher.IsCapturing);
Assert.Equal(InputAction.MovementForward, vm.RebindInProgress);
Assert.Equal(original, vm.RebindOriginal);
}
[Fact]
public void BeginRebind_then_chord_with_no_conflict_applies_rebind()
{
var (vm, kb, _, _, _, _, _, _, _, _) = Build();
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
vm.BeginRebind(InputAction.MovementForward, original);
// User presses Q — not bound to anything in our minimal table.
kb.EmitKeyDown(Key.Q, ModifierMask.None);
Assert.Null(vm.RebindInProgress);
Assert.Null(vm.PendingConflict);
var binds = vm.Draft.ForAction(InputAction.MovementForward).ToList();
Assert.Single(binds);
Assert.Equal(new KeyChord(Key.Q, ModifierMask.None), binds[0].Chord);
Assert.True(vm.HasUnsavedChanges);
}
[Fact]
public void BeginRebind_then_Escape_cancels_with_no_change()
{
var (vm, kb, _, _, _, _, _, _, _, _) = Build();
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
vm.BeginRebind(InputAction.MovementForward, original);
kb.EmitKeyDown(Key.Escape, ModifierMask.None);
Assert.Null(vm.RebindInProgress);
Assert.Null(vm.PendingConflict);
var binds = vm.Draft.ForAction(InputAction.MovementForward).ToList();
Assert.Single(binds);
Assert.Equal(new KeyChord(Key.W, ModifierMask.None), binds[0].Chord);
Assert.False(vm.HasUnsavedChanges);
}
[Fact]
public void BeginRebind_with_conflict_surfaces_PendingConflict()
{
var (vm, kb, _, _, _, _, _, _, _, _) = Build();
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
// Bind chord that conflicts with MovementTurnLeft (which has Key.A).
vm.BeginRebind(InputAction.MovementForward, original);
kb.EmitKeyDown(Key.A, ModifierMask.None);
Assert.NotNull(vm.PendingConflict);
var c = vm.PendingConflict!.Value;
Assert.Equal(InputAction.MovementForward, c.NewAction);
Assert.Equal(new KeyChord(Key.A, ModifierMask.None), c.NewChord);
Assert.Equal(InputAction.MovementTurnLeft, c.ConflictingAction);
// Rebind has NOT been applied yet — still on W.
var binds = vm.Draft.ForAction(InputAction.MovementForward).ToList();
Assert.Equal(new KeyChord(Key.W, ModifierMask.None), binds[0].Chord);
}
[Fact]
public void ResolveConflict_replace_true_removes_conflict_and_applies_rebind()
{
var (vm, kb, _, _, _, _, _, _, _, _) = Build();
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
vm.BeginRebind(InputAction.MovementForward, original);
kb.EmitKeyDown(Key.A, ModifierMask.None);
vm.ResolveConflict(replace: true);
Assert.Null(vm.PendingConflict);
Assert.Null(vm.RebindInProgress);
// MovementForward now bound to A.
var fwd = vm.Draft.ForAction(InputAction.MovementForward).ToList();
Assert.Single(fwd);
Assert.Equal(new KeyChord(Key.A, ModifierMask.None), fwd[0].Chord);
// MovementTurnLeft no longer bound to A (conflict removed).
var left = vm.Draft.ForAction(InputAction.MovementTurnLeft).ToList();
Assert.Empty(left);
}
[Fact]
public void ResolveConflict_replace_false_cancels_rebind()
{
var (vm, kb, _, _, _, _, _, _, _, _) = Build();
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
vm.BeginRebind(InputAction.MovementForward, original);
kb.EmitKeyDown(Key.A, ModifierMask.None);
vm.ResolveConflict(replace: false);
Assert.Null(vm.PendingConflict);
Assert.Null(vm.RebindInProgress);
// MovementForward still bound to W.
var fwd = vm.Draft.ForAction(InputAction.MovementForward).ToList();
Assert.Equal(new KeyChord(Key.W, ModifierMask.None), fwd[0].Chord);
// MovementTurnLeft still bound to A.
var left = vm.Draft.ForAction(InputAction.MovementTurnLeft).ToList();
Assert.Equal(new KeyChord(Key.A, ModifierMask.None), left[0].Chord);
}
[Fact]
public void ResetActionToDefault_restores_single_action_to_RetailDefaults()
{
// 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 original = vm.Draft.ForAction(InputAction.MovementForward).First();
vm.BeginRebind(InputAction.MovementForward, original);
// F7 is unbound in retail-default (only Ctrl+F7 is acdream debug);
// pick it deliberately to avoid triggering a conflict prompt that
// would block the rebind from applying.
kb.EmitKeyDown(Key.F7, ModifierMask.None);
Assert.True(vm.HasUnsavedChanges);
vm.ResetActionToDefault(InputAction.MovementForward);
var fwd = vm.Draft.ForAction(InputAction.MovementForward).ToList();
Assert.Contains(fwd, x => x.Chord == new KeyChord(Key.W, ModifierMask.None));
Assert.Contains(fwd, x => x.Chord == new KeyChord(Key.Up, ModifierMask.None));
}
[Fact]
public void ResetAllToDefaults_replaces_entire_draft()
{
var (vm, _, _, _, _, _, _, _, _, _) = Build();
vm.ResetAllToDefaults();
// Should now include retail-default size set (~149 bindings).
Assert.True(vm.Draft.All.Count >= 100);
Assert.True(vm.HasUnsavedChanges);
}
[Fact]
public void Save_invokes_callback_with_draft()
{
var (vm, kb, _, _, savedHistory, _, _, _, _, _) = Build();
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
vm.BeginRebind(InputAction.MovementForward, original);
kb.EmitKeyDown(Key.Q, ModifierMask.None);
vm.Save();
Assert.Single(savedHistory);
var saved = savedHistory[0];
var fwd = saved.ForAction(InputAction.MovementForward).ToList();
Assert.Equal(new KeyChord(Key.Q, ModifierMask.None), fwd[0].Chord);
}
[Fact]
public void Cancel_reverts_draft_to_persisted()
{
var (vm, kb, _, _, _, _, _, _, _, _) = Build();
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
vm.BeginRebind(InputAction.MovementForward, original);
kb.EmitKeyDown(Key.Q, ModifierMask.None);
Assert.True(vm.HasUnsavedChanges);
vm.Cancel();
Assert.False(vm.HasUnsavedChanges);
var fwd = vm.Draft.ForAction(InputAction.MovementForward).ToList();
Assert.Equal(new KeyChord(Key.W, ModifierMask.None), fwd[0].Chord);
}
[Fact]
public void Cancel_during_active_capture_clears_dispatcher_capture_state()
{
var (vm, _, dispatcher, _, _, _, _, _, _, _) = Build();
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
vm.BeginRebind(InputAction.MovementForward, original);
Assert.True(dispatcher.IsCapturing);
vm.Cancel();
Assert.False(dispatcher.IsCapturing);
Assert.Null(vm.RebindInProgress);
}
[Fact]
public void HasUnsavedChanges_false_initially_and_after_save_sync()
{
var (vm, _, _, _, _, _, _, _, _, _) = Build();
Assert.False(vm.HasUnsavedChanges);
}
// -- Display tab state ------------------------------------------------
[Fact]
public void DisplayDraft_initial_value_matches_persisted()
{
var custom = DisplaySettings.Default with { FieldOfView = 90f, ShowFps = true };
var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedDisplay: custom);
Assert.Equal(custom, vm.DisplayDraft);
Assert.False(vm.HasUnsavedChanges);
}
[Fact]
public void SetDisplay_marks_unsaved_changes()
{
var (vm, _, _, _, _, _, _, _, _, _) = Build();
// Default ShowFps is false → flip to true to ensure the with-
// expression actually mutates a field.
vm.SetDisplay(vm.DisplayDraft with { ShowFps = true });
Assert.True(vm.HasUnsavedChanges);
}
[Fact]
public void ApplyExternalDisplayChange_updates_both_snapshots_and_preserves_other_drafts()
{
var (vm, _, _, _, _, _, _, _, _, _) = Build();
vm.SetDisplay(vm.DisplayDraft with { FieldOfView = 90f });
Assert.True(vm.HasUnsavedChanges);
vm.ApplyExternalDisplayChange(display => display with { ShowFps = true });
Assert.True(vm.DisplayDraft.ShowFps);
Assert.Equal(90f, vm.DisplayDraft.FieldOfView);
Assert.True(vm.HasUnsavedChanges);
vm.Cancel();
Assert.True(vm.DisplayDraft.ShowFps);
Assert.Equal(DisplaySettings.Default.FieldOfView, vm.DisplayDraft.FieldOfView);
}
[Fact]
public void Save_invokes_display_callback_with_draft()
{
var (vm, _, _, _, _, savedDisplayHistory, _, _, _, _) = Build();
vm.SetDisplay(vm.DisplayDraft with { Resolution = "2560x1440", FieldOfView = 100f });
vm.Save();
Assert.Single(savedDisplayHistory);
Assert.Equal("2560x1440", savedDisplayHistory[0].Resolution);
Assert.Equal(100f, savedDisplayHistory[0].FieldOfView);
Assert.False(vm.HasUnsavedChanges);
}
[Fact]
public void Cancel_reverts_display_draft_to_persisted()
{
var custom = DisplaySettings.Default with { FieldOfView = 90f };
var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedDisplay: custom);
vm.SetDisplay(vm.DisplayDraft with { FieldOfView = 30f, ShowFps = true });
Assert.True(vm.HasUnsavedChanges);
vm.Cancel();
Assert.Equal(custom, vm.DisplayDraft);
Assert.False(vm.HasUnsavedChanges);
}
[Fact]
public void ResetAllToDefaults_resets_display_to_default()
{
var custom = DisplaySettings.Default with { FieldOfView = 30f, ShowFps = true };
var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedDisplay: custom);
Assert.NotEqual(DisplaySettings.Default, vm.DisplayDraft);
vm.ResetAllToDefaults();
Assert.Equal(DisplaySettings.Default, vm.DisplayDraft);
Assert.True(vm.HasUnsavedChanges);
}
[Fact]
public void Save_then_Cancel_does_not_revert()
{
// 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();
vm.SetDisplay(vm.DisplayDraft with { ShowFps = true });
vm.Save();
Assert.False(vm.HasUnsavedChanges);
vm.Cancel();
Assert.True(vm.DisplayDraft.ShowFps);
Assert.False(vm.HasUnsavedChanges);
}
// -- Audio tab state --------------------------------------------------
[Fact]
public void AudioDraft_initial_value_matches_persisted()
{
var custom = AudioSettings.Default with { Master = 0.3f, Ambient = 0.1f };
var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedAudio: custom);
Assert.Equal(custom, vm.AudioDraft);
Assert.False(vm.HasUnsavedChanges);
}
[Fact]
public void SetAudio_marks_unsaved_changes()
{
var (vm, _, _, _, _, _, _, _, _, _) = Build();
vm.SetAudio(vm.AudioDraft with { Master = 0.5f });
Assert.True(vm.HasUnsavedChanges);
}
[Fact]
public void Save_invokes_audio_callback_with_draft()
{
var (vm, _, _, _, _, _, savedAudioHistory, _, _, _) = Build();
vm.SetAudio(vm.AudioDraft with { Master = 0.4f, Sfx = 0.6f });
vm.Save();
Assert.Single(savedAudioHistory);
Assert.Equal(0.4f, savedAudioHistory[0].Master);
Assert.Equal(0.6f, savedAudioHistory[0].Sfx);
Assert.False(vm.HasUnsavedChanges);
}
[Fact]
public void Cancel_reverts_audio_draft_to_persisted()
{
var custom = AudioSettings.Default with { Ambient = 0.2f };
var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedAudio: custom);
vm.SetAudio(vm.AudioDraft with { Ambient = 0.9f, Master = 0.3f });
Assert.True(vm.HasUnsavedChanges);
vm.Cancel();
Assert.Equal(custom, vm.AudioDraft);
Assert.False(vm.HasUnsavedChanges);
}
[Fact]
public void ResetAllToDefaults_resets_audio_to_default()
{
var custom = AudioSettings.Default with { Master = 0.1f };
var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedAudio: custom);
Assert.NotEqual(AudioSettings.Default, vm.AudioDraft);
vm.ResetAllToDefaults();
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 { 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 ApplyExternalGameplayChange_updates_both_snapshots_and_preserves_other_drafts()
{
var (vm, _, _, _, _, _, _, _, _, _) = Build();
bool persistedLockUI = !GameplaySettings.Default.LockUI;
vm.SetGameplay(vm.GameplayDraft with
{
ShowTooltips = !GameplaySettings.Default.ShowTooltips,
CoordinatesOnRadar = !GameplaySettings.Default.CoordinatesOnRadar,
});
vm.ApplyExternalGameplayChange(gameplay => gameplay with
{
LockUI = persistedLockUI,
});
Assert.Equal(persistedLockUI, vm.GameplayDraft.LockUI);
Assert.Equal(
!GameplaySettings.Default.ShowTooltips,
vm.GameplayDraft.ShowTooltips);
Assert.Equal(
!GameplaySettings.Default.CoordinatesOnRadar,
vm.GameplayDraft.CoordinatesOnRadar);
Assert.True(vm.HasUnsavedChanges);
vm.Cancel();
Assert.Equal(persistedLockUI, vm.GameplayDraft.LockUI);
Assert.Equal(
GameplaySettings.Default.ShowTooltips,
vm.GameplayDraft.ShowTooltips);
Assert.Equal(
GameplaySettings.Default.CoordinatesOnRadar,
vm.GameplayDraft.CoordinatesOnRadar);
Assert.False(vm.HasUnsavedChanges);
}
[Fact]
public void Save_invokes_gameplay_callback_with_draft()
{
var (vm, _, _, _, _, _, _, savedGameplayHistory, _, _) = Build();
vm.SetGameplay(vm.GameplayDraft with
{
LockUI = true,
ShowTooltips = false,
UseMouseTurning = true,
});
vm.Save();
Assert.Single(savedGameplayHistory);
Assert.True(savedGameplayHistory[0].LockUI);
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 { 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);
}
// -- 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);
}
// -- Character tab state ----------------------------------------------
[Fact]
public void CharacterDraft_initial_value_matches_persisted()
{
var custom = CharacterSettings.Default with { AutoAttack = true, DefaultChatChannel = "Allegiance" };
var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedCharacter: custom);
Assert.Equal(custom, vm.CharacterDraft);
Assert.False(vm.HasUnsavedChanges);
}
[Fact]
public void SetCharacter_marks_unsaved_changes()
{
var (vm, _, _, _, _, _, _, _, _, _) = Build();
vm.SetCharacter(vm.CharacterDraft with { AutoAttack = true });
Assert.True(vm.HasUnsavedChanges);
}
[Fact]
public void Save_invokes_character_callback_with_draft()
{
var (vm, _, _, _, _, _, _, _, _, savedCharacterHistory) = Build();
vm.SetCharacter(vm.CharacterDraft with
{
DefaultChatChannel = "Fellowship",
AutoAttack = true,
ConfirmSalvage = false,
});
vm.Save();
Assert.Single(savedCharacterHistory);
Assert.Equal("Fellowship", savedCharacterHistory[0].DefaultChatChannel);
Assert.True(savedCharacterHistory[0].AutoAttack);
Assert.False(savedCharacterHistory[0].ConfirmSalvage);
Assert.False(vm.HasUnsavedChanges);
}
[Fact]
public void Cancel_reverts_character_draft_to_persisted()
{
var custom = CharacterSettings.Default with { AutoAttack = true };
var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedCharacter: custom);
vm.SetCharacter(vm.CharacterDraft with { AutoAttack = false, DefaultChatChannel = "Trade" });
Assert.True(vm.HasUnsavedChanges);
vm.Cancel();
Assert.Equal(custom, vm.CharacterDraft);
Assert.False(vm.HasUnsavedChanges);
}
[Fact]
public void ResetAllToDefaults_resets_character_to_default()
{
var custom = CharacterSettings.Default with { AutoAttack = true, DefaultChatChannel = "Trade" };
var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedCharacter: custom);
Assert.NotEqual(CharacterSettings.Default, vm.CharacterDraft);
vm.ResetAllToDefaults();
Assert.Equal(CharacterSettings.Default, vm.CharacterDraft);
Assert.True(vm.HasUnsavedChanges);
}
[Fact]
public void LoadCharacterContext_swaps_persisted_and_draft_atomically()
{
// Simulates the post-EnterWorld toon swap — host loads the
// chosen toon's bag from disk and pushes it via
// LoadCharacterContext. BOTH persisted and draft must update
// so HasUnsavedChanges stays false; otherwise the user would
// see a "pending changes" indicator on every login.
var (vm, _, _, _, _, _, _, _, _, _) = Build();
var newToonBag = CharacterSettings.Default with { DefaultChatChannel = "Allegiance", AutoAttack = true };
vm.LoadCharacterContext(newToonBag);
Assert.Equal(newToonBag, vm.CharacterDraft);
Assert.False(vm.HasUnsavedChanges);
}
[Fact]
public void LoadCharacterContext_clears_pending_unsaved_character_edits()
{
// If the user had pending character edits from the previous
// toon (or pre-login session), swapping to a new toon's bag
// must wipe them — Save is per-toon, and bleed-through would
// write the pre-login bag's edits to the new toon's slot.
var (vm, _, _, _, _, _, _, _, _, _) = Build();
vm.SetCharacter(vm.CharacterDraft with { AutoAttack = true });
Assert.True(vm.HasUnsavedChanges);
vm.LoadCharacterContext(CharacterSettings.Default with { DefaultChatChannel = "Fellowship" });
Assert.Equal("Fellowship", vm.CharacterDraft.DefaultChatChannel);
Assert.False(vm.CharacterDraft.AutoAttack);
Assert.False(vm.HasUnsavedChanges);
}
}