fix(interaction): close selection lifecycle review gaps
Bind queued actions and pending inventory requests to exact live incarnations, separate optimistic placement from authoritative responses, and serialize retail-style inventory ownership across UI surfaces. Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
parent
d2bb5af453
commit
5acc3f01cf
23 changed files with 2635 additions and 168 deletions
|
|
@ -16,6 +16,7 @@ namespace AcDream.App.Input;
|
|||
public sealed class OutboundInteractionQueue
|
||||
{
|
||||
private readonly Queue<Action> _pending = new();
|
||||
private uint _clearEpoch;
|
||||
|
||||
public int Count => _pending.Count;
|
||||
|
||||
|
|
@ -33,9 +34,14 @@ public sealed class OutboundInteractionQueue
|
|||
public void Drain()
|
||||
{
|
||||
int count = _pending.Count;
|
||||
while (count-- > 0)
|
||||
uint epoch = _clearEpoch;
|
||||
while (count-- > 0 && epoch == _clearEpoch && _pending.Count > 0)
|
||||
_pending.Dequeue().Invoke();
|
||||
}
|
||||
|
||||
public void Clear() => _pending.Clear();
|
||||
public void Clear()
|
||||
{
|
||||
_pending.Clear();
|
||||
_clearEpoch++;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,13 @@ namespace AcDream.App.Interaction;
|
|||
|
||||
internal interface IPlayerInteractionMovementSink
|
||||
{
|
||||
bool BeginApproach(InteractionApproach approach);
|
||||
/// <summary>
|
||||
/// Cancels any preceding MoveTo, invokes <paramref name="armAfterCancel"/>,
|
||||
/// then installs the new movement. The callback boundary lets the intent
|
||||
/// owner arm completion state before an already-facing TurnTo can finish
|
||||
/// synchronously, without letting the preceding cancellation clear it.
|
||||
/// </summary>
|
||||
bool BeginApproach(InteractionApproach approach, Action? armAfterCancel = null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -20,7 +26,7 @@ internal sealed class PlayerInteractionMovementSink(
|
|||
private readonly Func<PlayerMovementController?> _player = player
|
||||
?? throw new ArgumentNullException(nameof(player));
|
||||
|
||||
public bool BeginApproach(InteractionApproach approach)
|
||||
public bool BeginApproach(InteractionApproach approach, Action? armAfterCancel = null)
|
||||
{
|
||||
PlayerMovementController? controller = _player();
|
||||
if (controller?.MoveTo is null)
|
||||
|
|
@ -47,11 +53,16 @@ internal sealed class PlayerInteractionMovementSink(
|
|||
Height = approach.TargetHeight,
|
||||
};
|
||||
|
||||
// PerformMovement cancels at its head. Do it explicitly before the
|
||||
// intent is armed so cancellation of the preceding move cannot clear
|
||||
// the new request; the internal second call is then a retail no-op.
|
||||
controller.Movement.CancelMoveTo(WeenieError.ActionCancelled);
|
||||
armAfterCancel?.Invoke();
|
||||
|
||||
// P1's wire MoveToObject store marks the command non-autonomous.
|
||||
// The speculative local install must do the same or the next raw-input
|
||||
// pump overwrites it before ACE's authoritative movement arrives.
|
||||
controller.SetLastMoveWasAutonomous(false);
|
||||
controller.Movement.PerformMovement(movement);
|
||||
return true;
|
||||
return controller.Movement.PerformMovement(movement) == WeenieError.None;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using AcDream.App.Input;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Selection;
|
||||
using AcDream.Core.Ui;
|
||||
|
|
@ -24,12 +25,18 @@ internal sealed class SelectionInteractionController
|
|||
private readonly Action<string>? _toast;
|
||||
private PendingPostArrivalAction? _pendingPostArrival;
|
||||
|
||||
private readonly record struct QueuedInteractionIdentity(
|
||||
uint ServerGuid,
|
||||
uint? LocalEntityId,
|
||||
ClientObject? ClientObject);
|
||||
|
||||
private readonly record struct PendingPostArrivalAction(
|
||||
uint ServerGuid,
|
||||
uint LocalEntityId,
|
||||
bool IsPickup,
|
||||
uint DestinationContainerId,
|
||||
int Placement);
|
||||
int Placement,
|
||||
ulong PendingPlacementToken);
|
||||
|
||||
public SelectionInteractionController(
|
||||
SelectionState selection,
|
||||
|
|
@ -69,8 +76,14 @@ internal sealed class SelectionInteractionController
|
|||
case InputAction.SelectionPickUp:
|
||||
if (_selection.SelectedObjectId is uint pickupTarget)
|
||||
{
|
||||
_outbound.Enqueue(() =>
|
||||
_items.PlaceWorldItemInBackpack(pickupTarget));
|
||||
EnqueueIdentityBound(
|
||||
pickupTarget,
|
||||
requireLiveEntity: true,
|
||||
guid =>
|
||||
{
|
||||
if (ValidatePickupTarget(guid, showToast: true))
|
||||
_items.PlaceWorldItemInBackpack(guid);
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
|
|
@ -85,12 +98,18 @@ internal sealed class SelectionInteractionController
|
|||
}
|
||||
}
|
||||
|
||||
public uint? PickAt(float mouseX, float mouseY, bool includeSelf)
|
||||
=> _query.PickAt(mouseX, mouseY, includeSelf);
|
||||
|
||||
public uint? PickAtCursor(bool includeSelf)
|
||||
=> _query.PickAtCursor(includeSelf);
|
||||
|
||||
public void PlaceDraggedItem(ItemDragPayload payload, float mouseX, float mouseY)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(payload);
|
||||
uint target = _query.PickAt(mouseX, mouseY, includeSelf: true) ?? 0u;
|
||||
if (target != 0u)
|
||||
_query.BeginLightingPulse(target);
|
||||
_items.PlaceIn3D(payload, target);
|
||||
}
|
||||
|
||||
public uint? GetSelectedOrClosestCombatTarget(bool autoTarget)
|
||||
{
|
||||
if (_selection.SelectedObjectId is { } selected
|
||||
|
|
@ -147,7 +166,13 @@ internal sealed class SelectionInteractionController
|
|||
Console.WriteLine($"[B.4b] pick guid=0x{guid:X8} name={label}");
|
||||
_toast?.Invoke($"Selected: {label}");
|
||||
if (useImmediately)
|
||||
_outbound.Enqueue(() => _items.ActivateItem(guid));
|
||||
EnqueueIdentityBound(
|
||||
guid,
|
||||
requireLiveEntity: true,
|
||||
selected =>
|
||||
{
|
||||
_items.ActivateItem(selected);
|
||||
});
|
||||
}
|
||||
|
||||
public void UseCurrentSelection()
|
||||
|
|
@ -157,11 +182,18 @@ internal sealed class SelectionInteractionController
|
|||
_toast?.Invoke("Nothing selected");
|
||||
return;
|
||||
}
|
||||
_outbound.Enqueue(() => _items.UseSelectedOrEnterMode(selected));
|
||||
EnqueueIdentityBound(
|
||||
selected,
|
||||
requireLiveEntity: false,
|
||||
guid =>
|
||||
{
|
||||
_items.UseSelectedOrEnterMode(guid);
|
||||
});
|
||||
}
|
||||
|
||||
public void SendUse(uint serverGuid)
|
||||
{
|
||||
CancelPendingApproach();
|
||||
if (!_transport.IsInWorld)
|
||||
{
|
||||
_toast?.Invoke("Not in world");
|
||||
|
|
@ -173,63 +205,104 @@ internal sealed class SelectionInteractionController
|
|||
return;
|
||||
}
|
||||
|
||||
_movement.BeginApproach(approach);
|
||||
if (approach.IsCloseRange)
|
||||
{
|
||||
_pendingPostArrival = new PendingPostArrivalAction(
|
||||
var pending = new PendingPostArrivalAction(
|
||||
serverGuid,
|
||||
approach.Target.LocalEntityId,
|
||||
IsPickup: false,
|
||||
DestinationContainerId: 0u,
|
||||
Placement: 0);
|
||||
Placement: 0,
|
||||
PendingPlacementToken: 0u);
|
||||
bool started = _movement.BeginApproach(
|
||||
approach,
|
||||
() => _pendingPostArrival = pending);
|
||||
if (!started && _pendingPostArrival == pending)
|
||||
CancelPendingApproach();
|
||||
return;
|
||||
}
|
||||
|
||||
_movement.BeginApproach(approach);
|
||||
if (_transport.TrySendUse(serverGuid, out uint sequence))
|
||||
Console.WriteLine($"[B.4b] use guid=0x{serverGuid:X8} seq={sequence}");
|
||||
}
|
||||
|
||||
public void SendPickup(uint itemGuid, uint destinationContainerId, int placement)
|
||||
{
|
||||
CancelPendingApproach();
|
||||
if (!_transport.IsInWorld)
|
||||
{
|
||||
_toast?.Invoke("Not in world");
|
||||
CancelPickupPresentation(itemGuid);
|
||||
return;
|
||||
}
|
||||
if (_query.IsCreature(itemGuid))
|
||||
if (!ValidatePickupTarget(itemGuid, showToast: true)
|
||||
|| !_query.TryGetApproach(itemGuid, out InteractionApproach approach))
|
||||
{
|
||||
CancelPickupPresentation(itemGuid);
|
||||
return;
|
||||
}
|
||||
|
||||
ulong pendingPlacementToken = _items.TryGetPendingBackpackPlacement(
|
||||
itemGuid,
|
||||
out PendingBackpackPlacement pendingPlacement)
|
||||
? pendingPlacement.Token
|
||||
: 0u;
|
||||
if (!IsCurrentPickupPresentation(
|
||||
itemGuid,
|
||||
destinationContainerId,
|
||||
placement,
|
||||
pendingPlacementToken))
|
||||
{
|
||||
_toast?.Invoke(RetailMessages.CannotPickUpCreatures);
|
||||
return;
|
||||
}
|
||||
if (!_query.IsPickupable(itemGuid))
|
||||
{
|
||||
_toast?.Invoke(RetailMessages.CantBePickedUp(_query.Describe(itemGuid)));
|
||||
return;
|
||||
}
|
||||
if (!_query.TryGetApproach(itemGuid, out InteractionApproach approach))
|
||||
return;
|
||||
|
||||
_movement.BeginApproach(approach);
|
||||
if (approach.IsCloseRange)
|
||||
{
|
||||
_pendingPostArrival = new PendingPostArrivalAction(
|
||||
var pending = new PendingPostArrivalAction(
|
||||
itemGuid,
|
||||
approach.Target.LocalEntityId,
|
||||
IsPickup: true,
|
||||
destinationContainerId,
|
||||
placement);
|
||||
placement,
|
||||
pendingPlacementToken);
|
||||
bool started = _movement.BeginApproach(
|
||||
approach,
|
||||
() => _pendingPostArrival = pending);
|
||||
if (!started && _pendingPostArrival == pending)
|
||||
CancelPendingApproach();
|
||||
else if (!started)
|
||||
CancelPickupPresentation(itemGuid, pendingPlacementToken);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_transport.TrySendPickup(
|
||||
_movement.BeginApproach(approach);
|
||||
if (!IsCurrentPickupPresentation(
|
||||
itemGuid,
|
||||
destinationContainerId,
|
||||
placement,
|
||||
out uint sequence))
|
||||
pendingPlacementToken))
|
||||
{
|
||||
return;
|
||||
}
|
||||
uint sequence = 0u;
|
||||
if (_items.TryDispatchInventoryRequest(
|
||||
InventoryRequestKind.Pickup,
|
||||
itemGuid,
|
||||
() => _transport.TrySendPickup(
|
||||
itemGuid,
|
||||
destinationContainerId,
|
||||
placement,
|
||||
out sequence),
|
||||
pendingPlacementToken))
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[B.5] pickup item=0x{itemGuid:X8} container=0x{destinationContainerId:X8} placement={placement} seq={sequence}");
|
||||
}
|
||||
else
|
||||
{
|
||||
CancelPickupPresentation(itemGuid, pendingPlacementToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Fires only after natural MoveToComplete(None), never cancellation.</summary>
|
||||
|
|
@ -239,15 +312,38 @@ internal sealed class SelectionInteractionController
|
|||
return;
|
||||
_pendingPostArrival = null;
|
||||
if (!_query.IsCurrent(pending.ServerGuid, pending.LocalEntityId))
|
||||
{
|
||||
if (pending.IsPickup)
|
||||
CancelPickupPresentation(
|
||||
pending.ServerGuid,
|
||||
pending.PendingPlacementToken);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pending.IsPickup)
|
||||
{
|
||||
_transport.TrySendPickup(
|
||||
pending.ServerGuid,
|
||||
pending.DestinationContainerId,
|
||||
pending.Placement,
|
||||
out _);
|
||||
if (!IsCurrentPickupPresentation(
|
||||
pending.ServerGuid,
|
||||
pending.DestinationContainerId,
|
||||
pending.Placement,
|
||||
pending.PendingPlacementToken))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!_items.TryDispatchInventoryRequest(
|
||||
InventoryRequestKind.Pickup,
|
||||
pending.ServerGuid,
|
||||
() => _transport.TrySendPickup(
|
||||
pending.ServerGuid,
|
||||
pending.DestinationContainerId,
|
||||
pending.Placement,
|
||||
out _),
|
||||
pending.PendingPlacementToken))
|
||||
{
|
||||
CancelPickupPresentation(
|
||||
pending.ServerGuid,
|
||||
pending.PendingPlacementToken);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
|
|
@ -257,10 +353,12 @@ internal sealed class SelectionInteractionController
|
|||
|
||||
public void DrainOutbound() => _outbound.Drain();
|
||||
|
||||
public void OnMoveToCancelled(WeenieError _) => CancelPendingApproach();
|
||||
|
||||
public void OnEntityHidden(uint serverGuid)
|
||||
{
|
||||
if (_pendingPostArrival?.ServerGuid == serverGuid)
|
||||
_pendingPostArrival = null;
|
||||
CancelPendingApproach();
|
||||
if (_selection.SelectedObjectId == serverGuid)
|
||||
{
|
||||
_selection.Clear(
|
||||
|
|
@ -276,7 +374,7 @@ internal sealed class SelectionInteractionController
|
|||
&& pending.ServerGuid == record.ServerGuid
|
||||
&& pending.LocalEntityId == record.LocalEntityId)
|
||||
{
|
||||
_pendingPostArrival = null;
|
||||
CancelPendingApproach();
|
||||
}
|
||||
if (!replacementExists && _selection.SelectedObjectId == record.ServerGuid)
|
||||
{
|
||||
|
|
@ -288,9 +386,85 @@ internal sealed class SelectionInteractionController
|
|||
|
||||
public void ResetSession()
|
||||
{
|
||||
CancelPendingApproach();
|
||||
_items.ResetSession();
|
||||
_selection.Reset();
|
||||
_outbound.Clear();
|
||||
_pendingPostArrival = null;
|
||||
}
|
||||
|
||||
private bool ValidatePickupTarget(uint serverGuid, bool showToast)
|
||||
{
|
||||
if (_query.IsCreature(serverGuid))
|
||||
{
|
||||
if (showToast)
|
||||
_toast?.Invoke(RetailMessages.CannotPickUpCreatures);
|
||||
return false;
|
||||
}
|
||||
if (_query.IsPickupable(serverGuid))
|
||||
return true;
|
||||
if (showToast)
|
||||
_toast?.Invoke(RetailMessages.CantBePickedUp(_query.Describe(serverGuid)));
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool EnqueueIdentityBound(
|
||||
uint serverGuid,
|
||||
bool requireLiveEntity,
|
||||
Action<uint> action)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(action);
|
||||
uint? localEntityId = _query.TryCaptureIdentity(serverGuid, out uint localId)
|
||||
? localId
|
||||
: null;
|
||||
ClientObject? item = _items.TryCaptureObjectIdentity(serverGuid, out ClientObject captured)
|
||||
? captured
|
||||
: null;
|
||||
if ((requireLiveEntity && localEntityId is null)
|
||||
|| (localEntityId is null && item is null))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var identity = new QueuedInteractionIdentity(serverGuid, localEntityId, item);
|
||||
_outbound.Enqueue(() =>
|
||||
{
|
||||
if (IsCurrent(identity))
|
||||
action(identity.ServerGuid);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool IsCurrent(QueuedInteractionIdentity identity)
|
||||
=> (identity.LocalEntityId is not uint localId
|
||||
|| _query.IsCurrent(identity.ServerGuid, localId))
|
||||
&& (identity.ClientObject is not { } item
|
||||
|| _items.IsCurrentObjectIdentity(identity.ServerGuid, item));
|
||||
|
||||
private void CancelPendingApproach()
|
||||
{
|
||||
if (_pendingPostArrival is not { } pending)
|
||||
return;
|
||||
_pendingPostArrival = null;
|
||||
if (pending.IsPickup)
|
||||
CancelPickupPresentation(
|
||||
pending.ServerGuid,
|
||||
pending.PendingPlacementToken);
|
||||
}
|
||||
|
||||
private void CancelPickupPresentation(uint itemGuid, ulong token = 0u)
|
||||
=> _items.CancelPendingBackpackPlacement(itemGuid, token);
|
||||
|
||||
private bool IsCurrentPickupPresentation(
|
||||
uint itemGuid,
|
||||
uint destinationContainerId,
|
||||
int placement,
|
||||
ulong token)
|
||||
=> token != 0u
|
||||
&& _items.TryGetPendingBackpackPlacement(
|
||||
itemGuid,
|
||||
out PendingBackpackPlacement pending)
|
||||
&& pending.Token == token
|
||||
&& pending.ContainerId == destinationContainerId
|
||||
&& pending.Placement == placement;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ internal interface IWorldSelectionQuery
|
|||
uint? PickAtCursor(bool includeSelf);
|
||||
uint? PickAt(float mouseX, float mouseY, bool includeSelf);
|
||||
void BeginLightingPulse(uint serverGuid);
|
||||
bool TryCaptureIdentity(uint serverGuid, out uint localEntityId);
|
||||
bool IsCurrent(uint serverGuid, uint localEntityId);
|
||||
string Describe(uint serverGuid);
|
||||
bool IsCreature(uint serverGuid);
|
||||
|
|
@ -142,6 +143,18 @@ internal sealed class WorldSelectionQuery : IWorldSelectionQuery
|
|||
_selectionScene.BeginLightingPulse(target.ServerGuid, target.LocalEntityId);
|
||||
}
|
||||
|
||||
public bool TryCaptureIdentity(uint serverGuid, out uint localEntityId)
|
||||
{
|
||||
if (TryGetInteractionTarget(serverGuid, out WorldInteractionTarget target))
|
||||
{
|
||||
localEntityId = target.LocalEntityId;
|
||||
return true;
|
||||
}
|
||||
|
||||
localEntityId = 0u;
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool TryGetInteractionTarget(
|
||||
uint serverGuid,
|
||||
out WorldInteractionTarget target)
|
||||
|
|
@ -274,7 +287,7 @@ internal sealed class WorldSelectionQuery : IWorldSelectionQuery
|
|||
return true;
|
||||
}
|
||||
|
||||
/// <summary>ItemUses::IsUseable @ retail 0x00566A20 call family.</summary>
|
||||
/// <summary>ItemUses::IsUseable @ retail 0x004FCCC0 call family.</summary>
|
||||
public bool IsUseable(uint serverGuid)
|
||||
{
|
||||
if (_liveEntities.TryGetSnapshot(serverGuid, out var spawn))
|
||||
|
|
|
|||
|
|
@ -13081,12 +13081,7 @@ public sealed class GameWindow : IDisposable
|
|||
private void OnUiDragReleasedOutside(object payload, int x, int y)
|
||||
{
|
||||
if (payload is AcDream.App.UI.ItemDragPayload itemPayload)
|
||||
{
|
||||
uint target = PickWorldGuidAt(x, y, includeSelf: true) ?? 0u;
|
||||
if (target != 0u)
|
||||
BeginSelectionLightingPulse(target);
|
||||
_itemInteractionController?.PlaceIn3D(itemPayload, target);
|
||||
}
|
||||
_selectionInteractions?.PlaceDraggedItem(itemPayload, x, y);
|
||||
}
|
||||
|
||||
// L.0 follow-up: persisted-settings cache populated by
|
||||
|
|
@ -13746,15 +13741,8 @@ public sealed class GameWindow : IDisposable
|
|||
return _worldSelectionQuery?.GetCombatCameraTargetPoint(selected);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Phase B.4b — outbound Use handler. Wires three input actions
|
||||
// (LMB click select, LMB-double-click select+use, R hotkey
|
||||
// use-selected) through WorldPicker into InteractRequests.BuildUse.
|
||||
// The inbound reply (SetState 0xF74B) lands via L.2g slice 1.
|
||||
// ============================================================
|
||||
|
||||
/// <summary>
|
||||
/// Shared world pick at the current cursor. The renderer supplies the
|
||||
/// Item-target-mode world pick at the current cursor. The renderer supplies the
|
||||
/// exact visible CPhysicsPart equivalents and RetailWorldPicker performs
|
||||
/// retail's drawing-sphere broadphase followed by flat visual-polygon
|
||||
/// intersection. <paramref name="includeSelf"/> allows item target-use to
|
||||
|
|
@ -13764,13 +13752,6 @@ public sealed class GameWindow : IDisposable
|
|||
=> _selectionInteractions?.PickAtCursor(includeSelf)
|
||||
?? _worldSelectionQuery?.PickAtCursor(includeSelf);
|
||||
|
||||
private uint? PickWorldGuidAt(float mouseX, float mouseY, bool includeSelf)
|
||||
=> _selectionInteractions?.PickAt(mouseX, mouseY, includeSelf)
|
||||
?? _worldSelectionQuery?.PickAt(mouseX, mouseY, includeSelf);
|
||||
|
||||
private void BeginSelectionLightingPulse(uint serverGuid)
|
||||
=> _worldSelectionQuery?.BeginLightingPulse(serverGuid);
|
||||
|
||||
// Contained/toolbar activation is not a world-selection interaction: it
|
||||
// sends directly without speculative TurnToObject/MoveToObject.
|
||||
private void UseItemByGuid(uint guid)
|
||||
|
|
@ -13818,33 +13799,6 @@ public sealed class GameWindow : IDisposable
|
|||
private AcDream.App.UI.Layout.VividTargetInfo? ResolveVividTargetInfo(uint guid)
|
||||
=> _worldSelectionQuery?.ResolveVividTargetInfo(guid);
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 2026-05-16 — retail-faithful port of
|
||||
/// <c>SmartBox::GetObjectBoundingBox</c> (decomp <c>0x00452e20</c>)
|
||||
/// using <c>CPhysicsObj::GetSelectionSphere</c> (<c>0x0050ea40</c>)
|
||||
/// → <c>CPartArray::GetSelectionSphere</c> (<c>0x00518b80</c>).
|
||||
///
|
||||
/// <para>
|
||||
/// Retail's VividTargetIndicator does NOT use a per-mesh AABB —
|
||||
/// it uses the Setup's <c>selection_sphere</c> field (a single
|
||||
/// sphere encompassing the entire entity, baked at Setup-creation
|
||||
/// time). The sphere is scaled by the part-array scale
|
||||
/// (component-wise on center, Z-scale on radius — retail's exact
|
||||
/// formula at <c>0x00518ba6–0x00518be3</c>) and rotated by entity
|
||||
/// orientation. The screen indicator rect is the projection of
|
||||
/// the camera-aligned BBox of this sphere — i.e. a screen circle
|
||||
/// of radius <c>worldRadius * focalLength / depth</c>.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Result: the indicator rect MATCHES the Setup's intended
|
||||
/// "selectable extent" — which is typically larger than the mesh
|
||||
/// AABB by design (Setups bake a slightly oversized selection
|
||||
/// sphere so far targets still get pickable indicators). That's
|
||||
/// why our previous mesh-AABB indicator was smaller than retail's.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private void TogglePlayerMode()
|
||||
{
|
||||
// Phase B.2 guard: only active when a live session is in-world.
|
||||
|
|
@ -14084,7 +14038,11 @@ public sealed class GameWindow : IDisposable
|
|||
Console.WriteLine($"[autowalk-end] reason=complete err={err}");
|
||||
if (err == AcDream.Core.Physics.WeenieError.None)
|
||||
OnAutoWalkArrivedSendDeferredAction();
|
||||
else
|
||||
_selectionInteractions?.OnMoveToCancelled(err);
|
||||
};
|
||||
playerMoveTo.MoveToCancelled = err =>
|
||||
_selectionInteractions?.OnMoveToCancelled(err);
|
||||
|
||||
// R5-V3 (#171, retires TS-39 — player side): bind the sticky
|
||||
// seams to the player host's PositionManager (same trio as the
|
||||
|
|
|
|||
|
|
@ -12,6 +12,31 @@ public enum ItemPrimaryClickResult
|
|||
ConsumedRejected,
|
||||
}
|
||||
|
||||
public readonly record struct PendingBackpackPlacement(
|
||||
ulong Token,
|
||||
uint ItemId,
|
||||
uint ContainerId,
|
||||
int Placement,
|
||||
ClientObject? ItemIdentity);
|
||||
|
||||
public enum InventoryRequestKind
|
||||
{
|
||||
Pickup,
|
||||
PutInContainer,
|
||||
SplitToContainer,
|
||||
Merge,
|
||||
DropToWorld,
|
||||
SplitToWorld,
|
||||
Give,
|
||||
}
|
||||
|
||||
public readonly record struct PendingInventoryRequest(
|
||||
ulong Token,
|
||||
InventoryRequestKind Kind,
|
||||
uint ItemId,
|
||||
ClientObject? ItemIdentity,
|
||||
bool Dispatched);
|
||||
|
||||
/// <summary>
|
||||
/// Shared retail item interaction orchestrator. UI widgets route item clicks,
|
||||
/// target acquisition, and drag-out drops here instead of duplicating
|
||||
|
|
@ -20,6 +45,8 @@ public enum ItemPrimaryClickResult
|
|||
public sealed class ItemInteractionController : IDisposable
|
||||
{
|
||||
private const long RetailUseThrottleMs = 200;
|
||||
internal const string InventoryRequestBusyMessage =
|
||||
"You can only move or use one item at a time";
|
||||
private const long RetailDoubleClickMs = 500;
|
||||
|
||||
private readonly ClientObjectTable _objects;
|
||||
|
|
@ -56,6 +83,9 @@ public sealed class ItemInteractionController : IDisposable
|
|||
private uint _consumedPrimaryClickTarget;
|
||||
private long _consumedPrimaryClickMs = long.MinValue / 2;
|
||||
private int _busyCount;
|
||||
private ulong _nextInventoryRequestToken;
|
||||
private PendingBackpackPlacement? _pendingBackpackPlacement;
|
||||
private PendingInventoryRequest? _pendingInventoryRequest;
|
||||
private bool _disposed;
|
||||
|
||||
public ItemInteractionController(
|
||||
|
|
@ -119,6 +149,11 @@ public sealed class ItemInteractionController : IDisposable
|
|||
_systemMessage = systemMessage;
|
||||
_interactionState = interactionState ?? new InteractionState();
|
||||
_interactionState.Changed += OnInteractionModeChanged;
|
||||
_objects.ObjectMoved += OnInventoryObjectMoved;
|
||||
_objects.MoveRequestFailed += OnInventoryMoveFailed;
|
||||
_objects.ObjectRemoved += OnInventoryObjectRemoved;
|
||||
_objects.StackSizeUpdated += OnInventoryStackSizeUpdated;
|
||||
_objects.Cleared += OnInventoryObjectsCleared;
|
||||
_autoWield = new AutoWieldController(
|
||||
_objects,
|
||||
_playerGuid,
|
||||
|
|
@ -137,7 +172,20 @@ public sealed class ItemInteractionController : IDisposable
|
|||
/// panel inserts a waiting projection before the pickup request is sent.
|
||||
/// The destination and placement are the same values sent on the wire.
|
||||
/// </summary>
|
||||
public event Action<uint, uint, int>? PendingBackpackPlacementRequested;
|
||||
public event Action<PendingBackpackPlacement>? PendingBackpackPlacementRequested;
|
||||
|
||||
/// <summary>
|
||||
/// Withdraws a waiting pickup projection when the approach is rejected or
|
||||
/// cancelled before a request reaches the server.
|
||||
/// </summary>
|
||||
public event Action<PendingBackpackPlacement>? PendingBackpackPlacementCancelled;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the exact waiting projection after an authoritative response.
|
||||
/// The token prevents a late response for a recycled GUID from erasing a
|
||||
/// newer incarnation's projection in the inventory panel.
|
||||
/// </summary>
|
||||
public event Action<PendingBackpackPlacement>? PendingBackpackPlacementResolved;
|
||||
|
||||
public uint PlayerGuid => _playerGuid();
|
||||
|
||||
|
|
@ -146,8 +194,151 @@ public sealed class ItemInteractionController : IDisposable
|
|||
public int BusyCount => _busyCount;
|
||||
|
||||
public bool CanMakeInventoryRequest =>
|
||||
BaseCanMakeInventoryRequest && _pendingInventoryRequest is null;
|
||||
|
||||
private bool BaseCanMakeInventoryRequest =>
|
||||
_readyForInventoryRequest() && _busyCount == 0 && !_autoWield.IsBusy;
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>ACCWeenieObject::IsPlayerReadyToMakeInventoryRequest</c>.
|
||||
/// The destination ItemList's separate <c>m_pendingItem</c> rejection is
|
||||
/// reported by <see cref="ReportPendingBackpackPlacementConflict"/>.
|
||||
/// </summary>
|
||||
public bool EnsureInventoryRequestReady()
|
||||
{
|
||||
if (CanMakeInventoryRequest)
|
||||
return true;
|
||||
|
||||
if (_pendingInventoryRequest is not null
|
||||
|| _busyCount != 0
|
||||
|| _autoWield.IsBusy)
|
||||
_systemMessage?.Invoke(InventoryRequestBusyMessage);
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>UIElement_ItemList::AcceptDragObject</c>'s local
|
||||
/// <c>m_pendingItem</c> branch. This wording belongs only to the destination
|
||||
/// list which already displays the waiting projection.
|
||||
/// </summary>
|
||||
public void ReportPendingBackpackPlacementConflict()
|
||||
{
|
||||
if (_pendingBackpackPlacement is not { } pending)
|
||||
return;
|
||||
string? itemName = _objects.Get(pending.ItemId)?.Name;
|
||||
string name = string.IsNullOrWhiteSpace(itemName) ? "that item" : itemName;
|
||||
_systemMessage?.Invoke($"Already attempting to place {name} here");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dispatches one retail inventory request. A provisional global
|
||||
/// <c>ACCWeenieObject::prevRequest</c> owner closes callback reentrancy,
|
||||
/// then becomes dispatched immediately after a successful wire send.
|
||||
/// The callback returns false when it could not dispatch anything.
|
||||
/// Retail: <c>RecordRequest @ 0x0058C220</c>, UIAttempt family
|
||||
/// <c>0x0058D590..0x0058D850</c>.
|
||||
/// </summary>
|
||||
public bool TryDispatchInventoryRequest(
|
||||
InventoryRequestKind kind,
|
||||
uint itemId,
|
||||
Func<bool> dispatch,
|
||||
ulong reservationToken = 0u)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dispatch);
|
||||
if (itemId == 0u)
|
||||
return false;
|
||||
|
||||
if (reservationToken != 0u)
|
||||
{
|
||||
if (_pendingInventoryRequest is not { } reserved
|
||||
|| reserved.Token != reservationToken
|
||||
|| reserved.ItemId != itemId
|
||||
|| reserved.Kind != kind
|
||||
|| reserved.Dispatched)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
bool reservedDispatch;
|
||||
try
|
||||
{
|
||||
reservedDispatch = dispatch();
|
||||
}
|
||||
catch
|
||||
{
|
||||
CancelPendingBackpackPlacement(itemId, reservationToken);
|
||||
throw;
|
||||
}
|
||||
if (!reservedDispatch)
|
||||
{
|
||||
CancelPendingBackpackPlacement(itemId, reservationToken);
|
||||
return false;
|
||||
}
|
||||
// A synchronous test transport or reentrant table listener may
|
||||
// already have completed this exact request. Never resurrect it or
|
||||
// overwrite a newer transaction acquired from that response.
|
||||
if (_pendingInventoryRequest is { } reservationCurrent
|
||||
&& reservationCurrent.Token == reserved.Token)
|
||||
{
|
||||
_pendingInventoryRequest = reserved with { Dispatched = true };
|
||||
StateChanged?.Invoke();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!EnsureInventoryRequestReady())
|
||||
return false;
|
||||
|
||||
ulong token = ++_nextInventoryRequestToken;
|
||||
if (token == 0u)
|
||||
token = ++_nextInventoryRequestToken;
|
||||
var provisional = new PendingInventoryRequest(
|
||||
token,
|
||||
kind,
|
||||
itemId,
|
||||
_objects.Get(itemId),
|
||||
Dispatched: false);
|
||||
// Provisional ownership closes the reentrancy window around optimistic
|
||||
// table mutation and arbitrary send adapters. Retail writes prevRequest
|
||||
// after its synchronous event call; our callback seam can publish local
|
||||
// notices reentrantly, so the owner is reserved before entering it.
|
||||
_pendingInventoryRequest = provisional;
|
||||
StateChanged?.Invoke();
|
||||
|
||||
bool dispatched;
|
||||
try
|
||||
{
|
||||
dispatched = dispatch();
|
||||
}
|
||||
catch
|
||||
{
|
||||
ClearPendingInventoryRequest(token);
|
||||
throw;
|
||||
}
|
||||
if (!dispatched)
|
||||
{
|
||||
ClearPendingInventoryRequest(token);
|
||||
return false;
|
||||
}
|
||||
if (_pendingInventoryRequest is { } current
|
||||
&& current.Token == token)
|
||||
{
|
||||
_pendingInventoryRequest = provisional with { Dispatched = true };
|
||||
StateChanged?.Invoke();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryGetPendingInventoryRequest(out PendingInventoryRequest pending)
|
||||
{
|
||||
if (_pendingInventoryRequest is { } current)
|
||||
{
|
||||
pending = current;
|
||||
return true;
|
||||
}
|
||||
pending = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Increments retail's shared <c>ClientUISystem</c> busy reference after a
|
||||
/// request issued by another retained controller has been sent. The
|
||||
|
|
@ -287,6 +478,8 @@ public sealed class ItemInteractionController : IDisposable
|
|||
|
||||
if (!ConsumeUseThrottle())
|
||||
return true;
|
||||
if (!EnsureInventoryRequestReady())
|
||||
return false;
|
||||
|
||||
var input = new ItemUsePolicyInput(
|
||||
Snapshot(item),
|
||||
|
|
@ -317,11 +510,195 @@ public sealed class ItemInteractionController : IDisposable
|
|||
if (containerId == 0u)
|
||||
containerId = _playerGuid();
|
||||
const int placement = 0;
|
||||
PendingBackpackPlacementRequested?.Invoke(itemGuid, containerId, placement);
|
||||
if (!TryBeginPendingBackpackPlacement(
|
||||
itemGuid,
|
||||
containerId,
|
||||
placement,
|
||||
out _))
|
||||
{
|
||||
// Retail permits only one inventory request at a time. Consume the
|
||||
// input without replacing a pending projection or issuing a wire
|
||||
// request while another inventory transaction owns the gate.
|
||||
return true;
|
||||
}
|
||||
// This callback may install a retail MoveToObject approach instead of
|
||||
// sending immediately. SelectionInteractionController promotes the
|
||||
// reservation to Dispatched only when it actually emits the wire move.
|
||||
_placeInBackpack(itemGuid, containerId, placement);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryBeginPendingBackpackPlacement(
|
||||
uint itemGuid,
|
||||
uint containerId,
|
||||
int placement,
|
||||
out PendingBackpackPlacement pending)
|
||||
=> TryBeginPendingBackpackPlacement(
|
||||
itemGuid,
|
||||
containerId,
|
||||
placement,
|
||||
InventoryRequestKind.Pickup,
|
||||
out pending);
|
||||
|
||||
private bool TryBeginPendingBackpackPlacement(
|
||||
uint itemGuid,
|
||||
uint containerId,
|
||||
int placement,
|
||||
InventoryRequestKind kind,
|
||||
out PendingBackpackPlacement pending)
|
||||
{
|
||||
if (itemGuid == 0u || containerId == 0u)
|
||||
{
|
||||
pending = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_pendingBackpackPlacement is { } existing)
|
||||
{
|
||||
ReportPendingBackpackPlacementConflict();
|
||||
pending = existing;
|
||||
return false;
|
||||
}
|
||||
if (!EnsureInventoryRequestReady())
|
||||
{
|
||||
pending = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
ulong token = ++_nextInventoryRequestToken;
|
||||
if (token == 0u)
|
||||
token = ++_nextInventoryRequestToken;
|
||||
ClientObject? identity = _objects.Get(itemGuid);
|
||||
pending = new PendingBackpackPlacement(
|
||||
token,
|
||||
itemGuid,
|
||||
containerId,
|
||||
placement,
|
||||
identity);
|
||||
_pendingBackpackPlacement = pending;
|
||||
_pendingInventoryRequest = new PendingInventoryRequest(
|
||||
token,
|
||||
kind,
|
||||
itemGuid,
|
||||
identity,
|
||||
Dispatched: false);
|
||||
PendingBackpackPlacementRequested?.Invoke(pending);
|
||||
if (_pendingBackpackPlacement != pending
|
||||
|| _pendingInventoryRequest is not { } published
|
||||
|| published.Token != token
|
||||
|| published.ItemId != itemGuid
|
||||
|| published.Kind != kind
|
||||
|| published.Dispatched)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
StateChanged?.Invoke();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes retail's destination waiting projection, dispatches the move,
|
||||
/// then records the global request. Failed dispatch withdraws only the
|
||||
/// projection created by this call.
|
||||
/// </summary>
|
||||
public bool TryDispatchPendingBackpackPlacement(
|
||||
uint itemGuid,
|
||||
uint containerId,
|
||||
int placement,
|
||||
InventoryRequestKind kind,
|
||||
Func<bool> dispatch)
|
||||
{
|
||||
// Callers such as gmToolbarUI check the global prevRequest before they
|
||||
// ask CPlayerSystem to publish ShowPendingInPlayer. This preserves the
|
||||
// generic busy wording when both states happen to be present.
|
||||
if (!EnsureInventoryRequestReady())
|
||||
return false;
|
||||
if (!TryBeginPendingBackpackPlacement(
|
||||
itemGuid,
|
||||
containerId,
|
||||
placement,
|
||||
kind,
|
||||
out PendingBackpackPlacement pending))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_pendingBackpackPlacement != pending
|
||||
|| _pendingInventoryRequest is not { } reserved
|
||||
|| reserved.Token != pending.Token
|
||||
|| reserved.ItemId != pending.ItemId
|
||||
|| reserved.Kind != kind
|
||||
|| reserved.Dispatched)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (TryDispatchInventoryRequest(
|
||||
kind,
|
||||
itemGuid,
|
||||
dispatch,
|
||||
pending.Token))
|
||||
return true;
|
||||
|
||||
CancelPendingBackpackPlacement(itemGuid, pending.Token);
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool TryGetPendingBackpackPlacement(
|
||||
uint itemGuid,
|
||||
out PendingBackpackPlacement pending)
|
||||
{
|
||||
if (_pendingBackpackPlacement is { } current
|
||||
&& current.ItemId == itemGuid)
|
||||
{
|
||||
pending = current;
|
||||
return true;
|
||||
}
|
||||
|
||||
pending = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
public void CancelPendingBackpackPlacement(uint itemGuid = 0u, ulong token = 0u)
|
||||
{
|
||||
if (_pendingBackpackPlacement is not { } pending
|
||||
|| (itemGuid != 0u && pending.ItemId != itemGuid)
|
||||
|| (token != 0u && pending.Token != token))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_pendingBackpackPlacement = null;
|
||||
if (_pendingInventoryRequest is { } request
|
||||
&& request.Token == pending.Token
|
||||
&& !request.Dispatched)
|
||||
{
|
||||
_pendingInventoryRequest = null;
|
||||
StateChanged?.Invoke();
|
||||
}
|
||||
PendingBackpackPlacementCancelled?.Invoke(pending);
|
||||
}
|
||||
|
||||
private void ClearPendingInventoryRequest(ulong token)
|
||||
{
|
||||
if (_pendingInventoryRequest is not { } pending
|
||||
|| pending.Token != token)
|
||||
{
|
||||
return;
|
||||
}
|
||||
_pendingInventoryRequest = null;
|
||||
StateChanged?.Invoke();
|
||||
}
|
||||
|
||||
internal bool TryCaptureObjectIdentity(uint objectId, out ClientObject item)
|
||||
{
|
||||
item = _objects.Get(objectId)!;
|
||||
return item is not null;
|
||||
}
|
||||
|
||||
internal bool IsCurrentObjectIdentity(uint objectId, ClientObject item)
|
||||
=> ReferenceEquals(_objects.Get(objectId), item);
|
||||
|
||||
/// <summary>
|
||||
/// Retail paperdoll drop entry point. The paperdoll resolves the exact
|
||||
/// target side/location; this shared interaction owner performs AutoWield's
|
||||
|
|
@ -333,6 +710,8 @@ public sealed class ItemInteractionController : IDisposable
|
|||
return false;
|
||||
if (_objects.Get(itemGuid) is not { } item)
|
||||
return false;
|
||||
if (!EnsureInventoryRequestReady())
|
||||
return false;
|
||||
return _autoWield.TryWield(item, targetMask);
|
||||
}
|
||||
|
||||
|
|
@ -360,7 +739,7 @@ public sealed class ItemInteractionController : IDisposable
|
|||
if (!compatible)
|
||||
return false;
|
||||
|
||||
if (!CanMakeInventoryRequest)
|
||||
if (!EnsureInventoryRequestReady())
|
||||
return false;
|
||||
_sendUseWithTarget?.Invoke(sourceGuid, targetGuid);
|
||||
_busyCount++;
|
||||
|
|
@ -380,6 +759,8 @@ public sealed class ItemInteractionController : IDisposable
|
|||
{
|
||||
if (objectId == 0u || _sendUse is null)
|
||||
return false;
|
||||
if (!EnsureInventoryRequestReady())
|
||||
return false;
|
||||
_sendUse(objectId);
|
||||
_busyCount++;
|
||||
return true;
|
||||
|
|
@ -407,6 +788,8 @@ public sealed class ItemInteractionController : IDisposable
|
|||
return false;
|
||||
if (payload.ObjId == 0 || _objects.Get(payload.ObjId) is not { } item)
|
||||
return false;
|
||||
if (!EnsureInventoryRequestReady())
|
||||
return false;
|
||||
|
||||
uint fullStack = (uint)Math.Max(1, item.StackSize);
|
||||
int splitSize = (int)(_stackSplitQuantity?.GetObjectSplitSize(
|
||||
|
|
@ -496,22 +879,54 @@ public sealed class ItemInteractionController : IDisposable
|
|||
switch (action.Kind)
|
||||
{
|
||||
case ItemPolicyActionKind.DropToWorld:
|
||||
_objects.MoveItemOptimistic(action.ObjectId, newContainerId: 0u, newSlot: -1);
|
||||
_sendDrop?.Invoke(action.ObjectId);
|
||||
TryDispatchInventoryRequest(
|
||||
InventoryRequestKind.DropToWorld,
|
||||
action.ObjectId,
|
||||
() =>
|
||||
{
|
||||
if (_sendDrop is null
|
||||
|| !_objects.MoveItemOptimistic(
|
||||
action.ObjectId,
|
||||
newContainerId: 0u,
|
||||
newSlot: -1))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
_sendDrop(action.ObjectId);
|
||||
return true;
|
||||
});
|
||||
break;
|
||||
case ItemPolicyActionKind.SplitToWorld:
|
||||
// UIAttemptSplitTo3D leaves the original object in its container;
|
||||
// the server assigns the split-off ground stack a new guid.
|
||||
_sendSplitToWorld?.Invoke(action.ObjectId, (uint)action.Amount);
|
||||
TryDispatchInventoryRequest(
|
||||
InventoryRequestKind.SplitToWorld,
|
||||
action.ObjectId,
|
||||
() =>
|
||||
{
|
||||
if (_sendSplitToWorld is null)
|
||||
return false;
|
||||
_sendSplitToWorld(action.ObjectId, (uint)action.Amount);
|
||||
return true;
|
||||
});
|
||||
break;
|
||||
case ItemPolicyActionKind.GiveToTarget:
|
||||
// UIAttemptGive @ 0x0058D620 sends the request and leaves
|
||||
// the source untouched until the authoritative server
|
||||
// InventoryRemoveObject / SetStackSize response arrives.
|
||||
_sendGive?.Invoke(
|
||||
action.TargetId,
|
||||
TryDispatchInventoryRequest(
|
||||
InventoryRequestKind.Give,
|
||||
action.ObjectId,
|
||||
(uint)Math.Max(1, action.Amount));
|
||||
() =>
|
||||
{
|
||||
if (_sendGive is null)
|
||||
return false;
|
||||
_sendGive(
|
||||
action.TargetId,
|
||||
action.ObjectId,
|
||||
(uint)Math.Max(1, action.Amount));
|
||||
return true;
|
||||
});
|
||||
break;
|
||||
case ItemPolicyActionKind.PlaceInContainer:
|
||||
{
|
||||
|
|
@ -519,10 +934,35 @@ public sealed class ItemInteractionController : IDisposable
|
|||
1,
|
||||
_objects.Get(action.ObjectId)?.StackSize ?? action.Amount);
|
||||
uint amount = (uint)Math.Max(1, action.Amount);
|
||||
if (amount < fullStack)
|
||||
_sendSplitToContainer?.Invoke(action.ObjectId, action.TargetId, 0u, amount);
|
||||
else
|
||||
_sendPutItemInContainer?.Invoke(action.ObjectId, action.TargetId, 0);
|
||||
InventoryRequestKind kind = amount < fullStack
|
||||
? InventoryRequestKind.SplitToContainer
|
||||
: InventoryRequestKind.PutInContainer;
|
||||
TryDispatchInventoryRequest(
|
||||
kind,
|
||||
action.ObjectId,
|
||||
() =>
|
||||
{
|
||||
if (amount < fullStack)
|
||||
{
|
||||
if (_sendSplitToContainer is null)
|
||||
return false;
|
||||
_sendSplitToContainer(
|
||||
action.ObjectId,
|
||||
action.TargetId,
|
||||
0u,
|
||||
amount);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (_sendPutItemInContainer is null)
|
||||
return false;
|
||||
_sendPutItemInContainer(
|
||||
action.ObjectId,
|
||||
action.TargetId,
|
||||
0);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
break;
|
||||
}
|
||||
case ItemPolicyActionKind.Reject:
|
||||
|
|
@ -571,11 +1011,93 @@ public sealed class ItemInteractionController : IDisposable
|
|||
private void OnInteractionModeChanged(InteractionModeTransition _)
|
||||
=> StateChanged?.Invoke();
|
||||
|
||||
private void OnInventoryObjectMoved(ClientObjectMove move)
|
||||
{
|
||||
if (move.Origin != ClientObjectMoveOrigin.AuthoritativeResponse)
|
||||
return;
|
||||
|
||||
CompleteInventoryResponse(move.ItemId, move.Item);
|
||||
}
|
||||
|
||||
private void OnInventoryMoveFailed(MoveRequestFailure failure)
|
||||
{
|
||||
ClientObject? identity = _objects.Get(failure.ItemId);
|
||||
CompleteInventoryResponse(failure.ItemId, identity);
|
||||
}
|
||||
|
||||
private void OnInventoryObjectRemoved(ClientObject item)
|
||||
{
|
||||
CompleteInventoryResponse(item.ObjectId, item);
|
||||
}
|
||||
|
||||
private void OnInventoryStackSizeUpdated(ClientObject item)
|
||||
{
|
||||
CompleteInventoryResponse(item.ObjectId, item);
|
||||
}
|
||||
|
||||
private void CompleteInventoryResponse(uint itemId, ClientObject? identity)
|
||||
{
|
||||
PendingInventoryRequest? completedRequest = null;
|
||||
PendingBackpackPlacement? completedPlacement = null;
|
||||
|
||||
if (_pendingInventoryRequest is { } request
|
||||
&& request.ItemId == itemId
|
||||
&& MatchesIdentity(request.ItemIdentity, itemId, identity))
|
||||
{
|
||||
completedRequest = request;
|
||||
_pendingInventoryRequest = null;
|
||||
}
|
||||
|
||||
if (_pendingBackpackPlacement is { } placement
|
||||
&& placement.ItemId == itemId
|
||||
&& MatchesIdentity(placement.ItemIdentity, itemId, identity))
|
||||
{
|
||||
completedPlacement = placement;
|
||||
_pendingBackpackPlacement = null;
|
||||
}
|
||||
|
||||
// ACCWeenieObject::RecordResponse @ 0x0058CAB0 clears prevRequest
|
||||
// before ServerSaysMoveItem publishes the item-list notice. Clear the
|
||||
// coupled global owner and local waiting projection atomically before
|
||||
// either callback so observers never see a half-completed transaction.
|
||||
if (completedPlacement is { } resolved)
|
||||
PendingBackpackPlacementResolved?.Invoke(resolved);
|
||||
if (completedRequest is not null)
|
||||
StateChanged?.Invoke();
|
||||
}
|
||||
|
||||
private bool MatchesIdentity(
|
||||
ClientObject? expected,
|
||||
uint itemId,
|
||||
ClientObject? actual)
|
||||
{
|
||||
if (expected is null)
|
||||
return true;
|
||||
if (actual is not null)
|
||||
return ReferenceEquals(expected, actual);
|
||||
return _objects.Get(itemId) is not { } current
|
||||
|| ReferenceEquals(expected, current);
|
||||
}
|
||||
|
||||
private void OnInventoryObjectsCleared()
|
||||
{
|
||||
bool changed = _pendingInventoryRequest is not null;
|
||||
_pendingInventoryRequest = null;
|
||||
_pendingBackpackPlacement = null;
|
||||
if (changed)
|
||||
StateChanged?.Invoke();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_interactionState.Changed -= OnInteractionModeChanged;
|
||||
_objects.ObjectMoved -= OnInventoryObjectMoved;
|
||||
_objects.MoveRequestFailed -= OnInventoryMoveFailed;
|
||||
_objects.ObjectRemoved -= OnInventoryObjectRemoved;
|
||||
_objects.StackSizeUpdated -= OnInventoryStackSizeUpdated;
|
||||
_objects.Cleared -= OnInventoryObjectsCleared;
|
||||
_autoWield.Dispose();
|
||||
}
|
||||
|
||||
|
|
@ -602,9 +1124,11 @@ public sealed class ItemInteractionController : IDisposable
|
|||
/// </summary>
|
||||
public void ResetSession()
|
||||
{
|
||||
bool busyChanged = _busyCount != 0;
|
||||
CancelPendingBackpackPlacement();
|
||||
bool busyChanged = _busyCount != 0 || _pendingInventoryRequest is not null;
|
||||
bool modeChanged = IsAnyTargetModeActive;
|
||||
_busyCount = 0;
|
||||
_pendingInventoryRequest = null;
|
||||
_lastUseMs = long.MinValue / 2;
|
||||
_consumedPrimaryClickTarget = 0u;
|
||||
_consumedPrimaryClickMs = long.MinValue / 2;
|
||||
|
|
|
|||
|
|
@ -223,6 +223,8 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine
|
|||
{
|
||||
if (OnDragOver(targetList, targetCell, payload) != ItemDragAcceptance.Accept)
|
||||
return;
|
||||
if (!_itemInteraction.EnsureInventoryRequestReady())
|
||||
return;
|
||||
if (_objects.Get(payload.ObjId) is not { } item)
|
||||
return;
|
||||
|
||||
|
|
@ -238,10 +240,20 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine
|
|||
// ACCWeenieObject::UIAttemptPutInContainer @ 0x0058D680 and
|
||||
// UIAttemptSplitToContainer @ 0x0058D7D0 are request-only. Do not
|
||||
// optimistically rewrite external ownership.
|
||||
if (amount < fullStack)
|
||||
_sendSplitToContainer(item.ObjectId, _openContainer, (uint)placement, amount);
|
||||
else
|
||||
_sendPutItemInContainer(item.ObjectId, _openContainer, placement);
|
||||
InventoryRequestKind kind = amount < fullStack
|
||||
? InventoryRequestKind.SplitToContainer
|
||||
: InventoryRequestKind.PutInContainer;
|
||||
_itemInteraction.TryDispatchInventoryRequest(
|
||||
kind,
|
||||
item.ObjectId,
|
||||
() =>
|
||||
{
|
||||
if (amount < fullStack)
|
||||
_sendSplitToContainer(item.ObjectId, _openContainer, (uint)placement, amount);
|
||||
else
|
||||
_sendPutItemInContainer(item.ObjectId, _openContainer, placement);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
private void OnExternalContainerChanged(ExternalContainerTransition transition)
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
private bool _disposed;
|
||||
|
||||
private readonly record struct PendingListPlacement(
|
||||
ulong Token,
|
||||
uint ItemId,
|
||||
uint ContainerId,
|
||||
int Placement);
|
||||
|
|
@ -183,6 +184,8 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
{
|
||||
_itemInteraction.StateChanged += OnInteractionStateChanged;
|
||||
_itemInteraction.PendingBackpackPlacementRequested += OnPendingBackpackPlacementRequested;
|
||||
_itemInteraction.PendingBackpackPlacementCancelled += OnPendingBackpackPlacementCancelled;
|
||||
_itemInteraction.PendingBackpackPlacementResolved += OnPendingBackpackPlacementResolved;
|
||||
}
|
||||
|
||||
Populate();
|
||||
|
|
@ -242,7 +245,9 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
}
|
||||
private void OnObjectRemoved(ClientObject o)
|
||||
{
|
||||
if (_pendingListPlacement?.ItemId == o.ObjectId)
|
||||
bool fallbackResolved = _itemInteraction is null
|
||||
&& _pendingListPlacement?.ItemId == o.ObjectId;
|
||||
if (fallbackResolved)
|
||||
_pendingListPlacement = null;
|
||||
if (_selection.SelectedObjectId == o.ObjectId)
|
||||
{
|
||||
|
|
@ -250,15 +255,16 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
SelectionChangeSource.System,
|
||||
SelectionChangeReason.SelectedObjectRemoved);
|
||||
}
|
||||
if (Concerns(o)) Populate();
|
||||
if (fallbackResolved || Concerns(o)) Populate();
|
||||
}
|
||||
private void OnObjectMoved(ClientObjectMove move)
|
||||
{
|
||||
bool resolvedPending = _pendingListPlacement?.ItemId == move.ItemId;
|
||||
if (resolvedPending)
|
||||
bool fallbackResolved = _itemInteraction is null
|
||||
&& _pendingListPlacement?.ItemId == move.ItemId;
|
||||
if (fallbackResolved)
|
||||
_pendingListPlacement = null;
|
||||
uint player = _playerGuid();
|
||||
if (resolvedPending
|
||||
if (fallbackResolved
|
||||
|| (move.Item is { } item && Concerns(item))
|
||||
|| move.Previous.ContainerId == player
|
||||
|| move.Current.ContainerId == player
|
||||
|
|
@ -272,30 +278,45 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
Populate();
|
||||
}
|
||||
private void OnInteractionStateChanged() => ApplyIndicators();
|
||||
private void OnPendingBackpackPlacementRequested(
|
||||
uint itemId,
|
||||
uint containerId,
|
||||
int placement)
|
||||
private void OnPendingBackpackPlacementRequested(PendingBackpackPlacement pending)
|
||||
{
|
||||
if (_pendingListPlacement is not null
|
||||
|| itemId == 0u
|
||||
|| containerId != EffectiveOpen()
|
||||
|| _objects.Get(itemId) is not { } item
|
||||
|| pending.ItemId == 0u
|
||||
|| pending.ContainerId != EffectiveOpen()
|
||||
|| _objects.Get(pending.ItemId) is not { } item
|
||||
|| IsBag(item))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_pendingListPlacement = new PendingListPlacement(
|
||||
itemId,
|
||||
containerId,
|
||||
placement);
|
||||
pending.Token,
|
||||
pending.ItemId,
|
||||
pending.ContainerId,
|
||||
pending.Placement);
|
||||
Populate();
|
||||
}
|
||||
private void OnPendingBackpackPlacementCancelled(PendingBackpackPlacement pending)
|
||||
{
|
||||
if (_pendingListPlacement is not { } projection
|
||||
|| projection.Token != pending.Token)
|
||||
return;
|
||||
_pendingListPlacement = null;
|
||||
Populate();
|
||||
}
|
||||
private void OnPendingBackpackPlacementResolved(PendingBackpackPlacement pending)
|
||||
{
|
||||
if (_pendingListPlacement is not { } projection
|
||||
|| projection.Token != pending.Token)
|
||||
return;
|
||||
_pendingListPlacement = null;
|
||||
Populate();
|
||||
}
|
||||
private void OnSelectionChanged(SelectionTransition _) => ApplyIndicators();
|
||||
private void OnMoveRequestFailed(MoveRequestFailure failure)
|
||||
{
|
||||
if (_pendingListPlacement?.ItemId != failure.ItemId)
|
||||
if (_itemInteraction is not null
|
||||
|| _pendingListPlacement?.ItemId != failure.ItemId)
|
||||
return;
|
||||
_pendingListPlacement = null;
|
||||
Populate();
|
||||
|
|
@ -531,9 +552,6 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
return ItemDragAcceptance.None;
|
||||
if (payload.ObjId == 0)
|
||||
return ItemDragAcceptance.Reject;
|
||||
if (payload.SourceKind == ItemDragSource.Ground
|
||||
&& _pendingListPlacement is not null)
|
||||
return ItemDragAcceptance.Reject;
|
||||
if (targetList == _contentsGrid)
|
||||
return ItemDragAcceptance.Accept;
|
||||
if (targetList == _containerList || targetList == _topContainer)
|
||||
|
|
@ -561,6 +579,23 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
uint item = payload.ObjId;
|
||||
if (item == 0) return;
|
||||
|
||||
// UIElement_ItemList::AcceptDragObject @ 0x004E4250 rejects every
|
||||
// release while m_pendingItem exists, before merge, split, or ordinary
|
||||
// placement. ItemList_DragOver has no equivalent gate, so hover may
|
||||
// still show the authored accept cursor immediately before this no-op.
|
||||
if (targetList == _contentsGrid
|
||||
&& _pendingListPlacement is { } localPending
|
||||
&& localPending.ContainerId == EffectiveOpen())
|
||||
{
|
||||
_itemInteraction?.ReportPendingBackpackPlacementConflict();
|
||||
return;
|
||||
}
|
||||
if (_itemInteraction is not null
|
||||
&& !_itemInteraction.EnsureInventoryRequestReady())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// UIElement_ItemList::AcceptDragObject tries ItemHolder::AttemptMerge first
|
||||
// when a physical item is released on an occupied inventory cell. A legal
|
||||
// merge consumes the drop; an illegal merge falls through to insert/move.
|
||||
|
|
@ -597,8 +632,20 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
{
|
||||
// UIAttemptSplitToContainer leaves the source stack where it is. ACE will
|
||||
// publish the reduced source plus a newly-guided destination stack.
|
||||
_sendStackableSplitToContainer?.Invoke(
|
||||
item, container, (uint)placement, splitSize);
|
||||
DispatchInventoryRequest(
|
||||
InventoryRequestKind.SplitToContainer,
|
||||
item,
|
||||
() =>
|
||||
{
|
||||
if (_sendStackableSplitToContainer is null)
|
||||
return false;
|
||||
_sendStackableSplitToContainer(
|
||||
item,
|
||||
container,
|
||||
(uint)placement,
|
||||
splitSize);
|
||||
return true;
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -610,13 +657,52 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
// @ 0x004E4790; ACCWeenieObject::UIAttemptPutInContainer @ 0x0058D680.
|
||||
if (payload.SourceKind == ItemDragSource.Ground)
|
||||
{
|
||||
if (_pendingListPlacement is not null)
|
||||
if (_itemInteraction is not null)
|
||||
{
|
||||
if (!_itemInteraction.TryDispatchPendingBackpackPlacement(
|
||||
item,
|
||||
container,
|
||||
placement,
|
||||
InventoryRequestKind.Pickup,
|
||||
() =>
|
||||
{
|
||||
if (_sendPutItemInContainer is null)
|
||||
return false;
|
||||
_sendPutItemInContainer(item, container, placement);
|
||||
return true;
|
||||
}))
|
||||
{
|
||||
return;
|
||||
}
|
||||
return;
|
||||
_pendingListPlacement = new PendingListPlacement(item, container, placement);
|
||||
Populate();
|
||||
}
|
||||
else
|
||||
{
|
||||
if (_pendingListPlacement is not null)
|
||||
return;
|
||||
_pendingListPlacement = new PendingListPlacement(0u, item, container, placement);
|
||||
Populate();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (_itemInteraction is not null)
|
||||
{
|
||||
DispatchInventoryRequest(
|
||||
InventoryRequestKind.PutInContainer,
|
||||
item,
|
||||
() =>
|
||||
{
|
||||
if (_sendPutItemInContainer is null
|
||||
|| !_objects.MoveItemOptimistic(item, container, placement))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
_sendPutItemInContainer(item, container, placement);
|
||||
return true;
|
||||
});
|
||||
return;
|
||||
}
|
||||
_objects.MoveItemOptimistic(item, container, placement);
|
||||
}
|
||||
_sendPutItemInContainer?.Invoke(item, container, placement);
|
||||
|
|
@ -651,14 +737,30 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
if (plan is not { } merge)
|
||||
return false;
|
||||
|
||||
_sendStackableMerge(merge.SourceObjectId, merge.TargetObjectId, merge.Amount);
|
||||
// ItemHolder::AttemptMerge broadcasts this immediately after UIAttemptMerge;
|
||||
// it is an attempt notice, not a later server-confirm event.
|
||||
_notifyMergeAttempt?.Invoke(merge.SourceObjectId, merge.TargetObjectId);
|
||||
_selection.Select(merge.TargetObjectId, SelectionChangeSource.Inventory);
|
||||
return true;
|
||||
return DispatchInventoryRequest(
|
||||
InventoryRequestKind.Merge,
|
||||
merge.SourceObjectId,
|
||||
() =>
|
||||
{
|
||||
_sendStackableMerge(
|
||||
merge.SourceObjectId,
|
||||
merge.TargetObjectId,
|
||||
merge.Amount);
|
||||
// ItemHolder::AttemptMerge broadcasts this immediately after UIAttemptMerge;
|
||||
// it is an attempt notice, not a later server-confirm event.
|
||||
_notifyMergeAttempt?.Invoke(merge.SourceObjectId, merge.TargetObjectId);
|
||||
_selection.Select(merge.TargetObjectId, SelectionChangeSource.Inventory);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
private bool DispatchInventoryRequest(
|
||||
InventoryRequestKind kind,
|
||||
uint itemId,
|
||||
Func<bool> dispatch)
|
||||
=> _itemInteraction?.TryDispatchInventoryRequest(kind, itemId, dispatch)
|
||||
?? dispatch();
|
||||
|
||||
/// <summary>True only when we KNOW the container is full (capacity known + contents indexed). A
|
||||
/// closed bag (unknown count) returns false → advisory accept; the server is authoritative.</summary>
|
||||
private bool IsContainerFull(uint container)
|
||||
|
|
@ -691,7 +793,12 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
uint p = _playerGuid();
|
||||
_openContainer = guid;
|
||||
if (guid != p)
|
||||
_sendUse?.Invoke(guid); // open the side bag (ViewContents will land)
|
||||
{
|
||||
if (_itemInteraction is not null)
|
||||
_itemInteraction.ActivateItem(guid);
|
||||
else
|
||||
_sendUse?.Invoke(guid); // open the side bag (ViewContents will land)
|
||||
}
|
||||
Populate(); // repaint the grid for the new open container
|
||||
}
|
||||
|
||||
|
|
@ -814,6 +921,8 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
{
|
||||
_itemInteraction.StateChanged -= OnInteractionStateChanged;
|
||||
_itemInteraction.PendingBackpackPlacementRequested -= OnPendingBackpackPlacementRequested;
|
||||
_itemInteraction.PendingBackpackPlacementCancelled -= OnPendingBackpackPlacementCancelled;
|
||||
_itemInteraction.PendingBackpackPlacementResolved -= OnPendingBackpackPlacementResolved;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -519,6 +519,29 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
|
|||
uint player = _playerGuid?.Invoke() ?? 0u;
|
||||
if (player == 0 || _repo.Get(payload.ObjId) is null)
|
||||
return;
|
||||
if (_itemInteraction is not null)
|
||||
{
|
||||
Func<bool> dispatch = () =>
|
||||
{
|
||||
if (_sendPutItemInContainer is null)
|
||||
return false;
|
||||
_sendPutItemInContainer(payload.ObjId, player, 0);
|
||||
return true;
|
||||
};
|
||||
if (_itemInteraction.IsOwnedByPlayer(payload.ObjId))
|
||||
_itemInteraction.TryDispatchPendingBackpackPlacement(
|
||||
payload.ObjId,
|
||||
player,
|
||||
0,
|
||||
InventoryRequestKind.PutInContainer,
|
||||
dispatch);
|
||||
else
|
||||
_itemInteraction.TryDispatchInventoryRequest(
|
||||
InventoryRequestKind.Pickup,
|
||||
payload.ObjId,
|
||||
dispatch);
|
||||
return;
|
||||
}
|
||||
_sendPutItemInContainer?.Invoke(payload.ObjId, player, 0);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -50,11 +50,19 @@ public readonly record struct ClientObjectPlacement(
|
|||
/// <c>ACCWeenieObject::ServerSaysMoveItem @ 0x0058DBB0</c> snapshots and
|
||||
/// publishes both complete placements after applying the new state.
|
||||
/// </summary>
|
||||
public enum ClientObjectMoveOrigin
|
||||
{
|
||||
LocalProjection,
|
||||
ServerRollbackProjection,
|
||||
AuthoritativeResponse,
|
||||
}
|
||||
|
||||
public readonly record struct ClientObjectMove(
|
||||
uint ItemId,
|
||||
ClientObject? Item,
|
||||
ClientObjectPlacement Previous,
|
||||
ClientObjectPlacement Current);
|
||||
ClientObjectPlacement Current,
|
||||
ClientObjectMoveOrigin Origin);
|
||||
|
||||
public enum ClientObjectRemovalReason
|
||||
{
|
||||
|
|
@ -119,7 +127,10 @@ public sealed class ClientObjectTable
|
|||
|
||||
/// <summary>
|
||||
/// Fires after a complete placement notice (moved between packs,
|
||||
/// equipped, unequipped, dropped on ground). <see cref="ClientObjectMove.Item"/>
|
||||
/// equipped, unequipped, dropped on ground). <see cref="ClientObjectMove.Origin"/>
|
||||
/// distinguishes an immediate local projection from ACE's authoritative
|
||||
/// response; transaction owners must complete only on the latter.
|
||||
/// <see cref="ClientObjectMove.Item"/>
|
||||
/// is null when retail publishes the notice before CreateObject resolves
|
||||
/// the GUID; the old/new placement remains authoritative notice data.
|
||||
/// </summary>
|
||||
|
|
@ -168,6 +179,13 @@ public sealed class ClientObjectTable
|
|||
/// <summary>Fires when an object's properties are updated (typically after Appraise).</summary>
|
||||
public event Action<ClientObject>? ObjectUpdated;
|
||||
|
||||
/// <summary>
|
||||
/// Fires only for an authoritative SetStackSize response. Inventory request
|
||||
/// owners use this narrower seam instead of treating an unrelated appraisal
|
||||
/// or property update as the response to a merge, split, give, or drop.
|
||||
/// </summary>
|
||||
public event Action<ClientObject>? StackSizeUpdated;
|
||||
|
||||
/// <summary>Fires after all session object and pending-move state is flushed.</summary>
|
||||
public event Action? Cleared;
|
||||
|
||||
|
|
@ -242,7 +260,8 @@ public sealed class ClientObjectTable
|
|||
newSlot,
|
||||
item.WielderId,
|
||||
newEquipLocation),
|
||||
containerTypeHint);
|
||||
containerTypeHint,
|
||||
ClientObjectMoveOrigin.LocalProjection);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -270,13 +289,15 @@ public sealed class ClientObjectTable
|
|||
newSlot,
|
||||
newWielderId,
|
||||
newEquipLocation),
|
||||
containerTypeHint);
|
||||
containerTypeHint,
|
||||
ClientObjectMoveOrigin.AuthoritativeResponse);
|
||||
}
|
||||
|
||||
private bool ApplyPlacement(
|
||||
ClientObject item,
|
||||
ClientObjectPlacement current,
|
||||
uint? containerTypeHint = null)
|
||||
uint? containerTypeHint,
|
||||
ClientObjectMoveOrigin origin)
|
||||
{
|
||||
ClientObjectPlacement previous = ClientObjectPlacement.From(item);
|
||||
List<uint>? changedContainers = RemoveFromOtherContainerIndexes(
|
||||
|
|
@ -289,18 +310,19 @@ public sealed class ClientObjectTable
|
|||
if (containerTypeHint is { } hint)
|
||||
item.ContainerTypeHint = hint;
|
||||
Reindex(item, previous.ContainerId);
|
||||
PublishPlacementChange(item, previous);
|
||||
PublishPlacementChange(item, previous, origin);
|
||||
PublishContainerContentsChanges(changedContainers);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void PublishPlacementChange(
|
||||
ClientObject item,
|
||||
ClientObjectPlacement previous)
|
||||
ClientObjectPlacement previous,
|
||||
ClientObjectMoveOrigin origin)
|
||||
{
|
||||
ClientObjectPlacement current = ClientObjectPlacement.From(item);
|
||||
UpdateEquipmentIndex(item.ObjectId, previous, current);
|
||||
ObjectMoved?.Invoke(new ClientObjectMove(item.ObjectId, item, previous, current));
|
||||
ObjectMoved?.Invoke(new ClientObjectMove(item.ObjectId, item, previous, current, origin));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -337,7 +359,8 @@ public sealed class ClientObjectTable
|
|||
newContainerId,
|
||||
newSlot,
|
||||
newWielderId,
|
||||
newEquipLocation)));
|
||||
newEquipLocation),
|
||||
ClientObjectMoveOrigin.AuthoritativeResponse));
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -364,14 +387,17 @@ public sealed class ClientObjectTable
|
|||
itemId,
|
||||
Item: null,
|
||||
Previous: default,
|
||||
Current: placement));
|
||||
Current: placement,
|
||||
ClientObjectMoveOrigin.AuthoritativeResponse));
|
||||
WieldConfirmed?.Invoke(itemId);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!ApplyPlacement(
|
||||
item,
|
||||
placement))
|
||||
placement,
|
||||
containerTypeHint: null,
|
||||
ClientObjectMoveOrigin.AuthoritativeResponse))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
|
@ -434,7 +460,7 @@ public sealed class ClientObjectTable
|
|||
}
|
||||
else item.ContainerSlot = newSlot;
|
||||
|
||||
PublishPlacementChange(item, previous);
|
||||
PublishPlacementChange(item, previous, ClientObjectMoveOrigin.LocalProjection);
|
||||
PublishContainerContentsChanges(changedContainers);
|
||||
return true;
|
||||
}
|
||||
|
|
@ -485,12 +511,12 @@ public sealed class ClientObjectTable
|
|||
if (!_pendingMoves.TryGetValue(itemId, out var pre)) return false;
|
||||
_pendingMoves.Remove(itemId);
|
||||
ClientObjectPlacement placement = pre.placement;
|
||||
if (!ApplyServerMove(
|
||||
itemId,
|
||||
placement.ContainerId,
|
||||
placement.WielderId,
|
||||
placement.ContainerSlot,
|
||||
placement.EquipLocation))
|
||||
if (!_objects.TryGetValue(itemId, out ClientObject? item)
|
||||
|| !ApplyPlacement(
|
||||
item,
|
||||
placement,
|
||||
containerTypeHint: null,
|
||||
ClientObjectMoveOrigin.ServerRollbackProjection))
|
||||
return false;
|
||||
MoveRolledBack?.Invoke(_objects[itemId]);
|
||||
return true;
|
||||
|
|
@ -654,6 +680,7 @@ public sealed class ClientObjectTable
|
|||
item.StackSize = stackSize;
|
||||
item.Value = value;
|
||||
ObjectUpdated?.Invoke(item);
|
||||
StackSizeUpdated?.Invoke(item);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -167,6 +167,14 @@ public sealed class MoveToManager
|
|||
/// </summary>
|
||||
public Action<WeenieError>? MoveToComplete { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// CLIENT ADDITION — cancellation companion to <see cref="MoveToComplete"/>.
|
||||
/// Fires only when <see cref="CancelMoveTo"/> actually tears down an
|
||||
/// active move. It never changes retail movement behavior; App owners use
|
||||
/// it to withdraw presentation state which was waiting for natural arrival.
|
||||
/// </summary>
|
||||
public Action<WeenieError>? MoveToCancelled { get; set; }
|
||||
|
||||
/// <summary>Retail <c>Timer::cur_time</c> — injectable clock (tests drive
|
||||
/// this explicitly; production binds to the real wall/game clock).
|
||||
/// Defaults to a monotonically-increasing stub if not overridden via the
|
||||
|
|
@ -1455,18 +1463,18 @@ public sealed class MoveToManager
|
|||
/// <see cref="_pendingActions"/>, <see cref="CleanUp"/>, then
|
||||
/// StopCompletely. The <paramref name="error"/> argument is NEVER READ
|
||||
/// in retail's body (every call site's error code is dropped in this
|
||||
/// build, decomp §7c) — kept for parity/logging/tests only; NO behavior
|
||||
/// depends on its value.
|
||||
/// build, decomp §7c). The retail body remains independent of it; only the
|
||||
/// documented client-addition <see cref="MoveToCancelled"/> observes it
|
||||
/// after teardown so App presentation can classify the cancellation.
|
||||
/// </summary>
|
||||
public void CancelMoveTo(WeenieError error)
|
||||
{
|
||||
_ = error; // retail: never read in the body (decomp §7c) — kept for parity/logging.
|
||||
|
||||
if (MovementTypeState == MovementType.Invalid) return;
|
||||
|
||||
_pendingActions.Clear();
|
||||
CleanUp();
|
||||
if (HasPhysicsObj) _stopCompletely();
|
||||
MoveToCancelled?.Invoke(error);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue