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

@ -46,13 +46,11 @@ internal sealed record InteractionRetainedUiDependencies(
CombatState Combat,
ICombatAttackOperations CombatAttackOperations,
SelectionState Selection,
ExternalContainerState ExternalContainers,
ClientObjectTable Objects,
RuntimeInventoryState Inventory,
MagicCatalog MagicCatalog,
Spellbook Spellbook,
RuntimeCommunicationState Communication,
LocalPlayerState LocalPlayer,
ItemManaState ItemMana,
StackSplitQuantityState StackSplitQuantity,
BufferedUiRegistry? UiRegistry,
LiveCombatModeCommandSlot CombatModeCommands,
@ -60,7 +58,6 @@ internal sealed record InteractionRetainedUiDependencies(
ILocalPlayerControllerSource PlayerController,
ILocalPlayerModeSource PlayerMode,
PlayerCharacterOptionsState CharacterOptions,
ShortcutSnapshotState Shortcuts,
Func<DeferredSelectionViewPlaneSource, SelectionCameraSource>
SelectionCameraFactory,
DeferredRenderFrameDiagnosticsSource FrameDiagnostics,
@ -275,8 +272,8 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
InteractionRetainedUiDependencies d,
DeferredLiveSessionUiAuthority session) =>
new(
d.ExternalContainers,
d.Objects,
d.Inventory.ExternalContainers,
d.Inventory.Objects,
guid => session.CurrentSession?.SendNoLongerViewingContents(guid));
public ItemInteractionController CreateItemInteraction(
@ -286,7 +283,7 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
DeferredLiveSessionUiAuthority session = late.Session;
DeferredSelectionUiAuthority selection = late.Selection;
return new ItemInteractionController(
d.Objects,
d.Inventory.Objects,
playerGuid: () => d.PlayerIdentity.ServerGuid,
sendUse: null,
sendExamine: guid => session.CurrentSession?.SendAppraise(guid),
@ -313,7 +310,8 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
placeInBackpack: selection.SendPickup,
backpackContainerId: () => late.InventoryContainer.Current(
d.PlayerIdentity.ServerGuid),
groundObjectId: () => d.ExternalContainers.CurrentContainerId,
groundObjectId: () =>
d.Inventory.ExternalContainers.CurrentContainerId,
sendSplitToWorld: (item, amount) =>
session.CurrentSession?.SendStackableSplitTo3D(item, amount),
selectedObjectId: () => d.Selection.SelectedObjectId ?? 0u,
@ -333,9 +331,10 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
amount),
requestExternalContainer: guid =>
{
d.ExternalContainers.RequestOpen(guid);
d.Inventory.ExternalContainers.RequestOpen(guid);
},
requestUse: selection.RequestUse);
requestUse: selection.RequestUse,
transactions: d.Inventory.Transactions);
}
public RetainedUiComposition CreateRetainedUi(
@ -372,7 +371,7 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
checkpoint(InteractionRetainedUiCompositionPoint.CursorAssetsCreated);
var characterSheet = new CharacterSheetProvider(
d.Objects,
d.Inventory.Objects,
d.LocalPlayer,
playerGuid: () => d.PlayerIdentity.ServerGuid,
activeToonName: () => d.Settings.ActiveToonKey,
@ -391,7 +390,7 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
MagicRuntime magic = MagicRuntime.Create(
d.MagicCatalog,
d.Spellbook,
d.Objects,
d.Inventory.Objects,
selectedObject: () => d.Selection.SelectedObjectId,
localPlayerId: () => d.PlayerIdentity.ServerGuid,
accountName: () => late.Session.AccountName
@ -530,7 +529,7 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
Magic: new MagicRuntimeBindings(
d.Spellbook,
magic.Casting,
d.Objects,
d.Inventory.Objects,
() => d.PlayerIdentity.ServerGuid,
d.MagicCatalog.Components,
iconComposer.GetIcon,
@ -571,7 +570,7 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
late.SelectionCamera.UiSnapshot),
Indicators: new IndicatorRuntimeBindings(
d.Spellbook,
d.Objects,
d.Inventory.Objects,
() => d.PlayerIdentity.ServerGuid,
() => d.LocalPlayer.GetAttribute(
LocalPlayerState.AttributeKind.Strength)
@ -581,13 +580,13 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
() => late.Session.CurrentSession?.RequestLinkStatusPing(),
d.Window.Close),
Toolbar: new ToolbarRuntimeBindings(
d.Objects,
() => d.Shortcuts.Items,
d.Inventory.Objects,
() => d.Inventory.Shortcuts.Items,
iconComposer.GetIcon,
iconComposer.GetDragIcon,
guid => late.Session.TryUseItem(guid, d.Log),
d.Combat,
d.ItemMana,
d.Inventory.ItemMana,
d.CombatModeCommands.Toggle,
itemInteraction,
entry => late.Session.CurrentSession?.SendAddShortcut(entry),
@ -596,10 +595,11 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
handler => d.Combat.HealthChanged += handler,
handler => d.Combat.HealthChanged -= handler,
late.Selection.ShouldShowHealth,
guid => d.Objects.Get(guid)?.GetAppropriateName(),
guid => d.Inventory.Objects.Get(guid)?.GetAppropriateName(),
d.Combat.GetHealthPercent,
d.Combat.HasHealth,
guid => (uint)(d.Objects.Get(guid)?.StackSize ?? 0),
guid =>
(uint)(d.Inventory.Objects.Get(guid)?.StackSize ?? 0),
guid => late.Session.CurrentSession?.SendQueryHealth(guid),
guid => late.Session.CurrentSession?.SendQueryItemMana(guid),
() => d.PlayerIdentity.ServerGuid,
@ -610,7 +610,7 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
placement)),
Character: new CharacterRuntimeBindings(characterSheet),
Inventory: new InventoryRuntimeBindings(
d.Objects,
d.Inventory.Objects,
() => d.PlayerIdentity.ServerGuid,
iconComposer.GetIcon,
iconComposer.GetDragIcon,
@ -637,8 +637,8 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
itemInteraction,
d.Selection),
ExternalContainer: new ExternalContainerRuntimeBindings(
d.ExternalContainers,
d.Objects,
d.Inventory.ExternalContainers,
d.Inventory.Objects,
iconComposer.GetIcon,
iconComposer.GetDragIcon,
itemInteraction,

View file

@ -6,6 +6,7 @@ using AcDream.App.Rendering;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.App.UI.Testing;
using AcDream.Core.Items;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.UI.Abstractions;
@ -406,32 +407,6 @@ internal sealed class PlayerCharacterOptionsState
public void Reset() => Options = PlayerDescriptionParser.CharacterOptions1.Default;
}
internal sealed class ShortcutSnapshotState
{
private IReadOnlyList<AcDream.Core.Items.ShortcutEntry> _items =
Array.Empty<AcDream.Core.Items.ShortcutEntry>();
public IReadOnlyList<AcDream.Core.Items.ShortcutEntry> Items
{
get => _items;
set => _items = value ?? Array.Empty<AcDream.Core.Items.ShortcutEntry>();
}
}
internal sealed class DesiredComponentSnapshotState
{
private IReadOnlyList<(uint Id, uint Amount)> _items =
Array.Empty<(uint Id, uint Amount)>();
public IReadOnlyList<(uint Id, uint Amount)> Items
{
get => _items;
set => _items = value ?? Array.Empty<(uint Id, uint Amount)>();
}
public void Clear() => _items = Array.Empty<(uint Id, uint Amount)>();
}
/// <summary>
/// Probe scripts are mounted in Phase 5; reveal/resource facts become valid in
/// Phase 7. Calls remain inert and diagnostic until that exact owner binds.

View file

@ -72,8 +72,7 @@ internal sealed record SessionPlayerDependencies(
ViewportAspectState ViewportAspect,
PlayerApproachCompletionState PlayerApproachCompletions,
PlayerCharacterOptionsState CharacterOptions,
ShortcutSnapshotState Shortcuts,
DesiredComponentSnapshotState DesiredComponents,
RuntimeInventoryState Inventory,
PointerPositionState PointerPosition,
DispatcherMovementInputSource MovementInput,
IInputCaptureSource InputCapture,
@ -88,8 +87,6 @@ internal sealed record SessionPlayerDependencies(
RuntimeCommunicationState Communication,
LocalPlayerState LocalPlayer,
Spellbook Spellbook,
ItemManaState ItemMana,
ExternalContainerState ExternalContainers,
TransferableResourceSlot<PortalTunnelPresentation> PortalTunnelFallback,
Action<string> Log);
@ -802,16 +799,13 @@ internal sealed class SessionPlayerCompositionPhase
d.PlayerController,
d.PlayerSkills,
d.WorldOrigin,
d.CharacterOptions,
d.Shortcuts,
d.DesiredComponents),
d.CharacterOptions),
new LiveSessionDomainRuntime(
d.EntityObjects,
d.LocalPlayer,
d.Spellbook,
d.Combat,
d.ItemMana,
d.ExternalContainers,
d.Inventory,
d.Communication),
new LiveSessionUiRuntime(
interaction.RetainedUi?.Runtime,

View file

@ -18,6 +18,7 @@ internal sealed class LiveSessionResetBindings
public required Action EquippedChildren { get; init; }
public required Action ExternalContainer { get; init; }
public required Action InteractionAndSelection { get; init; }
public required Action InventoryTransactions { get; init; }
public required Action SelectionPresentation { get; init; }
public required Action ObjectTable { get; init; }
public required Action Spellbook { get; init; }
@ -67,6 +68,7 @@ internal static class LiveSessionResetManifest
new("equipped children", bindings.EquippedChildren),
new("external container", bindings.ExternalContainer),
new("interaction and selection", bindings.InteractionAndSelection),
new("inventory transactions", bindings.InventoryTransactions),
new("selection presentation", bindings.SelectionPresentation),
new("object table", bindings.ObjectTable),
new("spellbook", bindings.Spellbook),

View file

@ -38,17 +38,14 @@ internal sealed record LiveSessionPlayerRuntime(
LocalPlayerControllerSlot Controller,
LocalPlayerSkillState Skills,
LiveWorldOriginState WorldOrigin,
PlayerCharacterOptionsState CharacterOptions,
ShortcutSnapshotState Shortcuts,
DesiredComponentSnapshotState DesiredComponents);
PlayerCharacterOptionsState CharacterOptions);
internal sealed record LiveSessionDomainRuntime(
RuntimeEntityObjectLifetime EntityObjects,
LocalPlayerState LocalPlayer,
Spellbook Spellbook,
CombatState Combat,
ItemManaState ItemMana,
ExternalContainerState ExternalContainers,
RuntimeInventoryState Inventory,
RuntimeCommunicationState Communication);
internal sealed record LiveSessionUiRuntime(
@ -168,15 +165,16 @@ internal sealed class LiveSessionRuntimeFactory
SettingsCharacterContext =
_interaction.Settings.RestoreDefaultCharacterContext,
EquippedChildren = _world.EquippedChildren.Clear,
ExternalContainer = () => _domain.ExternalContainers.Reset(),
ExternalContainer = _domain.Inventory.ResetExternalContainer,
InteractionAndSelection = _interaction.SelectionInteractions.ResetSession,
InventoryTransactions = _domain.Inventory.ResetTransactions,
SelectionPresentation = _world.SelectionScene.Reset,
ObjectTable = _domain.EntityObjects.ClearObjects,
Spellbook = _domain.Spellbook.Clear,
MagicRuntime = () => _ui.Magic?.Reset(),
CombatAttack = _interaction.CombatAttack.ResetSession,
CombatState = _domain.Combat.Clear,
ItemMana = _domain.ItemMana.Clear,
ItemMana = _domain.Inventory.ResetItemMana,
LocalPlayer = _domain.LocalPlayer.Clear,
Friends = _domain.Communication.ResetFriends,
Squelch = _domain.Communication.ResetSquelch,
@ -222,8 +220,7 @@ internal sealed class LiveSessionRuntimeFactory
_player.Skills.ResetSession();
_world.NetworkUpdates.ResetSessionState();
_world.Hydration.ResetSessionState();
_player.Shortcuts.Items = Array.Empty<ShortcutEntry>();
_player.DesiredComponents.Clear();
_domain.Inventory.ResetPlayerSnapshots();
_ui.Paperdoll?.ResetSession();
// X/Y are ignored until the next logical player CreateObject claims a
@ -258,19 +255,18 @@ internal sealed class LiveSessionRuntimeFactory
}
private LiveInventorySessionBindings CreateInventoryBindings() => new(
_domain.EntityObjects.Objects,
_domain.Inventory.Objects,
_domain.LocalPlayer,
PlayerGuid: () => _player.Identity.ServerGuid,
OnShortcuts: list => _player.Shortcuts.Items = list,
OnShortcuts: _domain.Inventory.Shortcuts.Replace,
OnUseDone: error =>
{
_domain.ExternalContainers.ApplyUseDone(error);
_interaction.ItemInteraction.CompleteUse(error);
_domain.Inventory.ExternalContainers.ApplyUseDone(error);
_domain.Inventory.Transactions.CompleteUse(error);
},
_domain.ItemMana,
OnDesiredComponents: components =>
_player.DesiredComponents.Items = components,
ExternalContainers: _domain.ExternalContainers,
_domain.Inventory.ItemMana,
OnDesiredComponents: _domain.Inventory.DesiredComponents.Replace,
ExternalContainers: _domain.Inventory.ExternalContainers,
OnAppraisal: appraisal =>
{
if (_ui.RetailUi is { } retailUi)
@ -376,7 +372,7 @@ internal sealed class LiveSessionRuntimeFactory
ClearDesiredComponents: () =>
{
session.SendClearDesiredComponents();
_player.DesiredComponents.Clear();
_domain.Inventory.DesiredComponents.Clear();
},
HasOpenVendor: () => false,
FillComponentBuyList: (_, _) => { }),

View file

@ -335,22 +335,19 @@ public sealed class GameWindow :
public AcDream.Core.Social.SquelchState Squelch =>
_runtimeCommunication.Squelch;
public readonly AcDream.Core.Combat.CombatState Combat = new();
public readonly AcDream.Core.Items.ItemManaState ItemMana = new();
private readonly DesiredComponentSnapshotState _desiredComponents = new();
private readonly RuntimeEntityObjectLifetime _runtimeEntityObjects = new();
private readonly RuntimeInventoryState _runtimeInventory;
public AcDream.Core.Items.ItemManaState ItemMana =>
_runtimeInventory.ItemMana;
public IReadOnlyList<(uint Id, uint Amount)> DesiredComponents =>
_desiredComponents.Items;
_runtimeInventory.DesiredComponents.Items;
// Retail resolves learned IDs through portal.dat's CSpellTable. MagicCatalog
// installs that immutable table once DatCollection opens in OnLoad.
public AcDream.Core.Spells.SpellTable SpellTable => SpellBook.Metadata;
public readonly AcDream.Core.Spells.Spellbook SpellBook = null!;
private readonly RuntimeEntityObjectLifetime _runtimeEntityObjects = new();
/// <summary>Persisted hotbar shortcuts from the last PlayerDescription (D.5.1 toolbar source).</summary>
private readonly ShortcutSnapshotState _shortcutSnapshots = new();
public IReadOnlyList<AcDream.Core.Items.ShortcutEntry> Shortcuts
{
get => _shortcutSnapshots.Items;
private set => _shortcutSnapshots.Items = value;
}
public IReadOnlyList<AcDream.Core.Items.ShortcutEntry> Shortcuts =>
_runtimeInventory.Shortcuts.Items;
// Issue #5 — caches CreatureProfile.{Stamina, Mana, *Max} from
// PlayerDescription so the Vitals HUD can render those bars.
// Issue #6 — wired to SpellBook so GetMaxApprox folds enchantment
@ -377,7 +374,6 @@ public sealed class GameWindow :
private AcDream.App.Combat.CombatAttackController? _combatAttackController;
private AcDream.App.Combat.CombatTargetController? _combatTargetController;
private AcDream.App.UI.ItemInteractionController? _itemInteractionController;
private readonly AcDream.Core.Items.ExternalContainerState _externalContainers = new();
private AcDream.App.World.ExternalContainerLifecycleController? _externalContainerLifecycle;
private AcDream.App.Spells.MagicRuntime? _magicRuntime;
private AcDream.App.Spells.MagicCatalog? _magicCatalog;
@ -565,6 +561,7 @@ public sealed class GameWindow :
AcDream.App.Plugins.BufferedUiRegistry? uiRegistry = null)
{
_options = options ?? throw new System.ArgumentNullException(nameof(options));
_runtimeInventory = new RuntimeInventoryState(_runtimeEntityObjects);
var alphaScratchBudgets =
AcDream.App.Rendering.Residency.AlphaScratchBudgetProfile.Create(
_options.ResidencyBudgets.AlphaScratchBytes);
@ -1262,13 +1259,11 @@ public sealed class GameWindow :
Combat,
_combatAttackOperations,
_selection,
_externalContainers,
_runtimeEntityObjects.Objects,
_runtimeInventory,
contentEffectsAudio.MagicCatalog,
SpellBook,
_runtimeCommunication,
LocalPlayer,
ItemMana,
_stackSplitQuantity,
_uiRegistry,
_liveCombatModeCommands,
@ -1276,7 +1271,6 @@ public sealed class GameWindow :
_playerControllerSlot,
_localPlayerMode,
_characterOptions,
_shortcutSnapshots,
viewPlane => new SelectionCameraSource(
hostInputCamera.CameraController,
_window!,
@ -1398,8 +1392,7 @@ public sealed class GameWindow :
_viewportAspect,
_playerApproachCompletions,
_characterOptions,
_shortcutSnapshots,
_desiredComponents,
_runtimeInventory,
_pointerPosition,
_movementInput,
_inputCapture,
@ -1414,8 +1407,6 @@ public sealed class GameWindow :
_runtimeCommunication,
LocalPlayer,
SpellBook,
ItemMana,
_externalContainers,
_portalTunnelFallback,
Console.WriteLine),
this).Compose(
@ -1590,6 +1581,7 @@ public sealed class GameWindow :
_equippedChildRenderer,
_liveEntities,
_runtimeEntityObjects,
_runtimeInventory,
_runtimeCommunication,
_renderSceneShadow,
_livePresentationBindings,

View file

@ -84,6 +84,7 @@ internal sealed record LiveShutdownRoots(
EquippedChildRenderController? EquippedChildren,
LiveEntityRuntime? LiveEntities,
RuntimeEntityObjectLifetime EntityObjects,
RuntimeInventoryState Inventory,
RuntimeCommunicationState Communication,
RenderSceneShadowRuntime? RenderSceneShadow,
LivePresentationRuntimeBindings? PresentationBindings,
@ -390,6 +391,9 @@ internal static class GameWindowShutdownManifest
]),
new ResourceShutdownStage("runtime entity/object stream",
[
Hard(
"runtime inventory state",
live.Inventory.Dispose),
Hard(
"runtime entity/object lifetime",
live.EntityObjects.Dispose),

View file

@ -19,50 +19,6 @@ public readonly record struct PendingBackpackPlacement(
int Placement,
ClientObject? ItemIdentity);
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>
/// Shared retail item interaction orchestrator. UI widgets route item clicks,
/// target acquisition, and drag-out drops here instead of duplicating
@ -105,17 +61,15 @@ public sealed class ItemInteractionController : IDisposable
private readonly Action<string>? _systemMessage;
private readonly AutoWieldController _autoWield;
private readonly Action<uint, ItemUseRequestReservation>? _requestUse;
private readonly InventoryTransactionState _transactions;
private readonly bool _ownsTransactions;
private long _lastUseMs = long.MinValue / 2;
private uint _consumedPrimaryClickTarget;
private long _consumedPrimaryClickMs = long.MinValue / 2;
private int _busyCount;
private uint _awaitingAppraisalId;
private uint _currentAppraisalId;
private ulong _nextInventoryRequestToken;
private PendingBackpackPlacement? _pendingBackpackPlacement;
private PendingInventoryRequest? _pendingInventoryRequest;
private ulong _useReservationGeneration;
private bool _disposed;
public ItemInteractionController(
@ -149,7 +103,8 @@ public sealed class ItemInteractionController : IDisposable
Action<uint>? requestExternalContainer = null,
CombatState? combatState = null,
Action<CombatMode>? sendChangeCombatMode = null,
Action<uint, ItemUseRequestReservation>? requestUse = null)
Action<uint, ItemUseRequestReservation>? requestUse = null,
InventoryTransactionState? transactions = null)
{
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
@ -180,21 +135,38 @@ public sealed class ItemInteractionController : IDisposable
_systemMessage = systemMessage;
_requestUse = requestUse;
_interactionState = interactionState ?? new InteractionState();
_transactions = transactions ?? new InventoryTransactionState(_objects);
_ownsTransactions = transactions is null;
if (!ReferenceEquals(_transactions.Objects, _objects))
{
if (_ownsTransactions)
_transactions.Dispose();
throw new ArgumentException(
"The inventory transaction owner must borrow the controller's exact object table.",
nameof(transactions));
}
try
{
_autoWield = new AutoWieldController(
_objects,
_playerGuid,
_sendWield,
sendPutItemInContainer,
_toast,
_systemMessage,
combatState,
sendChangeCombatMode);
}
catch
{
if (_ownsTransactions)
_transactions.Dispose();
throw;
}
_interactionState.Changed += OnInteractionModeChanged;
_objects.ObjectMoved += OnInventoryObjectMoved;
_objects.MoveRequestFailed += OnInventoryMoveFailed;
_objects.ObjectRemoved += OnInventoryObjectRemoved;
_objects.StackSizeUpdated += OnInventoryStackSizeUpdated;
_objects.Cleared += OnInventoryObjectsCleared;
_autoWield = new AutoWieldController(
_objects,
_playerGuid,
_sendWield,
sendPutItemInContainer,
_toast,
_systemMessage,
combatState,
sendChangeCombatMode);
_transactions.StateChanged += OnTransactionStateChanged;
_transactions.RequestCompleted += OnInventoryRequestCompleted;
_transactions.ObjectTableCleared += OnInventoryObjectsCleared;
}
public event Action? StateChanged;
@ -223,14 +195,16 @@ public sealed class ItemInteractionController : IDisposable
public InteractionState InteractionState => _interactionState;
public int BusyCount => _busyCount;
public int BusyCount => _transactions.BusyCount;
public uint CurrentAppraisalId => _currentAppraisalId;
public bool CanMakeInventoryRequest =>
BaseCanMakeInventoryRequest && _pendingInventoryRequest is null;
BaseCanMakeInventoryRequest && !_transactions.HasPendingRequest;
private bool BaseCanMakeInventoryRequest =>
_readyForInventoryRequest() && _busyCount == 0 && !_autoWield.IsBusy;
_readyForInventoryRequest()
&& _transactions.BusyCount == 0
&& !_autoWield.IsBusy;
/// <summary>
/// Retail <c>ACCWeenieObject::IsPlayerReadyToMakeInventoryRequest</c>.
@ -242,8 +216,8 @@ public sealed class ItemInteractionController : IDisposable
if (CanMakeInventoryRequest)
return true;
if (_pendingInventoryRequest is not null
|| _busyCount != 0
if (_transactions.HasPendingRequest
|| _transactions.BusyCount != 0
|| _autoWield.IsBusy)
_systemMessage?.Invoke(InventoryRequestBusyMessage);
return false;
@ -283,94 +257,28 @@ public sealed class ItemInteractionController : IDisposable
if (reservationToken != 0u)
{
if (_pendingInventoryRequest is not { } reserved
|| reserved.Token != reservationToken
|| reserved.ItemId != itemId
|| reserved.Kind != kind
|| reserved.Dispatched)
{
return false;
}
bool reservedDispatch;
try
{
reservedDispatch = dispatch();
return _transactions.TryDispatch(
kind,
itemId,
dispatch,
reservationToken);
}
catch
{
CancelPendingBackpackPlacement(itemId, reservationToken);
throw;
}
if (!reservedDispatch)
{
CancelPendingBackpackPlacement(itemId, reservationToken);
return false;
}
// A synchronous test transport or reentrant table listener may
// already have completed this exact request. Never resurrect it or
// overwrite a newer transaction acquired from that response.
if (_pendingInventoryRequest is { } reservationCurrent
&& reservationCurrent.Token == reserved.Token)
{
_pendingInventoryRequest = reserved with { Dispatched = true };
StateChanged?.Invoke();
}
return true;
}
if (!EnsureInventoryRequestReady())
return false;
ulong token = ++_nextInventoryRequestToken;
if (token == 0u)
token = ++_nextInventoryRequestToken;
var provisional = new PendingInventoryRequest(
token,
kind,
itemId,
_objects.Get(itemId),
Dispatched: false);
// Provisional ownership closes the reentrancy window around optimistic
// table mutation and arbitrary send adapters. Retail writes prevRequest
// after its synchronous event call; our callback seam can publish local
// notices reentrantly, so the owner is reserved before entering it.
_pendingInventoryRequest = provisional;
StateChanged?.Invoke();
bool dispatched;
try
{
dispatched = dispatch();
}
catch
{
ClearPendingInventoryRequest(token);
throw;
}
if (!dispatched)
{
ClearPendingInventoryRequest(token);
return false;
}
if (_pendingInventoryRequest is { } current
&& current.Token == token)
{
_pendingInventoryRequest = provisional with { Dispatched = true };
StateChanged?.Invoke();
}
return true;
return _transactions.TryDispatch(kind, itemId, dispatch);
}
public bool TryGetPendingInventoryRequest(out PendingInventoryRequest pending)
{
if (_pendingInventoryRequest is { } current)
{
pending = current;
return true;
}
pending = default;
return false;
}
=> _transactions.TryGetPending(out pending);
/// <summary>
/// Increments retail's shared <c>ClientUISystem</c> busy reference after a
@ -379,10 +287,7 @@ public sealed class ItemInteractionController : IDisposable
/// Item__UseDone too; AttackDone belongs only to the combat attack owner.
/// </summary>
public void IncrementBusyCount()
{
_busyCount++;
StateChanged?.Invoke();
}
=> _transactions.IncrementBusyCount();
/// <summary>
/// Raised for retail confirmation/auxiliary actions whose retained panel is
@ -530,10 +435,11 @@ public sealed class ItemInteractionController : IDisposable
if (firstResponse)
{
_awaitingAppraisalId = 0;
if (_busyCount > 0)
_busyCount--;
_currentAppraisalId = objectId;
StateChanged?.Invoke();
bool ownedBusyReference = _transactions.BusyCount > 0;
_transactions.CompleteUse(0u);
if (!ownedBusyReference)
StateChanged?.Invoke();
}
return new AppraisalResponseAcceptance(
@ -570,12 +476,18 @@ public sealed class ItemInteractionController : IDisposable
if (_awaitingAppraisalId != 0u)
{
_awaitingAppraisalId = 0u;
if (_busyCount > 0)
_busyCount--;
_currentAppraisalId = 0u;
bool ownedBusyReference = _transactions.BusyCount > 0;
_transactions.CompleteUse(0u);
if (!ownedBusyReference)
StateChanged?.Invoke();
}
else
{
_currentAppraisalId = 0u;
StateChanged?.Invoke();
}
_currentAppraisalId = 0u;
_sendExamine?.Invoke(0u);
StateChanged?.Invoke();
}
/// <summary>
@ -694,34 +606,63 @@ public sealed class ItemInteractionController : IDisposable
return false;
}
ulong token = ++_nextInventoryRequestToken;
if (token == 0u)
token = ++_nextInventoryRequestToken;
ClientObject? identity = _objects.Get(itemGuid);
pending = new PendingBackpackPlacement(
token,
itemGuid,
containerId,
placement,
identity);
_pendingBackpackPlacement = pending;
_pendingInventoryRequest = new PendingInventoryRequest(
token,
kind,
itemGuid,
identity,
Dispatched: false);
PendingBackpackPlacementRequested?.Invoke(pending);
if (_pendingBackpackPlacement != pending
|| _pendingInventoryRequest is not { } published
|| published.Token != token
PendingBackpackPlacement candidate = default;
bool reserved;
PendingInventoryRequest published;
try
{
reserved = _transactions.TryReserve(
kind,
itemGuid,
out published,
request =>
{
candidate = new PendingBackpackPlacement(
request.Token,
itemGuid,
containerId,
placement,
request.ItemIdentity);
_pendingBackpackPlacement = candidate;
List<Exception> failures = [];
DispatchAll(
PendingBackpackPlacementRequested,
candidate,
failures);
if (failures.Count != 0)
{
throw new AggregateException(
"One or more pending-placement observers failed.",
failures);
}
});
}
catch (Exception failure)
{
if (candidate.Token == 0u)
throw;
_pendingBackpackPlacement = null;
_transactions.CancelBeforeDispatch(candidate.Token);
List<Exception> failures = [failure];
DispatchAll(
PendingBackpackPlacementCancelled,
candidate,
failures);
throw new AggregateException(
"Pending backpack placement publication failed.",
failures);
}
pending = candidate;
if (!reserved
|| _pendingBackpackPlacement != candidate
|| published.Token != candidate.Token
|| published.ItemId != itemGuid
|| published.Kind != kind
|| published.Dispatched)
{
return false;
}
StateChanged?.Invoke();
return true;
}
@ -753,7 +694,7 @@ public sealed class ItemInteractionController : IDisposable
}
if (_pendingBackpackPlacement != pending
|| _pendingInventoryRequest is not { } reserved
|| !_transactions.TryGetPending(out PendingInventoryRequest reserved)
|| reserved.Token != pending.Token
|| reserved.ItemId != pending.ItemId
|| reserved.Kind != kind
@ -798,25 +739,15 @@ public sealed class ItemInteractionController : IDisposable
}
_pendingBackpackPlacement = null;
if (_pendingInventoryRequest is { } request
&& request.Token == pending.Token
&& !request.Dispatched)
_transactions.CancelBeforeDispatch(pending.Token);
List<Exception> failures = [];
DispatchAll(PendingBackpackPlacementCancelled, pending, failures);
if (failures.Count != 0)
{
_pendingInventoryRequest = null;
StateChanged?.Invoke();
throw new AggregateException(
"One or more pending-placement cancellation observers failed.",
failures);
}
PendingBackpackPlacementCancelled?.Invoke(pending);
}
private void ClearPendingInventoryRequest(ulong token)
{
if (_pendingInventoryRequest is not { } pending
|| pending.Token != token)
{
return;
}
_pendingInventoryRequest = null;
StateChanged?.Invoke();
}
internal bool TryCaptureObjectIdentity(uint objectId, out ClientObject item)
@ -871,7 +802,7 @@ public sealed class ItemInteractionController : IDisposable
if (!EnsureInventoryRequestReady())
return false;
_sendUseWithTarget?.Invoke(sourceGuid, targetGuid);
_busyCount++;
_transactions.IncrementBusyCount();
return true;
}
@ -906,7 +837,7 @@ public sealed class ItemInteractionController : IDisposable
else
{
_sendUse!(objectId);
_busyCount++;
_transactions.IncrementBusyCount();
}
return true;
}
@ -1023,7 +954,7 @@ public sealed class ItemInteractionController : IDisposable
}
else
{
_busyCount++;
_transactions.IncrementBusyCount();
}
break;
case ItemPolicyActionKind.Reject:
@ -1044,19 +975,7 @@ public sealed class ItemInteractionController : IDisposable
}
private ItemUseRequestReservation BeginUseRequestReservation()
{
ulong generation = _useReservationGeneration;
_busyCount++;
StateChanged?.Invoke();
return new ItemUseRequestReservation(dispatched =>
{
if (generation != _useReservationGeneration || dispatched)
return;
if (_busyCount > 0)
_busyCount--;
StateChanged?.Invoke();
});
}
=> _transactions.BeginUseRequestReservation();
private void ExecutePlacementActions(System.Collections.Generic.IReadOnlyList<ItemPolicyAction> actions)
{
@ -1204,81 +1123,39 @@ public sealed class ItemInteractionController : IDisposable
failures);
}
private void OnInventoryObjectMoved(ClientObjectMove move)
private void OnTransactionStateChanged()
{
if (move.Origin != ClientObjectMoveOrigin.AuthoritativeResponse)
return;
CompleteInventoryResponse(move.ItemId, move.Item);
}
private void OnInventoryMoveFailed(MoveRequestFailure failure)
{
ClientObject? identity = _objects.Get(failure.ItemId);
CompleteInventoryResponse(failure.ItemId, identity);
}
private void OnInventoryObjectRemoved(ClientObject item)
{
CompleteInventoryResponse(item.ObjectId, item);
}
private void OnInventoryStackSizeUpdated(ClientObject item)
{
CompleteInventoryResponse(item.ObjectId, item);
}
private void CompleteInventoryResponse(uint itemId, ClientObject? identity)
{
PendingInventoryRequest? completedRequest = null;
PendingBackpackPlacement? completedPlacement = null;
if (_pendingInventoryRequest is { } request
&& request.ItemId == itemId
&& MatchesIdentity(request.ItemIdentity, itemId, identity))
List<Exception> failures = [];
DispatchAll(StateChanged, failures);
if (failures.Count != 0)
{
completedRequest = request;
_pendingInventoryRequest = null;
throw new AggregateException(
"One or more item-interaction observers failed.",
failures);
}
}
private void OnInventoryRequestCompleted(PendingInventoryRequest request)
{
if (_pendingBackpackPlacement is { } placement
&& placement.ItemId == itemId
&& MatchesIdentity(placement.ItemIdentity, itemId, identity))
&& placement.Token == request.Token
&& placement.ItemId == request.ItemId)
{
completedPlacement = placement;
_pendingBackpackPlacement = null;
List<Exception> failures = [];
DispatchAll(PendingBackpackPlacementResolved, placement, failures);
if (failures.Count != 0)
{
throw new AggregateException(
"One or more pending-placement resolution observers failed.",
failures);
}
}
// ACCWeenieObject::RecordResponse @ 0x0058CAB0 clears prevRequest
// before ServerSaysMoveItem publishes the item-list notice. Clear the
// coupled global owner and local waiting projection atomically before
// either callback so observers never see a half-completed transaction.
if (completedPlacement is { } resolved)
PendingBackpackPlacementResolved?.Invoke(resolved);
if (completedRequest is not null)
StateChanged?.Invoke();
}
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 OnInventoryObjectsCleared()
{
bool changed = _pendingInventoryRequest is not null;
_pendingInventoryRequest = null;
_pendingBackpackPlacement = null;
if (changed)
StateChanged?.Invoke();
}
public void Dispose()
@ -1286,30 +1163,21 @@ public sealed class ItemInteractionController : IDisposable
if (_disposed) return;
_disposed = true;
_interactionState.Changed -= OnInteractionModeChanged;
_objects.ObjectMoved -= OnInventoryObjectMoved;
_objects.MoveRequestFailed -= OnInventoryMoveFailed;
_objects.ObjectRemoved -= OnInventoryObjectRemoved;
_objects.StackSizeUpdated -= OnInventoryStackSizeUpdated;
_objects.Cleared -= OnInventoryObjectsCleared;
_transactions.ObjectTableCleared -= OnInventoryObjectsCleared;
_transactions.RequestCompleted -= OnInventoryRequestCompleted;
_transactions.StateChanged -= OnTransactionStateChanged;
if (_ownsTransactions)
_transactions.Dispose();
_autoWield.Dispose();
}
/// <summary>Retail UseDone (0x01C7) releases one UI busy reference.</summary>
public void CompleteUse(uint _)
{
if (_busyCount == 0) return;
_busyCount--;
StateChanged?.Invoke();
}
=> _transactions.CompleteUse(0u);
/// <summary>Session teardown drops every outstanding client UI busy reference.</summary>
public void ClearBusy()
{
if (_busyCount == 0) return;
_useReservationGeneration++;
_busyCount = 0;
StateChanged?.Invoke();
}
=> _transactions.ClearBusy();
/// <summary>
/// Ends all session-scoped ItemHolder state. Target modes, click
@ -1320,11 +1188,21 @@ public sealed class ItemInteractionController : IDisposable
{
PendingBackpackPlacement? pendingPlacement = _pendingBackpackPlacement;
_pendingBackpackPlacement = null;
_useReservationGeneration++;
_busyCount = 0;
// The canonical owner publishes its reset to Runtime observers, while
// this controller preserves the pre-J4 single UI notification emitted
// by InteractionState.ResetSession below.
_transactions.StateChanged -= OnTransactionStateChanged;
try
{
_transactions.ResetSession();
}
finally
{
if (!_disposed)
_transactions.StateChanged += OnTransactionStateChanged;
}
_awaitingAppraisalId = 0;
_currentAppraisalId = 0;
_pendingInventoryRequest = null;
_lastUseMs = long.MinValue / 2;
_consumedPrimaryClickTarget = 0u;
_consumedPrimaryClickMs = long.MinValue / 2;

View 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;
}
}

View file

@ -0,0 +1,114 @@
using AcDream.Core.Items;
using AcDream.Runtime.Entities;
namespace AcDream.Runtime.Gameplay;
/// <summary>
/// Canonical presentation-independent owner for inventory gameplay state.
/// The object collection is borrowed directly from J3's entity/object
/// lifetime; this owner creates no second inventory model.
/// </summary>
public sealed class RuntimeInventoryState : IDisposable
{
private readonly RuntimeEntityObjectLifetime _entityObjects;
private bool _disposed;
public RuntimeInventoryState(RuntimeEntityObjectLifetime entityObjects)
{
_entityObjects = entityObjects
?? throw new ArgumentNullException(nameof(entityObjects));
ExternalContainers = new ExternalContainerState();
ItemMana = new ItemManaState();
Shortcuts = new ShortcutState();
DesiredComponents = new DesiredComponentState();
Transactions = new InventoryTransactionState(_entityObjects.Objects);
}
public ClientObjectTable Objects => _entityObjects.Objects;
public ExternalContainerState ExternalContainers { get; }
public ItemManaState ItemMana { get; }
public ShortcutState Shortcuts { get; }
public DesiredComponentState DesiredComponents { get; }
public InventoryTransactionState Transactions { get; }
public bool IsDisposed => _disposed;
public void ResetExternalContainer() => ExternalContainers.Reset();
public void ResetTransactions() => Transactions.ResetSession();
public void ResetItemMana() => ItemMana.Clear();
public void ResetPlayerSnapshots()
{
Shortcuts.Clear();
DesiredComponents.Clear();
}
public void Dispose()
{
if (_disposed)
return;
List<Exception>? failures = null;
Try(() => ExternalContainers.Reset(), ref failures);
Try(ItemMana.Clear, ref failures);
Try(Shortcuts.Clear, ref failures);
Try(DesiredComponents.Clear, ref failures);
Try(Transactions.Dispose, ref failures);
if (failures is not null)
throw new AggregateException(
"Runtime inventory state did not converge during disposal.",
failures);
_disposed = true;
}
private static void Try(Action action, ref List<Exception>? failures)
{
try
{
action();
}
catch (Exception error)
{
(failures ??= []).Add(error);
}
}
}
public sealed class ShortcutState
{
private IReadOnlyList<ShortcutEntry> _items = Array.Empty<ShortcutEntry>();
private long _revision;
public IReadOnlyList<ShortcutEntry> Items => Volatile.Read(ref _items);
public long Revision => Interlocked.Read(ref _revision);
public void Replace(IReadOnlyList<ShortcutEntry>? items)
{
Volatile.Write(
ref _items,
items ?? Array.Empty<ShortcutEntry>());
Interlocked.Increment(ref _revision);
}
public void Clear() => Replace(Array.Empty<ShortcutEntry>());
}
public sealed class DesiredComponentState
{
private IReadOnlyList<(uint Id, uint Amount)> _items =
Array.Empty<(uint Id, uint Amount)>();
private long _revision;
public IReadOnlyList<(uint Id, uint Amount)> Items =>
Volatile.Read(ref _items);
public long Revision => Interlocked.Read(ref _revision);
public void Replace(IReadOnlyList<(uint Id, uint Amount)>? items)
{
Volatile.Write(
ref _items,
items ?? Array.Empty<(uint Id, uint Amount)>());
Interlocked.Increment(ref _revision);
}
public void Clear() =>
Replace(Array.Empty<(uint Id, uint Amount)>());
}