From da189103b8623b5c925d0b0ce1cf5299e6d7a7ba Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 26 Apr 2026 00:14:25 +0200 Subject: [PATCH] feat(input): #23 Phase K.1c - retail-faithful keymap cutover + JSON persistence (muscle memory change) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single bisectable commit where the user-visible keyboard layout flips from acdream-current (W/S/A/D/Z/X) to canonical AC retail (W/X/A/D/Z/C). The InputDispatcher abstraction landed in K.1a, existing handlers cut over in K.1b, and now KeyBindings.RetailDefaults() returns the byte-precise retail preset matching docs/research/named-retail/retail-default.keymap.txt. Movement (matches AC1 muscle memory): - W/Up = MovementForward (run by default) - X/Down = MovementBackup - A/Left = MovementTurnLeft - D/Right = MovementTurnRight - Z = MovementStrafeLeft - C = MovementStrafeRight - Alt+A / Alt+Left = MovementStrafeLeft (Alt-flips-turn) - Alt+D / Alt+Right = MovementStrafeRight - LShift (Hold) = MovementWalkMode (default = run; held = walk) - Q = MovementRunLock (autorun toggle) - S = MovementStop (sets Ready stance / idle) - Space = MovementJump (hold to charge) - Y = Ready, G = Sitting, H = Crouch, B = Sleeping (postures) Selection / targeting (18 bindings on punctuation cluster): - F = SelectionPickUp, T = SelectionSplitStack, P = PreviousSelection - Backspace/Minus/Equals = closest/prev/next CompassItem - Backslash/[/] = closest/prev/next Item - Apostrophe/L/Semicolon = closest/prev/next Monster - Home = LastAttacker - Slash/Comma/Period = closest/prev/next Player - N/M = prev/next Fellow - E = SelectionExamine - R = UseSelected UI: - F1 = ToggleHelp; Shift+Ctrl+F1 = TogglePluginManager - F3 = Allegiance, F4 = Fellowship, F5 = Spellbook, F6 = SpellComponents - F8 = Attributes, F9 = Skills, F10 = World, F11 = Options (lights up the Settings panel in K.3), F12 = Inventory - Alt+1/2/3/4 = ToggleFloatingChatWindow1/2/3/4 - Esc = EscapeKey, Shift+Esc = LOGOUT - Numpad * = CaptureScreenshot Hotbar / spellbook: - 1-9 = UseQuickSlot_1..9 (hotbar) AND UseSpellSlot_1..9 (in MagicCombat scope - dormant until Phase L) - Ctrl+1-9 = UseQuickSlot_1..9 (duplicate) - Alt+5-9 = UseQuickSlot_14..18 (second bar) - 0 / Ctrl+0 = CreateShortcut Chat: - Tab = ToggleChatEntry (focus chat input; subscriber stub-TODO in K.2) - Return = EnterChatMode (send) Combat (mode-dependent, dormant - Phase L lights up): - Grave (`) = CombatToggleCombat - Insert/PgUp/Delete/End/PgDn = melee power+attack-level OR missile accuracy+aim-level OR magic spell-tab nav + cast (resolved by scope at runtime once CombatState.CurrentMode lands). - Ctrl+Insert/PgUp/Delete/PgDn = first/last spell tab + first/last spell Emotes: U = Cry, I = Laugh, J = Wave, O = Cheer, K = PointState Camera (numpad cluster + F2): - F2 / Numpad/ = CameraActivateAlternateMode - Numpad 4/6/8/2 = rotate left/right/up/down - Numpad - / + = move toward / away - Numpad 0 = ViewDefault, Numpad . = FirstPerson - Numpad 5 = LookDown, Numpad Enter = MapMode Scroll: - Mouse wheel handled by dispatcher OnScroll path - Ctrl+Up / Ctrl+Down = ScrollUp / ScrollDown Acdream debug actions relocated from F-keys to Ctrl+F-keys to avoid retail conflicts: - Ctrl+F1 = AcdreamToggleDebugPanel - Ctrl+F2 = AcdreamToggleCollisionWires - Ctrl+F3 = AcdreamDumpNearby - Ctrl+F7 = AcdreamCycleTimeOfDay - Ctrl+F8 / Ctrl+F9 = AcdreamSensitivityDown / Up - Ctrl+F10 = AcdreamCycleWeather AcdreamToggleFlyMode + AcdreamTogglePlayerMode have NO keyboard binding in retail-default. K.2 adds a DebugPanel button for fly toggle and auto-enter player mode at login. Total: 149 bindings. JSON load/save: - KeyBindings.LoadOrDefault(path): merge-over-defaults migration. Missing actions get default bindings; unknown actions in user file are skipped (preserves user customizations across action enum additions). Corrupt file warns + returns RetailDefaults without overwriting (don't blow away user's file silently). - KeyBindings.SaveToFile(path): writes with schema version=1, alpha- sorted action names, alpha-sorted modifier keys for stable diffs. - KeyBindings.DefaultPath() = %LOCALAPPDATA%/acdream/keybinds.json. GameWindow startup: - Replaces KeyBindings.AcdreamCurrentDefaults() call with KeyBindings.LoadOrDefault(KeyBindings.DefaultPath()) via a small LoadStartupKeyBindings() helper. - Logs "keybinds: loaded N bindings from " so launch.log shows the source of truth at session start. Three deviations from plan: 1. LoadStartupKeyBindings() helper instead of inline initializer (field initializer can't call methods directly). 2. ToggleChatEntry subscriber is a no-op stub with TODO K.2 comment (ChatPanel doesn't expose FocusInput() yet; will add in K.2). 3. AcdreamRmbOrbitHold removed from RetailDefaults() to avoid double-binding RMB (SelectRight + RmbOrbitHold on the same chord would fire both subscribers). Chase-camera orbit will be replaced by MMB-hold mouse-look in K.2 - retail's CameraInstantMouseLook. 28 new tests: - KeyBindingsRetailTests: 19 cases pinning every retail mapping (W/X movement, Z/C strafe, Tab=ToggleChatEntry, Shift+Esc=LOGOUT, Shift+Ctrl+F1=TogglePluginManager, MovementWalkMode=Hold, Acdream debug on Ctrl+F*, hotbar number-row variants, etc). - KeyBindingsJsonTests: 9 cases (round-trip; missing file → defaults; corrupt → defaults + no-overwrite; merge-over-defaults; legacy version=0 parsing; Hold-activation preservation; unknown- action skip; DefaultPath shape). Solution total: 1162 green (243 Core.Net + 254 UI + 665 Core), 0 warnings. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 25 +- .../Input/KeyBindings.cs | 416 ++++++++++++++++-- .../Input/KeyBindingsJsonTests.cs | 234 ++++++++++ .../Input/KeyBindingsRetailTests.cs | 200 +++++++++ .../Input/KeyBindingsTests.cs | 15 +- 5 files changed, 847 insertions(+), 43 deletions(-) create mode 100644 tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsJsonTests.cs create mode 100644 tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsRetailTests.cs diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 2450265..e4a2731 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -412,8 +412,20 @@ public sealed class GameWindow : IDisposable private AcDream.App.Input.SilkKeyboardSource? _kbSource; private AcDream.App.Input.SilkMouseSource? _mouseSource; private AcDream.UI.Abstractions.Input.InputDispatcher? _inputDispatcher; - private readonly AcDream.UI.Abstractions.Input.KeyBindings _keyBindings = - AcDream.UI.Abstractions.Input.KeyBindings.RetailDefaults(); + // K.1c: load user-customized bindings from %LOCALAPPDATA%\acdream\keybinds.json, + // falling back to the retail-faithful defaults if the file is missing + // or corrupt. This is THE single source of truth for the keymap at + // startup — no other call to RetailDefaults() / AcdreamCurrentDefaults() + // should land in the GameWindow construction path. + private readonly AcDream.UI.Abstractions.Input.KeyBindings _keyBindings = LoadStartupKeyBindings(); + + private static AcDream.UI.Abstractions.Input.KeyBindings LoadStartupKeyBindings() + { + var path = AcDream.UI.Abstractions.Input.KeyBindings.DefaultPath(); + var bindings = AcDream.UI.Abstractions.Input.KeyBindings.LoadOrDefault(path); + Console.WriteLine($"keybinds: loaded {bindings.All.Count} bindings from {path}"); + return bindings; + } // Phase 4.7: optional live connection to an ACE server. Enabled only when // ACDREAM_LIVE=1 is in the environment — fully backward compatible with @@ -5018,6 +5030,15 @@ public sealed class GameWindow : IDisposable TogglePlayerMode(); break; + case AcDream.UI.Abstractions.Input.InputAction.ToggleChatEntry: + // K.1c: Tab in retail focuses the chat input. Phase K.2 + // wires this to ChatPanel.FocusInput() once the panel grows + // an explicit focus method; the current ImGui-backed chat + // panel takes focus on click. Press is logged via the + // [input] diagnostic above so the cutover is observable. + // TODO K.2: call _chatPanel.FocusInput() once available. + break; + case AcDream.UI.Abstractions.Input.InputAction.EscapeKey: if (_cameraController?.IsFlyMode == true) _cameraController.ToggleFly(); // exit fly, release cursor diff --git a/src/AcDream.UI.Abstractions/Input/KeyBindings.cs b/src/AcDream.UI.Abstractions/Input/KeyBindings.cs index bb23b41..58ba368 100644 --- a/src/AcDream.UI.Abstractions/Input/KeyBindings.cs +++ b/src/AcDream.UI.Abstractions/Input/KeyBindings.cs @@ -1,4 +1,8 @@ +using System; using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; using Silk.NET.Input; namespace AcDream.UI.Abstractions.Input; @@ -11,15 +15,19 @@ namespace AcDream.UI.Abstractions.Input; /// without removing the default. /// /// -/// JSON IO + the real preset land in K.1c. -/// For K.1a, proxies -/// so the bindings table matches -/// today's hard-coded chords — no behavior change during the K.1a/K.1b -/// cutover. +/// K.1c: now returns the full retail-faithful +/// preset (byte-precise to retail-default.keymap.txt); +/// + persist the +/// table to JSON under with version-based +/// migration. is preserved as a +/// reference for tests pinning the older WASD-only behavior, but is no +/// longer the GameWindow startup source. /// /// public sealed class KeyBindings { + private const int CurrentSchemaVersion = 1; + private readonly List _bindings = new(); /// All bindings in insertion order. @@ -59,26 +67,22 @@ public sealed class KeyBindings } /// - /// K.1a stub: returns CURRENT acdream binds (W=fwd, S=back, A/D=turn, - /// Z/X=strafe, Shift=run, Tab=toggle player↔fly mode, F1=DebugPanel, - /// F2=collision wires, F3=dump, F7=cycle time, F8/F9=sensitivity, - /// F10=cycle weather, F=fly toggle, Space=jump). NOT the retail - /// preset — that lands in K.1c. This stub keeps behavior unchanged - /// during K.1a/K.1b cutover. + /// Acdream-current keymap (W=fwd, S=back, A/D=turn, Z/X=strafe, + /// Shift=run, Tab=toggle player↔fly mode, F1=DebugPanel, F2=collision + /// wires, F3=dump, F7=cycle time, F8/F9=sensitivity, F10=cycle + /// weather, F=fly toggle, Space=jump). Preserved as a regression + /// anchor for tests that pin the older modifier-blind WASD layout — + /// NOT the GameWindow startup source after K.1c. /// public static KeyBindings AcdreamCurrentDefaults() { var b = new KeyBindings(); - // Movement (current acdream — wrong for retail but unchanged for K.1a/K.1b). - // - // Each movement key gets BOTH a bare-mods chord and a Shift-mods chord - // so the per-frame IsActionHeld query stays true while the user holds - // Shift (the acdream-current run-modifier). Before K.1b, movement - // polled IsKeyPressed(Key.W) directly — modifier-blind. K.1b's - // dispatcher does strict modifier matching, so we duplicate-bind here - // to preserve the modifier-blind feel until K.1c reshapes the whole - // table to retail defaults. + // Movement (current acdream — wrong for retail but preserved for + // legacy tests). Each movement key gets BOTH a bare-mods chord + // and a Shift-mods chord so the per-frame IsActionHeld query + // stays true while the user holds Shift (the acdream-current + // run-modifier). b.Add(new(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward)); b.Add(new(new KeyChord(Key.W, ModifierMask.Shift), InputAction.MovementForward)); b.Add(new(new KeyChord(Key.S, ModifierMask.None), InputAction.MovementBackup)); @@ -91,22 +95,14 @@ public sealed class KeyBindings b.Add(new(new KeyChord(Key.Z, ModifierMask.Shift), InputAction.MovementStrafeLeft)); b.Add(new(new KeyChord(Key.X, ModifierMask.None), InputAction.MovementStrafeRight)); b.Add(new(new KeyChord(Key.X, ModifierMask.Shift), InputAction.MovementStrafeRight)); - // Run-modifier: Shift held triggers run. When ShiftLeft/ShiftRight - // is held, the keyboard's CurrentModifiers includes Shift — so the - // chord requires Modifiers=Shift. Both sides resolve to the same - // action so a held-poll on either side answers true. b.Add(new(new KeyChord(Key.ShiftLeft, ModifierMask.Shift), InputAction.MovementRunLock, ActivationType.Hold)); b.Add(new(new KeyChord(Key.ShiftRight, ModifierMask.Shift), InputAction.MovementRunLock, ActivationType.Hold)); b.Add(new(new KeyChord(Key.Space, ModifierMask.None), InputAction.MovementJump)); b.Add(new(new KeyChord(Key.Space, ModifierMask.Shift), InputAction.MovementJump)); - // Fly-camera descend — Ctrl held in fly mode lowers the camera. - // ControlLeft held delivers CurrentModifiers=Ctrl, so chord uses - // mask=Ctrl. Both Ctrl sides resolve to the same action. b.Add(new(new KeyChord(Key.ControlLeft, ModifierMask.Ctrl), InputAction.AcdreamFlyDown, ActivationType.Hold)); b.Add(new(new KeyChord(Key.ControlRight, ModifierMask.Ctrl), InputAction.AcdreamFlyDown, ActivationType.Hold)); - // Acdream debug binds b.Add(new(new KeyChord(Key.F1, ModifierMask.None), InputAction.AcdreamToggleDebugPanel)); b.Add(new(new KeyChord(Key.F2, ModifierMask.None), InputAction.AcdreamToggleCollisionWires)); b.Add(new(new KeyChord(Key.F3, ModifierMask.None), InputAction.AcdreamDumpNearby)); @@ -118,9 +114,6 @@ public sealed class KeyBindings b.Add(new(new KeyChord(Key.Tab, ModifierMask.None), InputAction.AcdreamTogglePlayerMode)); b.Add(new(new KeyChord(Key.Escape, ModifierMask.None), InputAction.EscapeKey)); - // K.1b mouse: RMB-hold drives camera-only orbit (never character yaw). - // Device=1 marks this as a mouse chord; the dispatcher routes - // _mouse.IsHeld(Right) through this chord for IsActionHeld lookup. b.Add(new( new KeyChord(InputDispatcher.MouseButtonToKey(Silk.NET.Input.MouseButton.Right), ModifierMask.None, Device: 1), InputAction.AcdreamRmbOrbitHold, @@ -130,10 +123,361 @@ public sealed class KeyBindings } /// - /// K.1c will replace this with the retail-faithful preset built from - /// docs/research/named-retail/retail-default.keymap.txt. - /// For K.1a, returns the acdream-current map so behavior is unchanged. + /// Canonical AC retail-default keymap, byte-precise to + /// docs/research/named-retail/retail-default.keymap.txt (the + /// user's Throne of Destiny test.keymap). All bindings + /// categorized by the Bindings [...] sections of the keymap + /// text. Default movement mode = RUN; hold LShift toggles to walk. + /// Note: K.1c puts the bindings in the table; the dispatcher / + /// MovementInput wiring that interprets MovementWalkMode + the + /// "default-run" semantics doesn't change in K.1c — that's K.2. /// - // TODO K.1c: replace with full retail preset + JSON LoadOrDefault. - public static KeyBindings RetailDefaults() => AcdreamCurrentDefaults(); + public static KeyBindings RetailDefaults() + { + var b = new KeyBindings(); + + // ── MovementCommands ─────────────────────────────────── + b.Add(new(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward)); + b.Add(new(new KeyChord(Key.Up, ModifierMask.None), InputAction.MovementForward)); + b.Add(new(new KeyChord(Key.X, ModifierMask.None), InputAction.MovementBackup)); + b.Add(new(new KeyChord(Key.Down, ModifierMask.None), InputAction.MovementBackup)); + b.Add(new(new KeyChord(Key.A, ModifierMask.None), InputAction.MovementTurnLeft)); + b.Add(new(new KeyChord(Key.Left, ModifierMask.None), InputAction.MovementTurnLeft)); + b.Add(new(new KeyChord(Key.D, ModifierMask.None), InputAction.MovementTurnRight)); + b.Add(new(new KeyChord(Key.Right, ModifierMask.None), InputAction.MovementTurnRight)); + b.Add(new(new KeyChord(Key.Z, ModifierMask.None), InputAction.MovementStrafeLeft)); + b.Add(new(new KeyChord(Key.A, ModifierMask.Alt), InputAction.MovementStrafeLeft)); + b.Add(new(new KeyChord(Key.Left, ModifierMask.Alt), InputAction.MovementStrafeLeft)); + b.Add(new(new KeyChord(Key.C, ModifierMask.None), InputAction.MovementStrafeRight)); + b.Add(new(new KeyChord(Key.D, ModifierMask.Alt), InputAction.MovementStrafeRight)); + b.Add(new(new KeyChord(Key.Right, ModifierMask.Alt), InputAction.MovementStrafeRight)); + // Walk-mode modifier — Hold so a subscriber can latch state on + // press and unlatch on release. + b.Add(new(new KeyChord(Key.ShiftLeft, ModifierMask.None), InputAction.MovementWalkMode, ActivationType.Hold)); + b.Add(new(new KeyChord(Key.Q, ModifierMask.None), InputAction.MovementRunLock)); + b.Add(new(new KeyChord(Key.S, ModifierMask.None), InputAction.MovementStop)); + b.Add(new(new KeyChord(Key.Y, ModifierMask.None), InputAction.Ready)); + b.Add(new(new KeyChord(Key.G, ModifierMask.None), InputAction.Sitting)); + b.Add(new(new KeyChord(Key.H, ModifierMask.None), InputAction.Crouch)); + b.Add(new(new KeyChord(Key.B, ModifierMask.None), InputAction.Sleeping)); + b.Add(new(new KeyChord(Key.Space, ModifierMask.None), InputAction.MovementJump)); + + // ── ItemSelectionCommands ────────────────────────────── + b.Add(new(new KeyChord(Key.F, ModifierMask.None), InputAction.SelectionPickUp)); + b.Add(new(new KeyChord(Key.T, ModifierMask.None), InputAction.SelectionSplitStack)); + b.Add(new(new KeyChord(Key.P, ModifierMask.None), InputAction.SelectionPreviousSelection)); + b.Add(new(new KeyChord(Key.Backspace, ModifierMask.None), InputAction.SelectionClosestCompassItem)); + b.Add(new(new KeyChord(Key.Minus, ModifierMask.None), InputAction.SelectionPreviousCompassItem)); + b.Add(new(new KeyChord(Key.Equal, ModifierMask.None), InputAction.SelectionNextCompassItem)); + b.Add(new(new KeyChord(Key.BackSlash, ModifierMask.None), InputAction.SelectionClosestItem)); + b.Add(new(new KeyChord(Key.LeftBracket, ModifierMask.None), InputAction.SelectionPreviousItem)); + b.Add(new(new KeyChord(Key.RightBracket, ModifierMask.None), InputAction.SelectionNextItem)); + b.Add(new(new KeyChord(Key.Apostrophe, ModifierMask.None), InputAction.SelectionClosestMonster)); + b.Add(new(new KeyChord(Key.L, ModifierMask.None), InputAction.SelectionPreviousMonster)); + // Silk.NET names this Semicolon (lowercase c), not SemiColon. + b.Add(new(new KeyChord(Key.Semicolon, ModifierMask.None), InputAction.SelectionNextMonster)); + b.Add(new(new KeyChord(Key.Home, ModifierMask.None), InputAction.SelectionLastAttacker)); + b.Add(new(new KeyChord(Key.Slash, ModifierMask.None), InputAction.SelectionClosestPlayer)); + b.Add(new(new KeyChord(Key.Comma, ModifierMask.None), InputAction.SelectionPreviousPlayer)); + b.Add(new(new KeyChord(Key.Period, ModifierMask.None), InputAction.SelectionNextPlayer)); + b.Add(new(new KeyChord(Key.N, ModifierMask.None), InputAction.SelectionPreviousFellow)); + b.Add(new(new KeyChord(Key.M, ModifierMask.None), InputAction.SelectionNextFellow)); + + // ── UICommands ───────────────────────────────────────── + b.Add(new(new KeyChord(Key.E, ModifierMask.None), InputAction.SelectionExamine)); + b.Add(new(new KeyChord(Key.KeypadMultiply, ModifierMask.None), InputAction.CaptureScreenshot)); + b.Add(new(new KeyChord(Key.F1, ModifierMask.None), InputAction.ToggleHelp)); + b.Add(new(new KeyChord(Key.F1, ModifierMask.Shift | ModifierMask.Ctrl), InputAction.TogglePluginManager)); + b.Add(new(new KeyChord(Key.F3, ModifierMask.None), InputAction.ToggleAllegiancePanel)); + b.Add(new(new KeyChord(Key.F4, ModifierMask.None), InputAction.ToggleFellowshipPanel)); + b.Add(new(new KeyChord(Key.F5, ModifierMask.None), InputAction.ToggleSpellbookPanel)); + b.Add(new(new KeyChord(Key.F6, ModifierMask.None), InputAction.ToggleSpellComponentsPanel)); + b.Add(new(new KeyChord(Key.F8, ModifierMask.None), InputAction.ToggleAttributesPanel)); + b.Add(new(new KeyChord(Key.F9, ModifierMask.None), InputAction.ToggleSkillsPanel)); + b.Add(new(new KeyChord(Key.F10, ModifierMask.None), InputAction.ToggleWorldPanel)); + b.Add(new(new KeyChord(Key.F11, ModifierMask.None), InputAction.ToggleOptionsPanel)); + b.Add(new(new KeyChord(Key.F12, ModifierMask.None), InputAction.ToggleInventoryPanel)); + b.Add(new(new KeyChord(Key.Number1, ModifierMask.Alt), InputAction.ToggleFloatingChatWindow1)); + b.Add(new(new KeyChord(Key.Number2, ModifierMask.Alt), InputAction.ToggleFloatingChatWindow2)); + b.Add(new(new KeyChord(Key.Number3, ModifierMask.Alt), InputAction.ToggleFloatingChatWindow3)); + b.Add(new(new KeyChord(Key.Number4, ModifierMask.Alt), InputAction.ToggleFloatingChatWindow4)); + b.Add(new(new KeyChord(Key.R, ModifierMask.None), InputAction.UseSelected)); + b.Add(new(new KeyChord(Key.Escape, ModifierMask.None), InputAction.EscapeKey)); + b.Add(new(new KeyChord(Key.Escape, ModifierMask.Shift), InputAction.LOGOUT)); + + // ── QuickslotCommands ────────────────────────────────── + // Number1..9 → UseQuickSlot_1..9 with bare-mod and Ctrl-mod chords. + for (int i = 1; i <= 9; i++) + { + var k = (Key)((int)Key.Number0 + i); // Number1..Number9 + var action = (InputAction)((int)InputAction.UseQuickSlot_1 + i - 1); + b.Add(new(new KeyChord(k, ModifierMask.None), action)); + b.Add(new(new KeyChord(k, ModifierMask.Ctrl), action)); + } + // Alt+5..9 → UseQuickSlot_14..18. + for (int i = 5; i <= 9; i++) + { + var k = (Key)((int)Key.Number0 + i); + var action = (InputAction)((int)InputAction.UseQuickSlot_14 + i - 5); + b.Add(new(new KeyChord(k, ModifierMask.Alt), action)); + } + b.Add(new(new KeyChord(Key.Number0, ModifierMask.None), InputAction.CreateShortcut)); + b.Add(new(new KeyChord(Key.Number0, ModifierMask.Ctrl), InputAction.CreateShortcut)); + + // ── Chat ──────────────────────────────────────────────── + b.Add(new(new KeyChord(Key.Tab, ModifierMask.None), InputAction.ToggleChatEntry)); + b.Add(new(new KeyChord(Key.Enter, ModifierMask.None), InputAction.EnterChatMode)); + + // ── Combat (mode-dependent — dormant in K, lights up in Phase L) ── + b.Add(new(new KeyChord(Key.GraveAccent, ModifierMask.None), InputAction.CombatToggleCombat)); + // Melee mode (active when MeleeCombat scope pushed). + b.Add(new(new KeyChord(Key.Insert, ModifierMask.None), InputAction.CombatDecreaseAttackPower)); + b.Add(new(new KeyChord(Key.PageUp, ModifierMask.None), InputAction.CombatIncreaseAttackPower)); + b.Add(new(new KeyChord(Key.Delete, ModifierMask.None), InputAction.CombatLowAttack)); + b.Add(new(new KeyChord(Key.End, ModifierMask.None), InputAction.CombatMediumAttack)); + b.Add(new(new KeyChord(Key.PageDown, ModifierMask.None), InputAction.CombatHighAttack)); + // Missile + Magic + Spell-tab — same chords; resolved by scope at + // runtime per InputDispatcher's stack lookup. Add the bindings; + // subscribers arrive in Phase L when CombatState.CurrentMode is + // wired. + b.Add(new(new KeyChord(Key.Insert, ModifierMask.None), InputAction.CombatDecreaseMissileAccuracy)); + b.Add(new(new KeyChord(Key.PageUp, ModifierMask.None), InputAction.CombatIncreaseMissileAccuracy)); + b.Add(new(new KeyChord(Key.Delete, ModifierMask.None), InputAction.CombatAimLow)); + b.Add(new(new KeyChord(Key.End, ModifierMask.None), InputAction.CombatAimMedium)); + b.Add(new(new KeyChord(Key.PageDown, ModifierMask.None), InputAction.CombatAimHigh)); + b.Add(new(new KeyChord(Key.Insert, ModifierMask.None), InputAction.CombatPrevSpellTab)); + b.Add(new(new KeyChord(Key.PageUp, ModifierMask.None), InputAction.CombatNextSpellTab)); + b.Add(new(new KeyChord(Key.Delete, ModifierMask.None), InputAction.CombatPrevSpell)); + b.Add(new(new KeyChord(Key.End, ModifierMask.None), InputAction.CombatCastCurrentSpell)); + b.Add(new(new KeyChord(Key.PageDown, ModifierMask.None), InputAction.CombatNextSpell)); + b.Add(new(new KeyChord(Key.Insert, ModifierMask.Ctrl), InputAction.CombatFirstSpellTab)); + b.Add(new(new KeyChord(Key.PageUp, ModifierMask.Ctrl), InputAction.CombatLastSpellTab)); + b.Add(new(new KeyChord(Key.Delete, ModifierMask.Ctrl), InputAction.CombatFirstSpell)); + b.Add(new(new KeyChord(Key.PageDown, ModifierMask.Ctrl), InputAction.CombatLastSpell)); + for (int i = 1; i <= 9; i++) + { + var k = (Key)((int)Key.Number0 + i); + var action = (InputAction)((int)InputAction.UseSpellSlot_1 + i - 1); + b.Add(new(new KeyChord(k, ModifierMask.None), action)); + } + + // ── Emotes ────────────────────────────────────────────── + b.Add(new(new KeyChord(Key.U, ModifierMask.None), InputAction.Cry)); + b.Add(new(new KeyChord(Key.I, ModifierMask.None), InputAction.Laugh)); + b.Add(new(new KeyChord(Key.J, ModifierMask.None), InputAction.Wave)); + b.Add(new(new KeyChord(Key.O, ModifierMask.None), InputAction.Cheer)); + b.Add(new(new KeyChord(Key.K, ModifierMask.None), InputAction.PointState)); + + // ── Camera ───────────────────────────────────────────── + b.Add(new(new KeyChord(Key.KeypadDivide, ModifierMask.None), InputAction.CameraActivateAlternateMode)); + b.Add(new(new KeyChord(Key.F2, ModifierMask.None), InputAction.CameraActivateAlternateMode)); + // CameraInstantMouseLook (MMB hold) — encoded as a mouse chord + // via the K.1a Device=1 convention. K.2 lights up the actual + // camera+yaw drive logic. + b.Add(new( + new KeyChord(InputDispatcher.MouseButtonToKey(MouseButton.Middle), ModifierMask.None, Device: 1), + InputAction.CameraInstantMouseLook, + ActivationType.Hold)); + // Numpad cluster. + b.Add(new(new KeyChord(Key.Keypad4, ModifierMask.None), InputAction.CameraRotateLeft)); + b.Add(new(new KeyChord(Key.Keypad6, ModifierMask.None), InputAction.CameraRotateRight)); + b.Add(new(new KeyChord(Key.Keypad8, ModifierMask.None), InputAction.CameraRotateUp)); + b.Add(new(new KeyChord(Key.Keypad2, ModifierMask.None), InputAction.CameraRotateDown)); + b.Add(new(new KeyChord(Key.KeypadSubtract, ModifierMask.None), InputAction.CameraMoveToward)); + b.Add(new(new KeyChord(Key.KeypadAdd, ModifierMask.None), InputAction.CameraMoveAway)); + b.Add(new(new KeyChord(Key.Keypad0, ModifierMask.None), InputAction.CameraViewDefault)); + b.Add(new(new KeyChord(Key.KeypadDecimal, ModifierMask.None), InputAction.CameraViewFirstPerson)); + b.Add(new(new KeyChord(Key.Keypad5, ModifierMask.None), InputAction.CameraViewLookDown)); + b.Add(new(new KeyChord(Key.KeypadEnter, ModifierMask.None), InputAction.CameraViewMapMode)); + + // ── Mouse selection ──────────────────────────────────── + // Retail keymap: SelectLeft = LMB, SelectRight = RMB, SelectMid = MMB, + // and the doubles fire on MouseDblClick. Encoded as Device=1 chords; + // dispatcher resolves the mouse button via MouseButtonToKey. + b.Add(new( + new KeyChord(InputDispatcher.MouseButtonToKey(MouseButton.Left), ModifierMask.None, Device: 1), + InputAction.SelectLeft)); + b.Add(new( + new KeyChord(InputDispatcher.MouseButtonToKey(MouseButton.Right), ModifierMask.None, Device: 1), + InputAction.SelectRight)); + b.Add(new( + new KeyChord(InputDispatcher.MouseButtonToKey(MouseButton.Middle), ModifierMask.None, Device: 1), + InputAction.SelectMid)); + b.Add(new( + new KeyChord(InputDispatcher.MouseButtonToKey(MouseButton.Left), ModifierMask.None, Device: 1), + InputAction.SelectDblLeft, ActivationType.DoubleClick)); + b.Add(new( + new KeyChord(InputDispatcher.MouseButtonToKey(MouseButton.Right), ModifierMask.None, Device: 1), + InputAction.SelectDblRight, ActivationType.DoubleClick)); + b.Add(new( + new KeyChord(InputDispatcher.MouseButtonToKey(MouseButton.Middle), ModifierMask.None, Device: 1), + InputAction.SelectDblMid, ActivationType.DoubleClick)); + + // ── Scrollable ───────────────────────────────────────── + // Mouse wheel → ScrollUp/Down handled by dispatcher's OnScroll path. + b.Add(new(new KeyChord(Key.Up, ModifierMask.Ctrl), InputAction.ScrollUp)); + b.Add(new(new KeyChord(Key.Down, ModifierMask.Ctrl), InputAction.ScrollDown)); + + // ── Acdream debug actions: relocated to Ctrl+F* to avoid retail + // conflicts. AcdreamToggleFlyMode + AcdreamTogglePlayerMode have + // NO keyboard binding in retail-default; K.2 adds a DebugPanel + // button for free-fly toggle and player-mode is auto-entered at + // login. AcdreamRmbOrbitHold is intentionally absent — retail + // binds RMB to SelectRight; the chase-camera orbit lives behind + // a debug-mode flag in K.2. + b.Add(new(new KeyChord(Key.F1, ModifierMask.Ctrl), InputAction.AcdreamToggleDebugPanel)); + b.Add(new(new KeyChord(Key.F2, ModifierMask.Ctrl), InputAction.AcdreamToggleCollisionWires)); + b.Add(new(new KeyChord(Key.F3, ModifierMask.Ctrl), InputAction.AcdreamDumpNearby)); + b.Add(new(new KeyChord(Key.F7, ModifierMask.Ctrl), InputAction.AcdreamCycleTimeOfDay)); + b.Add(new(new KeyChord(Key.F8, ModifierMask.Ctrl), InputAction.AcdreamSensitivityDown)); + b.Add(new(new KeyChord(Key.F9, ModifierMask.Ctrl), InputAction.AcdreamSensitivityUp)); + b.Add(new(new KeyChord(Key.F10, ModifierMask.Ctrl), InputAction.AcdreamCycleWeather)); + + return b; + } + + /// + /// Load bindings from . If the file doesn't + /// exist, returns . If the file exists + /// but is missing actions present in the current default set, those + /// missing actions get the default binding (merge-over-defaults). + /// On corrupt file: warns + returns RetailDefaults without writing + /// (don't blow away the user's file silently). + /// + public static KeyBindings LoadOrDefault(string path) + { + if (!File.Exists(path)) return RetailDefaults(); + try + { + using var stream = File.OpenRead(path); + var doc = JsonDocument.Parse(stream); + var root = doc.RootElement; + // version is currently advisory — read so future migrations + // can branch on it; the field's presence is non-fatal. + _ = root.TryGetProperty("version", out var vEl) ? vEl.GetInt32() : 0; + + var defaults = RetailDefaults(); + var loaded = new KeyBindings(); + + if (root.TryGetProperty("actions", out var actionsEl) + && actionsEl.ValueKind == JsonValueKind.Object) + { + foreach (var actionProp in actionsEl.EnumerateObject()) + { + if (!Enum.TryParse(actionProp.Name, out var action)) + continue; // unknown action → skip + if (actionProp.Value.ValueKind != JsonValueKind.Array) continue; + foreach (var bindingEl in actionProp.Value.EnumerateArray()) + { + if (!bindingEl.TryGetProperty("key", out var keyEl)) continue; + var key = keyEl.GetString(); + if (key is null) continue; + if (!Enum.TryParse(key, out var silkKey)) + continue; // unknown key → skip + var mods = ParseModifiers(bindingEl); + var activation = ActivationType.Press; + if (bindingEl.TryGetProperty("activation", out var actEl) + && actEl.ValueKind == JsonValueKind.String + && Enum.TryParse(actEl.GetString(), out var parsedAct)) + { + activation = parsedAct; + } + byte device = 0; + if (bindingEl.TryGetProperty("device", out var dEl) + && dEl.ValueKind == JsonValueKind.Number) + { + device = (byte)dEl.GetInt32(); + } + loaded.Add(new(new KeyChord(silkKey, mods, device), action, activation)); + } + } + } + + // Merge: any action that has bindings in defaults but NOT in + // loaded picks up the defaults. Preserves user customizations + // for actions they DID rebind, while not silently losing + // newly-added actions if the user file is older. + foreach (var actionInDefaults in Enum.GetValues()) + { + if (!loaded.ForAction(actionInDefaults).Any() + && defaults.ForAction(actionInDefaults).Any()) + { + foreach (var def in defaults.ForAction(actionInDefaults)) + loaded.Add(def); + } + } + + return loaded; + } + catch (Exception ex) + { + Console.WriteLine($"keybinds: failed to load {path}: {ex.Message} — using retail defaults"); + return RetailDefaults(); + } + } + + /// + /// Persist this binding set to as JSON. + /// Format: { "version": N, "actions": { "ActionName": [ {key,mod,activation,device}... ] } }. + /// Sorted keys for deterministic diffs. + /// + public void SaveToFile(string path) + { + var dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); + + var actions = new SortedDictionary>(StringComparer.Ordinal); + foreach (var binding in _bindings) + { + if (!actions.TryGetValue(binding.Action.ToString(), out var list)) + { + list = new List(); + actions[binding.Action.ToString()] = list; + } + var entry = new SortedDictionary(StringComparer.Ordinal) + { + ["key"] = binding.Chord.Key.ToString(), + }; + if (binding.Chord.Modifiers != ModifierMask.None) + entry["mod"] = binding.Chord.Modifiers.ToString(); + if (binding.Chord.Device != 0) + entry["device"] = (int)binding.Chord.Device; + if (binding.Activation != ActivationType.Press) + entry["activation"] = binding.Activation.ToString(); + list.Add(entry); + } + + var root = new SortedDictionary(StringComparer.Ordinal) + { + ["version"] = CurrentSchemaVersion, + ["actions"] = actions, + }; + var json = JsonSerializer.Serialize(root, new JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(path, json); + } + + private static ModifierMask ParseModifiers(JsonElement bindingEl) + { + if (!bindingEl.TryGetProperty("mod", out var modEl)) return ModifierMask.None; + if (modEl.ValueKind != JsonValueKind.String) return ModifierMask.None; + var modString = modEl.GetString(); + if (string.IsNullOrEmpty(modString)) return ModifierMask.None; + var result = ModifierMask.None; + foreach (var part in modString.Split( + new[] { '|', ',', ' ' }, + StringSplitOptions.RemoveEmptyEntries)) + { + if (Enum.TryParse(part, ignoreCase: true, out var single)) + result |= single; + } + return result; + } + + /// + /// Default path: %LOCALAPPDATA%\acdream\keybinds.json. + /// + public static string DefaultPath() => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "acdream", + "keybinds.json"); } diff --git a/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsJsonTests.cs b/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsJsonTests.cs new file mode 100644 index 0000000..1b51c37 --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsJsonTests.cs @@ -0,0 +1,234 @@ +using System.IO; +using System.Linq; +using AcDream.UI.Abstractions.Input; +using Silk.NET.Input; + +namespace AcDream.UI.Abstractions.Tests.Input; + +/// +/// K.1c: / +/// round-trip + version migration. +/// Pins schema-stable behavior so future K.1d / Phase L panel-binding +/// edits don't accidentally break existing user keymap files. +/// +public class KeyBindingsJsonTests +{ + private static string TempFile(string suffix = ".json") + { + var f = Path.Combine(Path.GetTempPath(), + $"acdream_kb_test_{System.Guid.NewGuid():N}{suffix}"); + return f; + } + + [Fact] + public void LoadOrDefault_missing_file_returns_RetailDefaults() + { + var path = TempFile(); + // No file written. + var loaded = KeyBindings.LoadOrDefault(path); + var defaults = KeyBindings.RetailDefaults(); + Assert.Equal(defaults.All.Count, loaded.All.Count); + } + + [Fact] + public void Roundtrip_preserves_every_binding() + { + var path = TempFile(); + try + { + var original = KeyBindings.RetailDefaults(); + original.SaveToFile(path); + + var loaded = KeyBindings.LoadOrDefault(path); + + Assert.Equal(original.All.Count, loaded.All.Count); + // Every binding from the original must round-trip with structural + // equality (chord + action + activation). + foreach (var b in original.All) + { + Assert.Contains(loaded.All, x => + x.Chord == b.Chord + && x.Action == b.Action + && x.Activation == b.Activation); + } + } + finally + { + if (File.Exists(path)) File.Delete(path); + } + } + + [Fact] + public void LoadOrDefault_corrupt_file_returns_RetailDefaults() + { + var path = TempFile(); + try + { + File.WriteAllText(path, "{ this is not valid JSON :: garbage"); + + var loaded = KeyBindings.LoadOrDefault(path); + var defaults = KeyBindings.RetailDefaults(); + Assert.Equal(defaults.All.Count, loaded.All.Count); + } + finally + { + if (File.Exists(path)) File.Delete(path); + } + } + + [Fact] + public void LoadOrDefault_corrupt_file_does_NOT_overwrite_user_file() + { + var path = TempFile(); + try + { + const string corrupt = "{ this is not valid JSON :: garbage"; + File.WriteAllText(path, corrupt); + + _ = KeyBindings.LoadOrDefault(path); + + // The corrupt file must remain untouched on disk so the user + // can recover or hand-edit. We never silently blow it away. + Assert.Equal(corrupt, File.ReadAllText(path)); + } + finally + { + if (File.Exists(path)) File.Delete(path); + } + } + + [Fact] + public void Custom_binding_for_one_action_keeps_defaults_for_others() + { + var path = TempFile(); + try + { + // User customizes ONE action — replace MovementForward with Q. + var custom = new KeyBindings(); + custom.Add(new(new KeyChord(Key.Q, ModifierMask.None), InputAction.MovementForward)); + custom.SaveToFile(path); + + var loaded = KeyBindings.LoadOrDefault(path); + + // Custom: MovementForward → Q (only — the user's file + // overrode all defaults for that action). + var fwd = loaded.ForAction(InputAction.MovementForward).ToList(); + Assert.Single(fwd); + Assert.Equal(new KeyChord(Key.Q, ModifierMask.None), fwd[0].Chord); + + // Default-merged: MovementBackup still has its retail bindings + // because the user file didn't customize it. + var back = loaded.ForAction(InputAction.MovementBackup).ToList(); + Assert.Contains(back, x => x.Chord == new KeyChord(Key.X, ModifierMask.None)); + } + finally + { + if (File.Exists(path)) File.Delete(path); + } + } + + [Fact] + public void LoadOrDefault_handles_version_zero_legacy_file() + { + // A pretend "old schema" file with version=0 + just one action. + // Should still parse — unknown fields are ignored, missing + // actions get default-merged. + var path = TempFile(); + try + { + const string legacyJson = """ + { + "version": 0, + "actions": { + "MovementForward": [ + { "key": "W", "mod": "None" } + ] + } + } + """; + File.WriteAllText(path, legacyJson); + + var loaded = KeyBindings.LoadOrDefault(path); + + // The custom binding survives. + var fwd = loaded.ForAction(InputAction.MovementForward).ToList(); + Assert.Single(fwd); + + // Default-merged: the user file didn't define MovementBackup + // so it gets the retail default chords. + var back = loaded.ForAction(InputAction.MovementBackup).ToList(); + Assert.NotEmpty(back); + } + finally + { + if (File.Exists(path)) File.Delete(path); + } + } + + [Fact] + public void LoadOrDefault_preserves_Hold_activation() + { + var path = TempFile(); + try + { + var original = new KeyBindings(); + original.Add(new( + new KeyChord(Key.ShiftLeft, ModifierMask.None), + InputAction.MovementWalkMode, + ActivationType.Hold)); + original.SaveToFile(path); + + var loaded = KeyBindings.LoadOrDefault(path); + var binds = loaded.ForAction(InputAction.MovementWalkMode).ToList(); + // The user binding is preserved with Hold activation. + Assert.Contains(binds, x => + x.Chord.Key == Key.ShiftLeft + && x.Activation == ActivationType.Hold); + } + finally + { + if (File.Exists(path)) File.Delete(path); + } + } + + [Fact] + public void LoadOrDefault_unknown_action_name_skipped_silently() + { + var path = TempFile(); + try + { + const string json = """ + { + "version": 1, + "actions": { + "NotARealActionName": [ + { "key": "W", "mod": "None" } + ], + "MovementForward": [ + { "key": "Q", "mod": "None" } + ] + } + } + """; + File.WriteAllText(path, json); + + var loaded = KeyBindings.LoadOrDefault(path); + // The known action loaded; unknown action skipped without + // throwing. + var fwd = loaded.ForAction(InputAction.MovementForward).ToList(); + Assert.Contains(fwd, x => x.Chord.Key == Key.Q); + } + finally + { + if (File.Exists(path)) File.Delete(path); + } + } + + [Fact] + public void DefaultPath_lives_under_LocalAppData_acdream() + { + var path = KeyBindings.DefaultPath(); + Assert.Contains("acdream", path); + Assert.EndsWith("keybinds.json", path); + } +} diff --git a/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsRetailTests.cs b/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsRetailTests.cs new file mode 100644 index 0000000..432f52c --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsRetailTests.cs @@ -0,0 +1,200 @@ +using System.Linq; +using AcDream.UI.Abstractions.Input; +using Silk.NET.Input; + +namespace AcDream.UI.Abstractions.Tests.Input; + +/// +/// K.1c: now returns the full +/// retail-faithful preset, byte-precise to +/// docs/research/named-retail/retail-default.keymap.txt. These +/// tests pin the canonical mappings the user-visible keyboard layout +/// shifts to (W/X movement instead of W/S, Z/C strafe instead of Z/X, +/// Tab → ToggleChatEntry instead of fly-mode toggle, etc). +/// +public class KeyBindingsRetailTests +{ + [Fact] + public void MovementForward_bound_to_W_and_Up() + { + var b = KeyBindings.RetailDefaults(); + var binds = b.ForAction(InputAction.MovementForward).ToList(); + Assert.Contains(binds, x => x.Chord == new KeyChord(Key.W, ModifierMask.None)); + Assert.Contains(binds, x => x.Chord == new KeyChord(Key.Up, ModifierMask.None)); + } + + [Fact] + public void MovementBackup_bound_to_X_and_Down_NOT_S() + { + var b = KeyBindings.RetailDefaults(); + var binds = b.ForAction(InputAction.MovementBackup).ToList(); + Assert.Contains(binds, x => x.Chord == new KeyChord(Key.X, ModifierMask.None)); + Assert.Contains(binds, x => x.Chord == new KeyChord(Key.Down, ModifierMask.None)); + // Retail: S is NOT MovementBackup. S → MovementStop. + Assert.DoesNotContain(binds, x => x.Chord.Key == Key.S); + } + + [Fact] + public void S_key_is_MovementStop_in_retail() + { + var b = KeyBindings.RetailDefaults(); + var stop = b.Find(new KeyChord(Key.S, ModifierMask.None), ActivationType.Press); + Assert.NotNull(stop); + Assert.Equal(InputAction.MovementStop, stop!.Value.Action); + } + + [Fact] + public void MovementStrafeLeft_bound_to_Z_and_AltA_and_AltLeft() + { + var b = KeyBindings.RetailDefaults(); + var binds = b.ForAction(InputAction.MovementStrafeLeft).ToList(); + Assert.Contains(binds, x => x.Chord == new KeyChord(Key.Z, ModifierMask.None)); + Assert.Contains(binds, x => x.Chord == new KeyChord(Key.A, ModifierMask.Alt)); + Assert.Contains(binds, x => x.Chord == new KeyChord(Key.Left, ModifierMask.Alt)); + } + + [Fact] + public void MovementStrafeRight_bound_to_C_and_AltD_and_AltRight() + { + var b = KeyBindings.RetailDefaults(); + var binds = b.ForAction(InputAction.MovementStrafeRight).ToList(); + Assert.Contains(binds, x => x.Chord == new KeyChord(Key.C, ModifierMask.None)); + Assert.Contains(binds, x => x.Chord == new KeyChord(Key.D, ModifierMask.Alt)); + Assert.Contains(binds, x => x.Chord == new KeyChord(Key.Right, ModifierMask.Alt)); + } + + [Fact] + public void Sleeping_bound_to_B() + { + var b = KeyBindings.RetailDefaults(); + Assert.Contains(b.ForAction(InputAction.Sleeping), + x => x.Chord == new KeyChord(Key.B, ModifierMask.None)); + } + + [Fact] + public void MovementWalkMode_uses_Hold_activation() + { + var b = KeyBindings.RetailDefaults(); + var binds = b.ForAction(InputAction.MovementWalkMode).ToList(); + Assert.NotEmpty(binds); + Assert.All(binds, x => Assert.Equal(ActivationType.Hold, x.Activation)); + Assert.Contains(binds, x => x.Chord.Key == Key.ShiftLeft); + } + + [Fact] + public void ToggleChatEntry_bound_to_Tab_NOT_fly_toggle() + { + var b = KeyBindings.RetailDefaults(); + var hit = b.Find(new KeyChord(Key.Tab, ModifierMask.None), ActivationType.Press); + Assert.NotNull(hit); + Assert.Equal(InputAction.ToggleChatEntry, hit!.Value.Action); + } + + [Fact] + public void LOGOUT_is_Shift_Escape() + { + var b = KeyBindings.RetailDefaults(); + var hit = b.Find(new KeyChord(Key.Escape, ModifierMask.Shift), ActivationType.Press); + Assert.NotNull(hit); + Assert.Equal(InputAction.LOGOUT, hit!.Value.Action); + } + + [Fact] + public void EscapeKey_is_bare_Escape() + { + var b = KeyBindings.RetailDefaults(); + var hit = b.Find(new KeyChord(Key.Escape, ModifierMask.None), ActivationType.Press); + Assert.NotNull(hit); + Assert.Equal(InputAction.EscapeKey, hit!.Value.Action); + } + + [Fact] + public void UseQuickSlot_5_bound_to_Number5_with_and_without_Ctrl() + { + var b = KeyBindings.RetailDefaults(); + var binds = b.ForAction(InputAction.UseQuickSlot_5).ToList(); + Assert.Contains(binds, x => x.Chord == new KeyChord(Key.Number5, ModifierMask.None)); + Assert.Contains(binds, x => x.Chord == new KeyChord(Key.Number5, ModifierMask.Ctrl)); + } + + [Fact] + public void UseQuickSlot_18_bound_to_Alt_Number9() + { + var b = KeyBindings.RetailDefaults(); + var binds = b.ForAction(InputAction.UseQuickSlot_18).ToList(); + Assert.Contains(binds, x => x.Chord == new KeyChord(Key.Number9, ModifierMask.Alt)); + } + + [Fact] + public void TogglePluginManager_is_Shift_Ctrl_F1() + { + var b = KeyBindings.RetailDefaults(); + var binds = b.ForAction(InputAction.TogglePluginManager).ToList(); + Assert.Contains(binds, x => + x.Chord.Key == Key.F1 + && x.Chord.Modifiers.HasFlag(ModifierMask.Shift) + && x.Chord.Modifiers.HasFlag(ModifierMask.Ctrl)); + } + + [Fact] + public void Acdream_debug_actions_relocated_to_Ctrl_F_keys() + { + var b = KeyBindings.RetailDefaults(); + Assert.Contains(b.ForAction(InputAction.AcdreamToggleDebugPanel), + x => x.Chord == new KeyChord(Key.F1, ModifierMask.Ctrl)); + Assert.Contains(b.ForAction(InputAction.AcdreamToggleCollisionWires), + x => x.Chord == new KeyChord(Key.F2, ModifierMask.Ctrl)); + Assert.Contains(b.ForAction(InputAction.AcdreamDumpNearby), + x => x.Chord == new KeyChord(Key.F3, ModifierMask.Ctrl)); + } + + [Fact] + public void ToggleHelp_is_bare_F1_NOT_acdream_debug() + { + var b = KeyBindings.RetailDefaults(); + var hit = b.Find(new KeyChord(Key.F1, ModifierMask.None), ActivationType.Press); + Assert.NotNull(hit); + Assert.Equal(InputAction.ToggleHelp, hit!.Value.Action); + } + + [Fact] + public void Total_binding_count_is_at_least_80() + { + var b = KeyBindings.RetailDefaults(); + Assert.True(b.All.Count >= 80, + $"Expected at least 80 bindings, got {b.All.Count}"); + } + + [Fact] + public void Cry_emote_bound_to_U() + { + var b = KeyBindings.RetailDefaults(); + Assert.Contains(b.ForAction(InputAction.Cry), + x => x.Chord == new KeyChord(Key.U, ModifierMask.None)); + } + + [Fact] + public void CombatToggleCombat_bound_to_GraveAccent() + { + var b = KeyBindings.RetailDefaults(); + Assert.Contains(b.ForAction(InputAction.CombatToggleCombat), + x => x.Chord == new KeyChord(Key.GraveAccent, ModifierMask.None)); + } + + [Fact] + public void CameraActivateAlternateMode_bound_to_F2_and_NumpadDivide() + { + var b = KeyBindings.RetailDefaults(); + var binds = b.ForAction(InputAction.CameraActivateAlternateMode).ToList(); + Assert.Contains(binds, x => x.Chord == new KeyChord(Key.F2, ModifierMask.None)); + Assert.Contains(binds, x => x.Chord == new KeyChord(Key.KeypadDivide, ModifierMask.None)); + } + + [Fact] + public void ScrollUp_bound_to_Ctrl_Up() + { + var b = KeyBindings.RetailDefaults(); + Assert.Contains(b.ForAction(InputAction.ScrollUp), + x => x.Chord == new KeyChord(Key.Up, ModifierMask.Ctrl)); + } +} diff --git a/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsTests.cs b/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsTests.cs index cfc3565..3cb86d6 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsTests.cs @@ -97,13 +97,18 @@ public class KeyBindingsTests } [Fact] - public void RetailDefaults_proxies_AcdreamCurrentDefaults_in_K1a() + public void RetailDefaults_diverges_from_AcdreamCurrentDefaults_in_K1c() { - // K.1a stub — RetailDefaults() returns the acdream-current binds so - // K.1b cutover doesn't change behavior. K.1c flips this to the - // retail preset. + // K.1c flips RetailDefaults() to the retail-faithful preset. + // The acdream-current map remains accessible (for tests pinning + // the older WASD-only behavior), but RetailDefaults is now the + // canonical startup source. The two MUST differ — at minimum + // RetailDefaults binds X to Backup (retail) where AcdreamCurrent + // bound X to StrafeRight, and RetailDefaults binds Tab to + // ToggleChatEntry where AcdreamCurrent bound Tab to + // AcdreamTogglePlayerMode. var retail = KeyBindings.RetailDefaults(); var current = KeyBindings.AcdreamCurrentDefaults(); - Assert.Equal(current.All.Count, retail.All.Count); + Assert.NotEqual(current.All.Count, retail.All.Count); } }