fix(client): restore retail interaction parity
All checks were successful
CI / linux-portable (push) Successful in 3m27s
CI / windows-gate (push) Successful in 6m42s
CI / release (push) Successful in 2m12s

Harden keyboard and camera routing, inventory and vendor interactions, chat/emotes, relog portal flow, and paperdoll rendering. Add retail research, connected gate coverage, and release-gate validation.
This commit is contained in:
Erik 2026-08-26 20:45:11 +02:00
parent 0c699240e0
commit f6fe0f2a4f
151 changed files with 10162 additions and 1211 deletions

View file

@ -12,8 +12,8 @@ namespace AcDream.UI.Abstractions.Tests.Input;
/// non-modifier chord is reported via the supplied callback and the
/// dispatcher does NOT fire normal action events for that chord. Esc
/// cancels capture (callback receives a sentinel <c>default</c> chord).
/// Modifier-only key transitions don't complete capture — the user can
/// dial in Shift / Ctrl / Alt before pressing the trigger key.
/// A modifier key is captured on release when used alone, or remains a
/// modifier prefix when another key is pressed while it is held.
/// </summary>
public class InputDispatcherCaptureTests
{
@ -93,6 +93,23 @@ public class InputDispatcherCaptureTests
Assert.Equal(new KeyChord(Key.A, ModifierMask.Shift | ModifierMask.Ctrl), captured!.Value);
}
[Fact]
public void BeginCapture_modifier_released_alone_becomes_bare_primary_key()
{
var (dispatcher, kb, _, _, fired) = Build();
KeyChord? captured = null;
dispatcher.BeginCapture(chord => captured = chord);
kb.EmitKeyDown(Key.ShiftLeft, ModifierMask.Shift);
Assert.Null(captured);
kb.EmitKeyUp(Key.ShiftLeft, ModifierMask.Shift);
Assert.Equal(
new KeyChord(Key.ShiftLeft, ModifierMask.None),
captured);
Assert.Empty(fired);
}
[Fact]
public void BeginCapture_completes_with_modifier_state()
{
@ -106,6 +123,26 @@ public class InputDispatcherCaptureTests
Assert.Equal(new KeyChord(Key.A, ModifierMask.Ctrl), captured!.Value);
}
[Fact]
public void BeginCapture_consumes_mouse_button_as_retail_qualified_control()
{
var (dispatcher, _, mouse, bindings, fired) = Build();
var left = new KeyChord(
InputDispatcher.MouseButtonToKey(MouseButton.Left),
ModifierMask.Ctrl,
Device: 1);
bindings.Add(new Binding(left, InputAction.ToggleInventoryPanel));
mouse.WantCaptureMouse = true;
KeyChord? captured = null;
dispatcher.BeginCapture(chord => captured = chord);
mouse.EmitMouseDown(MouseButton.Left, ModifierMask.Ctrl);
Assert.Equal(left, captured);
Assert.False(dispatcher.IsCapturing);
Assert.Empty(fired);
}
[Fact]
public void CancelCapture_invokes_callback_with_default_chord_and_clears_state()
{

View file

@ -101,6 +101,22 @@ public class InputDispatcherTests
fired);
}
[Fact]
public void Same_scope_retail_duplicate_chord_fires_every_distinct_action()
{
var (_, kb, _, bindings, fired) = Build();
var chord = new KeyChord(Key.Number1, ModifierMask.Alt);
bindings.Add(new Binding(chord, InputAction.ToggleFloatingChatWindow1));
bindings.Add(new Binding(chord, InputAction.UseQuickSlot_10));
kb.EmitKeyDown(Key.Number1, ModifierMask.Alt);
Assert.Equal(
[(InputAction.ToggleFloatingChatWindow1, ActivationType.Press),
(InputAction.UseQuickSlot_10, ActivationType.Press)],
fired);
}
[Fact]
public void Changing_combat_scope_releases_hold_resolved_in_previous_scope()
{
@ -188,6 +204,30 @@ public class InputDispatcherTests
Assert.Empty(fired); // no longer held
}
[Fact]
public void RetailBareLeftShiftBinding_NormalizesSilkSelfModifierBit()
{
var kb = new FakeKeyboardSource();
var mouse = new FakeMouseSource();
var dispatcher = InputDispatcher.CreateDetached(
kb,
mouse,
KeyBindings.RetailDefaults());
dispatcher.Attach();
var fired = new List<(InputAction, ActivationType)>();
dispatcher.Fired += (action, activation) => fired.Add((action, activation));
kb.EmitKeyDown(Key.ShiftLeft, ModifierMask.Shift);
kb.EmitKeyUp(Key.ShiftLeft, ModifierMask.Shift);
Assert.Contains(
(InputAction.MovementWalkMode, ActivationType.Press),
fired);
Assert.Contains(
(InputAction.MovementWalkMode, ActivationType.Release),
fired);
}
[Fact]
public void Hold_callback_scope_change_DoesNotDispatchStaleSnapshotChord()
{

View file

@ -104,10 +104,19 @@ public class KeyBindingsJsonTests
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);
// A pre-v7 partial file customizes ONE action. Missing actions in
// those schemas mean "not stored yet", so they default-merge.
const string legacyJson = """
{
"version": 6,
"actions": {
"MovementForward": [
{ "key": "Q" }
]
}
}
""";
File.WriteAllText(path, legacyJson);
var loaded = KeyBindings.LoadOrDefault(path);
@ -128,6 +137,32 @@ public class KeyBindingsJsonTests
}
}
[Fact]
public void Roundtrip_preserves_explicitly_unbound_retail_action()
{
var path = TempFile();
try
{
KeyBindings defaults = KeyBindings.RetailDefaults();
var customized = new KeyBindings();
foreach (Binding binding in defaults.All)
{
if (binding.Action != InputAction.ToggleHelp)
customized.Add(binding);
}
customized.SaveToFile(path);
KeyBindings loaded = KeyBindings.LoadOrDefault(path);
Assert.Empty(loaded.ForAction(InputAction.ToggleHelp));
Assert.NotEmpty(loaded.ForAction(InputAction.ToggleOptionsPanel));
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
[Fact]
public void LoadOrDefault_handles_version_zero_legacy_file()
{
@ -193,17 +228,16 @@ public class KeyBindingsJsonTests
}
[Fact]
public void LoadOrDefault_migratesV1CtrlNumberQuickSlotFromUseToSelect()
public void LoadOrDefault_migratesV5CtrlNumberQuickSlotFromSelectBackToRetailUse()
{
var path = TempFile();
try
{
const string json = """
{
"version": 1,
"version": 5,
"actions": {
"UseQuickSlot_5": [
{ "key": "Number5" },
"SelectQuickSlot_5": [
{ "key": "Number5", "mod": "Ctrl" }
]
}
@ -214,9 +248,8 @@ public class KeyBindingsJsonTests
var loaded = KeyBindings.LoadOrDefault(path);
Assert.Equal(InputAction.UseQuickSlot_5,
loaded.Find(new KeyChord(Key.Number5, ModifierMask.None), ActivationType.Press)?.Action);
Assert.Equal(InputAction.SelectQuickSlot_5,
loaded.Find(new KeyChord(Key.Number5, ModifierMask.Ctrl), ActivationType.Press)?.Action);
Assert.Empty(loaded.ForAction(InputAction.SelectQuickSlot_5));
}
finally
{

View file

@ -76,9 +76,11 @@ public class KeyBindingsRetailTests
{
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);
Binding binding = Assert.Single(binds);
Assert.Equal(ActivationType.Hold, binding.Activation);
Assert.Equal(
new KeyChord(Key.ShiftLeft, ModifierMask.None),
binding.Chord);
}
[Fact]
@ -135,14 +137,15 @@ public class KeyBindingsRetailTests
}
[Fact]
public void QuickSlot_5_bareUsesAndCtrlSelects()
public void QuickSlot_5_BareAndCtrlBothUseRetailAction()
{
var b = KeyBindings.RetailDefaults();
var bare = b.Find(new KeyChord(Key.Number5, ModifierMask.None), ActivationType.Press);
var ctrl = b.Find(new KeyChord(Key.Number5, ModifierMask.Ctrl), ActivationType.Press);
Assert.Equal(InputAction.UseQuickSlot_5, bare?.Action);
Assert.Equal(InputAction.SelectQuickSlot_5, ctrl?.Action);
Assert.Equal(InputAction.UseQuickSlot_5, ctrl?.Action);
Assert.Empty(b.ForAction(InputAction.SelectQuickSlot_5));
}
[Fact]
@ -216,6 +219,18 @@ public class KeyBindingsRetailTests
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));
Assert.All(binds, x => Assert.Equal(ActivationType.Hold, x.Activation));
}
[Theory]
[InlineData(InputAction.CombatAimLow)]
[InlineData(InputAction.CombatAimMedium)]
[InlineData(InputAction.CombatAimHigh)]
public void Missile_aim_actions_use_retail_press_and_release_edges(InputAction action)
{
var binding = Assert.Single(KeyBindings.RetailDefaults().ForAction(action));
Assert.Equal(ActivationType.Hold, binding.Activation);
Assert.Equal(InputScope.MissileCombat, binding.Scope);
}
[Fact]