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
|
|
@ -43,6 +43,7 @@ public sealed class ItemInteractionController : IDisposable
|
|||
private readonly Action<uint, uint>? _sendSplitToWorld;
|
||||
private readonly Action<uint, uint, int>? _sendPutItemInContainer;
|
||||
private readonly Action<uint, uint, uint, uint>? _sendSplitToContainer;
|
||||
private readonly Action<uint, uint, uint>? _sendStackableMerge;
|
||||
private readonly Action<uint, uint, uint>? _sendGive;
|
||||
private readonly Action<string>? _toast;
|
||||
private readonly Func<bool> _readyForInventoryRequest;
|
||||
|
|
@ -118,7 +119,8 @@ public sealed class ItemInteractionController : IDisposable
|
|||
Func<uint, uint, int, uint, bool>? sendBuy = null,
|
||||
Func<uint, IReadOnlyList<(int Amount, uint ItemGuid)>, uint, bool>? sendBuyAll = null,
|
||||
Func<uint, IReadOnlyList<(int Amount, uint ItemGuid)>, bool>? sendSell = null,
|
||||
Action<string, RetailLogTextType>? interfaceText = null)
|
||||
Action<string, RetailLogTextType>? interfaceText = null,
|
||||
Action<uint, uint, uint>? sendStackableMerge = null)
|
||||
{
|
||||
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
||||
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
|
||||
|
|
@ -130,6 +132,7 @@ public sealed class ItemInteractionController : IDisposable
|
|||
_sendSplitToWorld = sendSplitToWorld;
|
||||
_sendPutItemInContainer = sendPutItemInContainer;
|
||||
_sendSplitToContainer = sendSplitToContainer;
|
||||
_sendStackableMerge = sendStackableMerge;
|
||||
_sendGive = sendGive;
|
||||
_nowMs = nowMs ?? (() => Environment.TickCount64);
|
||||
_toast = toast;
|
||||
|
|
@ -168,10 +171,10 @@ public sealed class ItemInteractionController : IDisposable
|
|||
_playerGuid,
|
||||
_sendWield,
|
||||
sendPutItemInContainer,
|
||||
_toast,
|
||||
_systemMessage,
|
||||
combatState,
|
||||
sendChangeCombatMode);
|
||||
sendChangeCombatMode,
|
||||
_transactions);
|
||||
_interactionState.Changed += OnInteractionModeChanged;
|
||||
_transactions.StateChanged += OnTransactionStateChanged;
|
||||
_transactions.RequestCompleted += OnInventoryRequestCompleted;
|
||||
|
|
@ -182,6 +185,12 @@ public sealed class ItemInteractionController : IDisposable
|
|||
|
||||
public event Action? StateChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>ItemHolder::AttemptMerge</c> immediately selects the target
|
||||
/// stack and publishes the toolbar merge-attempt notice after dispatch.
|
||||
/// </summary>
|
||||
public event Action<uint, uint>? MergeAttempted;
|
||||
|
||||
/// <summary>
|
||||
/// Retail's two secure-trade open paths surface here for the trade UI:
|
||||
/// (partnerGuid, itemGuid) — itemGuid 0 for Use-on-player
|
||||
|
|
@ -457,6 +466,40 @@ public sealed class ItemInteractionController : IDisposable
|
|||
public bool TryGetPendingInventoryRequest(out PendingInventoryRequest pending)
|
||||
=> _transactions.TryGetPending(out pending);
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>ACCWeenieObject::UIAttemptSplitToContainer</c>: split an
|
||||
/// exact partial quantity into a container through the canonical
|
||||
/// one-request inventory gate. The source remains in place until the
|
||||
/// authoritative stack update and newly-created split object arrive.
|
||||
/// </summary>
|
||||
public bool TrySplitToContainer(
|
||||
uint itemId,
|
||||
uint containerId,
|
||||
uint placement,
|
||||
uint amount)
|
||||
{
|
||||
if (itemId == 0u
|
||||
|| containerId == 0u
|
||||
|| _sendSplitToContainer is null
|
||||
|| _objects.Get(itemId) is not { } item)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
uint fullStack = (uint)Math.Max(1, item.StackSize);
|
||||
if (amount == 0u || amount >= fullStack)
|
||||
return false;
|
||||
|
||||
return TryDispatchInventoryRequest(
|
||||
InventoryRequestKind.SplitToContainer,
|
||||
itemId,
|
||||
() =>
|
||||
{
|
||||
_sendSplitToContainer(itemId, containerId, placement, amount);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Increments retail's shared <c>ClientUISystem</c> busy reference after a
|
||||
/// request issued by another retained controller has been sent. The
|
||||
|
|
@ -486,6 +529,29 @@ public sealed class ItemInteractionController : IDisposable
|
|||
public bool IsPendingSource(uint itemGuid)
|
||||
=> itemGuid != 0 && itemGuid == PendingSourceItem;
|
||||
|
||||
/// <summary>
|
||||
/// True while retail's global inventory request latch owns this physical
|
||||
/// item. Retained item lists use it for the waiting/ghosted source visual;
|
||||
/// canonical placement remains unchanged until the server response.
|
||||
/// </summary>
|
||||
public bool IsPendingInventorySource(uint itemGuid)
|
||||
=> itemGuid != 0
|
||||
&& _transactions.TryGetPending(out PendingInventoryRequest pending)
|
||||
&& pending.ItemId == itemGuid;
|
||||
|
||||
/// <summary>Route a literal local refusal to retail's SpewBox channel.</summary>
|
||||
public void ReportClientLocal(string message)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(message))
|
||||
return;
|
||||
if (_interfaceText is not null)
|
||||
_interfaceText(message, RetailLogTextType.ClientLocal);
|
||||
else if (_systemMessage is not null)
|
||||
_systemMessage(message);
|
||||
else
|
||||
_toast?.Invoke(message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>ACCWeenieObject::IsOwnedByPlayer</c> projection shared with
|
||||
/// toolbar shortcut creation.
|
||||
|
|
@ -689,15 +755,46 @@ public sealed class ItemInteractionController : IDisposable
|
|||
/// publishes the waiting destination slot before issuing the move request,
|
||||
/// exactly like double-click pickup through ItemHolder.
|
||||
/// </summary>
|
||||
public bool PlaceWorldItemInBackpack(uint itemGuid)
|
||||
public bool PlaceWorldItemInBackpack(uint itemGuid, bool mainPack = false)
|
||||
{
|
||||
if (itemGuid == 0u || _placeInBackpack is null)
|
||||
return false;
|
||||
|
||||
uint containerId = _backpackContainerId();
|
||||
uint containerId = mainPack ? _playerGuid() : _backpackContainerId();
|
||||
if (containerId == 0u)
|
||||
containerId = _playerGuid();
|
||||
const int placement = 0;
|
||||
|
||||
// CPlayerSystem::PlaceInBackpack passes autoMerge=true to
|
||||
// ItemHolder::AttemptToPlaceInContainer. Retail searches the player's
|
||||
// exhaustive carried inventory first and only merges when one target
|
||||
// can accept the complete selected split quantity.
|
||||
if (TryPlanAutoMerge(itemGuid) is { } merge)
|
||||
{
|
||||
if (!TryDispatchPendingBackpackPlacement(
|
||||
itemGuid,
|
||||
containerId,
|
||||
placement,
|
||||
InventoryRequestKind.Merge,
|
||||
() =>
|
||||
{
|
||||
_sendStackableMerge!(
|
||||
merge.SourceObjectId,
|
||||
merge.TargetObjectId,
|
||||
merge.Amount);
|
||||
MergeAttempted?.Invoke(
|
||||
merge.SourceObjectId,
|
||||
merge.TargetObjectId);
|
||||
return true;
|
||||
}))
|
||||
{
|
||||
// As with ordinary pickup, retail consumes the key while the
|
||||
// shared inventory-request gate is busy.
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!TryBeginPendingBackpackPlacement(
|
||||
itemGuid,
|
||||
containerId,
|
||||
|
|
@ -716,6 +813,67 @@ public sealed class ItemInteractionController : IDisposable
|
|||
return true;
|
||||
}
|
||||
|
||||
private StackMergePlan? TryPlanAutoMerge(uint sourceId)
|
||||
{
|
||||
if (_sendStackableMerge is null
|
||||
|| _objects.Get(sourceId) is not { } source
|
||||
|| source.StackSizeMax <= 1)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
uint requested = _stackSplitQuantity?.GetObjectSplitSize(
|
||||
sourceId,
|
||||
_selectedObjectId(),
|
||||
(uint)Math.Max(1, source.StackSize))
|
||||
?? (uint)Math.Max(1, source.StackSize);
|
||||
int requestedAmount = (int)Math.Min(requested, int.MaxValue);
|
||||
var sourceMerge = ToStackMergeItem(source);
|
||||
uint player = _playerGuid();
|
||||
if (player == 0u)
|
||||
return null;
|
||||
|
||||
var visitedContainers = new HashSet<uint>();
|
||||
foreach (uint targetId in ExhaustiveContents(player, visitedContainers))
|
||||
{
|
||||
if (_objects.Get(targetId) is not { } target)
|
||||
continue;
|
||||
StackMergePlan? plan = StackMergePlanner.Plan(
|
||||
sourceMerge,
|
||||
ToStackMergeItem(target),
|
||||
CanMakeInventoryRequest,
|
||||
requestedAmount);
|
||||
// AttemptAutoMerge rejects a partial fit and keeps searching.
|
||||
if (plan is { } complete && complete.Amount == requested)
|
||||
return complete;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private IEnumerable<uint> ExhaustiveContents(
|
||||
uint containerId,
|
||||
HashSet<uint> visitedContainers)
|
||||
{
|
||||
if (!visitedContainers.Add(containerId))
|
||||
yield break;
|
||||
|
||||
foreach (uint itemId in _objects.GetContents(containerId))
|
||||
{
|
||||
yield return itemId;
|
||||
if (_objects.GetContents(itemId).Count == 0)
|
||||
continue;
|
||||
foreach (uint nested in ExhaustiveContents(itemId, visitedContainers))
|
||||
yield return nested;
|
||||
}
|
||||
}
|
||||
|
||||
private static StackMergeItem ToStackMergeItem(ClientObject item) => new(
|
||||
item.ObjectId,
|
||||
item.WeenieClassId,
|
||||
item.StackSize,
|
||||
item.StackSizeMax,
|
||||
item.TradeState);
|
||||
|
||||
public bool TryBeginPendingBackpackPlacement(
|
||||
uint itemGuid,
|
||||
uint containerId,
|
||||
|
|
@ -1043,6 +1201,14 @@ public sealed class ItemInteractionController : IDisposable
|
|||
public bool DropToWorld(ItemDragPayload payload)
|
||||
=> PlaceIn3D(payload, targetGuid: 0u);
|
||||
|
||||
/// <summary>
|
||||
/// Keyboard equivalent of dropping the selected inventory item into the
|
||||
/// 3-D view. Retail routes Give Selected and Drop Selected through the
|
||||
/// same <c>ItemHolder::AttemptPlaceIn3D @ 0x00588600</c> policy as a drag.
|
||||
/// </summary>
|
||||
public bool PlaceSelectedIn3D(uint itemGuid, uint targetGuid)
|
||||
=> PlaceIn3D(itemGuid, ItemDragSource.Inventory, targetGuid);
|
||||
|
||||
/// <summary>
|
||||
/// Retail inventory drag released into SmartBox. The release target is the
|
||||
/// world object under the cursor, or zero for empty ground. This is the live
|
||||
|
|
@ -1052,9 +1218,17 @@ public sealed class ItemInteractionController : IDisposable
|
|||
{
|
||||
ArgumentNullException.ThrowIfNull(payload);
|
||||
|
||||
if (payload.SourceKind == ItemDragSource.ShortcutBar)
|
||||
return PlaceIn3D(payload.ObjId, payload.SourceKind, targetGuid);
|
||||
}
|
||||
|
||||
private bool PlaceIn3D(
|
||||
uint itemGuid,
|
||||
ItemDragSource sourceKind,
|
||||
uint targetGuid)
|
||||
{
|
||||
if (sourceKind == ItemDragSource.ShortcutBar)
|
||||
return false;
|
||||
if (payload.ObjId == 0 || _objects.Get(payload.ObjId) is not { } item)
|
||||
if (itemGuid == 0 || _objects.Get(itemGuid) is not { } item)
|
||||
return false;
|
||||
if (!EnsureInventoryRequestReady())
|
||||
return false;
|
||||
|
|
@ -1154,7 +1328,7 @@ public sealed class ItemInteractionController : IDisposable
|
|||
break;
|
||||
case ItemPolicyActionKind.Reject:
|
||||
if (!string.IsNullOrWhiteSpace(action.Message))
|
||||
_toast?.Invoke(action.Message);
|
||||
ReportClientLocal(action.Message);
|
||||
break;
|
||||
case ItemPolicyActionKind.OpenSecureTrade:
|
||||
// Use-on-player (ItemHolder::DetermineUseResult
|
||||
|
|
@ -1169,8 +1343,9 @@ public sealed class ItemInteractionController : IDisposable
|
|||
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;
|
||||
ReportClientLocal(PolicyActionMessage(action));
|
||||
acted |= handled || _interfaceText is not null
|
||||
|| _systemMessage is not null || _toast is not null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
@ -1199,14 +1374,8 @@ public sealed class ItemInteractionController : IDisposable
|
|||
action.ObjectId,
|
||||
() =>
|
||||
{
|
||||
if (_sendDrop is null
|
||||
|| !_objects.MoveItemOptimistic(
|
||||
action.ObjectId,
|
||||
newContainerId: 0u,
|
||||
newSlot: -1))
|
||||
{
|
||||
if (_sendDrop is null)
|
||||
return false;
|
||||
}
|
||||
_sendDrop(action.ObjectId);
|
||||
return true;
|
||||
});
|
||||
|
|
@ -1290,13 +1459,13 @@ public sealed class ItemInteractionController : IDisposable
|
|||
}
|
||||
case ItemPolicyActionKind.Reject:
|
||||
if (!string.IsNullOrWhiteSpace(action.Message))
|
||||
_toast?.Invoke(action.Message);
|
||||
ReportClientLocal(action.Message);
|
||||
break;
|
||||
default:
|
||||
_auxiliaryAction?.Invoke(action);
|
||||
PolicyActionRequested?.Invoke(action);
|
||||
if (_auxiliaryAction is null && PolicyActionRequested is null)
|
||||
_toast?.Invoke(PolicyActionMessage(action));
|
||||
ReportClientLocal(PolicyActionMessage(action));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
@ -1308,7 +1477,7 @@ public sealed class ItemInteractionController : IDisposable
|
|||
_interactionState.EnterUseItemOnTarget(sourceGuid);
|
||||
var name = _objects.Get(sourceGuid)?.Name;
|
||||
if (!string.IsNullOrWhiteSpace(name))
|
||||
_toast?.Invoke($"Choose a target for the {name}");
|
||||
ReportClientLocal($"Choose a target for the {name}");
|
||||
}
|
||||
|
||||
private void ClearTargetMode()
|
||||
|
|
@ -1387,8 +1556,6 @@ public sealed class ItemInteractionController : IDisposable
|
|||
PendingInventoryRequest request,
|
||||
uint weenieError)
|
||||
{
|
||||
if (_interfaceText is null)
|
||||
return;
|
||||
ClientObject? item = request.ItemIdentity ?? _objects.Get(request.ItemId);
|
||||
if (item is null)
|
||||
return;
|
||||
|
|
@ -1407,7 +1574,7 @@ public sealed class ItemInteractionController : IDisposable
|
|||
if (InventoryFailureMessages.Compose(request.Kind, name, weenieError)
|
||||
is { } text)
|
||||
{
|
||||
_interfaceText(text, RetailLogTextType.ClientLocal);
|
||||
ReportClientLocal(text);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1443,6 +1610,7 @@ public sealed class ItemInteractionController : IDisposable
|
|||
_transactions.RequestCompleted -= OnInventoryRequestCompleted;
|
||||
_transactions.StateChanged -= OnTransactionStateChanged;
|
||||
WorldDropDispatched = null;
|
||||
MergeAttempted = null;
|
||||
_autoWield.Dispose();
|
||||
}
|
||||
|
||||
|
|
@ -1575,7 +1743,8 @@ public sealed class ItemInteractionController : IDisposable
|
|||
stackSize,
|
||||
stackSize,
|
||||
IsIn3DView: item.ContainerId == 0 && item.WielderId == 0
|
||||
&& item.ObjectId != _playerGuid());
|
||||
&& item.ObjectId != _playerGuid(),
|
||||
Name: item.GetAppropriateName());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue