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

@ -1,5 +1,4 @@
using AcDream.Core.Net.Messages;
using AcDream.UI.Abstractions.Input;
using AcDream.UI.Abstractions.Panels.Settings;
using AcDream.UI.Abstractions.Settings;
@ -15,8 +14,6 @@ internal interface IRuntimeSettingsStorage
AudioSettings LoadAudio();
GameplaySettings LoadGameplay();
ChatSettings LoadChat();
CharacterSettings LoadCharacter(string toonKey);
@ -29,8 +26,6 @@ internal interface IRuntimeSettingsStorage
void SaveAudio(AudioSettings audio);
void SaveGameplay(GameplaySettings gameplay);
void SaveChat(ChatSettings chat);
void SaveCharacter(string toonKey, CharacterSettings character);
@ -57,8 +52,6 @@ internal sealed class JsonRuntimeSettingsStorage : IRuntimeSettingsStorage
public AudioSettings LoadAudio() => _store.LoadAudio();
public GameplaySettings LoadGameplay() => _store.LoadGameplay();
public ChatSettings LoadChat() => _store.LoadChat();
public CharacterSettings LoadCharacter(string toonKey) =>
@ -70,9 +63,6 @@ internal sealed class JsonRuntimeSettingsStorage : IRuntimeSettingsStorage
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 SaveCharacter(string toonKey, CharacterSettings character) =>
@ -85,39 +75,10 @@ internal sealed class JsonRuntimeSettingsStorage : IRuntimeSettingsStorage
internal sealed record RuntimeSettingsSnapshot(
DisplaySettings Display,
AudioSettings Audio,
GameplaySettings Gameplay,
ChatSettings Chat,
CharacterSettings Character,
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
{
void ApplyDisplay(DisplaySettings display);
@ -186,16 +147,15 @@ internal sealed class RuntimeSettingsController :
private readonly Func<QualityPreset, QualitySettings> _resolveQuality;
private readonly Action<string> _log;
private IRuntimeSettingsTargets? _runtimeTargets;
private SettingsVM? _viewModel;
private CharacterSettings _defaultCharacter;
private bool _startupDisplayApplied;
private bool _startupAudioApplied;
private bool _startupApplied;
private bool _uiLockConverged = true;
// MUST-FIX 4 (OP4 review-fix round, 2026-08-11, blast M3): the last
// `locked` value actually pushed to `_runtimeTargets.ApplyUiLock` —
// the guard `SetUiLocked` compares against, decoupled from whatever
// `Gameplay.LockUI`'s own persisted/draft snapshot currently holds.
// the guard `SetUiLocked` compares against. Originally decoupled from
// the retired client-local `GameplaySettings.LockUI` mirror (OP9
// deleted that record outright); this is now the ONLY store.
private bool? _lastAppliedUiLocked;
public RuntimeSettingsController(
@ -209,7 +169,6 @@ internal sealed class RuntimeSettingsController :
Display = _storage.LoadDisplay();
Audio = _storage.LoadAudio();
Gameplay = _storage.LoadGameplay();
Chat = _storage.LoadChat();
_defaultCharacter = _storage.LoadCharacter(DefaultToonKey);
Character = _defaultCharacter;
@ -217,7 +176,6 @@ internal sealed class RuntimeSettingsController :
Startup = new RuntimeSettingsSnapshot(
Display,
Audio,
Gameplay,
Chat,
Character,
ResolvedQuality);
@ -233,19 +191,24 @@ internal sealed class RuntimeSettingsController :
public AudioSettings Audio { get; private set; }
public GameplaySettings Gameplay { get; private set; }
public ChatSettings Chat { get; private set; }
public CharacterSettings Character { 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)
{
@ -316,94 +279,23 @@ internal sealed class RuntimeSettingsController :
.UnbindRuntimeTargets(_expected);
}
public SettingsVM CreateViewModel(
KeyBindings persistedBindings,
InputDispatcher dispatcher,
Action<KeyBindings> saveBindings)
{
ArgumentNullException.ThrowIfNull(persistedBindings);
ArgumentNullException.ThrowIfNull(dispatcher);
ArgumentNullException.ThrowIfNull(saveBindings);
if (_viewModel is not null)
throw new InvalidOperationException("A settings view model is already bound.");
_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;
}
/// <summary>
/// OP9: rewritten off the retired client-local <c>GameplaySettings.LockUI</c>
/// mirror (MUST-FIX 4, OP4 review-fix round, 2026-08-11, blast M3) — the
/// guard now compares directly against <see cref="_lastAppliedUiLocked"/>,
/// the last value actually pushed to <see cref="_runtimeTargets"/>, with
/// no persisted store of its own left to read or write: the server bit
/// (<c>RuntimeCharacterOptionsState</c>, read through
/// <c>CharacterOptionId.LockUI</c>) is the sole authority, exactly as
/// D7's Group-C re-point already made it at OP4.
/// </summary>
public void SetUiLocked(bool locked)
{
// MUST-FIX 4 (OP4 review-fix round, 2026-08-11, blast M3): the
// 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)
if (_lastAppliedUiLocked == locked)
return;
_uiLockConverged = false;
Gameplay = Gameplay with { LockUI = locked };
_runtimeTargets?.ApplyUiLock(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>
@ -415,10 +307,6 @@ internal sealed class RuntimeSettingsController :
public void ToggleFrameRate()
{
Display = Display with { ShowFps = !Display.ShowFps };
_viewModel?.ApplyExternalDisplayChange(display => display with
{
ShowFps = Display.ShowFps,
});
try
{
@ -434,7 +322,7 @@ internal sealed class RuntimeSettingsController :
/// Campaign OP slice OP3: the five client-local preferences the "Use
/// Mouse Turning Settings" Gameplay-tab macro reads/writes. Read
/// 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
/// OP6's Config tab), so there is nothing today that needs a cached,
/// change-notified copy.
@ -468,14 +356,12 @@ internal sealed class RuntimeSettingsController :
ArgumentException.ThrowIfNullOrWhiteSpace(characterName);
ActiveToonKey = characterName;
Character = _storage.LoadCharacter(characterName);
_viewModel?.LoadCharacterContext(Character);
_log($"settings: loaded character[{characterName}] preferences");
}
public void RestoreDefaultCharacterContext()
{
Character = _defaultCharacter;
_viewModel?.LoadCharacterContext(Character);
}
public void ResetActiveCharacterKey() => ActiveToonKey = DefaultToonKey;
@ -500,7 +386,7 @@ internal sealed class RuntimeSettingsController :
/// same shape as the already-public <see cref="SaveCameraTurning"/>,
/// now also called directly by <c>ConfigOptionsPageController</c>'s
/// 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>
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,
/// same reason as <see cref="SaveDisplay"/> — the Config tab's Chat
/// 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
// 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.
PublishHearOptionChange(
previous.HearGeneralChat, chat.HearGeneralChat,
@ -627,11 +495,10 @@ internal sealed class RuntimeSettingsController :
/// </summary>
public void SyncChatFromServerOptions(uint options2)
{
// Applied identically to BOTH the persisted snapshot and the live
// draft (mirrors ApplyExternalGameplayChange's own idempotent-update
// shape) so an unsaved draft edit to an unrelated field (font size,
// timestamps, ...) survives the reseed instead of being clobbered by
// a value computed once against the persisted snapshot.
// Reseed function applied to the persisted snapshot below so an
// unsaved edit to an unrelated field (font size, timestamps, ...)
// survives the reseed instead of being clobbered by a value
// computed once against a stale snapshot.
ChatSettings Reseed(ChatSettings current) => current with
{
HearGeneralChat = (options2
@ -651,7 +518,6 @@ internal sealed class RuntimeSettingsController :
return;
Chat = synced;
_viewModel?.ApplyExternalChatChange(Reseed);
try
{
_storage.SaveChat(synced);