diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs index 9c844e61..462757d8 100644 --- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs +++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs @@ -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 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, diff --git a/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs b/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs index 46c19073..dbcb509f 100644 --- a/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs +++ b/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs @@ -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 _items = - Array.Empty(); - - public IReadOnlyList Items - { - get => _items; - set => _items = value ?? Array.Empty(); - } -} - -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)>(); -} - /// /// 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. diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs index 4cf814b0..63c666ba 100644 --- a/src/AcDream.App/Composition/SessionPlayerComposition.cs +++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs @@ -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 PortalTunnelFallback, Action 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, diff --git a/src/AcDream.App/Net/LiveSessionResetManifest.cs b/src/AcDream.App/Net/LiveSessionResetManifest.cs index 4d6a9708..39688f34 100644 --- a/src/AcDream.App/Net/LiveSessionResetManifest.cs +++ b/src/AcDream.App/Net/LiveSessionResetManifest.cs @@ -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), diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs index fca393f9..94132d01 100644 --- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs +++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs @@ -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(); - _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: (_, _) => { }), diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 4619052b..63fe9aa9 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -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(); /// Persisted hotbar shortcuts from the last PlayerDescription (D.5.1 toolbar source). - private readonly ShortcutSnapshotState _shortcutSnapshots = new(); - public IReadOnlyList Shortcuts - { - get => _shortcutSnapshots.Items; - private set => _shortcutSnapshots.Items = value; - } + public IReadOnlyList 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, diff --git a/src/AcDream.App/Rendering/GameWindowLifetime.cs b/src/AcDream.App/Rendering/GameWindowLifetime.cs index 8da2fa68..aca51946 100644 --- a/src/AcDream.App/Rendering/GameWindowLifetime.cs +++ b/src/AcDream.App/Rendering/GameWindowLifetime.cs @@ -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), diff --git a/src/AcDream.App/UI/ItemInteractionController.cs b/src/AcDream.App/UI/ItemInteractionController.cs index 8b22a10e..8eee737a 100644 --- a/src/AcDream.App/UI/ItemInteractionController.cs +++ b/src/AcDream.App/UI/ItemInteractionController.cs @@ -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); - -/// -/// 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. -/// -public sealed class ItemUseRequestReservation -{ - private readonly Action _resolve; - private int _resolved; - - internal ItemUseRequestReservation(Action 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); - } -} - /// /// 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? _systemMessage; private readonly AutoWieldController _autoWield; private readonly Action? _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? requestExternalContainer = null, CombatState? combatState = null, Action? sendChangeCombatMode = null, - Action? requestUse = null) + Action? 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; /// /// Retail ACCWeenieObject::IsPlayerReadyToMakeInventoryRequest. @@ -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); /// /// Increments retail's shared ClientUISystem busy reference after a @@ -379,10 +287,7 @@ public sealed class ItemInteractionController : IDisposable /// Item__UseDone too; AttackDone belongs only to the combat attack owner. /// public void IncrementBusyCount() - { - _busyCount++; - StateChanged?.Invoke(); - } + => _transactions.IncrementBusyCount(); /// /// 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(); } /// @@ -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 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 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 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 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 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 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(); } /// Retail UseDone (0x01C7) releases one UI busy reference. public void CompleteUse(uint _) - { - if (_busyCount == 0) return; - _busyCount--; - StateChanged?.Invoke(); - } + => _transactions.CompleteUse(0u); /// Session teardown drops every outstanding client UI busy reference. public void ClearBusy() - { - if (_busyCount == 0) return; - _useReservationGeneration++; - _busyCount = 0; - StateChanged?.Invoke(); - } + => _transactions.ClearBusy(); /// /// 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; diff --git a/src/AcDream.Core/Items/InventoryTransactionState.cs b/src/AcDream.Core/Items/InventoryTransactionState.cs new file mode 100644 index 00000000..f233bc89 --- /dev/null +++ b/src/AcDream.Core/Items/InventoryTransactionState.cs @@ -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); + +/// +/// 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. +/// +public sealed class ItemUseRequestReservation +{ + private readonly Action _resolve; + private int _resolved; + + internal ItemUseRequestReservation(Action 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); + } +} + +/// +/// Presentation-independent owner for retail's single +/// ACCWeenieObject::prevRequest inventory gate and +/// ClientUISystem busy-reference count. +/// +/// +/// Request recording and response clearing follow +/// ACCWeenieObject::RecordRequest @ 0x0058C220 and +/// ACCWeenieObject::RecordResponse @ 0x0058CAB0. The owner borrows the +/// canonical object table and never creates a second inventory collection. +/// +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? 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? 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 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(Action? listeners, T value) + { + if (listeners is null) + return; + foreach (Action listener in listeners.GetInvocationList()) + { + try + { + listener(value); + } + catch (Exception error) + { + RecordDispatchFailure(error); + } + } + } + + private void RecordDispatchFailure(Exception error) + { + Interlocked.Increment(ref _dispatchFailureCount); + LastDispatchFailure = error; + } +} diff --git a/src/AcDream.Runtime/Gameplay/RuntimeInventoryState.cs b/src/AcDream.Runtime/Gameplay/RuntimeInventoryState.cs new file mode 100644 index 00000000..374fe2a2 --- /dev/null +++ b/src/AcDream.Runtime/Gameplay/RuntimeInventoryState.cs @@ -0,0 +1,114 @@ +using AcDream.Core.Items; +using AcDream.Runtime.Entities; + +namespace AcDream.Runtime.Gameplay; + +/// +/// 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. +/// +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? 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? failures) + { + try + { + action(); + } + catch (Exception error) + { + (failures ??= []).Add(error); + } + } +} + +public sealed class ShortcutState +{ + private IReadOnlyList _items = Array.Empty(); + private long _revision; + + public IReadOnlyList Items => Volatile.Read(ref _items); + public long Revision => Interlocked.Read(ref _revision); + + public void Replace(IReadOnlyList? items) + { + Volatile.Write( + ref _items, + items ?? Array.Empty()); + Interlocked.Increment(ref _revision); + } + + public void Clear() => Replace(Array.Empty()); +} + +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)>()); +} diff --git a/tests/AcDream.App.Tests/Composition/InteractionRetainedUiCompositionTests.cs b/tests/AcDream.App.Tests/Composition/InteractionRetainedUiCompositionTests.cs index 17baf253..c52afd8b 100644 --- a/tests/AcDream.App.Tests/Composition/InteractionRetainedUiCompositionTests.cs +++ b/tests/AcDream.App.Tests/Composition/InteractionRetainedUiCompositionTests.cs @@ -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(), FrameDiagnostics: new DeferredRenderFrameDiagnosticsSource(), ExistingVitals: null, diff --git a/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs b/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs index 39cac4af..6284b6b7 100644 --- a/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs +++ b/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs @@ -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() { diff --git a/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs b/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs index 4e2cf531..6335b165 100644 --- a/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs +++ b/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs @@ -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", diff --git a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs index 602c1f32..5d6f6dfe 100644 --- a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs +++ b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs @@ -511,6 +511,7 @@ public sealed class CurrentGameRuntimeAdapterTests EquippedChildren = noop, ExternalContainer = noop, InteractionAndSelection = noop, + InventoryTransactions = noop, SelectionPresentation = noop, ObjectTable = noop, Spellbook = noop, diff --git a/tests/AcDream.App.Tests/Runtime/RuntimeInventoryOwnershipTests.cs b/tests/AcDream.App.Tests/Runtime/RuntimeInventoryOwnershipTests.cs new file mode 100644 index 00000000..75524455 --- /dev/null +++ b/tests/AcDream.App.Tests/Runtime/RuntimeInventoryOwnershipTests.cs @@ -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; + } + } +} diff --git a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs index 0da6e19e..fc6293e7 100644 --- a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs @@ -31,6 +31,7 @@ public sealed class ItemInteractionControllerTests public readonly List 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? requestUse = null) + Action? 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(() => 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(); + h.Controller.PendingBackpackPlacementCancelled += cancelled.Add; + + Assert.Throws(() => + 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(() => + 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() { diff --git a/tests/AcDream.Core.Tests/Items/InventoryTransactionStateTests.cs b/tests/AcDream.Core.Tests/Items/InventoryTransactionStateTests.cs new file mode 100644 index 00000000..9672e1ab --- /dev/null +++ b/tests/AcDream.Core.Tests/Items/InventoryTransactionStateTests.cs @@ -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(() => 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(() => + 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(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(() => + 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; + } +} diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeInventoryStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeInventoryStateTests.cs new file mode 100644 index 00000000..d38abba2 --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeInventoryStateTests.cs @@ -0,0 +1,128 @@ +using AcDream.Core.Items; +using AcDream.Runtime.Entities; +using AcDream.Runtime.Gameplay; + +namespace AcDream.Runtime.Tests.Gameplay; + +public sealed class RuntimeInventoryStateTests +{ + [Fact] + public void OwnsOneGameplayGraphOverExactEntityObjectTable() + { + using var entities = new RuntimeEntityObjectLifetime(); + using var inventory = new RuntimeInventoryState(entities); + IReadOnlyList shortcuts = + [new ShortcutEntry(2, 0x50000001u, 0u)]; + IReadOnlyList<(uint Id, uint Amount)> components = + [(0x68000001u, 12u)]; + + inventory.ExternalContainers.RequestOpen(0x70000001u); + inventory.ExternalContainers.ApplyViewContents(0x70000001u); + inventory.ItemMana.OnQueryItemManaResponse(0x50000001u, 0.75f, true); + inventory.Shortcuts.Replace(shortcuts); + inventory.DesiredComponents.Replace(components); + + Assert.Same(entities.Objects, inventory.Objects); + Assert.Equal(0x70000001u, inventory.ExternalContainers.CurrentContainerId); + Assert.Equal(0.75f, inventory.ItemMana.GetManaPercent(0x50000001u)); + Assert.Same(shortcuts, inventory.Shortcuts.Items); + Assert.Same(components, inventory.DesiredComponents.Items); + Assert.Equal(1, inventory.Shortcuts.Revision); + Assert.Equal(1, inventory.DesiredComponents.Revision); + } + + [Fact] + public void ResetOperationsClearOnlyTheirOwnedLifetimeGroup() + { + using var entities = new RuntimeEntityObjectLifetime(); + using var inventory = new RuntimeInventoryState(entities); + inventory.ExternalContainers.RequestOpen(0x70000001u); + inventory.ExternalContainers.ApplyViewContents(0x70000001u); + inventory.ItemMana.OnQueryItemManaResponse(0x50000001u, 0.75f, true); + inventory.Shortcuts.Replace([new ShortcutEntry(1, 2u, 3u)]); + inventory.DesiredComponents.Replace([(4u, 5u)]); + inventory.Transactions.IncrementBusyCount(); + + inventory.ResetExternalContainer(); + Assert.Equal(0u, inventory.ExternalContainers.CurrentContainerId); + Assert.True(inventory.ItemMana.HasMana(0x50000001u)); + Assert.NotEmpty(inventory.Shortcuts.Items); + Assert.Equal(1, inventory.Transactions.BusyCount); + + inventory.ResetTransactions(); + inventory.ResetItemMana(); + inventory.ResetPlayerSnapshots(); + + Assert.Equal(0, inventory.Transactions.BusyCount); + Assert.False(inventory.ItemMana.HasMana(0x50000001u)); + Assert.Empty(inventory.Shortcuts.Items); + Assert.Empty(inventory.DesiredComponents.Items); + } + + [Fact] + public void IndependentRuntimeInstancesNeverShareState() + { + using var firstEntities = new RuntimeEntityObjectLifetime(); + using var secondEntities = new RuntimeEntityObjectLifetime(); + using var first = new RuntimeInventoryState(firstEntities); + using var second = new RuntimeInventoryState(secondEntities); + + first.Shortcuts.Replace([new ShortcutEntry(1, 2u, 3u)]); + first.Transactions.IncrementBusyCount(); + + Assert.NotSame(first.Objects, second.Objects); + Assert.Single(first.Shortcuts.Items); + Assert.Empty(second.Shortcuts.Items); + Assert.Equal(1, first.Transactions.BusyCount); + Assert.Equal(0, second.Transactions.BusyCount); + } + + [Fact] + public void DisposeDetachesTransactionsAndClearsOwnedState() + { + using var entities = new RuntimeEntityObjectLifetime(); + var inventory = new RuntimeInventoryState(entities); + inventory.ExternalContainers.RequestOpen(0x70000001u); + inventory.ExternalContainers.ApplyViewContents(0x70000001u); + inventory.ItemMana.OnQueryItemManaResponse(0x50000001u, 0.5f, true); + inventory.Shortcuts.Replace([new ShortcutEntry(1, 2u, 3u)]); + inventory.DesiredComponents.Replace([(4u, 5u)]); + + inventory.Dispose(); + inventory.Dispose(); + + Assert.True(inventory.IsDisposed); + Assert.True(inventory.Transactions.IsDisposed); + Assert.Equal(0u, inventory.ExternalContainers.CurrentContainerId); + Assert.False(inventory.ItemMana.HasMana(0x50000001u)); + Assert.Empty(inventory.Shortcuts.Items); + Assert.Empty(inventory.DesiredComponents.Items); + } + + [Fact] + public void DisposalRetriesATransientOwnerObserverFailureToConvergence() + { + using var entities = new RuntimeEntityObjectLifetime(); + var inventory = new RuntimeInventoryState(entities); + inventory.ExternalContainers.RequestOpen(0x70000001u); + inventory.ExternalContainers.ApplyViewContents(0x70000001u); + bool fail = true; + inventory.ExternalContainers.Changed += _ => + { + if (fail) + { + fail = false; + throw new InvalidOperationException("transient"); + } + }; + + Assert.Throws(inventory.Dispose); + Assert.False(inventory.IsDisposed); + Assert.True(inventory.Transactions.IsDisposed); + + inventory.Dispose(); + + Assert.True(inventory.IsDisposed); + Assert.Equal(0u, inventory.ExternalContainers.CurrentContainerId); + } +}