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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
437
src/AcDream.Core/Items/ItemInteractionPolicy.cs
Normal file
437
src/AcDream.Core/Items/ItemInteractionPolicy.cs
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue