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

View file

@ -11,13 +11,12 @@ public sealed class GameplayInputCommandControllerTests
{
[Theory]
[InlineData(InputAction.ToggleInventoryPanel, "inventory")]
[InlineData(InputAction.AcdreamToggleDebugPanel, "debug")]
[InlineData(InputAction.AcdreamToggleFlyMode, "fly-or-chase")]
[InlineData(InputAction.AcdreamTogglePlayerMode, "player-mode")]
[InlineData(InputAction.ToggleChatEntry, "chat")]
// Campaign OP slice OP3 (D1): F11 now opens the retail Options panel
// through IRetainedGameplayWindowCommands, not the retired
// IDevToolsGameplayCommands.ToggleSettingsPanel() no-op.
// through IRetainedGameplayWindowCommands, not the OP9-retired
// IDevToolsGameplayCommands.ToggleSettingsPanel() no-op (which was
// never dispatched from this switch in the first place).
[InlineData(InputAction.ToggleOptionsPanel, "options")]
[InlineData(InputAction.CombatToggleCombat, "combat")]
[InlineData(InputAction.ToggleFloatingChatWindow1, "chat-window-1")]
@ -36,6 +35,26 @@ public sealed class GameplayInputCommandControllerTests
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]
public void DiagnosticCommand_PrecedesRemainingCommandSwitch()
{
@ -89,7 +108,6 @@ public sealed class GameplayInputCommandControllerTests
public Harness()
{
Retained = new FakeRetained(Calls);
DevTools = new FakeDevTools(Calls);
Diagnostics = new FakeDiagnostics(Calls);
Player = new FakePlayerMode(Calls);
TargetMode = new FakeTargetMode(Calls);
@ -99,7 +117,6 @@ public sealed class GameplayInputCommandControllerTests
Window = new FakeWindow(Calls);
Controller = new GameplayInputCommandController(
Retained,
DevTools,
Diagnostics,
Player,
TargetMode,
@ -111,7 +128,6 @@ public sealed class GameplayInputCommandControllerTests
public List<string> Calls { get; } = [];
public FakeRetained Retained { get; }
public FakeDevTools DevTools { get; }
public FakeDiagnostics Diagnostics { get; }
public FakePlayerMode Player { get; }
public FakeTargetMode TargetMode { get; }
@ -133,14 +149,6 @@ public sealed class GameplayInputCommandControllerTests
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)
: IRuntimeDiagnosticCommands
{

View file

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

View file

@ -4,10 +4,8 @@ using AcDream.App.Rendering;
using AcDream.App.Settings;
using AcDream.Core.Net.Messages;
using AcDream.UI.Abstractions;
using AcDream.UI.Abstractions.Input;
using AcDream.UI.Abstractions.Panels.Settings;
using AcDream.UI.Abstractions.Settings;
using Silk.NET.Input;
namespace AcDream.App.Tests.Settings;
@ -39,14 +37,12 @@ public sealed class RuntimeSettingsControllerTests
Assert.Equal(1, storage.DisplayLoads);
Assert.Equal(1, storage.AudioLoads);
Assert.Equal(1, storage.GameplayLoads);
Assert.Equal(1, storage.ChatLoads);
Assert.Equal(1, storage.CharacterLoads);
Assert.Equal(1, resolveCount);
Assert.Equal("default", storage.LastLoadedCharacter);
Assert.Same(storage.DisplayValue, controller.Startup.Display);
Assert.Same(storage.AudioValue, controller.Startup.Audio);
Assert.Same(storage.GameplayValue, controller.Startup.Gameplay);
Assert.Same(storage.ChatValue, controller.Startup.Chat);
Assert.Same(storage.DefaultCharacterValue, controller.Startup.Character);
Assert.Equal(resolved, controller.Startup.Quality);
@ -75,31 +71,6 @@ public sealed class RuntimeSettingsControllerTests
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]
public void StartupRetryResumesAfterLastSuccessfulStage()
{
@ -262,63 +233,6 @@ public sealed class RuntimeSettingsControllerTests
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
// ApplyAudio actually computes, not just that some target was called.
// 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
// (0x0005) for exactly the Hear*Chat bits that actually changed —
// 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 controller = new RuntimeSettingsController(
storage,
@ -376,31 +294,24 @@ public sealed class RuntimeSettingsControllerTests
static _ => { });
var targets = new FakeRuntimeTargets([]);
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
// CharacterOptions2.Default — Roleplay/Society start FALSE (only
// General/Trade/LFG start true). Flip Roleplay ON first so the
// second edit below can flip it back off.
Assert.False(viewModel.ChatDraft.HearRoleplayChat);
viewModel.SetChat(viewModel.ChatDraft with { HearRoleplayChat = true });
viewModel.Save();
Assert.False(controller.Chat.HearRoleplayChat);
controller.SaveChat(controller.Chat with { HearRoleplayChat = true });
Assert.Equal(
[((uint)CharacterOptionId.ListenToRoleplayChat, true)],
targets.SingleOptionCalls);
targets.SingleOptionCalls.Clear();
viewModel.SetChat(viewModel.ChatDraft with
controller.SaveChat(controller.Chat with
{
HearRoleplayChat = false,
HearSocietyChat = true,
});
viewModel.Save();
Assert.Equal(
[
@ -416,15 +327,9 @@ public sealed class RuntimeSettingsControllerTests
var controller = CreateController();
var targets = new FakeRuntimeTargets([]);
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.
viewModel.SetChat(viewModel.ChatDraft with { ShowTimestamps = false });
viewModel.Save();
controller.SaveChat(controller.Chat with { ShowTimestamps = false });
Assert.Empty(targets.SingleOptionCalls);
}
@ -434,23 +339,17 @@ public sealed class RuntimeSettingsControllerTests
{
// Campaign CH slice CH6c: unlike the Hear* options (local-only, no
// wire), opacity is ALWAYS pushed on Save (not diffed) so the linking
// invariant self-heals; the point of this test is that clicking Save
// is enough — no restart, no separate "apply" step.
// invariant self-heals; the point of this test is that calling
// SaveChat is enough — no restart, no separate "apply" step.
var controller = CreateController();
var targets = new FakeRuntimeTargets([]);
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,
ActiveOpacity = 0.6f,
});
viewModel.Save();
Assert.Equal([(0.3f, 0.6f)], targets.ChatOpacityCalls);
Assert.Equal(0.3f, controller.Chat.DefaultOpacity);
@ -486,7 +385,7 @@ public sealed class RuntimeSettingsControllerTests
}
[Fact]
public void SyncChatFromServerOptionsReseedsPersistedAndDraft()
public void SyncChatFromServerOptionsReseedsPersisted()
{
// Research doc §5.2: ACE's CharacterOptions2.Default omits
// HearRoleplayChat/HearSocietyChat. N4 (CH3 Opus review) aligned
@ -507,13 +406,7 @@ public sealed class RuntimeSettingsControllerTests
storage,
static preset => QualitySettings.From(preset),
static _ => { });
using InputDispatcher dispatcher = CreateDispatcher();
SettingsVM viewModel = controller.CreateViewModel(
new KeyBindings(),
dispatcher,
static _ => { });
Assert.True(controller.Chat.HearRoleplayChat);
Assert.True(viewModel.ChatDraft.HearRoleplayChat);
controller.SyncChatFromServerOptions(0x00948700u); // ACE's real default
@ -522,28 +415,9 @@ public sealed class RuntimeSettingsControllerTests
Assert.True(controller.Chat.HearLFGChat);
Assert.False(controller.Chat.HearRoleplayChat);
Assert.False(controller.Chat.HearSocietyChat);
Assert.False(viewModel.ChatDraft.HearRoleplayChat);
Assert.False(viewModel.ChatDraft.HearSocietyChat);
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]
public void SyncChatFromServerOptionsIsANoOpWhenUnchanged()
{
@ -569,126 +443,31 @@ public sealed class RuntimeSettingsControllerTests
}
[Fact]
public void DraftPreviewAndExternalCommandsShareCanonicalState()
public void DraftPreviewAlwaysMirrorsCommittedState()
{
var events = new List<string>();
var storage = new FakeStorage(events);
var controller = CreateController(storage, events: events);
storage.ClearEvents();
var targets = new FakeRuntimeTargets(events);
controller.BindRuntimeTargets(targets);
using InputDispatcher dispatcher = CreateDispatcher();
SettingsVM viewModel = controller.CreateViewModel(
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 });
// OP9: the optional developer-tools draft-preview view model
// (SettingsVM) was retired — it had zero production construction
// sites. HasDraftPreview is now hardcoded false and
// DisplayPreview/AudioPreview always mirror the committed
// Display/Audio snapshot. This pins the NEW (trivial) contract that
// WorldRenderFrameBuilder and SettingsParticleRangeSource still
// consume through IRuntimeSettingsPreviewSource.
var controller = CreateController();
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(ParticleRange.Retail, controller.DisplayPreview.ParticleRange);
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]
public void DraftCancelRestoresPreviewAndCombatTogglePreservesUnrelatedDrafts()
{
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()
public void CharacterContextSwitchesActiveToonAndReloadsSettings()
{
var storage = new FakeStorage();
storage.Characters["Alice"] = CharacterSettings.Default with
@ -707,76 +486,23 @@ public sealed class RuntimeSettingsControllerTests
Assert.Equal("Alice", controller.ActiveToonKey);
Assert.Equal("Trade", controller.Character.DefaultChatChannel);
using InputDispatcher dispatcher = CreateDispatcher();
SettingsVM viewModel = controller.CreateViewModel(
new KeyBindings(),
dispatcher,
static _ => { });
controller.LoadCharacterContext("Bob");
Assert.Equal("Bob", controller.ActiveToonKey);
Assert.False(viewModel.CharacterDraft.ConfirmSalvage);
Assert.False(controller.Character.ConfirmSalvage);
controller.RestoreDefaultCharacterContext();
controller.ResetActiveCharacterKey();
Assert.Equal("default", controller.ActiveToonKey);
Assert.Same(storage.DefaultCharacterValue, controller.Character);
Assert.Same(storage.DefaultCharacterValue, viewModel.CharacterDraft);
}
[Fact]
public void CharacterSaveUsesActiveToonAndDefaultSaveBecomesResetContext()
{
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()
public void RuntimeTargetLoansCanBeWithdrawnAndReboundPrecisely()
{
var events = new List<string>();
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);
controller.BindRuntimeTargets(first);
@ -822,20 +548,14 @@ public sealed class RuntimeSettingsControllerTests
logs.Add);
storage.ClearEvents();
controller.BindRuntimeTargets(new FakeRuntimeTargets(events));
using InputDispatcher dispatcher = CreateDispatcher();
SettingsVM viewModel = controller.CreateViewModel(
new KeyBindings(),
dispatcher,
static _ => { });
DisplaySettings original = controller.Display;
viewModel.SetDisplay(viewModel.DisplayDraft with
controller.SaveDisplay(controller.Display with
{
Resolution = "2560x1440",
Quality = QualityPreset.Ultra,
});
viewModel.Save();
Assert.Same(original, controller.Display);
Assert.DoesNotContain("target-display", events);
Assert.DoesNotContain("target-quality", events);
@ -857,20 +577,14 @@ public sealed class RuntimeSettingsControllerTests
{
ThrowOnDisplay = true,
});
using InputDispatcher dispatcher = CreateDispatcher();
SettingsVM viewModel = controller.CreateViewModel(
new KeyBindings(),
dispatcher,
static _ => { });
DisplaySettings original = controller.Display;
viewModel.SetDisplay(viewModel.DisplayDraft with
controller.SaveDisplay(controller.Display with
{
Resolution = "3840x2160",
Quality = QualityPreset.Ultra,
});
viewModel.Save();
Assert.Equal(1, storage.DisplaySaves);
Assert.Same(original, controller.Display);
Assert.DoesNotContain("target-quality", events);
@ -883,9 +597,7 @@ public sealed class RuntimeSettingsControllerTests
var storage = new FakeStorage
{
ThrowOnAudioSave = true,
ThrowOnGameplaySave = true,
ThrowOnChatSave = true,
ThrowOnCharacterSave = true,
};
var logs = new List<string>();
var controller = new RuntimeSettingsController(
@ -893,29 +605,15 @@ public sealed class RuntimeSettingsControllerTests
static preset => QualitySettings.From(preset),
logs.Add);
AudioSettings originalAudio = controller.Audio;
GameplaySettings originalGameplay = controller.Gameplay;
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(originalGameplay, controller.Gameplay);
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("gameplay 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]
@ -956,135 +654,74 @@ public sealed class RuntimeSettingsControllerTests
}
[Fact]
public void SetUiLocked_AppliesEvenWhenGameplayLockUIAlreadyMatches_IfNeverActuallyApplied()
public void SetUiLocked_AppliesOnFirstCallThenNoOpsOnRepeatedSameValue()
{
// MUST-FIX 4 (OP4 review-fix round, 2026-08-11, blast M3): the
// guard used to compare the requested value against
// `Gameplay.LockUI` — valid only while `ToggleUiLock` derived
// `locked` AS `!Gameplay.LockUI`. OP4 re-pointed `ToggleUiLock` to
// read the SERVER bit (RuntimeCharacterOptionsState) instead, a
// DIFFERENT store that can already equal a value this controller
// never actually pushed to `_runtimeTargets`. A pre-existing save
// seeds Gameplay.LockUI = true; the runtime target has never seen
// `true` — the FIRST SetUiLocked(true) call must still apply.
// OP9: SetUiLocked no longer reads or writes a persisted
// GameplaySettings mirror (MUST-FIX 4, OP4 review-fix round,
// 2026-08-11, blast M3, superseded) — RuntimeCharacterOptionsState
// (the server bit) is the sole authority now, so there is no longer
// a second store that could disagree with what was actually
// applied. The guard's own idempotency (compare against the last
// value ACTUALLY pushed to _runtimeTargets) is the only behavior
// left to pin here.
var events = new List<string>();
var storage = new FakeStorage(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 controller = CreateController(events: events);
var targets = new FakeRuntimeTargets(events);
controller.BindRuntimeTargets(targets);
controller.SetUiLocked(true);
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);
Assert.Equal(1, targets.UiLockCalls);
controller.SetUiLocked(false);
Assert.Equal(2, targets.UiLockCalls);
}
[Fact]
public void UiLockTargetFailureCanRetryTheSameRequestedValue()
{
var events = new List<string>();
var storage = new FakeStorage(events);
var controller = CreateController(storage, events);
storage.ClearEvents();
var controller = CreateController(events: events);
var targets = new FakeRuntimeTargets(events)
{
RemainingUiLockFailures = 1,
};
controller.BindRuntimeTargets(targets);
using InputDispatcher dispatcher = CreateDispatcher();
SettingsVM viewModel = controller.CreateViewModel(
new KeyBindings(),
dispatcher,
static _ => { });
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);
// 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();
controller.SetUiLocked(true);
Assert.True(controller.Gameplay.LockUI);
Assert.True(viewModel.GameplayDraft.LockUI);
viewModel.Cancel();
Assert.True(viewModel.GameplayDraft.LockUI);
Assert.Equal(1, storage.GameplaySaves);
Assert.Equal(["target-ui-lock:True", "save-gameplay"], events);
Assert.Equal(["target-ui-lock:True"], events);
Assert.Equal(2, targets.UiLockCalls);
}
[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 storage = new FakeStorage(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();
var controller = CreateController(events: events);
controller.BindRuntimeTargets(new FakeRuntimeTargets(events));
controller.UnbindRuntimeTargets();
controller.SetUiLocked(true);
controller.ReapplyQualityPreset(QualityPreset.Ultra);
Assert.True(controller.Gameplay.LockUI);
Assert.Equal(
QualitySettings.From(QualityPreset.Ultra),
controller.ResolvedQuality);
Assert.DoesNotContain(events, value => value.StartsWith("target-", StringComparison.Ordinal));
Assert.Equal(1, storage.GameplaySaves);
}
[Theory]
@ -1121,32 +758,6 @@ public sealed class RuntimeSettingsControllerTests
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)
: IRuntimeSettingsStartupTarget
{
@ -1356,8 +967,6 @@ public sealed class RuntimeSettingsControllerTests
public AudioSettings AudioValue { get; set; } = AudioSettings.Default;
public GameplaySettings GameplayValue { get; set; } = GameplaySettings.Default;
public ChatSettings ChatValue { get; set; } = ChatSettings.Default;
public CharacterSettings DefaultCharacterValue { get; set; } =
@ -1377,16 +986,12 @@ public sealed class RuntimeSettingsControllerTests
public int AudioLoads { get; private set; }
public int GameplayLoads { get; private set; }
public int ChatLoads { get; private set; }
public int CharacterLoads { get; private set; }
public int DisplaySaves { get; private set; }
public int GameplaySaves { get; private set; }
public int ChatSaves { get; private set; }
public string? LastLoadedCharacter { get; private set; }
@ -1395,10 +1000,6 @@ public sealed class RuntimeSettingsControllerTests
public bool ThrowOnAudioSave { get; init; }
public bool ThrowOnGameplaySave { get; init; }
public int RemainingGameplaySaveFailures { get; set; }
public bool ThrowOnChatSave { get; init; }
public bool ThrowOnCharacterSave { get; init; }
@ -1415,12 +1016,6 @@ public sealed class RuntimeSettingsControllerTests
return AudioValue;
}
public GameplaySettings LoadGameplay()
{
GameplayLoads++;
return GameplayValue;
}
public ChatSettings LoadChat()
{
ChatLoads++;
@ -1453,20 +1048,6 @@ public sealed class RuntimeSettingsControllerTests
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)
{
ChatSaves++;
@ -1532,27 +1113,4 @@ public sealed class RuntimeSettingsControllerTests
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;
}
}