Part 1 deleted probes whose owning issues were closed. These 14 named no issue at all, so each was traced to its introducing commit (git log -S) instead of guessed at. Attribution split them three ways: DELETED (7, investigations closed): ACDREAM_A8_DUMP_PV and ACDREAM_DUMP_LIVE_SPAWNS (Phase A8), ACDREAM_DUMP_CLOTHING (#37), ACDREAM_DUMP_EDGE_SLIDE (#32), ACDREAM_DUMP_STEPUP (L.2.3d-f), ACDREAM_DUMP_VENDOR (the vendor campaign, 25 call sites across 8 files), ACDREAM_DUMP_VITALS (#5, four independent read sites). VendorDiagnostics.cs went entirely. RECLASSIFIED (8, tools misfiled as probes): the DUMP_CELLS/DUMP_GFXOBJS fixture-extraction family (replay-harness tooling with a roundtrip test), PROBE_CELL (standing cell-transit tracer, pair of the permanent PROBE_RESOLVE), DUMP_SKY and HIDE_PART (generic isolation tools), and DUMP_STEEP_ROOF — which looked like an L.4 relic but observes LIVE divergence-register row AD-56; deleting it would have removed the only runtime lens on an active divergence. All moved to Permanent diagnostics with their attribution recorded. RESTORED (1): ACDREAM_DUMP_MOVE_TRUTH was deleted and un-deleted the same day. It is not a probe — the canonical nine-stop soak (run-connected-r6-soak.ps1) hard-fails every destination without its 'move-truth OUT' records, with a message that would misdirect the next operator. Under the no-workarounds rule the gate's mechanism is restored, not left broken with an IOU (#437, closed). Process lesson recorded on both issues: a closed owning issue is NOT sufficient to delete a probe — grep tools/ and the contract tests for consumers first. Also lands the owner-requested default-off invariant: every diagnostic in the codebase is inert until its env var is explicitly set. Exactly four flags default ON and none is a diagnostic — RETAIL_CHASE, CAMERA_COLLIDE, CAMERA_ALIGN_SLOPE, RETAIL_CLOSE_DEGRADES are retail behaviors wearing an A/B off-switch. That set is now FROZEN by LaunchOptionsDocumentationTests.OnlyTheFourRetailBehaviorFlagsDefaultOn; docs/launch-options.md's Conventions and CLAUDE.md state the rule, and CLAUDE.md now binds future probes to a documented row in the same commit. The client reads 137 environment variables (161 at audit start); 40 temporary probes remain, every one attributed. Full hermetic suite 15,322 passed / 0 failed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
746 lines
23 KiB
C#
746 lines
23 KiB
C#
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);
|
|
|
|
/// <summary>
|
|
/// G3 (grand-gate finding): an out-of-range Use armed to dispatch on
|
|
/// arrival, mirroring <see cref="RuntimePendingPickup"/>'s shape. Holds the
|
|
/// eligibility snapshot computed at request time (retail's own
|
|
/// <c>ItemHolder::UseObject</c> eligibility test runs once, before the
|
|
/// walk-in) plus the caller's <see cref="ItemUseRequestReservation"/>, which
|
|
/// crosses the approach boundary unresolved until arrival (dispatch) or
|
|
/// cancellation.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <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 (already-in-range) 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.
|
|
/// <para>
|
|
/// G3 (grand-gate finding, 2026-08-08): an OUT-OF-RANGE Use does NOT send
|
|
/// immediately — ACE's <c>Player.HandleActionUseItem</c>
|
|
/// (<c>Player_Use.cs:176-215</c>) only calls <c>ActOnUse</c> once its own
|
|
/// <c>CreateMoveToChain</c> 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). <see cref="TryArmPostArrivalUse"/>/
|
|
/// <see cref="TryResolveUseApproachCompletion"/> mirror
|
|
/// <see cref="RuntimePendingPickup"/>'s exact arrival-gated shape for this
|
|
/// case; an already-in-range Use is unaffected and still dispatches via
|
|
/// <see cref="TryDispatchUse"/> immediately.
|
|
/// </para>
|
|
/// </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 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<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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// G3: arms an out-of-range Use to dispatch once the approach completes
|
|
/// naturally, mirroring <see cref="TryArmPostArrivalPickup"/>. The
|
|
/// <paramref name="reservation"/> (if any) crosses the approach boundary
|
|
/// unresolved — it is released only by
|
|
/// <see cref="TryResolveUseApproachCompletion"/> (via the caller's
|
|
/// dispatch/cancel), <see cref="TryCancelPendingUse(out RuntimePendingUse)"/>,
|
|
/// or a reset/dispose, never here.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// G3: resolves an armed Use's approach completion, mirroring
|
|
/// <see cref="TryResolveApproachCompletion"/>. The caller is responsible
|
|
/// for dispatching (via <see cref="TryDispatchUse"/>, which itself
|
|
/// resolves <see cref="RuntimePendingUse.Reservation"/>) or cancelling
|
|
/// (<see cref="ItemUseRequestReservation.CancelBeforeDispatch"/>)
|
|
/// depending on the returned bool and whether the target is still
|
|
/// current.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>Unconditional cancel — used when a NEW approach supersedes whatever was armed.</summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>Guid-matching cancel — used when the target itself vanishes (hidden/removed).</summary>
|
|
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);
|
|
}
|