feat(ui): port retail item interaction policy
This commit is contained in:
parent
0cf780478a
commit
a8da4fd05a
20 changed files with 1110 additions and 104 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 & target->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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue