refactor(runtime): own interaction transactions

Move use throttling, appraisal identity, queued interactions, and exact post-arrival pickup state beneath RuntimeActionState. Keep App as the picker, movement, transport, and retained-presentation adapter while preserving retail send and UseDone ordering. Add reset, disposal, GUID-reuse, callback-reentrancy, and transport-failure coverage.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Erik 2026-07-26 11:13:09 +02:00
parent be73bccf5a
commit f5f7b4177f
31 changed files with 1365 additions and 432 deletions

View file

@ -1,10 +1,10 @@
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;
using AcDream.Runtime.Gameplay;
using AcDream.UI.Abstractions.Input;
namespace AcDream.App.Interaction;
@ -19,31 +19,17 @@ internal sealed class SelectionInteractionController
private readonly SelectionState _selection;
private readonly IWorldSelectionQuery _query;
private readonly ItemInteractionController _items;
private readonly ISelectionInteractionTransport _transport;
private readonly RuntimeInteractionTransactionState _transactions;
private readonly IRuntimeInteractionTransport _transport;
private readonly IPlayerInteractionMovementSink _movement;
private readonly OutboundInteractionQueue _outbound = new();
private readonly PlayerApproachCompletionState _approachCompletions;
private readonly Action<string>? _toast;
private PendingPostArrivalPickup? _pendingPostArrival;
private readonly record struct QueuedInteractionIdentity(
uint ServerGuid,
uint? LocalEntityId,
ClientObject? ClientObject);
private readonly record struct PendingPostArrivalPickup(
uint ServerGuid,
uint LocalEntityId,
uint DestinationContainerId,
int Placement,
ulong PendingPlacementToken,
PlayerApproachToken ApproachToken);
public SelectionInteractionController(
SelectionState selection,
IWorldSelectionQuery query,
ItemInteractionController items,
ISelectionInteractionTransport transport,
IRuntimeInteractionTransport transport,
IPlayerInteractionMovementSink movement,
Action<string>? toast = null,
PlayerApproachCompletionState? approachCompletions = null)
@ -51,6 +37,7 @@ internal sealed class SelectionInteractionController
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
_query = query ?? throw new ArgumentNullException(nameof(query));
_items = items ?? throw new ArgumentNullException(nameof(items));
_transactions = _items.RuntimeTransactions;
_transport = transport ?? throw new ArgumentNullException(nameof(transport));
_movement = movement ?? throw new ArgumentNullException(nameof(movement));
_toast = toast;
@ -88,13 +75,9 @@ internal sealed class SelectionInteractionController
if (_selection.SelectedObjectId is uint pickupTarget)
{
EnqueueIdentityBound(
RuntimeQueuedInteractionKind.Pickup,
pickupTarget,
requireLiveEntity: true,
guid =>
{
if (ValidatePickupTarget(guid, showToast: true))
_items.PlaceWorldItemInBackpack(guid);
});
requireLiveEntity: true);
}
else
{
@ -178,12 +161,9 @@ internal sealed class SelectionInteractionController
_toast?.Invoke($"Selected: {label}");
if (useImmediately)
EnqueueIdentityBound(
RuntimeQueuedInteractionKind.Activate,
guid,
requireLiveEntity: true,
selected =>
{
_items.ActivateItem(selected);
});
requireLiveEntity: true);
}
/// <summary>
@ -213,12 +193,9 @@ internal sealed class SelectionInteractionController
return;
}
EnqueueIdentityBound(
RuntimeQueuedInteractionKind.Use,
selected,
requireLiveEntity: false,
guid =>
{
_items.UseSelectedOrEnterMode(guid);
});
requireLiveEntity: false);
}
public void SendUse(uint serverGuid)
@ -229,45 +206,19 @@ internal sealed class SelectionInteractionController
ItemUseRequestReservation? reservation)
{
CancelPendingApproach();
if (!_transport.IsInWorld)
{
bool ownedByPlayer = _items.IsOwnedByPlayer(serverGuid);
RuntimeInteractionDispatchResult result =
_transactions.TryDispatchUse(
serverGuid,
ownedByPlayer,
ownedByPlayer || _query.IsUseable(serverGuid),
reservation,
_transport,
out uint sequence);
if (result == RuntimeInteractionDispatchResult.NotInWorld)
_toast?.Invoke("Not in world");
reservation?.CancelBeforeDispatch();
return;
}
// ItemHolder::UseObject @ 0x00588A80 sends Event_UseEvent before
// CPlayerSystem::UsingItem for every accepted ordinary Use. The latter
// is presentation/local inventory policy; it never delays the wire
// request behind a client-side TurnTo/MoveTo completion. ACE owns the
// authoritative approach chain for spatial targets.
if (_items.IsOwnedByPlayer(serverGuid))
{
SendUseNow(serverGuid, reservation);
return;
}
if (!_query.IsUseable(serverGuid))
{
reservation?.CancelBeforeDispatch();
return;
}
SendUseNow(serverGuid, reservation);
}
private void SendUseNow(
uint serverGuid,
ItemUseRequestReservation? reservation)
{
if (_transport.TrySendUse(serverGuid, out uint sequence))
{
reservation?.MarkDispatched();
if (result == RuntimeInteractionDispatchResult.Dispatched)
Console.WriteLine($"[B.4b] use guid=0x{serverGuid:X8} seq={sequence}");
return;
}
reservation?.CancelBeforeDispatch();
}
public void SendPickup(uint itemGuid, uint destinationContainerId, int placement)
@ -302,20 +253,38 @@ internal sealed class SelectionInteractionController
if (approach.IsCloseRange)
{
var pending = new PendingPostArrivalPickup(
itemGuid,
approach.Target.LocalEntityId,
destinationContainerId,
placement,
pendingPlacementToken,
ApproachToken: default);
bool armed = false;
bool started = _movement.BeginApproach(
approach,
token => _pendingPostArrival = pending with { ApproachToken = token });
if (!started && _pendingPostArrival?.ServerGuid == pending.ServerGuid)
CancelPendingApproach();
else if (!started)
CancelPickupPresentation(itemGuid, pendingPlacementToken);
token =>
{
armed = _transactions.TryArmPostArrivalPickup(
itemGuid,
approach.Target.LocalEntityId,
destinationContainerId,
placement,
pendingPlacementToken,
new RuntimeInteractionApproachToken(
token.ControllerLifetime,
token.ApproachGeneration),
out _);
});
if (!started || !armed)
{
if (_transactions.TryCancelPendingPickup(
itemGuid,
approach.Target.LocalEntityId,
out RuntimePendingPickup cancelled))
{
CancelPickupPresentation(
cancelled.ServerGuid,
cancelled.PendingPlacementToken);
}
else
{
CancelPickupPresentation(itemGuid, pendingPlacementToken);
}
}
return;
}
@ -328,16 +297,18 @@ internal sealed class SelectionInteractionController
{
return;
}
uint sequence = 0u;
if (_items.TryDispatchInventoryRequest(
InventoryRequestKind.Pickup,
itemGuid,
() => _transport.TrySendPickup(
itemGuid,
destinationContainerId,
placement,
out sequence),
pendingPlacementToken))
var immediate = new RuntimePendingPickup(
Token: 0u,
itemGuid,
approach.Target.LocalEntityId,
destinationContainerId,
placement,
pendingPlacementToken,
ApproachToken: default);
if (_transactions.TryDispatchPickup(
immediate,
_transport,
out uint sequence))
{
Console.WriteLine(
$"[B.5] pickup item=0x{itemGuid:X8} container=0x{destinationContainerId:X8} placement={placement} seq={sequence}");
@ -350,13 +321,28 @@ internal sealed class SelectionInteractionController
/// <summary>Fires only after natural MoveToComplete(None), never cancellation.</summary>
public void OnNaturalMoveToComplete()
=> HandleNaturalMoveToComplete();
private void HandleNaturalMoveToComplete()
{
if (_pendingPostArrival is not { } pending)
if (_transactions.TryGetPendingPickup(out RuntimePendingPickup pending))
HandleApproachCompletion(pending.ApproachToken, natural: true);
}
private void HandleApproachCompletion(
RuntimeInteractionApproachToken approachToken,
bool natural)
{
bool accepted = _transactions.TryResolveApproachCompletion(
approachToken,
natural,
out RuntimePendingPickup pending);
if (pending.Token == 0u)
return;
_pendingPostArrival = null;
if (!accepted)
{
CancelPickupPresentation(
pending.ServerGuid,
pending.PendingPlacementToken);
return;
}
if (!_query.IsCurrent(pending.ServerGuid, pending.LocalEntityId))
{
CancelPickupPresentation(
@ -373,15 +359,10 @@ internal sealed class SelectionInteractionController
{
return;
}
if (!_items.TryDispatchInventoryRequest(
InventoryRequestKind.Pickup,
pending.ServerGuid,
() => _transport.TrySendPickup(
pending.ServerGuid,
pending.DestinationContainerId,
pending.Placement,
out _),
pending.PendingPlacementToken))
if (!_transactions.TryDispatchPickup(
pending,
_transport,
out _))
{
CancelPickupPresentation(
pending.ServerGuid,
@ -393,22 +374,29 @@ internal sealed class SelectionInteractionController
{
while (_approachCompletions.TryTake(out PlayerApproachCompletion completion))
{
if (_pendingPostArrival?.ApproachToken != completion.Token)
continue;
if (completion.IsNatural)
HandleNaturalMoveToComplete();
else
CancelPendingApproach();
HandleApproachCompletion(
new RuntimeInteractionApproachToken(
completion.Token.ControllerLifetime,
completion.Token.ApproachGeneration),
completion.IsNatural);
}
_outbound.Drain();
_transactions.DrainOutbound(DispatchQueuedInteraction);
}
public void OnMoveToCancelled(WeenieError _) => CancelPendingApproach();
public void OnEntityHidden(uint serverGuid)
{
if (_pendingPostArrival?.ServerGuid == serverGuid)
CancelPendingApproach();
_transactions.CancelQueuedInteractions(serverGuid);
if (_transactions.TryCancelPendingPickup(
serverGuid,
localEntityId: null,
out RuntimePendingPickup cancelled))
{
CancelPickupPresentation(
cancelled.ServerGuid,
cancelled.PendingPlacementToken);
}
if (_selection.SelectedObjectId == serverGuid)
{
_selection.Clear(
@ -420,11 +408,17 @@ internal sealed class SelectionInteractionController
public void OnEntityRemoved(LiveEntityRecord record, bool replacementExists)
{
ArgumentNullException.ThrowIfNull(record);
if (_pendingPostArrival is { } pending
&& pending.ServerGuid == record.ServerGuid
&& pending.LocalEntityId == record.LocalEntityId)
_transactions.CancelQueuedInteractions(
record.ServerGuid,
record.LocalEntityId);
if (_transactions.TryCancelPendingPickup(
record.ServerGuid,
record.LocalEntityId,
out RuntimePendingPickup cancelled))
{
CancelPendingApproach();
CancelPickupPresentation(
cancelled.ServerGuid,
cancelled.PendingPlacementToken);
}
if (!replacementExists && _selection.SelectedObjectId == record.ServerGuid)
{
@ -443,11 +437,8 @@ internal sealed class SelectionInteractionController
catch (Exception error) { failures.Add(error); }
try { _selection.Reset(); }
catch (Exception error) { failures.Add(error); }
try { _outbound.Clear(); }
catch (Exception error) { failures.Add(error); }
try { _approachCompletions.Clear(); }
catch (Exception error) { failures.Add(error); }
_pendingPostArrival = null;
if (failures.Count != 0)
throw new AggregateException(
@ -471,11 +462,10 @@ internal sealed class SelectionInteractionController
}
private bool EnqueueIdentityBound(
RuntimeQueuedInteractionKind kind,
uint serverGuid,
bool requireLiveEntity,
Action<uint> action)
bool requireLiveEntity)
{
ArgumentNullException.ThrowIfNull(action);
uint? localEntityId = _query.TryCaptureIdentity(serverGuid, out uint localId)
? localId
: null;
@ -488,16 +478,15 @@ internal sealed class SelectionInteractionController
return false;
}
var identity = new QueuedInteractionIdentity(serverGuid, localEntityId, item);
_outbound.Enqueue(() =>
{
if (IsCurrent(identity))
action(identity.ServerGuid);
});
var identity = new RuntimeInteractionIdentity(
serverGuid,
localEntityId,
item);
_transactions.Enqueue(new RuntimeQueuedInteraction(kind, identity));
return true;
}
private bool IsCurrent(QueuedInteractionIdentity identity)
private bool IsCurrent(RuntimeInteractionIdentity identity)
=> (identity.LocalEntityId is not uint localId
|| _query.IsCurrent(identity.ServerGuid, localId))
&& (identity.ClientObject is not { } item
@ -505,14 +494,39 @@ internal sealed class SelectionInteractionController
private void CancelPendingApproach()
{
if (_pendingPostArrival is not { } pending)
if (!_transactions.TryCancelPendingPickup(
out RuntimePendingPickup pending))
return;
_pendingPostArrival = null;
CancelPickupPresentation(
pending.ServerGuid,
pending.PendingPlacementToken);
}
private void DispatchQueuedInteraction(
RuntimeQueuedInteraction interaction)
{
RuntimeInteractionIdentity identity = interaction.Identity;
if (!IsCurrent(identity))
return;
switch (interaction.Kind)
{
case RuntimeQueuedInteractionKind.Activate:
_items.ActivateItem(identity.ServerGuid);
break;
case RuntimeQueuedInteractionKind.Use:
_items.UseSelectedOrEnterMode(identity.ServerGuid);
break;
case RuntimeQueuedInteractionKind.Pickup:
if (ValidatePickupTarget(identity.ServerGuid, showToast: true))
_items.PlaceWorldItemInBackpack(identity.ServerGuid);
break;
default:
throw new InvalidOperationException(
$"Unknown queued interaction kind {interaction.Kind}.");
}
}
private void CancelPickupPresentation(uint itemGuid, ulong token = 0u)
=> _items.CancelPendingBackpackPlacement(itemGuid, token);