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:
Erik 2026-07-26 08:20:32 +02:00
parent 595d5e6b01
commit 011efbeaa7
18 changed files with 1337 additions and 406 deletions

View file

@ -189,13 +189,11 @@ public sealed class InteractionRetainedUiCompositionTests
Combat: null!,
CombatAttackOperations: null!,
Selection: null!,
ExternalContainers: null!,
Objects: null!,
Inventory: null!,
MagicCatalog: null!,
Spellbook: null!,
Communication: null!,
LocalPlayer: null!,
ItemMana: null!,
StackSplitQuantity: null!,
UiRegistry: null,
CombatModeCommands: null!,
@ -203,7 +201,6 @@ public sealed class InteractionRetainedUiCompositionTests
PlayerController: null!,
PlayerMode: null!,
CharacterOptions: null!,
Shortcuts: null!,
SelectionCameraFactory: static _ => Stub<SelectionCameraSource>(),
FrameDiagnostics: new DeferredRenderFrameDiagnosticsSource(),
ExistingVitals: null,

View file

@ -16,23 +16,6 @@ namespace AcDream.App.Tests.Composition;
public sealed class InteractionUiRuntimeSourcesTests
{
[Fact]
public void DesiredComponentSnapshotNormalizesNullAndClearsExactly()
{
var state = new DesiredComponentSnapshotState();
IReadOnlyList<(uint Id, uint Amount)> items =
[(0x01020304u, 7u)];
Assert.Empty(state.Items);
state.Items = items;
Assert.Same(items, state.Items);
state.Clear();
Assert.Empty(state.Items);
state.Items = null!;
Assert.Empty(state.Items);
}
[Fact]
public void SessionAuthorityDefaultsBindUnbindRebindAndDeactivateExactly()
{

View file

@ -281,6 +281,7 @@ public sealed class LiveSessionResetPlanTests
InteractionAndSelection = Stage(
"interaction and selection",
() => selection.Reset()),
InventoryTransactions = Stage("inventory transactions"),
SelectionPresentation = Stage("selection presentation"),
ObjectTable = Stage("object table", objects.Clear),
Spellbook = Stage("spellbook", spells.Clear),
@ -364,6 +365,7 @@ public sealed class LiveSessionResetPlanTests
"equipped children",
"external container",
"interaction and selection",
"inventory transactions",
"selection presentation",
"object table",
"spellbook",

View file

@ -511,6 +511,7 @@ public sealed class CurrentGameRuntimeAdapterTests
EquippedChildren = noop,
ExternalContainer = noop,
InteractionAndSelection = noop,
InventoryTransactions = noop,
SelectionPresentation = noop,
ObjectTable = noop,
Spellbook = noop,

View file

@ -0,0 +1,97 @@
namespace AcDream.App.Tests.Runtime;
public sealed class RuntimeInventoryOwnershipTests
{
[Fact]
public void ProductionConstructsOnlyTheRuntimeInventoryOwner()
{
string source = ReadSource("Rendering", "GameWindow.cs");
Assert.Contains(
"_runtimeInventory = new RuntimeInventoryState(_runtimeEntityObjects);",
source,
StringComparison.Ordinal);
Assert.DoesNotContain(
"new AcDream.Core.Items.ItemManaState",
source,
StringComparison.Ordinal);
Assert.DoesNotContain(
"new DesiredComponentSnapshotState",
source,
StringComparison.Ordinal);
Assert.DoesNotContain(
"new ShortcutSnapshotState",
source,
StringComparison.Ordinal);
Assert.DoesNotContain(
"new AcDream.Core.Items.ExternalContainerState",
source,
StringComparison.Ordinal);
}
[Fact]
public void UiSessionAndShutdownBorrowTheExactRuntimeOwner()
{
string ui = ReadSource(
"Composition",
"InteractionRetainedUiComposition.cs");
string session = ReadSource("Net", "LiveSessionRuntimeFactory.cs");
string shutdown = ReadSource("Rendering", "GameWindowLifetime.cs");
Assert.Contains(
"transactions: d.Inventory.Transactions",
ui,
StringComparison.Ordinal);
Assert.Contains(
"OnShortcuts: _domain.Inventory.Shortcuts.Replace",
session,
StringComparison.Ordinal);
Assert.Contains(
"_domain.Inventory.Transactions.CompleteUse(error)",
session,
StringComparison.Ordinal);
Assert.Contains(
"OnDesiredComponents: _domain.Inventory.DesiredComponents.Replace",
session,
StringComparison.Ordinal);
AssertAppearsInOrder(
shutdown,
"\"runtime inventory state\"",
"\"runtime entity/object lifetime\"");
}
private static string ReadSource(params string[] relative)
{
string root = FindRepositoryRoot();
return File.ReadAllText(Path.Combine(
[root, "src", "AcDream.App", .. relative]));
}
private static string FindRepositoryRoot()
{
var current = new DirectoryInfo(AppContext.BaseDirectory);
while (current is not null)
{
if (File.Exists(Path.Combine(current.FullName, "AcDream.slnx")))
return current.FullName;
current = current.Parent;
}
throw new DirectoryNotFoundException("AcDream.slnx was not found.");
}
private static void AssertAppearsInOrder(
string source,
params string[] fragments)
{
int cursor = 0;
foreach (string fragment in fragments)
{
int index = source.IndexOf(
fragment,
cursor,
StringComparison.Ordinal);
Assert.True(index >= 0, $"Missing source fragment: {fragment}");
cursor = index + fragment.Length;
}
}
}

View file

@ -31,6 +31,7 @@ public sealed class ItemInteractionControllerTests
public readonly List<CombatMode> CombatModeRequests = new();
public readonly CombatState Combat = new();
public readonly StackSplitQuantityState SplitQuantity = new();
public readonly InventoryTransactionState? SharedTransactions;
public uint SelectedObject;
public bool NonCombatMode;
public bool DragOnPlayerOpensSecureTrade = true;
@ -38,7 +39,8 @@ public sealed class ItemInteractionControllerTests
public long Now = 1_000;
public Harness(
Action<uint, ItemUseRequestReservation>? requestUse = null)
Action<uint, ItemUseRequestReservation>? requestUse = null,
bool sharedTransactions = false)
{
Objects.AddOrUpdate(new ClientObject
{
@ -54,6 +56,9 @@ public sealed class ItemInteractionControllerTests
ItemsCapacity = 24,
});
Objects.MoveItem(Pack, Player, 0);
SharedTransactions = sharedTransactions
? new InventoryTransactionState(Objects)
: null;
Controller = new ItemInteractionController(
Objects,
@ -86,7 +91,8 @@ public sealed class ItemInteractionControllerTests
},
combatState: Combat,
sendChangeCombatMode: CombatModeRequests.Add,
requestUse: requestUse);
requestUse: requestUse,
transactions: SharedTransactions);
}
public ItemInteractionController Controller { get; }
@ -312,6 +318,39 @@ public sealed class ItemInteractionControllerTests
Assert.False(h.Controller.IsAnyTargetModeActive);
}
[Fact]
public void BorrowedTransactionOwnerIsExactAndOutlivesController()
{
var h = new Harness(sharedTransactions: true);
h.Controller.IncrementBusyCount();
Assert.Equal(1, h.SharedTransactions!.BusyCount);
h.Controller.Dispose();
Assert.False(h.SharedTransactions.IsDisposed);
h.SharedTransactions.ClearBusy();
Assert.Equal(0, h.SharedTransactions.BusyCount);
h.SharedTransactions.Dispose();
}
[Fact]
public void BorrowedTransactionOwnerRejectsDifferentObjectTable()
{
var objects = new ClientObjectTable();
using var transactions =
new InventoryTransactionState(new ClientObjectTable());
Assert.Throws<ArgumentException>(() => new ItemInteractionController(
objects,
playerGuid: () => Player,
sendUse: null,
sendUseWithTarget: null,
sendWield: null,
sendDrop: null,
transactions: transactions));
}
[Fact]
public void AppraisalResponse_releasesOneBusyReferenceAndAcceptsCurrentRefresh()
{
@ -2019,6 +2058,67 @@ public sealed class ItemInteractionControllerTests
Assert.False(h.Controller.TryGetPendingInventoryRequest(out _));
}
[Fact]
public void ThrowingReservedDispatchWithdrawsBothOwnersBeforeRethrow()
{
var h = new Harness();
const uint item = 0x70000A27u;
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = item,
Name = "Loot",
Type = ItemType.Misc,
});
var cancelled = new List<PendingBackpackPlacement>();
h.Controller.PendingBackpackPlacementCancelled += cancelled.Add;
Assert.Throws<InvalidOperationException>(() =>
h.Controller.TryDispatchPendingBackpackPlacement(
item,
Player,
0,
InventoryRequestKind.Pickup,
static () => throw new InvalidOperationException("transport")));
Assert.False(h.Controller.TryGetPendingBackpackPlacement(item, out _));
Assert.False(h.Controller.TryGetPendingInventoryRequest(out _));
Assert.Single(cancelled);
}
[Fact]
public void ThrowingPendingProjectionObserverRollsBackEveryOwner()
{
var h = new Harness();
const uint item = 0x70000A28u;
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = item,
Name = "Loot",
Type = ItemType.Misc,
});
bool firstObserved = false;
bool cancelled = false;
h.Controller.PendingBackpackPlacementRequested += _ =>
firstObserved = true;
h.Controller.PendingBackpackPlacementRequested += _ =>
throw new InvalidOperationException("projection");
h.Controller.PendingBackpackPlacementCancelled += _ =>
cancelled = true;
Assert.Throws<AggregateException>(() =>
h.Controller.TryDispatchPendingBackpackPlacement(
item,
Player,
0,
InventoryRequestKind.Pickup,
static () => true));
Assert.True(firstObserved);
Assert.True(cancelled);
Assert.False(h.Controller.TryGetPendingBackpackPlacement(item, out _));
Assert.False(h.Controller.TryGetPendingInventoryRequest(out _));
}
[Fact]
public void ResetSession_ClearsTargetBusyThrottleAndConsumedClickState()
{