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

@ -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);
}