fix(client): restore retail interaction parity
Harden keyboard and camera routing, inventory and vendor interactions, chat/emotes, relog portal flow, and paperdoll rendering. Add retail research, connected gate coverage, and release-gate validation.
This commit is contained in:
parent
0c699240e0
commit
f6fe0f2a4f
151 changed files with 10162 additions and 1211 deletions
|
|
@ -24,14 +24,31 @@ public readonly record struct ExternalContainerTransition(
|
|||
/// </summary>
|
||||
public sealed class ExternalContainerState
|
||||
{
|
||||
private readonly HashSet<uint> _openedCorpses = [];
|
||||
|
||||
public uint RequestedContainerId { get; private set; }
|
||||
public uint CurrentContainerId { get; private set; }
|
||||
public int OpenedCorpseCount => _openedCorpses.Count;
|
||||
|
||||
public event Action<ExternalContainerTransition>? Changed;
|
||||
|
||||
public bool RequestOpen(uint containerId)
|
||||
/// <summary>
|
||||
/// Sets retail's requested ground object. When that object is a corpse,
|
||||
/// this is also the exact <c>SetGroundObject</c> edge at which retail calls
|
||||
/// <c>ACCWeenieObject::SetCorpseOpened @ 0x0058E670</c>.
|
||||
/// </summary>
|
||||
public bool RequestOpen(uint containerId, bool isCorpse = false)
|
||||
{
|
||||
if (containerId == 0u || RequestedContainerId == containerId)
|
||||
if (containerId == 0u)
|
||||
return false;
|
||||
|
||||
// Retail marks the corpse on the SetGroundObject edge even when the
|
||||
// requested ground-object id is already current. Keep that lifetime
|
||||
// fact independent from whether this call changes presentation state.
|
||||
if (isCorpse)
|
||||
_openedCorpses.Add(containerId);
|
||||
|
||||
if (RequestedContainerId == containerId)
|
||||
return false;
|
||||
|
||||
uint previous = CurrentContainerId;
|
||||
|
|
@ -54,6 +71,19 @@ public sealed class ExternalContainerState
|
|||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>ACCWeenieObject::HasCorpseBeenOpened @ 0x0058DB70</c>.
|
||||
/// The set is session-scoped and an object's delete edge removes its id.
|
||||
/// </summary>
|
||||
public bool HasCorpseBeenOpened(uint objectId)
|
||||
=> objectId != 0u && _openedCorpses.Contains(objectId);
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>ACCWeenieObject::SetCorpseDeleted @ 0x0058E6C0</c>.
|
||||
/// </summary>
|
||||
public bool SetCorpseDeleted(uint objectId)
|
||||
=> objectId != 0u && _openedCorpses.Remove(objectId);
|
||||
|
||||
public bool ApplyViewContents(uint containerId)
|
||||
{
|
||||
if (containerId == 0u || containerId != RequestedContainerId)
|
||||
|
|
@ -98,9 +128,12 @@ public sealed class ExternalContainerState
|
|||
public bool Reset()
|
||||
{
|
||||
uint previous = CurrentContainerId;
|
||||
bool changed = previous != 0u || RequestedContainerId != 0u;
|
||||
bool changed = previous != 0u
|
||||
|| RequestedContainerId != 0u
|
||||
|| _openedCorpses.Count != 0;
|
||||
CurrentContainerId = 0u;
|
||||
RequestedContainerId = 0u;
|
||||
_openedCorpses.Clear();
|
||||
|
||||
var transition = new ExternalContainerTransition(
|
||||
ExternalContainerTransitionKind.Reset,
|
||||
|
|
|
|||
158
src/AcDream.Core/Items/InventoryContainerPlacementPolicy.cs
Normal file
158
src/AcDream.Core/Items/InventoryContainerPlacementPolicy.cs
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
namespace AcDream.Core.Items;
|
||||
|
||||
/// <summary>
|
||||
/// Side-effect-free container-placement result shared by drag hover and
|
||||
/// release. It ports the observable rules from
|
||||
/// <c>ItemHolder::AttemptToPlaceInContainer_IsItemLegal @ 0x005870C0</c>,
|
||||
/// <c>AttemptToPlaceInContainer_IsContainerLegal @ 0x005879B0</c>, and
|
||||
/// <c>WillItemFitInContainer @ 0x00587D60</c> that can be answered from the
|
||||
/// client's public object projection.
|
||||
/// </summary>
|
||||
public enum InventoryContainerPlacementRejection
|
||||
{
|
||||
None,
|
||||
InvalidItem,
|
||||
CannotMovePlayer,
|
||||
CannotMoveCreature,
|
||||
SourceBeingTraded,
|
||||
InvalidDestination,
|
||||
DestinationBeingTraded,
|
||||
RecursiveContainment,
|
||||
ItemCapacityFull,
|
||||
ContainerCapacityFull,
|
||||
}
|
||||
|
||||
public static class InventoryContainerPlacementPolicy
|
||||
{
|
||||
public static InventoryContainerPlacementRejection Evaluate(
|
||||
ClientObjectTable objects,
|
||||
uint itemId,
|
||||
uint destinationId,
|
||||
uint playerId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(objects);
|
||||
if (itemId == 0u || objects.Get(itemId) is not { } item)
|
||||
return InventoryContainerPlacementRejection.InvalidItem;
|
||||
if (itemId == playerId)
|
||||
return InventoryContainerPlacementRejection.CannotMovePlayer;
|
||||
if ((item.Type & ItemType.Creature) != 0)
|
||||
return InventoryContainerPlacementRejection.CannotMoveCreature;
|
||||
if (item.TradeState == 1)
|
||||
return InventoryContainerPlacementRejection.SourceBeingTraded;
|
||||
ClientObject? destination = objects.Get(destinationId);
|
||||
if (destinationId == 0u
|
||||
|| (destination is null && destinationId != playerId)
|
||||
|| (destination is not null && !IsContainer(destination) && destinationId != playerId))
|
||||
{
|
||||
return InventoryContainerPlacementRejection.InvalidDestination;
|
||||
}
|
||||
if (destination?.TradeState == 1)
|
||||
return InventoryContainerPlacementRejection.DestinationBeingTraded;
|
||||
if (itemId == destinationId || IsContainedBy(objects, destinationId, itemId))
|
||||
return InventoryContainerPlacementRejection.RecursiveContainment;
|
||||
|
||||
bool alreadyDirectlyContained = item.ContainerId == destinationId;
|
||||
if (IsContainer(item))
|
||||
{
|
||||
int capacity = destination?.ContainersCapacity ?? 0;
|
||||
if (!alreadyDirectlyContained
|
||||
&& capacity > 0
|
||||
&& CountContainers(objects, destinationId) >= capacity)
|
||||
{
|
||||
return InventoryContainerPlacementRejection.ContainerCapacityFull;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
int capacity = destination?.ItemsCapacity ?? 0;
|
||||
if (!alreadyDirectlyContained
|
||||
&& capacity > 0
|
||||
&& CountItems(objects, destinationId) >= capacity)
|
||||
{
|
||||
return InventoryContainerPlacementRejection.ItemCapacityFull;
|
||||
}
|
||||
}
|
||||
|
||||
return InventoryContainerPlacementRejection.None;
|
||||
}
|
||||
|
||||
public static string? ComposeClientLocal(
|
||||
InventoryContainerPlacementRejection rejection,
|
||||
ClientObject? item,
|
||||
ClientObject? destination,
|
||||
uint playerId)
|
||||
{
|
||||
string itemName = item?.GetAppropriateName() ?? "item";
|
||||
string destinationName = destination?.GetAppropriateName() ?? "container";
|
||||
return rejection switch
|
||||
{
|
||||
InventoryContainerPlacementRejection.None => null,
|
||||
InventoryContainerPlacementRejection.InvalidItem => "That item is not valid!",
|
||||
InventoryContainerPlacementRejection.CannotMovePlayer =>
|
||||
"You cannot place yourself within another object!",
|
||||
InventoryContainerPlacementRejection.CannotMoveCreature =>
|
||||
"You cannot pick up creatures!",
|
||||
InventoryContainerPlacementRejection.SourceBeingTraded =>
|
||||
$"The {itemName} is being traded",
|
||||
InventoryContainerPlacementRejection.InvalidDestination =>
|
||||
"The destination container is not valid!",
|
||||
InventoryContainerPlacementRejection.DestinationBeingTraded =>
|
||||
$"The {destinationName} is being traded",
|
||||
InventoryContainerPlacementRejection.RecursiveContainment =>
|
||||
"You cannot place an object within itself!",
|
||||
InventoryContainerPlacementRejection.ItemCapacityFull =>
|
||||
destination?.ObjectId == playerId
|
||||
? $"{destinationName} is completely full!"
|
||||
: $"The {destinationName} is completely full!",
|
||||
InventoryContainerPlacementRejection.ContainerCapacityFull =>
|
||||
destination?.ObjectId == playerId
|
||||
? $"{destinationName} can carry no more containers!"
|
||||
: $"The {destinationName} can fit no more containers!",
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
public static bool IsContainer(ClientObject item)
|
||||
=> item.ContainerTypeHint != 0u
|
||||
|| (item.Type & ItemType.Container) != 0
|
||||
|| item.ItemsCapacity != 0
|
||||
|| item.ContainersCapacity != 0;
|
||||
|
||||
private static int CountItems(ClientObjectTable objects, uint containerId)
|
||||
{
|
||||
int count = 0;
|
||||
foreach (uint childId in objects.GetContents(containerId))
|
||||
{
|
||||
if (objects.Get(childId) is { } child && !IsContainer(child))
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private static int CountContainers(ClientObjectTable objects, uint containerId)
|
||||
{
|
||||
int count = 0;
|
||||
foreach (uint childId in objects.GetContents(containerId))
|
||||
{
|
||||
if (objects.Get(childId) is { } child && IsContainer(child))
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private static bool IsContainedBy(
|
||||
ClientObjectTable objects,
|
||||
uint candidateId,
|
||||
uint possibleAncestorId)
|
||||
{
|
||||
var visited = new HashSet<uint>();
|
||||
uint current = candidateId;
|
||||
while (current != 0u && visited.Add(current))
|
||||
{
|
||||
if (current == possibleAncestorId)
|
||||
return true;
|
||||
current = objects.Get(current)?.ContainerId ?? 0u;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -6,8 +6,10 @@ public enum InventoryRequestKind
|
|||
PutInContainer,
|
||||
SplitToContainer,
|
||||
Merge,
|
||||
Move,
|
||||
DropToWorld,
|
||||
SplitToWorld,
|
||||
Wield,
|
||||
Give,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,13 +20,22 @@ public enum PublicWeenieFlags : uint
|
|||
Attackable = 0x00000010,
|
||||
/// <summary>PWD bit 5 — <c>ACCWeenieObject::IsPK @0x0058C8B0</c>.</summary>
|
||||
PlayerKiller = 0x00000020,
|
||||
HiddenAdmin = 0x00000040,
|
||||
UiHidden = 0x00000080,
|
||||
Book = 0x00000100,
|
||||
Vendor = 0x00000200,
|
||||
PlayerKillerSwitch = 0x00000400,
|
||||
NonPlayerKillerSwitch = 0x00000800,
|
||||
Door = 0x00001000,
|
||||
Corpse = 0x00002000,
|
||||
Lifestone = 0x00004000,
|
||||
Food = 0x00008000,
|
||||
Healer = 0x00010000,
|
||||
Lockpick = 0x00020000,
|
||||
Portal = 0x00040000,
|
||||
Admin = 0x00100000,
|
||||
FreePlayerKiller = 0x00200000,
|
||||
ImmuneCellRestrictions = 0x00400000,
|
||||
RequiresPackSlot = 0x00800000,
|
||||
/// <summary>
|
||||
/// F4 (Slice 6b/6c review): <c>BF_RETAINED</c>, the "unsellable" bit
|
||||
|
|
@ -37,6 +46,8 @@ public enum PublicWeenieFlags : uint
|
|||
Retained = 0x01000000,
|
||||
/// <summary>PWD bit 0x19 (25) — <c>ACCWeenieObject::IsPKLite @0x0058C8A0</c>.</summary>
|
||||
PlayerKillerLite = 0x02000000,
|
||||
IncludesSecondHeader = 0x04000000,
|
||||
Bindstone = 0x08000000,
|
||||
VolatileRare = 0x10000000,
|
||||
WieldOnUse = 0x20000000,
|
||||
WieldLeft = 0x40000000,
|
||||
|
|
@ -79,7 +90,8 @@ public readonly record struct ItemPolicyObject(
|
|||
int TradeState,
|
||||
int StackSize,
|
||||
int MaxSplitSize,
|
||||
bool IsIn3DView)
|
||||
bool IsIn3DView,
|
||||
string Name = "item")
|
||||
{
|
||||
public bool IsPlayer => (Flags & PublicWeenieFlags.Player) != 0;
|
||||
}
|
||||
|
|
@ -249,11 +261,11 @@ public static class ItemInteractionPolicy
|
|||
}
|
||||
|
||||
if (source.TradeState == 1)
|
||||
return Reject("You cannot use an item while it is being traded.");
|
||||
return Reject($"You cannot use the {NameOf(source)} because you are trading it");
|
||||
|
||||
if (source.CurrentLocation == EquipMask.None
|
||||
&& ItemUseability.LeastLimitedSourceUse(source.Useability) == ItemUseability.Wielded)
|
||||
return Reject("You must wield that item before you can use it.");
|
||||
return Reject($"You must wield the {NameOf(source)} to use it");
|
||||
|
||||
if (ItemUseability.IsTargeted(source.Useability))
|
||||
{
|
||||
|
|
@ -261,9 +273,9 @@ public static class ItemInteractionPolicy
|
|||
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.");
|
||||
return Reject($"Select your target before using the {NameOf(source)}");
|
||||
if (TargetCompatibilityFailure(source, target, input.PlayerId) is { } failure)
|
||||
return Reject(failure);
|
||||
|
||||
var actions = new List<ItemPolicyAction>
|
||||
{
|
||||
|
|
@ -305,13 +317,13 @@ public static class ItemInteractionPolicy
|
|||
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.");
|
||||
return Reject($"You can't open or close this {NameOf(source)} that way");
|
||||
if ((source.Flags & PublicWeenieFlags.Attackable) != 0
|
||||
&& input.InNonCombatMode)
|
||||
return Reject("You must switch to a combat mode before attacking that target.");
|
||||
return Reject($"To attack {NameOf(source)}, click on the dove icon first");
|
||||
if ((source.Flags & PublicWeenieFlags.Attackable) == 0
|
||||
|| input.InNonCombatMode)
|
||||
return Reject("That object cannot be used.");
|
||||
return Reject($"The {NameOf(source)} cannot be used");
|
||||
return new ItemUsePolicyDecision(false, Array.Empty<ItemPolicyAction>());
|
||||
}
|
||||
|
||||
|
|
@ -319,9 +331,18 @@ public static class ItemInteractionPolicy
|
|||
in ItemPolicyObject source,
|
||||
in ItemPolicyObject target,
|
||||
uint playerId)
|
||||
=> TargetCompatibilityFailure(source, target, playerId) is null;
|
||||
|
||||
private static string? TargetCompatibilityFailure(
|
||||
in ItemPolicyObject source,
|
||||
in ItemPolicyObject target,
|
||||
uint playerId)
|
||||
{
|
||||
if (source.TradeState == 1)
|
||||
return false;
|
||||
return $"You cannot use the {NameOf(source)} because you are trading it";
|
||||
|
||||
if (target.TradeState == 1)
|
||||
return $"You can't use the {NameOf(source)} on an item you are trading";
|
||||
|
||||
uint flags = ItemUseability.TargetFlags(source.Useability);
|
||||
if (!target.OwnedByPlayer)
|
||||
|
|
@ -330,18 +351,20 @@ public static class ItemInteractionPolicy
|
|||
if ((least & ItemUseability.Contained) != 0)
|
||||
{
|
||||
if (!(target.Id == playerId && (flags & ItemUseability.Self) != 0))
|
||||
return false;
|
||||
return $"You can't use the {NameOf(source)} on what you don't own";
|
||||
}
|
||||
else if ((least & ItemUseability.Wielded) != 0)
|
||||
{
|
||||
return false;
|
||||
return $"You can't use the {NameOf(source)} on what you aren't wielding";
|
||||
}
|
||||
}
|
||||
|
||||
if (target.Id == playerId && (flags & ItemUseability.Self) == 0)
|
||||
return false;
|
||||
return $"Cannot use the {NameOf(source)} on yourself";
|
||||
|
||||
return (source.TargetType & (uint)target.Type) != 0;
|
||||
return (source.TargetType & (uint)target.Type) != 0
|
||||
? null
|
||||
: $"Cannot use the {NameOf(source)} with the {NameOf(target)}";
|
||||
}
|
||||
|
||||
public static ItemPlacementPolicyDecision DecidePlacement(
|
||||
|
|
@ -353,9 +376,10 @@ public static class ItemInteractionPolicy
|
|||
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."));
|
||||
return Placement(false, RejectAction($"You must first pick up the {NameOf(input.Item)}"));
|
||||
if (input.Item.TradeState != 0)
|
||||
return Placement(false, RejectAction("You cannot move an item while it is being traded."));
|
||||
return Placement(false, RejectAction(
|
||||
$"You are trading the {NameOf(input.Item)}, it cannot be dropped"));
|
||||
|
||||
if (input.TargetId == 0)
|
||||
return input.AllowGroundFallback ? PlaceOnGround(input) : Placement(false);
|
||||
|
|
@ -370,7 +394,7 @@ public static class ItemInteractionPolicy
|
|||
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."));
|
||||
return Placement(false, RejectAction("You must split the stack before selling it."));
|
||||
}
|
||||
|
||||
if (input.DragOnPlayerOpensSecureTrade && target.IsPlayer)
|
||||
|
|
@ -384,16 +408,17 @@ public static class ItemInteractionPolicy
|
|||
if (target.IsContainer)
|
||||
{
|
||||
if ((target.Flags & PublicWeenieFlags.Openable) == 0)
|
||||
return Placement(false, RejectAction("That container is locked."));
|
||||
return Placement(false, RejectAction($"The {NameOf(target)} is locked"));
|
||||
if (target.Id != input.GroundObjectId)
|
||||
return Placement(false, RejectAction("You must open that container first."));
|
||||
return Placement(false, RejectAction($"You must open the {NameOf(target)} 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."));
|
||||
return Placement(false, RejectAction(
|
||||
$"Cannot give {NameOf(input.Item)} to {NameOf(target)}"));
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ItemPolicyAction> BuildUsingItemActions(
|
||||
|
|
@ -436,15 +461,18 @@ public static class ItemInteractionPolicy
|
|||
in ItemPlacementPolicyInput input)
|
||||
{
|
||||
if (!input.PlayerOnGround)
|
||||
return Placement(false, RejectAction("You cannot do that in mid air."));
|
||||
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."));
|
||||
return Placement(false, RejectAction("Move cancelled"));
|
||||
}
|
||||
|
||||
private static string NameOf(in ItemPolicyObject item)
|
||||
=> string.IsNullOrWhiteSpace(item.Name) ? "item" : item.Name;
|
||||
|
||||
private static ItemUsePolicyDecision Consumed(params ItemPolicyAction[] actions)
|
||||
=> new(true, actions);
|
||||
|
||||
|
|
|
|||
|
|
@ -127,6 +127,27 @@ public sealed class VendorStagingList
|
|||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replaces retail's temporary pre-split sell-row identity with the
|
||||
/// server-created split stack while preserving the row's position and
|
||||
/// selected quantity. <c>VendorSellUI::ItemAttributesChanged</c>
|
||||
/// performs the same in-place substitution after matching the new
|
||||
/// object's class id and stack size.
|
||||
/// </summary>
|
||||
public bool Replace(uint itemGuid, uint replacementGuid)
|
||||
{
|
||||
if (itemGuid == 0u || replacementGuid == 0u || itemGuid == replacementGuid)
|
||||
return false;
|
||||
|
||||
int index = _entries.FindIndex(entry => entry.ItemGuid == itemGuid);
|
||||
if (index < 0 || _entries.Exists(entry => entry.ItemGuid == replacementGuid))
|
||||
return false;
|
||||
|
||||
_entries[index] = _entries[index] with { ItemGuid = replacementGuid };
|
||||
Changed?.Invoke();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Port of the unconditional <c>PackableList<ItemProfile>::Flush</c> calls
|
||||
/// ("Clear List" buttons, and the optimistic post-send clear both Buy All and Sell All
|
||||
/// perform immediately after their wire send — see the batched-send call sites).</summary>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue