feat(ui): port retail item interaction policy

This commit is contained in:
Erik 2026-07-11 01:23:31 +02:00
parent 0cf780478a
commit a8da4fd05a
20 changed files with 1110 additions and 104 deletions

View file

@ -1984,6 +1984,11 @@ public sealed class GameWindow : IDisposable
_vitalsVm ??= new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer);
_uiHost = new AcDream.App.UI.UiHost(_gl, shadersDir, _debugFont);
_uiHost.Root.UiLocked = _persistedGameplay.LockUI;
var spellComponentTable =
// DBObj file id is 0x0E00000F. Retail's IsComponentPack
// passes enum key 0x10000001 to DBObj::GetByEnum; that key is
// NOT a portal.dat file id (using it here reads unrelated bytes).
_dats!.Get<DatReaderWriter.DBObjs.SpellComponentTable>(0x0E00000Fu);
_itemInteractionController = new AcDream.App.UI.ItemInteractionController(
Objects,
playerGuid: () => _playerServerGuid,
@ -1991,7 +1996,14 @@ public sealed class GameWindow : IDisposable
sendUseWithTarget: (source, target) => _liveSession?.SendUseWithTarget(source, target),
sendWield: (item, mask) => _liveSession?.SendGetAndWieldItem(item, mask),
sendDrop: item => _liveSession?.SendDropItem(item),
toast: text => _debugVm?.AddToast(text));
toast: text => _debugVm?.AddToast(text),
readyForInventoryRequest: () => _liveSession is not null
&& _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld,
playerOnGround: GetDebugPlayerOnGround,
inNonCombatMode: () => Combat.CurrentMode
== AcDream.Core.Combat.CombatMode.NonCombat,
isComponentPack: wcid => spellComponentTable?.Components.ContainsKey(wcid) == true,
placeInBackpack: SendPickUp);
var cursorFeedbackController = new AcDream.App.UI.CursorFeedbackController(
_itemInteractionController,
// Retail UpdateCursorState (0x00564630) keys target-mode
@ -2561,7 +2573,8 @@ public sealed class GameWindow : IDisposable
}
},
onShortcuts: list => Shortcuts = list,
playerGuid: () => _playerServerGuid);
playerGuid: () => _playerServerGuid,
onUseDone: error => _itemInteractionController?.CompleteUse(error));
// Phase I.7: subscribe to CombatState events and emit
// retail-faithful "You hit X for Y damage" chat lines into

View file

