refactor(runtime): own inventory transaction state
Move the retail one-request-at-a-time gate, shared use busy references, external-container state, item mana, shortcuts, and desired-component snapshots into one Runtime-owned graph over J3's exact ClientObjectTable. Retained UI and session routing now borrow that owner; reset and shutdown preserve the existing order while failure/reentrancy tests protect the transaction boundary. Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
parent
595d5e6b01
commit
011efbeaa7
18 changed files with 1337 additions and 406 deletions
406
src/AcDream.Core/Items/InventoryTransactionState.cs
Normal file
406
src/AcDream.Core/Items/InventoryTransactionState.cs
Normal file
|
|
@ -0,0 +1,406 @@
|
|||
namespace AcDream.Core.Items;
|
||||
|
||||
public enum InventoryRequestKind
|
||||
{
|
||||
Pickup,
|
||||
PutInContainer,
|
||||
SplitToContainer,
|
||||
Merge,
|
||||
DropToWorld,
|
||||
SplitToWorld,
|
||||
Give,
|
||||
}
|
||||
|
||||
public readonly record struct PendingInventoryRequest(
|
||||
ulong Token,
|
||||
InventoryRequestKind Kind,
|
||||
uint ItemId,
|
||||
ClientObject? ItemIdentity,
|
||||
bool Dispatched);
|
||||
|
||||
/// <summary>
|
||||
/// Owns one retail UI busy reference while an accepted Use request crosses the
|
||||
/// local approach boundary. Dispatch transfers that reference to the matching
|
||||
/// authoritative UseDone; cancellation before dispatch releases it locally.
|
||||
/// </summary>
|
||||
public sealed class ItemUseRequestReservation
|
||||
{
|
||||
private readonly Action<bool> _resolve;
|
||||
private int _resolved;
|
||||
|
||||
internal ItemUseRequestReservation(Action<bool> resolve)
|
||||
=> _resolve = resolve ?? throw new ArgumentNullException(nameof(resolve));
|
||||
|
||||
public void MarkDispatched()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _resolved, 1) == 0)
|
||||
_resolve(true);
|
||||
}
|
||||
|
||||
public void CancelBeforeDispatch()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _resolved, 1) == 0)
|
||||
_resolve(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Presentation-independent owner for retail's single
|
||||
/// <c>ACCWeenieObject::prevRequest</c> inventory gate and
|
||||
/// <c>ClientUISystem</c> busy-reference count.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Request recording and response clearing follow
|
||||
/// <c>ACCWeenieObject::RecordRequest @ 0x0058C220</c> and
|
||||
/// <c>ACCWeenieObject::RecordResponse @ 0x0058CAB0</c>. The owner borrows the
|
||||
/// canonical object table and never creates a second inventory collection.
|
||||
/// </remarks>
|
||||
public sealed class InventoryTransactionState : IDisposable
|
||||
{
|
||||
private readonly ClientObjectTable _objects;
|
||||
private ulong _nextRequestToken;
|
||||
private ulong _useReservationGeneration;
|
||||
private PendingInventoryRequest? _pendingRequest;
|
||||
private int _busyCount;
|
||||
private long _dispatchFailureCount;
|
||||
private bool _disposed;
|
||||
|
||||
public InventoryTransactionState(ClientObjectTable objects)
|
||||
{
|
||||
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
||||
_objects.ObjectMoved += OnObjectMoved;
|
||||
_objects.MoveRequestFailed += OnMoveFailed;
|
||||
_objects.ObjectRemoved += OnObjectRemoved;
|
||||
_objects.StackSizeUpdated += OnStackSizeUpdated;
|
||||
_objects.Cleared += OnObjectsCleared;
|
||||
}
|
||||
|
||||
public event Action? StateChanged;
|
||||
public event Action<PendingInventoryRequest>? RequestCompleted;
|
||||
public event Action? ObjectTableCleared;
|
||||
|
||||
public ClientObjectTable Objects => _objects;
|
||||
public int BusyCount => _busyCount;
|
||||
public bool HasPendingRequest => _pendingRequest is not null;
|
||||
public bool CanBeginRequest => _busyCount == 0 && _pendingRequest is null;
|
||||
public bool IsDisposed => _disposed;
|
||||
public long DispatchFailureCount =>
|
||||
Interlocked.Read(ref _dispatchFailureCount);
|
||||
public Exception? LastDispatchFailure { get; private set; }
|
||||
|
||||
public bool TryReserve(
|
||||
InventoryRequestKind kind,
|
||||
uint itemId,
|
||||
out PendingInventoryRequest pending,
|
||||
Action<PendingInventoryRequest>? beforeStateChanged = null)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (itemId == 0u || !CanBeginRequest)
|
||||
{
|
||||
pending = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
ulong token = NextToken();
|
||||
pending = new PendingInventoryRequest(
|
||||
token,
|
||||
kind,
|
||||
itemId,
|
||||
_objects.Get(itemId),
|
||||
Dispatched: false);
|
||||
_pendingRequest = pending;
|
||||
|
||||
if (beforeStateChanged is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
beforeStateChanged(pending);
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (IsCurrent(pending))
|
||||
_pendingRequest = null;
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
if (!IsCurrent(pending))
|
||||
return false;
|
||||
|
||||
DispatchStateChanged();
|
||||
return IsCurrent(pending);
|
||||
}
|
||||
|
||||
public bool TryDispatch(
|
||||
InventoryRequestKind kind,
|
||||
uint itemId,
|
||||
Func<bool> dispatch,
|
||||
ulong reservationToken = 0u)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dispatch);
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (itemId == 0u)
|
||||
return false;
|
||||
|
||||
PendingInventoryRequest reserved;
|
||||
if (reservationToken != 0u)
|
||||
{
|
||||
if (_pendingRequest is not { } current
|
||||
|| current.Token != reservationToken
|
||||
|| current.ItemId != itemId
|
||||
|| current.Kind != kind
|
||||
|| current.Dispatched)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
reserved = current;
|
||||
}
|
||||
else if (!TryReserve(kind, itemId, out reserved))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
bool dispatched;
|
||||
try
|
||||
{
|
||||
dispatched = dispatch();
|
||||
}
|
||||
catch
|
||||
{
|
||||
ClearPending(reserved.Token);
|
||||
throw;
|
||||
}
|
||||
|
||||
if (!dispatched)
|
||||
{
|
||||
ClearPending(reserved.Token);
|
||||
return false;
|
||||
}
|
||||
|
||||
// A synchronous transport or re-entrant table callback may already
|
||||
// have completed this exact request. Never resurrect it or overwrite a
|
||||
// newer transaction acquired from that response.
|
||||
if (_pendingRequest is { } currentRequest
|
||||
&& currentRequest.Token == reserved.Token)
|
||||
{
|
||||
_pendingRequest = reserved with { Dispatched = true };
|
||||
DispatchStateChanged();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryGetPending(out PendingInventoryRequest pending)
|
||||
{
|
||||
if (_pendingRequest is { } current)
|
||||
{
|
||||
pending = current;
|
||||
return true;
|
||||
}
|
||||
pending = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool CancelBeforeDispatch(ulong token)
|
||||
{
|
||||
if (_pendingRequest is not { } pending
|
||||
|| pending.Token != token
|
||||
|| pending.Dispatched)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
_pendingRequest = null;
|
||||
DispatchStateChanged();
|
||||
return true;
|
||||
}
|
||||
|
||||
public void IncrementBusyCount()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
_busyCount++;
|
||||
DispatchStateChanged();
|
||||
}
|
||||
|
||||
public ItemUseRequestReservation BeginUseRequestReservation()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
ulong generation = _useReservationGeneration;
|
||||
_busyCount++;
|
||||
DispatchStateChanged();
|
||||
return new ItemUseRequestReservation(dispatched =>
|
||||
{
|
||||
if (generation != _useReservationGeneration || dispatched)
|
||||
return;
|
||||
if (_busyCount > 0)
|
||||
_busyCount--;
|
||||
DispatchStateChanged();
|
||||
});
|
||||
}
|
||||
|
||||
public void CompleteUse(uint _)
|
||||
{
|
||||
if (_busyCount == 0)
|
||||
return;
|
||||
_busyCount--;
|
||||
DispatchStateChanged();
|
||||
}
|
||||
|
||||
public void ClearBusy()
|
||||
{
|
||||
if (_busyCount == 0)
|
||||
return;
|
||||
_useReservationGeneration++;
|
||||
_busyCount = 0;
|
||||
DispatchStateChanged();
|
||||
}
|
||||
|
||||
public void ResetSession()
|
||||
{
|
||||
bool changed = _pendingRequest is not null || _busyCount != 0;
|
||||
_pendingRequest = null;
|
||||
_useReservationGeneration++;
|
||||
_busyCount = 0;
|
||||
if (changed)
|
||||
DispatchStateChanged();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
_disposed = true;
|
||||
_objects.Cleared -= OnObjectsCleared;
|
||||
_objects.StackSizeUpdated -= OnStackSizeUpdated;
|
||||
_objects.ObjectRemoved -= OnObjectRemoved;
|
||||
_objects.MoveRequestFailed -= OnMoveFailed;
|
||||
_objects.ObjectMoved -= OnObjectMoved;
|
||||
_pendingRequest = null;
|
||||
_useReservationGeneration++;
|
||||
_busyCount = 0;
|
||||
StateChanged = null;
|
||||
RequestCompleted = null;
|
||||
ObjectTableCleared = null;
|
||||
}
|
||||
|
||||
private ulong NextToken()
|
||||
{
|
||||
ulong token = ++_nextRequestToken;
|
||||
if (token == 0u)
|
||||
token = ++_nextRequestToken;
|
||||
return token;
|
||||
}
|
||||
|
||||
private bool IsCurrent(PendingInventoryRequest expected) =>
|
||||
_pendingRequest is { } current
|
||||
&& current.Token == expected.Token
|
||||
&& current.ItemId == expected.ItemId
|
||||
&& current.Kind == expected.Kind
|
||||
&& !current.Dispatched;
|
||||
|
||||
private void ClearPending(ulong token)
|
||||
{
|
||||
if (_pendingRequest is not { } pending || pending.Token != token)
|
||||
return;
|
||||
_pendingRequest = null;
|
||||
DispatchStateChanged();
|
||||
}
|
||||
|
||||
private void OnObjectMoved(ClientObjectMove move)
|
||||
{
|
||||
if (move.Origin == ClientObjectMoveOrigin.AuthoritativeResponse)
|
||||
CompleteInventoryResponse(move.ItemId, move.Item);
|
||||
}
|
||||
|
||||
private void OnMoveFailed(MoveRequestFailure failure) =>
|
||||
CompleteInventoryResponse(
|
||||
failure.ItemId,
|
||||
_objects.Get(failure.ItemId));
|
||||
|
||||
private void OnObjectRemoved(ClientObject item) =>
|
||||
CompleteInventoryResponse(item.ObjectId, item);
|
||||
|
||||
private void OnStackSizeUpdated(ClientObject item) =>
|
||||
CompleteInventoryResponse(item.ObjectId, item);
|
||||
|
||||
private void CompleteInventoryResponse(
|
||||
uint itemId,
|
||||
ClientObject? identity)
|
||||
{
|
||||
if (_pendingRequest is not { } request
|
||||
|| request.ItemId != itemId
|
||||
|| !MatchesIdentity(request.ItemIdentity, itemId, identity))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// RecordResponse clears prevRequest before ItemList receives the
|
||||
// response notice, so a completion observer may synchronously acquire
|
||||
// the next request.
|
||||
_pendingRequest = null;
|
||||
Dispatch(RequestCompleted, request);
|
||||
DispatchStateChanged();
|
||||
}
|
||||
|
||||
private bool MatchesIdentity(
|
||||
ClientObject? expected,
|
||||
uint itemId,
|
||||
ClientObject? actual)
|
||||
{
|
||||
if (expected is null)
|
||||
return true;
|
||||
if (actual is not null)
|
||||
return ReferenceEquals(expected, actual);
|
||||
return _objects.Get(itemId) is not { } current
|
||||
|| ReferenceEquals(expected, current);
|
||||
}
|
||||
|
||||
private void OnObjectsCleared()
|
||||
{
|
||||
bool changed = _pendingRequest is not null;
|
||||
_pendingRequest = null;
|
||||
Dispatch(ObjectTableCleared);
|
||||
if (changed)
|
||||
DispatchStateChanged();
|
||||
}
|
||||
|
||||
private void DispatchStateChanged() => Dispatch(StateChanged);
|
||||
|
||||
private void Dispatch(Action? listeners)
|
||||
{
|
||||
if (listeners is null)
|
||||
return;
|
||||
foreach (Action listener in listeners.GetInvocationList())
|
||||
{
|
||||
try
|
||||
{
|
||||
listener();
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
RecordDispatchFailure(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void Dispatch<T>(Action<T>? listeners, T value)
|
||||
{
|
||||
if (listeners is null)
|
||||
return;
|
||||
foreach (Action<T> listener in listeners.GetInvocationList())
|
||||
{
|
||||
try
|
||||
{
|
||||
listener(value);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
RecordDispatchFailure(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void RecordDispatchFailure(Exception error)
|
||||
{
|
||||
Interlocked.Increment(ref _dispatchFailureCount);
|
||||
LastDispatchFailure = error;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue