437 lines
17 KiB
C#
437 lines
17 KiB
C#
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);
|
|
}
|