feat(ui): complete retail item drop branches

Give retained buttons a reusable item-drop seam, wire the toolbar backpack target, and port retail stack-merge legality, capacity clamping, wire dispatch, destination selection, and immediate shortcut rekey notice. Record the live ACE merge gate and keep split quantity under AP-101.

Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-11 09:46:32 +02:00
parent 3281e0acb4
commit dc1649c493
19 changed files with 479 additions and 28 deletions

View file

@ -127,6 +127,8 @@ public sealed class ItemInteractionController : IDisposable
public int BusyCount => _busyCount;
public bool CanMakeInventoryRequest => _readyForInventoryRequest() && _busyCount == 0;
/// <summary>
/// Raised for retail confirmation/auxiliary actions whose retained panel is
/// owned outside this controller (PK/NPK altar, volatile rare, trade, salvage).

View file

@ -58,6 +58,8 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
private readonly Action<uint>? _sendUse;
private readonly Action<uint>? _sendNoLongerViewing;
private readonly Action<uint, uint, int>? _sendPutItemInContainer; // (item, container, placement)
private readonly Action<uint, uint, uint>? _sendStackableMerge;
private readonly Action<uint, uint>? _notifyMergeAttempt;
private readonly ItemInteractionController? _itemInteraction;
private bool _disposed;
@ -80,6 +82,8 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
Action<uint>? sendUse,
Action<uint>? sendNoLongerViewing,
Action<uint, uint, int>? sendPutItemInContainer,
Action<uint, uint, uint>? sendStackableMerge,
Action<uint, uint>? notifyMergeAttempt,
ItemInteractionController? itemInteraction,
Action? onClose)
{
@ -91,6 +95,8 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
_sendUse = sendUse;
_sendNoLongerViewing = sendNoLongerViewing;
_sendPutItemInContainer = sendPutItemInContainer;
_sendStackableMerge = sendStackableMerge;
_notifyMergeAttempt = notifyMergeAttempt;
_itemInteraction = itemInteraction;
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
@ -197,12 +203,15 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
Action<uint>? sendUse = null,
Action<uint>? sendNoLongerViewing = null,
Action<uint, uint, int>? sendPutItemInContainer = null,
Action<uint, uint, uint>? sendStackableMerge = null,
Action<uint, uint>? notifyMergeAttempt = null,
ItemInteractionController? itemInteraction = null,
Action? onClose = null)
=> new InventoryController(layout, objects, playerGuid, iconIds, strength, selection,
ownerName, datFont,
contentsEmptySprite, sideBagEmptySprite, mainPackEmptySprite,
sendUse, sendNoLongerViewing, sendPutItemInContainer, itemInteraction,
sendUse, sendNoLongerViewing, sendPutItemInContainer,
sendStackableMerge, notifyMergeAttempt, itemInteraction,
onClose);
private void OnObjectChanged(ClientObject o) { if (Concerns(o)) Populate(); }
@ -417,6 +426,14 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
uint item = payload.ObjId;
if (item == 0) return;
// UIElement_ItemList::AcceptDragObject tries ItemHolder::AttemptMerge first
// when a physical item is released on an occupied inventory cell. A legal
// merge consumes the drop; an illegal merge falls through to insert/move.
if (targetList == _contentsGrid
&& targetCell.ItemId != 0
&& TryMergeStacks(item, targetCell.ItemId))
return;
uint container; int placement;
if (targetList == _contentsGrid)
{
@ -439,6 +456,39 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
_sendPutItemInContainer?.Invoke(item, container, placement);
}
private bool TryMergeStacks(uint sourceId, uint targetId)
{
if (_sendStackableMerge is null
|| _objects.Get(sourceId) is not { } source
|| _objects.Get(targetId) is not { } target)
return false;
StackMergePlan? plan = StackMergePlanner.Plan(
new StackMergeItem(
source.ObjectId,
source.WeenieClassId,
source.StackSize,
source.StackSizeMax,
source.TradeState),
new StackMergeItem(
target.ObjectId,
target.WeenieClassId,
target.StackSize,
target.StackSizeMax,
target.TradeState),
_itemInteraction?.CanMakeInventoryRequest ?? true,
requestedAmount: Math.Max(1, source.StackSize));
if (plan is not { } merge)
return false;
_sendStackableMerge(merge.SourceObjectId, merge.TargetObjectId, merge.Amount);
// ItemHolder::AttemptMerge broadcasts this immediately after UIAttemptMerge;
// it is an attempt notice, not a later server-confirm event.
_notifyMergeAttempt?.Invoke(merge.SourceObjectId, merge.TargetObjectId);
_selection.Select(merge.TargetObjectId, SelectionChangeSource.Inventory);
return true;
}
/// <summary>True only when we KNOW the container is full (capacity known + contents indexed). A
/// closed bag (unknown count) returns false → advisory accept; the server is authoritative.</summary>
private bool IsContainerFull(uint container)