@ -55,9 +55,18 @@ public sealed class ItemInteractionController : IDisposable
private readonly Action<uint, uint>? _sendWield;
private readonly Action<uint>? _sendDrop;
private readonly Action<string>? _toast;
private readonly Func<bool> _readyForInventoryRequest;
private readonly Func<uint> _activeVendorId;
private readonly Func<uint> _groundObjectId;
private readonly Func<bool> _playerOnGround;
private readonly Func<bool> _inNonCombatMode;
private readonly Func<uint, bool> _isComponentPack;
private readonly Action<uint>? _placeInBackpack;
private readonly Action<ItemPolicyAction>? _auxiliaryAction;
private readonly InteractionState _interactionState;
private long _lastUseMs = long.MinValue / 2;
private int _busyCount;
private bool _disposed;
public ItemInteractionController(
@ -69,7 +78,15 @@ public sealed class ItemInteractionController : IDisposable
Action<uint>? sendDrop,
Func<long>? nowMs = null,
Action<string>? toast = null,
InteractionState? interactionState = null)
InteractionState? interactionState = null,
Func<bool>? readyForInventoryRequest = null,
Func<uint>? activeVendorId = null,
Func<uint>? groundObjectId = null,
Func<bool>? playerOnGround = null,
Func<bool>? inNonCombatMode = null,
Func<uint, bool>? isComponentPack = null,
Action<uint>? placeInBackpack = null,
Action<ItemPolicyAction>? auxiliaryAction = null)
{
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
@ -79,6 +96,14 @@ public sealed class ItemInteractionController : IDisposable
_sendDrop = sendDrop;
_nowMs = nowMs ?? (() => Environment.TickCount64);
_toast = toast;
_readyForInventoryRequest = readyForInventoryRequest ?? (() => true);
_activeVendorId = activeVendorId ?? (() => 0u);
_groundObjectId = groundObjectId ?? (() => 0u);
_playerOnGround = playerOnGround ?? (() => true);
_inNonCombatMode = inNonCombatMode ?? (() => false);
_isComponentPack = isComponentPack ?? (_ => false);
_placeInBackpack = placeInBackpack;
_auxiliaryAction = auxiliaryAction;
_interactionState = interactionState ?? new InteractionState();
_interactionState.Changed += OnInteractionModeChanged;
}
@ -89,6 +114,14 @@ public sealed class ItemInteractionController : IDisposable
public InteractionState InteractionState => _interactionState;
public int BusyCount => _busyCount;
/// <summary>
/// Raised for retail confirmation/auxiliary actions whose retained panel is
/// owned outside this controller (PK/NPK altar, volatile rare, trade, salvage).
/// </summary>
public event Action<ItemPolicyAction>? PolicyActionRequested;
public uint PendingSourceItem
=> _interactionState.Current is { Kind: InteractionModeKind.UseItemOnTarget } mode
? mode.SourceObjectId
@ -105,8 +138,8 @@ public sealed class ItemInteractionController : IDisposable
if (!IsTargetModeActive || targetGuid == 0) return false;
var source = _objects.Get(PendingSourceItem);
return source?.Useability is { } useability
&& TargetCompatible(source, targetGuid, useability);
return source?.Useability is not null
&& TargetCompatible(source, targetGuid);
}
public bool ActivateItem(uint itemGuid)
@ -122,32 +155,19 @@ public sealed class ItemInteractionController : IDisposable
if (!ConsumeUseThrottle())
return true;
if (item.Useability is { } targetedUse
&& ItemUseability.IsTargeted(targetedUse)
&& SourceCompatible(item, targetedUse))
{
EnterTargetMode(itemGuid);
return true;
}
if (IsContainer(item))
{
_sendUse?.Invoke(itemGuid);
return true;
}
if (TryAutoWield(item))
return true;
if (item.Useability is { } directUse
&& ItemUseability.IsDirectUseable(directUse)
&& SourceCompatible(item, directUse))
{
_sendUse?.Invoke(itemGuid);
return true;
}
return false;
var input = new ItemUsePolicyInput(
Snapshot(item),
_playerGuid(),
_groundObjectId(),
_readyForInventoryRequest() && _busyCount == 0,
_activeVendorId(),
BypassClassification: false,
UseCurrentSelection: false,
SelectedTarget: null,
ConfirmVolatileRareUses: true,
InNonCombatMode: _inNonCombatMode());
var decision = ItemInteractionPolicy.DecideUse(input);
return ExecuteUseActions(decision.Actions);
}
public bool AcquireTarget(uint targetGuid)
@ -161,7 +181,7 @@ public sealed class ItemInteractionController : IDisposable
if (source?.Useability is not { } useability)
return false;
bool compatible = TargetCompatible(source, targetGuid, useability);
bool compatible = TargetCompatible(source, targetGuid);
var target = _objects.Get(targetGuid);
Console.WriteLine(
$"[use-target] src=0x{sourceGuid:X8} use=0x{useability:X8} ttypeMask=0x{source.TargetType ?? 0u:X8}"
@ -170,7 +190,10 @@ public sealed class ItemInteractionController : IDisposable
if (!compatible)
return false;
if (!_readyForInventoryRequest() || _busyCount != 0)
return false;
_sendUseWithTarget?.Invoke(sourceGuid, targetGuid);
_busyCount++;
return true;
}
@ -189,12 +212,106 @@ public sealed class ItemInteractionController : IDisposable
if (payload.SourceKind == ItemDragSource.ShortcutBar)
return false;
if (payload.ObjId == 0 || _objects.Get(payload.ObjId) is null)
if (payload.ObjId == 0 || _objects.Get(payload.ObjId) is not { } item)
return false;
_objects.MoveItemOptimistic(payload.ObjId, newContainerId: 0u, newSlot: -1);
_sendDrop?.Invoke(payload.ObjId);
return true;
int splitSize = Math.Max(1, item.StackSize);
var decision = ItemInteractionPolicy.DecidePlacement(new ItemPlacementPolicyInput(
Snapshot(item),
_playerGuid(),
_groundObjectId(),
_readyForInventoryRequest() && _busyCount == 0,
TargetId: 0,
Target: null,
AllowGroundFallback: true,
MergeAccepted: false,
DragOnPlayerOpensSecureTrade: true,
PlayerOnGround: _playerOnGround(),
SplitSize: splitSize));
ExecutePlacementActions(decision.Actions);
return decision.ReturnValue;
}
private bool ExecuteUseActions(System.Collections.Generic.IReadOnlyList<ItemPolicyAction> actions)
{
uint openedOrUsed = 0;
bool acted = false;
foreach (var action in actions)
{
switch (action.Kind)
{
case ItemPolicyActionKind.PlaceInBackpack:
_placeInBackpack?.Invoke(action.ObjectId);
acted |= _placeInBackpack is not null;
break;
case ItemPolicyActionKind.WieldRight:
case ItemPolicyActionKind.WieldLeft:
case ItemPolicyActionKind.AutoSort:
if (_objects.Get(action.ObjectId) is { } item)
acted |= TryAutoWield(item);
break;
case ItemPolicyActionKind.OpenContainedContainer:
case ItemPolicyActionKind.SendUse:
// Retail's wire Use and local OpenContainedContainer notice are
// distinct. acdream currently represents both with SendUse, so
// coalesce the pair to one packet.
if (openedOrUsed != action.ObjectId)
{
_sendUse?.Invoke(action.ObjectId);
openedOrUsed = action.ObjectId;
acted |= _sendUse is not null;
}
break;
case ItemPolicyActionKind.SendUseWithTarget:
_sendUseWithTarget?.Invoke(action.ObjectId, action.TargetId);
acted |= _sendUseWithTarget is not null;
break;
case ItemPolicyActionKind.EnterTargetMode:
EnterTargetMode(action.ObjectId);
acted = true;
break;
case ItemPolicyActionKind.IncrementBusy:
_busyCount++;
break;
case ItemPolicyActionKind.Reject:
if (!string.IsNullOrWhiteSpace(action.Message))
_toast?.Invoke(action.Message);
break;
default:
_auxiliaryAction?.Invoke(action);
PolicyActionRequested?.Invoke(action);
bool handled = _auxiliaryAction is not null || PolicyActionRequested is not null;
if (!handled)
_toast?.Invoke(PolicyActionMessage(action));
acted |= handled || _toast is not null;
break;
}
}
return acted;
}
private void ExecutePlacementActions(System.Collections.Generic.IReadOnlyList<ItemPolicyAction> actions)
{
foreach (var action in actions)
{
switch (action.Kind)
{
case ItemPolicyActionKind.DropToWorld:
_objects.MoveItemOptimistic(action.ObjectId, newContainerId: 0u, newSlot: -1);
_sendDrop?.Invoke(action.ObjectId);
break;
case ItemPolicyActionKind.Reject:
if (!string.IsNullOrWhiteSpace(action.Message))
_toast?.Invoke(action.Message);
break;
default:
_auxiliaryAction?.Invoke(action);
PolicyActionRequested?.Invoke(action);
if (_auxiliaryAction is null && PolicyActionRequested is null)
_toast?.Invoke(PolicyActionMessage(action));
break;
}
}
}
private void EnterTargetMode(uint sourceGuid)
@ -210,6 +327,21 @@ public sealed class ItemInteractionController : IDisposable
_interactionState.Clear();
}
private static string PolicyActionMessage(ItemPolicyAction action)
=> action.Kind switch
{
ItemPolicyActionKind.ConfirmPlayerKillerSwitch =>
"Confirm using this Player Killer altar before continuing.",
ItemPolicyActionKind.ConfirmNonPlayerKillerSwitch =>
"Confirm using this Non-Player Killer altar before continuing.",
ItemPolicyActionKind.ConfirmVolatileRare =>
"Confirm using this volatile rare before continuing.",
ItemPolicyActionKind.OpenSecureTrade or ItemPolicyActionKind.StartSecureTrade =>
"Secure trade is not open.",
ItemPolicyActionKind.OpenSalvage => "Open the salvage panel to use that item.",
_ => "That item action is not available here.",
};
private void OnInteractionModeChanged(InteractionModeTransition _)
=> StateChanged?.Invoke();
@ -220,6 +352,14 @@ public sealed class ItemInteractionController : IDisposable
_interactionState.Changed -= OnInteractionModeChanged;
}
/// <summary>Retail UseDone (0x01C7) releases one UI busy reference.</summary>
public void CompleteUse(uint _)
{
if (_busyCount == 0) return;
_busyCount--;
StateChanged?.Invoke();
}
private bool ConsumeUseThrottle()
{
long now = _nowMs();
@ -347,64 +487,52 @@ public sealed class ItemInteractionController : IDisposable
|| item.Type.HasFlag(ItemType.Container)
|| item.ItemsCapacity > 0;
private bool SourceCompatible(ClientObject source, uint useability)
private ItemPolicyObject Snapshot(ClientObject item)
{
uint flags = ItemUseability.SourceFlags(useability);
if ((flags & ItemUseability.Remote) != 0) return true;
if ((flags & ItemUseability.Viewed) != 0) return true;
if ((flags & ItemUseability.Self) != 0 && source.ObjectId == _playerGuid()) return true;
if ((flags & ItemUseability.Wielded) != 0 && IsWieldedByPlayer(source)) return true;
if ((flags & ItemUseability.Contained) != 0 && IsCarriedByPlayer(source)) return true;
return false;
var flags = (PublicWeenieFlags)(item.PublicWeenieBitfield ?? 0u);
if (item.ObjectId == _playerGuid())
flags |= PublicWeenieFlags.Player;
bool owned = item.ObjectId == _playerGuid()
|| IsCarriedByPlayer(item)
|| IsEquippedByPlayer(item);
int stackSize = Math.Max(1, item.StackSize);
return new ItemPolicyObject(
item.ObjectId,
item.Type,
flags,
item.ContainerId,
item.WielderId,
item.ValidLocations,
item.CurrentlyEquippedLocation,
item.CombatUse ?? 0,
item.ItemsCapacity,
item.ContainersCapacity,
item.Useability ?? 0u,
item.TargetType ?? 0u,
owned,
IsContainer(item),
item.IsComponentPack || _isComponentPack(item.WeenieClassId),
item.TradeState,
stackSize,
stackSize,
IsIn3DView: item.ContainerId == 0 && item.WielderId == 0
&& item.ObjectId != _playerGuid());
}
/// <summary>
/// Faithful port of <c>ItemHolder::IsTargetCompatibleWithTargetingObject</c>
/// (0x00588070) for a player-owned source (ActivateItem's SourceCompatible
/// already applied retail's GetLeastLimitedSourceUse arm when target mode was
/// armed). Retail shape: (1) a LOCATION constraint applies only when the
/// least-limited target-use bit (ItemUses::GetLeastLimitedTargetUse
/// 0x004fcd50) is Contained — target must be in our inventory, sole
/// exception self when the Self bit is present — or Wielded (refuse
/// non-owned); (2) the player as target additionally requires the Self bit
/// (ItemUses::IsUseable_SelfTarget 0x004fcd30); (3) EVERY target then passes
/// the KIND gate: <c>source._targetType &amp; target-&gt;InqType()</c>. A
/// missing/zero TargetType mask therefore matches nothing, exactly like
/// retail. Retail's tradeState!=1 refusal is skipped — acdream has no trade
/// state yet. The previous per-bit arms (Remote ⇒ accept-anything, invented
/// Viewed/Contained accepts, self path skipping the kind gate) were the
/// 2026-07-03 "yellow bullseye over a coat" bug.
/// App adapter for Core's pure port of
/// <c>ItemHolder::IsTargetCompatibleWithTargetingObject @ 0x00588070</c>.
/// </summary>
private bool TargetCompatible(ClientObject source, uint targetGuid, uint useability)
private bool TargetCompatible(ClientObject source, uint targetGuid)
{
uint flags = ItemUseability.TargetFlags(useability);
uint player = _playerGuid();
var target = _objects.Get(targetGuid);
if (target is null)
return false; // retail: GetWeenieObject(target) null → incompatible
if (!(IsCarriedByPlayer(target) || IsEquippedByPlayer(target)))
{
uint least = ItemUseability.LeastLimitedTargetUse(useability);
if ((least & ItemUseability.Contained) != 0)
{
if (!(targetGuid == player && (flags & ItemUseability.Self) != 0))
return false;
}
else if ((least & ItemUseability.Wielded) != 0)
return false;
}
if (targetGuid == player && (flags & ItemUseability.Self) == 0)
return false;
return ((source.TargetType ?? 0u) & (uint)target.Type) != 0;
return ItemInteractionPolicy.IsTargetCompatible(
Snapshot(source), Snapshot(target), _playerGuid());
}
private bool IsWieldedByPlayer(ClientObject item)
=> IsEquippedByPlayer(item);
private bool IsEquippedByPlayer(ClientObject item)
{
uint player = _playerGuid();