fix(interaction): publish pending keyboard pickups

Route F-key pickup through the same ItemHolder backpack placement entry point as double-click activation so the inventory reserves the destination slot before the request is sent.
This commit is contained in:
Erik 2026-07-21 07:08:30 +02:00
parent 047a4c83b5
commit 52dbb5749e
4 changed files with 43 additions and 17 deletions

View file

@ -69,7 +69,7 @@ Copy this block when adding a new issue:
## #231 — F-key pickup omits retail pending destination-slot presentation ## #231 — F-key pickup omits retail pending destination-slot presentation
**Status:** IN-PROGRESS — isolated as Slice 1 corrective commit **Status:** FIXED (2026-07-21) — keyboard pickup uses the shared ItemHolder path
**Severity:** MEDIUM **Severity:** MEDIUM
**Filed:** 2026-07-21 **Filed:** 2026-07-21
**Component:** selection / inventory / retained UI **Component:** selection / inventory / retained UI
@ -98,6 +98,11 @@ destination/placement pending reservation, then sends one authoritative request.
Server confirmation settles it; server failure removes it without speculative Server confirmation settles it; server failure removes it without speculative
ownership mutation. ownership mutation.
**Resolution:** `ItemInteractionController.PlaceWorldItemInBackpack` now owns
the shared pending-placement notification and request dispatch. Both
double-click activation and the F-key route enter that method, so they publish
the same destination container and placement before the server request.
--- ---
## #230 — Selection hits and deferred actions can cross live GUID incarnations ## #230 — Selection hits and deferred actions can cross live GUID incarnations

View file

@ -13588,7 +13588,8 @@ public sealed class GameWindow : IDisposable
case AcDream.UI.Abstractions.Input.InputAction.SelectionPickUp: case AcDream.UI.Abstractions.Input.InputAction.SelectionPickUp:
if (_selection.SelectedObjectId is uint pickupTarget) if (_selection.SelectedObjectId is uint pickupTarget)
_outboundInteractions.Enqueue(() => SendPickUp(pickupTarget)); _outboundInteractions.Enqueue(() =>
_itemInteractionController?.PlaceWorldItemInBackpack(pickupTarget));
else else
_debugVm?.AddToast("Nothing selected"); _debugVm?.AddToast("Nothing selected");
break; break;
@ -13973,9 +13974,6 @@ public sealed class GameWindow : IDisposable
Console.WriteLine($"[D.5.1] toolbar use-item guid=0x{guid:X8} seq={seq}"); Console.WriteLine($"[D.5.1] toolbar use-item guid=0x{guid:X8} seq={seq}");
} }
private void SendPickUp(uint itemGuid)
=> SendPickUp(itemGuid, _playerServerGuid, placement: 0);
private void SendPickUp(uint itemGuid, uint destinationContainerId, int placement) private void SendPickUp(uint itemGuid, uint destinationContainerId, int placement)
{ {
if (_liveSession is null if (_liveSession is null

View file

@ -303,6 +303,25 @@ public sealed class ItemInteractionController : IDisposable
return ExecuteUseActions(decision.Actions); return ExecuteUseActions(decision.Actions);
} }
/// <summary>
/// Retail keyboard pickup entry point. <c>CPlayerSystem::PlaceInBackpack</c>
/// publishes the waiting destination slot before issuing the move request,
/// exactly like double-click pickup through ItemHolder.
/// </summary>
public bool PlaceWorldItemInBackpack(uint itemGuid)
{
if (itemGuid == 0u || _placeInBackpack is null)
return false;
uint containerId = _backpackContainerId();
if (containerId == 0u)
containerId = _playerGuid();
const int placement = 0;
PendingBackpackPlacementRequested?.Invoke(itemGuid, containerId, placement);
_placeInBackpack(itemGuid, containerId, placement);
return true;
}
/// <summary> /// <summary>
/// Retail paperdoll drop entry point. The paperdoll resolves the exact /// Retail paperdoll drop entry point. The paperdoll resolves the exact
/// target side/location; this shared interaction owner performs AutoWield's /// target side/location; this shared interaction owner performs AutoWield's
@ -418,18 +437,7 @@ public sealed class ItemInteractionController : IDisposable
switch (action.Kind) switch (action.Kind)
{ {
case ItemPolicyActionKind.PlaceInBackpack: case ItemPolicyActionKind.PlaceInBackpack:
if (_placeInBackpack is null) acted |= PlaceWorldItemInBackpack(action.ObjectId);
break;
uint containerId = _backpackContainerId();
if (containerId == 0u)
containerId = _playerGuid();
const int placement = 0;
PendingBackpackPlacementRequested?.Invoke(
action.ObjectId,
containerId,
placement);
_placeInBackpack(action.ObjectId, containerId, placement);
acted = true;
break; break;
case ItemPolicyActionKind.WieldRight: case ItemPolicyActionKind.WieldRight:
case ItemPolicyActionKind.WieldLeft: case ItemPolicyActionKind.WieldLeft:

View file

@ -1419,6 +1419,21 @@ public sealed class ItemInteractionControllerTests
Assert.Equal(2, h.Uses.Count); Assert.Equal(2, h.Uses.Count);
} }
[Fact]
public void KeyboardPickup_PublishesPendingDestinationBeforeRequest()
{
var h = new Harness();
const uint item = 0x70000A0Bu;
var pending = new List<(uint Item, uint Container, int Placement)>();
h.Controller.PendingBackpackPlacementRequested +=
(objectId, container, placement) => pending.Add((objectId, container, placement));
Assert.True(h.Controller.PlaceWorldItemInBackpack(item));
Assert.Equal(new[] { (item, Player, 0) }, pending);
Assert.Equal(new[] { (item, Player, 0) }, h.BackpackPlacements);
}
[Fact] [Fact]
public void ResetSession_ClearsTargetBusyThrottleAndConsumedClickState() public void ResetSession_ClearsTargetBusyThrottleAndConsumedClickState()
{ {