fix(client): restore retail interaction parity
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:
parent
0c699240e0
commit
f6fe0f2a4f
151 changed files with 10162 additions and 1211 deletions
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue