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

@ -13,7 +13,8 @@ public readonly record struct RuntimeActionSnapshot(
int TrackedTargetHealthCount,
long InteractionRevision,
InteractionModeKind InteractionMode,
uint InteractionSourceObjectId);
uint InteractionSourceObjectId,
RuntimeInteractionTransactionSnapshot InteractionTransactions);
public interface IRuntimeActionView
{

View file

@ -169,7 +169,14 @@ public sealed class RuntimeTraceRecorder : IRuntimeEventObserver
$"{checkpoint.Actions.TrackedTargetHealthCount}:" +
$"{checkpoint.Actions.InteractionRevision}:" +
$"{(int)checkpoint.Actions.InteractionMode}:" +
$"{checkpoint.Actions.InteractionSourceObjectId:X8};" +
$"{checkpoint.Actions.InteractionSourceObjectId:X8}:" +
$"{checkpoint.Actions.InteractionTransactions.Revision}:" +
$"{checkpoint.Actions.InteractionTransactions.LastUseSourceId:X8}:" +
$"{checkpoint.Actions.InteractionTransactions.LastUseTargetId:X8}:" +
$"{checkpoint.Actions.InteractionTransactions.AwaitingAppraisalId:X8}:" +
$"{checkpoint.Actions.InteractionTransactions.CurrentAppraisalId:X8}:" +
$"{checkpoint.Actions.InteractionTransactions.OutboundCount}:" +
$"{checkpoint.Actions.InteractionTransactions.PendingPickupToken};" +
$"portal={checkpoint.Portal.Generation}:{(int)checkpoint.Portal.Kind}:" +
$"{checkpoint.Portal.DestinationCell:X8}:{checkpoint.Portal.IsReady}")));
}

View file

@ -1,4 +1,5 @@
using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Core.Selection;
namespace AcDream.Runtime.Gameplay;
@ -6,12 +7,14 @@ namespace AcDream.Runtime.Gameplay;
public readonly record struct RuntimeActionOwnershipSnapshot(
bool IsDisposed,
bool InternalSubscriptionsAttached,
bool InteractionTransactionsDisposed,
uint SelectedObjectId,
uint PreviousObjectId,
uint PreviousValidObjectId,
CombatMode CombatMode,
int TrackedTargetHealthCount,
InteractionMode InteractionMode,
RuntimeInteractionTransactionSnapshot InteractionTransactions,
long SelectionRevision,
long CombatRevision,
long InteractionRevision)
@ -19,12 +22,14 @@ public readonly record struct RuntimeActionOwnershipSnapshot(
public bool IsConverged =>
IsDisposed
&& !InternalSubscriptionsAttached
&& InteractionTransactionsDisposed
&& SelectedObjectId == 0u
&& PreviousObjectId == 0u
&& PreviousValidObjectId == 0u
&& CombatMode == CombatMode.NonCombat
&& TrackedTargetHealthCount == 0
&& InteractionMode == InteractionMode.None;
&& InteractionMode == InteractionMode.None
&& InteractionTransactions.IsConverged;
}
/// <summary>
@ -40,11 +45,14 @@ public sealed class RuntimeActionState : IDisposable
private long _combatRevision;
private long _interactionRevision;
public RuntimeActionState()
public RuntimeActionState(InventoryTransactionState inventoryTransactions)
{
ArgumentNullException.ThrowIfNull(inventoryTransactions);
Selection = new SelectionState();
Combat = new CombatState();
Interaction = new InteractionState();
Transactions = new RuntimeInteractionTransactionState(
inventoryTransactions);
View = new ActionView(this);
Selection.Changed += OnSelectionChanged;
@ -57,18 +65,21 @@ public sealed class RuntimeActionState : IDisposable
public SelectionState Selection { get; }
public CombatState Combat { get; }
public InteractionState Interaction { get; }
public RuntimeInteractionTransactionState Transactions { get; }
public IRuntimeActionView View { get; }
public bool IsDisposed => _disposed;
public RuntimeActionOwnershipSnapshot CaptureOwnership() => new(
_disposed,
_internalSubscriptionsAttached,
Transactions.IsDisposed,
Selection.SelectedObjectId ?? 0u,
Selection.PreviousObjectId ?? 0u,
Selection.PreviousValidObjectId ?? 0u,
Combat.CurrentMode,
Combat.TrackedTargetCount,
Interaction.Current,
Transactions.CaptureOwnership(),
Interlocked.Read(ref _selectionRevision),
Interlocked.Read(ref _combatRevision),
Interlocked.Read(ref _interactionRevision));
@ -82,6 +93,7 @@ public sealed class RuntimeActionState : IDisposable
{
ObjectDisposedException.ThrowIf(_disposed, this);
List<Exception>? failures = null;
Try(Transactions.ResetSession, ref failures);
Try(Interaction.ResetSession, ref failures);
Try(() => Selection.Reset(), ref failures);
Try(Combat.Clear, ref failures);
@ -101,6 +113,7 @@ public sealed class RuntimeActionState : IDisposable
List<Exception>? failures = null;
try
{
Try(Transactions.Dispose, ref failures);
Try(Interaction.ResetSession, ref failures);
Try(() => Selection.Reset(), ref failures);
Try(Combat.Clear, ref failures);
@ -160,7 +173,8 @@ public sealed class RuntimeActionState : IDisposable
owner.Combat.TrackedTargetCount,
Interlocked.Read(ref owner._interactionRevision),
owner.Interaction.Current.Kind,
owner.Interaction.Current.SourceObjectId);
owner.Interaction.Current.SourceObjectId,
owner.Transactions.CaptureOwnership());
public bool TryGetHealth(uint objectId, out float healthPercent)
{

View file

@ -0,0 +1,572 @@
using AcDream.Core.Items;
namespace AcDream.Runtime.Gameplay;
public enum RuntimeQueuedInteractionKind
{
Activate,
Use,
Pickup,
}
public readonly record struct RuntimeInteractionIdentity(
uint ServerGuid,
uint? LocalEntityId,
ClientObject? ClientObject);
public readonly record struct RuntimeQueuedInteraction(
RuntimeQueuedInteractionKind Kind,
RuntimeInteractionIdentity Identity);
public readonly record struct RuntimeInteractionApproachToken(
ulong ControllerLifetime,
ulong ApproachGeneration);
public readonly record struct RuntimePendingPickup(
ulong Token,
uint ServerGuid,
uint LocalEntityId,
uint DestinationContainerId,
int Placement,
ulong PendingPlacementToken,
RuntimeInteractionApproachToken ApproachToken);
public readonly record struct RuntimeAppraisalResponseAcceptance(
bool Accepted,
bool FirstResponse);
public enum RuntimeInteractionDispatchResult
{
Rejected,
NotInWorld,
NotUseable,
Dispatched,
}
public readonly record struct RuntimeInteractionTransactionSnapshot(
bool IsDisposed,
long Revision,
uint LastUseSourceId,
uint LastUseTargetId,
uint AwaitingAppraisalId,
uint CurrentAppraisalId,
int OutboundCount,
bool HasPendingPickup,
ulong PendingPickupToken,
long DispatchFailureCount)
{
public bool IsConverged =>
IsDisposed
&& LastUseSourceId == 0u
&& LastUseTargetId == 0u
&& AwaitingAppraisalId == 0u
&& CurrentAppraisalId == 0u
&& OutboundCount == 0
&& !HasPendingPickup;
}
/// <summary>
/// Canonical presentation-independent owner for retail interaction
/// transactions. The shared inventory gate remains owned by
/// <see cref="InventoryTransactionState"/> and is borrowed exactly.
/// </summary>
/// <remarks>
/// Ordinary Use follows <c>ItemHolder::UseObject @ 0x00588A80</c>: consume the
/// strict 0.2-second gate, send immediately, then transfer the busy reference
/// to <c>ClientUISystem::Handle_Item__UseDone @ 0x00564900</c>. Pickup keeps
/// the existing local approach transaction and exact post-arrival token.
/// </remarks>
public sealed class RuntimeInteractionTransactionState : IDisposable
{
public const long RetailUseThrottleMs = 200;
private readonly InventoryTransactionState _inventory;
private readonly Queue<RuntimeQueuedInteraction> _outbound = new();
private long _lastUseMs = long.MinValue / 2;
private uint _lastUseSourceId;
private uint _lastUseTargetId;
private uint _awaitingAppraisalId;
private uint _currentAppraisalId;
private RuntimePendingPickup? _pendingPickup;
private ulong _nextPickupToken;
private uint _clearEpoch;
private long _revision;
private long _dispatchFailureCount;
private bool _disposed;
public RuntimeInteractionTransactionState(
InventoryTransactionState inventory)
{
_inventory = inventory
?? throw new ArgumentNullException(nameof(inventory));
}
public InventoryTransactionState Inventory => _inventory;
public uint AwaitingAppraisalId => _awaitingAppraisalId;
public uint CurrentAppraisalId => _currentAppraisalId;
public int OutboundCount => _outbound.Count;
public bool HasPendingPickup => _pendingPickup is not null;
public bool IsDisposed => _disposed;
public long Revision => Interlocked.Read(ref _revision);
public long DispatchFailureCount =>
Interlocked.Read(ref _dispatchFailureCount);
public Exception? LastDispatchFailure { get; private set; }
public RuntimeInteractionTransactionSnapshot CaptureOwnership() => new(
_disposed,
Revision,
_lastUseSourceId,
_lastUseTargetId,
_awaitingAppraisalId,
_currentAppraisalId,
_outbound.Count,
_pendingPickup is not null,
_pendingPickup?.Token ?? 0u,
DispatchFailureCount);
public bool TryConsumeUseThrottle(long nowMs)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (nowMs - _lastUseMs < RetailUseThrottleMs)
return false;
_lastUseMs = nowMs;
IncrementRevision();
return true;
}
public ItemUseRequestReservation BeginUseRequestReservation()
{
ObjectDisposedException.ThrowIf(_disposed, this);
return _inventory.BeginUseRequestReservation();
}
public RuntimeInteractionDispatchResult TryDispatchUse(
uint serverGuid,
bool ownedByPlayer,
bool useable,
ItemUseRequestReservation? reservation,
IRuntimeInteractionTransport transport,
out uint sequence)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(transport);
sequence = 0u;
if (serverGuid == 0u)
{
reservation?.CancelBeforeDispatch();
return RuntimeInteractionDispatchResult.Rejected;
}
if (!transport.IsInWorld)
{
reservation?.CancelBeforeDispatch();
return RuntimeInteractionDispatchResult.NotInWorld;
}
if (!ownedByPlayer && !useable)
{
reservation?.CancelBeforeDispatch();
return RuntimeInteractionDispatchResult.NotUseable;
}
if (!transport.TrySendUse(serverGuid, out sequence))
{
reservation?.CancelBeforeDispatch();
return RuntimeInteractionDispatchResult.Rejected;
}
reservation?.MarkDispatched();
_lastUseSourceId = serverGuid;
_lastUseTargetId = 0u;
IncrementRevision();
return RuntimeInteractionDispatchResult.Dispatched;
}
public bool TryDispatchTargetedUse(
uint sourceObjectId,
uint targetObjectId,
Action<uint, uint>? dispatch,
bool incrementBusy)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (sourceObjectId == 0u
|| targetObjectId == 0u
|| dispatch is null)
return false;
uint epoch = _clearEpoch;
dispatch(sourceObjectId, targetObjectId);
if (_disposed || epoch != _clearEpoch)
return false;
_lastUseSourceId = sourceObjectId;
_lastUseTargetId = targetObjectId;
if (incrementBusy)
_inventory.IncrementBusyCount();
IncrementRevision();
return true;
}
public void IncrementBusyCount()
{
ObjectDisposedException.ThrowIf(_disposed, this);
_inventory.IncrementBusyCount();
IncrementRevision();
}
public void CompleteUse(uint error)
{
ObjectDisposedException.ThrowIf(_disposed, this);
int before = _inventory.BusyCount;
_inventory.CompleteUse(error);
if (_inventory.BusyCount != before)
IncrementRevision();
}
public bool TryRequestAppraisal(
uint objectId,
Action<uint> sendAppraisal)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(sendAppraisal);
if (objectId == 0u)
return false;
uint epoch = _clearEpoch;
bool acquiredBusy = _awaitingAppraisalId == 0u;
if (acquiredBusy)
{
_inventory.IncrementBusyCount();
if (_disposed || epoch != _clearEpoch)
return false;
}
uint previousAwaiting = _awaitingAppraisalId;
_awaitingAppraisalId = objectId;
IncrementRevision();
try
{
sendAppraisal(objectId);
}
catch
{
// A synchronous reset/disposal or newer request owns the result.
// Roll back only the exact request installed by this call.
if (!_disposed
&& epoch == _clearEpoch
&& _awaitingAppraisalId == objectId)
{
_awaitingAppraisalId = previousAwaiting;
if (acquiredBusy)
_inventory.CompleteUse(0u);
IncrementRevision();
}
throw;
}
return true;
}
public RuntimeAppraisalResponseAcceptance AcceptAppraisalResponse(
uint objectId)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (objectId == 0u
|| (objectId != _awaitingAppraisalId
&& objectId != _currentAppraisalId))
{
return default;
}
bool firstResponse = objectId == _awaitingAppraisalId;
if (firstResponse)
{
_awaitingAppraisalId = 0u;
_currentAppraisalId = objectId;
_inventory.CompleteUse(0u);
IncrementRevision();
}
return new RuntimeAppraisalResponseAcceptance(
Accepted: true,
FirstResponse: firstResponse);
}
public bool RefreshCurrentAppraisal(Action<uint> sendAppraisal)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(sendAppraisal);
if (_currentAppraisalId == 0u)
return false;
sendAppraisal(_currentAppraisalId);
return true;
}
public bool CancelObjectAppraisalForSpell(Action<uint> sendAppraisal)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(sendAppraisal);
if (_awaitingAppraisalId == 0u && _currentAppraisalId == 0u)
return false;
if (_awaitingAppraisalId != 0u)
_inventory.CompleteUse(0u);
_awaitingAppraisalId = 0u;
_currentAppraisalId = 0u;
IncrementRevision();
sendAppraisal(0u);
return true;
}
public void Enqueue(RuntimeQueuedInteraction interaction)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (interaction.Identity.ServerGuid == 0u)
return;
_outbound.Enqueue(interaction);
IncrementRevision();
}
public int CancelQueuedInteractions(
uint serverGuid,
uint? localEntityId = null)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (serverGuid == 0u || _outbound.Count == 0)
return 0;
int original = _outbound.Count;
int removed = 0;
for (int i = 0; i < original; i++)
{
RuntimeQueuedInteraction interaction = _outbound.Dequeue();
RuntimeInteractionIdentity identity = interaction.Identity;
bool matches =
identity.ServerGuid == serverGuid
&& (localEntityId is not uint exact
|| identity.LocalEntityId == exact);
if (matches)
removed++;
else
_outbound.Enqueue(interaction);
}
if (removed != 0)
IncrementRevision();
return removed;
}
/// <summary>
/// Drains only work present at the frame boundary. Re-entrant additions
/// remain for the next frame, matching the pre-J5 ordered interaction
/// queue without retaining App delegates.
/// </summary>
public void DrainOutbound(Action<RuntimeQueuedInteraction> dispatch)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(dispatch);
int count = _outbound.Count;
uint epoch = _clearEpoch;
bool drained = false;
while (count-- > 0 && epoch == _clearEpoch && _outbound.Count > 0)
{
RuntimeQueuedInteraction interaction = _outbound.Dequeue();
drained = true;
try
{
dispatch(interaction);
}
catch (Exception error)
{
Interlocked.Increment(ref _dispatchFailureCount);
LastDispatchFailure = error;
throw;
}
}
if (drained)
IncrementRevision();
}
public bool TryArmPostArrivalPickup(
uint serverGuid,
uint localEntityId,
uint destinationContainerId,
int placement,
ulong pendingPlacementToken,
RuntimeInteractionApproachToken approachToken,
out RuntimePendingPickup pending)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (serverGuid == 0u
|| localEntityId == 0u
|| destinationContainerId == 0u
|| pendingPlacementToken == 0u
|| approachToken.ControllerLifetime == 0u
|| approachToken.ApproachGeneration == 0u)
{
pending = default;
return false;
}
if (_pendingPickup is not null)
{
pending = default;
return false;
}
ulong token = ++_nextPickupToken;
if (token == 0u)
token = ++_nextPickupToken;
pending = new RuntimePendingPickup(
token,
serverGuid,
localEntityId,
destinationContainerId,
placement,
pendingPlacementToken,
approachToken);
_pendingPickup = pending;
IncrementRevision();
return true;
}
public bool TryResolveApproachCompletion(
RuntimeInteractionApproachToken approachToken,
bool natural,
out RuntimePendingPickup pending)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_pendingPickup is not { } current
|| current.ApproachToken != approachToken)
{
pending = default;
return false;
}
_pendingPickup = null;
pending = current;
IncrementRevision();
return natural;
}
public bool TryGetPendingPickup(out RuntimePendingPickup pending)
{
if (_pendingPickup is { } current)
{
pending = current;
return true;
}
pending = default;
return false;
}
public bool TryCancelPendingPickup(out RuntimePendingPickup pending)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_pendingPickup is not { } current)
{
pending = default;
return false;
}
_pendingPickup = null;
pending = current;
IncrementRevision();
return true;
}
public bool TryCancelPendingPickup(
uint serverGuid,
uint? localEntityId,
out RuntimePendingPickup pending)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_pendingPickup is not { } current
|| current.ServerGuid != serverGuid
|| (localEntityId is uint exact
&& current.LocalEntityId != exact))
{
pending = default;
return false;
}
_pendingPickup = null;
pending = current;
IncrementRevision();
return true;
}
public bool TryDispatchPickup(
RuntimePendingPickup pickup,
IRuntimeInteractionTransport transport,
out uint sequence)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(transport);
sequence = 0u;
if (!transport.IsInWorld)
return false;
uint sentSequence = 0u;
bool dispatched = _inventory.TryDispatch(
InventoryRequestKind.Pickup,
pickup.ServerGuid,
() => transport.TrySendPickup(
pickup.ServerGuid,
pickup.DestinationContainerId,
pickup.Placement,
out sentSequence),
pickup.PendingPlacementToken);
sequence = sentSequence;
if (dispatched)
IncrementRevision();
return dispatched;
}
public void ResetSession()
{
ObjectDisposedException.ThrowIf(_disposed, this);
ResetCore(resetInventory: true);
}
public void Dispose()
{
if (_disposed)
return;
ResetCore(resetInventory: true);
_disposed = true;
}
private void ResetCore(bool resetInventory)
{
bool changed =
_awaitingAppraisalId != 0u
|| _currentAppraisalId != 0u
|| _outbound.Count != 0
|| _pendingPickup is not null
|| _lastUseSourceId != 0u
|| _lastUseTargetId != 0u
|| _lastUseMs != long.MinValue / 2;
_lastUseSourceId = 0u;
_lastUseTargetId = 0u;
_awaitingAppraisalId = 0u;
_currentAppraisalId = 0u;
_outbound.Clear();
_pendingPickup = null;
_lastUseMs = long.MinValue / 2;
_clearEpoch++;
if (resetInventory)
_inventory.ResetSession();
if (changed)
IncrementRevision();
}
private void IncrementRevision() =>
Interlocked.Increment(ref _revision);
}
public interface IRuntimeInteractionTransport
{
bool IsInWorld { get; }
bool TrySendUse(uint serverGuid, out uint sequence);
bool TrySendPickup(
uint itemGuid,
uint destinationContainerId,
int placement,
out uint sequence);
}