The two-client user gate PASSED 2026-08-14 (open both ways, stage with the retail trading marker, accept/decline, executed swap, Clear All, cancel text). Every TEMPORARY [trade] probe line from gate rounds 1-2 is stripped (ItemInteractionController, SelectionInteractionController, SecureTradeUiController, LiveSessionCommandRouter, WorldSession, RuntimeTradeState). Roadmap gains the shipped-trade ledger row. Suites after strip: App 4,992/3, Runtime 1,626 - green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1632 lines
63 KiB
C#
1632 lines
63 KiB
C#
using System;
|
|
using AcDream.Core.Chat;
|
|
using AcDream.Core.Combat;
|
|
using AcDream.Core.Items;
|
|
using AcDream.Runtime.Gameplay;
|
|
|
|
namespace AcDream.App.UI;
|
|
|
|
/// <summary>Result of offering a primary click to active item-target mode.</summary>
|
|
public enum ItemPrimaryClickResult
|
|
{
|
|
NotActive,
|
|
ConsumedSuccess,
|
|
ConsumedRejected,
|
|
}
|
|
|
|
public readonly record struct PendingBackpackPlacement(
|
|
ulong Token,
|
|
uint ItemId,
|
|
uint ContainerId,
|
|
int Placement,
|
|
ClientObject? ItemIdentity);
|
|
|
|
/// <summary>
|
|
/// Shared retail item interaction orchestrator. UI widgets route item clicks,
|
|
/// target acquisition, and drag-out drops here instead of duplicating
|
|
/// ItemHolder::UseObject fragments in each panel.
|
|
/// </summary>
|
|
public sealed class ItemInteractionController : IDisposable
|
|
{
|
|
internal const string InventoryRequestBusyMessage =
|
|
"You can only move or use one item at a time";
|
|
private const long RetailDoubleClickMs = 500;
|
|
|
|
private readonly ClientObjectTable _objects;
|
|
private readonly Func<uint> _playerGuid;
|
|
private readonly Func<long> _nowMs;
|
|
private readonly Action<uint>? _sendUse;
|
|
private readonly Action<uint>? _sendExamine;
|
|
private readonly Action<uint, uint>? _sendUseWithTarget;
|
|
private readonly Action<uint, uint>? _sendWield;
|
|
private readonly Action<uint>? _sendDrop;
|
|
private readonly Action<uint, uint>? _sendSplitToWorld;
|
|
private readonly Action<uint, uint, int>? _sendPutItemInContainer;
|
|
private readonly Action<uint, uint, uint, uint>? _sendSplitToContainer;
|
|
private readonly Action<uint, uint, uint>? _sendGive;
|
|
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, uint, int>? _placeInBackpack;
|
|
private readonly Func<uint> _backpackContainerId;
|
|
private readonly Action<uint>? _requestExternalContainer;
|
|
private readonly Action<ItemPolicyAction>? _auxiliaryAction;
|
|
private readonly InteractionState _interactionState;
|
|
private readonly Func<uint> _selectedObjectId;
|
|
private readonly StackSplitQuantityState? _stackSplitQuantity;
|
|
private readonly Func<bool> _dragOnPlayerOpensSecureTrade;
|
|
private readonly Action<string>? _systemMessage;
|
|
private readonly Action<string, RetailLogTextType>? _interfaceText;
|
|
private readonly AutoWieldController _autoWield;
|
|
private readonly Action<uint, ItemUseRequestReservation>? _requestUse;
|
|
// Slice 6.3: vendorGuid, itemGuid, amount, alternateCurrencyId -> true
|
|
// when the wire send actually happened. F4 (Slice 6 review): a plain
|
|
// Action can't tell TryBuy apart from a silent no-op (no session / not
|
|
// in world) — the bool return is what lets TryBuy release the
|
|
// reservation instead of leaking BusyCount forever.
|
|
private readonly Func<uint, uint, int, uint, bool>? _sendBuy;
|
|
// Slice 6b/6c: vendorGuid, staged (amount, itemGuid) entries[, alternate
|
|
// currency] -> true when the wire send actually happened. Same
|
|
// dispatched-vs-silent-no-op shape as _sendBuy — see TryBuyAll/TrySell.
|
|
private readonly Func<uint, IReadOnlyList<(int Amount, uint ItemGuid)>, uint, bool>? _sendBuyAll;
|
|
private readonly Func<uint, IReadOnlyList<(int Amount, uint ItemGuid)>, bool>? _sendSell;
|
|
private readonly RuntimeInteractionTransactionState _runtimeTransactions;
|
|
private readonly InventoryTransactionState _transactions;
|
|
|
|
private uint _consumedPrimaryClickTarget;
|
|
private long _consumedPrimaryClickMs = long.MinValue / 2;
|
|
private PendingBackpackPlacement? _pendingBackpackPlacement;
|
|
private bool _disposed;
|
|
|
|
public ItemInteractionController(
|
|
ClientObjectTable objects,
|
|
RuntimeInteractionTransactionState runtimeTransactions,
|
|
InteractionState interactionState,
|
|
Func<uint> playerGuid,
|
|
Action<uint>? sendUse,
|
|
Action<uint, uint>? sendUseWithTarget,
|
|
Action<uint, uint>? sendWield,
|
|
Action<uint>? sendDrop,
|
|
Action<uint>? sendExamine = null,
|
|
Func<long>? nowMs = null,
|
|
Action<string>? toast = 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, uint, int>? placeInBackpack = null,
|
|
Func<uint>? backpackContainerId = null,
|
|
Action<ItemPolicyAction>? auxiliaryAction = null,
|
|
Action<uint, uint>? sendSplitToWorld = null,
|
|
Func<uint>? selectedObjectId = null,
|
|
StackSplitQuantityState? stackSplitQuantity = null,
|
|
Action<uint, uint, int>? sendPutItemInContainer = null,
|
|
Action<uint, uint, uint>? sendGive = null,
|
|
Func<bool>? dragOnPlayerOpensSecureTrade = null,
|
|
Action<string>? systemMessage = null,
|
|
Action<uint, uint, uint, uint>? sendSplitToContainer = null,
|
|
Action<uint>? requestExternalContainer = null,
|
|
CombatState? combatState = null,
|
|
Action<CombatMode>? sendChangeCombatMode = null,
|
|
Action<uint, ItemUseRequestReservation>? requestUse = null,
|
|
Func<uint, uint, int, uint, bool>? sendBuy = null,
|
|
Func<uint, IReadOnlyList<(int Amount, uint ItemGuid)>, uint, bool>? sendBuyAll = null,
|
|
Func<uint, IReadOnlyList<(int Amount, uint ItemGuid)>, bool>? sendSell = null,
|
|
Action<string, RetailLogTextType>? interfaceText = null)
|
|
{
|
|
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
|
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
|
|
_sendUse = sendUse;
|
|
_sendExamine = sendExamine;
|
|
_sendUseWithTarget = sendUseWithTarget;
|
|
_sendWield = sendWield;
|
|
_sendDrop = sendDrop;
|
|
_sendSplitToWorld = sendSplitToWorld;
|
|
_sendPutItemInContainer = sendPutItemInContainer;
|
|
_sendSplitToContainer = sendSplitToContainer;
|
|
_sendGive = sendGive;
|
|
_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;
|
|
_backpackContainerId = backpackContainerId ?? _playerGuid;
|
|
_requestExternalContainer = requestExternalContainer;
|
|
_auxiliaryAction = auxiliaryAction;
|
|
_selectedObjectId = selectedObjectId ?? (() => 0u);
|
|
_stackSplitQuantity = stackSplitQuantity;
|
|
_dragOnPlayerOpensSecureTrade = dragOnPlayerOpensSecureTrade ?? (() => true);
|
|
_systemMessage = systemMessage;
|
|
_interfaceText = interfaceText;
|
|
_requestUse = requestUse;
|
|
_sendBuy = sendBuy;
|
|
_sendBuyAll = sendBuyAll;
|
|
_sendSell = sendSell;
|
|
_interactionState = interactionState
|
|
?? throw new ArgumentNullException(nameof(interactionState));
|
|
_runtimeTransactions = runtimeTransactions
|
|
?? throw new ArgumentNullException(nameof(runtimeTransactions));
|
|
_transactions = _runtimeTransactions.Inventory;
|
|
if (!ReferenceEquals(_transactions.Objects, _objects))
|
|
{
|
|
throw new ArgumentException(
|
|
"The inventory transaction owner must borrow the controller's exact object table.",
|
|
nameof(runtimeTransactions));
|
|
}
|
|
_autoWield = new AutoWieldController(
|
|
_objects,
|
|
_playerGuid,
|
|
_sendWield,
|
|
sendPutItemInContainer,
|
|
_toast,
|
|
_systemMessage,
|
|
combatState,
|
|
sendChangeCombatMode);
|
|
_interactionState.Changed += OnInteractionModeChanged;
|
|
_transactions.StateChanged += OnTransactionStateChanged;
|
|
_transactions.RequestCompleted += OnInventoryRequestCompleted;
|
|
_transactions.RequestFailed += OnInventoryRequestFailed;
|
|
_transactions.ObjectTableCleared += OnInventoryObjectsCleared;
|
|
_objects.MoveRequestFailed += OnMoveRequestFailedNotice;
|
|
}
|
|
|
|
public event Action? StateChanged;
|
|
|
|
/// <summary>
|
|
/// Retail's two secure-trade open paths surface here for the trade UI:
|
|
/// (partnerGuid, itemGuid) — itemGuid 0 for Use-on-player
|
|
/// (<c>DetermineUseResult @ 0x00588460</c> result 5), non-zero for
|
|
/// drag-item-onto-player with the DragItemOnPlayerOpensSecureTrade
|
|
/// option (<c>AttemptPlaceIn3D @ 0x00588600</c>). The subscriber
|
|
/// (SecureTradeUiController) owns the open/stage sequencing.
|
|
/// </summary>
|
|
public event Action<uint, uint>? SecureTradeRequested;
|
|
|
|
/// <summary>
|
|
/// Retail <c>CM_Item::SendNotice_ShowPendingInPlayer</c>: the inventory
|
|
/// panel inserts a waiting projection before the pickup request is sent.
|
|
/// The destination and placement are the same values sent on the wire.
|
|
/// </summary>
|
|
public event Action<PendingBackpackPlacement>? PendingBackpackPlacementRequested;
|
|
|
|
/// <summary>
|
|
/// Withdraws a waiting pickup projection when the approach is rejected or
|
|
/// cancelled before a request reaches the server.
|
|
/// </summary>
|
|
public event Action<PendingBackpackPlacement>? PendingBackpackPlacementCancelled;
|
|
|
|
/// <summary>
|
|
/// Resolves the exact waiting projection after an authoritative response.
|
|
/// The token prevents a late response for a recycled GUID from erasing a
|
|
/// newer incarnation's projection in the inventory panel.
|
|
/// </summary>
|
|
public event Action<PendingBackpackPlacement>? PendingBackpackPlacementResolved;
|
|
|
|
/// <summary>
|
|
/// Publishes the exact split-to-world request after its wire send has
|
|
/// succeeded. Retail <c>ACCWeenieObject::UIAttemptSplitTo3D @
|
|
/// 0x0058D850</c> retains the source class, selected amount, and request
|
|
/// time so the subsequently created ground stack can be recognized.
|
|
/// </summary>
|
|
public event Action<WorldDropDispatch>? WorldDropDispatched;
|
|
|
|
public uint PlayerGuid => _playerGuid();
|
|
|
|
public InteractionState InteractionState => _interactionState;
|
|
public RuntimeInteractionTransactionState RuntimeTransactions =>
|
|
_runtimeTransactions;
|
|
|
|
public int BusyCount => _transactions.BusyCount;
|
|
public uint CurrentAppraisalId =>
|
|
_runtimeTransactions.CurrentAppraisalId;
|
|
|
|
public bool CanMakeInventoryRequest =>
|
|
BaseCanMakeInventoryRequest && !_transactions.HasPendingRequest;
|
|
|
|
private bool BaseCanMakeInventoryRequest =>
|
|
_readyForInventoryRequest()
|
|
&& _transactions.BusyCount == 0
|
|
&& !_autoWield.IsBusy;
|
|
|
|
/// <summary>
|
|
/// Retail <c>ACCWeenieObject::IsPlayerReadyToMakeInventoryRequest</c>.
|
|
/// The destination ItemList's separate <c>m_pendingItem</c> rejection is
|
|
/// reported by <see cref="ReportPendingBackpackPlacementConflict"/>.
|
|
/// </summary>
|
|
public bool EnsureInventoryRequestReady()
|
|
{
|
|
if (CanMakeInventoryRequest)
|
|
return true;
|
|
|
|
if (_transactions.HasPendingRequest
|
|
|| _transactions.BusyCount != 0
|
|
|| _autoWield.IsBusy)
|
|
_systemMessage?.Invoke(InventoryRequestBusyMessage);
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Slice 6.3: retail's Buy button — <c>gmVendorUI::BuySingleItem</c>
|
|
/// (<c>pc:201661</c>, <c>0x004C2820</c>). Immediate single-item purchase
|
|
/// of <paramref name="itemGuid"/> (a vendor shop item) at
|
|
/// <paramref name="amount"/> units, with the vendor's own trade currency
|
|
/// (0 = pyreal). Rides the EXISTING one-request-at-a-time reservation
|
|
/// ordinary Use takes (research doc §A.4: every
|
|
/// <c>HandleActionBuyItem</c> path ends in exactly one
|
|
/// <c>SendUseDoneEvent()</c>, the SAME completion signal
|
|
/// <c>RuntimeInteractionTransactionState.CompleteUse</c> already
|
|
/// resolves via the wired <c>UseDone (0x01C7)</c> handler) — no second
|
|
/// gate, and retail's client-side busy-count increment
|
|
/// (<c>ClientUISystem::IncrementBusyCount</c>, <c>pc:201765</c>) is
|
|
/// exactly what <see cref="EnsureInventoryRequestReady"/>'s
|
|
/// <c>BusyCount == 0</c> check already guards for every other request.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// F4 (Slice 6 review): <see cref="_sendBuy"/> reports whether the wire
|
|
/// send actually happened (false when there is no live session or the
|
|
/// session is not in world). Mirrors
|
|
/// <c>RuntimeInteractionTransactionState.TryDispatchUse</c>'s shape —
|
|
/// every rejecting path calls <see cref="ItemUseRequestReservation.CancelBeforeDispatch"/>
|
|
/// before returning, never <see cref="ItemUseRequestReservation.MarkDispatched"/>.
|
|
/// The OLD void-returning delegate could not distinguish "sent" from a
|
|
/// session-null no-op, so a buy attempted while disconnected/mid-teardown
|
|
/// permanently marked the reservation dispatched — <c>BusyCount</c> then
|
|
/// never balances, because no <c>UseDone</c> will ever arrive for a
|
|
/// request that was never actually sent, leaking "You can only move or
|
|
/// use one item at a time" forever.
|
|
/// </remarks>
|
|
public bool TryBuy(uint vendorGuid, uint itemGuid, int amount, uint alternateCurrencyId)
|
|
{
|
|
if (vendorGuid == 0u || itemGuid == 0u || amount <= 0 || _sendBuy is null)
|
|
return false;
|
|
if (!EnsureInventoryRequestReady())
|
|
return false;
|
|
|
|
ItemUseRequestReservation reservation = BeginUseRequestReservation();
|
|
bool dispatched;
|
|
try
|
|
{
|
|
dispatched = _sendBuy(vendorGuid, itemGuid, amount, alternateCurrencyId);
|
|
}
|
|
catch
|
|
{
|
|
reservation.CancelBeforeDispatch();
|
|
throw;
|
|
}
|
|
|
|
if (!dispatched)
|
|
{
|
|
reservation.CancelBeforeDispatch();
|
|
return false;
|
|
}
|
|
|
|
reservation.MarkDispatched();
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Slice 6b: retail's "Buy All" button (<c>gmVendorUI::HandleButtonClicks</c>
|
|
/// case <c>0x100000ca</c>, <c>pc:204011-204079</c>) — the ONE path that
|
|
/// sends a multi-item Buy in a single wire call. Rides the SAME
|
|
/// one-request-at-a-time use reservation <see cref="TryBuy"/> does.
|
|
/// F1 (Slice 6b/6c review): retail's four client-side affordability/
|
|
/// pack-capacity pre-checks (<c>pc:204017/204032/204053/204067</c>) are
|
|
/// now ported, but live in the CALLER —
|
|
/// <c>VendorUiController.BuyAllButtonPressed</c> — ahead of this
|
|
/// method, not inside it, since they need the staged entries' prices
|
|
/// and the player's live capacity, neither of which this method
|
|
/// otherwise touches. <see cref="TryBuy"/>'s single-item path still has
|
|
/// none (register AP-162, narrowed to that one remaining case).
|
|
/// </summary>
|
|
public bool TryBuyAll(
|
|
uint vendorGuid,
|
|
IReadOnlyList<(int Amount, uint ItemGuid)> items,
|
|
uint alternateCurrencyId)
|
|
{
|
|
if (vendorGuid == 0u || items is null || items.Count == 0 || _sendBuyAll is null)
|
|
return false;
|
|
if (!EnsureInventoryRequestReady())
|
|
return false;
|
|
|
|
ItemUseRequestReservation reservation = BeginUseRequestReservation();
|
|
bool dispatched;
|
|
try
|
|
{
|
|
dispatched = _sendBuyAll(vendorGuid, items, alternateCurrencyId);
|
|
}
|
|
catch
|
|
{
|
|
reservation.CancelBeforeDispatch();
|
|
throw;
|
|
}
|
|
|
|
if (!dispatched)
|
|
{
|
|
reservation.CancelBeforeDispatch();
|
|
return false;
|
|
}
|
|
|
|
reservation.MarkDispatched();
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Slice 6c: retail's Sell path — both "Sell Item"
|
|
/// (<c>gmVendorUI::HandleButtonClicks</c> case <c>0x100000d2</c>,
|
|
/// <c>pc:204101-204112</c>, a one-entry list) and "Sell All" (case
|
|
/// <c>0x100000d3</c>, <c>pc:204113-204129</c>, an n-entry list) reuse
|
|
/// this single method — retail's own <c>CM_Vendor::Event_Sell</c> has no
|
|
/// separate single-item opcode the way Buy does. Same reservation dance
|
|
/// as <see cref="TryBuy"/>/<see cref="TryBuyAll"/>.
|
|
/// </summary>
|
|
public bool TrySell(
|
|
uint vendorGuid,
|
|
IReadOnlyList<(int Amount, uint ItemGuid)> items)
|
|
{
|
|
if (vendorGuid == 0u || items is null || items.Count == 0 || _sendSell is null)
|
|
return false;
|
|
if (!EnsureInventoryRequestReady())
|
|
return false;
|
|
|
|
ItemUseRequestReservation reservation = BeginUseRequestReservation();
|
|
bool dispatched;
|
|
try
|
|
{
|
|
dispatched = _sendSell(vendorGuid, items);
|
|
}
|
|
catch
|
|
{
|
|
reservation.CancelBeforeDispatch();
|
|
throw;
|
|
}
|
|
|
|
if (!dispatched)
|
|
{
|
|
reservation.CancelBeforeDispatch();
|
|
return false;
|
|
}
|
|
|
|
reservation.MarkDispatched();
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retail <c>UIElement_ItemList::AcceptDragObject</c>'s local
|
|
/// <c>m_pendingItem</c> branch. This wording belongs only to the destination
|
|
/// list which already displays the waiting projection.
|
|
/// </summary>
|
|
public void ReportPendingBackpackPlacementConflict()
|
|
{
|
|
if (_pendingBackpackPlacement is not { } pending)
|
|
return;
|
|
string? itemName = _objects.Get(pending.ItemId)?.Name;
|
|
string name = string.IsNullOrWhiteSpace(itemName) ? "that item" : itemName;
|
|
_systemMessage?.Invoke($"Already attempting to place {name} here");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Dispatches one retail inventory request. A provisional global
|
|
/// <c>ACCWeenieObject::prevRequest</c> owner closes callback reentrancy,
|
|
/// then becomes dispatched immediately after a successful wire send.
|
|
/// The callback returns false when it could not dispatch anything.
|
|
/// Retail: <c>RecordRequest @ 0x0058C220</c>, UIAttempt family
|
|
/// <c>0x0058D590..0x0058D850</c>.
|
|
/// </summary>
|
|
public bool TryDispatchInventoryRequest(
|
|
InventoryRequestKind kind,
|
|
uint itemId,
|
|
Func<bool> dispatch,
|
|
ulong reservationToken = 0u)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(dispatch);
|
|
if (itemId == 0u)
|
|
return false;
|
|
|
|
if (reservationToken != 0u)
|
|
{
|
|
try
|
|
{
|
|
return _transactions.TryDispatch(
|
|
kind,
|
|
itemId,
|
|
dispatch,
|
|
reservationToken);
|
|
}
|
|
catch
|
|
{
|
|
CancelPendingBackpackPlacement(itemId, reservationToken);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
if (!EnsureInventoryRequestReady())
|
|
return false;
|
|
return _transactions.TryDispatch(kind, itemId, dispatch);
|
|
}
|
|
|
|
public bool TryGetPendingInventoryRequest(out PendingInventoryRequest pending)
|
|
=> _transactions.TryGetPending(out pending);
|
|
|
|
/// <summary>
|
|
/// Increments retail's shared <c>ClientUISystem</c> busy reference after a
|
|
/// request issued by another retained controller has been sent. The
|
|
/// corresponding UseDone releases it. Retail spell casts complete through
|
|
/// Item__UseDone too; AttackDone belongs only to the combat attack owner.
|
|
/// </summary>
|
|
public void IncrementBusyCount()
|
|
=> _runtimeTransactions.IncrementBusyCount();
|
|
|
|
/// <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
|
|
: 0u;
|
|
|
|
public bool IsTargetModeActive
|
|
=> _interactionState.Current.Kind == InteractionModeKind.UseItemOnTarget;
|
|
|
|
public bool IsAnyTargetModeActive
|
|
=> _interactionState.Current.Kind != InteractionModeKind.None;
|
|
|
|
public bool IsPendingSource(uint itemGuid)
|
|
=> itemGuid != 0 && itemGuid == PendingSourceItem;
|
|
|
|
/// <summary>
|
|
/// Retail <c>ACCWeenieObject::IsOwnedByPlayer</c> projection shared with
|
|
/// toolbar shortcut creation.
|
|
/// </summary>
|
|
public bool IsOwnedByPlayer(uint itemGuid)
|
|
=> _objects.IsOwnedByObject(itemGuid, _playerGuid());
|
|
|
|
public bool IsCurrentTargetCompatible(uint targetGuid)
|
|
{
|
|
if (!IsTargetModeActive || targetGuid == 0) return false;
|
|
|
|
var source = _objects.Get(PendingSourceItem);
|
|
return source?.Useability is not null
|
|
&& TargetCompatible(source, targetGuid);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The single target-mode interception point for world and retained item
|
|
/// clicks. A rejected target is still consumed and must never fall through
|
|
/// to selection, container opening, or ordinary item activation.
|
|
/// </summary>
|
|
public ItemPrimaryClickResult OfferPrimaryClick(uint targetGuid)
|
|
{
|
|
InteractionModeKind mode = _interactionState.Current.Kind;
|
|
if (mode == InteractionModeKind.None)
|
|
{
|
|
long now = _nowMs();
|
|
if (targetGuid != 0
|
|
&& targetGuid == _consumedPrimaryClickTarget
|
|
&& now - _consumedPrimaryClickMs <= RetailDoubleClickMs)
|
|
return ItemPrimaryClickResult.ConsumedRejected;
|
|
_consumedPrimaryClickTarget = 0;
|
|
return ItemPrimaryClickResult.NotActive;
|
|
}
|
|
|
|
bool accepted;
|
|
switch (mode)
|
|
{
|
|
case InteractionModeKind.Use:
|
|
if (VendorDiagnostics.DumpVendorEnabled)
|
|
{
|
|
Console.WriteLine(
|
|
$"[vendor-diag] route=target-mode-click OfferPrimaryClick(Use) guid=0x{targetGuid:X8}");
|
|
}
|
|
ClearTargetMode();
|
|
accepted = ActivateItem(targetGuid);
|
|
break;
|
|
case InteractionModeKind.Examine:
|
|
ClearTargetMode();
|
|
accepted = targetGuid != 0 && _sendExamine is not null;
|
|
if (accepted)
|
|
RequestAppraisal(targetGuid);
|
|
break;
|
|
case InteractionModeKind.UseItemOnTarget:
|
|
accepted = AcquireTarget(targetGuid);
|
|
break;
|
|
default:
|
|
throw new InvalidOperationException($"Unknown interaction mode {mode}.");
|
|
}
|
|
_consumedPrimaryClickTarget = targetGuid;
|
|
_consumedPrimaryClickMs = _nowMs();
|
|
return accepted
|
|
? ItemPrimaryClickResult.ConsumedSuccess
|
|
: ItemPrimaryClickResult.ConsumedRejected;
|
|
}
|
|
|
|
public ItemPrimaryClickResult OfferSelfPrimaryClick()
|
|
=> OfferPrimaryClick(_playerGuid());
|
|
|
|
/// <summary>
|
|
/// Retail toolbar Use button: use the current selection immediately, or arm
|
|
/// one-shot <c>TARGET_MODE_USE</c> when no object is selected.
|
|
/// Retail reference: <c>gmToolbarUI::ListenToElementMessage @ 0x004BEE90</c>.
|
|
/// </summary>
|
|
public bool UseSelectedOrEnterMode(uint selectedObjectId)
|
|
{
|
|
if (VendorDiagnostics.DumpVendorEnabled)
|
|
{
|
|
Console.WriteLine(
|
|
$"[vendor-diag] route=toolbar-or-queued-use UseSelectedOrEnterMode guid=0x{selectedObjectId:X8}");
|
|
}
|
|
if (selectedObjectId != 0)
|
|
return ActivateItem(selectedObjectId);
|
|
return _interactionState.EnterUse();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retail toolbar Examine button: appraise the current selection immediately,
|
|
/// or arm one-shot <c>TARGET_MODE_EXAMINE</c> when no object is selected.
|
|
/// Retail reference: <c>gmToolbarUI::ListenToElementMessage @ 0x004BEE90</c>.
|
|
/// </summary>
|
|
public bool ExamineSelectedOrEnterMode(uint selectedObjectId)
|
|
{
|
|
if (selectedObjectId == 0)
|
|
return _interactionState.EnterExamine();
|
|
if (_sendExamine is null)
|
|
return false;
|
|
RequestAppraisal(selectedObjectId);
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retail <c>gmExaminationUI::RecvNotice_ExamineObject @ 0x004AB7B0</c>.
|
|
/// One shared UI-busy reference covers the currently pending appraisal,
|
|
/// even when another examine request replaces its GUID before a response.
|
|
/// </summary>
|
|
private void RequestAppraisal(uint objectId)
|
|
{
|
|
if (objectId == 0 || _sendExamine is null)
|
|
return;
|
|
_runtimeTransactions.TryRequestAppraisal(objectId, _sendExamine);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Accepts only the pending or current appraisal, matching
|
|
/// <c>gmExaminationUI::SetAppraiseInfo @ 0x004ADAE0</c>.
|
|
/// </summary>
|
|
public AppraisalResponseAcceptance AcceptAppraisalResponse(uint objectId)
|
|
{
|
|
bool ownedBusyReference = _transactions.BusyCount > 0;
|
|
RuntimeAppraisalResponseAcceptance acceptance =
|
|
_runtimeTransactions.AcceptAppraisalResponse(objectId);
|
|
if (acceptance.FirstResponse && !ownedBusyReference)
|
|
StateChanged?.Invoke();
|
|
return new AppraisalResponseAcceptance(
|
|
acceptance.Accepted,
|
|
acceptance.FirstResponse);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retail combat-time creature/player refresh sends CM_Item directly and
|
|
/// therefore does not acquire a second UI-busy reference.
|
|
/// </summary>
|
|
public bool RefreshCurrentAppraisal()
|
|
{
|
|
if (_sendExamine is null)
|
|
return false;
|
|
return _runtimeTransactions.RefreshCurrentAppraisal(_sendExamine);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Releases only the object-appraisal transaction before the local spell
|
|
/// examination subview takes ownership of the shared floaty window.
|
|
///
|
|
/// Retail <c>gmExaminationUI::ExamineSpell @ 0x004B6900</c> decrements the
|
|
/// appraisal busy reference when a response is pending, clears both pending
|
|
/// and current object ids, and sends <c>CM_Item::Event_Appraise(0)</c>. It
|
|
/// does not clear unrelated use/inventory busy references.
|
|
/// </summary>
|
|
public void CancelObjectAppraisalForSpell()
|
|
{
|
|
if (_sendExamine is null)
|
|
return;
|
|
bool ownedBusyReference = _transactions.BusyCount > 0;
|
|
if (_runtimeTransactions.CancelObjectAppraisalForSpell(_sendExamine)
|
|
&& !ownedBusyReference)
|
|
{
|
|
StateChanged?.Invoke();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Projects retail <c>gmToolbarUI::HandleSelectionChanged @ 0x004BF380</c>
|
|
/// from the live selected object's public description. This is deliberately
|
|
/// a read-only query; activation still flows through <see cref="ActivateItem"/>.
|
|
/// </summary>
|
|
public bool IsToolbarUseEnabled(uint selectedObjectId)
|
|
{
|
|
if (selectedObjectId == 0
|
|
|| _objects.Get(selectedObjectId) is not { } item)
|
|
return false;
|
|
|
|
return ItemInteractionPolicy.IsToolbarUseEnabled(
|
|
item.Type,
|
|
item.CombatUse ?? 0,
|
|
item.Useability ?? ItemUseability.Undef);
|
|
}
|
|
|
|
public bool ActivateItem(uint itemGuid)
|
|
{
|
|
if (VendorDiagnostics.DumpVendorEnabled)
|
|
{
|
|
Console.WriteLine(
|
|
$"[vendor-diag] entry:ActivateItem guid=0x{itemGuid:X8} isTargetModeActive={IsTargetModeActive}");
|
|
}
|
|
if (itemGuid == 0) return false;
|
|
|
|
if (IsTargetModeActive)
|
|
return OfferPrimaryClick(itemGuid) == ItemPrimaryClickResult.ConsumedSuccess;
|
|
|
|
var item = _objects.Get(itemGuid);
|
|
if (item is null) return false;
|
|
|
|
if (!ConsumeUseThrottle())
|
|
return true;
|
|
if (!EnsureInventoryRequestReady())
|
|
return false;
|
|
|
|
var input = new ItemUsePolicyInput(
|
|
Snapshot(item),
|
|
_playerGuid(),
|
|
_groundObjectId(),
|
|
CanMakeInventoryRequest,
|
|
_activeVendorId(),
|
|
BypassClassification: false,
|
|
UseCurrentSelection: false,
|
|
SelectedTarget: null,
|
|
ConfirmVolatileRareUses: true,
|
|
InNonCombatMode: _inNonCombatMode());
|
|
var decision = ItemInteractionPolicy.DecideUse(input);
|
|
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;
|
|
if (!TryBeginPendingBackpackPlacement(
|
|
itemGuid,
|
|
containerId,
|
|
placement,
|
|
out _))
|
|
{
|
|
// Retail permits only one inventory request at a time. Consume the
|
|
// input without replacing a pending projection or issuing a wire
|
|
// request while another inventory transaction owns the gate.
|
|
return true;
|
|
}
|
|
// This callback may install a retail MoveToObject approach instead of
|
|
// sending immediately. SelectionInteractionController promotes the
|
|
// reservation to Dispatched only when it actually emits the wire move.
|
|
_placeInBackpack(itemGuid, containerId, placement);
|
|
return true;
|
|
}
|
|
|
|
public bool TryBeginPendingBackpackPlacement(
|
|
uint itemGuid,
|
|
uint containerId,
|
|
int placement,
|
|
out PendingBackpackPlacement pending)
|
|
=> TryBeginPendingBackpackPlacement(
|
|
itemGuid,
|
|
containerId,
|
|
placement,
|
|
InventoryRequestKind.Pickup,
|
|
out pending);
|
|
|
|
private bool TryBeginPendingBackpackPlacement(
|
|
uint itemGuid,
|
|
uint containerId,
|
|
int placement,
|
|
InventoryRequestKind kind,
|
|
out PendingBackpackPlacement pending)
|
|
{
|
|
if (itemGuid == 0u || containerId == 0u)
|
|
{
|
|
pending = default;
|
|
return false;
|
|
}
|
|
|
|
if (_pendingBackpackPlacement is { } existing)
|
|
{
|
|
ReportPendingBackpackPlacementConflict();
|
|
pending = existing;
|
|
return false;
|
|
}
|
|
if (!EnsureInventoryRequestReady())
|
|
{
|
|
pending = default;
|
|
return false;
|
|
}
|
|
|
|
PendingBackpackPlacement candidate = default;
|
|
bool reserved;
|
|
PendingInventoryRequest published;
|
|
try
|
|
{
|
|
reserved = _transactions.TryReserve(
|
|
kind,
|
|
itemGuid,
|
|
out published,
|
|
request =>
|
|
{
|
|
candidate = new PendingBackpackPlacement(
|
|
request.Token,
|
|
itemGuid,
|
|
containerId,
|
|
placement,
|
|
request.ItemIdentity);
|
|
_pendingBackpackPlacement = candidate;
|
|
List<Exception> failures = [];
|
|
DispatchAll(
|
|
PendingBackpackPlacementRequested,
|
|
candidate,
|
|
failures);
|
|
if (failures.Count != 0)
|
|
{
|
|
throw new AggregateException(
|
|
"One or more pending-placement observers failed.",
|
|
failures);
|
|
}
|
|
});
|
|
}
|
|
catch (Exception failure)
|
|
{
|
|
if (candidate.Token == 0u)
|
|
throw;
|
|
|
|
_pendingBackpackPlacement = null;
|
|
_transactions.CancelBeforeDispatch(candidate.Token);
|
|
List<Exception> failures = [failure];
|
|
DispatchAll(
|
|
PendingBackpackPlacementCancelled,
|
|
candidate,
|
|
failures);
|
|
throw new AggregateException(
|
|
"Pending backpack placement publication failed.",
|
|
failures);
|
|
}
|
|
pending = candidate;
|
|
if (!reserved
|
|
|| _pendingBackpackPlacement != candidate
|
|
|| published.Token != candidate.Token
|
|
|| published.ItemId != itemGuid
|
|
|| published.Kind != kind
|
|
|| published.Dispatched)
|
|
{
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Publishes retail's destination waiting projection, dispatches the move,
|
|
/// then records the global request. Failed dispatch withdraws only the
|
|
/// projection created by this call.
|
|
/// </summary>
|
|
public bool TryDispatchPendingBackpackPlacement(
|
|
uint itemGuid,
|
|
uint containerId,
|
|
int placement,
|
|
InventoryRequestKind kind,
|
|
Func<bool> dispatch)
|
|
{
|
|
// Callers such as gmToolbarUI check the global prevRequest before they
|
|
// ask CPlayerSystem to publish ShowPendingInPlayer. This preserves the
|
|
// generic busy wording when both states happen to be present.
|
|
if (!EnsureInventoryRequestReady())
|
|
return false;
|
|
if (!TryBeginPendingBackpackPlacement(
|
|
itemGuid,
|
|
containerId,
|
|
placement,
|
|
kind,
|
|
out PendingBackpackPlacement pending))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (_pendingBackpackPlacement != pending
|
|
|| !_transactions.TryGetPending(out PendingInventoryRequest reserved)
|
|
|| reserved.Token != pending.Token
|
|
|| reserved.ItemId != pending.ItemId
|
|
|| reserved.Kind != kind
|
|
|| reserved.Dispatched)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (TryDispatchInventoryRequest(
|
|
kind,
|
|
itemGuid,
|
|
dispatch,
|
|
pending.Token))
|
|
return true;
|
|
|
|
CancelPendingBackpackPlacement(itemGuid, pending.Token);
|
|
return false;
|
|
}
|
|
|
|
public bool TryGetPendingBackpackPlacement(
|
|
uint itemGuid,
|
|
out PendingBackpackPlacement pending)
|
|
{
|
|
if (_pendingBackpackPlacement is { } current
|
|
&& current.ItemId == itemGuid)
|
|
{
|
|
pending = current;
|
|
return true;
|
|
}
|
|
|
|
pending = default;
|
|
return false;
|
|
}
|
|
|
|
public void CancelPendingBackpackPlacement(uint itemGuid = 0u, ulong token = 0u)
|
|
{
|
|
if (_pendingBackpackPlacement is not { } pending
|
|
|| (itemGuid != 0u && pending.ItemId != itemGuid)
|
|
|| (token != 0u && pending.Token != token))
|
|
{
|
|
return;
|
|
}
|
|
|
|
_pendingBackpackPlacement = null;
|
|
_transactions.CancelBeforeDispatch(pending.Token);
|
|
List<Exception> failures = [];
|
|
DispatchAll(PendingBackpackPlacementCancelled, pending, failures);
|
|
if (failures.Count != 0)
|
|
{
|
|
throw new AggregateException(
|
|
"One or more pending-placement cancellation observers failed.",
|
|
failures);
|
|
}
|
|
}
|
|
|
|
internal bool TryCaptureObjectIdentity(uint objectId, out ClientObject item)
|
|
{
|
|
item = _objects.Get(objectId)!;
|
|
return item is not null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns whether the object is a member of retail's current
|
|
/// <c>ClientUISystem::groundObject</c>. Such objects are container
|
|
/// contents, not independently projected world objects, so
|
|
/// <c>CPlayerSystem::PlaceInBackpack @ 0x0055D8C0</c> sends their
|
|
/// container transfer directly without a 3-D approach lookup.
|
|
/// </summary>
|
|
internal bool IsInCurrentGroundObject(uint objectId)
|
|
{
|
|
uint groundObjectId = _groundObjectId();
|
|
return groundObjectId != 0u
|
|
&& _objects.Get(objectId) is { } item
|
|
&& item.ContainerId == groundObjectId;
|
|
}
|
|
|
|
internal bool IsCurrentObjectIdentity(uint objectId, ClientObject item)
|
|
=> ReferenceEquals(_objects.Get(objectId), item);
|
|
|
|
/// <summary>
|
|
/// Retail paperdoll drop entry point. The paperdoll resolves the exact
|
|
/// target side/location; this shared interaction owner performs AutoWield's
|
|
/// confirmed blocker transaction before sending GetAndWieldItem.
|
|
/// </summary>
|
|
public bool WieldFromPaperdoll(uint itemGuid, EquipMask targetMask)
|
|
{
|
|
if (itemGuid == 0u || targetMask == EquipMask.None)
|
|
return false;
|
|
if (_objects.Get(itemGuid) is not { } item)
|
|
return false;
|
|
if (!EnsureInventoryRequestReady())
|
|
return false;
|
|
return _autoWield.TryWield(item, targetMask);
|
|
}
|
|
|
|
/// <summary>User combat-mode input supersedes AutoWield's retained mode.</summary>
|
|
public void NotifyExplicitCombatModeRequest()
|
|
=> _autoWield.NotifyExplicitCombatModeRequest();
|
|
|
|
public bool AcquireTarget(uint targetGuid)
|
|
{
|
|
if (!IsTargetModeActive || targetGuid == 0) return false;
|
|
|
|
uint sourceGuid = PendingSourceItem;
|
|
ClearTargetMode();
|
|
|
|
var source = _objects.Get(sourceGuid);
|
|
if (source?.Useability is not { } useability)
|
|
return false;
|
|
|
|
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}"
|
|
+ $" tgt=0x{targetGuid:X8} tgtKind={(target is null ? "none" : $"0x{(uint)target.Type:X8}")}"
|
|
+ $" -> {(compatible ? "SEND UseWithTarget" : "refused")}");
|
|
if (!compatible)
|
|
return false;
|
|
|
|
if (!EnsureInventoryRequestReady())
|
|
return false;
|
|
_runtimeTransactions.TryDispatchTargetedUse(
|
|
sourceGuid,
|
|
targetGuid,
|
|
_sendUseWithTarget,
|
|
incrementBusy: true);
|
|
return true;
|
|
}
|
|
|
|
public bool AcquireSelfTarget()
|
|
=> IsTargetModeActive && AcquireTarget(_playerGuid());
|
|
|
|
/// <summary>
|
|
/// Completes the positive branch of retail
|
|
/// <c>ClientUISystem::UsageCallback @ 0x00565B20</c>. Confirmation policy has
|
|
/// already consumed the original activation, so acceptance sends the retained
|
|
/// object id and enters the same busy state as an ordinary use.
|
|
/// </summary>
|
|
public bool ExecuteConfirmedUse(uint objectId)
|
|
{
|
|
if (VendorDiagnostics.DumpVendorEnabled)
|
|
{
|
|
Console.WriteLine(
|
|
$"[vendor-diag] route=confirmed-use ExecuteConfirmedUse guid=0x{objectId:X8}");
|
|
}
|
|
if (objectId == 0u || (_requestUse is null && _sendUse is null))
|
|
return false;
|
|
if (!EnsureInventoryRequestReady())
|
|
return false;
|
|
if (_requestUse is not null)
|
|
{
|
|
ItemUseRequestReservation reservation = BeginUseRequestReservation();
|
|
try
|
|
{
|
|
_requestUse(objectId, reservation);
|
|
}
|
|
catch
|
|
{
|
|
reservation.CancelBeforeDispatch();
|
|
throw;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
_sendUse!(objectId);
|
|
_runtimeTransactions.IncrementBusyCount();
|
|
}
|
|
return true;
|
|
}
|
|
|
|
public void CancelTargetMode()
|
|
{
|
|
if (!IsAnyTargetModeActive) return;
|
|
ClearTargetMode();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gate fix (2026-08-14 trade round 1): the world Use path
|
|
/// (<c>SelectionInteractionController.RequestUse</c>) dispatches
|
|
/// <c>0x0036 Use</c> directly and never consults
|
|
/// <c>DetermineUseResult</c> — retail's <c>CPlayerSystem::UsingItem
|
|
/// @ 0x00562F70</c> DOES, and routes result 5 (target is another
|
|
/// PLAYER) to <c>ClientTradeSystem::AttemptToOpenTradeNegotiations
|
|
/// @ 0x0056DEE0</c>, gated on peace mode, with no Use send at all.
|
|
/// Returns true when the target is another player (the use is consumed
|
|
/// by the trade path either way; in combat mode retail's open attempt
|
|
/// early-outs silently).
|
|
/// </summary>
|
|
public bool TryOpenSecureTradeWithPlayer(uint targetGuid)
|
|
{
|
|
if (targetGuid == 0u || targetGuid == _playerGuid())
|
|
return false;
|
|
ClientObject? target = _objects.Get(targetGuid);
|
|
if (target is null
|
|
|| ((PublicWeenieFlags)(target.PublicWeenieBitfield ?? 0u)
|
|
& PublicWeenieFlags.Player) == 0)
|
|
return false;
|
|
|
|
if (_inNonCombatMode())
|
|
SecureTradeRequested?.Invoke(targetGuid, 0u);
|
|
return true;
|
|
}
|
|
|
|
public bool DropToWorld(ItemDragPayload payload)
|
|
=> PlaceIn3D(payload, targetGuid: 0u);
|
|
|
|
/// <summary>
|
|
/// Retail inventory drag released into SmartBox. The release target is the
|
|
/// world object under the cursor, or zero for empty ground. This is the live
|
|
/// adapter for <c>ItemHolder::AttemptPlaceIn3D @ 0x00588600</c>.
|
|
/// </summary>
|
|
public bool PlaceIn3D(ItemDragPayload payload, uint targetGuid)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(payload);
|
|
|
|
if (payload.SourceKind == ItemDragSource.ShortcutBar)
|
|
return false;
|
|
if (payload.ObjId == 0 || _objects.Get(payload.ObjId) is not { } item)
|
|
return false;
|
|
if (!EnsureInventoryRequestReady())
|
|
return false;
|
|
|
|
uint fullStack = (uint)Math.Max(1, item.StackSize);
|
|
int splitSize = (int)(_stackSplitQuantity?.GetObjectSplitSize(
|
|
item.ObjectId, _selectedObjectId(), fullStack) ?? fullStack);
|
|
ClientObject? target = targetGuid == 0u ? null : _objects.Get(targetGuid);
|
|
var decision = ItemInteractionPolicy.DecidePlacement(new ItemPlacementPolicyInput(
|
|
Snapshot(item),
|
|
_playerGuid(),
|
|
_groundObjectId(),
|
|
CanMakeInventoryRequest,
|
|
TargetId: targetGuid,
|
|
Target: target is null ? null : Snapshot(target),
|
|
AllowGroundFallback: true,
|
|
MergeAccepted: false,
|
|
DragOnPlayerOpensSecureTrade: _dragOnPlayerOpensSecureTrade(),
|
|
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;
|
|
bool busyOwnedByUseReservation = false;
|
|
foreach (var action in actions)
|
|
{
|
|
switch (action.Kind)
|
|
{
|
|
case ItemPolicyActionKind.PlaceInBackpack:
|
|
acted |= PlaceWorldItemInBackpack(action.ObjectId);
|
|
break;
|
|
case ItemPolicyActionKind.WieldRight:
|
|
case ItemPolicyActionKind.WieldLeft:
|
|
case ItemPolicyActionKind.AutoSort:
|
|
if (_objects.Get(action.ObjectId) is { } item)
|
|
acted |= _autoWield.TryWield(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)
|
|
{
|
|
if (_requestUse is not null)
|
|
{
|
|
ItemUseRequestReservation reservation =
|
|
BeginUseRequestReservation();
|
|
try
|
|
{
|
|
_requestUse(action.ObjectId, reservation);
|
|
}
|
|
catch
|
|
{
|
|
reservation.CancelBeforeDispatch();
|
|
throw;
|
|
}
|
|
busyOwnedByUseReservation = true;
|
|
}
|
|
else
|
|
{
|
|
_sendUse?.Invoke(action.ObjectId);
|
|
}
|
|
openedOrUsed = action.ObjectId;
|
|
acted |= _requestUse is not null || _sendUse is not null;
|
|
}
|
|
break;
|
|
case ItemPolicyActionKind.SendUseWithTarget:
|
|
acted |= _runtimeTransactions.TryDispatchTargetedUse(
|
|
action.ObjectId,
|
|
action.TargetId,
|
|
_sendUseWithTarget,
|
|
incrementBusy: false);
|
|
break;
|
|
case ItemPolicyActionKind.SetGroundObject:
|
|
_requestExternalContainer?.Invoke(action.ObjectId);
|
|
acted |= _requestExternalContainer is not null;
|
|
break;
|
|
case ItemPolicyActionKind.EnterTargetMode:
|
|
EnterTargetMode(action.ObjectId);
|
|
acted = true;
|
|
break;
|
|
case ItemPolicyActionKind.IncrementBusy:
|
|
if (busyOwnedByUseReservation)
|
|
{
|
|
busyOwnedByUseReservation = false;
|
|
}
|
|
else
|
|
{
|
|
_runtimeTransactions.IncrementBusyCount();
|
|
}
|
|
break;
|
|
case ItemPolicyActionKind.Reject:
|
|
if (!string.IsNullOrWhiteSpace(action.Message))
|
|
_toast?.Invoke(action.Message);
|
|
break;
|
|
case ItemPolicyActionKind.OpenSecureTrade:
|
|
// Use-on-player (ItemHolder::DetermineUseResult
|
|
// @ 0x00588460 result 5 → ClientTradeSystem::
|
|
// AttemptToOpenTradeNegotiations @ 0x0056DEE0). The
|
|
// action's ObjectId IS the target player.
|
|
SecureTradeRequested?.Invoke(action.ObjectId, 0u);
|
|
acted |= SecureTradeRequested is not null;
|
|
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 ItemUseRequestReservation BeginUseRequestReservation()
|
|
=> _runtimeTransactions.BeginUseRequestReservation();
|
|
|
|
private void ExecutePlacementActions(System.Collections.Generic.IReadOnlyList<ItemPolicyAction> actions)
|
|
{
|
|
foreach (var action in actions)
|
|
{
|
|
switch (action.Kind)
|
|
{
|
|
case ItemPolicyActionKind.StartSecureTrade:
|
|
// Drag-item-onto-player with DragItemOnPlayerOpensSecureTrade
|
|
// (ItemHolder::AttemptPlaceIn3D @ 0x00588600's option branch
|
|
// → ClientTradeSystem::AttemptToTradeItem @ 0x0056DF80).
|
|
// ObjectId = the dragged item, TargetId = the player.
|
|
SecureTradeRequested?.Invoke(action.TargetId, action.ObjectId);
|
|
break;
|
|
case ItemPolicyActionKind.DropToWorld:
|
|
TryDispatchInventoryRequest(
|
|
InventoryRequestKind.DropToWorld,
|
|
action.ObjectId,
|
|
() =>
|
|
{
|
|
if (_sendDrop is null
|
|
|| !_objects.MoveItemOptimistic(
|
|
action.ObjectId,
|
|
newContainerId: 0u,
|
|
newSlot: -1))
|
|
{
|
|
return false;
|
|
}
|
|
_sendDrop(action.ObjectId);
|
|
return true;
|
|
});
|
|
break;
|
|
case ItemPolicyActionKind.SplitToWorld:
|
|
// UIAttemptSplitTo3D leaves the original object in its container;
|
|
// the server assigns the split-off ground stack a new guid.
|
|
TryDispatchInventoryRequest(
|
|
InventoryRequestKind.SplitToWorld,
|
|
action.ObjectId,
|
|
() =>
|
|
{
|
|
if (_sendSplitToWorld is null)
|
|
return false;
|
|
_sendSplitToWorld(action.ObjectId, (uint)action.Amount);
|
|
if (_transactions.TryGetPending(out PendingInventoryRequest pending)
|
|
&& pending.Kind is InventoryRequestKind.SplitToWorld
|
|
&& pending.ItemId == action.ObjectId)
|
|
{
|
|
WorldDropDispatched?.Invoke(new WorldDropDispatch(
|
|
pending,
|
|
(uint)action.Amount));
|
|
}
|
|
return true;
|
|
});
|
|
break;
|
|
case ItemPolicyActionKind.GiveToTarget:
|
|
// UIAttemptGive @ 0x0058D620 sends the request and leaves
|
|
// the source untouched until the authoritative server
|
|
// InventoryRemoveObject / SetStackSize response arrives.
|
|
TryDispatchInventoryRequest(
|
|
InventoryRequestKind.Give,
|
|
action.ObjectId,
|
|
() =>
|
|
{
|
|
if (_sendGive is null)
|
|
return false;
|
|
_sendGive(
|
|
action.TargetId,
|
|
action.ObjectId,
|
|
(uint)Math.Max(1, action.Amount));
|
|
return true;
|
|
});
|
|
break;
|
|
case ItemPolicyActionKind.PlaceInContainer:
|
|
{
|
|
uint fullStack = (uint)Math.Max(
|
|
1,
|
|
_objects.Get(action.ObjectId)?.StackSize ?? action.Amount);
|
|
uint amount = (uint)Math.Max(1, action.Amount);
|
|
InventoryRequestKind kind = amount < fullStack
|
|
? InventoryRequestKind.SplitToContainer
|
|
: InventoryRequestKind.PutInContainer;
|
|
TryDispatchInventoryRequest(
|
|
kind,
|
|
action.ObjectId,
|
|
() =>
|
|
{
|
|
if (amount < fullStack)
|
|
{
|
|
if (_sendSplitToContainer is null)
|
|
return false;
|
|
_sendSplitToContainer(
|
|
action.ObjectId,
|
|
action.TargetId,
|
|
0u,
|
|
amount);
|
|
}
|
|
else
|
|
{
|
|
if (_sendPutItemInContainer is null)
|
|
return false;
|
|
_sendPutItemInContainer(
|
|
action.ObjectId,
|
|
action.TargetId,
|
|
0);
|
|
}
|
|
return true;
|
|
});
|
|
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)
|
|
{
|
|
_consumedPrimaryClickTarget = 0;
|
|
_interactionState.EnterUseItemOnTarget(sourceGuid);
|
|
var name = _objects.Get(sourceGuid)?.Name;
|
|
if (!string.IsNullOrWhiteSpace(name))
|
|
_toast?.Invoke($"Choose a target for the {name}");
|
|
}
|
|
|
|
private void ClearTargetMode()
|
|
{
|
|
_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 _)
|
|
{
|
|
List<Exception> failures = [];
|
|
DispatchAll(StateChanged, failures);
|
|
if (failures.Count != 0)
|
|
throw new AggregateException(
|
|
"One or more item-interaction observers failed.",
|
|
failures);
|
|
}
|
|
|
|
private void OnTransactionStateChanged()
|
|
{
|
|
List<Exception> failures = [];
|
|
DispatchAll(StateChanged, failures);
|
|
if (failures.Count != 0)
|
|
{
|
|
throw new AggregateException(
|
|
"One or more item-interaction observers failed.",
|
|
failures);
|
|
}
|
|
}
|
|
|
|
private void OnInventoryRequestCompleted(PendingInventoryRequest request)
|
|
{
|
|
if (_pendingBackpackPlacement is { } placement
|
|
&& placement.Token == request.Token
|
|
&& placement.ItemId == request.ItemId)
|
|
{
|
|
_pendingBackpackPlacement = null;
|
|
List<Exception> failures = [];
|
|
DispatchAll(PendingBackpackPlacementResolved, placement, failures);
|
|
if (failures.Count != 0)
|
|
{
|
|
throw new AggregateException(
|
|
"One or more pending-placement resolution observers failed.",
|
|
failures);
|
|
}
|
|
}
|
|
}
|
|
|
|
private void OnInventoryObjectsCleared()
|
|
{
|
|
_pendingBackpackPlacement = null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// <c>ACCWeenieObject::ServerSaysAttemptFailed @ 0x0058EAE0</c>: the
|
|
/// server rejected the latched inventory request — compose
|
|
/// "The <item> can't be <verb>" and route it as LogTextType
|
|
/// 0x1A (ClientLocal, the SpewBox-only channel). No resolvable item or no
|
|
/// latched kind → no text, exactly retail's silence.
|
|
/// </summary>
|
|
private void OnInventoryRequestFailed(
|
|
PendingInventoryRequest request,
|
|
uint weenieError)
|
|
{
|
|
if (_interfaceText is null)
|
|
return;
|
|
ClientObject? item = request.ItemIdentity ?? _objects.Get(request.ItemId);
|
|
if (item is null)
|
|
return;
|
|
// NAME_PLURAL for merge/split, NAME_APPROPRIATE otherwise (the
|
|
// ServerSaysAttemptFailed name-style column); a wire-omitted plural
|
|
// falls back to the appropriate form.
|
|
bool plural = request.Kind
|
|
is InventoryRequestKind.Merge
|
|
or InventoryRequestKind.SplitToContainer
|
|
or InventoryRequestKind.SplitToWorld;
|
|
string name = plural && !string.IsNullOrEmpty(item.PluralName)
|
|
? item.PluralName
|
|
: item.GetAppropriateName();
|
|
if (string.IsNullOrEmpty(name))
|
|
return;
|
|
if (InventoryFailureMessages.Compose(request.Kind, name, weenieError)
|
|
is { } text)
|
|
{
|
|
_interfaceText(text, RetailLogTextType.ClientLocal);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The 0x00A0 dispatcher's second leg (<c>case 0xA0 @ 0x0055B342</c>):
|
|
/// unless the error is in the exclusion set, the generic
|
|
/// <c>HandleFailureEvent</c> table also runs — independent of whether a
|
|
/// request was latched. Codes without a table row (0x426 AttunedItem
|
|
/// among them) resolve to null text and stay silent.
|
|
/// </summary>
|
|
private void OnMoveRequestFailedNotice(MoveRequestFailure failure)
|
|
{
|
|
if (_interfaceText is null
|
|
|| failure.WeenieError == 0u
|
|
|| InventoryFailureMessages.SuppressesGenericFailureText(
|
|
failure.WeenieError))
|
|
{
|
|
return;
|
|
}
|
|
var (text, type) = WeenieErrorMessages.Resolve(failure.WeenieError, null);
|
|
if (text is not null)
|
|
_interfaceText(text, type);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed) return;
|
|
_disposed = true;
|
|
_interactionState.Changed -= OnInteractionModeChanged;
|
|
_objects.MoveRequestFailed -= OnMoveRequestFailedNotice;
|
|
_transactions.ObjectTableCleared -= OnInventoryObjectsCleared;
|
|
_transactions.RequestFailed -= OnInventoryRequestFailed;
|
|
_transactions.RequestCompleted -= OnInventoryRequestCompleted;
|
|
_transactions.StateChanged -= OnTransactionStateChanged;
|
|
WorldDropDispatched = null;
|
|
_autoWield.Dispose();
|
|
}
|
|
|
|
/// <summary>Retail UseDone (0x01C7) releases one UI busy reference.</summary>
|
|
public void CompleteUse(uint error)
|
|
=> _runtimeTransactions.CompleteUse(error);
|
|
|
|
/// <summary>Session teardown drops every outstanding client UI busy reference.</summary>
|
|
public void ClearBusy()
|
|
=> _transactions.ClearBusy();
|
|
|
|
/// <summary>
|
|
/// Ends all session-scoped ItemHolder state. Target modes, click
|
|
/// suppression, and use throttles must not survive a reconnect where the
|
|
/// same numeric GUID can identify a different object.
|
|
/// </summary>
|
|
public void ResetSession()
|
|
=> ResetSessionCore(resetRuntime: true);
|
|
|
|
/// <summary>
|
|
/// Clears only retained UI transaction presentation. Runtime's canonical
|
|
/// transaction, interaction, and busy owners are reset once by the shared
|
|
/// generation-reset transaction.
|
|
/// </summary>
|
|
internal void ResetGenerationPresentation()
|
|
=> ResetSessionCore(resetRuntime: false);
|
|
|
|
private void ResetSessionCore(bool resetRuntime)
|
|
{
|
|
PendingBackpackPlacement? pendingPlacement = _pendingBackpackPlacement;
|
|
_pendingBackpackPlacement = null;
|
|
if (resetRuntime)
|
|
{
|
|
// Runtime owns the transaction reset. This controller preserves
|
|
// the pre-J4 single UI notification emitted by InteractionState.
|
|
_transactions.StateChanged -= OnTransactionStateChanged;
|
|
try
|
|
{
|
|
_runtimeTransactions.ResetSession();
|
|
}
|
|
finally
|
|
{
|
|
if (!_disposed)
|
|
_transactions.StateChanged += OnTransactionStateChanged;
|
|
}
|
|
}
|
|
_consumedPrimaryClickTarget = 0u;
|
|
_consumedPrimaryClickMs = long.MinValue / 2;
|
|
|
|
List<Exception> failures = [];
|
|
if (resetRuntime)
|
|
{
|
|
try { _interactionState.ResetSession(); }
|
|
catch (Exception error) { failures.Add(error); }
|
|
}
|
|
|
|
if (pendingPlacement is { } pending)
|
|
DispatchAll(PendingBackpackPlacementCancelled, pending, failures);
|
|
|
|
if (failures.Count != 0)
|
|
throw new AggregateException(
|
|
"One or more item-interaction reset observers failed.",
|
|
failures);
|
|
}
|
|
|
|
public readonly record struct AppraisalResponseAcceptance(
|
|
bool Accepted,
|
|
bool FirstResponse);
|
|
|
|
private static void DispatchAll(Action? listeners, List<Exception> failures)
|
|
{
|
|
if (listeners is null)
|
|
return;
|
|
foreach (Action listener in listeners.GetInvocationList())
|
|
{
|
|
try { listener(); }
|
|
catch (Exception error) { failures.Add(error); }
|
|
}
|
|
}
|
|
|
|
private static void DispatchAll<T>(
|
|
Action<T>? listeners,
|
|
T value,
|
|
List<Exception> failures)
|
|
{
|
|
if (listeners is null)
|
|
return;
|
|
foreach (Action<T> listener in listeners.GetInvocationList())
|
|
{
|
|
try { listener(value); }
|
|
catch (Exception error) { failures.Add(error); }
|
|
}
|
|
}
|
|
|
|
private bool ConsumeUseThrottle()
|
|
=> _runtimeTransactions.TryConsumeUseThrottle(_nowMs());
|
|
|
|
private static bool IsContainer(ClientObject item)
|
|
=> item.ContainerTypeHint != 0
|
|
|| item.Type.HasFlag(ItemType.Container)
|
|
|| item.ItemsCapacity > 0;
|
|
|
|
private ItemPolicyObject Snapshot(ClientObject item)
|
|
{
|
|
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>
|
|
/// App adapter for Core's pure port of
|
|
/// <c>ItemHolder::IsTargetCompatibleWithTargetingObject @ 0x00588070</c>.
|
|
/// </summary>
|
|
private bool TargetCompatible(ClientObject source, uint targetGuid)
|
|
{
|
|
var target = _objects.Get(targetGuid);
|
|
if (target is null)
|
|
return false; // retail: GetWeenieObject(target) null → incompatible
|
|
return ItemInteractionPolicy.IsTargetCompatible(
|
|
Snapshot(source), Snapshot(target), _playerGuid());
|
|
}
|
|
|
|
private bool IsEquippedByPlayer(ClientObject item)
|
|
{
|
|
uint player = _playerGuid();
|
|
return item.CurrentlyEquippedLocation != EquipMask.None
|
|
&& (item.WielderId == player || item.ContainerId == player);
|
|
}
|
|
|
|
private bool IsCarriedByPlayer(ClientObject item)
|
|
{
|
|
uint player = _playerGuid();
|
|
uint container = item.ContainerId;
|
|
for (int hops = 0; container != 0 && hops < 8; hops++)
|
|
{
|
|
if (container == player) return true;
|
|
container = _objects.Get(container)?.ContainerId ?? 0u;
|
|
}
|
|
return false;
|
|
}
|
|
}
|