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
262
tests/AcDream.Core.Tests/Items/InventoryTransactionStateTests.cs
Normal file
262
tests/AcDream.Core.Tests/Items/InventoryTransactionStateTests.cs
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
using AcDream.Core.Items;
|
||||
|
||||
namespace AcDream.Core.Tests.Items;
|
||||
|
||||
public sealed class InventoryTransactionStateTests
|
||||
{
|
||||
private const uint Player = 0x50000001u;
|
||||
private const uint Pack = 0x50000002u;
|
||||
private const uint First = 0x60000001u;
|
||||
private const uint Second = 0x60000002u;
|
||||
|
||||
[Fact]
|
||||
public void ReserveClosesReentrantDispatchWindowAndSuccessfulSendCommits()
|
||||
{
|
||||
var objects = CreateTable();
|
||||
using var state = new InventoryTransactionState(objects);
|
||||
bool secondDispatched = true;
|
||||
|
||||
Assert.True(state.TryDispatch(
|
||||
InventoryRequestKind.PutInContainer,
|
||||
First,
|
||||
() =>
|
||||
{
|
||||
secondDispatched = state.TryDispatch(
|
||||
InventoryRequestKind.PutInContainer,
|
||||
Second,
|
||||
static () => true);
|
||||
return true;
|
||||
}));
|
||||
|
||||
Assert.False(secondDispatched);
|
||||
Assert.True(state.TryGetPending(out PendingInventoryRequest pending));
|
||||
Assert.Equal(First, pending.ItemId);
|
||||
Assert.True(pending.Dispatched);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DispatchFailureOrExceptionReleasesExactProvisionalOwner()
|
||||
{
|
||||
var objects = CreateTable();
|
||||
using var state = new InventoryTransactionState(objects);
|
||||
|
||||
Assert.False(state.TryDispatch(
|
||||
InventoryRequestKind.PutInContainer,
|
||||
First,
|
||||
static () => false));
|
||||
Assert.False(state.HasPendingRequest);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => state.TryDispatch(
|
||||
InventoryRequestKind.PutInContainer,
|
||||
First,
|
||||
static () => throw new InvalidOperationException("transport")));
|
||||
Assert.False(state.HasPendingRequest);
|
||||
Assert.True(state.CanBeginRequest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FailedPrePublicationCallbackRollsBackOnlyItsReservation()
|
||||
{
|
||||
var objects = CreateTable();
|
||||
using var state = new InventoryTransactionState(objects);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
state.TryReserve(
|
||||
InventoryRequestKind.Pickup,
|
||||
First,
|
||||
out _,
|
||||
static _ => throw new InvalidOperationException("projection")));
|
||||
|
||||
Assert.False(state.HasPendingRequest);
|
||||
Assert.True(state.CanBeginRequest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuthoritativeResponseClearsBeforeReentrantCompletionObserver()
|
||||
{
|
||||
var objects = CreateTable();
|
||||
using var state = new InventoryTransactionState(objects);
|
||||
bool replacementDispatched = false;
|
||||
state.RequestCompleted += request =>
|
||||
{
|
||||
Assert.Equal(First, request.ItemId);
|
||||
Assert.False(state.HasPendingRequest);
|
||||
replacementDispatched = state.TryDispatch(
|
||||
InventoryRequestKind.PutInContainer,
|
||||
Second,
|
||||
static () => true);
|
||||
};
|
||||
|
||||
Assert.True(state.TryDispatch(
|
||||
InventoryRequestKind.PutInContainer,
|
||||
First,
|
||||
static () => true));
|
||||
Assert.True(objects.ApplyConfirmedServerMove(First, Player, 0u, 0));
|
||||
|
||||
Assert.True(replacementDispatched);
|
||||
Assert.True(state.TryGetPending(out PendingInventoryRequest pending));
|
||||
Assert.Equal(Second, pending.ItemId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OptimisticMoveAndUnrelatedResponsesDoNotCompleteRequest()
|
||||
{
|
||||
var objects = CreateTable();
|
||||
using var state = new InventoryTransactionState(objects);
|
||||
|
||||
Assert.True(state.TryDispatch(
|
||||
InventoryRequestKind.PutInContainer,
|
||||
First,
|
||||
() => objects.MoveItemOptimistic(First, Player, 0)));
|
||||
Assert.True(objects.ApplyConfirmedServerMove(Second, Player, 1u, 1));
|
||||
Assert.True(state.HasPendingRequest);
|
||||
|
||||
Assert.True(objects.ApplyConfirmedServerMove(First, Player, 0u, 0));
|
||||
Assert.False(state.HasPendingRequest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReusedGuidResponseCannotCompletePriorObjectIdentity()
|
||||
{
|
||||
var objects = CreateTable();
|
||||
using var state = new InventoryTransactionState(objects);
|
||||
ClientObject original = objects.Get(First)!;
|
||||
|
||||
Assert.True(state.TryDispatch(
|
||||
InventoryRequestKind.PutInContainer,
|
||||
First,
|
||||
static () => true));
|
||||
|
||||
var replacement = new ClientObject
|
||||
{
|
||||
ObjectId = First,
|
||||
Name = "replacement",
|
||||
Type = ItemType.Misc,
|
||||
};
|
||||
objects.AddOrUpdate(replacement);
|
||||
objects.MoveItem(First, Pack, 0);
|
||||
Assert.NotSame(original, replacement);
|
||||
|
||||
Assert.True(objects.ApplyConfirmedServerMove(First, Player, 0u, 0));
|
||||
Assert.True(state.HasPendingRequest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UseReservationTransfersToUseDoneOrCancelsBeforeDispatch()
|
||||
{
|
||||
var objects = CreateTable();
|
||||
using var state = new InventoryTransactionState(objects);
|
||||
|
||||
ItemUseRequestReservation cancelled = state.BeginUseRequestReservation();
|
||||
Assert.Equal(1, state.BusyCount);
|
||||
cancelled.CancelBeforeDispatch();
|
||||
cancelled.CancelBeforeDispatch();
|
||||
Assert.Equal(0, state.BusyCount);
|
||||
|
||||
ItemUseRequestReservation dispatched = state.BeginUseRequestReservation();
|
||||
dispatched.MarkDispatched();
|
||||
dispatched.CancelBeforeDispatch();
|
||||
Assert.Equal(1, state.BusyCount);
|
||||
state.CompleteUse(0u);
|
||||
Assert.Equal(0, state.BusyCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResetInvalidatesLateUseReservationAndClearsPendingRequest()
|
||||
{
|
||||
var objects = CreateTable();
|
||||
using var state = new InventoryTransactionState(objects);
|
||||
Assert.True(state.TryReserve(
|
||||
InventoryRequestKind.Pickup,
|
||||
First,
|
||||
out _));
|
||||
ItemUseRequestReservation stale = state.BeginUseRequestReservation();
|
||||
|
||||
state.ResetSession();
|
||||
stale.CancelBeforeDispatch();
|
||||
|
||||
Assert.Equal(0, state.BusyCount);
|
||||
Assert.False(state.HasPendingRequest);
|
||||
Assert.True(state.CanBeginRequest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObserverFailureIsIsolatedAndRecorded()
|
||||
{
|
||||
var objects = CreateTable();
|
||||
using var state = new InventoryTransactionState(objects);
|
||||
int delivered = 0;
|
||||
state.StateChanged += () =>
|
||||
throw new InvalidOperationException("observer");
|
||||
state.StateChanged += () => delivered++;
|
||||
|
||||
state.IncrementBusyCount();
|
||||
|
||||
Assert.Equal(1, delivered);
|
||||
Assert.Equal(1, state.DispatchFailureCount);
|
||||
Assert.IsType<InvalidOperationException>(state.LastDispatchFailure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObjectTableClearReleasesRequestAndNotifiesProjectionOwner()
|
||||
{
|
||||
var objects = CreateTable();
|
||||
using var state = new InventoryTransactionState(objects);
|
||||
int cleared = 0;
|
||||
state.ObjectTableCleared += () => cleared++;
|
||||
Assert.True(state.TryDispatch(
|
||||
InventoryRequestKind.PutInContainer,
|
||||
First,
|
||||
static () => true));
|
||||
|
||||
objects.Clear();
|
||||
|
||||
Assert.Equal(1, cleared);
|
||||
Assert.False(state.HasPendingRequest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DisposeDetachesFromBorrowedObjectTable()
|
||||
{
|
||||
var objects = CreateTable();
|
||||
var state = new InventoryTransactionState(objects);
|
||||
int completed = 0;
|
||||
state.RequestCompleted += _ => completed++;
|
||||
Assert.True(state.TryDispatch(
|
||||
InventoryRequestKind.PutInContainer,
|
||||
First,
|
||||
static () => true));
|
||||
|
||||
state.Dispose();
|
||||
state.Dispose();
|
||||
objects.ApplyConfirmedServerMove(First, Player, 0u, 0);
|
||||
|
||||
Assert.True(state.IsDisposed);
|
||||
Assert.Equal(0, completed);
|
||||
Assert.Throws<ObjectDisposedException>(() =>
|
||||
state.TryReserve(
|
||||
InventoryRequestKind.Pickup,
|
||||
First,
|
||||
out _));
|
||||
}
|
||||
|
||||
private static ClientObjectTable CreateTable()
|
||||
{
|
||||
var objects = new ClientObjectTable();
|
||||
objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = First,
|
||||
Name = "first",
|
||||
Type = ItemType.Misc,
|
||||
});
|
||||
objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = Second,
|
||||
Name = "second",
|
||||
Type = ItemType.Misc,
|
||||
});
|
||||
objects.MoveItem(First, Pack, 0);
|
||||
objects.MoveItem(Second, Pack, 1);
|
||||
return objects;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue