From 89e6b207f81946b40c38c8dc6597817ad99fc6e0 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 26 Jul 2026 09:48:51 +0200 Subject: [PATCH] refactor(runtime): close canonical gameplay ownership Unify the toolbar shortcut manager with Runtime inventory state, route retail-ordered shortcut and spellbook command effects through the canonical owners, and make retained controllers borrow those exact instances. Remove the item-interaction transaction fallback and add graphical/no-window parity plus failure-safe terminal ownership-ledger coverage. Co-authored-by: Codex --- ...6-slice-j4-5-retail-local-command-order.md | 154 +++++++++++++ .../InteractionRetainedUiComposition.cs | 6 +- .../Net/LiveSessionRuntimeFactory.cs | 2 +- .../Runtime/CurrentGameRuntimeAdapter.cs | 2 + .../CurrentGameRuntimeCommandAdapter.cs | 67 ++++-- src/AcDream.App/Studio/FixtureProvider.cs | 3 +- .../UI/ItemInteractionController.cs | 40 ++-- .../UI/Layout/SpellbookWindowController.cs | 2 - .../UI/Layout/SpellcastingUiController.cs | 10 +- .../UI/Layout/ToolbarController.cs | 81 +++---- src/AcDream.App/UI/RetailUiRuntime.cs | 2 +- .../Chat/ChatCommandTargetState.cs | 9 + src/AcDream.Core/Items/ShortcutStore.cs | 118 +++++++++- src/AcDream.Core/Spells/Spellbook.cs | 24 ++- .../Gameplay/RuntimeCharacterState.cs | 204 +++++++++++++++++- .../Gameplay/RuntimeCommunicationState.cs | 74 +++++-- .../Gameplay/RuntimeGameplayOwnership.cs | 34 +++ .../Gameplay/RuntimeInventoryState.cs | 138 +++++++++--- .../SelectionInteractionControllerTests.cs | 1 + .../Runtime/CurrentGameRuntimeAdapterTests.cs | 112 +++++++++- .../Runtime/RuntimeInventoryOwnershipTests.cs | 90 +++++++- .../UI/CursorFeedbackControllerTests.cs | 4 + .../UI/ItemInteractionControllerTests.cs | 21 +- .../UI/Layout/AppraisalUiControllerTests.cs | 1 + .../ExternalContainerControllerTests.cs | 1 + .../UI/Layout/InventoryControllerTests.cs | 12 ++ .../UI/Layout/PaperdollControllerTests.cs | 1 + .../Layout/SpellbookWindowControllerTests.cs | 12 +- .../Layout/SpellcastingUiControllerTests.cs | 8 +- .../UI/Layout/ToolbarControllerTests.cs | 109 ++++++---- .../RetailItemConfirmationControllerTests.cs | 3 + .../UI/RetailUiInteractionFlowTests.cs | 2 + .../Items/ShortcutStoreTests.cs | 37 ++++ .../Gameplay/RuntimeCharacterStateTests.cs | 79 ++++++- .../Gameplay/RuntimeGameplayOwnershipTests.cs | 143 ++++++++++++ .../Gameplay/RuntimeInventoryStateTests.cs | 78 ++++++- 36 files changed, 1433 insertions(+), 251 deletions(-) create mode 100644 docs/research/2026-07-26-slice-j4-5-retail-local-command-order.md create mode 100644 src/AcDream.Runtime/Gameplay/RuntimeGameplayOwnership.cs create mode 100644 tests/AcDream.Runtime.Tests/Gameplay/RuntimeGameplayOwnershipTests.cs diff --git a/docs/research/2026-07-26-slice-j4-5-retail-local-command-order.md b/docs/research/2026-07-26-slice-j4-5-retail-local-command-order.md new file mode 100644 index 00000000..484d173e --- /dev/null +++ b/docs/research/2026-07-26-slice-j4-5-retail-local-command-order.md @@ -0,0 +1,154 @@ +# Slice J4.5 — retail local gameplay-command ordering + +## Scope + +This note fixes the command/local-state ordering used while J4.5 removes the +last graphical-only shortcut and spellbook mirrors. It does not add a new +gameplay algorithm. It preserves the existing packet builders and moves the +retail `PlayerModule` mutation onto the one Runtime-owned state object. + +Oracle: + +- `docs/research/named-retail/acclient_2013_pseudo_c.txt` +- `docs/research/named-retail/acclient.h` + +The existing wire ports remain: + +- `AcDream.Core.Net.Messages.ClientCommandRequests` +- `AcDream.Core.Net.WorldSession` +- `AcDream.App.Net.LiveSessionCommandRouter` + +## Retail pseudocode + +### Toolbar shortcut add + +Retail anchors: + +- `gmToolbarUI::AddShortcut @ 0x004BD9A0` +- `PlayerModule::AddShortCut @ 0x005D29A0` +- `ShortCutManager::AddShortCut @ 0x005D5790` + +```text +if slot is valid and the toolbar accepts the item: + create ShortCutData(slot, objectId, spellId) + send Event_AddShortCut(data) + playerModule.AddShortCut(data) + +ShortCutManager.AddShortCut(data): + if data.index is outside [0, 18): + return false + if the slot already owns a record: + replace all three record fields in place + else: + allocate and install one record + return true +``` + +### Toolbar shortcut remove + +Retail anchors: + +- `gmToolbarUI::RemoveShortcut @ 0x004BD450` +- `PlayerModule::RemoveShortCut @ 0x005D29E0` +- `ShortCutManager::RemoveShortCut @ 0x005D5690` + +```text +find the slot containing the selected shortcut +flush the visible slot +if this is a persisted removal: + send Event_RemoveShortCut(slot) + playerModule.RemoveShortCut(slot) + +ShortCutManager.RemoveShortCut(slot): + if slot is outside [0, 18): + return + destroy the record if present + set the slot to empty +``` + +### Favorite spell add/remove + +Retail anchors: + +- spell-menu handler at `0x004C64C0` +- `PlayerModule::AddSpellFavorite @ 0x005D43E0` +- `PlayerModule::RemoveSpellFavorite @ 0x005D4910` + +```text +add: + if tab is in [0, 8): + insert spellId at position in that tab + send Event_AddSpellFavorite(spellId, position, tab) + +remove: + if tab is in [0, 8): + remove spellId from that tab + send Event_RemoveSpellFavorite(spellId, tab) +``` + +Unlike toolbar shortcuts, favorite-spell state changes locally before the +outbound event. + +### Spellbook filter + +Retail anchors: + +- `gmSpellbookUI::UpdateFilter @ 0x0048B5E0` + +```text +derive the changed school/level bit +derive the next filter bitfield from the button state +if next differs from current: + playerModule.SetSpellbookFilter(next) + send Event_SpellbookFilterEvent(next) +rebuild the visible spell list +scroll to row zero +``` + +### Desired component level + +Retail anchors: + +- `gmSpellComponentUI::ListenToElementMessage @ 0x0048A420` +- `PlayerModule::SetDesiredCompLevel @ 0x005D4940` + +```text +parse the edited amount +if amount is in [0, 5000]: + send Event_SetDesiredComponentLevel(componentId, amount) + playerModule.SetDesiredCompLevel(componentId, amount) +else: + restore the current value in the editor + +PlayerModule.SetDesiredCompLevel(componentId, amount): + if amount is outside [0, 5000]: + return false + lazily create the desired-component table + replace an existing value or add a new value + return true +``` + +Retail retains an explicit zero-valued entry. J4.5 preserves that distinction; +only the clear-list command destroys the complete desired-component table. + +### Clear desired components + +Retail anchors: + +- chat-command handler at `0x0056FE4A` +- `PlayerModule::ClearDesiredCompList @ 0x005D2A00` + +```text +send Event_SetDesiredComponentLevel(INVALID_DID, -1) +destroy the complete desired-component table +set the table pointer to null +publish the spell-component UI update notice +``` + +## J4.5 integration consequence + +The graphical toolbar, retained spell UI, plugins, and future no-window host +must all mutate the same Runtime-owned `ShortcutStore` and `Spellbook`. +Graphical controllers may initiate the operation, but they may not retain or +reconstruct another mutable slot/favorite/component collection. Commands stay +synchronous; no additional frame or background queue is introduced. diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs index 964e251a..5f67b8fd 100644 --- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs +++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs @@ -285,6 +285,7 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory DeferredSelectionUiAuthority selection = late.Selection; return new ItemInteractionController( d.Inventory.Objects, + d.Inventory.Transactions, playerGuid: () => d.PlayerIdentity.ServerGuid, sendUse: null, sendExamine: guid => session.CurrentSession?.SendAppraise(guid), @@ -334,8 +335,7 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory { d.Inventory.ExternalContainers.RequestOpen(guid); }, - requestUse: selection.RequestUse, - transactions: d.Inventory.Transactions); + requestUse: selection.RequestUse); } public RetainedUiComposition CreateRetainedUi( @@ -592,7 +592,7 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory d.Window.Close), Toolbar: new ToolbarRuntimeBindings( d.Inventory.Objects, - () => d.Inventory.Shortcuts.Items, + d.Inventory.Shortcuts, iconComposer.GetIcon, iconComposer.GetDragIcon, guid => late.Session.TryUseItem(guid, d.Log), diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs index caca5cc3..3b0ac1bb 100644 --- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs +++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs @@ -254,7 +254,7 @@ internal sealed class LiveSessionRuntimeFactory private LiveInventorySessionBindings CreateInventoryBindings() => new( _domain.Inventory.Objects, PlayerGuid: () => _player.Identity.ServerGuid, - OnShortcuts: _domain.Inventory.Shortcuts.Replace, + OnShortcuts: _domain.Inventory.Shortcuts.Load, OnUseDone: error => { _domain.Inventory.ExternalContainers.ApplyUseDone(error); diff --git a/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs b/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs index 1a053e2c..98ead4d3 100644 --- a/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs +++ b/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs @@ -71,6 +71,8 @@ internal sealed class CurrentGameRuntimeAdapter sessionHost, commands, _view, + inventory, + character, selectionState, selection, gameplayInput, diff --git a/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs b/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs index 5f5f0073..1004a191 100644 --- a/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs +++ b/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs @@ -5,6 +5,7 @@ using AcDream.App.Net; using AcDream.Core.Selection; using AcDream.Core.Items; using AcDream.Runtime; +using AcDream.Runtime.Gameplay; using AcDream.Runtime.Session; using AcDream.UI.Abstractions; using AcDream.UI.Abstractions.Input; @@ -31,6 +32,8 @@ internal sealed class CurrentGameRuntimeCommandAdapter private readonly LiveSessionHost _sessionHost; private readonly ICommandBus _commands; private readonly CurrentGameRuntimeViewAdapter _view; + private readonly RuntimeInventoryState _inventory; + private readonly RuntimeCharacterState _character; private readonly SelectionState _selectionState; private readonly SelectionInteractionController _selection; private readonly GameplayInputFrameController _gameplayInput; @@ -42,6 +45,8 @@ internal sealed class CurrentGameRuntimeCommandAdapter LiveSessionHost sessionHost, ICommandBus commands, CurrentGameRuntimeViewAdapter view, + RuntimeInventoryState inventory, + RuntimeCharacterState character, SelectionState selectionState, SelectionInteractionController selection, GameplayInputFrameController gameplayInput, @@ -52,6 +57,8 @@ internal sealed class CurrentGameRuntimeCommandAdapter _sessionHost = sessionHost ?? throw new ArgumentNullException(nameof(sessionHost)); _commands = commands ?? throw new ArgumentNullException(nameof(commands)); _view = view ?? throw new ArgumentNullException(nameof(view)); + _inventory = inventory ?? throw new ArgumentNullException(nameof(inventory)); + _character = character ?? throw new ArgumentNullException(nameof(character)); _selectionState = selectionState ?? throw new ArgumentNullException(nameof(selectionState)); _selection = selection ?? throw new ArgumentNullException(nameof(selection)); @@ -288,8 +295,13 @@ internal sealed class CurrentGameRuntimeCommandAdapter RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true); if (gate != RuntimeCommandStatus.Accepted) return Result(gate); - if (command.Index < 0 - || (command.ObjectId == 0u && command.SpellId == 0u)) + var entry = new ShortcutEntry( + command.Index, + command.ObjectId, + command.SpellId); + if (!_inventory.TryAddShortcut( + entry, + () => _commands.Publish(new AddShortcutRuntimeCmd(entry)))) { return EmitResult( RuntimeCommandDomain.InventoryState, @@ -298,10 +310,6 @@ internal sealed class CurrentGameRuntimeCommandAdapter command.ObjectId); } - _commands.Publish(new AddShortcutRuntimeCmd(new ShortcutEntry( - command.Index, - command.ObjectId, - command.SpellId))); return EmitResult( RuntimeCommandDomain.InventoryState, operation: 0, @@ -316,7 +324,10 @@ internal sealed class CurrentGameRuntimeCommandAdapter RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true); if (gate != RuntimeCommandStatus.Accepted) return Result(gate); - if (index < 0) + if (!_inventory.TryRemoveShortcut( + index, + () => _commands.Publish( + new RemoveShortcutRuntimeCmd((uint)index)))) { return EmitResult( RuntimeCommandDomain.InventoryState, @@ -324,7 +335,6 @@ internal sealed class CurrentGameRuntimeCommandAdapter RuntimeCommandStatus.Rejected); } - _commands.Publish(new RemoveShortcutRuntimeCmd((uint)index)); return EmitResult( RuntimeCommandDomain.InventoryState, operation: 1, @@ -340,9 +350,14 @@ internal sealed class CurrentGameRuntimeCommandAdapter RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true); if (gate != RuntimeCommandStatus.Accepted) return Result(gate); - if ((uint)tabIndex >= 8u - || position < 0 - || spellId == 0u) + if (!_character.TryAddFavorite( + tabIndex, + position, + spellId, + () => _commands.Publish(new AddFavoriteRuntimeCmd( + spellId, + position, + tabIndex)))) { return EmitResult( RuntimeCommandDomain.Spellbook, @@ -351,10 +366,6 @@ internal sealed class CurrentGameRuntimeCommandAdapter spellId); } - _commands.Publish(new AddFavoriteRuntimeCmd( - spellId, - position, - tabIndex)); return EmitResult( RuntimeCommandDomain.Spellbook, operation: 0, @@ -370,7 +381,11 @@ internal sealed class CurrentGameRuntimeCommandAdapter RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true); if (gate != RuntimeCommandStatus.Accepted) return Result(gate); - if ((uint)tabIndex >= 8u || spellId == 0u) + if (!_character.TryRemoveFavorite( + tabIndex, + spellId, + () => _commands.Publish( + new RemoveFavoriteRuntimeCmd(spellId, tabIndex)))) { return EmitResult( RuntimeCommandDomain.Spellbook, @@ -379,7 +394,6 @@ internal sealed class CurrentGameRuntimeCommandAdapter spellId); } - _commands.Publish(new RemoveFavoriteRuntimeCmd(spellId, tabIndex)); return EmitResult( RuntimeCommandDomain.Spellbook, operation: 1, @@ -394,7 +408,10 @@ internal sealed class CurrentGameRuntimeCommandAdapter RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true); if (gate != RuntimeCommandStatus.Accepted) return Result(gate); - _commands.Publish(new SetSpellbookFilterRuntimeCmd(filters)); + _character.SetSpellbookFilter( + filters, + () => _commands.Publish( + new SetSpellbookFilterRuntimeCmd(filters))); return EmitResult( RuntimeCommandDomain.Spellbook, operation: 2, @@ -433,7 +450,12 @@ internal sealed class CurrentGameRuntimeCommandAdapter RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true); if (gate != RuntimeCommandStatus.Accepted) return Result(gate); - if (componentId == 0u) + if (!_character.TrySetDesiredComponent( + componentId, + amount, + () => _commands.Publish(new SetDesiredComponentRuntimeCmd( + componentId, + amount)))) { return EmitResult( RuntimeCommandDomain.Spellbook, @@ -442,9 +464,6 @@ internal sealed class CurrentGameRuntimeCommandAdapter componentId); } - _commands.Publish(new SetDesiredComponentRuntimeCmd( - componentId, - amount)); return EmitResult( RuntimeCommandDomain.Spellbook, operation: 4, @@ -458,7 +477,9 @@ internal sealed class CurrentGameRuntimeCommandAdapter RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true); if (gate != RuntimeCommandStatus.Accepted) return Result(gate); - _commands.Publish(new ClearDesiredComponentsRuntimeCmd()); + _character.ClearDesiredComponents( + () => _commands.Publish( + new ClearDesiredComponentsRuntimeCmd())); return EmitResult( RuntimeCommandDomain.Spellbook, operation: 5, diff --git a/src/AcDream.App/Studio/FixtureProvider.cs b/src/AcDream.App/Studio/FixtureProvider.cs index 7f14e387..35b32246 100644 --- a/src/AcDream.App/Studio/FixtureProvider.cs +++ b/src/AcDream.App/Studio/FixtureProvider.cs @@ -117,7 +117,7 @@ public static class FixtureProvider return ToolbarController.Bind( layout, objects, - shortcuts: () => System.Array.Empty(), + shortcuts: new ShortcutStore(), iconIds: MakeIconIds(stack), useItem: _ => { }, combatState: null, @@ -141,6 +141,7 @@ public static class FixtureProvider var selection = new AcDream.Core.Selection.SelectionState(); var itemInteraction = new ItemInteractionController( objects, + new InventoryTransactionState(objects), () => SampleData.PlayerGuid, sendUse: null, sendUseWithTarget: null, diff --git a/src/AcDream.App/UI/ItemInteractionController.cs b/src/AcDream.App/UI/ItemInteractionController.cs index 8eee737a..6726167e 100644 --- a/src/AcDream.App/UI/ItemInteractionController.cs +++ b/src/AcDream.App/UI/ItemInteractionController.cs @@ -62,7 +62,6 @@ public sealed class ItemInteractionController : IDisposable 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; @@ -74,6 +73,7 @@ public sealed class ItemInteractionController : IDisposable public ItemInteractionController( ClientObjectTable objects, + InventoryTransactionState transactions, Func playerGuid, Action? sendUse, Action? sendUseWithTarget, @@ -103,8 +103,7 @@ public sealed class ItemInteractionController : IDisposable Action? requestExternalContainer = null, CombatState? combatState = null, Action? sendChangeCombatMode = null, - Action? requestUse = null, - InventoryTransactionState? transactions = null) + Action? requestUse = null) { _objects = objects ?? throw new ArgumentNullException(nameof(objects)); _playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid)); @@ -135,34 +134,23 @@ public sealed class ItemInteractionController : IDisposable _systemMessage = systemMessage; _requestUse = requestUse; _interactionState = interactionState ?? new InteractionState(); - _transactions = transactions ?? new InventoryTransactionState(_objects); - _ownsTransactions = transactions is null; + _transactions = transactions + ?? throw new ArgumentNullException(nameof(transactions)); 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; - } + _autoWield = new AutoWieldController( + _objects, + _playerGuid, + _sendWield, + sendPutItemInContainer, + _toast, + _systemMessage, + combatState, + sendChangeCombatMode); _interactionState.Changed += OnInteractionModeChanged; _transactions.StateChanged += OnTransactionStateChanged; _transactions.RequestCompleted += OnInventoryRequestCompleted; @@ -1166,8 +1154,6 @@ public sealed class ItemInteractionController : IDisposable _transactions.ObjectTableCleared -= OnInventoryObjectsCleared; _transactions.RequestCompleted -= OnInventoryRequestCompleted; _transactions.StateChanged -= OnTransactionStateChanged; - if (_ownsTransactions) - _transactions.Dispose(); _autoWield.Dispose(); } diff --git a/src/AcDream.App/UI/Layout/SpellbookWindowController.cs b/src/AcDream.App/UI/Layout/SpellbookWindowController.cs index b5470b2a..b6e52d73 100644 --- a/src/AcDream.App/UI/Layout/SpellbookWindowController.cs +++ b/src/AcDream.App/UI/Layout/SpellbookWindowController.cs @@ -251,7 +251,6 @@ public sealed class SpellbookWindowController : IRetainedPanelController // gmSpellbookUI::UpdateFilter @ 0x0048B5E0 persists only the changed // filter bitfield, rebuilds from learned spells, and returns to row zero. uint filters = _spellbook.SpellbookFilters ^ mask; - _spellbook.SetSpellbookFilters(filters); _sendFilter(filters); _spellList.Scroll.SetScrollY(0); } @@ -383,7 +382,6 @@ public sealed class SpellbookWindowController : IRetainedPanelController } if (parsed == committed) return; committed = parsed; - _spellbook.SetDesiredComponent(componentId, parsed); _setDesiredComponent(componentId, parsed); } desiredField.OnSubmit = Commit; diff --git a/src/AcDream.App/UI/Layout/SpellcastingUiController.cs b/src/AcDream.App/UI/Layout/SpellcastingUiController.cs index 5e1ed6df..b0c461e2 100644 --- a/src/AcDream.App/UI/Layout/SpellcastingUiController.cs +++ b/src/AcDream.App/UI/Layout/SpellcastingUiController.cs @@ -209,7 +209,6 @@ public sealed class SpellcastingUiController : IRetainedPanelController } int position = spells.Count; _addFavorite?.Invoke(_activeTab, position, spellId); - _spellbook.SetFavorite(_activeTab, position, spellId); SelectSpell(spellId); } @@ -424,13 +423,15 @@ public sealed class SpellcastingUiController : IRetainedPanelController private void BeginFavoriteDrag(SpellFavoriteDragPayload payload) => _removeFavorite?.Invoke(payload.SourceTab, payload.SpellId); - private void EndFavoriteDrag(SpellFavoriteDragPayload payload) - => _spellbook.RemoveFavorite(payload.SourceTab, payload.SpellId); + private static void EndFavoriteDrag(SpellFavoriteDragPayload payload) + { + // The press-time command already performed retail's PlayerModule + // removal on the one Runtime-owned Spellbook. + } private void DropFavorite(SpellFavoriteDragPayload payload, int targetTab, int targetPosition) { _addFavorite?.Invoke(targetTab, targetPosition, payload.SpellId); - _spellbook.SetFavorite(targetTab, targetPosition, payload.SpellId); _selected[targetTab] = payload.SpellId; } @@ -443,7 +444,6 @@ public sealed class SpellcastingUiController : IRetainedPanelController // CM_Magic::SendNotice_AddSpellShortcut; the open spellcasting menu // chooses the favorite tab/position and persists it. _addFavorite?.Invoke(targetTab, targetPosition, payload.SpellId); - _spellbook.SetFavorite(targetTab, targetPosition, payload.SpellId); _selected[targetTab] = payload.SpellId; } diff --git a/src/AcDream.App/UI/Layout/ToolbarController.cs b/src/AcDream.App/UI/Layout/ToolbarController.cs index 8418b4dd..9d880d84 100644 --- a/src/AcDream.App/UI/Layout/ToolbarController.cs +++ b/src/AcDream.App/UI/Layout/ToolbarController.cs @@ -63,12 +63,10 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont private readonly List<(uint PanelId, UiButton Button)> _panelButtons = new(); private readonly ClientObjectTable _repo; private readonly CombatState? _combatState; - private readonly Func> _shortcuts; + private readonly ShortcutStore _store; private readonly Func _iconIds; // (itemType, icon, underlay, overlay, effects) → GL tex private readonly Func? _dragIconIds; private readonly Action _useItem; // guid → fire UseObject - private readonly ShortcutStore _store = new(); - private bool _storeLoaded; private readonly Action? _sendAddShortcut; private readonly Action? _sendRemoveShortcut; // (index) private readonly ItemInteractionController? _itemInteraction; @@ -95,7 +93,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont private ToolbarController( ImportedLayout layout, ClientObjectTable repo, - Func> shortcuts, + ShortcutStore shortcuts, Func iconIds, Action useItem, CombatState? combatState, @@ -116,7 +114,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont { _repo = repo; _combatState = combatState; - _shortcuts = shortcuts; + _store = shortcuts ?? throw new ArgumentNullException(nameof(shortcuts)); _iconIds = iconIds; _dragIconIds = dragIconIds; _useItem = useItem; @@ -213,6 +211,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont repo.ObjectRemoved += OnRepositoryObjectChanged; repo.ObjectMoved += OnRepositoryObjectMoved; repo.Cleared += OnRepositoryCleared; + _store.Changed += Populate; RefreshAmmo(); RefreshUseButton(); } @@ -296,19 +295,11 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont /// Returns true if is one of the currently-active shortcut guids /// (i.e., present in the live ). /// Used to gate repo-event subscriptions so we don't re-populate on every creature spawn. - /// Falls back to scanning _shortcuts() before the store is loaded (pre-PD window). /// private bool IsShortcutGuid(uint guid) { - if (_storeLoaded) - { - for (int s = 0; s < AcDream.Core.Items.ShortcutStore.SlotCount; s++) - if (_store.Get(s) == guid) return true; - return false; - } - // Store not yet loaded — fall back to the shortcuts provider (pre-PD window). - foreach (var sc in _shortcuts()) - if (sc.ObjectId == guid) return true; + for (int s = 0; s < ShortcutStore.SlotCount; s++) + if (_store.Get(s) == guid) return true; return false; } @@ -320,7 +311,10 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont /// /// Imported toolbar layout (LayoutDesc 0x21000016). /// Live item repository — must stay alive for the controller's lifetime. - /// Provider for the current shortcut bar list. + /// + /// Runtime-owned retail shortcut manager. The toolbar borrows this exact + /// mutable owner; it never reconstructs another slot map. + /// /// Resolves (itemType, iconId, underlayId, overlayId, effects) → GL texture handle. /// Callback fired when a bound slot is clicked; receives the item guid. /// @@ -345,7 +339,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont public static ToolbarController Bind( ImportedLayout layout, ClientObjectTable repo, - Func> shortcuts, + ShortcutStore shortcuts, Func iconIds, Action useItem, CombatState? combatState = null, @@ -417,19 +411,11 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont /// Entries whose item is not yet in the repo are silently skipped here; the /// ObjectAdded event re-fires this method when the item arrives /// (matching retail's SetDelayedShortcutNum deferred-rebind path). - /// As of B.2: the is the - /// authoritative in-session slot map; _shortcuts() seeds it on first call. + /// The Runtime-owned is the + /// authoritative in-session slot map. /// public void Populate() { - // Lazy-load the store from the login shortcut list the first time it's present (PD arrives - // after Bind); thereafter the store is authoritative and drag ops mutate it directly. - if (!_storeLoaded && _shortcuts().Count > 0) - { - _store.Load(_shortcuts()); - _storeLoaded = true; - } - foreach (var list in _slots) list?.Cell.Clear(); for (int slot = 0; slot < _slots.Length; slot++) @@ -654,7 +640,6 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont if (_itemInteraction?.IsOwnedByPlayer(itemId) != true) return false; - EnsureStoreLoadedForMutation(); for (int slot = 0; slot < _slots.Length; slot++) if (_store.Get(slot) == itemId) return false; @@ -672,20 +657,11 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont return false; var entry = new ShortcutEntry(empty, itemId, 0u); - _store.Set(entry); _sendAddShortcut?.Invoke(entry); - Populate(); + _store.Set(entry); return true; } - private void EnsureStoreLoadedForMutation() - { - if (_storeLoaded) - return; - _store.Load(_shortcuts()); - _storeLoaded = true; - } - // ── IItemListDragHandler (B.2 live handler) ────────────────────────────── // Retail: gmToolbarUI is the m_dragHandler for every shortcut slot list. // Retail model (remove-on-lift / place-on-drop / no-restore): @@ -705,9 +681,8 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont // Retail RecvNotice_ItemListBeginDrag → RemoveShortcut (0x004bd930/0x004bd450): the lifted // shortcut leaves the bar (+ wire) the instant the drag starts; it's re-placed only on a // drop onto a slot. Off-bar release leaves it removed. - _store.Remove(payload.SourceSlot); _sendRemoveShortcut?.Invoke((uint)payload.SourceSlot); - Populate(); + _store.Remove(payload.SourceSlot); } /// @@ -717,7 +692,6 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont /// public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload) { - EnsureStoreLoadedForMutation(); ShortcutDropSource source = payload.SourceKind == ItemDragSource.ShortcutBar ? ShortcutDropSource.ShortcutAlias : ShortcutDropSource.FreshItem; @@ -727,7 +701,6 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont _store.Snapshot(), source, payload.SourceSlot, targetCell.SlotIndex, dragged); ApplyShortcutPlan(plan); - Populate(); } /// @@ -736,32 +709,29 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont /// public void ReplaceFullyMergedShortcut(uint oldObjectId, uint newObjectId) { - EnsureStoreLoadedForMutation(); ShortcutMutation[] plan = ShortcutDropPlanner.PlanFullStackMerge( _store.Snapshot(), oldObjectId, newObjectId); ApplyShortcutPlan(plan); - if (plan.Length > 0) - Populate(); } private void ApplyShortcutPlan(IReadOnlyList plan) { - // Apply the already-validated transaction locally first, then reproduce the - // planner's exact Remove/Add wire order. Shortcut events have no rejection reply. - foreach (var mutation in plan) - { - if (mutation.Kind == ShortcutMutationKind.Remove) - _store.Remove(mutation.Slot); - else if (mutation.Entry is { } entry) - _store.Set(entry); - } - + // Retail sends each shortcut event immediately before applying the same + // PlayerModule mutation (gmToolbarUI @ 0x004BD450/0x004BD9A0). + // Production commands mutate this exact Runtime-owned store; the + // idempotent local call also keeps isolated controller fixtures useful. foreach (var mutation in plan) { if (mutation.Kind == ShortcutMutationKind.Remove) + { _sendRemoveShortcut?.Invoke((uint)mutation.Slot); + _store.Remove(mutation.Slot); + } else if (mutation.Entry is { } entry) + { _sendAddShortcut?.Invoke(entry); + _store.Set(entry); + } } } @@ -800,5 +770,6 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont _repo.ObjectRemoved -= OnRepositoryObjectChanged; _repo.ObjectMoved -= OnRepositoryObjectMoved; _repo.Cleared -= OnRepositoryCleared; + _store.Changed -= Populate; } } diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index a1b7cce2..f857d80b 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -90,7 +90,7 @@ public sealed record IndicatorRuntimeBindings( public sealed record ToolbarRuntimeBindings( ClientObjectTable Objects, - Func> Shortcuts, + ShortcutStore Shortcuts, Func ResolveIcon, Func ResolveDragIcon, Action UseItem, diff --git a/src/AcDream.Core/Chat/ChatCommandTargetState.cs b/src/AcDream.Core/Chat/ChatCommandTargetState.cs index df10d34f..47a955cc 100644 --- a/src/AcDream.Core/Chat/ChatCommandTargetState.cs +++ b/src/AcDream.Core/Chat/ChatCommandTargetState.cs @@ -42,6 +42,15 @@ public sealed class ChatCommandTargetState : IDisposable } } + public bool IsDisposed + { + get + { + lock (_gate) + return _disposed; + } + } + /// /// Forget character-scoped command targets without clearing visible /// transcript history. diff --git a/src/AcDream.Core/Items/ShortcutStore.cs b/src/AcDream.Core/Items/ShortcutStore.cs index f6161165..0500b702 100644 --- a/src/AcDream.Core/Items/ShortcutStore.cs +++ b/src/AcDream.Core/Items/ShortcutStore.cs @@ -9,10 +9,40 @@ namespace AcDream.Core.Items; /// fields, including non-object records that the object-only /// gmToolbarUI does not render. /// -public sealed class ShortcutStore +public sealed class ShortcutStore : IDisposable { public const int SlotCount = 18; private readonly ShortcutEntry?[] _entries = new ShortcutEntry?[SlotCount]; + private ShortcutEntry[] _items = []; + private Action? _changed; + private long _revision; + private long _dispatchFailureCount; + private bool _disposed; + + /// + /// Raised synchronously after the canonical slot map changes. One failing + /// observer cannot prevent another host from seeing the same mutation. + /// + public event Action? Changed + { + add + { + ObjectDisposedException.ThrowIf(_disposed, this); + _changed += value; + } + remove => _changed -= value; + } + + /// Stable packed-order view of all present shortcut records. + public IReadOnlyList Items => Volatile.Read(ref _items); + + public int Count => Volatile.Read(ref _items).Length; + public long Revision => Interlocked.Read(ref _revision); + public int SubscriberCount => _changed?.GetInvocationList().Length ?? 0; + public long DispatchFailureCount => + Interlocked.Read(ref _dispatchFailureCount); + public Exception? LastDispatchFailure { get; private set; } + public bool IsDisposed => _disposed; /// /// Replace all slots from packed entries. Invalid signed indices are rejected exactly @@ -21,9 +51,12 @@ public sealed class ShortcutStore /// public void Load(IEnumerable entries) { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentNullException.ThrowIfNull(entries); Array.Clear(_entries); foreach (var entry in entries) - Set(entry); + SetCore(entry); + PublishChanged(); } /// Raw entry at , or null for absent/out-of-range. @@ -44,8 +77,9 @@ public sealed class ShortcutStore public void Set(ShortcutEntry entry) { - if ((uint)entry.Index < SlotCount) - _entries[entry.Index] = entry; + ObjectDisposedException.ThrowIf(_disposed, this); + if (SetCore(entry)) + PublishChanged(); } /// Create/replace a retail object shortcut; toolbar-created records use spell word zero. @@ -53,7 +87,79 @@ public sealed class ShortcutStore public void Remove(int slot) { - if ((uint)slot < SlotCount) - _entries[slot] = null; + ObjectDisposedException.ThrowIf(_disposed, this); + if ((uint)slot >= SlotCount || _entries[slot] is null) + return; + _entries[slot] = null; + PublishChanged(); + } + + /// + /// Clear the complete retail shortcut manager while retaining this owner + /// for the next session. + /// + public void Clear() + { + ObjectDisposedException.ThrowIf(_disposed, this); + Array.Clear(_entries); + PublishChanged(); + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + Array.Clear(_entries); + RebuildItems(); + Interlocked.Increment(ref _revision); + DispatchChanged(); + _changed = null; + } + + private bool SetCore(ShortcutEntry entry) + { + if ((uint)entry.Index >= SlotCount + || _entries[entry.Index] == entry) + { + return false; + } + _entries[entry.Index] = entry; + return true; + } + + private void PublishChanged() + { + RebuildItems(); + Interlocked.Increment(ref _revision); + DispatchChanged(); + } + + private void RebuildItems() + { + var items = new List(SlotCount); + for (int i = 0; i < _entries.Length; i++) + { + if (_entries[i] is ShortcutEntry entry) + items.Add(entry); + } + Volatile.Write(ref _items, items.ToArray()); + } + + private void DispatchChanged() + { + Delegate[] observers = _changed?.GetInvocationList() ?? []; + foreach (Delegate observer in observers) + { + try + { + ((Action)observer)(); + } + catch (Exception error) + { + Interlocked.Increment(ref _dispatchFailureCount); + LastDispatchFailure = error; + } + } } } diff --git a/src/AcDream.Core/Spells/Spellbook.cs b/src/AcDream.Core/Spells/Spellbook.cs index 754f5bec..90c91da7 100644 --- a/src/AcDream.Core/Spells/Spellbook.cs +++ b/src/AcDream.Core/Spells/Spellbook.cs @@ -401,11 +401,25 @@ public sealed class Spellbook public void SetDesiredComponent(uint componentId, uint amount) { - uint current = _desiredComponents.TryGetValue(componentId, out uint existing) - ? existing : 0u; - if (current == amount) return; - if (amount == 0) _desiredComponents.Remove(componentId); - else _desiredComponents[componentId] = amount; + bool exists = _desiredComponents.TryGetValue( + componentId, + out uint current); + if (exists && current == amount) return; + // PlayerModule::SetDesiredCompLevel @ 0x005D4940 retains zero as a + // real table value; only ClearDesiredCompList destroys the entry set. + _desiredComponents[componentId] = amount; + DesiredComponentsChanged?.Invoke(); + StateChanged?.Invoke(); + } + + /// + /// Destroy the complete desired-component list, matching + /// PlayerModule::ClearDesiredCompList @ 0x005D2A00. + /// + public void ClearDesiredComponents() + { + if (_desiredComponents.Count == 0) return; + _desiredComponents.Clear(); DesiredComponentsChanged?.Invoke(); StateChanged?.Invoke(); } diff --git a/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs b/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs index f5b720f3..ed4bed24 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs @@ -1,9 +1,41 @@ using AcDream.Core.Player; using AcDream.Core.Net.Messages; using AcDream.Core.Spells; +using AcDream.Core.Items; namespace AcDream.Runtime.Gameplay; +public readonly record struct RuntimeCharacterOwnershipSnapshot( + bool IsDisposed, + bool InternalSubscriptionsAttached, + int LearnedSpellCount, + int ActiveEnchantmentCount, + int DesiredComponentCount, + int FavoriteSpellCount, + int VitalCount, + int AttributeCount, + int SkillCount, + int PositionCount, + int PropertyCount, + bool OptionsAreDefaults, + bool MovementSkillsAreReset) +{ + public bool IsConverged => + IsDisposed + && !InternalSubscriptionsAttached + && LearnedSpellCount == 0 + && ActiveEnchantmentCount == 0 + && DesiredComponentCount == 0 + && FavoriteSpellCount == 0 + && VitalCount == 0 + && AttributeCount == 0 + && SkillCount == 0 + && PositionCount == 0 + && PropertyCount == 0 + && OptionsAreDefaults + && MovementSkillsAreReset; +} + /// /// Canonical presentation-independent owner for the local character's magic /// and player-sheet state. The two objects form one lifetime group because @@ -14,6 +46,7 @@ public sealed class RuntimeCharacterState : IDisposable private bool _disposed; private long _characterRevision; private long _spellbookRevision; + private bool _internalSubscriptionsAttached; public RuntimeCharacterState(SpellTable? spellTable = null) { @@ -26,6 +59,7 @@ public sealed class RuntimeCharacterState : IDisposable LocalPlayer.Changed += OnVitalChanged; LocalPlayer.AttributeChanged += OnAttributeChanged; LocalPlayer.CharacterChanged += OnCharacterChanged; + _internalSubscriptionsAttached = true; } public Spellbook Spellbook { get; } @@ -35,6 +69,57 @@ public sealed class RuntimeCharacterState : IDisposable public IRuntimeCharacterView View { get; } public bool IsDisposed => _disposed; + public RuntimeCharacterOwnershipSnapshot CaptureOwnership() + { + int favoriteCount = 0; + for (int tab = 0; tab < 8; tab++) + favoriteCount += Spellbook.GetFavorites(tab).Count; + + int vitalCount = 0; + foreach (LocalPlayerState.VitalKind kind + in Enum.GetValues()) + { + if (LocalPlayer.Get(kind) is not null) + vitalCount++; + } + + int attributeCount = 0; + foreach (LocalPlayerState.AttributeKind kind + in Enum.GetValues()) + { + if (LocalPlayer.GetAttribute(kind) is not null) + attributeCount++; + } + + PropertyBundle properties = LocalPlayer.Properties; + int propertyCount = + properties.Bools.Count + + properties.Ints.Count + + properties.Int64s.Count + + properties.Floats.Count + + properties.Strings.Count + + properties.DataIds.Count + + properties.InstanceIds.Count; + RuntimeCharacterOptionsSnapshot options = Options.Snapshot; + return new RuntimeCharacterOwnershipSnapshot( + _disposed, + _internalSubscriptionsAttached, + Spellbook.LearnedCount, + Spellbook.ActiveCount, + Spellbook.DesiredComponents.Count, + favoriteCount, + vitalCount, + attributeCount, + LocalPlayer.Skills.Count, + LocalPlayer.Positions.Count, + propertyCount, + options.Options1 == RuntimeCharacterOptionsState.DefaultOptions1 + && options.Options2 + == RuntimeCharacterOptionsState.DefaultOptions2, + MovementSkills.RunSkill == -1 + && MovementSkills.JumpSkill == -1); + } + /// /// Installs immutable DAT metadata without transferring its ownership to /// Runtime. The content host may install one table after portal.dat opens. @@ -57,6 +142,93 @@ public sealed class RuntimeCharacterState : IDisposable LocalPlayer.Clear(); } + /// + /// Apply retail's local favorite insertion before sending the matching + /// character event. + /// + public bool TryAddFavorite( + int tabIndex, + int position, + uint spellId, + Action publishOutbound) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentNullException.ThrowIfNull(publishOutbound); + if ((uint)tabIndex >= 8u || position < 0 || spellId == 0u) + return false; + Spellbook.SetFavorite(tabIndex, position, spellId); + publishOutbound(); + return true; + } + + public bool TryRemoveFavorite( + int tabIndex, + uint spellId, + Action publishOutbound) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentNullException.ThrowIfNull(publishOutbound); + if ((uint)tabIndex >= 8u || spellId == 0u) + return false; + Spellbook.RemoveFavorite(tabIndex, spellId); + publishOutbound(); + return true; + } + + /// + /// Apply and publish a spellbook filter only when it differs, matching + /// gmSpellbookUI::UpdateFilter @ 0x0048B5E0. + /// + public void SetSpellbookFilter( + uint filters, + Action publishOutbound) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentNullException.ThrowIfNull(publishOutbound); + if (Spellbook.SpellbookFilters == filters) + return; + Spellbook.SetSpellbookFilters(filters); + publishOutbound(); + } + + /// + /// Retail publishes the desired-component event before changing its local + /// PlayerModule table. + /// + public bool TrySetDesiredComponent( + uint componentId, + uint amount, + Action publishOutbound) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentNullException.ThrowIfNull(publishOutbound); + if (componentId == 0u || amount > 5000u) + return false; + try + { + publishOutbound(); + } + finally + { + Spellbook.SetDesiredComponent(componentId, amount); + } + return true; + } + + public void ClearDesiredComponents(Action publishOutbound) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentNullException.ThrowIfNull(publishOutbound); + try + { + publishOutbound(); + } + finally + { + Spellbook.ClearDesiredComponents(); + } + } + /// /// Clears both coupled owners while retaining every failed suffix for a /// retry. State mutation happens before the existing synchronous @@ -82,12 +254,32 @@ public sealed class RuntimeCharacterState : IDisposable { if (_disposed) return; - ResetSession(); - Spellbook.StateChanged -= OnSpellbookChanged; - LocalPlayer.Changed -= OnVitalChanged; - LocalPlayer.AttributeChanged -= OnAttributeChanged; - LocalPlayer.CharacterChanged -= OnCharacterChanged; - _disposed = true; + List? failures = null; + try + { + // Do not call ResetSession as one opaque step here. Terminal + // disposal must run every suffix even when an external UI observer + // throws from one Core owner's synchronous clear notification. + Try(Spellbook.Clear, ref failures); + Try(LocalPlayer.Clear, ref failures); + Try(Options.ResetSession, ref failures); + Try(MovementSkills.ResetSession, ref failures); + } + finally + { + Spellbook.StateChanged -= OnSpellbookChanged; + LocalPlayer.Changed -= OnVitalChanged; + LocalPlayer.AttributeChanged -= OnAttributeChanged; + LocalPlayer.CharacterChanged -= OnCharacterChanged; + _internalSubscriptionsAttached = false; + _disposed = true; + } + if (failures is not null) + { + throw new AggregateException( + "Runtime character state did not converge during disposal.", + failures); + } } private void OnSpellbookChanged() => diff --git a/src/AcDream.Runtime/Gameplay/RuntimeCommunicationState.cs b/src/AcDream.Runtime/Gameplay/RuntimeCommunicationState.cs index 46965a31..a9a122a9 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeCommunicationState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeCommunicationState.cs @@ -17,6 +17,36 @@ public interface IRuntimeCommunicationEventSource IDisposable Subscribe(IRuntimeCommunicationObserver observer); } +public readonly record struct RuntimeCommunicationOwnershipSnapshot( + bool IsDisposed, + bool CommandTargetsDisposed, + int StreamSubscriberCount, + int PendingDispatchCount, + bool IsDispatching, + int FriendCount, + int SquelchAccountCount, + int SquelchCharacterCount, + int SquelchGlobalTypeCount, + int NegotiatedRoomCount, + bool HasReplyTarget, + bool HasRetellTarget, + long DispatchFailureCount) +{ + public bool IsConverged => + IsDisposed + && CommandTargetsDisposed + && StreamSubscriberCount == 0 + && PendingDispatchCount == 0 + && !IsDispatching + && FriendCount == 0 + && SquelchAccountCount == 0 + && SquelchCharacterCount == 0 + && SquelchGlobalTypeCount == 0 + && NegotiatedRoomCount == 0 + && !HasReplyTarget + && !HasRetellTarget; +} + /// /// Canonical presentation-independent owner for communication and social /// state. Graphical, headless, plugin, and bot hosts borrow these exact @@ -59,6 +89,25 @@ public sealed class RuntimeCommunicationState : IDisposable public long DispatchFailureCount => _events.DispatchFailureCount; public Exception? LastDispatchFailure => _events.LastDispatchFailure; + public RuntimeCommunicationOwnershipSnapshot CaptureOwnership() + { + SquelchDatabase squelch = Squelch.Snapshot(); + return new RuntimeCommunicationOwnershipSnapshot( + _disposed, + CommandTargets.IsDisposed, + SubscriberCount, + PendingDispatchCount, + IsDispatching, + Friends.Count, + squelch.Accounts.Count, + squelch.Characters.Count, + squelch.Global.MessageTypes.Count, + CountRooms(TurbineChat), + CommandTargets.LastIncomingTellSender is not null, + CommandTargets.LastOutgoingTellTarget is not null, + DispatchFailureCount); + } + public void ResetCommandTargets() => CommandTargets.ResetSession(); public void ResetChatIdentity() => Chat.ResetSessionIdentity(); public void ResetNegotiatedChannels() => TurbineChat.Reset(); @@ -126,18 +175,19 @@ public sealed class RuntimeCommunicationState : IDisposable return true; } - private static int CountRooms(TurbineChatState state) - { - int count = 0; - if (state.AllegianceRoom != 0u) count++; - if (state.GeneralRoom != 0u) count++; - if (state.TradeRoom != 0u) count++; - if (state.LfgRoom != 0u) count++; - if (state.RoleplayRoom != 0u) count++; - if (state.SocietyRoom != 0u) count++; - if (state.OlthoiRoom != 0u) count++; - return count; - } + } + + private static int CountRooms(TurbineChatState state) + { + int count = 0; + if (state.AllegianceRoom != 0u) count++; + if (state.GeneralRoom != 0u) count++; + if (state.TradeRoom != 0u) count++; + if (state.LfgRoom != 0u) count++; + if (state.RoleplayRoom != 0u) count++; + if (state.SocietyRoom != 0u) count++; + if (state.OlthoiRoom != 0u) count++; + return count; } } diff --git a/src/AcDream.Runtime/Gameplay/RuntimeGameplayOwnership.cs b/src/AcDream.Runtime/Gameplay/RuntimeGameplayOwnership.cs new file mode 100644 index 00000000..c00fa76c --- /dev/null +++ b/src/AcDream.Runtime/Gameplay/RuntimeGameplayOwnership.cs @@ -0,0 +1,34 @@ +namespace AcDream.Runtime.Gameplay; + +/// +/// One allocation-free ledger over the complete J4 gameplay-state lifetime +/// group. It contains no state of its own; graphical and no-window hosts +/// capture the same three canonical owners. +/// +public readonly record struct RuntimeGameplayOwnershipSnapshot( + RuntimeInventoryOwnershipSnapshot Inventory, + RuntimeCharacterOwnershipSnapshot Character, + RuntimeCommunicationOwnershipSnapshot Communication) +{ + public bool IsConverged => + Inventory.IsConverged + && Character.IsConverged + && Communication.IsConverged; +} + +public static class RuntimeGameplayOwnership +{ + public static RuntimeGameplayOwnershipSnapshot Capture( + RuntimeInventoryState inventory, + RuntimeCharacterState character, + RuntimeCommunicationState communication) + { + ArgumentNullException.ThrowIfNull(inventory); + ArgumentNullException.ThrowIfNull(character); + ArgumentNullException.ThrowIfNull(communication); + return new RuntimeGameplayOwnershipSnapshot( + inventory.CaptureOwnership(), + character.CaptureOwnership(), + communication.CaptureOwnership()); + } +} diff --git a/src/AcDream.Runtime/Gameplay/RuntimeInventoryState.cs b/src/AcDream.Runtime/Gameplay/RuntimeInventoryState.cs index 525dbcc9..7f8c3527 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeInventoryState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeInventoryState.cs @@ -3,6 +3,33 @@ using AcDream.Runtime.Entities; namespace AcDream.Runtime.Gameplay; +public readonly record struct RuntimeInventoryOwnershipSnapshot( + bool IsDisposed, + bool TransactionsDisposed, + bool ShortcutsDisposed, + int BusyCount, + bool HasPendingRequest, + uint RequestedContainerId, + uint CurrentContainerId, + int ItemManaCount, + int ShortcutCount, + int ShortcutSubscriberCount, + long ShortcutDispatchFailureCount, + long TransactionDispatchFailureCount) +{ + public bool IsConverged => + IsDisposed + && TransactionsDisposed + && ShortcutsDisposed + && BusyCount == 0 + && !HasPendingRequest + && RequestedContainerId == 0u + && CurrentContainerId == 0u + && ItemManaCount == 0 + && ShortcutCount == 0 + && ShortcutSubscriberCount == 0; +} + /// /// Canonical presentation-independent owner for inventory gameplay state. /// The object collection is borrowed directly from J3's entity/object @@ -19,7 +46,7 @@ public sealed class RuntimeInventoryState : IDisposable ?? throw new ArgumentNullException(nameof(entityObjects)); ExternalContainers = new ExternalContainerState(); ItemMana = new ItemManaState(); - Shortcuts = new ShortcutState(); + Shortcuts = new ShortcutStore(); Transactions = new InventoryTransactionState(_entityObjects.Objects); View = new InventoryStateView(this); } @@ -27,11 +54,25 @@ public sealed class RuntimeInventoryState : IDisposable public ClientObjectTable Objects => _entityObjects.Objects; public ExternalContainerState ExternalContainers { get; } public ItemManaState ItemMana { get; } - public ShortcutState Shortcuts { get; } + public ShortcutStore Shortcuts { get; } public InventoryTransactionState Transactions { get; } public IRuntimeInventoryStateView View { get; } public bool IsDisposed => _disposed; + public RuntimeInventoryOwnershipSnapshot CaptureOwnership() => new( + _disposed, + Transactions.IsDisposed, + Shortcuts.IsDisposed, + Transactions.BusyCount, + Transactions.HasPendingRequest, + ExternalContainers.RequestedContainerId, + ExternalContainers.CurrentContainerId, + ItemMana.Count, + Shortcuts.Count, + Shortcuts.SubscriberCount, + Shortcuts.DispatchFailureCount, + Transactions.DispatchFailureCount); + public void ResetExternalContainer() => ExternalContainers.Reset(); public void ResetTransactions() => Transactions.ResetSession(); public void ResetItemMana() => ItemMana.Clear(); @@ -41,20 +82,82 @@ public sealed class RuntimeInventoryState : IDisposable Shortcuts.Clear(); } + /// + /// Send and apply one retail toolbar add on the canonical shortcut + /// manager. Retail emits Event_AddShortCut before mutating + /// PlayerModule. + /// + public bool TryAddShortcut( + ShortcutEntry entry, + Action publishOutbound) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentNullException.ThrowIfNull(publishOutbound); + if ((uint)entry.Index >= ShortcutStore.SlotCount + || (entry.ObjectId == 0u && entry.SpellId == 0u)) + { + return false; + } + + try + { + publishOutbound(); + } + finally + { + Shortcuts.Set(entry); + } + return true; + } + + /// + /// Send and apply one retail toolbar removal on the canonical shortcut + /// manager. + /// + public bool TryRemoveShortcut( + int index, + Action publishOutbound) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentNullException.ThrowIfNull(publishOutbound); + if ((uint)index >= ShortcutStore.SlotCount) + return false; + + try + { + publishOutbound(); + } + finally + { + Shortcuts.Remove(index); + } + return true; + } + 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(Transactions.Dispose, ref failures); + try + { + Try(() => ExternalContainers.Reset(), ref failures); + Try(ItemMana.Clear, ref failures); + Try(Shortcuts.Dispose, ref failures); + Try(Transactions.Dispose, ref failures); + } + finally + { + // Disposal is terminal even when a borrowed presentation observer + // fails. Every owned leaf above mutates before dispatching, so the + // full suffix is safe to run and the Runtime root must never remain + // half-alive over already-disposed children. + _disposed = true; + } 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) @@ -93,7 +196,7 @@ public sealed class RuntimeInventoryState : IDisposable owner.Transactions.BusyCount, owner.Transactions.CanBeginRequest, pending, - owner.Shortcuts.Items.Count, + owner.Shortcuts.Count, owner.Shortcuts.Revision, owner.ItemMana.Count, owner.ItemMana.Revision); @@ -124,22 +227,3 @@ public sealed class RuntimeInventoryState : IDisposable owner.ItemMana.TryGetManaPercent(objectId, out fraction); } } - -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()); -} diff --git a/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs b/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs index 84c4161e..61690c0a 100644 --- a/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs +++ b/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs @@ -160,6 +160,7 @@ public sealed class SelectionInteractionControllerTests }); Items = new ItemInteractionController( Objects, + new InventoryTransactionState(Objects), () => Player, sendUse: null, sendUseWithTarget: null, diff --git a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs index f333fb29..e8de2cb1 100644 --- a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs +++ b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs @@ -371,7 +371,7 @@ public sealed class CurrentGameRuntimeAdapterTests harness.Character.Options.Replace(0x04000000u, 0x00948700u); harness.Character.MovementSkills.Update(200, 175); harness.Character.Spellbook.OnSpellLearned(42u); - harness.InventoryState.Shortcuts.Replace( + harness.InventoryState.Shortcuts.Load( [ new ShortcutEntry(1, 0x80000001u, 0u), ]); @@ -506,6 +506,20 @@ public sealed class CurrentGameRuntimeAdapterTests static entry => entry.Kind == RuntimeTraceKind.Command && (entry.Code >> 16) == (int)RuntimeCommandDomain.Social); + Assert.True(harness.InventoryState.View.TryGetShortcut( + 2, + out RuntimeShortcutSnapshot storedShortcut)); + Assert.Equal(0x80000001u, storedShortcut.ObjectId); + Assert.True(harness.Character.View.TryGetFavorite( + 0, + 0, + out uint storedFavorite)); + Assert.Equal(42u, storedFavorite); + Assert.Equal(0x3FFEu, harness.Character.Spellbook.SpellbookFilters); + Assert.True(harness.Character.View.TryGetDesiredComponent( + 0x68000001u, + out uint storedDesired)); + Assert.Equal(10u, storedDesired); int published = harness.Commands.Published.Count; RuntimeCommandResult stale = commands.Character.SetOptions1( @@ -515,6 +529,101 @@ public sealed class CurrentGameRuntimeAdapterTests Assert.Equal(published, harness.Commands.Published.Count); } + [Fact] + public void GraphicalAndNoWindowJ4CommandsProduceIdenticalCanonicalState() + { + using var directEntities = new RuntimeEntityObjectLifetime(); + using var directInventory = + new RuntimeInventoryState(directEntities); + using var directCharacter = new RuntimeCharacterState(); + using var harness = new Harness(); + _ = harness.Runtime.Session.Start(harness.Runtime.Generation); + RuntimeGenerationToken generation = harness.Runtime.Generation; + IGameRuntimeCommands graphical = harness.Runtime; + var directOutbound = new List(); + var shortcut = new ShortcutEntry(2, 0x80000001u, 0u); + + Assert.True(directInventory.TryAddShortcut( + shortcut, + () => directOutbound.Add("add-shortcut"))); + Assert.True(directCharacter.TryAddFavorite( + 0, + 0, + 42u, + () => directOutbound.Add("add-favorite"))); + directCharacter.SetSpellbookFilter( + 0x3FFEu, + () => directOutbound.Add("filter")); + Assert.True(directCharacter.TrySetDesiredComponent( + 0x68000001u, + 10u, + () => directOutbound.Add("desired"))); + + Assert.True(graphical.InventoryState.AddShortcut( + generation, + new RuntimeShortcutCommand( + shortcut.Index, + shortcut.ObjectId, + shortcut.SpellId)).Accepted); + Assert.True(graphical.Spellbook.AddFavorite( + generation, + 0, + 0, + 42u).Accepted); + Assert.True(graphical.Spellbook.SetFilter( + generation, + 0x3FFEu).Accepted); + Assert.True(graphical.Spellbook.SetDesiredComponent( + generation, + 0x68000001u, + 10u).Accepted); + + Assert.Equal( + directInventory.View.Snapshot, + harness.InventoryState.View.Snapshot); + Assert.Equal( + directCharacter.View.Snapshot, + harness.Character.View.Snapshot); + Assert.Equal( + directInventory.Shortcuts.Items, + harness.InventoryState.Shortcuts.Items); + Assert.Equal( + directCharacter.Spellbook.GetFavorites(0), + harness.Character.Spellbook.GetFavorites(0)); + Assert.Equal( + ["add-shortcut", "add-favorite", "filter", "desired"], + directOutbound); + + Assert.True(directInventory.TryRemoveShortcut( + 2, + () => directOutbound.Add("remove-shortcut"))); + Assert.True(directCharacter.TryRemoveFavorite( + 0, + 42u, + () => directOutbound.Add("remove-favorite"))); + directCharacter.ClearDesiredComponents( + () => directOutbound.Add("clear-desired")); + Assert.True(graphical.InventoryState.RemoveShortcut( + generation, + 2).Accepted); + Assert.True(graphical.Spellbook.RemoveFavorite( + generation, + 0, + 42u).Accepted); + Assert.True(graphical.Spellbook.ClearDesiredComponents( + generation).Accepted); + + Assert.Equal( + directInventory.View.Snapshot, + harness.InventoryState.View.Snapshot); + Assert.Equal( + directCharacter.View.Snapshot, + harness.Character.View.Snapshot); + Assert.Empty(harness.InventoryState.Shortcuts.Items); + Assert.Empty(harness.Character.Spellbook.GetFavorites(0)); + Assert.Empty(harness.Character.Spellbook.DesiredComponents); + } + private static ClientObject Object(WorldSession.EntitySpawn spawn) => new() { ObjectId = spawn.Guid, @@ -587,6 +696,7 @@ public sealed class CurrentGameRuntimeAdapterTests _items = new ItemInteractionController( Objects, + InventoryState.Transactions, () => PlayerGuid, sendUse: null, sendUseWithTarget: null, diff --git a/tests/AcDream.App.Tests/Runtime/RuntimeInventoryOwnershipTests.cs b/tests/AcDream.App.Tests/Runtime/RuntimeInventoryOwnershipTests.cs index d99e1e90..267d0e47 100644 --- a/tests/AcDream.App.Tests/Runtime/RuntimeInventoryOwnershipTests.cs +++ b/tests/AcDream.App.Tests/Runtime/RuntimeInventoryOwnershipTests.cs @@ -41,13 +41,32 @@ public sealed class RuntimeInventoryOwnershipTests "InteractionRetainedUiComposition.cs"); string session = ReadSource("Net", "LiveSessionRuntimeFactory.cs"); string shutdown = ReadSource("Rendering", "GameWindowLifetime.cs"); + string itemInteraction = ReadSource( + "UI", + "ItemInteractionController.cs"); Assert.Contains( - "transactions: d.Inventory.Transactions", + "d.Inventory.Transactions", ui, StringComparison.Ordinal); + Assert.DoesNotContain( + "new InventoryTransactionState(d.Inventory.Objects)", + ui, + StringComparison.Ordinal); + Assert.DoesNotContain( + "new InventoryTransactionState", + itemInteraction, + StringComparison.Ordinal); + Assert.DoesNotContain( + "_ownsTransactions", + itemInteraction, + StringComparison.Ordinal); Assert.Contains( - "OnShortcuts: _domain.Inventory.Shortcuts.Replace", + "InventoryTransactionState transactions,", + itemInteraction, + StringComparison.Ordinal); + Assert.Contains( + "OnShortcuts: _domain.Inventory.Shortcuts.Load", session, StringComparison.Ordinal); Assert.Contains( @@ -64,6 +83,73 @@ public sealed class RuntimeInventoryOwnershipTests "\"runtime entity/object lifetime\""); } + [Fact] + public void ToolbarBorrowsRuntimeShortcutManagerWithoutAProviderOrMirror() + { + string toolbar = ReadSource( + "UI", + "Layout", + "ToolbarController.cs"); + string ui = ReadSource( + "Composition", + "InteractionRetainedUiComposition.cs"); + + Assert.Contains( + "private readonly ShortcutStore _store;", + toolbar, + StringComparison.Ordinal); + Assert.Contains( + "_store = shortcuts ?? throw", + toolbar, + StringComparison.Ordinal); + Assert.DoesNotContain( + "new ShortcutStore()", + toolbar, + StringComparison.Ordinal); + Assert.DoesNotContain( + "Func>", + toolbar, + StringComparison.Ordinal); + Assert.DoesNotContain( + "_storeLoaded", + toolbar, + StringComparison.Ordinal); + Assert.Contains( + "d.Inventory.Shortcuts,", + ui, + StringComparison.Ordinal); + } + + [Fact] + public void SpellUiDoesNotApplyASecondLocalCommandMutation() + { + string spellbook = ReadSource( + "UI", + "Layout", + "SpellbookWindowController.cs"); + string spellcasting = ReadSource( + "UI", + "Layout", + "SpellcastingUiController.cs"); + + Assert.DoesNotContain( + "_spellbook.SetSpellbookFilters(filters);", + spellbook, + StringComparison.Ordinal); + Assert.DoesNotContain( + "_spellbook.SetDesiredComponent(componentId, parsed);", + spellbook, + StringComparison.Ordinal); + Assert.DoesNotContain( + "_spellbook.SetFavorite(", + spellcasting, + StringComparison.Ordinal); + Assert.DoesNotContain( + "_spellbook.RemoveFavorite(", + spellcasting, + StringComparison.Ordinal); + } + private static string ReadSource(params string[] relative) { string root = FindRepositoryRoot(); diff --git a/tests/AcDream.App.Tests/UI/CursorFeedbackControllerTests.cs b/tests/AcDream.App.Tests/UI/CursorFeedbackControllerTests.cs index 557bf6ce..cf07327f 100644 --- a/tests/AcDream.App.Tests/UI/CursorFeedbackControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/CursorFeedbackControllerTests.cs @@ -153,6 +153,7 @@ public sealed class CursorFeedbackControllerTests var objects = SeedTargetObjects(); var interaction = new ItemInteractionController( objects, + new InventoryTransactionState(objects), playerGuid: () => Player, sendUse: null, sendUseWithTarget: null, @@ -188,6 +189,7 @@ public sealed class CursorFeedbackControllerTests objects.Get(Source)!.TargetType = (uint)ItemType.Misc; // a tool that targets items var interaction = new ItemInteractionController( objects, + new InventoryTransactionState(objects), playerGuid: () => Player, sendUse: null, sendUseWithTarget: null, @@ -226,6 +228,7 @@ public sealed class CursorFeedbackControllerTests var objects = SeedTargetObjects(); var interaction = new ItemInteractionController( objects, + new InventoryTransactionState(objects), playerGuid: () => Player, sendUse: null, sendUseWithTarget: null, @@ -254,6 +257,7 @@ public sealed class CursorFeedbackControllerTests var objects = SeedTargetObjects(); var interaction = new ItemInteractionController( objects, + new InventoryTransactionState(objects), playerGuid: () => Player, sendUse: null, sendUseWithTarget: null, diff --git a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs index fc6293e7..85fd3fac 100644 --- a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs @@ -31,7 +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 readonly InventoryTransactionState SharedTransactions; public uint SelectedObject; public bool NonCombatMode; public bool DragOnPlayerOpensSecureTrade = true; @@ -39,8 +39,7 @@ public sealed class ItemInteractionControllerTests public long Now = 1_000; public Harness( - Action? requestUse = null, - bool sharedTransactions = false) + Action? requestUse = null) { Objects.AddOrUpdate(new ClientObject { @@ -56,12 +55,11 @@ public sealed class ItemInteractionControllerTests ItemsCapacity = 24, }); Objects.MoveItem(Pack, Player, 0); - SharedTransactions = sharedTransactions - ? new InventoryTransactionState(Objects) - : null; + SharedTransactions = new InventoryTransactionState(Objects); Controller = new ItemInteractionController( Objects, + SharedTransactions, playerGuid: () => Player, sendUse: requestUse is null ? Uses.Add : null, sendExamine: Examines.Add, @@ -91,8 +89,7 @@ public sealed class ItemInteractionControllerTests }, combatState: Combat, sendChangeCombatMode: CombatModeRequests.Add, - requestUse: requestUse, - transactions: SharedTransactions); + requestUse: requestUse); } public ItemInteractionController Controller { get; } @@ -321,11 +318,11 @@ public sealed class ItemInteractionControllerTests [Fact] public void BorrowedTransactionOwnerIsExactAndOutlivesController() { - var h = new Harness(sharedTransactions: true); + var h = new Harness(); h.Controller.IncrementBusyCount(); - Assert.Equal(1, h.SharedTransactions!.BusyCount); + Assert.Equal(1, h.SharedTransactions.BusyCount); h.Controller.Dispose(); Assert.False(h.SharedTransactions.IsDisposed); @@ -343,12 +340,12 @@ public sealed class ItemInteractionControllerTests Assert.Throws(() => new ItemInteractionController( objects, + transactions, playerGuid: () => Player, sendUse: null, sendUseWithTarget: null, sendWield: null, - sendDrop: null, - transactions: transactions)); + sendDrop: null)); } [Fact] diff --git a/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs index fe8dfbde..eb79e34b 100644 --- a/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs @@ -858,6 +858,7 @@ public sealed class AppraisalUiControllerTests List sent) => new( objects, + new InventoryTransactionState(objects), playerGuid: () => 0x50000002u, sendUse: null, sendUseWithTarget: null, diff --git a/tests/AcDream.App.Tests/UI/Layout/ExternalContainerControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ExternalContainerControllerTests.cs index 795329aa..fde91696 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ExternalContainerControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ExternalContainerControllerTests.cs @@ -106,6 +106,7 @@ public sealed class ExternalContainerControllerTests Interaction = new ItemInteractionController( Objects, + new InventoryTransactionState(Objects), playerGuid: () => Player, sendUse: Uses.Add, sendUseWithTarget: null, diff --git a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs index 02e2adbd..a318d0f4 100644 --- a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs @@ -508,6 +508,7 @@ public class InventoryControllerTests var appraisals = new List(); using var interaction = new ItemInteractionController( objects, + new InventoryTransactionState(objects), playerGuid: () => Player, sendUse: null, sendUseWithTarget: null, @@ -544,6 +545,7 @@ public class InventoryControllerTests objects.Get(0xA)!.Useability = 0x000A0008u; var interaction = new ItemInteractionController( objects, + new InventoryTransactionState(objects), playerGuid: () => Player, sendUse: null, sendUseWithTarget: null, @@ -712,6 +714,7 @@ public class InventoryControllerTests var pickups = new List<(uint item, uint container, int placement)>(); using var interaction = new ItemInteractionController( objects, + new InventoryTransactionState(objects), playerGuid: () => Player, sendUse: null, sendUseWithTarget: null, @@ -756,6 +759,7 @@ public class InventoryControllerTests var eventOrder = new List<(string Kind, uint Item, ulong Token)>(); using var interaction = new ItemInteractionController( objects, + new InventoryTransactionState(objects), playerGuid: () => Player, sendUse: null, sendUseWithTarget: null, @@ -807,6 +811,7 @@ public class InventoryControllerTests var eventOrder = new List<(string Kind, uint Item)>(); using var interaction = new ItemInteractionController( objects, + new InventoryTransactionState(objects), playerGuid: () => Player, sendUse: null, sendUseWithTarget: null, @@ -864,6 +869,7 @@ public class InventoryControllerTests var messages = new List(); using var interaction = new ItemInteractionController( objects, + new InventoryTransactionState(objects), playerGuid: () => Player, sendUse: null, sendUseWithTarget: null, @@ -932,6 +938,7 @@ public class InventoryControllerTests var messages = new List(); using var interaction = new ItemInteractionController( objects, + new InventoryTransactionState(objects), playerGuid: () => Player, sendUse: null, sendUseWithTarget: null, @@ -994,6 +1001,7 @@ public class InventoryControllerTests var merges = new List<(uint Source, uint Target, uint Amount)>(); using var interaction = new ItemInteractionController( objects, + new InventoryTransactionState(objects), playerGuid: () => Player, sendUse: null, sendUseWithTarget: null, @@ -1053,6 +1061,7 @@ public class InventoryControllerTests var splits = new List<(uint Item, uint Container, uint Placement, uint Amount)>(); using var interaction = new ItemInteractionController( objects, + new InventoryTransactionState(objects), playerGuid: () => Player, sendUse: null, sendUseWithTarget: null, @@ -1099,6 +1108,7 @@ public class InventoryControllerTests var puts = new List<(uint Item, uint Container, int Placement)>(); using var interaction = new ItemInteractionController( objects, + new InventoryTransactionState(objects), playerGuid: () => Player, sendUse: null, sendUseWithTarget: null, @@ -1138,6 +1148,7 @@ public class InventoryControllerTests SeedContained(objects, loot, chest, slot: 0, type: ItemType.Misc); using var interaction = new ItemInteractionController( objects, + new InventoryTransactionState(objects), playerGuid: () => Player, sendUse: null, sendUseWithTarget: null, @@ -1176,6 +1187,7 @@ public class InventoryControllerTests SeedContained(objects, loot, chest, slot: 0, type: ItemType.Misc); using var interaction = new ItemInteractionController( objects, + new InventoryTransactionState(objects), playerGuid: () => Player, sendUse: null, sendUseWithTarget: null, diff --git a/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs index 3786210a..e8ab8706 100644 --- a/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs @@ -52,6 +52,7 @@ public class PaperdollControllerTests { var itemInteraction = new ItemInteractionController( objects, + new InventoryTransactionState(objects), () => Player, sendUse: null, sendUseWithTarget: null, diff --git a/tests/AcDream.App.Tests/UI/Layout/SpellbookWindowControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/SpellbookWindowControllerTests.cs index 2854435f..8a09a412 100644 --- a/tests/AcDream.App.Tests/UI/Layout/SpellbookWindowControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/SpellbookWindowControllerTests.cs @@ -413,10 +413,18 @@ public sealed class SpellbookWindowControllerTests spellId => spellId == 103u ? 8 : 1, selectObject ?? (_ => { }), addFavorite ?? (_ => { }), - sendFilter ?? (_ => { }), + filters => + { + spellbook.SetSpellbookFilters(filters); + sendFilter?.Invoke(filters); + }, removeSpell ?? (_ => { }), showConfirmation ?? ((_, _) => { }), - setDesiredComponent ?? ((_, _) => { }), + (componentId, amount) => + { + spellbook.SetDesiredComponent(componentId, amount); + setDesiredComponent?.Invoke(componentId, amount); + }, close ?? (() => { }), new ComponentBookTemplateFactory( FixtureLoader.LoadComponentCategoryTemplateInfos(), diff --git a/tests/AcDream.App.Tests/UI/Layout/SpellcastingUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/SpellcastingUiControllerTests.cs index c7713d01..1898d66d 100644 --- a/tests/AcDream.App.Tests/UI/Layout/SpellcastingUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/SpellcastingUiControllerTests.cs @@ -338,8 +338,12 @@ public sealed class SpellcastingUiControllerTests item => item.ObjectId, useItem, selection ?? new SelectionState(), - addFavorite ?? ((_, _, _) => { }), - (_, _) => { }, + (tab, position, spellId) => + { + spellbook.SetFavorite(tab, position, spellId); + addFavorite?.Invoke(tab, position, spellId); + }, + (tab, spellId) => spellbook.RemoveFavorite(tab, spellId), shortcutDigits, emptySlotSprite, examineSpell); diff --git a/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs index a1432bbd..dac72355 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs @@ -34,6 +34,14 @@ public class ToolbarControllerTests (InventoryButtonId, RetailPanelCatalog.Inventory), }; + private static ShortcutStore Store( + IEnumerable shortcuts) + { + var store = new ShortcutStore(); + store.Load(shortcuts); + return store; + } + private static (ImportedLayout layout, Dictionary slots, Dictionary indicators) FakeToolbar() { @@ -108,7 +116,7 @@ public class ToolbarControllerTests var shortcuts = new List { new(Index: 0, ObjectId: 0x5001u, SpellId: 0) }; - ToolbarController.Bind(layout, repo, () => shortcuts, + ToolbarController.Bind(layout, repo, Store(shortcuts), iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { }); Assert.Equal(0x5001u, slots[Row1[0]].Cell.ItemId); @@ -134,7 +142,7 @@ public class ToolbarControllerTests ToolbarController.Bind( layout, repo, - () => shortcuts, + Store(shortcuts), iconIds: (_, _, _, _, _) => 0x77u, useItem: _ => { }); Assert.Equal(0x5001u, slots[Row1[0]].Cell.ItemId); @@ -152,7 +160,7 @@ public class ToolbarControllerTests var shortcuts = new List { new(Index: 2, ObjectId: 0x5002u, SpellId: 0) }; - ToolbarController.Bind(layout, repo, () => shortcuts, + ToolbarController.Bind(layout, repo, Store(shortcuts), iconIds: (_,_,_,_,_) => 0x88u, useItem: _ => { }); Assert.Equal(0u, slots[Row1[2]].Cell.ItemId); // not bound yet @@ -168,7 +176,7 @@ public class ToolbarControllerTests var repo = new ClientObjectTable(); var shortcuts = new List { new(Index: 2, ObjectId: 0x5002u, SpellId: 0) }; - var controller = ToolbarController.Bind(layout, repo, () => shortcuts, + var controller = ToolbarController.Bind(layout, repo, Store(shortcuts), iconIds: (_,_,_,_,_) => 0x88u, useItem: _ => { }); controller.Dispose(); @@ -194,7 +202,7 @@ public class ToolbarControllerTests uint used = 0; var selection = new SelectionState(); - ToolbarController.Bind(layout, repo, () => shortcuts, + ToolbarController.Bind(layout, repo, Store(shortcuts), iconIds: (_,_,_,_,_) => 0x77u, useItem: g => used = g, selection: selection); @@ -217,7 +225,7 @@ public class ToolbarControllerTests var toggles = new List(); var ctrl = ToolbarController.Bind(layout, repo, - () => Array.Empty(), + new ShortcutStore(), iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }); ctrl.BindPanelButtons( panelId => panelId is RetailPanelCatalog.Inventory or RetailPanelCatalog.Character, @@ -257,6 +265,7 @@ public class ToolbarControllerTests var useWithTarget = new List<(uint Source, uint Target)>(); var interaction = new ItemInteractionController( repo, + new InventoryTransactionState(repo), playerGuid: () => player, sendUse: null, sendUseWithTarget: (source, target) => useWithTarget.Add((source, target)), @@ -266,7 +275,7 @@ public class ToolbarControllerTests int inventoryClicks = 0; var ctrl = ToolbarController.Bind(layout, repo, - () => Array.Empty(), + new ShortcutStore(), iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }, itemInteraction: interaction); @@ -293,7 +302,7 @@ public class ToolbarControllerTests repo.AddOrUpdate(new ClientObject { ObjectId = item, Type = ItemType.Misc }); var puts = new List<(uint Item, uint Container, int Placement)>(); ToolbarController.Bind(layout, repo, - () => Array.Empty(), + new ShortcutStore(), iconIds: (_, _, _, _, _) => 0u, useItem: _ => { }, playerGuid: () => player, @@ -322,6 +331,7 @@ public class ToolbarControllerTests var directPuts = new List<(uint Item, uint Container, int Placement)>(); using var interaction = new ItemInteractionController( repo, + new InventoryTransactionState(repo), playerGuid: () => player, sendUse: null, sendUseWithTarget: null, @@ -331,7 +341,7 @@ public class ToolbarControllerTests ToolbarController.Bind( layout, repo, - () => Array.Empty(), + new ShortcutStore(), iconIds: static (_, _, _, _, _) => 0u, useItem: static _ => { }, itemInteraction: interaction, @@ -370,6 +380,7 @@ public class ToolbarControllerTests var messages = new List(); using var interaction = new ItemInteractionController( repo, + new InventoryTransactionState(repo), playerGuid: () => player, sendUse: null, sendUseWithTarget: null, @@ -379,7 +390,7 @@ public class ToolbarControllerTests ToolbarController.Bind( layout, repo, - () => Array.Empty(), + new ShortcutStore(), iconIds: static (_, _, _, _, _) => 0u, useItem: static _ => { }, itemInteraction: interaction, @@ -411,7 +422,7 @@ public class ToolbarControllerTests repo.AddOrUpdate(new ClientObject { ObjectId = item, Type = ItemType.Misc }); var puts = new List<(uint Item, uint Container, int Placement)>(); ToolbarController.Bind(layout, repo, - () => Array.Empty(), + new ShortcutStore(), iconIds: (_, _, _, _, _) => 0u, useItem: _ => { }, playerGuid: () => player, @@ -432,7 +443,7 @@ public class ToolbarControllerTests var (layout, _, _) = FakeToolbar(); var repo = new ClientObjectTable(); var ctrl = ToolbarController.Bind(layout, repo, - () => Array.Empty(), + new ShortcutStore(), iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }); var inventoryButton = (UiButton)layout.FindElement(InventoryButtonId)!; var characterButton = (UiButton)layout.FindElement(CharacterButtonId)!; @@ -498,6 +509,7 @@ public class ToolbarControllerTests uint selected = item; var interaction = new ItemInteractionController( repo, + new InventoryTransactionState(repo), () => player, sendUse: uses.Add, sendUseWithTarget: null, @@ -507,7 +519,7 @@ public class ToolbarControllerTests ToolbarController.Bind( layout, repo, - () => Array.Empty(), + new ShortcutStore(), iconIds: (_, _, _, _, _) => 0u, useItem: _ => { }, itemInteraction: interaction, @@ -555,6 +567,7 @@ public class ToolbarControllerTests var selection = new SelectionState(); using var interaction = new ItemInteractionController( repo, + new InventoryTransactionState(repo), () => player, sendUse: null, sendUseWithTarget: null, @@ -563,7 +576,7 @@ public class ToolbarControllerTests using var controller = ToolbarController.Bind( layout, repo, - () => Array.Empty(), + new ShortcutStore(), iconIds: (_, _, _, _, _) => 0u, useItem: _ => { }, itemInteraction: interaction, @@ -619,6 +632,7 @@ public class ToolbarControllerTests var wields = new List<(uint Item, uint Location)>(); using var interaction = new ItemInteractionController( repo, + new InventoryTransactionState(repo), () => player, sendUse: null, sendUseWithTarget: null, @@ -627,7 +641,7 @@ public class ToolbarControllerTests using var controller = ToolbarController.Bind( layout, repo, - () => Array.Empty(), + new ShortcutStore(), iconIds: (_, _, _, _, _) => 0u, useItem: _ => { }, itemInteraction: interaction, @@ -670,7 +684,7 @@ public class ToolbarControllerTests ToolbarController.Bind( layout, repo, - () => Array.Empty(), + new ShortcutStore(), iconIds: (_, _, _, _, _) => 0u, useItem: _ => { }, playerGuid: () => player); @@ -709,7 +723,7 @@ public class ToolbarControllerTests ToolbarController.Bind( layout, repo, - () => Array.Empty(), + new ShortcutStore(), iconIds: (_, _, _, _, _) => 0u, useItem: _ => { }, playerGuid: () => player); @@ -731,7 +745,7 @@ public class ToolbarControllerTests ToolbarController.Bind( layout, repo, - () => Array.Empty(), + new ShortcutStore(), iconIds: (_, _, _, _, _) => 0u, useItem: _ => { }, toggleCombat: () => toggles++); @@ -758,7 +772,7 @@ public class ToolbarControllerTests var controller = ToolbarController.Bind( layout, repo, - () => shortcuts, + Store(shortcuts), iconIds: (_, _, _, _, _) => 1u, useItem: id => used = id, selectItem: id => selected = id); @@ -784,6 +798,7 @@ public class ToolbarControllerTests var appraisals = new List(); using var interaction = new ItemInteractionController( repo, + new InventoryTransactionState(repo), playerGuid: () => player, sendUse: null, sendUseWithTarget: null, @@ -793,7 +808,7 @@ public class ToolbarControllerTests using var controller = ToolbarController.Bind( layout, repo, - () => shortcuts, + Store(shortcuts), iconIds: (_, _, _, _, _) => 1u, useItem: _ => { }, itemInteraction: interaction, @@ -832,6 +847,7 @@ public class ToolbarControllerTests uint sentTarget = 0; var interaction = new ItemInteractionController( repo, + new InventoryTransactionState(repo), () => player, sendUse: null, sendUseWithTarget: (s, t) => (sentSource, sentTarget) = (s, t), @@ -845,7 +861,7 @@ public class ToolbarControllerTests var controller = ToolbarController.Bind( layout, repo, - () => shortcuts, + Store(shortcuts), iconIds: (_, _, _, _, _) => 1u, useItem: _ => { }, itemInteraction: interaction); @@ -873,6 +889,7 @@ public class ToolbarControllerTests repo.MoveItem(item, pack, 0); var interaction = new ItemInteractionController( repo, + new InventoryTransactionState(repo), () => player, sendUse: null, sendUseWithTarget: null, @@ -882,7 +899,7 @@ public class ToolbarControllerTests var controller = ToolbarController.Bind( layout, repo, - () => Array.Empty(), + new ShortcutStore(), iconIds: (_, _, _, _, _) => 1u, useItem: _ => { }, itemInteraction: interaction, @@ -908,7 +925,7 @@ public class ToolbarControllerTests var repo = new ClientObjectTable(); ToolbarController.Bind(layout, repo, - () => Array.Empty(), + new ShortcutStore(), iconIds: (_,_,_,_,_) =>0u, useItem: _ => { }); // Only peace indicator (index 0 = 0x10000192) is visible. @@ -928,7 +945,7 @@ public class ToolbarControllerTests var repo = new ClientObjectTable(); var ctrl = ToolbarController.Bind(layout, repo, - () => Array.Empty(), + new ShortcutStore(), iconIds: (_,_,_,_,_) =>0u, useItem: _ => { }); ctrl.SetCombatMode(CombatMode.Melee); @@ -950,7 +967,7 @@ public class ToolbarControllerTests var combat = new CombatState(); ToolbarController.Bind(layout, repo, - () => Array.Empty(), + new ShortcutStore(), iconIds: (_,_,_,_,_) =>0u, useItem: _ => { }, combatState: combat); @@ -989,7 +1006,7 @@ public class ToolbarControllerTests var repo = new ClientObjectTable(); ToolbarController.Bind(layout, repo, - () => Array.Empty(), + new ShortcutStore(), iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }, regularDigits: FakeRegular, ghostedDigits: FakeGhosted); @@ -1018,7 +1035,7 @@ public class ToolbarControllerTests var (layout, slots, _) = FakeToolbar(); var repo = new ClientObjectTable(); var ctrl = ToolbarController.Bind(layout, repo, - () => Array.Empty(), + new ShortcutStore(), iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }, regularDigits: FakeRegular, ghostedDigits: FakeGhosted); @@ -1046,7 +1063,7 @@ public class ToolbarControllerTests var (layout, slots, _) = FakeToolbar(); var repo = new ClientObjectTable(); var ctrl = ToolbarController.Bind(layout, repo, - () => Array.Empty(), + new ShortcutStore(), iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }, regularDigits: FakeRegular, ghostedDigits: FakeGhosted); @@ -1078,7 +1095,7 @@ public class ToolbarControllerTests var repo = new ClientObjectTable(); ToolbarController.Bind(layout, repo, - () => Array.Empty(), + new ShortcutStore(), iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }, regularDigits: FakeRegular, ghostedDigits: FakeGhosted); @@ -1100,7 +1117,7 @@ public class ToolbarControllerTests var repo = new ClientObjectTable(); ToolbarController.Bind(layout, repo, - () => Array.Empty(), + new ShortcutStore(), iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }, regularDigits: FakeRegular, ghostedDigits: FakeGhosted, emptyDigits: FakeEmpty); @@ -1121,7 +1138,7 @@ public class ToolbarControllerTests var repo = new ClientObjectTable(); ToolbarController.Bind(layout, repo, - () => Array.Empty(), + new ShortcutStore(), iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }, regularDigits: FakeRegular, ghostedDigits: FakeGhosted, emptyDigits: null); @@ -1147,7 +1164,7 @@ public class ToolbarControllerTests { new(Index: 0, ObjectId: 0x5001u, SpellId: 0) }; int iconCallCount = 0; - ToolbarController.Bind(layout, repo, () => shortcuts, + ToolbarController.Bind(layout, repo, Store(shortcuts), iconIds: (_,_,_,_,_) => { iconCallCount++; return 0x77u; }, useItem: _ => { }); int callsAfterBind = iconCallCount; // 1 call from initial Populate @@ -1172,7 +1189,7 @@ public class ToolbarControllerTests { new(Index: 1, ObjectId: 0x5003u, SpellId: 0) }; int iconCallCount = 0; - ToolbarController.Bind(layout, repo, () => shortcuts, + ToolbarController.Bind(layout, repo, Store(shortcuts), iconIds: (_,_,_,_,_) => { iconCallCount++; return 0x99u; }, useItem: _ => { }); Assert.Equal(0, iconCallCount); // not called — item absent during initial Populate @@ -1198,7 +1215,7 @@ public class ToolbarControllerTests var shortcuts = new List { new(Index: 3, ObjectId: 0x5004u, SpellId: 0) }; - ToolbarController.Bind(layout, repo, () => shortcuts, + ToolbarController.Bind(layout, repo, Store(shortcuts), iconIds: (_,_,_,_,_) => 0xAAu, useItem: _ => { }); Assert.Equal(0x5004u, slots[Row1[3]].Cell.ItemId); // bound @@ -1225,7 +1242,7 @@ public class ToolbarControllerTests { new(Index: 4, ObjectId: 0x5005u, SpellId: 0) }; int iconCallCount = 0; - ToolbarController.Bind(layout, repo, () => shortcuts, + ToolbarController.Bind(layout, repo, Store(shortcuts), iconIds: (_,_,_,_,_) => { iconCallCount++; return 0xBBu; }, useItem: _ => { }); int callsAfterBind = iconCallCount; // 1 call for the shortcut item @@ -1247,7 +1264,7 @@ public class ToolbarControllerTests var repo = new ClientObjectTable(); var ctrl = ToolbarController.Bind(layout, repo, - () => Array.Empty(), + new ShortcutStore(), iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }); for (int i = 0; i < Row1.Length; i++) @@ -1272,7 +1289,7 @@ public class ToolbarControllerTests { var (layout, slots, _) = FakeToolbar(); var ctrl = ToolbarController.Bind(layout, new ClientObjectTable(), - () => Array.Empty(), + new ShortcutStore(), iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }); var list = slots[Row1[0]]; @@ -1287,7 +1304,7 @@ public class ToolbarControllerTests { var (layout, slots, _) = FakeToolbar(); var ctrl = ToolbarController.Bind(layout, new ClientObjectTable(), - () => Array.Empty(), + new ShortcutStore(), iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }); var list = slots[Row1[0]]; var payload = new ItemDragPayload(0u, ItemDragSource.Inventory, 0, new UiItemSlot()); @@ -1316,7 +1333,7 @@ public class ToolbarControllerTests var (adds, removes) = NewSpies(out var add, out var rem); uint selected = 0; - var ctrl = ToolbarController.Bind(layout, repo, () => shortcuts, + var ctrl = ToolbarController.Bind(layout, repo, Store(shortcuts), iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { }, sendAddShortcut: add, sendRemoveShortcut: rem, selectItem: item => selected = item, selectedObjectId: () => selected); @@ -1341,7 +1358,7 @@ public class ToolbarControllerTests { new(Index: 3, ObjectId: 0x5001u, SpellId: 0), new(Index: 5, ObjectId: 0x5002u, SpellId: 0) }; var (adds, removes) = NewSpies(out var add, out var rem); - var ctrl = ToolbarController.Bind(layout, repo, () => shortcuts, + var ctrl = ToolbarController.Bind(layout, repo, Store(shortcuts), iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { }, sendAddShortcut: add, sendRemoveShortcut: rem); @@ -1370,7 +1387,7 @@ public class ToolbarControllerTests }; var (adds, _) = NewSpies(out var add, out var remove); var controller = ToolbarController.Bind( - layout, repo, () => shortcuts, + layout, repo, Store(shortcuts), iconIds: (_, _, _, _, _) => 1u, useItem: _ => { }, sendAddShortcut: add, @@ -1393,7 +1410,7 @@ public class ToolbarControllerTests var shortcuts = new System.Collections.Generic.List { new(Index: 3, ObjectId: 0x5001u, SpellId: 0) }; var (adds, removes) = NewSpies(out var add, out var rem); - var ctrl = ToolbarController.Bind(layout, repo, () => shortcuts, + var ctrl = ToolbarController.Bind(layout, repo, Store(shortcuts), iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { }, sendAddShortcut: add, sendRemoveShortcut: rem); @@ -1419,7 +1436,7 @@ public class ToolbarControllerTests new ShortcutEntry(5, 0x5002u, 0xA5C31234u), }; var wire = new List(); - var ctrl = ToolbarController.Bind(layout, repo, () => shortcuts, + var ctrl = ToolbarController.Bind(layout, repo, Store(shortcuts), iconIds: (_, _, _, _, _) => 1u, useItem: _ => { }, sendAddShortcut: entry => wire.Add($"add:{entry.Index}:{entry.ObjectId:X8}:{entry.SpellId:X8}"), @@ -1452,7 +1469,7 @@ public class ToolbarControllerTests new ShortcutEntry(4, 0x5001u, 0x11223344u), }; var wire = new List(); - var ctrl = ToolbarController.Bind(layout, repo, () => shortcuts, + var ctrl = ToolbarController.Bind(layout, repo, Store(shortcuts), iconIds: (_, _, _, _, _) => 1u, useItem: _ => { }, sendAddShortcut: entry => wire.Add($"add:{entry.Index}:{entry.ObjectId:X8}:{entry.SpellId:X8}"), @@ -1477,7 +1494,7 @@ public class ToolbarControllerTests var shortcuts = new System.Collections.Generic.List { new(Index: 3, ObjectId: 0x5001u, SpellId: 0) }; var (adds, removes) = NewSpies(out var add, out var rem); - var ctrl = ToolbarController.Bind(layout, repo, () => shortcuts, + var ctrl = ToolbarController.Bind(layout, repo, Store(shortcuts), iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { }, sendAddShortcut: add, sendRemoveShortcut: rem); @@ -1496,7 +1513,7 @@ public class ToolbarControllerTests { var (layout, slots, _) = FakeToolbar(); ToolbarController.Bind(layout, new ClientObjectTable(), - () => System.Array.Empty(), + new ShortcutStore(), iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }); Assert.Equal(0x060011FAu, slots[Row1[0]].Cell.DragAcceptSprite); // green cross, not the ring F9 } diff --git a/tests/AcDream.App.Tests/UI/RetailItemConfirmationControllerTests.cs b/tests/AcDream.App.Tests/UI/RetailItemConfirmationControllerTests.cs index 385b6b1c..c7527f5f 100644 --- a/tests/AcDream.App.Tests/UI/RetailItemConfirmationControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/RetailItemConfirmationControllerTests.cs @@ -18,6 +18,7 @@ public sealed class RetailItemConfirmationControllerTests var uses = new List(); var items = new ItemInteractionController( objects, + new InventoryTransactionState(objects), playerGuid: () => Player, sendUse: uses.Add, sendUseWithTarget: null, @@ -52,6 +53,7 @@ public sealed class RetailItemConfirmationControllerTests var uses = new List(); var items = new ItemInteractionController( objects, + new InventoryTransactionState(objects), playerGuid: () => Player, sendUse: uses.Add, sendUseWithTarget: null, @@ -80,6 +82,7 @@ public sealed class RetailItemConfirmationControllerTests var messages = new List(); var items = new ItemInteractionController( objects, + new InventoryTransactionState(objects), playerGuid: () => Player, sendUse: uses.Add, sendUseWithTarget: null, diff --git a/tests/AcDream.App.Tests/UI/RetailUiInteractionFlowTests.cs b/tests/AcDream.App.Tests/UI/RetailUiInteractionFlowTests.cs index b9cd46aa..9f4409fa 100644 --- a/tests/AcDream.App.Tests/UI/RetailUiInteractionFlowTests.cs +++ b/tests/AcDream.App.Tests/UI/RetailUiInteractionFlowTests.cs @@ -163,6 +163,7 @@ public sealed class RetailUiInteractionFlowTests { var interaction = new ItemInteractionController( Objects, + new InventoryTransactionState(Objects), playerGuid: () => Player, sendUse: Uses.Add, sendUseWithTarget: (source, target) => UseWithTarget.Add((source, target)), @@ -197,6 +198,7 @@ public sealed class RetailUiInteractionFlowTests { itemInteraction ??= new ItemInteractionController( Objects, + new InventoryTransactionState(Objects), () => Player, sendUse: null, sendUseWithTarget: null, diff --git a/tests/AcDream.Core.Tests/Items/ShortcutStoreTests.cs b/tests/AcDream.Core.Tests/Items/ShortcutStoreTests.cs index c927daee..0babca1e 100644 --- a/tests/AcDream.Core.Tests/Items/ShortcutStoreTests.cs +++ b/tests/AcDream.Core.Tests/Items/ShortcutStoreTests.cs @@ -41,4 +41,41 @@ public class ShortcutStoreTests store.SetItem(18, 1u); // no-op, no throw store.Remove(-1); // no-op, no throw } + + [Fact] + public void RevisionObserversAndDisposalTrackTheOneCanonicalSlotMap() + { + var store = new ShortcutStore(); + int observed = 0; + store.Changed += () => throw new InvalidOperationException("observer"); + store.Changed += () => observed++; + + store.Load([new ShortcutEntry(2, 0x5001u, 0u)]); + + Assert.Equal(1, observed); + Assert.Equal(1, store.Revision); + Assert.Equal(1, store.DispatchFailureCount); + Assert.Equal(2, store.SubscriberCount); + Assert.Equal(1, store.Count); + + store.Set(new ShortcutEntry(2, 0x5001u, 0u)); + Assert.Equal(1, store.Revision); + Assert.Equal(1, observed); + + store.Set(new ShortcutEntry(2, 0x5002u, 0u)); + Assert.Equal(2, store.Revision); + Assert.Equal(2, observed); + + store.Dispose(); + store.Dispose(); + + Assert.True(store.IsDisposed); + Assert.Empty(store.Items); + Assert.Equal(0, store.SubscriberCount); + Assert.Equal(3, observed); + Assert.Equal(3, store.Revision); + Assert.Equal(3, store.DispatchFailureCount); + Assert.Throws( + () => store.SetItem(1, 1u)); + } } diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterStateTests.cs index 7856bed2..ffce2c8d 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterStateTests.cs @@ -115,7 +115,7 @@ public sealed class RuntimeCharacterStateTests } [Fact] - public void DisposalRetriesObserverFailuresAndClearsBothOwners() + public void DisposalReportsObserverFailuresAfterTerminalConvergence() { var state = new RuntimeCharacterState(); state.Spellbook.OnSpellLearned(7u); @@ -143,13 +143,13 @@ public sealed class RuntimeCharacterStateTests state.Dispose); Assert.Equal(2, error.InnerExceptions.Count); - Assert.False(state.IsDisposed); + Assert.True(state.IsDisposed); Assert.False(state.Spellbook.Knows(7u)); Assert.Null(state.LocalPlayer.Get( AcDream.Core.Player.LocalPlayerState.VitalKind.Health)); + Assert.True(state.CaptureOwnership().IsConverged); state.Dispose(); - state.Dispose(); Assert.True(state.IsDisposed); } @@ -254,4 +254,77 @@ public sealed class RuntimeCharacterStateTests Assert.True(first.View.Snapshot.SpellbookRevision > 0); Assert.Equal(0, second.View.Snapshot.SpellbookRevision); } + + [Fact] + public void SpellbookCommandsFollowRetailLocalAndOutboundOrder() + { + using var state = new RuntimeCharacterState(); + var order = new List(); + state.Spellbook.SpellbookChanged += () => order.Add("local"); + state.Spellbook.DesiredComponentsChanged += () => order.Add("local"); + + Assert.True(state.TryAddFavorite( + 0, + 0, + 42u, + () => order.Add("send"))); + Assert.Equal(["local", "send"], order); + Assert.Equal([42u], state.Spellbook.GetFavorites(0)); + + order.Clear(); + state.SetSpellbookFilter(0x3FFEu, () => order.Add("send")); + Assert.Equal(["local", "send"], order); + Assert.Equal(0x3FFEu, state.Spellbook.SpellbookFilters); + + order.Clear(); + Assert.True(state.TrySetDesiredComponent( + 0x68000001u, + 0u, + () => order.Add("send"))); + Assert.Equal(["send", "local"], order); + Assert.True(state.Spellbook.DesiredComponents.ContainsKey( + 0x68000001u)); + Assert.Equal(0u, state.Spellbook.DesiredComponents[0x68000001u]); + + order.Clear(); + state.ClearDesiredComponents(() => order.Add("send")); + Assert.Equal(["send", "local"], order); + Assert.Empty(state.Spellbook.DesiredComponents); + + Assert.Throws( + () => state.TrySetDesiredComponent( + 0x68000002u, + 10u, + () => throw new InvalidOperationException("transport"))); + Assert.Equal(10u, state.Spellbook.DesiredComponents[0x68000002u]); + } + + [Fact] + public void InvalidSpellbookCommandsDoNotPublishOrMutate() + { + using var state = new RuntimeCharacterState(); + int sends = 0; + + Assert.False(state.TryAddFavorite( + 8, + 0, + 42u, + () => sends++)); + Assert.False(state.TryRemoveFavorite( + -1, + 42u, + () => sends++)); + Assert.False(state.TrySetDesiredComponent( + 0u, + 1u, + () => sends++)); + Assert.False(state.TrySetDesiredComponent( + 1u, + 5001u, + () => sends++)); + + Assert.Equal(0, sends); + Assert.Empty(state.Spellbook.GetFavorites(0)); + Assert.Empty(state.Spellbook.DesiredComponents); + } } diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeGameplayOwnershipTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeGameplayOwnershipTests.cs new file mode 100644 index 00000000..16b3b344 --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeGameplayOwnershipTests.cs @@ -0,0 +1,143 @@ +using AcDream.Core.Chat; +using AcDream.Core.Items; +using AcDream.Core.Social; +using AcDream.Runtime.Entities; +using AcDream.Runtime.Gameplay; + +namespace AcDream.Runtime.Tests.Gameplay; + +public sealed class RuntimeGameplayOwnershipTests +{ + [Fact] + public void CombinedLedgerConvergesEveryJ4OwnerAndSubscription() + { + using var entities = new RuntimeEntityObjectLifetime(); + var inventory = new RuntimeInventoryState(entities); + var character = new RuntimeCharacterState(); + var communication = new RuntimeCommunicationState(); + inventory.Shortcuts.Changed += static () => { }; + inventory.Shortcuts.Load([new ShortcutEntry(1, 2u, 3u)]); + inventory.ItemMana.OnQueryItemManaResponse(2u, 0.5f, true); + inventory.ExternalContainers.RequestOpen(3u); + inventory.ExternalContainers.ApplyViewContents(3u); + inventory.Transactions.IncrementBusyCount(); + + character.Spellbook.OnSpellLearned(4u); + character.Spellbook.SetFavorite(0, 0, 4u); + character.Spellbook.SetDesiredComponent(5u, 6u); + character.LocalPlayer.OnVitalUpdate(7u, 1u, 2u, 3u, 4u); + character.LocalPlayer.OnAttributeUpdate(1u, 2u, 3u, 4u); + character.LocalPlayer.OnSkillUpdate( + 6u, 1u, 2u, 3u, 4u, 5u, 6d, 7u); + character.Options.Replace(1u, 2u); + character.MovementSkills.Update(3, 4); + + communication.Chat.OnTellReceived( + "Sender", + "hello", + 0x50000001u); + communication.Chat.OnSelfSent( + ChatKind.Tell, + "reply", + "Recipient"); + communication.TurbineChat.OnChannelsReceived( + 1u, 2u, 3u, 4u, 5u, 6u, 7u, 8u, 9u, 10u); + communication.Friends.Apply(new FriendsUpdate( + FriendsUpdateType.Full, + [new FriendEntry(1u, "Friend", true, false, [], [])])); + communication.Squelch.Replace(new SquelchDatabase( + new Dictionary { ["account"] = 1u }, + new Dictionary + { + [2u] = new SquelchInfo( + "Character", + false, + new HashSet { 3u }), + }, + new SquelchInfo( + string.Empty, + false, + new HashSet { 4u }))); + IDisposable subscription = + communication.Events.Subscribe(new NoopObserver()); + + RuntimeGameplayOwnershipSnapshot populated = + RuntimeGameplayOwnership.Capture( + inventory, + character, + communication); + + Assert.False(populated.IsConverged); + Assert.Equal(1, populated.Inventory.ShortcutCount); + Assert.Equal(1, populated.Character.LearnedSpellCount); + Assert.Equal(1, populated.Communication.StreamSubscriberCount); + Assert.True(populated.Communication.HasReplyTarget); + Assert.True(populated.Communication.HasRetellTarget); + + communication.Dispose(); + character.Dispose(); + inventory.Dispose(); + subscription.Dispose(); + + RuntimeGameplayOwnershipSnapshot retired = + RuntimeGameplayOwnership.Capture( + inventory, + character, + communication); + + Assert.True(retired.IsConverged); + Assert.Equal(0, retired.Inventory.ShortcutSubscriberCount); + Assert.False(retired.Character.InternalSubscriptionsAttached); + Assert.Equal(0, retired.Communication.StreamSubscriberCount); + Assert.Equal(0, retired.Communication.PendingDispatchCount); + } + + [Fact] + public void BorrowerFailuresCannotStrandAnyJ4OwnerDuringTerminalDisposal() + { + using var entities = new RuntimeEntityObjectLifetime(); + var inventory = new RuntimeInventoryState(entities); + var character = new RuntimeCharacterState(); + var communication = new RuntimeCommunicationState(); + + inventory.ExternalContainers.RequestOpen(0x70000001u); + inventory.ExternalContainers.ApplyViewContents(0x70000001u); + inventory.ExternalContainers.Changed += static _ => + throw new InvalidOperationException("inventory observer"); + + character.Spellbook.OnSpellLearned(42u); + character.LocalPlayer.OnVitalUpdate(7u, 1u, 2u, 3u, 4u); + character.Spellbook.SpellbookChanged += static () => + throw new InvalidOperationException("spellbook observer"); + character.LocalPlayer.CharacterChanged += static () => + throw new InvalidOperationException("character observer"); + + _ = communication.Events.Subscribe(new ThrowingObserver()); + communication.Chat.OnSystemMessage("failure-isolated event", 0u); + Assert.Equal(1, communication.DispatchFailureCount); + + Assert.Throws(inventory.Dispose); + Assert.Throws(character.Dispose); + communication.Dispose(); + + RuntimeGameplayOwnershipSnapshot retired = + RuntimeGameplayOwnership.Capture( + inventory, + character, + communication); + + Assert.True(retired.IsConverged); + Assert.Equal(1, retired.Communication.DispatchFailureCount); + } + + private sealed class NoopObserver : IRuntimeCommunicationObserver + { + public void OnChat(in RuntimeCommunicationEvent delta) { } + } + + private sealed class ThrowingObserver : IRuntimeCommunicationObserver + { + public void OnChat(in RuntimeCommunicationEvent delta) => + throw new InvalidOperationException("communication observer"); + } +} diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeInventoryStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeInventoryStateTests.cs index 8d7c6cce..25681f7d 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeInventoryStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeInventoryStateTests.cs @@ -11,7 +11,7 @@ public sealed class RuntimeInventoryStateTests { using var entities = new RuntimeEntityObjectLifetime(); using var inventory = new RuntimeInventoryState(entities); - inventory.Shortcuts.Replace( + inventory.Shortcuts.Load( [ new AcDream.Core.Items.ShortcutEntry(3, 0x80000001u, 0u), new AcDream.Core.Items.ShortcutEntry(4, 0u, 42u), @@ -55,12 +55,12 @@ public sealed class RuntimeInventoryStateTests inventory.ExternalContainers.RequestOpen(0x70000001u); inventory.ExternalContainers.ApplyViewContents(0x70000001u); inventory.ItemMana.OnQueryItemManaResponse(0x50000001u, 0.75f, true); - inventory.Shortcuts.Replace(shortcuts); + inventory.Shortcuts.Load(shortcuts); 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.Equal(shortcuts, inventory.Shortcuts.Items); Assert.Equal(1, inventory.Shortcuts.Revision); } @@ -72,7 +72,7 @@ public sealed class RuntimeInventoryStateTests inventory.ExternalContainers.RequestOpen(0x70000001u); inventory.ExternalContainers.ApplyViewContents(0x70000001u); inventory.ItemMana.OnQueryItemManaResponse(0x50000001u, 0.75f, true); - inventory.Shortcuts.Replace([new ShortcutEntry(1, 2u, 3u)]); + inventory.Shortcuts.Load([new ShortcutEntry(1, 2u, 3u)]); inventory.Transactions.IncrementBusyCount(); inventory.ResetExternalContainer(); @@ -98,7 +98,7 @@ public sealed class RuntimeInventoryStateTests using var first = new RuntimeInventoryState(firstEntities); using var second = new RuntimeInventoryState(secondEntities); - first.Shortcuts.Replace([new ShortcutEntry(1, 2u, 3u)]); + first.Shortcuts.Load([new ShortcutEntry(1, 2u, 3u)]); first.Transactions.IncrementBusyCount(); Assert.NotSame(first.Objects, second.Objects); @@ -116,7 +116,7 @@ public sealed class RuntimeInventoryStateTests inventory.ExternalContainers.RequestOpen(0x70000001u); inventory.ExternalContainers.ApplyViewContents(0x70000001u); inventory.ItemMana.OnQueryItemManaResponse(0x50000001u, 0.5f, true); - inventory.Shortcuts.Replace([new ShortcutEntry(1, 2u, 3u)]); + inventory.Shortcuts.Load([new ShortcutEntry(1, 2u, 3u)]); inventory.Dispose(); inventory.Dispose(); @@ -129,7 +129,7 @@ public sealed class RuntimeInventoryStateTests } [Fact] - public void DisposalRetriesATransientOwnerObserverFailureToConvergence() + public void DisposalFailureIsReportedAfterTerminalOwnerConvergence() { using var entities = new RuntimeEntityObjectLifetime(); var inventory = new RuntimeInventoryState(entities); @@ -146,12 +146,72 @@ public sealed class RuntimeInventoryStateTests }; Assert.Throws(inventory.Dispose); - Assert.False(inventory.IsDisposed); + Assert.True(inventory.IsDisposed); Assert.True(inventory.Transactions.IsDisposed); + inventory.Dispose(); + + Assert.True(inventory.CaptureOwnership().IsConverged); + Assert.Equal(0u, inventory.ExternalContainers.CurrentContainerId); + } + + [Fact] + public void ShortcutCommandsUseRetailSendThenLocalOrderOnTheExactOwner() + { + using var entities = new RuntimeEntityObjectLifetime(); + using var inventory = new RuntimeInventoryState(entities); + var order = new List(); + inventory.Shortcuts.Changed += () => order.Add("local"); + var entry = new ShortcutEntry(3, 0x80000001u, 0u); + + Assert.True(inventory.TryAddShortcut( + entry, + () => + { + Assert.Null(inventory.Shortcuts.GetEntry(3)); + order.Add("send"); + })); + Assert.Equal(["send", "local"], order); + Assert.Equal(entry, inventory.Shortcuts.GetEntry(3)); + + order.Clear(); + Assert.True(inventory.TryRemoveShortcut( + 3, + () => + { + Assert.Equal(entry, inventory.Shortcuts.GetEntry(3)); + order.Add("send"); + })); + Assert.Equal(["send", "local"], order); + Assert.Null(inventory.Shortcuts.GetEntry(3)); + + Assert.Throws( + () => inventory.TryAddShortcut( + entry, + () => throw new InvalidOperationException("transport"))); + Assert.Equal(entry, inventory.Shortcuts.GetEntry(3)); + + int sends = 0; + Assert.False(inventory.TryAddShortcut( + new ShortcutEntry(ShortcutStore.SlotCount, 1u, 0u), + () => sends++)); + Assert.False(inventory.TryRemoveShortcut(-1, () => sends++)); + Assert.Equal(0, sends); + } + + [Fact] + public void DisposalRetiresShortcutObserversAndTransactionSubscriptions() + { + using var entities = new RuntimeEntityObjectLifetime(); + var inventory = new RuntimeInventoryState(entities); + inventory.Shortcuts.Changed += static () => { }; + inventory.Shortcuts.Load([new ShortcutEntry(1, 2u, 3u)]); inventory.Dispose(); Assert.True(inventory.IsDisposed); - Assert.Equal(0u, inventory.ExternalContainers.CurrentContainerId); + Assert.True(inventory.Shortcuts.IsDisposed); + Assert.True(inventory.Transactions.IsDisposed); + Assert.Equal(0, inventory.Shortcuts.SubscriberCount); + Assert.Empty(inventory.Shortcuts.Items); } }