View file

@ -72,6 +72,8 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
private readonly Action<uint>? _sendRemoveShortcut; // (index)
private readonly ItemInteractionController? _itemInteraction;
private readonly Action<uint>? _selectItem;
private readonly Func<uint>? _playerGuid;
private readonly Action<uint, uint, int>? _sendPutItemInContainer;
private bool _disposed;
// Digit sprite DID arrays for slot labels (top row, numbers 1-9).
@ -100,7 +102,9 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
Action<ShortcutEntry>? sendAddShortcut = null,
Action<uint>? sendRemoveShortcut = null,
Action? toggleCombat = null,
Action<uint>? selectItem = null)
Action<uint>? selectItem = null,
Func<uint>? playerGuid = null,
Action<uint, uint, int>? sendPutItemInContainer = null)
{
_repo = repo;
_combatState = combatState;
@ -114,6 +118,8 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
_sendAddShortcut = sendAddShortcut;
_sendRemoveShortcut = sendRemoveShortcut;
_selectItem = selectItem;
_playerGuid = playerGuid;
_sendPutItemInContainer = sendPutItemInContainer;
for (int i = 0; i < SlotIds.Length; i++)
{
@ -145,6 +151,15 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
_characterButton = layout.FindElement(CharacterButtonId) as UiButton;
_inventoryButton = layout.FindElement(InventoryButtonId) as UiButton;
if (_inventoryButton is not null)
{
// gmToolbarUI::HandleInventoryButtonDragOver @ 0x004BD180 uses the
// authored child overlay's Accept state (0x10000046 = 0x060011F7).
// UiButton owns the retained drop-target seam; the panel owns policy.
_inventoryButton.ItemDragAcceptSprite = 0x060011F7u;
_inventoryButton.OnItemDragOver = InventoryButtonDragOver;
_inventoryButton.OnItemDrop = HandleInventoryButtonDrop;
}
// Hide target-object meters + stack slider (gmToolbarUI::PostInit).
foreach (var id in HiddenIds)
@ -237,11 +252,14 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
Action<ShortcutEntry>? sendAddShortcut = null,
Action<uint>? sendRemoveShortcut = null,
Action? toggleCombat = null,
Action<uint>? selectItem = null)
Action<uint>? selectItem = null,
Func<uint>? playerGuid = null,
Action<uint, uint, int>? sendPutItemInContainer = null)
{
var c = new ToolbarController(layout, repo, shortcuts, iconIds, useItem, combatState,
peaceDigits, warDigits, emptyDigits, itemInteraction,
sendAddShortcut, sendRemoveShortcut, toggleCombat, selectItem);
sendAddShortcut, sendRemoveShortcut, toggleCombat, selectItem,
playerGuid, sendPutItemInContainer);
c.Populate();
return c;
}
@ -392,6 +410,24 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
};
}
private static ItemDragAcceptance InventoryButtonDragOver(ItemDragPayload payload)
=> payload.ObjId != 0 && payload.SourceKind != ItemDragSource.ShortcutBar
? ItemDragAcceptance.Accept
: ItemDragAcceptance.None;
private void HandleInventoryButtonDrop(ItemDragPayload payload)
{
// gmToolbarUI::HandleDropRelease @ 0x004BE7C0 inventory-button branch:
// only fresh physical items are placed into the player's main inventory.
// A shortcut alias remains neutral and its remove-on-lift result stands.
if (payload.ObjId == 0 || payload.SourceKind == ItemDragSource.ShortcutBar)
return;
uint player = _playerGuid?.Invoke() ?? 0u;
if (player == 0 || _repo.Get(payload.ObjId) is null)
return;
_sendPutItemInContainer?.Invoke(payload.ObjId, player, 0);
}
/// <summary>
/// Port of <c>gmToolbarUI::UseShortcut @ 0x004BD350</c>. Target mode
/// is one-shot and precedes both normal use and selection.

View file

@ -53,7 +53,9 @@ public sealed record ToolbarRuntimeBindings(
Func<uint, float> HealthPercent,
Func<uint, bool> HasHealth,
Func<uint, uint> StackSize,
Action<uint> SendQueryHealth);
Action<uint> SendQueryHealth,
Func<uint> PlayerGuid,
Action<uint, uint, int>? SendPutItemInContainer);
public sealed record CharacterRuntimeBindings(CharacterSheetProvider Provider);
@ -65,6 +67,7 @@ public sealed record InventoryRuntimeBindings(
Action<uint>? SendUse,
Action<uint>? SendNoLongerViewing,
Action<uint, uint, int>? SendPutItemInContainer,
Action<uint, uint, uint>? SendStackableMerge,
Action<uint, uint>? SendWield,
ItemInteractionController ItemInteraction,
SelectionState Selection);
@ -365,7 +368,9 @@ public sealed class RetailUiRuntime : IDisposable
layout, b.Objects, b.Shortcuts, b.ResolveIcon, b.UseItem, b.Combat,
peace, war, empty, b.ItemInteraction, b.SendAddShortcut, b.SendRemoveShortcut,
toggleCombat: b.ToggleCombat,
selectItem: guid => b.Selection.Select(guid, SelectionChangeSource.Toolbar));
selectItem: guid => b.Selection.Select(guid, SelectionChangeSource.Toolbar),
playerGuid: b.PlayerGuid,
sendPutItemInContainer: b.SendPutItemInContainer);
ToolbarInputController = new ToolbarInputController(ToolbarController, b.Selection);
SelectedObjectController = Layout.SelectedObjectController.Bind(
layout,
@ -583,11 +588,15 @@ public sealed class RetailUiRuntime : IDisposable
}
InventoryRuntimeBindings b = _bindings.Inventory;
Action<uint, uint>? notifyMergeAttempt = ToolbarController is null
? null
: ToolbarController.ReplaceFullyMergedShortcut;
InventoryController inventory = InventoryController.Bind(
layout, b.Objects, b.PlayerGuid, b.ResolveIcon, b.Strength, b.Selection,
_bindings.Assets.DefaultFont, _bindings.Character.Provider.CharacterName,
contents, sideBag, mainPack, b.SendUse, b.SendNoLongerViewing,
b.SendPutItemInContainer, b.ItemInteraction,
b.SendPutItemInContainer, b.SendStackableMerge,
notifyMergeAttempt, b.ItemInteraction,
() => CloseWindow(WindowNames.Inventory));
PaperdollController paperdoll = PaperdollController.Bind(
layout, b.Objects, b.PlayerGuid, b.ResolveIcon, b.Selection, b.SendWield,

View file

@ -45,6 +45,19 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
/// <summary>Optional click handler. Wired by the controller (e.g. chat Submit, ToggleMaximize).</summary>
public Action? OnClick { get; set; }
/// <summary>
/// Optional item-drop target callbacks. Retail uses ordinary buttons as drop surfaces
/// in a few panels (notably gmToolbarUI's inventory button), so this belongs on the
/// retained button rather than in UiRoot or a panel-specific hit-test workaround.
/// </summary>
public Func<ItemDragPayload, ItemDragAcceptance>? OnItemDragOver { get; set; }
public Action<ItemDragPayload>? OnItemDrop { get; set; }
public uint ItemDragAcceptSprite { get; set; }
public uint ItemDragRejectSprite { get; set; }
private ItemDragAcceptance _itemDragAcceptance;
internal ItemDragAcceptance ItemDragAcceptanceForTest => _itemDragAcceptance;
/// <summary>The dat element id from <see cref="ElementInfo.Id"/>.</summary>
public uint ElementId => _info.Id;
@ -224,6 +237,19 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
float ty = (Height - lf.LineHeight) * 0.5f;
ctx.DrawStringDat(lf, label, tx, ty, LabelColor);
}
uint dragSprite = _itemDragAcceptance switch
{
ItemDragAcceptance.Accept => ItemDragAcceptSprite,
ItemDragAcceptance.Reject => ItemDragRejectSprite,
_ => 0u,
};
if (dragSprite != 0)
{
var (tex, _, _) = _resolve(dragSprite);
if (tex != 0)
ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
}
}
public override bool OnEvent(in UiEvent e)
@ -276,6 +302,19 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
}
OnClick?.Invoke();
return OnClick is not null;
case UiEventType.DragEnter:
_itemDragAcceptance = e.Payload is ItemDragPayload payload
? OnItemDragOver?.Invoke(payload) ?? ItemDragAcceptance.None
: ItemDragAcceptance.None;
return OnItemDragOver is not null;
case UiEventType.DragOver:
_itemDragAcceptance = ItemDragAcceptance.None;
return OnItemDragOver is not null;
case UiEventType.DropReleased:
_itemDragAcceptance = ItemDragAcceptance.None;
if (e.Payload is ItemDragPayload dropped)
OnItemDrop?.Invoke(dropped);
return OnItemDrop is not null;
default:
return false;
}