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);
///
/// G3 (grand-gate finding): an out-of-range Use armed to dispatch on
/// arrival, mirroring 's shape. Holds the
/// eligibility snapshot computed at request time (retail's own
/// ItemHolder::UseObject eligibility test runs once, before the
/// walk-in) plus the caller's , which
/// crosses the approach boundary unresolved until arrival (dispatch) or
/// cancellation.
///
public readonly record struct RuntimePendingUse(
ulong Token,
uint ServerGuid,
bool OwnedByPlayer,
bool Useable,
ItemUseRequestReservation? Reservation,
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,
// G3 (grand-gate finding): an armed out-of-range Use holds a live
// ItemUseRequestReservation (a busy-count reference) until arrival or
// cancellation resolves it — it must reach zero at teardown exactly
// like HasPendingPickup.
bool HasPendingUse = false,
ulong PendingUseToken = 0u)
{
public bool IsConverged =>
IsDisposed
&& LastUseSourceId == 0u
&& LastUseTargetId == 0u
&& AwaitingAppraisalId == 0u
&& CurrentAppraisalId == 0u
&& OutboundCount == 0
&& !HasPendingPickup
&& !HasPendingUse;
}
///
/// Canonical presentation-independent owner for retail interaction
/// transactions. The shared inventory gate remains owned by
/// and is borrowed exactly.
///
///
/// Ordinary (already-in-range) Use follows ItemHolder::UseObject @
/// 0x00588A80: consume the strict 0.2-second gate, send immediately,
/// then transfer the busy reference to ClientUISystem::Handle_Item__UseDone
/// @ 0x00564900. Pickup keeps the existing local approach transaction and
/// exact post-arrival token.
///
/// G3 (grand-gate finding, 2026-08-08): an OUT-OF-RANGE Use does NOT send
/// immediately — ACE's Player.HandleActionUseItem
/// (Player_Use.cs:176-215) only calls ActOnUse once its own
/// CreateMoveToChain confirms the player is within the target's use
/// radius; a Use that arrives while still out of range never opens the
/// vendor panel (see register AP-170). /
/// mirror
/// 's exact arrival-gated shape for this
/// case; an already-in-range Use is unaffected and still dispatches via
/// immediately.
///
///
public sealed class RuntimeInteractionTransactionState : IDisposable
{
public const long RetailUseThrottleMs = 200;
private readonly InventoryTransactionState _inventory;
private readonly Queue _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 RuntimePendingUse? _pendingUse;
private ulong _nextUseToken;
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 HasPendingUse => _pendingUse 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,
_pendingUse is not null,
_pendingUse?.Token ?? 0u);
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;
RuntimeInteractionDispatchResult verdict;
if (serverGuid == 0u)
{
reservation?.CancelBeforeDispatch();
verdict = RuntimeInteractionDispatchResult.Rejected;
}
else if (!transport.IsInWorld)
{
reservation?.CancelBeforeDispatch();
verdict = RuntimeInteractionDispatchResult.NotInWorld;
}
else if (!ownedByPlayer && !useable)
{
reservation?.CancelBeforeDispatch();
verdict = RuntimeInteractionDispatchResult.NotUseable;
}
else if (!transport.TrySendUse(serverGuid, out sequence))
{
reservation?.CancelBeforeDispatch();
verdict = RuntimeInteractionDispatchResult.Rejected;
}
else
{
reservation?.MarkDispatched();
_lastUseSourceId = serverGuid;
_lastUseTargetId = 0u;
IncrementRevision();
verdict = RuntimeInteractionDispatchResult.Dispatched;
}
return verdict;
}
public bool TryDispatchTargetedUse(
uint sourceObjectId,
uint targetObjectId,
Action? 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 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 sendAppraisal)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(sendAppraisal);
if (_currentAppraisalId == 0u)
return false;
sendAppraisal(_currentAppraisalId);
return true;
}
public bool CancelObjectAppraisalForSpell(Action 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;
}
///
/// 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.
///
public void DrainOutbound(Action 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;
}
///
/// G3: arms an out-of-range Use to dispatch once the approach completes
/// naturally, mirroring . The
/// (if any) crosses the approach boundary
/// unresolved — it is released only by
/// (via the caller's
/// dispatch/cancel), ,
/// or a reset/dispose, never here.
///
public bool TryArmPostArrivalUse(
uint serverGuid,
bool ownedByPlayer,
bool useable,
ItemUseRequestReservation? reservation,
RuntimeInteractionApproachToken approachToken,
out RuntimePendingUse pending)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (serverGuid == 0u
|| approachToken.ControllerLifetime == 0u
|| approachToken.ApproachGeneration == 0u)
{
pending = default;
return false;
}
if (_pendingUse is not null)
{
pending = default;
return false;
}
ulong token = ++_nextUseToken;
if (token == 0u)
token = ++_nextUseToken;
pending = new RuntimePendingUse(
token,
serverGuid,
ownedByPlayer,
useable,
reservation,
approachToken);
_pendingUse = pending;
IncrementRevision();
return true;
}
///
/// G3: resolves an armed Use's approach completion, mirroring
/// . The caller is responsible
/// for dispatching (via , which itself
/// resolves ) or cancelling
/// ()
/// depending on the returned bool and whether the target is still
/// current.
///
public bool TryResolveUseApproachCompletion(
RuntimeInteractionApproachToken approachToken,
bool natural,
out RuntimePendingUse pending)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_pendingUse is not { } current
|| current.ApproachToken != approachToken)
{
pending = default;
return false;
}
_pendingUse = null;
pending = current;
IncrementRevision();
return natural;
}
public bool TryGetPendingUse(out RuntimePendingUse pending)
{
if (_pendingUse is { } current)
{
pending = current;
return true;
}
pending = default;
return false;
}
/// Unconditional cancel — used when a NEW approach supersedes whatever was armed.
public bool TryCancelPendingUse(out RuntimePendingUse pending)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_pendingUse is not { } current)
{
pending = default;
return false;
}
_pendingUse = null;
pending = current;
IncrementRevision();
return true;
}
/// Guid-matching cancel — used when the target itself vanishes (hidden/removed).
public bool TryCancelPendingUse(
uint serverGuid,
out RuntimePendingUse pending)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_pendingUse is not { } current
|| current.ServerGuid != serverGuid)
{
pending = default;
return false;
}
_pendingUse = null;
pending = current;
IncrementRevision();
return true;
}
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
|| _pendingUse is not null
|| _lastUseSourceId != 0u
|| _lastUseTargetId != 0u
|| _lastUseMs != long.MinValue / 2;
// G3: an armed Use's reservation is a live busy-count reference —
// release it here unconditionally so a reset/dispose that runs
// without a preceding CancelPendingApproach() (e.g. a headless/
// no-window teardown with no SelectionInteractionController) can
// never leak it. Idempotent: a no-op if already resolved.
_pendingUse?.Reservation?.CancelBeforeDispatch();
_lastUseSourceId = 0u;
_lastUseTargetId = 0u;
_awaitingAppraisalId = 0u;
_currentAppraisalId = 0u;
_outbound.Clear();
_pendingPickup = null;
_pendingUse = 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);
}