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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -209,7 +209,6 @@ public sealed class SpellcastingUiController : IRetainedPanelController
}
int position = spells.Count;
_addFavorite?.Invoke(_activeTab, position, spellId);
_spellbook.SetFavorite(_activeTab, position, spellId);
SelectSpell(spellId);
}
@ -424,13 +423,15 @@ public sealed class SpellcastingUiController : IRetainedPanelController
private void BeginFavoriteDrag(SpellFavoriteDragPayload payload)
=> _removeFavorite?.Invoke(payload.SourceTab, payload.SpellId);
private void EndFavoriteDrag(SpellFavoriteDragPayload payload)
=> _spellbook.RemoveFavorite(payload.SourceTab, payload.SpellId);
private static void EndFavoriteDrag(SpellFavoriteDragPayload payload)
{
// The press-time command already performed retail's PlayerModule
// removal on the one Runtime-owned Spellbook.
}
private void DropFavorite(SpellFavoriteDragPayload payload, int targetTab, int targetPosition)
{
_addFavorite?.Invoke(targetTab, targetPosition, payload.SpellId);
_spellbook.SetFavorite(targetTab, targetPosition, payload.SpellId);
_selected[targetTab] = payload.SpellId;
}
@ -443,7 +444,6 @@ public sealed class SpellcastingUiController : IRetainedPanelController
// CM_Magic::SendNotice_AddSpellShortcut; the open spellcasting menu
// chooses the favorite tab/position and persists it.
_addFavorite?.Invoke(targetTab, targetPosition, payload.SpellId);
_spellbook.SetFavorite(targetTab, targetPosition, payload.SpellId);
_selected[targetTab] = payload.SpellId;
}

View file

@ -63,12 +63,10 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
private readonly List<(uint PanelId, UiButton Button)> _panelButtons = new();
private readonly ClientObjectTable _repo;
private readonly CombatState? _combatState;
private readonly Func<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>? _dragIconIds;
private readonly Action<uint> _useItem; // guid → fire UseObject
private readonly ShortcutStore _store = new();
private bool _storeLoaded;
private readonly Action<ShortcutEntry>? _sendAddShortcut;
private readonly Action<uint>? _sendRemoveShortcut; // (index)
private readonly ItemInteractionController? _itemInteraction;
@ -95,7 +93,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
private ToolbarController(
ImportedLayout layout,
ClientObjectTable repo,
Func<IReadOnlyList<ShortcutEntry>> shortcuts,
ShortcutStore shortcuts,
Func<ItemType, uint, uint, uint, uint, uint> iconIds,
Action<uint> useItem,
CombatState? combatState,
@ -116,7 +114,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
{
_repo = repo;
_combatState = combatState;
_shortcuts = shortcuts;
_store = shortcuts ?? throw new ArgumentNullException(nameof(shortcuts));
_iconIds = iconIds;
_dragIconIds = dragIconIds;
_useItem = useItem;
@ -213,6 +211,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
repo.ObjectRemoved += OnRepositoryObjectChanged;
repo.ObjectMoved += OnRepositoryObjectMoved;
repo.Cleared += OnRepositoryCleared;
_store.Changed += Populate;
RefreshAmmo();
RefreshUseButton();
}
@ -296,21 +295,13 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
/// 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"/>).
/// 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>
private bool IsShortcutGuid(uint guid)
{
if (_storeLoaded)
{
for (int s = 0; s < AcDream.Core.Items.ShortcutStore.SlotCount; s++)
for (int s = 0; s < 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;
}
/// <summary>
/// Create and bind a <see cref="ToolbarController"/> to <paramref name="layout"/>.
@ -320,7 +311,10 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
/// </summary>
/// <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="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="useItem">Callback fired when a bound slot is clicked; receives the item guid.</param>
/// <param name="combatState">
@ -345,7 +339,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
public static ToolbarController Bind(
ImportedLayout layout,
ClientObjectTable repo,
Func<IReadOnlyList<ShortcutEntry>> shortcuts,
ShortcutStore shortcuts,
Func<ItemType, uint, uint, uint, uint, uint> iconIds,
Action<uint> useItem,
CombatState? combatState = null,
@ -417,19 +411,11 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
/// Entries whose item is not yet in the repo are silently skipped here; the
/// <c>ObjectAdded</c> event re-fires this method when the item arrives
/// (matching retail's <c>SetDelayedShortcutNum</c> deferred-rebind path).
/// As of B.2: the <see cref="AcDream.Core.Items.ShortcutStore"/> is the
/// authoritative in-session slot map; <c>_shortcuts()</c> seeds it on first call.
/// The Runtime-owned <see cref="AcDream.Core.Items.ShortcutStore"/> is the
/// authoritative in-session slot map.
/// </summary>
public void Populate()
{
// Lazy-load the store from the login shortcut list the first time it's present (PD arrives
// after Bind); thereafter the store is authoritative and drag ops mutate it directly.
if (!_storeLoaded && _shortcuts().Count > 0)
{
_store.Load(_shortcuts());
_storeLoaded = true;
}
foreach (var list in _slots) list?.Cell.Clear();
for (int slot = 0; slot < _slots.Length; slot++)
@ -654,7 +640,6 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
if (_itemInteraction?.IsOwnedByPlayer(itemId) != true)
return false;
EnsureStoreLoadedForMutation();
for (int slot = 0; slot < _slots.Length; slot++)
if (_store.Get(slot) == itemId)
return false;
@ -672,20 +657,11 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
return false;
var entry = new ShortcutEntry(empty, itemId, 0u);
_store.Set(entry);
_sendAddShortcut?.Invoke(entry);
Populate();
_store.Set(entry);
return true;
}
private void EnsureStoreLoadedForMutation()
{
if (_storeLoaded)
return;
_store.Load(_shortcuts());
_storeLoaded = true;
}
// ── IItemListDragHandler (B.2 live handler) ──────────────────────────────
// Retail: gmToolbarUI is the m_dragHandler for every shortcut slot list.
// Retail model (remove-on-lift / place-on-drop / no-restore):
@ -705,9 +681,8 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
// Retail RecvNotice_ItemListBeginDrag → RemoveShortcut (0x004bd930/0x004bd450): the lifted
// shortcut leaves the bar (+ wire) the instant the drag starts; it's re-placed only on a
// drop onto a slot. Off-bar release leaves it removed.
_store.Remove(payload.SourceSlot);
_sendRemoveShortcut?.Invoke((uint)payload.SourceSlot);
Populate();
_store.Remove(payload.SourceSlot);
}
/// <inheritdoc/>
@ -717,7 +692,6 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
/// <inheritdoc/>
public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
{
EnsureStoreLoadedForMutation();
ShortcutDropSource source = payload.SourceKind == ItemDragSource.ShortcutBar
? ShortcutDropSource.ShortcutAlias
: ShortcutDropSource.FreshItem;
@ -727,7 +701,6 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
_store.Snapshot(), source, payload.SourceSlot, targetCell.SlotIndex, dragged);
ApplyShortcutPlan(plan);
Populate();
}
/// <summary>
@ -736,32 +709,29 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
/// </summary>
public void ReplaceFullyMergedShortcut(uint oldObjectId, uint newObjectId)
{
EnsureStoreLoadedForMutation();
ShortcutMutation[] plan = ShortcutDropPlanner.PlanFullStackMerge(
_store.Snapshot(), oldObjectId, newObjectId);
ApplyShortcutPlan(plan);
if (plan.Length > 0)
Populate();
}
private void ApplyShortcutPlan(IReadOnlyList<ShortcutMutation> plan)
{
// Apply the already-validated transaction locally first, then reproduce the
// planner's exact Remove/Add wire order. Shortcut events have no rejection reply.
// Retail sends each shortcut event immediately before applying the same
// PlayerModule mutation (gmToolbarUI @ 0x004BD450/0x004BD9A0).
// Production commands mutate this exact Runtime-owned store; the
// idempotent local call also keeps isolated controller fixtures useful.
foreach (var mutation in plan)
{
if (mutation.Kind == ShortcutMutationKind.Remove)
{
_sendRemoveShortcut?.Invoke((uint)mutation.Slot);
_store.Remove(mutation.Slot);
}
else if (mutation.Entry is { } entry)
{
_sendAddShortcut?.Invoke(entry);
_store.Set(entry);
}
foreach (var mutation in plan)
{
if (mutation.Kind == ShortcutMutationKind.Remove)
_sendRemoveShortcut?.Invoke((uint)mutation.Slot);
else if (mutation.Entry is { } entry)
_sendAddShortcut?.Invoke(entry);
}
}
@ -800,5 +770,6 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
_repo.ObjectRemoved -= OnRepositoryObjectChanged;
_repo.ObjectMoved -= OnRepositoryObjectMoved;
_repo.Cleared -= OnRepositoryCleared;
_store.Changed -= Populate;
}
}

View file

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

View file

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

View file

@ -9,10 +9,40 @@ namespace AcDream.Core.Items;
/// <see cref="ShortcutEntry"/> fields, including non-object records that the object-only
/// <c>gmToolbarUI</c> does not render.
/// </summary>
public sealed class ShortcutStore
public sealed class ShortcutStore : IDisposable
{
public const int SlotCount = 18;
private readonly ShortcutEntry?[] _entries = new ShortcutEntry?[SlotCount];
private ShortcutEntry[] _items = [];
private Action? _changed;
private long _revision;
private long _dispatchFailureCount;
private bool _disposed;
/// <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>
/// Replace all slots from packed entries. Invalid signed indices are rejected exactly
@ -21,9 +51,12 @@ public sealed class ShortcutStore
/// </summary>
public void Load(IEnumerable<ShortcutEntry> entries)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(entries);
Array.Clear(_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>
@ -44,8 +77,9 @@ public sealed class ShortcutStore
public void Set(ShortcutEntry entry)
{
if ((uint)entry.Index < SlotCount)
_entries[entry.Index] = entry;
ObjectDisposedException.ThrowIf(_disposed, this);
if (SetCore(entry))
PublishChanged();
}
/// <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)
{
if ((uint)slot < SlotCount)
ObjectDisposedException.ThrowIf(_disposed, this);
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)
{
uint current = _desiredComponents.TryGetValue(componentId, out uint existing)
? existing : 0u;
if (current == amount) return;
if (amount == 0) _desiredComponents.Remove(componentId);
else _desiredComponents[componentId] = amount;
bool exists = _desiredComponents.TryGetValue(
componentId,
out uint current);
if (exists && current == amount) return;
// PlayerModule::SetDesiredCompLevel @ 0x005D4940 retains zero as a
// real table value; only ClearDesiredCompList destroys the entry set.
_desiredComponents[componentId] = amount;
DesiredComponentsChanged?.Invoke();
StateChanged?.Invoke();
}
/// <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();
StateChanged?.Invoke();
}

View file

@ -1,9 +1,41 @@
using AcDream.Core.Player;
using AcDream.Core.Net.Messages;
using AcDream.Core.Spells;
using AcDream.Core.Items;
namespace AcDream.Runtime.Gameplay;
public readonly record struct RuntimeCharacterOwnershipSnapshot(
bool IsDisposed,
bool InternalSubscriptionsAttached,
int LearnedSpellCount,
int ActiveEnchantmentCount,
int DesiredComponentCount,
int FavoriteSpellCount,
int VitalCount,
int AttributeCount,
int SkillCount,
int PositionCount,
int PropertyCount,
bool OptionsAreDefaults,
bool MovementSkillsAreReset)
{
public bool IsConverged =>
IsDisposed
&& !InternalSubscriptionsAttached
&& LearnedSpellCount == 0
&& ActiveEnchantmentCount == 0
&& DesiredComponentCount == 0
&& FavoriteSpellCount == 0
&& VitalCount == 0
&& AttributeCount == 0
&& SkillCount == 0
&& PositionCount == 0
&& PropertyCount == 0
&& OptionsAreDefaults
&& MovementSkillsAreReset;
}
/// <summary>
/// Canonical presentation-independent owner for the local character's magic
/// and player-sheet state. The two objects form one lifetime group because
@ -14,6 +46,7 @@ public sealed class RuntimeCharacterState : IDisposable
private bool _disposed;
private long _characterRevision;
private long _spellbookRevision;
private bool _internalSubscriptionsAttached;
public RuntimeCharacterState(SpellTable? spellTable = null)
{
@ -26,6 +59,7 @@ public sealed class RuntimeCharacterState : IDisposable
LocalPlayer.Changed += OnVitalChanged;
LocalPlayer.AttributeChanged += OnAttributeChanged;
LocalPlayer.CharacterChanged += OnCharacterChanged;
_internalSubscriptionsAttached = true;
}
public Spellbook Spellbook { get; }
@ -35,6 +69,57 @@ public sealed class RuntimeCharacterState : IDisposable
public IRuntimeCharacterView View { get; }
public bool IsDisposed => _disposed;
public RuntimeCharacterOwnershipSnapshot CaptureOwnership()
{
int favoriteCount = 0;
for (int tab = 0; tab < 8; tab++)
favoriteCount += Spellbook.GetFavorites(tab).Count;
int vitalCount = 0;
foreach (LocalPlayerState.VitalKind kind
in Enum.GetValues<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>
/// Installs immutable DAT metadata without transferring its ownership to
/// Runtime. The content host may install one table after portal.dat opens.
@ -57,6 +142,93 @@ public sealed class RuntimeCharacterState : IDisposable
LocalPlayer.Clear();
}
/// <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>
/// Clears both coupled owners while retaining every failed suffix for a
/// retry. State mutation happens before the existing synchronous
@ -82,13 +254,33 @@ public sealed class RuntimeCharacterState : IDisposable
{
if (_disposed)
return;
ResetSession();
List<Exception>? failures = null;
try
{
// Do not call ResetSession as one opaque step here. Terminal
// disposal must run every suffix even when an external UI observer
// throws from one Core owner's synchronous clear notification.
Try(Spellbook.Clear, ref failures);
Try(LocalPlayer.Clear, ref failures);
Try(Options.ResetSession, ref failures);
Try(MovementSkills.ResetSession, ref failures);
}
finally
{
Spellbook.StateChanged -= OnSpellbookChanged;
LocalPlayer.Changed -= OnVitalChanged;
LocalPlayer.AttributeChanged -= OnAttributeChanged;
LocalPlayer.CharacterChanged -= OnCharacterChanged;
_internalSubscriptionsAttached = false;
_disposed = true;
}
if (failures is not null)
{
throw new AggregateException(
"Runtime character state did not converge during disposal.",
failures);
}
}
private void OnSpellbookChanged() =>
Interlocked.Increment(ref _spellbookRevision);

View file

@ -17,6 +17,36 @@ public interface IRuntimeCommunicationEventSource
IDisposable Subscribe(IRuntimeCommunicationObserver observer);
}
public readonly record struct RuntimeCommunicationOwnershipSnapshot(
bool IsDisposed,
bool CommandTargetsDisposed,
int StreamSubscriberCount,
int PendingDispatchCount,
bool IsDispatching,
int FriendCount,
int SquelchAccountCount,
int SquelchCharacterCount,
int SquelchGlobalTypeCount,
int NegotiatedRoomCount,
bool HasReplyTarget,
bool HasRetellTarget,
long DispatchFailureCount)
{
public bool IsConverged =>
IsDisposed
&& CommandTargetsDisposed
&& StreamSubscriberCount == 0
&& PendingDispatchCount == 0
&& !IsDispatching
&& FriendCount == 0
&& SquelchAccountCount == 0
&& SquelchCharacterCount == 0
&& SquelchGlobalTypeCount == 0
&& NegotiatedRoomCount == 0
&& !HasReplyTarget
&& !HasRetellTarget;
}
/// <summary>
/// Canonical presentation-independent owner for communication and social
/// state. Graphical, headless, plugin, and bot hosts borrow these exact
@ -59,6 +89,25 @@ public sealed class RuntimeCommunicationState : IDisposable
public long DispatchFailureCount => _events.DispatchFailureCount;
public Exception? LastDispatchFailure => _events.LastDispatchFailure;
public RuntimeCommunicationOwnershipSnapshot CaptureOwnership()
{
SquelchDatabase squelch = Squelch.Snapshot();
return new RuntimeCommunicationOwnershipSnapshot(
_disposed,
CommandTargets.IsDisposed,
SubscriberCount,
PendingDispatchCount,
IsDispatching,
Friends.Count,
squelch.Accounts.Count,
squelch.Characters.Count,
squelch.Global.MessageTypes.Count,
CountRooms(TurbineChat),
CommandTargets.LastIncomingTellSender is not null,
CommandTargets.LastOutgoingTellTarget is not null,
DispatchFailureCount);
}
public void ResetCommandTargets() => CommandTargets.ResetSession();
public void ResetChatIdentity() => Chat.ResetSessionIdentity();
public void ResetNegotiatedChannels() => TurbineChat.Reset();
@ -126,6 +175,8 @@ public sealed class RuntimeCommunicationState : IDisposable
return true;
}
}
private static int CountRooms(TurbineChatState state)
{
int count = 0;
@ -139,7 +190,6 @@ public sealed class RuntimeCommunicationState : IDisposable
return count;
}
}
}
internal sealed class RuntimeCommunicationEventStream
: IRuntimeCommunicationEventSource,

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;
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>
/// Canonical presentation-independent owner for inventory gameplay state.
/// The object collection is borrowed directly from J3's entity/object
@ -19,7 +46,7 @@ public sealed class RuntimeInventoryState : IDisposable
?? throw new ArgumentNullException(nameof(entityObjects));
ExternalContainers = new ExternalContainerState();
ItemMana = new ItemManaState();
Shortcuts = new ShortcutState();
Shortcuts = new ShortcutStore();
Transactions = new InventoryTransactionState(_entityObjects.Objects);
View = new InventoryStateView(this);
}
@ -27,11 +54,25 @@ public sealed class RuntimeInventoryState : IDisposable
public ClientObjectTable Objects => _entityObjects.Objects;
public ExternalContainerState ExternalContainers { get; }
public ItemManaState ItemMana { get; }
public ShortcutState Shortcuts { get; }
public ShortcutStore Shortcuts { get; }
public InventoryTransactionState Transactions { get; }
public IRuntimeInventoryStateView View { get; }
public bool IsDisposed => _disposed;
public RuntimeInventoryOwnershipSnapshot CaptureOwnership() => new(
_disposed,
Transactions.IsDisposed,
Shortcuts.IsDisposed,
Transactions.BusyCount,
Transactions.HasPendingRequest,
ExternalContainers.RequestedContainerId,
ExternalContainers.CurrentContainerId,
ItemMana.Count,
Shortcuts.Count,
Shortcuts.SubscriberCount,
Shortcuts.DispatchFailureCount,
Transactions.DispatchFailureCount);
public void ResetExternalContainer() => ExternalContainers.Reset();
public void ResetTransactions() => Transactions.ResetSession();
public void ResetItemMana() => ItemMana.Clear();
@ -41,20 +82,82 @@ public sealed class RuntimeInventoryState : IDisposable
Shortcuts.Clear();
}
/// <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()
{
if (_disposed)
return;
List<Exception>? failures = null;
try
{
Try(() => ExternalContainers.Reset(), ref failures);
Try(ItemMana.Clear, ref failures);
Try(Shortcuts.Clear, ref failures);
Try(Shortcuts.Dispose, ref failures);
Try(Transactions.Dispose, ref failures);
}
finally
{
// Disposal is terminal even when a borrowed presentation observer
// fails. Every owned leaf above mutates before dispatching, so the
// full suffix is safe to run and the Runtime root must never remain
// half-alive over already-disposed children.
_disposed = true;
}
if (failures is not null)
throw new AggregateException(
"Runtime inventory state did not converge during disposal.",
failures);
_disposed = true;
}
private static void Try(Action action, ref List<Exception>? failures)
@ -93,7 +196,7 @@ public sealed class RuntimeInventoryState : IDisposable
owner.Transactions.BusyCount,
owner.Transactions.CanBeginRequest,
pending,
owner.Shortcuts.Items.Count,
owner.Shortcuts.Count,
owner.Shortcuts.Revision,
owner.ItemMana.Count,
owner.ItemMana.Revision);
@ -124,22 +227,3 @@ public sealed class RuntimeInventoryState : IDisposable
owner.ItemMana.TryGetManaPercent(objectId, out fraction);
}
}
public sealed class ShortcutState
{
private IReadOnlyList<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(
Objects,
new InventoryTransactionState(Objects),
() => Player,
sendUse: null,
sendUseWithTarget: null,

View file

@ -371,7 +371,7 @@ public sealed class CurrentGameRuntimeAdapterTests
harness.Character.Options.Replace(0x04000000u, 0x00948700u);
harness.Character.MovementSkills.Update(200, 175);
harness.Character.Spellbook.OnSpellLearned(42u);
harness.InventoryState.Shortcuts.Replace(
harness.InventoryState.Shortcuts.Load(
[
new ShortcutEntry(1, 0x80000001u, 0u),
]);
@ -506,6 +506,20 @@ public sealed class CurrentGameRuntimeAdapterTests
static entry => entry.Kind == RuntimeTraceKind.Command
&& (entry.Code >> 16)
== (int)RuntimeCommandDomain.Social);
Assert.True(harness.InventoryState.View.TryGetShortcut(
2,
out RuntimeShortcutSnapshot storedShortcut));
Assert.Equal(0x80000001u, storedShortcut.ObjectId);
Assert.True(harness.Character.View.TryGetFavorite(
0,
0,
out uint storedFavorite));
Assert.Equal(42u, storedFavorite);
Assert.Equal(0x3FFEu, harness.Character.Spellbook.SpellbookFilters);
Assert.True(harness.Character.View.TryGetDesiredComponent(
0x68000001u,
out uint storedDesired));
Assert.Equal(10u, storedDesired);
int published = harness.Commands.Published.Count;
RuntimeCommandResult stale = commands.Character.SetOptions1(
@ -515,6 +529,101 @@ public sealed class CurrentGameRuntimeAdapterTests
Assert.Equal(published, harness.Commands.Published.Count);
}
[Fact]
public void GraphicalAndNoWindowJ4CommandsProduceIdenticalCanonicalState()
{
using var directEntities = new RuntimeEntityObjectLifetime();
using var directInventory =
new RuntimeInventoryState(directEntities);
using var directCharacter = new RuntimeCharacterState();
using var harness = new Harness();
_ = harness.Runtime.Session.Start(harness.Runtime.Generation);
RuntimeGenerationToken generation = harness.Runtime.Generation;
IGameRuntimeCommands graphical = harness.Runtime;
var directOutbound = new List<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()
{
ObjectId = spawn.Guid,
@ -587,6 +696,7 @@ public sealed class CurrentGameRuntimeAdapterTests
_items = new ItemInteractionController(
Objects,
InventoryState.Transactions,
() => PlayerGuid,
sendUse: null,
sendUseWithTarget: null,

View file

@ -41,13 +41,32 @@ public sealed class RuntimeInventoryOwnershipTests
"InteractionRetainedUiComposition.cs");
string session = ReadSource("Net", "LiveSessionRuntimeFactory.cs");
string shutdown = ReadSource("Rendering", "GameWindowLifetime.cs");
string itemInteraction = ReadSource(
"UI",
"ItemInteractionController.cs");
Assert.Contains(
"transactions: d.Inventory.Transactions",
"d.Inventory.Transactions",
ui,
StringComparison.Ordinal);
Assert.DoesNotContain(
"new InventoryTransactionState(d.Inventory.Objects)",
ui,
StringComparison.Ordinal);
Assert.DoesNotContain(
"new InventoryTransactionState",
itemInteraction,
StringComparison.Ordinal);
Assert.DoesNotContain(
"_ownsTransactions",
itemInteraction,
StringComparison.Ordinal);
Assert.Contains(
"OnShortcuts: _domain.Inventory.Shortcuts.Replace",
"InventoryTransactionState transactions,",
itemInteraction,
StringComparison.Ordinal);
Assert.Contains(
"OnShortcuts: _domain.Inventory.Shortcuts.Load",
session,
StringComparison.Ordinal);
Assert.Contains(
@ -64,6 +83,73 @@ public sealed class RuntimeInventoryOwnershipTests
"\"runtime entity/object lifetime\"");
}
[Fact]
public void ToolbarBorrowsRuntimeShortcutManagerWithoutAProviderOrMirror()
{
string toolbar = ReadSource(
"UI",
"Layout",
"ToolbarController.cs");
string ui = ReadSource(
"Composition",
"InteractionRetainedUiComposition.cs");
Assert.Contains(
"private readonly ShortcutStore _store;",
toolbar,
StringComparison.Ordinal);
Assert.Contains(
"_store = shortcuts ?? throw",
toolbar,
StringComparison.Ordinal);
Assert.DoesNotContain(
"new ShortcutStore()",
toolbar,
StringComparison.Ordinal);
Assert.DoesNotContain(
"Func<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)
{
string root = FindRepositoryRoot();

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -41,4 +41,41 @@ public class ShortcutStoreTests
store.SetItem(18, 1u); // no-op, no throw
store.Remove(-1); // no-op, no throw
}
[Fact]
public void RevisionObserversAndDisposalTrackTheOneCanonicalSlotMap()
{
var store = new ShortcutStore();
int observed = 0;
store.Changed += () => throw new InvalidOperationException("observer");
store.Changed += () => observed++;
store.Load([new ShortcutEntry(2, 0x5001u, 0u)]);
Assert.Equal(1, observed);
Assert.Equal(1, store.Revision);
Assert.Equal(1, store.DispatchFailureCount);
Assert.Equal(2, store.SubscriberCount);
Assert.Equal(1, store.Count);
store.Set(new ShortcutEntry(2, 0x5001u, 0u));
Assert.Equal(1, store.Revision);
Assert.Equal(1, observed);
store.Set(new ShortcutEntry(2, 0x5002u, 0u));
Assert.Equal(2, store.Revision);
Assert.Equal(2, observed);
store.Dispose();
store.Dispose();
Assert.True(store.IsDisposed);
Assert.Empty(store.Items);
Assert.Equal(0, store.SubscriberCount);
Assert.Equal(3, observed);
Assert.Equal(3, store.Revision);
Assert.Equal(3, store.DispatchFailureCount);
Assert.Throws<ObjectDisposedException>(
() => store.SetItem(1, 1u));
}
}

View file

@ -115,7 +115,7 @@ public sealed class RuntimeCharacterStateTests
}
[Fact]
public void DisposalRetriesObserverFailuresAndClearsBothOwners()
public void DisposalReportsObserverFailuresAfterTerminalConvergence()
{
var state = new RuntimeCharacterState();
state.Spellbook.OnSpellLearned(7u);
@ -143,13 +143,13 @@ public sealed class RuntimeCharacterStateTests
state.Dispose);
Assert.Equal(2, error.InnerExceptions.Count);
Assert.False(state.IsDisposed);
Assert.True(state.IsDisposed);
Assert.False(state.Spellbook.Knows(7u));
Assert.Null(state.LocalPlayer.Get(
AcDream.Core.Player.LocalPlayerState.VitalKind.Health));
Assert.True(state.CaptureOwnership().IsConverged);
state.Dispose();
state.Dispose();
Assert.True(state.IsDisposed);
}
@ -254,4 +254,77 @@ public sealed class RuntimeCharacterStateTests
Assert.True(first.View.Snapshot.SpellbookRevision > 0);
Assert.Equal(0, second.View.Snapshot.SpellbookRevision);
}
[Fact]
public void SpellbookCommandsFollowRetailLocalAndOutboundOrder()
{
using var state = new RuntimeCharacterState();
var order = new List<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 inventory = new RuntimeInventoryState(entities);
inventory.Shortcuts.Replace(
inventory.Shortcuts.Load(
[
new AcDream.Core.Items.ShortcutEntry(3, 0x80000001u, 0u),
new AcDream.Core.Items.ShortcutEntry(4, 0u, 42u),
@ -55,12 +55,12 @@ public sealed class RuntimeInventoryStateTests
inventory.ExternalContainers.RequestOpen(0x70000001u);
inventory.ExternalContainers.ApplyViewContents(0x70000001u);
inventory.ItemMana.OnQueryItemManaResponse(0x50000001u, 0.75f, true);
inventory.Shortcuts.Replace(shortcuts);
inventory.Shortcuts.Load(shortcuts);
Assert.Same(entities.Objects, inventory.Objects);
Assert.Equal(0x70000001u, inventory.ExternalContainers.CurrentContainerId);
Assert.Equal(0.75f, inventory.ItemMana.GetManaPercent(0x50000001u));
Assert.Same(shortcuts, inventory.Shortcuts.Items);
Assert.Equal(shortcuts, inventory.Shortcuts.Items);
Assert.Equal(1, inventory.Shortcuts.Revision);
}
@ -72,7 +72,7 @@ public sealed class RuntimeInventoryStateTests
inventory.ExternalContainers.RequestOpen(0x70000001u);
inventory.ExternalContainers.ApplyViewContents(0x70000001u);
inventory.ItemMana.OnQueryItemManaResponse(0x50000001u, 0.75f, true);
inventory.Shortcuts.Replace([new ShortcutEntry(1, 2u, 3u)]);
inventory.Shortcuts.Load([new ShortcutEntry(1, 2u, 3u)]);
inventory.Transactions.IncrementBusyCount();
inventory.ResetExternalContainer();
@ -98,7 +98,7 @@ public sealed class RuntimeInventoryStateTests
using var first = new RuntimeInventoryState(firstEntities);
using var second = new RuntimeInventoryState(secondEntities);
first.Shortcuts.Replace([new ShortcutEntry(1, 2u, 3u)]);
first.Shortcuts.Load([new ShortcutEntry(1, 2u, 3u)]);
first.Transactions.IncrementBusyCount();
Assert.NotSame(first.Objects, second.Objects);
@ -116,7 +116,7 @@ public sealed class RuntimeInventoryStateTests
inventory.ExternalContainers.RequestOpen(0x70000001u);
inventory.ExternalContainers.ApplyViewContents(0x70000001u);
inventory.ItemMana.OnQueryItemManaResponse(0x50000001u, 0.5f, true);
inventory.Shortcuts.Replace([new ShortcutEntry(1, 2u, 3u)]);
inventory.Shortcuts.Load([new ShortcutEntry(1, 2u, 3u)]);
inventory.Dispose();
inventory.Dispose();
@ -129,7 +129,7 @@ public sealed class RuntimeInventoryStateTests
}
[Fact]
public void DisposalRetriesATransientOwnerObserverFailureToConvergence()
public void DisposalFailureIsReportedAfterTerminalOwnerConvergence()
{
using var entities = new RuntimeEntityObjectLifetime();
var inventory = new RuntimeInventoryState(entities);
@ -146,12 +146,72 @@ public sealed class RuntimeInventoryStateTests
};
Assert.Throws<AggregateException>(inventory.Dispose);
Assert.False(inventory.IsDisposed);
Assert.True(inventory.IsDisposed);
Assert.True(inventory.Transactions.IsDisposed);
inventory.Dispose();
Assert.True(inventory.CaptureOwnership().IsConverged);
Assert.Equal(0u, inventory.ExternalContainers.CurrentContainerId);
}
[Fact]
public void ShortcutCommandsUseRetailSendThenLocalOrderOnTheExactOwner()
{
using var entities = new RuntimeEntityObjectLifetime();
using var inventory = new RuntimeInventoryState(entities);
var order = new List<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();
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);
}
}