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();

View file

@ -68,7 +68,8 @@ public static class GameEventWiring
Action<IReadOnlyList<PlayerDescriptionParser.ShortcutEntry>>? onShortcuts = null,
// B-Wire: the local player's server guid. When provided, the PD handler upserts
// the player's own PropertyBundle (EncumbranceVal etc.) into the player ClientObject.
Func<uint>? playerGuid = null)
Func<uint>? playerGuid = null,
Action<uint /*weenieError*/>? onUseDone = null)
{
ArgumentNullException.ThrowIfNull(dispatcher);
ArgumentNullException.ThrowIfNull(items);
@ -316,6 +317,7 @@ public static class GameEventWiring
uint? err = GameEvents.ParseUseDone(e.Payload.Span);
if (err is null) return;
Console.WriteLine($"[use-done] err=0x{err.Value:X4}");
onUseDone?.Invoke(err.Value);
if (err.Value != 0)
chat.OnSystemMessage(WeenieErrorText.For(err.Value), chatType: 0);
});

View file

@ -193,7 +193,8 @@ public static class CreateObject
// Nullable preserves the wire distinction between an absent flag and
// an explicitly transmitted zero (the enum's undefined/default value).
byte? RadarBlipColor = null,
byte? RadarBehavior = null);
byte? RadarBehavior = null,
byte? CombatUse = null);
/// <summary>
/// The relevant subset of the server-sent <c>MovementData</c> /
@ -750,6 +751,7 @@ public static class CreateObject
uint? targetType = null;
byte? radarBlipColor = null;
byte? radarBehavior = null;
byte? combatUse = null;
uint iconOverlayId = 0;
uint iconUnderlayId = 0;
uint uiEffects = 0;
@ -822,7 +824,7 @@ public static class CreateObject
if ((weenieFlags & 0x00000200u) != 0) // CombatUse sbyte (1 byte)
{
if (body.Length - pos < 1) throw new FormatException("trunc CombatUse");
pos += 1;
combatUse = body[pos]; pos += 1;
}
if ((weenieFlags & 0x00000400u) != 0) // Structure u16
{
@ -966,7 +968,8 @@ public static class CreateObject
ValidLocations: wValidLocations, CurrentWieldedLocation: wCurrentWieldedLocation,
Priority: wPriority, Structure: wStructure, MaxStructure: wMaxStructure,
Workmanship: wWorkmanship,
RadarBlipColor: radarBlipColor, RadarBehavior: radarBehavior);
RadarBlipColor: radarBlipColor, RadarBehavior: radarBehavior,
CombatUse: combatUse);
// Local helper: if we ran out of fields past PhysicsData, still
// return the useful prefix (guid/position/setup/animParts/textures/palettes/scale/motion).

View file

@ -85,5 +85,7 @@ public static class ObjectTableWiring
Useability: s.Useability,
TargetType: s.TargetType,
RadarBlipColor: s.RadarBlipColor,
RadarBehavior: s.RadarBehavior);
RadarBehavior: s.RadarBehavior,
PublicWeenieBitfield: s.ObjectDescriptionFlags,
CombatUse: s.CombatUse);
}

View file

@ -121,7 +121,8 @@ public sealed class WorldSession : IDisposable
// flag was absent; zero means the server explicitly sent the enum's
// undefined/default value.
byte? RadarBlipColor = null,
byte? RadarBehavior = null);
byte? RadarBehavior = null,
byte? CombatUse = null);
/// <summary>
/// Projects the wire-level CreateObject result into the stable session
@ -172,7 +173,8 @@ public sealed class WorldSession : IDisposable
MovementSequence: parsed.MovementSequence,
ServerControlSequence: parsed.ServerControlSequence,
RadarBlipColor: parsed.RadarBlipColor,
RadarBehavior: parsed.RadarBehavior);
RadarBehavior: parsed.RadarBehavior,
CombatUse: parsed.CombatUse);
/// <summary>Fires when the session finishes parsing a CreateObject.</summary>
public event Action<EntitySpawn>? EntitySpawned;

View file

@ -178,6 +178,14 @@ public sealed class ClientObject
public uint Priority { get; set; } // ClothingPriority / CoverageMask layer order
public uint? Useability { get; set; } // ITEM_USEABLE from PublicWeenieDesc
public uint? TargetType { get; set; } // ITEM_TYPE mask for targeted-use compatibility
/// <summary>Retail <c>PublicWeenieDesc._bitfield</c>; null until CreateObject supplies it.</summary>
public uint? PublicWeenieBitfield { get; set; }
/// <summary>Retail <c>PublicWeenieDesc._combatUse</c>; null when its header flag was absent.</summary>
public byte? CombatUse { get; set; }
/// <summary>Client-side trade state used by ItemHolder legality gates.</summary>
public int TradeState { get; set; }
/// <summary>Resolved membership in SpellComponentTable (retail IsComponentPack).</summary>
public bool IsComponentPack { get; set; }
/// <summary>Retail PublicWeenieDesc <c>_blipColor</c>; null until supplied by CreateObject.</summary>
public byte? RadarBlipColor { get; set; }
/// <summary>Retail PublicWeenieDesc <c>_radar_enum</c>; null until supplied by CreateObject.</summary>
@ -223,7 +231,9 @@ public readonly record struct WeenieData(
uint? Useability = null,
uint? TargetType = null,
byte? RadarBlipColor = null,
byte? RadarBehavior = null);
byte? RadarBehavior = null,
uint? PublicWeenieBitfield = null,
byte? CombatUse = null);
/// <summary>
/// Retail ITEM_USEABLE helpers (acclient.h:6478, ItemUses::* at 0x004fccd0).
@ -257,6 +267,18 @@ public static class ItemUseability
public static uint SourceFlags(uint useability)
=> useability & SourceMask;
/// <summary>Retail <c>ItemUses::GetLeastLimitedSourceUse</c>.</summary>
public static uint LeastLimitedSourceUse(uint useability)
{
uint s = SourceFlags(useability);
if ((s & Remote) != 0) return Remote;
if ((s & Viewed) != 0) return Viewed;
if ((s & Contained) != 0) return Contained;
if ((s & Wielded) != 0) return Wielded;
if ((s & Self) != 0) return Self;
return Undef;
}
public static uint TargetFlags(uint useability)
=> (useability & TargetMask) >> 16;

View file

@ -386,6 +386,8 @@ public sealed class ClientObjectTable
if (d.Priority is { } pr) obj.Priority = pr;
if (d.Useability is { } use) obj.Useability = use;
if (d.TargetType is { } targetType) obj.TargetType = targetType;
if (d.PublicWeenieBitfield is { } bitfield) obj.PublicWeenieBitfield = bitfield;
if (d.CombatUse is { } combatUse) obj.CombatUse = combatUse;
if (d.RadarBlipColor is { } radarBlipColor) obj.RadarBlipColor = radarBlipColor;
if (d.RadarBehavior is { } radarBehavior) obj.RadarBehavior = radarBehavior;
if (d.ItemsCapacity is { } ic) obj.ItemsCapacity = ic;

View file

@ -0,0 +1,437 @@
using System;
using System.Collections.Generic;
namespace AcDream.Core.Items;
/// <summary>
/// Retail <c>PublicWeenieDesc::BitfieldIndex</c> values used by item interaction.
/// Names and values are from named-retail <c>acclient.h:6431</c>; the two damaged
/// decompiler expressions were verified in the matching v11.4186 executable at
/// <c>0x005884B6</c> and <c>0x00588752</c>.
/// </summary>
[Flags]
public enum PublicWeenieFlags : uint
{
None = 0,
Openable = 0x00000001,
Stuck = 0x00000004,
Player = 0x00000008,
Attackable = 0x00000010,
Vendor = 0x00000200,
PlayerKillerSwitch = 0x00000400,
NonPlayerKillerSwitch = 0x00000800,
Door = 0x00001000,
RequiresPackSlot = 0x00800000,
VolatileRare = 0x10000000,
WieldOnUse = 0x20000000,
WieldLeft = 0x40000000,
}
/// <summary>Return values of retail <c>ItemHolder::DetermineUseResult</c>.</summary>
public enum ItemPrimaryUseResult : ushort
{
None = 0,
ItemUse = 1,
PlaceInBackpack = 2,
WieldRight = 3,
AutoSort = 4,
OpenSecureTrade = 5,
OpenSalvage = 6,
BeginGame = 7,
WieldLeft = 8,
}
/// <summary>
/// Immutable data needed by the retail item rules. App builds this snapshot from
/// the mutable object table; Core never reaches into UI, networking, or physics.
/// </summary>
public readonly record struct ItemPolicyObject(
uint Id,
ItemType Type,
PublicWeenieFlags Flags,
uint ContainerId,
uint WielderId,
EquipMask ValidLocations,
EquipMask CurrentLocation,
byte CombatUse,
int ItemsCapacity,
int ContainersCapacity,
uint Useability,
uint TargetType,
bool OwnedByPlayer,
bool IsContainer,
bool IsComponentPack,
int TradeState,
int StackSize,
int MaxSplitSize,
bool IsIn3DView)
{
public bool IsPlayer => (Flags & PublicWeenieFlags.Player) != 0;
}
public enum ItemPolicyActionKind
{
PlaceInBackpack,
WieldRight,
AutoSort,
OpenSecureTrade,
OpenSalvage,
BeginGame,
WieldLeft,
OpenContainedContainer,
SetGroundObject,
EnterTargetMode,
SendUse,
SendUseWithTarget,
IncrementBusy,
ConfirmPlayerKillerSwitch,
ConfirmNonPlayerKillerSwitch,
ConfirmVolatileRare,
MergeStack,
SellToVendor,
StartSecureTrade,
GiveToTarget,
PlaceInContainer,
SplitToWorld,
DropToWorld,
Reject,
}
public readonly record struct ItemPolicyAction(
ItemPolicyActionKind Kind,
uint ObjectId = 0,
uint TargetId = 0,
int Amount = 0,
string? Message = null);
public readonly record struct ItemUsePolicyInput(
ItemPolicyObject Source,
uint PlayerId,
uint GroundObjectId,
bool ReadyForInventoryRequest,
uint ActiveVendorId,
bool BypassClassification,
bool UseCurrentSelection,
ItemPolicyObject? SelectedTarget,
bool ConfirmVolatileRareUses,
bool InNonCombatMode);
public readonly record struct ItemUsePolicyDecision(
bool Consumed,
IReadOnlyList<ItemPolicyAction> Actions);
public readonly record struct ItemPlacementPolicyInput(
ItemPolicyObject Item,
uint PlayerId,
uint GroundObjectId,
bool ReadyForInventoryRequest,
uint TargetId,
ItemPolicyObject? Target,
bool AllowGroundFallback,
bool MergeAccepted,
bool DragOnPlayerOpensSecureTrade,
bool PlayerOnGround,
int SplitSize);
/// <summary>
/// Retail's placement return is deliberately separate from its action list:
/// vendor sale and secure-trade attempts return false even though they act.
/// </summary>
public readonly record struct ItemPlacementPolicyDecision(
bool ReturnValue,
IReadOnlyList<ItemPolicyAction> Actions);
/// <summary>
/// Pure ports of <c>ItemHolder::DetermineUseResult @ 0x00588460</c>,
/// <c>UseObject @ 0x00588A80</c>, and <c>AttemptPlaceIn3D @ 0x00588600</c>.
/// </summary>
public static class ItemInteractionPolicy
{
private static readonly EquipMask BodyLocations = (EquipMask)0x00007E00u;
private static readonly EquipMask ClothingLocations = (EquipMask)0x080001FFu;
private static readonly EquipMask HeldLocations = (EquipMask)0x7C0F8000u;
public static ItemPrimaryUseResult DetermineUseResult(
in ItemPolicyObject item,
uint playerId,
uint groundObjectId)
{
bool looseOrViewed = (item.ContainerId == 0
&& (item.Flags & PublicWeenieFlags.Stuck) == 0)
|| (groundObjectId != 0 && item.ContainerId == groundObjectId);
if (looseOrViewed && (item.WielderId == 0 || item.WielderId == playerId))
{
bool ordinaryLooseItem = (item.Flags & PublicWeenieFlags.RequiresPackSlot) == 0
&& item.ItemsCapacity == 0
&& item.ContainersCapacity == 0;
if (ordinaryLooseItem || item.IsComponentPack)
return ItemPrimaryUseResult.PlaceInBackpack;
}
if (item.OwnedByPlayer)
{
bool wieldOnPrimaryUse = item.CombatUse != 0
|| (item.Type & ItemType.Caster) != 0
|| (item.Flags & PublicWeenieFlags.WieldOnUse) != 0;
if (wieldOnPrimaryUse && item.WielderId != playerId)
return (item.Flags & PublicWeenieFlags.WieldLeft) != 0
? ItemPrimaryUseResult.WieldLeft
: ItemPrimaryUseResult.WieldRight;
if (HasUnusedLocation(item.ValidLocations, item.CurrentLocation))
return ItemPrimaryUseResult.AutoSort;
if ((item.Type & ItemType.TinkeringTool) != 0)
return ItemPrimaryUseResult.OpenSalvage;
}
else if ((item.Type & ItemType.Gameboard) != 0)
{
return ItemPrimaryUseResult.BeginGame;
}
if (IsUseable(item.Useability))
return ItemPrimaryUseResult.ItemUse;
if (item.IsPlayer && item.Id != playerId)
return ItemPrimaryUseResult.OpenSecureTrade;
return ItemPrimaryUseResult.None;
}
public static ItemUsePolicyDecision DecideUse(in ItemUsePolicyInput input)
{
var source = input.Source;
if (!input.ReadyForInventoryRequest)
return Consumed();
if (input.ActiveVendorId != 0 && source.ContainerId == input.ActiveVendorId)
return Consumed();
if (!input.BypassClassification)
{
var primary = DetermineUseResult(source, input.PlayerId, input.GroundObjectId);
// Exact retail bound: UseObject classifies 2..7, deliberately excluding 8.
if (primary is >= ItemPrimaryUseResult.PlaceInBackpack
and <= ItemPrimaryUseResult.BeginGame)
return Consumed(BuildUsingItemActions(source, input.PlayerId,
input.GroundObjectId, primary));
}
if (source.TradeState == 1)
return Reject("You cannot use an item while it is being traded.");
if (source.CurrentLocation == EquipMask.None
&& ItemUseability.LeastLimitedSourceUse(source.Useability) == ItemUseability.Wielded)
return Reject("You must wield that item before you can use it.");
if (ItemUseability.IsTargeted(source.Useability))
{
if (!input.UseCurrentSelection)
return Consumed(new ItemPolicyAction(ItemPolicyActionKind.EnterTargetMode, source.Id));
if (input.SelectedTarget is not { } target)
return Reject("Select a target for this item first.");
if (!IsTargetCompatible(source, target, input.PlayerId))
return Reject("That is not a valid target for this item.");
var actions = new List<ItemPolicyAction>
{
new(ItemPolicyActionKind.SendUseWithTarget, source.Id, target.Id),
new(ItemPolicyActionKind.IncrementBusy),
};
actions.AddRange(BuildUsingItemActions(source, input.PlayerId,
input.GroundObjectId, DetermineUseResult(source, input.PlayerId, input.GroundObjectId)));
return Consumed(actions);
}
if (IsUseable(source.Useability))
{
if ((source.Flags & PublicWeenieFlags.PlayerKillerSwitch) != 0)
return Consumed(new ItemPolicyAction(
ItemPolicyActionKind.ConfirmPlayerKillerSwitch, source.Id));
if ((source.Flags & PublicWeenieFlags.NonPlayerKillerSwitch) != 0)
return Consumed(new ItemPolicyAction(
ItemPolicyActionKind.ConfirmNonPlayerKillerSwitch, source.Id));
if ((source.Flags & PublicWeenieFlags.VolatileRare) != 0
&& input.ConfirmVolatileRareUses)
return Consumed(new ItemPolicyAction(
ItemPolicyActionKind.ConfirmVolatileRare, source.Id));
var actions = new List<ItemPolicyAction>
{
new(ItemPolicyActionKind.SendUse, source.Id),
new(ItemPolicyActionKind.IncrementBusy),
};
actions.AddRange(BuildUsingItemActions(source, input.PlayerId,
input.GroundObjectId, DetermineUseResult(source, input.PlayerId, input.GroundObjectId)));
return Consumed(actions);
}
var fallback = BuildUsingItemActions(source, input.PlayerId,
input.GroundObjectId, DetermineUseResult(source, input.PlayerId, input.GroundObjectId));
if (fallback.Count != 0)
return Consumed(fallback);
if (source.Id == input.PlayerId)
return new ItemUsePolicyDecision(false, Array.Empty<ItemPolicyAction>());
if ((source.Flags & PublicWeenieFlags.Door) != 0)
return Reject("You cannot open or close that object right now.");
if ((source.Flags & PublicWeenieFlags.Attackable) != 0
&& input.InNonCombatMode)
return Reject("You must switch to a combat mode before attacking that target.");
if ((source.Flags & PublicWeenieFlags.Attackable) == 0
|| input.InNonCombatMode)
return Reject("That object cannot be used.");
return new ItemUsePolicyDecision(false, Array.Empty<ItemPolicyAction>());
}
public static bool IsTargetCompatible(
in ItemPolicyObject source,
in ItemPolicyObject target,
uint playerId)
{
if (source.TradeState == 1)
return false;
uint flags = ItemUseability.TargetFlags(source.Useability);
if (!target.OwnedByPlayer)
{
uint least = ItemUseability.LeastLimitedTargetUse(source.Useability);
if ((least & ItemUseability.Contained) != 0)
{
if (!(target.Id == playerId && (flags & ItemUseability.Self) != 0))
return false;
}
else if ((least & ItemUseability.Wielded) != 0)
{
return false;
}
}
if (target.Id == playerId && (flags & ItemUseability.Self) == 0)
return false;
return (source.TargetType & (uint)target.Type) != 0;
}
public static ItemPlacementPolicyDecision DecidePlacement(
in ItemPlacementPolicyInput input)
{
if (!input.ReadyForInventoryRequest)
return Placement(false);
if (input.TargetId == input.PlayerId)
return Placement(true, new ItemPolicyAction(ItemPolicyActionKind.PlaceInBackpack, input.Item.Id));
if (!input.Item.OwnedByPlayer)
return Placement(false, RejectAction("You must first pick up that item."));
if (input.Item.TradeState != 0)
return Placement(false, RejectAction("You cannot move an item while it is being traded."));
if (input.TargetId == 0)
return input.AllowGroundFallback ? PlaceOnGround(input) : Placement(false);
if (input.MergeAccepted)
return Placement(true, new ItemPolicyAction(ItemPolicyActionKind.MergeStack,
input.Item.Id, input.TargetId, input.SplitSize));
if (input.Target is not { } target)
return input.AllowGroundFallback ? PlaceOnGround(input) : Placement(false);
if ((target.Flags & PublicWeenieFlags.Vendor) != 0)
{
if (input.SplitSize >= input.Item.MaxSplitSize)
return Placement(false, new ItemPolicyAction(ItemPolicyActionKind.SellToVendor,
input.Item.Id, target.Id, input.SplitSize));
return Placement(false, RejectAction("Split the stack before selling part of it."));
}
if (input.DragOnPlayerOpensSecureTrade && target.IsPlayer)
return Placement(false, new ItemPolicyAction(ItemPolicyActionKind.StartSecureTrade,
input.Item.Id, target.Id, input.SplitSize));
if (target.Type == ItemType.Creature)
return Placement(true, new ItemPolicyAction(ItemPolicyActionKind.GiveToTarget,
input.Item.Id, target.Id, input.SplitSize));
if (target.IsContainer)
{
if ((target.Flags & PublicWeenieFlags.Openable) == 0)
return Placement(false, RejectAction("That container is locked."));
if (target.Id != input.GroundObjectId)
return Placement(false, RejectAction("You must open that container first."));
return Placement(true, new ItemPolicyAction(ItemPolicyActionKind.PlaceInContainer,
input.Item.Id, target.Id, input.SplitSize));
}
if (input.AllowGroundFallback)
return PlaceOnGround(input);
return Placement(false, RejectAction("You cannot give that item to this target."));
}
private static IReadOnlyList<ItemPolicyAction> BuildUsingItemActions(
in ItemPolicyObject item,
uint playerId,
uint groundObjectId,
ItemPrimaryUseResult primary)
{
var actions = new List<ItemPolicyAction>(3);
ItemPolicyActionKind? primaryAction = primary switch
{
ItemPrimaryUseResult.PlaceInBackpack => ItemPolicyActionKind.PlaceInBackpack,
ItemPrimaryUseResult.WieldRight => ItemPolicyActionKind.WieldRight,
ItemPrimaryUseResult.AutoSort => ItemPolicyActionKind.AutoSort,
ItemPrimaryUseResult.OpenSecureTrade => ItemPolicyActionKind.OpenSecureTrade,
ItemPrimaryUseResult.OpenSalvage => ItemPolicyActionKind.OpenSalvage,
ItemPrimaryUseResult.BeginGame => ItemPolicyActionKind.BeginGame,
ItemPrimaryUseResult.WieldLeft => ItemPolicyActionKind.WieldLeft,
_ => null,
};
if (primaryAction is { } action)
actions.Add(new ItemPolicyAction(action, item.Id));
if (item.OwnedByPlayer && item.IsContainer)
actions.Add(new ItemPolicyAction(ItemPolicyActionKind.OpenContainedContainer, item.Id));
if (!item.OwnedByPlayer && item.IsContainer
&& IsUseable(item.Useability)
&& !ItemUseability.IsTargeted(item.Useability))
actions.Add(new ItemPolicyAction(ItemPolicyActionKind.SetGroundObject, item.Id,
groundObjectId));
return actions;
}
private static bool HasUnusedLocation(EquipMask valid, EquipMask current)
=> ((valid & BodyLocations) != 0 && (current & BodyLocations) == 0)
|| ((valid & ClothingLocations) != 0 && (current & ClothingLocations) == 0)
|| ((valid & HeldLocations) != 0 && (current & HeldLocations) == 0);
private static bool IsUseable(uint useability)
=> (ItemUseability.SourceFlags(useability)
& ~(ItemUseability.No | ItemUseability.NeverWalk)) != 0;
private static ItemPlacementPolicyDecision PlaceOnGround(
in ItemPlacementPolicyInput input)
{
if (!input.PlayerOnGround)
return Placement(false, RejectAction("You cannot do that in mid air."));
if (input.SplitSize < input.Item.MaxSplitSize)
return Placement(true, new ItemPolicyAction(ItemPolicyActionKind.SplitToWorld,
input.Item.Id, Amount: input.SplitSize));
if (!input.Item.IsIn3DView)
return Placement(true, new ItemPolicyAction(ItemPolicyActionKind.DropToWorld, input.Item.Id));
return Placement(false, RejectAction("Move cancelled."));
}
private static ItemUsePolicyDecision Consumed(params ItemPolicyAction[] actions)
=> new(true, actions);
private static ItemUsePolicyDecision Consumed(IReadOnlyList<ItemPolicyAction> actions)
=> new(true, actions);
private static ItemUsePolicyDecision Reject(string message)
=> Consumed(RejectAction(message));
private static ItemPolicyAction RejectAction(string message)
=> new(ItemPolicyActionKind.Reject, Message: message);
private static ItemPlacementPolicyDecision Placement(
bool returnValue,
params ItemPolicyAction[] actions)
=> new(returnValue, actions);
}