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:
Erik 2026-07-21 09:01:02 +02:00
parent d2bb5af453
commit 5acc3f01cf
23 changed files with 2635 additions and 168 deletions

View file

@ -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;
}
}

View file

@ -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;
}

View file

@ -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))