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 <codex@openai.com>
This commit is contained in:
Erik 2026-07-26 09:48:51 +02:00
parent ce6fae7b38
commit 89e6b207f8
36 changed files with 1433 additions and 251 deletions

View file

@ -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.

View file

@ -285,6 +285,7 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
DeferredSelectionUiAuthority selection = late.Selection; DeferredSelectionUiAuthority selection = late.Selection;
return new ItemInteractionController( return new ItemInteractionController(
d.Inventory.Objects, d.Inventory.Objects,
d.Inventory.Transactions,
playerGuid: () => d.PlayerIdentity.ServerGuid, playerGuid: () => d.PlayerIdentity.ServerGuid,
sendUse: null, sendUse: null,
sendExamine: guid => session.CurrentSession?.SendAppraise(guid), sendExamine: guid => session.CurrentSession?.SendAppraise(guid),
@ -334,8 +335,7 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
{ {
d.Inventory.ExternalContainers.RequestOpen(guid); d.Inventory.ExternalContainers.RequestOpen(guid);
}, },
requestUse: selection.RequestUse, requestUse: selection.RequestUse);
transactions: d.Inventory.Transactions);
} }
public RetainedUiComposition CreateRetainedUi( public RetainedUiComposition CreateRetainedUi(
@ -592,7 +592,7 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
d.Window.Close), d.Window.Close),
Toolbar: new ToolbarRuntimeBindings( Toolbar: new ToolbarRuntimeBindings(
d.Inventory.Objects, d.Inventory.Objects,
() => d.Inventory.Shortcuts.Items, d.Inventory.Shortcuts,
iconComposer.GetIcon, iconComposer.GetIcon,
iconComposer.GetDragIcon, iconComposer.GetDragIcon,
guid => late.Session.TryUseItem(guid, d.Log), guid => late.Session.TryUseItem(guid, d.Log),

View file

@ -254,7 +254,7 @@ internal sealed class LiveSessionRuntimeFactory
private LiveInventorySessionBindings CreateInventoryBindings() => new( private LiveInventorySessionBindings CreateInventoryBindings() => new(
_domain.Inventory.Objects, _domain.Inventory.Objects,
PlayerGuid: () => _player.Identity.ServerGuid, PlayerGuid: () => _player.Identity.ServerGuid,
OnShortcuts: _domain.Inventory.Shortcuts.Replace, OnShortcuts: _domain.Inventory.Shortcuts.Load,
OnUseDone: error => OnUseDone: error =>
{ {
_domain.Inventory.ExternalContainers.ApplyUseDone(error); _domain.Inventory.ExternalContainers.ApplyUseDone(error);

View file

@ -71,6 +71,8 @@ internal sealed class CurrentGameRuntimeAdapter
sessionHost, sessionHost,
commands, commands,
_view, _view,
inventory,
character,
selectionState, selectionState,
selection, selection,
gameplayInput, gameplayInput,

View file

@ -5,6 +5,7 @@ using AcDream.App.Net;
using AcDream.Core.Selection; using AcDream.Core.Selection;
using AcDream.Core.Items; using AcDream.Core.Items;
using AcDream.Runtime; using AcDream.Runtime;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Session; using AcDream.Runtime.Session;
using AcDream.UI.Abstractions; using AcDream.UI.Abstractions;
using AcDream.UI.Abstractions.Input; using AcDream.UI.Abstractions.Input;
@ -31,6 +32,8 @@ internal sealed class CurrentGameRuntimeCommandAdapter
private readonly LiveSessionHost _sessionHost; private readonly LiveSessionHost _sessionHost;
private readonly ICommandBus _commands; private readonly ICommandBus _commands;
private readonly CurrentGameRuntimeViewAdapter _view; private readonly CurrentGameRuntimeViewAdapter _view;
private readonly RuntimeInventoryState _inventory;
private readonly RuntimeCharacterState _character;
private readonly SelectionState _selectionState; private readonly SelectionState _selectionState;
private readonly SelectionInteractionController _selection; private readonly SelectionInteractionController _selection;
private readonly GameplayInputFrameController _gameplayInput; private readonly GameplayInputFrameController _gameplayInput;
@ -42,6 +45,8 @@ internal sealed class CurrentGameRuntimeCommandAdapter
LiveSessionHost sessionHost, LiveSessionHost sessionHost,
ICommandBus commands, ICommandBus commands,
CurrentGameRuntimeViewAdapter view, CurrentGameRuntimeViewAdapter view,
RuntimeInventoryState inventory,
RuntimeCharacterState character,
SelectionState selectionState, SelectionState selectionState,
SelectionInteractionController selection, SelectionInteractionController selection,
GameplayInputFrameController gameplayInput, GameplayInputFrameController gameplayInput,
@ -52,6 +57,8 @@ internal sealed class CurrentGameRuntimeCommandAdapter
_sessionHost = sessionHost ?? throw new ArgumentNullException(nameof(sessionHost)); _sessionHost = sessionHost ?? throw new ArgumentNullException(nameof(sessionHost));
_commands = commands ?? throw new ArgumentNullException(nameof(commands)); _commands = commands ?? throw new ArgumentNullException(nameof(commands));
_view = view ?? throw new ArgumentNullException(nameof(view)); _view = view ?? throw new ArgumentNullException(nameof(view));
_inventory = inventory ?? throw new ArgumentNullException(nameof(inventory));
_character = character ?? throw new ArgumentNullException(nameof(character));
_selectionState = selectionState _selectionState = selectionState
?? throw new ArgumentNullException(nameof(selectionState)); ?? throw new ArgumentNullException(nameof(selectionState));
_selection = selection ?? throw new ArgumentNullException(nameof(selection)); _selection = selection ?? throw new ArgumentNullException(nameof(selection));
@ -288,8 +295,13 @@ internal sealed class CurrentGameRuntimeCommandAdapter
RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true); RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true);
if (gate != RuntimeCommandStatus.Accepted) if (gate != RuntimeCommandStatus.Accepted)
return Result(gate); return Result(gate);
if (command.Index < 0 var entry = new ShortcutEntry(
|| (command.ObjectId == 0u && command.SpellId == 0u)) command.Index,
command.ObjectId,
command.SpellId);
if (!_inventory.TryAddShortcut(
entry,
() => _commands.Publish(new AddShortcutRuntimeCmd(entry))))
{ {
return EmitResult( return EmitResult(
RuntimeCommandDomain.InventoryState, RuntimeCommandDomain.InventoryState,
@ -298,10 +310,6 @@ internal sealed class CurrentGameRuntimeCommandAdapter
command.ObjectId); command.ObjectId);
} }
_commands.Publish(new AddShortcutRuntimeCmd(new ShortcutEntry(
command.Index,
command.ObjectId,
command.SpellId)));
return EmitResult( return EmitResult(
RuntimeCommandDomain.InventoryState, RuntimeCommandDomain.InventoryState,
operation: 0, operation: 0,
@ -316,7 +324,10 @@ internal sealed class CurrentGameRuntimeCommandAdapter
RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true); RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true);
if (gate != RuntimeCommandStatus.Accepted) if (gate != RuntimeCommandStatus.Accepted)
return Result(gate); return Result(gate);
if (index < 0) if (!_inventory.TryRemoveShortcut(
index,
() => _commands.Publish(
new RemoveShortcutRuntimeCmd((uint)index))))
{ {
return EmitResult( return EmitResult(
RuntimeCommandDomain.InventoryState, RuntimeCommandDomain.InventoryState,
@ -324,7 +335,6 @@ internal sealed class CurrentGameRuntimeCommandAdapter
RuntimeCommandStatus.Rejected); RuntimeCommandStatus.Rejected);
} }
_commands.Publish(new RemoveShortcutRuntimeCmd((uint)index));
return EmitResult( return EmitResult(
RuntimeCommandDomain.InventoryState, RuntimeCommandDomain.InventoryState,
operation: 1, operation: 1,
@ -340,9 +350,14 @@ internal sealed class CurrentGameRuntimeCommandAdapter
RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true); RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true);
if (gate != RuntimeCommandStatus.Accepted) if (gate != RuntimeCommandStatus.Accepted)
return Result(gate); return Result(gate);
if ((uint)tabIndex >= 8u if (!_character.TryAddFavorite(
|| position < 0 tabIndex,
|| spellId == 0u) position,
spellId,
() => _commands.Publish(new AddFavoriteRuntimeCmd(
spellId,
position,
tabIndex))))
{ {
return EmitResult( return EmitResult(
RuntimeCommandDomain.Spellbook, RuntimeCommandDomain.Spellbook,
@ -351,10 +366,6 @@ internal sealed class CurrentGameRuntimeCommandAdapter
spellId); spellId);
} }
_commands.Publish(new AddFavoriteRuntimeCmd(
spellId,
position,
tabIndex));
return EmitResult( return EmitResult(
RuntimeCommandDomain.Spellbook, RuntimeCommandDomain.Spellbook,
operation: 0, operation: 0,
@ -370,7 +381,11 @@ internal sealed class CurrentGameRuntimeCommandAdapter
RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true); RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true);
if (gate != RuntimeCommandStatus.Accepted) if (gate != RuntimeCommandStatus.Accepted)
return Result(gate); return Result(gate);
if ((uint)tabIndex >= 8u || spellId == 0u) if (!_character.TryRemoveFavorite(
tabIndex,
spellId,
() => _commands.Publish(
new RemoveFavoriteRuntimeCmd(spellId, tabIndex))))
{ {
return EmitResult( return EmitResult(
RuntimeCommandDomain.Spellbook, RuntimeCommandDomain.Spellbook,
@ -379,7 +394,6 @@ internal sealed class CurrentGameRuntimeCommandAdapter
spellId); spellId);
} }
_commands.Publish(new RemoveFavoriteRuntimeCmd(spellId, tabIndex));
return EmitResult( return EmitResult(
RuntimeCommandDomain.Spellbook, RuntimeCommandDomain.Spellbook,
operation: 1, operation: 1,
@ -394,7 +408,10 @@ internal sealed class CurrentGameRuntimeCommandAdapter
RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true); RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true);
if (gate != RuntimeCommandStatus.Accepted) if (gate != RuntimeCommandStatus.Accepted)
return Result(gate); return Result(gate);
_commands.Publish(new SetSpellbookFilterRuntimeCmd(filters)); _character.SetSpellbookFilter(
filters,
() => _commands.Publish(
new SetSpellbookFilterRuntimeCmd(filters)));
return EmitResult( return EmitResult(
RuntimeCommandDomain.Spellbook, RuntimeCommandDomain.Spellbook,
operation: 2, operation: 2,
@ -433,7 +450,12 @@ internal sealed class CurrentGameRuntimeCommandAdapter
RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true); RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true);
if (gate != RuntimeCommandStatus.Accepted) if (gate != RuntimeCommandStatus.Accepted)
return Result(gate); return Result(gate);
if (componentId == 0u) if (!_character.TrySetDesiredComponent(
componentId,
amount,
() => _commands.Publish(new SetDesiredComponentRuntimeCmd(
componentId,
amount))))
{ {
return EmitResult( return EmitResult(
RuntimeCommandDomain.Spellbook, RuntimeCommandDomain.Spellbook,
@ -442,9 +464,6 @@ internal sealed class CurrentGameRuntimeCommandAdapter
componentId); componentId);
} }
_commands.Publish(new SetDesiredComponentRuntimeCmd(
componentId,
amount));
return EmitResult( return EmitResult(
RuntimeCommandDomain.Spellbook, RuntimeCommandDomain.Spellbook,
operation: 4, operation: 4,
@ -458,7 +477,9 @@ internal sealed class CurrentGameRuntimeCommandAdapter
RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true); RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true);
if (gate != RuntimeCommandStatus.Accepted) if (gate != RuntimeCommandStatus.Accepted)
return Result(gate); return Result(gate);
_commands.Publish(new ClearDesiredComponentsRuntimeCmd()); _character.ClearDesiredComponents(
() => _commands.Publish(
new ClearDesiredComponentsRuntimeCmd()));
return EmitResult( return EmitResult(
RuntimeCommandDomain.Spellbook, RuntimeCommandDomain.Spellbook,
operation: 5, operation: 5,

View file

@ -117,7 +117,7 @@ public static class FixtureProvider
return ToolbarController.Bind( return ToolbarController.Bind(
layout, layout,
objects, objects,
shortcuts: () => System.Array.Empty<ShortcutEntry>(), shortcuts: new ShortcutStore(),
iconIds: MakeIconIds(stack), iconIds: MakeIconIds(stack),
useItem: _ => { }, useItem: _ => { },
combatState: null, combatState: null,
@ -141,6 +141,7 @@ public static class FixtureProvider
var selection = new AcDream.Core.Selection.SelectionState(); var selection = new AcDream.Core.Selection.SelectionState();
var itemInteraction = new ItemInteractionController( var itemInteraction = new ItemInteractionController(
objects, objects,
new InventoryTransactionState(objects),
() => SampleData.PlayerGuid, () => SampleData.PlayerGuid,
sendUse: null, sendUse: null,
sendUseWithTarget: null, sendUseWithTarget: null,

View file

@ -62,7 +62,6 @@ public sealed class ItemInteractionController : IDisposable
private readonly AutoWieldController _autoWield; private readonly AutoWieldController _autoWield;
private readonly Action<uint, ItemUseRequestReservation>? _requestUse; private readonly Action<uint, ItemUseRequestReservation>? _requestUse;
private readonly InventoryTransactionState _transactions; private readonly InventoryTransactionState _transactions;
private readonly bool _ownsTransactions;
private long _lastUseMs = long.MinValue / 2; private long _lastUseMs = long.MinValue / 2;
private uint _consumedPrimaryClickTarget; private uint _consumedPrimaryClickTarget;
@ -74,6 +73,7 @@ public sealed class ItemInteractionController : IDisposable
public ItemInteractionController( public ItemInteractionController(
ClientObjectTable objects, ClientObjectTable objects,
InventoryTransactionState transactions,
Func<uint> playerGuid, Func<uint> playerGuid,
Action<uint>? sendUse, Action<uint>? sendUse,
Action<uint, uint>? sendUseWithTarget, Action<uint, uint>? sendUseWithTarget,
@ -103,8 +103,7 @@ public sealed class ItemInteractionController : IDisposable
Action<uint>? requestExternalContainer = null, Action<uint>? requestExternalContainer = null,
CombatState? combatState = null, CombatState? combatState = null,
Action<CombatMode>? sendChangeCombatMode = null, Action<CombatMode>? sendChangeCombatMode = null,
Action<uint, ItemUseRequestReservation>? requestUse = null, Action<uint, ItemUseRequestReservation>? requestUse = null)
InventoryTransactionState? transactions = null)
{ {
_objects = objects ?? throw new ArgumentNullException(nameof(objects)); _objects = objects ?? throw new ArgumentNullException(nameof(objects));
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid)); _playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
@ -135,34 +134,23 @@ public sealed class ItemInteractionController : IDisposable
_systemMessage = systemMessage; _systemMessage = systemMessage;
_requestUse = requestUse; _requestUse = requestUse;
_interactionState = interactionState ?? new InteractionState(); _interactionState = interactionState ?? new InteractionState();
_transactions = transactions ?? new InventoryTransactionState(_objects); _transactions = transactions
_ownsTransactions = transactions is null; ?? throw new ArgumentNullException(nameof(transactions));
if (!ReferenceEquals(_transactions.Objects, _objects)) if (!ReferenceEquals(_transactions.Objects, _objects))
{ {
if (_ownsTransactions)
_transactions.Dispose();
throw new ArgumentException( throw new ArgumentException(
"The inventory transaction owner must borrow the controller's exact object table.", "The inventory transaction owner must borrow the controller's exact object table.",
nameof(transactions)); nameof(transactions));
} }
try _autoWield = new AutoWieldController(
{ _objects,
_autoWield = new AutoWieldController( _playerGuid,
_objects, _sendWield,
_playerGuid, sendPutItemInContainer,
_sendWield, _toast,
sendPutItemInContainer, _systemMessage,
_toast, combatState,
_systemMessage, sendChangeCombatMode);
combatState,
sendChangeCombatMode);
}
catch
{
if (_ownsTransactions)
_transactions.Dispose();
throw;
}
_interactionState.Changed += OnInteractionModeChanged; _interactionState.Changed += OnInteractionModeChanged;
_transactions.StateChanged += OnTransactionStateChanged; _transactions.StateChanged += OnTransactionStateChanged;
_transactions.RequestCompleted += OnInventoryRequestCompleted; _transactions.RequestCompleted += OnInventoryRequestCompleted;
@ -1166,8 +1154,6 @@ public sealed class ItemInteractionController : IDisposable
_transactions.ObjectTableCleared -= OnInventoryObjectsCleared; _transactions.ObjectTableCleared -= OnInventoryObjectsCleared;
_transactions.RequestCompleted -= OnInventoryRequestCompleted; _transactions.RequestCompleted -= OnInventoryRequestCompleted;
_transactions.StateChanged -= OnTransactionStateChanged; _transactions.StateChanged -= OnTransactionStateChanged;
if (_ownsTransactions)
_transactions.Dispose();
_autoWield.Dispose(); _autoWield.Dispose();
} }

View file

@ -251,7 +251,6 @@ public sealed class SpellbookWindowController : IRetainedPanelController
// gmSpellbookUI::UpdateFilter @ 0x0048B5E0 persists only the changed // gmSpellbookUI::UpdateFilter @ 0x0048B5E0 persists only the changed
// filter bitfield, rebuilds from learned spells, and returns to row zero. // filter bitfield, rebuilds from learned spells, and returns to row zero.
uint filters = _spellbook.SpellbookFilters ^ mask; uint filters = _spellbook.SpellbookFilters ^ mask;
_spellbook.SetSpellbookFilters(filters);
_sendFilter(filters); _sendFilter(filters);
_spellList.Scroll.SetScrollY(0); _spellList.Scroll.SetScrollY(0);
} }
@ -383,7 +382,6 @@ public sealed class SpellbookWindowController : IRetainedPanelController
} }
if (parsed == committed) return; if (parsed == committed) return;
committed = parsed; committed = parsed;
_spellbook.SetDesiredComponent(componentId, parsed);
_setDesiredComponent(componentId, parsed); _setDesiredComponent(componentId, parsed);
} }
desiredField.OnSubmit = Commit; desiredField.OnSubmit = Commit;

View file

@ -209,7 +209,6 @@ public sealed class SpellcastingUiController : IRetainedPanelController
} }
int position = spells.Count; int position = spells.Count;
_addFavorite?.Invoke(_activeTab, position, spellId); _addFavorite?.Invoke(_activeTab, position, spellId);
_spellbook.SetFavorite(_activeTab, position, spellId);
SelectSpell(spellId); SelectSpell(spellId);
} }
@ -424,13 +423,15 @@ public sealed class SpellcastingUiController : IRetainedPanelController
private void BeginFavoriteDrag(SpellFavoriteDragPayload payload) private void BeginFavoriteDrag(SpellFavoriteDragPayload payload)
=> _removeFavorite?.Invoke(payload.SourceTab, payload.SpellId); => _removeFavorite?.Invoke(payload.SourceTab, payload.SpellId);
private void EndFavoriteDrag(SpellFavoriteDragPayload payload) private static void EndFavoriteDrag(SpellFavoriteDragPayload payload)
=> _spellbook.RemoveFavorite(payload.SourceTab, payload.SpellId); {
// 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) private void DropFavorite(SpellFavoriteDragPayload payload, int targetTab, int targetPosition)
{ {
_addFavorite?.Invoke(targetTab, targetPosition, payload.SpellId); _addFavorite?.Invoke(targetTab, targetPosition, payload.SpellId);
_spellbook.SetFavorite(targetTab, targetPosition, payload.SpellId);
_selected[targetTab] = payload.SpellId; _selected[targetTab] = payload.SpellId;
} }
@ -443,7 +444,6 @@ public sealed class SpellcastingUiController : IRetainedPanelController
// CM_Magic::SendNotice_AddSpellShortcut; the open spellcasting menu // CM_Magic::SendNotice_AddSpellShortcut; the open spellcasting menu
// chooses the favorite tab/position and persists it. // chooses the favorite tab/position and persists it.
_addFavorite?.Invoke(targetTab, targetPosition, payload.SpellId); _addFavorite?.Invoke(targetTab, targetPosition, payload.SpellId);
_spellbook.SetFavorite(targetTab, targetPosition, payload.SpellId);
_selected[targetTab] = payload.SpellId; _selected[targetTab] = payload.SpellId;
} }

View file

@ -63,12 +63,10 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
private readonly List<(uint PanelId, UiButton Button)> _panelButtons = new(); private readonly List<(uint PanelId, UiButton Button)> _panelButtons = new();
private readonly ClientObjectTable _repo; private readonly ClientObjectTable _repo;
private readonly CombatState? _combatState; private readonly CombatState? _combatState;
private readonly Func<IReadOnlyList<ShortcutEntry>> _shortcuts; private readonly ShortcutStore _store;
private readonly Func<ItemType, uint, uint, uint, uint, uint> _iconIds; // (itemType, icon, underlay, overlay, effects) → GL tex private readonly Func<ItemType, uint, uint, uint, uint, uint> _iconIds; // (itemType, icon, underlay, overlay, effects) → GL tex
private readonly Func<ItemType, uint, uint, uint, uint, uint>? _dragIconIds; private readonly Func<ItemType, uint, uint, uint, uint, uint>? _dragIconIds;
private readonly Action<uint> _useItem; // guid → fire UseObject private readonly Action<uint> _useItem; // guid → fire UseObject
private readonly ShortcutStore _store = new();
private bool _storeLoaded;
private readonly Action<ShortcutEntry>? _sendAddShortcut; private readonly Action<ShortcutEntry>? _sendAddShortcut;
private readonly Action<uint>? _sendRemoveShortcut; // (index) private readonly Action<uint>? _sendRemoveShortcut; // (index)
private readonly ItemInteractionController? _itemInteraction; private readonly ItemInteractionController? _itemInteraction;
@ -95,7 +93,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
private ToolbarController( private ToolbarController(
ImportedLayout layout, ImportedLayout layout,
ClientObjectTable repo, ClientObjectTable repo,
Func<IReadOnlyList<ShortcutEntry>> shortcuts, ShortcutStore shortcuts,
Func<ItemType, uint, uint, uint, uint, uint> iconIds, Func<ItemType, uint, uint, uint, uint, uint> iconIds,
Action<uint> useItem, Action<uint> useItem,
CombatState? combatState, CombatState? combatState,
@ -116,7 +114,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
{ {
_repo = repo; _repo = repo;
_combatState = combatState; _combatState = combatState;
_shortcuts = shortcuts; _store = shortcuts ?? throw new ArgumentNullException(nameof(shortcuts));
_iconIds = iconIds; _iconIds = iconIds;
_dragIconIds = dragIconIds; _dragIconIds = dragIconIds;
_useItem = useItem; _useItem = useItem;
@ -213,6 +211,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
repo.ObjectRemoved += OnRepositoryObjectChanged; repo.ObjectRemoved += OnRepositoryObjectChanged;
repo.ObjectMoved += OnRepositoryObjectMoved; repo.ObjectMoved += OnRepositoryObjectMoved;
repo.Cleared += OnRepositoryCleared; repo.Cleared += OnRepositoryCleared;
_store.Changed += Populate;
RefreshAmmo(); RefreshAmmo();
RefreshUseButton(); RefreshUseButton();
} }
@ -296,19 +295,11 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
/// Returns true if <paramref name="guid"/> is one of the currently-active shortcut guids /// Returns true if <paramref name="guid"/> is one of the currently-active shortcut guids
/// (i.e., present in the live <see cref="AcDream.Core.Items.ShortcutStore"/>). /// (i.e., present in the live <see cref="AcDream.Core.Items.ShortcutStore"/>).
/// Used to gate repo-event subscriptions so we don't re-populate on every creature spawn. /// Used to gate repo-event subscriptions so we don't re-populate on every creature spawn.
/// Falls back to scanning <c>_shortcuts()</c> before the store is loaded (pre-PD window).
/// </summary> /// </summary>
private bool IsShortcutGuid(uint guid) private bool IsShortcutGuid(uint guid)
{ {
if (_storeLoaded) for (int s = 0; s < ShortcutStore.SlotCount; s++)
{ if (_store.Get(s) == guid) return true;
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;
return false; return false;
} }
@ -320,7 +311,10 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
/// </summary> /// </summary>
/// <param name="layout">Imported toolbar layout (LayoutDesc 0x21000016).</param> /// <param name="layout">Imported toolbar layout (LayoutDesc 0x21000016).</param>
/// <param name="repo">Live item repository — must stay alive for the controller's lifetime.</param> /// <param name="repo">Live item repository — must stay alive for the controller's lifetime.</param>
/// <param name="shortcuts">Provider for the current shortcut bar list.</param> /// <param name="shortcuts">
/// Runtime-owned retail shortcut manager. The toolbar borrows this exact
/// mutable owner; it never reconstructs another slot map.
/// </param>
/// <param name="iconIds">Resolves (itemType, iconId, underlayId, overlayId, effects) → GL texture handle.</param> /// <param name="iconIds">Resolves (itemType, iconId, underlayId, overlayId, effects) → GL texture handle.</param>
/// <param name="useItem">Callback fired when a bound slot is clicked; receives the item guid.</param> /// <param name="useItem">Callback fired when a bound slot is clicked; receives the item guid.</param>
/// <param name="combatState"> /// <param name="combatState">
@ -345,7 +339,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
public static ToolbarController Bind( public static ToolbarController Bind(
ImportedLayout layout, ImportedLayout layout,
ClientObjectTable repo, ClientObjectTable repo,
Func<IReadOnlyList<ShortcutEntry>> shortcuts, ShortcutStore shortcuts,
Func<ItemType, uint, uint, uint, uint, uint> iconIds, Func<ItemType, uint, uint, uint, uint, uint> iconIds,
Action<uint> useItem, Action<uint> useItem,
CombatState? combatState = null, 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 /// Entries whose item is not yet in the repo are silently skipped here; the
/// <c>ObjectAdded</c> event re-fires this method when the item arrives /// <c>ObjectAdded</c> event re-fires this method when the item arrives
/// (matching retail's <c>SetDelayedShortcutNum</c> deferred-rebind path). /// (matching retail's <c>SetDelayedShortcutNum</c> deferred-rebind path).
/// As of B.2: the <see cref="AcDream.Core.Items.ShortcutStore"/> is the /// The Runtime-owned <see cref="AcDream.Core.Items.ShortcutStore"/> is the
/// authoritative in-session slot map; <c>_shortcuts()</c> seeds it on first call. /// authoritative in-session slot map.
/// </summary> /// </summary>
public void Populate() 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(); foreach (var list in _slots) list?.Cell.Clear();
for (int slot = 0; slot < _slots.Length; slot++) for (int slot = 0; slot < _slots.Length; slot++)
@ -654,7 +640,6 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
if (_itemInteraction?.IsOwnedByPlayer(itemId) != true) if (_itemInteraction?.IsOwnedByPlayer(itemId) != true)
return false; return false;
EnsureStoreLoadedForMutation();
for (int slot = 0; slot < _slots.Length; slot++) for (int slot = 0; slot < _slots.Length; slot++)
if (_store.Get(slot) == itemId) if (_store.Get(slot) == itemId)
return false; return false;
@ -672,20 +657,11 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
return false; return false;
var entry = new ShortcutEntry(empty, itemId, 0u); var entry = new ShortcutEntry(empty, itemId, 0u);
_store.Set(entry);
_sendAddShortcut?.Invoke(entry); _sendAddShortcut?.Invoke(entry);
Populate(); _store.Set(entry);
return true; return true;
} }
private void EnsureStoreLoadedForMutation()
{
if (_storeLoaded)
return;
_store.Load(_shortcuts());
_storeLoaded = true;
}
// ── IItemListDragHandler (B.2 live handler) ────────────────────────────── // ── IItemListDragHandler (B.2 live handler) ──────────────────────────────
// Retail: gmToolbarUI is the m_dragHandler for every shortcut slot list. // Retail: gmToolbarUI is the m_dragHandler for every shortcut slot list.
// Retail model (remove-on-lift / place-on-drop / no-restore): // 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 // 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 // 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. // drop onto a slot. Off-bar release leaves it removed.
_store.Remove(payload.SourceSlot);
_sendRemoveShortcut?.Invoke((uint)payload.SourceSlot); _sendRemoveShortcut?.Invoke((uint)payload.SourceSlot);
Populate(); _store.Remove(payload.SourceSlot);
} }
/// <inheritdoc/> /// <inheritdoc/>
@ -717,7 +692,6 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
/// <inheritdoc/> /// <inheritdoc/>
public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload) public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
{ {
EnsureStoreLoadedForMutation();
ShortcutDropSource source = payload.SourceKind == ItemDragSource.ShortcutBar ShortcutDropSource source = payload.SourceKind == ItemDragSource.ShortcutBar
? ShortcutDropSource.ShortcutAlias ? ShortcutDropSource.ShortcutAlias
: ShortcutDropSource.FreshItem; : ShortcutDropSource.FreshItem;
@ -727,7 +701,6 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
_store.Snapshot(), source, payload.SourceSlot, targetCell.SlotIndex, dragged); _store.Snapshot(), source, payload.SourceSlot, targetCell.SlotIndex, dragged);
ApplyShortcutPlan(plan); ApplyShortcutPlan(plan);
Populate();
} }
/// <summary> /// <summary>
@ -736,32 +709,29 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
/// </summary> /// </summary>
public void ReplaceFullyMergedShortcut(uint oldObjectId, uint newObjectId) public void ReplaceFullyMergedShortcut(uint oldObjectId, uint newObjectId)
{ {
EnsureStoreLoadedForMutation();
ShortcutMutation[] plan = ShortcutDropPlanner.PlanFullStackMerge( ShortcutMutation[] plan = ShortcutDropPlanner.PlanFullStackMerge(
_store.Snapshot(), oldObjectId, newObjectId); _store.Snapshot(), oldObjectId, newObjectId);
ApplyShortcutPlan(plan); ApplyShortcutPlan(plan);
if (plan.Length > 0)
Populate();
} }
private void ApplyShortcutPlan(IReadOnlyList<ShortcutMutation> plan) private void ApplyShortcutPlan(IReadOnlyList<ShortcutMutation> plan)
{ {
// Apply the already-validated transaction locally first, then reproduce the // Retail sends each shortcut event immediately before applying the same
// planner's exact Remove/Add wire order. Shortcut events have no rejection reply. // PlayerModule mutation (gmToolbarUI @ 0x004BD450/0x004BD9A0).
foreach (var mutation in plan) // Production commands mutate this exact Runtime-owned store; the
{ // idempotent local call also keeps isolated controller fixtures useful.
if (mutation.Kind == ShortcutMutationKind.Remove)
_store.Remove(mutation.Slot);
else if (mutation.Entry is { } entry)
_store.Set(entry);
}
foreach (var mutation in plan) foreach (var mutation in plan)
{ {
if (mutation.Kind == ShortcutMutationKind.Remove) if (mutation.Kind == ShortcutMutationKind.Remove)
{
_sendRemoveShortcut?.Invoke((uint)mutation.Slot); _sendRemoveShortcut?.Invoke((uint)mutation.Slot);
_store.Remove(mutation.Slot);
}
else if (mutation.Entry is { } entry) else if (mutation.Entry is { } entry)
{
_sendAddShortcut?.Invoke(entry); _sendAddShortcut?.Invoke(entry);
_store.Set(entry);
}
} }
} }
@ -800,5 +770,6 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
_repo.ObjectRemoved -= OnRepositoryObjectChanged; _repo.ObjectRemoved -= OnRepositoryObjectChanged;
_repo.ObjectMoved -= OnRepositoryObjectMoved; _repo.ObjectMoved -= OnRepositoryObjectMoved;
_repo.Cleared -= OnRepositoryCleared; _repo.Cleared -= OnRepositoryCleared;
_store.Changed -= Populate;
} }
} }

View file

@ -90,7 +90,7 @@ public sealed record IndicatorRuntimeBindings(
public sealed record ToolbarRuntimeBindings( public sealed record ToolbarRuntimeBindings(
ClientObjectTable Objects, ClientObjectTable Objects,
Func<IReadOnlyList<ShortcutEntry>> Shortcuts, ShortcutStore Shortcuts,
Func<ItemType, uint, uint, uint, uint, uint> ResolveIcon, Func<ItemType, uint, uint, uint, uint, uint> ResolveIcon,
Func<ItemType, uint, uint, uint, uint, uint> ResolveDragIcon, Func<ItemType, uint, uint, uint, uint, uint> ResolveDragIcon,
Action<uint> UseItem, Action<uint> UseItem,

View file

@ -42,6 +42,15 @@ public sealed class ChatCommandTargetState : IDisposable
} }
} }
public bool IsDisposed
{
get
{
lock (_gate)
return _disposed;
}
}
/// <summary> /// <summary>
/// Forget character-scoped command targets without clearing visible /// Forget character-scoped command targets without clearing visible
/// transcript history. /// transcript history.

View file

@ -9,10 +9,40 @@ namespace AcDream.Core.Items;
/// <see cref="ShortcutEntry"/> fields, including non-object records that the object-only /// <see cref="ShortcutEntry"/> fields, including non-object records that the object-only
/// <c>gmToolbarUI</c> does not render. /// <c>gmToolbarUI</c> does not render.
/// </summary> /// </summary>
public sealed class ShortcutStore public sealed class ShortcutStore : IDisposable
{ {
public const int SlotCount = 18; public const int SlotCount = 18;
private readonly ShortcutEntry?[] _entries = new ShortcutEntry?[SlotCount]; private readonly ShortcutEntry?[] _entries = new ShortcutEntry?[SlotCount];
private ShortcutEntry[] _items = [];
private Action? _changed;
private long _revision;
private long _dispatchFailureCount;
private bool _disposed;
/// <summary>
/// Raised synchronously after the canonical slot map changes. One failing
/// observer cannot prevent another host from seeing the same mutation.
/// </summary>
public event Action? Changed
{
add
{
ObjectDisposedException.ThrowIf(_disposed, this);
_changed += value;
}
remove => _changed -= value;
}
/// <summary>Stable packed-order view of all present shortcut records.</summary>
public IReadOnlyList<ShortcutEntry> 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;
/// <summary> /// <summary>
/// Replace all slots from packed entries. Invalid signed indices are rejected exactly /// Replace all slots from packed entries. Invalid signed indices are rejected exactly
@ -21,9 +51,12 @@ public sealed class ShortcutStore
/// </summary> /// </summary>
public void Load(IEnumerable<ShortcutEntry> entries) public void Load(IEnumerable<ShortcutEntry> entries)
{ {
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(entries);
Array.Clear(_entries); Array.Clear(_entries);
foreach (var entry in entries) foreach (var entry in entries)
Set(entry); SetCore(entry);
PublishChanged();
} }
/// <summary>Raw entry at <paramref name="slot"/>, or null for absent/out-of-range.</summary> /// <summary>Raw entry at <paramref name="slot"/>, or null for absent/out-of-range.</summary>
@ -44,8 +77,9 @@ public sealed class ShortcutStore
public void Set(ShortcutEntry entry) public void Set(ShortcutEntry entry)
{ {
if ((uint)entry.Index < SlotCount) ObjectDisposedException.ThrowIf(_disposed, this);
_entries[entry.Index] = entry; if (SetCore(entry))
PublishChanged();
} }
/// <summary>Create/replace a retail object shortcut; toolbar-created records use spell word zero.</summary> /// <summary>Create/replace a retail object shortcut; toolbar-created records use spell word zero.</summary>
@ -53,7 +87,79 @@ public sealed class ShortcutStore
public void Remove(int slot) public void Remove(int slot)
{ {
if ((uint)slot < SlotCount) ObjectDisposedException.ThrowIf(_disposed, this);
_entries[slot] = null; if ((uint)slot >= SlotCount || _entries[slot] is null)
return;
_entries[slot] = null;
PublishChanged();
}
/// <summary>
/// Clear the complete retail shortcut manager while retaining this owner
/// for the next session.
/// </summary>
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<ShortcutEntry>(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;
}
}
} }
} }

View file

@ -401,11 +401,25 @@ public sealed class Spellbook
public void SetDesiredComponent(uint componentId, uint amount) public void SetDesiredComponent(uint componentId, uint amount)
{ {
uint current = _desiredComponents.TryGetValue(componentId, out uint existing) bool exists = _desiredComponents.TryGetValue(
? existing : 0u; componentId,
if (current == amount) return; out uint current);
if (amount == 0) _desiredComponents.Remove(componentId); if (exists && current == amount) return;
else _desiredComponents[componentId] = amount; // PlayerModule::SetDesiredCompLevel @ 0x005D4940 retains zero as a
// real table value; only ClearDesiredCompList destroys the entry set.
_desiredComponents[componentId] = amount;
DesiredComponentsChanged?.Invoke();
StateChanged?.Invoke();
}
/// <summary>
/// Destroy the complete desired-component list, matching
/// <c>PlayerModule::ClearDesiredCompList @ 0x005D2A00</c>.
/// </summary>
public void ClearDesiredComponents()
{
if (_desiredComponents.Count == 0) return;
_desiredComponents.Clear();
DesiredComponentsChanged?.Invoke(); DesiredComponentsChanged?.Invoke();
StateChanged?.Invoke(); StateChanged?.Invoke();
} }

View file

@ -1,9 +1,41 @@
using AcDream.Core.Player; using AcDream.Core.Player;
using AcDream.Core.Net.Messages; using AcDream.Core.Net.Messages;
using AcDream.Core.Spells; using AcDream.Core.Spells;
using AcDream.Core.Items;
namespace AcDream.Runtime.Gameplay; 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;
}
/// <summary> /// <summary>
/// Canonical presentation-independent owner for the local character's magic /// Canonical presentation-independent owner for the local character's magic
/// and player-sheet state. The two objects form one lifetime group because /// 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 bool _disposed;
private long _characterRevision; private long _characterRevision;
private long _spellbookRevision; private long _spellbookRevision;
private bool _internalSubscriptionsAttached;
public RuntimeCharacterState(SpellTable? spellTable = null) public RuntimeCharacterState(SpellTable? spellTable = null)
{ {
@ -26,6 +59,7 @@ public sealed class RuntimeCharacterState : IDisposable
LocalPlayer.Changed += OnVitalChanged; LocalPlayer.Changed += OnVitalChanged;
LocalPlayer.AttributeChanged += OnAttributeChanged; LocalPlayer.AttributeChanged += OnAttributeChanged;
LocalPlayer.CharacterChanged += OnCharacterChanged; LocalPlayer.CharacterChanged += OnCharacterChanged;
_internalSubscriptionsAttached = true;
} }
public Spellbook Spellbook { get; } public Spellbook Spellbook { get; }
@ -35,6 +69,57 @@ public sealed class RuntimeCharacterState : IDisposable
public IRuntimeCharacterView View { get; } public IRuntimeCharacterView View { get; }
public bool IsDisposed => _disposed; 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<LocalPlayerState.VitalKind>())
{
if (LocalPlayer.Get(kind) is not null)
vitalCount++;
}
int attributeCount = 0;
foreach (LocalPlayerState.AttributeKind kind
in Enum.GetValues<LocalPlayerState.AttributeKind>())
{
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);
}
/// <summary> /// <summary>
/// Installs immutable DAT metadata without transferring its ownership to /// Installs immutable DAT metadata without transferring its ownership to
/// Runtime. The content host may install one table after portal.dat opens. /// Runtime. The content host may install one table after portal.dat opens.
@ -57,6 +142,93 @@ public sealed class RuntimeCharacterState : IDisposable
LocalPlayer.Clear(); LocalPlayer.Clear();
} }
/// <summary>
/// Apply retail's local favorite insertion before sending the matching
/// character event.
/// </summary>
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;
}
/// <summary>
/// Apply and publish a spellbook filter only when it differs, matching
/// <c>gmSpellbookUI::UpdateFilter @ 0x0048B5E0</c>.
/// </summary>
public void SetSpellbookFilter(
uint filters,
Action publishOutbound)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(publishOutbound);
if (Spellbook.SpellbookFilters == filters)
return;
Spellbook.SetSpellbookFilters(filters);
publishOutbound();
}
/// <summary>
/// Retail publishes the desired-component event before changing its local
/// PlayerModule table.
/// </summary>
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();
}
}
/// <summary> /// <summary>
/// Clears both coupled owners while retaining every failed suffix for a /// Clears both coupled owners while retaining every failed suffix for a
/// retry. State mutation happens before the existing synchronous /// retry. State mutation happens before the existing synchronous
@ -82,12 +254,32 @@ public sealed class RuntimeCharacterState : IDisposable
{ {
if (_disposed) if (_disposed)
return; return;
ResetSession(); List<Exception>? failures = null;
Spellbook.StateChanged -= OnSpellbookChanged; try
LocalPlayer.Changed -= OnVitalChanged; {
LocalPlayer.AttributeChanged -= OnAttributeChanged; // Do not call ResetSession as one opaque step here. Terminal
LocalPlayer.CharacterChanged -= OnCharacterChanged; // disposal must run every suffix even when an external UI observer
_disposed = true; // 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() => private void OnSpellbookChanged() =>

View file

@ -17,6 +17,36 @@ public interface IRuntimeCommunicationEventSource
IDisposable Subscribe(IRuntimeCommunicationObserver observer); 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;
}
/// <summary> /// <summary>
/// Canonical presentation-independent owner for communication and social /// Canonical presentation-independent owner for communication and social
/// state. Graphical, headless, plugin, and bot hosts borrow these exact /// 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 long DispatchFailureCount => _events.DispatchFailureCount;
public Exception? LastDispatchFailure => _events.LastDispatchFailure; 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 ResetCommandTargets() => CommandTargets.ResetSession();
public void ResetChatIdentity() => Chat.ResetSessionIdentity(); public void ResetChatIdentity() => Chat.ResetSessionIdentity();
public void ResetNegotiatedChannels() => TurbineChat.Reset(); public void ResetNegotiatedChannels() => TurbineChat.Reset();
@ -126,18 +175,19 @@ public sealed class RuntimeCommunicationState : IDisposable
return true; return true;
} }
private static int CountRooms(TurbineChatState state) }
{
int count = 0; private static int CountRooms(TurbineChatState state)
if (state.AllegianceRoom != 0u) count++; {
if (state.GeneralRoom != 0u) count++; int count = 0;
if (state.TradeRoom != 0u) count++; if (state.AllegianceRoom != 0u) count++;
if (state.LfgRoom != 0u) count++; if (state.GeneralRoom != 0u) count++;
if (state.RoleplayRoom != 0u) count++; if (state.TradeRoom != 0u) count++;
if (state.SocietyRoom != 0u) count++; if (state.LfgRoom != 0u) count++;
if (state.OlthoiRoom != 0u) count++; if (state.RoleplayRoom != 0u) count++;
return count; if (state.SocietyRoom != 0u) count++;
} if (state.OlthoiRoom != 0u) count++;
return count;
} }
} }

View file

@ -0,0 +1,34 @@
namespace AcDream.Runtime.Gameplay;
/// <summary>
/// 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.
/// </summary>
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());
}
}

View file

@ -3,6 +3,33 @@ using AcDream.Runtime.Entities;
namespace AcDream.Runtime.Gameplay; 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;
}
/// <summary> /// <summary>
/// Canonical presentation-independent owner for inventory gameplay state. /// Canonical presentation-independent owner for inventory gameplay state.
/// The object collection is borrowed directly from J3's entity/object /// 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)); ?? throw new ArgumentNullException(nameof(entityObjects));
ExternalContainers = new ExternalContainerState(); ExternalContainers = new ExternalContainerState();
ItemMana = new ItemManaState(); ItemMana = new ItemManaState();
Shortcuts = new ShortcutState(); Shortcuts = new ShortcutStore();
Transactions = new InventoryTransactionState(_entityObjects.Objects); Transactions = new InventoryTransactionState(_entityObjects.Objects);
View = new InventoryStateView(this); View = new InventoryStateView(this);
} }
@ -27,11 +54,25 @@ public sealed class RuntimeInventoryState : IDisposable
public ClientObjectTable Objects => _entityObjects.Objects; public ClientObjectTable Objects => _entityObjects.Objects;
public ExternalContainerState ExternalContainers { get; } public ExternalContainerState ExternalContainers { get; }
public ItemManaState ItemMana { get; } public ItemManaState ItemMana { get; }
public ShortcutState Shortcuts { get; } public ShortcutStore Shortcuts { get; }
public InventoryTransactionState Transactions { get; } public InventoryTransactionState Transactions { get; }
public IRuntimeInventoryStateView View { get; } public IRuntimeInventoryStateView View { get; }
public bool IsDisposed => _disposed; 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 ResetExternalContainer() => ExternalContainers.Reset();
public void ResetTransactions() => Transactions.ResetSession(); public void ResetTransactions() => Transactions.ResetSession();
public void ResetItemMana() => ItemMana.Clear(); public void ResetItemMana() => ItemMana.Clear();
@ -41,20 +82,82 @@ public sealed class RuntimeInventoryState : IDisposable
Shortcuts.Clear(); Shortcuts.Clear();
} }
/// <summary>
/// Send and apply one retail toolbar add on the canonical shortcut
/// manager. Retail emits <c>Event_AddShortCut</c> before mutating
/// <c>PlayerModule</c>.
/// </summary>
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;
}
/// <summary>
/// Send and apply one retail toolbar removal on the canonical shortcut
/// manager.
/// </summary>
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() public void Dispose()
{ {
if (_disposed) if (_disposed)
return; return;
List<Exception>? failures = null; List<Exception>? failures = null;
Try(() => ExternalContainers.Reset(), ref failures); try
Try(ItemMana.Clear, ref failures); {
Try(Shortcuts.Clear, ref failures); Try(() => ExternalContainers.Reset(), ref failures);
Try(Transactions.Dispose, 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) if (failures is not null)
throw new AggregateException( throw new AggregateException(
"Runtime inventory state did not converge during disposal.", "Runtime inventory state did not converge during disposal.",
failures); failures);
_disposed = true;
} }
private static void Try(Action action, ref List<Exception>? failures) private static void Try(Action action, ref List<Exception>? failures)
@ -93,7 +196,7 @@ public sealed class RuntimeInventoryState : IDisposable
owner.Transactions.BusyCount, owner.Transactions.BusyCount,
owner.Transactions.CanBeginRequest, owner.Transactions.CanBeginRequest,
pending, pending,
owner.Shortcuts.Items.Count, owner.Shortcuts.Count,
owner.Shortcuts.Revision, owner.Shortcuts.Revision,
owner.ItemMana.Count, owner.ItemMana.Count,
owner.ItemMana.Revision); owner.ItemMana.Revision);
@ -124,22 +227,3 @@ public sealed class RuntimeInventoryState : IDisposable
owner.ItemMana.TryGetManaPercent(objectId, out fraction); owner.ItemMana.TryGetManaPercent(objectId, out fraction);
} }
} }
public sealed class ShortcutState
{
private IReadOnlyList<ShortcutEntry> _items = Array.Empty<ShortcutEntry>();
private long _revision;
public IReadOnlyList<ShortcutEntry> Items => Volatile.Read(ref _items);
public long Revision => Interlocked.Read(ref _revision);
public void Replace(IReadOnlyList<ShortcutEntry>? items)
{
Volatile.Write(
ref _items,
items ?? Array.Empty<ShortcutEntry>());
Interlocked.Increment(ref _revision);
}
public void Clear() => Replace(Array.Empty<ShortcutEntry>());
}

View file

@ -160,6 +160,7 @@ public sealed class SelectionInteractionControllerTests
}); });
Items = new ItemInteractionController( Items = new ItemInteractionController(
Objects, Objects,
new InventoryTransactionState(Objects),
() => Player, () => Player,
sendUse: null, sendUse: null,
sendUseWithTarget: null, sendUseWithTarget: null,

View file

@ -371,7 +371,7 @@ public sealed class CurrentGameRuntimeAdapterTests
harness.Character.Options.Replace(0x04000000u, 0x00948700u); harness.Character.Options.Replace(0x04000000u, 0x00948700u);
harness.Character.MovementSkills.Update(200, 175); harness.Character.MovementSkills.Update(200, 175);
harness.Character.Spellbook.OnSpellLearned(42u); harness.Character.Spellbook.OnSpellLearned(42u);
harness.InventoryState.Shortcuts.Replace( harness.InventoryState.Shortcuts.Load(
[ [
new ShortcutEntry(1, 0x80000001u, 0u), new ShortcutEntry(1, 0x80000001u, 0u),
]); ]);
@ -506,6 +506,20 @@ public sealed class CurrentGameRuntimeAdapterTests
static entry => entry.Kind == RuntimeTraceKind.Command static entry => entry.Kind == RuntimeTraceKind.Command
&& (entry.Code >> 16) && (entry.Code >> 16)
== (int)RuntimeCommandDomain.Social); == (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; int published = harness.Commands.Published.Count;
RuntimeCommandResult stale = commands.Character.SetOptions1( RuntimeCommandResult stale = commands.Character.SetOptions1(
@ -515,6 +529,101 @@ public sealed class CurrentGameRuntimeAdapterTests
Assert.Equal(published, harness.Commands.Published.Count); 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<string>();
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() private static ClientObject Object(WorldSession.EntitySpawn spawn) => new()
{ {
ObjectId = spawn.Guid, ObjectId = spawn.Guid,
@ -587,6 +696,7 @@ public sealed class CurrentGameRuntimeAdapterTests
_items = new ItemInteractionController( _items = new ItemInteractionController(
Objects, Objects,
InventoryState.Transactions,
() => PlayerGuid, () => PlayerGuid,
sendUse: null, sendUse: null,
sendUseWithTarget: null, sendUseWithTarget: null,

View file

@ -41,13 +41,32 @@ public sealed class RuntimeInventoryOwnershipTests
"InteractionRetainedUiComposition.cs"); "InteractionRetainedUiComposition.cs");
string session = ReadSource("Net", "LiveSessionRuntimeFactory.cs"); string session = ReadSource("Net", "LiveSessionRuntimeFactory.cs");
string shutdown = ReadSource("Rendering", "GameWindowLifetime.cs"); string shutdown = ReadSource("Rendering", "GameWindowLifetime.cs");
string itemInteraction = ReadSource(
"UI",
"ItemInteractionController.cs");
Assert.Contains( Assert.Contains(
"transactions: d.Inventory.Transactions", "d.Inventory.Transactions",
ui, ui,
StringComparison.Ordinal); 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( Assert.Contains(
"OnShortcuts: _domain.Inventory.Shortcuts.Replace", "InventoryTransactionState transactions,",
itemInteraction,
StringComparison.Ordinal);
Assert.Contains(
"OnShortcuts: _domain.Inventory.Shortcuts.Load",
session, session,
StringComparison.Ordinal); StringComparison.Ordinal);
Assert.Contains( Assert.Contains(
@ -64,6 +83,73 @@ public sealed class RuntimeInventoryOwnershipTests
"\"runtime entity/object lifetime\""); "\"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<IReadOnlyList<ShortcutEntry>>",
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) private static string ReadSource(params string[] relative)
{ {
string root = FindRepositoryRoot(); string root = FindRepositoryRoot();

View file

@ -153,6 +153,7 @@ public sealed class CursorFeedbackControllerTests
var objects = SeedTargetObjects(); var objects = SeedTargetObjects();
var interaction = new ItemInteractionController( var interaction = new ItemInteractionController(
objects, objects,
new InventoryTransactionState(objects),
playerGuid: () => Player, playerGuid: () => Player,
sendUse: null, sendUse: null,
sendUseWithTarget: null, sendUseWithTarget: null,
@ -188,6 +189,7 @@ public sealed class CursorFeedbackControllerTests
objects.Get(Source)!.TargetType = (uint)ItemType.Misc; // a tool that targets items objects.Get(Source)!.TargetType = (uint)ItemType.Misc; // a tool that targets items
var interaction = new ItemInteractionController( var interaction = new ItemInteractionController(
objects, objects,
new InventoryTransactionState(objects),
playerGuid: () => Player, playerGuid: () => Player,
sendUse: null, sendUse: null,
sendUseWithTarget: null, sendUseWithTarget: null,
@ -226,6 +228,7 @@ public sealed class CursorFeedbackControllerTests
var objects = SeedTargetObjects(); var objects = SeedTargetObjects();
var interaction = new ItemInteractionController( var interaction = new ItemInteractionController(
objects, objects,
new InventoryTransactionState(objects),
playerGuid: () => Player, playerGuid: () => Player,
sendUse: null, sendUse: null,
sendUseWithTarget: null, sendUseWithTarget: null,
@ -254,6 +257,7 @@ public sealed class CursorFeedbackControllerTests
var objects = SeedTargetObjects(); var objects = SeedTargetObjects();
var interaction = new ItemInteractionController( var interaction = new ItemInteractionController(
objects, objects,
new InventoryTransactionState(objects),
playerGuid: () => Player, playerGuid: () => Player,
sendUse: null, sendUse: null,
sendUseWithTarget: null, sendUseWithTarget: null,

View file

@ -31,7 +31,7 @@ public sealed class ItemInteractionControllerTests
public readonly List<CombatMode> CombatModeRequests = new(); public readonly List<CombatMode> CombatModeRequests = new();
public readonly CombatState Combat = new(); public readonly CombatState Combat = new();
public readonly StackSplitQuantityState SplitQuantity = new(); public readonly StackSplitQuantityState SplitQuantity = new();
public readonly InventoryTransactionState? SharedTransactions; public readonly InventoryTransactionState SharedTransactions;
public uint SelectedObject; public uint SelectedObject;
public bool NonCombatMode; public bool NonCombatMode;
public bool DragOnPlayerOpensSecureTrade = true; public bool DragOnPlayerOpensSecureTrade = true;
@ -39,8 +39,7 @@ public sealed class ItemInteractionControllerTests
public long Now = 1_000; public long Now = 1_000;
public Harness( public Harness(
Action<uint, ItemUseRequestReservation>? requestUse = null, Action<uint, ItemUseRequestReservation>? requestUse = null)
bool sharedTransactions = false)
{ {
Objects.AddOrUpdate(new ClientObject Objects.AddOrUpdate(new ClientObject
{ {
@ -56,12 +55,11 @@ public sealed class ItemInteractionControllerTests
ItemsCapacity = 24, ItemsCapacity = 24,
}); });
Objects.MoveItem(Pack, Player, 0); Objects.MoveItem(Pack, Player, 0);
SharedTransactions = sharedTransactions SharedTransactions = new InventoryTransactionState(Objects);
? new InventoryTransactionState(Objects)
: null;
Controller = new ItemInteractionController( Controller = new ItemInteractionController(
Objects, Objects,
SharedTransactions,
playerGuid: () => Player, playerGuid: () => Player,
sendUse: requestUse is null ? Uses.Add : null, sendUse: requestUse is null ? Uses.Add : null,
sendExamine: Examines.Add, sendExamine: Examines.Add,
@ -91,8 +89,7 @@ public sealed class ItemInteractionControllerTests
}, },
combatState: Combat, combatState: Combat,
sendChangeCombatMode: CombatModeRequests.Add, sendChangeCombatMode: CombatModeRequests.Add,
requestUse: requestUse, requestUse: requestUse);
transactions: SharedTransactions);
} }
public ItemInteractionController Controller { get; } public ItemInteractionController Controller { get; }
@ -321,11 +318,11 @@ public sealed class ItemInteractionControllerTests
[Fact] [Fact]
public void BorrowedTransactionOwnerIsExactAndOutlivesController() public void BorrowedTransactionOwnerIsExactAndOutlivesController()
{ {
var h = new Harness(sharedTransactions: true); var h = new Harness();
h.Controller.IncrementBusyCount(); h.Controller.IncrementBusyCount();
Assert.Equal(1, h.SharedTransactions!.BusyCount); Assert.Equal(1, h.SharedTransactions.BusyCount);
h.Controller.Dispose(); h.Controller.Dispose();
Assert.False(h.SharedTransactions.IsDisposed); Assert.False(h.SharedTransactions.IsDisposed);
@ -343,12 +340,12 @@ public sealed class ItemInteractionControllerTests
Assert.Throws<ArgumentException>(() => new ItemInteractionController( Assert.Throws<ArgumentException>(() => new ItemInteractionController(
objects, objects,
transactions,
playerGuid: () => Player, playerGuid: () => Player,
sendUse: null, sendUse: null,
sendUseWithTarget: null, sendUseWithTarget: null,
sendWield: null, sendWield: null,
sendDrop: null, sendDrop: null));
transactions: transactions));
} }
[Fact] [Fact]

View file

@ -858,6 +858,7 @@ public sealed class AppraisalUiControllerTests
List<uint> sent) List<uint> sent)
=> new( => new(
objects, objects,
new InventoryTransactionState(objects),
playerGuid: () => 0x50000002u, playerGuid: () => 0x50000002u,
sendUse: null, sendUse: null,
sendUseWithTarget: null, sendUseWithTarget: null,

View file

@ -106,6 +106,7 @@ public sealed class ExternalContainerControllerTests
Interaction = new ItemInteractionController( Interaction = new ItemInteractionController(
Objects, Objects,
new InventoryTransactionState(Objects),
playerGuid: () => Player, playerGuid: () => Player,
sendUse: Uses.Add, sendUse: Uses.Add,
sendUseWithTarget: null, sendUseWithTarget: null,

View file

@ -508,6 +508,7 @@ public class InventoryControllerTests
var appraisals = new List<uint>(); var appraisals = new List<uint>();
using var interaction = new ItemInteractionController( using var interaction = new ItemInteractionController(
objects, objects,
new InventoryTransactionState(objects),
playerGuid: () => Player, playerGuid: () => Player,
sendUse: null, sendUse: null,
sendUseWithTarget: null, sendUseWithTarget: null,
@ -544,6 +545,7 @@ public class InventoryControllerTests
objects.Get(0xA)!.Useability = 0x000A0008u; objects.Get(0xA)!.Useability = 0x000A0008u;
var interaction = new ItemInteractionController( var interaction = new ItemInteractionController(
objects, objects,
new InventoryTransactionState(objects),
playerGuid: () => Player, playerGuid: () => Player,
sendUse: null, sendUse: null,
sendUseWithTarget: null, sendUseWithTarget: null,
@ -712,6 +714,7 @@ public class InventoryControllerTests
var pickups = new List<(uint item, uint container, int placement)>(); var pickups = new List<(uint item, uint container, int placement)>();
using var interaction = new ItemInteractionController( using var interaction = new ItemInteractionController(
objects, objects,
new InventoryTransactionState(objects),
playerGuid: () => Player, playerGuid: () => Player,
sendUse: null, sendUse: null,
sendUseWithTarget: null, sendUseWithTarget: null,
@ -756,6 +759,7 @@ public class InventoryControllerTests
var eventOrder = new List<(string Kind, uint Item, ulong Token)>(); var eventOrder = new List<(string Kind, uint Item, ulong Token)>();
using var interaction = new ItemInteractionController( using var interaction = new ItemInteractionController(
objects, objects,
new InventoryTransactionState(objects),
playerGuid: () => Player, playerGuid: () => Player,
sendUse: null, sendUse: null,
sendUseWithTarget: null, sendUseWithTarget: null,
@ -807,6 +811,7 @@ public class InventoryControllerTests
var eventOrder = new List<(string Kind, uint Item)>(); var eventOrder = new List<(string Kind, uint Item)>();
using var interaction = new ItemInteractionController( using var interaction = new ItemInteractionController(
objects, objects,
new InventoryTransactionState(objects),
playerGuid: () => Player, playerGuid: () => Player,
sendUse: null, sendUse: null,
sendUseWithTarget: null, sendUseWithTarget: null,
@ -864,6 +869,7 @@ public class InventoryControllerTests
var messages = new List<string>(); var messages = new List<string>();
using var interaction = new ItemInteractionController( using var interaction = new ItemInteractionController(
objects, objects,
new InventoryTransactionState(objects),
playerGuid: () => Player, playerGuid: () => Player,
sendUse: null, sendUse: null,
sendUseWithTarget: null, sendUseWithTarget: null,
@ -932,6 +938,7 @@ public class InventoryControllerTests
var messages = new List<string>(); var messages = new List<string>();
using var interaction = new ItemInteractionController( using var interaction = new ItemInteractionController(
objects, objects,
new InventoryTransactionState(objects),
playerGuid: () => Player, playerGuid: () => Player,
sendUse: null, sendUse: null,
sendUseWithTarget: null, sendUseWithTarget: null,
@ -994,6 +1001,7 @@ public class InventoryControllerTests
var merges = new List<(uint Source, uint Target, uint Amount)>(); var merges = new List<(uint Source, uint Target, uint Amount)>();
using var interaction = new ItemInteractionController( using var interaction = new ItemInteractionController(
objects, objects,
new InventoryTransactionState(objects),
playerGuid: () => Player, playerGuid: () => Player,
sendUse: null, sendUse: null,
sendUseWithTarget: null, sendUseWithTarget: null,
@ -1053,6 +1061,7 @@ public class InventoryControllerTests
var splits = new List<(uint Item, uint Container, uint Placement, uint Amount)>(); var splits = new List<(uint Item, uint Container, uint Placement, uint Amount)>();
using var interaction = new ItemInteractionController( using var interaction = new ItemInteractionController(
objects, objects,
new InventoryTransactionState(objects),
playerGuid: () => Player, playerGuid: () => Player,
sendUse: null, sendUse: null,
sendUseWithTarget: null, sendUseWithTarget: null,
@ -1099,6 +1108,7 @@ public class InventoryControllerTests
var puts = new List<(uint Item, uint Container, int Placement)>(); var puts = new List<(uint Item, uint Container, int Placement)>();
using var interaction = new ItemInteractionController( using var interaction = new ItemInteractionController(
objects, objects,
new InventoryTransactionState(objects),
playerGuid: () => Player, playerGuid: () => Player,
sendUse: null, sendUse: null,
sendUseWithTarget: null, sendUseWithTarget: null,
@ -1138,6 +1148,7 @@ public class InventoryControllerTests
SeedContained(objects, loot, chest, slot: 0, type: ItemType.Misc); SeedContained(objects, loot, chest, slot: 0, type: ItemType.Misc);
using var interaction = new ItemInteractionController( using var interaction = new ItemInteractionController(
objects, objects,
new InventoryTransactionState(objects),
playerGuid: () => Player, playerGuid: () => Player,
sendUse: null, sendUse: null,
sendUseWithTarget: null, sendUseWithTarget: null,
@ -1176,6 +1187,7 @@ public class InventoryControllerTests
SeedContained(objects, loot, chest, slot: 0, type: ItemType.Misc); SeedContained(objects, loot, chest, slot: 0, type: ItemType.Misc);
using var interaction = new ItemInteractionController( using var interaction = new ItemInteractionController(
objects, objects,
new InventoryTransactionState(objects),
playerGuid: () => Player, playerGuid: () => Player,
sendUse: null, sendUse: null,
sendUseWithTarget: null, sendUseWithTarget: null,

View file

@ -52,6 +52,7 @@ public class PaperdollControllerTests
{ {
var itemInteraction = new ItemInteractionController( var itemInteraction = new ItemInteractionController(
objects, objects,
new InventoryTransactionState(objects),
() => Player, () => Player,
sendUse: null, sendUse: null,
sendUseWithTarget: null, sendUseWithTarget: null,

View file

@ -413,10 +413,18 @@ public sealed class SpellbookWindowControllerTests
spellId => spellId == 103u ? 8 : 1, spellId => spellId == 103u ? 8 : 1,
selectObject ?? (_ => { }), selectObject ?? (_ => { }),
addFavorite ?? (_ => { }), addFavorite ?? (_ => { }),
sendFilter ?? (_ => { }), filters =>
{
spellbook.SetSpellbookFilters(filters);
sendFilter?.Invoke(filters);
},
removeSpell ?? (_ => { }), removeSpell ?? (_ => { }),
showConfirmation ?? ((_, _) => { }), showConfirmation ?? ((_, _) => { }),
setDesiredComponent ?? ((_, _) => { }), (componentId, amount) =>
{
spellbook.SetDesiredComponent(componentId, amount);
setDesiredComponent?.Invoke(componentId, amount);
},
close ?? (() => { }), close ?? (() => { }),
new ComponentBookTemplateFactory( new ComponentBookTemplateFactory(
FixtureLoader.LoadComponentCategoryTemplateInfos(), FixtureLoader.LoadComponentCategoryTemplateInfos(),

View file

@ -338,8 +338,12 @@ public sealed class SpellcastingUiControllerTests
item => item.ObjectId, item => item.ObjectId,
useItem, useItem,
selection ?? new SelectionState(), selection ?? new SelectionState(),
addFavorite ?? ((_, _, _) => { }), (tab, position, spellId) =>
(_, _) => { }, {
spellbook.SetFavorite(tab, position, spellId);
addFavorite?.Invoke(tab, position, spellId);
},
(tab, spellId) => spellbook.RemoveFavorite(tab, spellId),
shortcutDigits, shortcutDigits,
emptySlotSprite, emptySlotSprite,
examineSpell); examineSpell);

View file

@ -34,6 +34,14 @@ public class ToolbarControllerTests
(InventoryButtonId, RetailPanelCatalog.Inventory), (InventoryButtonId, RetailPanelCatalog.Inventory),
}; };
private static ShortcutStore Store(
IEnumerable<ShortcutEntry> shortcuts)
{
var store = new ShortcutStore();
store.Load(shortcuts);
return store;
}
private static (ImportedLayout layout, Dictionary<uint, UiItemList> slots, private static (ImportedLayout layout, Dictionary<uint, UiItemList> slots,
Dictionary<uint, UiElement> indicators) FakeToolbar() Dictionary<uint, UiElement> indicators) FakeToolbar()
{ {
@ -108,7 +116,7 @@ public class ToolbarControllerTests
var shortcuts = new List<ShortcutEntry> var shortcuts = new List<ShortcutEntry>
{ new(Index: 0, ObjectId: 0x5001u, SpellId: 0) }; { new(Index: 0, ObjectId: 0x5001u, SpellId: 0) };
ToolbarController.Bind(layout, repo, () => shortcuts, ToolbarController.Bind(layout, repo, Store(shortcuts),
iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { }); iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { });
Assert.Equal(0x5001u, slots[Row1[0]].Cell.ItemId); Assert.Equal(0x5001u, slots[Row1[0]].Cell.ItemId);
@ -134,7 +142,7 @@ public class ToolbarControllerTests
ToolbarController.Bind( ToolbarController.Bind(
layout, layout,
repo, repo,
() => shortcuts, Store(shortcuts),
iconIds: (_, _, _, _, _) => 0x77u, iconIds: (_, _, _, _, _) => 0x77u,
useItem: _ => { }); useItem: _ => { });
Assert.Equal(0x5001u, slots[Row1[0]].Cell.ItemId); Assert.Equal(0x5001u, slots[Row1[0]].Cell.ItemId);
@ -152,7 +160,7 @@ public class ToolbarControllerTests
var shortcuts = new List<ShortcutEntry> var shortcuts = new List<ShortcutEntry>
{ new(Index: 2, ObjectId: 0x5002u, SpellId: 0) }; { new(Index: 2, ObjectId: 0x5002u, SpellId: 0) };
ToolbarController.Bind(layout, repo, () => shortcuts, ToolbarController.Bind(layout, repo, Store(shortcuts),
iconIds: (_,_,_,_,_) => 0x88u, useItem: _ => { }); iconIds: (_,_,_,_,_) => 0x88u, useItem: _ => { });
Assert.Equal(0u, slots[Row1[2]].Cell.ItemId); // not bound yet Assert.Equal(0u, slots[Row1[2]].Cell.ItemId); // not bound yet
@ -168,7 +176,7 @@ public class ToolbarControllerTests
var repo = new ClientObjectTable(); var repo = new ClientObjectTable();
var shortcuts = new List<ShortcutEntry> var shortcuts = new List<ShortcutEntry>
{ new(Index: 2, ObjectId: 0x5002u, SpellId: 0) }; { 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: _ => { }); iconIds: (_,_,_,_,_) => 0x88u, useItem: _ => { });
controller.Dispose(); controller.Dispose();
@ -194,7 +202,7 @@ public class ToolbarControllerTests
uint used = 0; uint used = 0;
var selection = new SelectionState(); var selection = new SelectionState();
ToolbarController.Bind(layout, repo, () => shortcuts, ToolbarController.Bind(layout, repo, Store(shortcuts),
iconIds: (_,_,_,_,_) => 0x77u, iconIds: (_,_,_,_,_) => 0x77u,
useItem: g => used = g, useItem: g => used = g,
selection: selection); selection: selection);
@ -217,7 +225,7 @@ public class ToolbarControllerTests
var toggles = new List<uint>(); var toggles = new List<uint>();
var ctrl = ToolbarController.Bind(layout, repo, var ctrl = ToolbarController.Bind(layout, repo,
() => Array.Empty<ShortcutEntry>(), new ShortcutStore(),
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }); iconIds: (_,_,_,_,_) => 0u, useItem: _ => { });
ctrl.BindPanelButtons( ctrl.BindPanelButtons(
panelId => panelId is RetailPanelCatalog.Inventory or RetailPanelCatalog.Character, panelId => panelId is RetailPanelCatalog.Inventory or RetailPanelCatalog.Character,
@ -257,6 +265,7 @@ public class ToolbarControllerTests
var useWithTarget = new List<(uint Source, uint Target)>(); var useWithTarget = new List<(uint Source, uint Target)>();
var interaction = new ItemInteractionController( var interaction = new ItemInteractionController(
repo, repo,
new InventoryTransactionState(repo),
playerGuid: () => player, playerGuid: () => player,
sendUse: null, sendUse: null,
sendUseWithTarget: (source, target) => useWithTarget.Add((source, target)), sendUseWithTarget: (source, target) => useWithTarget.Add((source, target)),
@ -266,7 +275,7 @@ public class ToolbarControllerTests
int inventoryClicks = 0; int inventoryClicks = 0;
var ctrl = ToolbarController.Bind(layout, repo, var ctrl = ToolbarController.Bind(layout, repo,
() => Array.Empty<ShortcutEntry>(), new ShortcutStore(),
iconIds: (_,_,_,_,_) => 0u, iconIds: (_,_,_,_,_) => 0u,
useItem: _ => { }, useItem: _ => { },
itemInteraction: interaction); itemInteraction: interaction);
@ -293,7 +302,7 @@ public class ToolbarControllerTests
repo.AddOrUpdate(new ClientObject { ObjectId = item, Type = ItemType.Misc }); repo.AddOrUpdate(new ClientObject { ObjectId = item, Type = ItemType.Misc });
var puts = new List<(uint Item, uint Container, int Placement)>(); var puts = new List<(uint Item, uint Container, int Placement)>();
ToolbarController.Bind(layout, repo, ToolbarController.Bind(layout, repo,
() => Array.Empty<ShortcutEntry>(), new ShortcutStore(),
iconIds: (_, _, _, _, _) => 0u, iconIds: (_, _, _, _, _) => 0u,
useItem: _ => { }, useItem: _ => { },
playerGuid: () => player, playerGuid: () => player,
@ -322,6 +331,7 @@ public class ToolbarControllerTests
var directPuts = new List<(uint Item, uint Container, int Placement)>(); var directPuts = new List<(uint Item, uint Container, int Placement)>();
using var interaction = new ItemInteractionController( using var interaction = new ItemInteractionController(
repo, repo,
new InventoryTransactionState(repo),
playerGuid: () => player, playerGuid: () => player,
sendUse: null, sendUse: null,
sendUseWithTarget: null, sendUseWithTarget: null,
@ -331,7 +341,7 @@ public class ToolbarControllerTests
ToolbarController.Bind( ToolbarController.Bind(
layout, layout,
repo, repo,
() => Array.Empty<ShortcutEntry>(), new ShortcutStore(),
iconIds: static (_, _, _, _, _) => 0u, iconIds: static (_, _, _, _, _) => 0u,
useItem: static _ => { }, useItem: static _ => { },
itemInteraction: interaction, itemInteraction: interaction,
@ -370,6 +380,7 @@ public class ToolbarControllerTests
var messages = new List<string>(); var messages = new List<string>();
using var interaction = new ItemInteractionController( using var interaction = new ItemInteractionController(
repo, repo,
new InventoryTransactionState(repo),
playerGuid: () => player, playerGuid: () => player,
sendUse: null, sendUse: null,
sendUseWithTarget: null, sendUseWithTarget: null,
@ -379,7 +390,7 @@ public class ToolbarControllerTests
ToolbarController.Bind( ToolbarController.Bind(
layout, layout,
repo, repo,
() => Array.Empty<ShortcutEntry>(), new ShortcutStore(),
iconIds: static (_, _, _, _, _) => 0u, iconIds: static (_, _, _, _, _) => 0u,
useItem: static _ => { }, useItem: static _ => { },
itemInteraction: interaction, itemInteraction: interaction,
@ -411,7 +422,7 @@ public class ToolbarControllerTests
repo.AddOrUpdate(new ClientObject { ObjectId = item, Type = ItemType.Misc }); repo.AddOrUpdate(new ClientObject { ObjectId = item, Type = ItemType.Misc });
var puts = new List<(uint Item, uint Container, int Placement)>(); var puts = new List<(uint Item, uint Container, int Placement)>();
ToolbarController.Bind(layout, repo, ToolbarController.Bind(layout, repo,
() => Array.Empty<ShortcutEntry>(), new ShortcutStore(),
iconIds: (_, _, _, _, _) => 0u, iconIds: (_, _, _, _, _) => 0u,
useItem: _ => { }, useItem: _ => { },
playerGuid: () => player, playerGuid: () => player,
@ -432,7 +443,7 @@ public class ToolbarControllerTests
var (layout, _, _) = FakeToolbar(); var (layout, _, _) = FakeToolbar();
var repo = new ClientObjectTable(); var repo = new ClientObjectTable();
var ctrl = ToolbarController.Bind(layout, repo, var ctrl = ToolbarController.Bind(layout, repo,
() => Array.Empty<ShortcutEntry>(), new ShortcutStore(),
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }); iconIds: (_,_,_,_,_) => 0u, useItem: _ => { });
var inventoryButton = (UiButton)layout.FindElement(InventoryButtonId)!; var inventoryButton = (UiButton)layout.FindElement(InventoryButtonId)!;
var characterButton = (UiButton)layout.FindElement(CharacterButtonId)!; var characterButton = (UiButton)layout.FindElement(CharacterButtonId)!;
@ -498,6 +509,7 @@ public class ToolbarControllerTests
uint selected = item; uint selected = item;
var interaction = new ItemInteractionController( var interaction = new ItemInteractionController(
repo, repo,
new InventoryTransactionState(repo),
() => player, () => player,
sendUse: uses.Add, sendUse: uses.Add,
sendUseWithTarget: null, sendUseWithTarget: null,
@ -507,7 +519,7 @@ public class ToolbarControllerTests
ToolbarController.Bind( ToolbarController.Bind(
layout, layout,
repo, repo,
() => Array.Empty<ShortcutEntry>(), new ShortcutStore(),
iconIds: (_, _, _, _, _) => 0u, iconIds: (_, _, _, _, _) => 0u,
useItem: _ => { }, useItem: _ => { },
itemInteraction: interaction, itemInteraction: interaction,
@ -555,6 +567,7 @@ public class ToolbarControllerTests
var selection = new SelectionState(); var selection = new SelectionState();
using var interaction = new ItemInteractionController( using var interaction = new ItemInteractionController(
repo, repo,
new InventoryTransactionState(repo),
() => player, () => player,
sendUse: null, sendUse: null,
sendUseWithTarget: null, sendUseWithTarget: null,
@ -563,7 +576,7 @@ public class ToolbarControllerTests
using var controller = ToolbarController.Bind( using var controller = ToolbarController.Bind(
layout, layout,
repo, repo,
() => Array.Empty<ShortcutEntry>(), new ShortcutStore(),
iconIds: (_, _, _, _, _) => 0u, iconIds: (_, _, _, _, _) => 0u,
useItem: _ => { }, useItem: _ => { },
itemInteraction: interaction, itemInteraction: interaction,
@ -619,6 +632,7 @@ public class ToolbarControllerTests
var wields = new List<(uint Item, uint Location)>(); var wields = new List<(uint Item, uint Location)>();
using var interaction = new ItemInteractionController( using var interaction = new ItemInteractionController(
repo, repo,
new InventoryTransactionState(repo),
() => player, () => player,
sendUse: null, sendUse: null,
sendUseWithTarget: null, sendUseWithTarget: null,
@ -627,7 +641,7 @@ public class ToolbarControllerTests
using var controller = ToolbarController.Bind( using var controller = ToolbarController.Bind(
layout, layout,
repo, repo,
() => Array.Empty<ShortcutEntry>(), new ShortcutStore(),
iconIds: (_, _, _, _, _) => 0u, iconIds: (_, _, _, _, _) => 0u,
useItem: _ => { }, useItem: _ => { },
itemInteraction: interaction, itemInteraction: interaction,
@ -670,7 +684,7 @@ public class ToolbarControllerTests
ToolbarController.Bind( ToolbarController.Bind(
layout, layout,
repo, repo,
() => Array.Empty<ShortcutEntry>(), new ShortcutStore(),
iconIds: (_, _, _, _, _) => 0u, iconIds: (_, _, _, _, _) => 0u,
useItem: _ => { }, useItem: _ => { },
playerGuid: () => player); playerGuid: () => player);
@ -709,7 +723,7 @@ public class ToolbarControllerTests
ToolbarController.Bind( ToolbarController.Bind(
layout, layout,
repo, repo,
() => Array.Empty<ShortcutEntry>(), new ShortcutStore(),
iconIds: (_, _, _, _, _) => 0u, iconIds: (_, _, _, _, _) => 0u,
useItem: _ => { }, useItem: _ => { },
playerGuid: () => player); playerGuid: () => player);
@ -731,7 +745,7 @@ public class ToolbarControllerTests
ToolbarController.Bind( ToolbarController.Bind(
layout, layout,
repo, repo,
() => Array.Empty<ShortcutEntry>(), new ShortcutStore(),
iconIds: (_, _, _, _, _) => 0u, iconIds: (_, _, _, _, _) => 0u,
useItem: _ => { }, useItem: _ => { },
toggleCombat: () => toggles++); toggleCombat: () => toggles++);
@ -758,7 +772,7 @@ public class ToolbarControllerTests
var controller = ToolbarController.Bind( var controller = ToolbarController.Bind(
layout, layout,
repo, repo,
() => shortcuts, Store(shortcuts),
iconIds: (_, _, _, _, _) => 1u, iconIds: (_, _, _, _, _) => 1u,
useItem: id => used = id, useItem: id => used = id,
selectItem: id => selected = id); selectItem: id => selected = id);
@ -784,6 +798,7 @@ public class ToolbarControllerTests
var appraisals = new List<uint>(); var appraisals = new List<uint>();
using var interaction = new ItemInteractionController( using var interaction = new ItemInteractionController(
repo, repo,
new InventoryTransactionState(repo),
playerGuid: () => player, playerGuid: () => player,
sendUse: null, sendUse: null,
sendUseWithTarget: null, sendUseWithTarget: null,
@ -793,7 +808,7 @@ public class ToolbarControllerTests
using var controller = ToolbarController.Bind( using var controller = ToolbarController.Bind(
layout, layout,
repo, repo,
() => shortcuts, Store(shortcuts),
iconIds: (_, _, _, _, _) => 1u, iconIds: (_, _, _, _, _) => 1u,
useItem: _ => { }, useItem: _ => { },
itemInteraction: interaction, itemInteraction: interaction,
@ -832,6 +847,7 @@ public class ToolbarControllerTests
uint sentTarget = 0; uint sentTarget = 0;
var interaction = new ItemInteractionController( var interaction = new ItemInteractionController(
repo, repo,
new InventoryTransactionState(repo),
() => player, () => player,
sendUse: null, sendUse: null,
sendUseWithTarget: (s, t) => (sentSource, sentTarget) = (s, t), sendUseWithTarget: (s, t) => (sentSource, sentTarget) = (s, t),
@ -845,7 +861,7 @@ public class ToolbarControllerTests
var controller = ToolbarController.Bind( var controller = ToolbarController.Bind(
layout, layout,
repo, repo,
() => shortcuts, Store(shortcuts),
iconIds: (_, _, _, _, _) => 1u, iconIds: (_, _, _, _, _) => 1u,
useItem: _ => { }, useItem: _ => { },
itemInteraction: interaction); itemInteraction: interaction);
@ -873,6 +889,7 @@ public class ToolbarControllerTests
repo.MoveItem(item, pack, 0); repo.MoveItem(item, pack, 0);
var interaction = new ItemInteractionController( var interaction = new ItemInteractionController(
repo, repo,
new InventoryTransactionState(repo),
() => player, () => player,
sendUse: null, sendUse: null,
sendUseWithTarget: null, sendUseWithTarget: null,
@ -882,7 +899,7 @@ public class ToolbarControllerTests
var controller = ToolbarController.Bind( var controller = ToolbarController.Bind(
layout, layout,
repo, repo,
() => Array.Empty<ShortcutEntry>(), new ShortcutStore(),
iconIds: (_, _, _, _, _) => 1u, iconIds: (_, _, _, _, _) => 1u,
useItem: _ => { }, useItem: _ => { },
itemInteraction: interaction, itemInteraction: interaction,
@ -908,7 +925,7 @@ public class ToolbarControllerTests
var repo = new ClientObjectTable(); var repo = new ClientObjectTable();
ToolbarController.Bind(layout, repo, ToolbarController.Bind(layout, repo,
() => Array.Empty<ShortcutEntry>(), new ShortcutStore(),
iconIds: (_,_,_,_,_) =>0u, useItem: _ => { }); iconIds: (_,_,_,_,_) =>0u, useItem: _ => { });
// Only peace indicator (index 0 = 0x10000192) is visible. // Only peace indicator (index 0 = 0x10000192) is visible.
@ -928,7 +945,7 @@ public class ToolbarControllerTests
var repo = new ClientObjectTable(); var repo = new ClientObjectTable();
var ctrl = ToolbarController.Bind(layout, repo, var ctrl = ToolbarController.Bind(layout, repo,
() => Array.Empty<ShortcutEntry>(), new ShortcutStore(),
iconIds: (_,_,_,_,_) =>0u, useItem: _ => { }); iconIds: (_,_,_,_,_) =>0u, useItem: _ => { });
ctrl.SetCombatMode(CombatMode.Melee); ctrl.SetCombatMode(CombatMode.Melee);
@ -950,7 +967,7 @@ public class ToolbarControllerTests
var combat = new CombatState(); var combat = new CombatState();
ToolbarController.Bind(layout, repo, ToolbarController.Bind(layout, repo,
() => Array.Empty<ShortcutEntry>(), new ShortcutStore(),
iconIds: (_,_,_,_,_) =>0u, useItem: _ => { }, iconIds: (_,_,_,_,_) =>0u, useItem: _ => { },
combatState: combat); combatState: combat);
@ -989,7 +1006,7 @@ public class ToolbarControllerTests
var repo = new ClientObjectTable(); var repo = new ClientObjectTable();
ToolbarController.Bind(layout, repo, ToolbarController.Bind(layout, repo,
() => Array.Empty<ShortcutEntry>(), new ShortcutStore(),
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }, iconIds: (_,_,_,_,_) => 0u, useItem: _ => { },
regularDigits: FakeRegular, ghostedDigits: FakeGhosted); regularDigits: FakeRegular, ghostedDigits: FakeGhosted);
@ -1018,7 +1035,7 @@ public class ToolbarControllerTests
var (layout, slots, _) = FakeToolbar(); var (layout, slots, _) = FakeToolbar();
var repo = new ClientObjectTable(); var repo = new ClientObjectTable();
var ctrl = ToolbarController.Bind(layout, repo, var ctrl = ToolbarController.Bind(layout, repo,
() => Array.Empty<ShortcutEntry>(), new ShortcutStore(),
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }, iconIds: (_,_,_,_,_) => 0u, useItem: _ => { },
regularDigits: FakeRegular, ghostedDigits: FakeGhosted); regularDigits: FakeRegular, ghostedDigits: FakeGhosted);
@ -1046,7 +1063,7 @@ public class ToolbarControllerTests
var (layout, slots, _) = FakeToolbar(); var (layout, slots, _) = FakeToolbar();
var repo = new ClientObjectTable(); var repo = new ClientObjectTable();
var ctrl = ToolbarController.Bind(layout, repo, var ctrl = ToolbarController.Bind(layout, repo,
() => Array.Empty<ShortcutEntry>(), new ShortcutStore(),
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }, iconIds: (_,_,_,_,_) => 0u, useItem: _ => { },
regularDigits: FakeRegular, ghostedDigits: FakeGhosted); regularDigits: FakeRegular, ghostedDigits: FakeGhosted);
@ -1078,7 +1095,7 @@ public class ToolbarControllerTests
var repo = new ClientObjectTable(); var repo = new ClientObjectTable();
ToolbarController.Bind(layout, repo, ToolbarController.Bind(layout, repo,
() => Array.Empty<ShortcutEntry>(), new ShortcutStore(),
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }, iconIds: (_,_,_,_,_) => 0u, useItem: _ => { },
regularDigits: FakeRegular, ghostedDigits: FakeGhosted); regularDigits: FakeRegular, ghostedDigits: FakeGhosted);
@ -1100,7 +1117,7 @@ public class ToolbarControllerTests
var repo = new ClientObjectTable(); var repo = new ClientObjectTable();
ToolbarController.Bind(layout, repo, ToolbarController.Bind(layout, repo,
() => Array.Empty<ShortcutEntry>(), new ShortcutStore(),
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }, iconIds: (_,_,_,_,_) => 0u, useItem: _ => { },
regularDigits: FakeRegular, ghostedDigits: FakeGhosted, emptyDigits: FakeEmpty); regularDigits: FakeRegular, ghostedDigits: FakeGhosted, emptyDigits: FakeEmpty);
@ -1121,7 +1138,7 @@ public class ToolbarControllerTests
var repo = new ClientObjectTable(); var repo = new ClientObjectTable();
ToolbarController.Bind(layout, repo, ToolbarController.Bind(layout, repo,
() => Array.Empty<ShortcutEntry>(), new ShortcutStore(),
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }, iconIds: (_,_,_,_,_) => 0u, useItem: _ => { },
regularDigits: FakeRegular, ghostedDigits: FakeGhosted, emptyDigits: null); regularDigits: FakeRegular, ghostedDigits: FakeGhosted, emptyDigits: null);
@ -1147,7 +1164,7 @@ public class ToolbarControllerTests
{ new(Index: 0, ObjectId: 0x5001u, SpellId: 0) }; { new(Index: 0, ObjectId: 0x5001u, SpellId: 0) };
int iconCallCount = 0; int iconCallCount = 0;
ToolbarController.Bind(layout, repo, () => shortcuts, ToolbarController.Bind(layout, repo, Store(shortcuts),
iconIds: (_,_,_,_,_) => { iconCallCount++; return 0x77u; }, useItem: _ => { }); iconIds: (_,_,_,_,_) => { iconCallCount++; return 0x77u; }, useItem: _ => { });
int callsAfterBind = iconCallCount; // 1 call from initial Populate int callsAfterBind = iconCallCount; // 1 call from initial Populate
@ -1172,7 +1189,7 @@ public class ToolbarControllerTests
{ new(Index: 1, ObjectId: 0x5003u, SpellId: 0) }; { new(Index: 1, ObjectId: 0x5003u, SpellId: 0) };
int iconCallCount = 0; int iconCallCount = 0;
ToolbarController.Bind(layout, repo, () => shortcuts, ToolbarController.Bind(layout, repo, Store(shortcuts),
iconIds: (_,_,_,_,_) => { iconCallCount++; return 0x99u; }, useItem: _ => { }); iconIds: (_,_,_,_,_) => { iconCallCount++; return 0x99u; }, useItem: _ => { });
Assert.Equal(0, iconCallCount); // not called — item absent during initial Populate Assert.Equal(0, iconCallCount); // not called — item absent during initial Populate
@ -1198,7 +1215,7 @@ public class ToolbarControllerTests
var shortcuts = new List<ShortcutEntry> var shortcuts = new List<ShortcutEntry>
{ new(Index: 3, ObjectId: 0x5004u, SpellId: 0) }; { new(Index: 3, ObjectId: 0x5004u, SpellId: 0) };
ToolbarController.Bind(layout, repo, () => shortcuts, ToolbarController.Bind(layout, repo, Store(shortcuts),
iconIds: (_,_,_,_,_) => 0xAAu, useItem: _ => { }); iconIds: (_,_,_,_,_) => 0xAAu, useItem: _ => { });
Assert.Equal(0x5004u, slots[Row1[3]].Cell.ItemId); // bound Assert.Equal(0x5004u, slots[Row1[3]].Cell.ItemId); // bound
@ -1225,7 +1242,7 @@ public class ToolbarControllerTests
{ new(Index: 4, ObjectId: 0x5005u, SpellId: 0) }; { new(Index: 4, ObjectId: 0x5005u, SpellId: 0) };
int iconCallCount = 0; int iconCallCount = 0;
ToolbarController.Bind(layout, repo, () => shortcuts, ToolbarController.Bind(layout, repo, Store(shortcuts),
iconIds: (_,_,_,_,_) => { iconCallCount++; return 0xBBu; }, useItem: _ => { }); iconIds: (_,_,_,_,_) => { iconCallCount++; return 0xBBu; }, useItem: _ => { });
int callsAfterBind = iconCallCount; // 1 call for the shortcut item int callsAfterBind = iconCallCount; // 1 call for the shortcut item
@ -1247,7 +1264,7 @@ public class ToolbarControllerTests
var repo = new ClientObjectTable(); var repo = new ClientObjectTable();
var ctrl = ToolbarController.Bind(layout, repo, var ctrl = ToolbarController.Bind(layout, repo,
() => Array.Empty<ShortcutEntry>(), new ShortcutStore(),
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }); iconIds: (_,_,_,_,_) => 0u, useItem: _ => { });
for (int i = 0; i < Row1.Length; i++) for (int i = 0; i < Row1.Length; i++)
@ -1272,7 +1289,7 @@ public class ToolbarControllerTests
{ {
var (layout, slots, _) = FakeToolbar(); var (layout, slots, _) = FakeToolbar();
var ctrl = ToolbarController.Bind(layout, new ClientObjectTable(), var ctrl = ToolbarController.Bind(layout, new ClientObjectTable(),
() => Array.Empty<ShortcutEntry>(), new ShortcutStore(),
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }); iconIds: (_,_,_,_,_) => 0u, useItem: _ => { });
var list = slots[Row1[0]]; var list = slots[Row1[0]];
@ -1287,7 +1304,7 @@ public class ToolbarControllerTests
{ {
var (layout, slots, _) = FakeToolbar(); var (layout, slots, _) = FakeToolbar();
var ctrl = ToolbarController.Bind(layout, new ClientObjectTable(), var ctrl = ToolbarController.Bind(layout, new ClientObjectTable(),
() => Array.Empty<ShortcutEntry>(), new ShortcutStore(),
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }); iconIds: (_,_,_,_,_) => 0u, useItem: _ => { });
var list = slots[Row1[0]]; var list = slots[Row1[0]];
var payload = new ItemDragPayload(0u, ItemDragSource.Inventory, 0, new UiItemSlot()); 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); var (adds, removes) = NewSpies(out var add, out var rem);
uint selected = 0; uint selected = 0;
var ctrl = ToolbarController.Bind(layout, repo, () => shortcuts, var ctrl = ToolbarController.Bind(layout, repo, Store(shortcuts),
iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { }, iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { },
sendAddShortcut: add, sendRemoveShortcut: rem, sendAddShortcut: add, sendRemoveShortcut: rem,
selectItem: item => selected = item, selectedObjectId: () => selected); selectItem: item => selected = item, selectedObjectId: () => selected);
@ -1341,7 +1358,7 @@ public class ToolbarControllerTests
{ new(Index: 3, ObjectId: 0x5001u, SpellId: 0), { new(Index: 3, ObjectId: 0x5001u, SpellId: 0),
new(Index: 5, ObjectId: 0x5002u, SpellId: 0) }; new(Index: 5, ObjectId: 0x5002u, SpellId: 0) };
var (adds, removes) = NewSpies(out var add, out var rem); 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: _ => { }, iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { },
sendAddShortcut: add, sendRemoveShortcut: rem); sendAddShortcut: add, sendRemoveShortcut: rem);
@ -1370,7 +1387,7 @@ public class ToolbarControllerTests
}; };
var (adds, _) = NewSpies(out var add, out var remove); var (adds, _) = NewSpies(out var add, out var remove);
var controller = ToolbarController.Bind( var controller = ToolbarController.Bind(
layout, repo, () => shortcuts, layout, repo, Store(shortcuts),
iconIds: (_, _, _, _, _) => 1u, iconIds: (_, _, _, _, _) => 1u,
useItem: _ => { }, useItem: _ => { },
sendAddShortcut: add, sendAddShortcut: add,
@ -1393,7 +1410,7 @@ public class ToolbarControllerTests
var shortcuts = new System.Collections.Generic.List<ShortcutEntry> var shortcuts = new System.Collections.Generic.List<ShortcutEntry>
{ new(Index: 3, ObjectId: 0x5001u, SpellId: 0) }; { new(Index: 3, ObjectId: 0x5001u, SpellId: 0) };
var (adds, removes) = NewSpies(out var add, out var rem); 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: _ => { }, iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { },
sendAddShortcut: add, sendRemoveShortcut: rem); sendAddShortcut: add, sendRemoveShortcut: rem);
@ -1419,7 +1436,7 @@ public class ToolbarControllerTests
new ShortcutEntry(5, 0x5002u, 0xA5C31234u), new ShortcutEntry(5, 0x5002u, 0xA5C31234u),
}; };
var wire = new List<string>(); var wire = new List<string>();
var ctrl = ToolbarController.Bind(layout, repo, () => shortcuts, var ctrl = ToolbarController.Bind(layout, repo, Store(shortcuts),
iconIds: (_, _, _, _, _) => 1u, iconIds: (_, _, _, _, _) => 1u,
useItem: _ => { }, useItem: _ => { },
sendAddShortcut: entry => wire.Add($"add:{entry.Index}:{entry.ObjectId:X8}:{entry.SpellId:X8}"), 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), new ShortcutEntry(4, 0x5001u, 0x11223344u),
}; };
var wire = new List<string>(); var wire = new List<string>();
var ctrl = ToolbarController.Bind(layout, repo, () => shortcuts, var ctrl = ToolbarController.Bind(layout, repo, Store(shortcuts),
iconIds: (_, _, _, _, _) => 1u, iconIds: (_, _, _, _, _) => 1u,
useItem: _ => { }, useItem: _ => { },
sendAddShortcut: entry => wire.Add($"add:{entry.Index}:{entry.ObjectId:X8}:{entry.SpellId:X8}"), 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<ShortcutEntry> var shortcuts = new System.Collections.Generic.List<ShortcutEntry>
{ new(Index: 3, ObjectId: 0x5001u, SpellId: 0) }; { new(Index: 3, ObjectId: 0x5001u, SpellId: 0) };
var (adds, removes) = NewSpies(out var add, out var rem); 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: _ => { }, iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { },
sendAddShortcut: add, sendRemoveShortcut: rem); sendAddShortcut: add, sendRemoveShortcut: rem);
@ -1496,7 +1513,7 @@ public class ToolbarControllerTests
{ {
var (layout, slots, _) = FakeToolbar(); var (layout, slots, _) = FakeToolbar();
ToolbarController.Bind(layout, new ClientObjectTable(), ToolbarController.Bind(layout, new ClientObjectTable(),
() => System.Array.Empty<ShortcutEntry>(), new ShortcutStore(),
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }); iconIds: (_,_,_,_,_) => 0u, useItem: _ => { });
Assert.Equal(0x060011FAu, slots[Row1[0]].Cell.DragAcceptSprite); // green cross, not the ring F9 Assert.Equal(0x060011FAu, slots[Row1[0]].Cell.DragAcceptSprite); // green cross, not the ring F9
} }

View file

@ -18,6 +18,7 @@ public sealed class RetailItemConfirmationControllerTests
var uses = new List<uint>(); var uses = new List<uint>();
var items = new ItemInteractionController( var items = new ItemInteractionController(
objects, objects,
new InventoryTransactionState(objects),
playerGuid: () => Player, playerGuid: () => Player,
sendUse: uses.Add, sendUse: uses.Add,
sendUseWithTarget: null, sendUseWithTarget: null,
@ -52,6 +53,7 @@ public sealed class RetailItemConfirmationControllerTests
var uses = new List<uint>(); var uses = new List<uint>();
var items = new ItemInteractionController( var items = new ItemInteractionController(
objects, objects,
new InventoryTransactionState(objects),
playerGuid: () => Player, playerGuid: () => Player,
sendUse: uses.Add, sendUse: uses.Add,
sendUseWithTarget: null, sendUseWithTarget: null,
@ -80,6 +82,7 @@ public sealed class RetailItemConfirmationControllerTests
var messages = new List<string>(); var messages = new List<string>();
var items = new ItemInteractionController( var items = new ItemInteractionController(
objects, objects,
new InventoryTransactionState(objects),
playerGuid: () => Player, playerGuid: () => Player,
sendUse: uses.Add, sendUse: uses.Add,
sendUseWithTarget: null, sendUseWithTarget: null,

View file

@ -163,6 +163,7 @@ public sealed class RetailUiInteractionFlowTests
{ {
var interaction = new ItemInteractionController( var interaction = new ItemInteractionController(
Objects, Objects,
new InventoryTransactionState(Objects),
playerGuid: () => Player, playerGuid: () => Player,
sendUse: Uses.Add, sendUse: Uses.Add,
sendUseWithTarget: (source, target) => UseWithTarget.Add((source, target)), sendUseWithTarget: (source, target) => UseWithTarget.Add((source, target)),
@ -197,6 +198,7 @@ public sealed class RetailUiInteractionFlowTests
{ {
itemInteraction ??= new ItemInteractionController( itemInteraction ??= new ItemInteractionController(
Objects, Objects,
new InventoryTransactionState(Objects),
() => Player, () => Player,
sendUse: null, sendUse: null,
sendUseWithTarget: null, sendUseWithTarget: null,

View file

@ -41,4 +41,41 @@ public class ShortcutStoreTests
store.SetItem(18, 1u); // no-op, no throw store.SetItem(18, 1u); // no-op, no throw
store.Remove(-1); // 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<ObjectDisposedException>(
() => store.SetItem(1, 1u));
}
} }

View file

@ -115,7 +115,7 @@ public sealed class RuntimeCharacterStateTests
} }
[Fact] [Fact]
public void DisposalRetriesObserverFailuresAndClearsBothOwners() public void DisposalReportsObserverFailuresAfterTerminalConvergence()
{ {
var state = new RuntimeCharacterState(); var state = new RuntimeCharacterState();
state.Spellbook.OnSpellLearned(7u); state.Spellbook.OnSpellLearned(7u);
@ -143,13 +143,13 @@ public sealed class RuntimeCharacterStateTests
state.Dispose); state.Dispose);
Assert.Equal(2, error.InnerExceptions.Count); Assert.Equal(2, error.InnerExceptions.Count);
Assert.False(state.IsDisposed); Assert.True(state.IsDisposed);
Assert.False(state.Spellbook.Knows(7u)); Assert.False(state.Spellbook.Knows(7u));
Assert.Null(state.LocalPlayer.Get( Assert.Null(state.LocalPlayer.Get(
AcDream.Core.Player.LocalPlayerState.VitalKind.Health)); AcDream.Core.Player.LocalPlayerState.VitalKind.Health));
Assert.True(state.CaptureOwnership().IsConverged);
state.Dispose(); state.Dispose();
state.Dispose();
Assert.True(state.IsDisposed); Assert.True(state.IsDisposed);
} }
@ -254,4 +254,77 @@ public sealed class RuntimeCharacterStateTests
Assert.True(first.View.Snapshot.SpellbookRevision > 0); Assert.True(first.View.Snapshot.SpellbookRevision > 0);
Assert.Equal(0, second.View.Snapshot.SpellbookRevision); Assert.Equal(0, second.View.Snapshot.SpellbookRevision);
} }
[Fact]
public void SpellbookCommandsFollowRetailLocalAndOutboundOrder()
{
using var state = new RuntimeCharacterState();
var order = new List<string>();
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<InvalidOperationException>(
() => 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);
}
} }

View file

@ -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<string, uint> { ["account"] = 1u },
new Dictionary<uint, SquelchInfo>
{
[2u] = new SquelchInfo(
"Character",
false,
new HashSet<uint> { 3u }),
},
new SquelchInfo(
string.Empty,
false,
new HashSet<uint> { 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<AggregateException>(inventory.Dispose);
Assert.Throws<AggregateException>(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");
}
}

View file

@ -11,7 +11,7 @@ public sealed class RuntimeInventoryStateTests
{ {
using var entities = new RuntimeEntityObjectLifetime(); using var entities = new RuntimeEntityObjectLifetime();
using var inventory = new RuntimeInventoryState(entities); 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(3, 0x80000001u, 0u),
new AcDream.Core.Items.ShortcutEntry(4, 0u, 42u), new AcDream.Core.Items.ShortcutEntry(4, 0u, 42u),
@ -55,12 +55,12 @@ public sealed class RuntimeInventoryStateTests
inventory.ExternalContainers.RequestOpen(0x70000001u); inventory.ExternalContainers.RequestOpen(0x70000001u);
inventory.ExternalContainers.ApplyViewContents(0x70000001u); inventory.ExternalContainers.ApplyViewContents(0x70000001u);
inventory.ItemMana.OnQueryItemManaResponse(0x50000001u, 0.75f, true); inventory.ItemMana.OnQueryItemManaResponse(0x50000001u, 0.75f, true);
inventory.Shortcuts.Replace(shortcuts); inventory.Shortcuts.Load(shortcuts);
Assert.Same(entities.Objects, inventory.Objects); Assert.Same(entities.Objects, inventory.Objects);
Assert.Equal(0x70000001u, inventory.ExternalContainers.CurrentContainerId); Assert.Equal(0x70000001u, inventory.ExternalContainers.CurrentContainerId);
Assert.Equal(0.75f, inventory.ItemMana.GetManaPercent(0x50000001u)); 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); Assert.Equal(1, inventory.Shortcuts.Revision);
} }
@ -72,7 +72,7 @@ public sealed class RuntimeInventoryStateTests
inventory.ExternalContainers.RequestOpen(0x70000001u); inventory.ExternalContainers.RequestOpen(0x70000001u);
inventory.ExternalContainers.ApplyViewContents(0x70000001u); inventory.ExternalContainers.ApplyViewContents(0x70000001u);
inventory.ItemMana.OnQueryItemManaResponse(0x50000001u, 0.75f, true); 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.Transactions.IncrementBusyCount();
inventory.ResetExternalContainer(); inventory.ResetExternalContainer();
@ -98,7 +98,7 @@ public sealed class RuntimeInventoryStateTests
using var first = new RuntimeInventoryState(firstEntities); using var first = new RuntimeInventoryState(firstEntities);
using var second = new RuntimeInventoryState(secondEntities); 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(); first.Transactions.IncrementBusyCount();
Assert.NotSame(first.Objects, second.Objects); Assert.NotSame(first.Objects, second.Objects);
@ -116,7 +116,7 @@ public sealed class RuntimeInventoryStateTests
inventory.ExternalContainers.RequestOpen(0x70000001u); inventory.ExternalContainers.RequestOpen(0x70000001u);
inventory.ExternalContainers.ApplyViewContents(0x70000001u); inventory.ExternalContainers.ApplyViewContents(0x70000001u);
inventory.ItemMana.OnQueryItemManaResponse(0x50000001u, 0.5f, true); 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();
inventory.Dispose(); inventory.Dispose();
@ -129,7 +129,7 @@ public sealed class RuntimeInventoryStateTests
} }
[Fact] [Fact]
public void DisposalRetriesATransientOwnerObserverFailureToConvergence() public void DisposalFailureIsReportedAfterTerminalOwnerConvergence()
{ {
using var entities = new RuntimeEntityObjectLifetime(); using var entities = new RuntimeEntityObjectLifetime();
var inventory = new RuntimeInventoryState(entities); var inventory = new RuntimeInventoryState(entities);
@ -146,12 +146,72 @@ public sealed class RuntimeInventoryStateTests
}; };
Assert.Throws<AggregateException>(inventory.Dispose); Assert.Throws<AggregateException>(inventory.Dispose);
Assert.False(inventory.IsDisposed); Assert.True(inventory.IsDisposed);
Assert.True(inventory.Transactions.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<string>();
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<InvalidOperationException>(
() => 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(); inventory.Dispose();
Assert.True(inventory.IsDisposed); 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);
} }
} }