refactor(runtime): own inventory transaction state

Move the retail one-request-at-a-time gate, shared use busy references, external-container state, item mana, shortcuts, and desired-component snapshots into one Runtime-owned graph over J3's exact ClientObjectTable. Retained UI and session routing now borrow that owner; reset and shutdown preserve the existing order while failure/reentrancy tests protect the transaction boundary.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-26 08:20:32 +02:00
parent 595d5e6b01
commit 011efbeaa7
18 changed files with 1337 additions and 406 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,406 @@
namespace AcDream.Core.Items;
public enum InventoryRequestKind
{
Pickup,
PutInContainer,
SplitToContainer,
Merge,
DropToWorld,
SplitToWorld,
Give,
}
public readonly record struct PendingInventoryRequest(
ulong Token,
InventoryRequestKind Kind,
uint ItemId,
ClientObject? ItemIdentity,
bool Dispatched);
/// <summary>
/// Owns one retail UI busy reference while an accepted Use request crosses the
/// local approach boundary. Dispatch transfers that reference to the matching
/// authoritative UseDone; cancellation before dispatch releases it locally.
/// </summary>
public sealed class ItemUseRequestReservation
{
private readonly Action<bool> _resolve;
private int _resolved;
internal ItemUseRequestReservation(Action<bool> resolve)
=> _resolve = resolve ?? throw new ArgumentNullException(nameof(resolve));
public void MarkDispatched()
{
if (Interlocked.Exchange(ref _resolved, 1) == 0)
_resolve(true);
}
public void CancelBeforeDispatch()
{
if (Interlocked.Exchange(ref _resolved, 1) == 0)
_resolve(false);
}
}
/// <summary>
/// Presentation-independent owner for retail's single
/// <c>ACCWeenieObject::prevRequest</c> inventory gate and
/// <c>ClientUISystem</c> busy-reference count.
/// </summary>
/// <remarks>
/// Request recording and response clearing follow
/// <c>ACCWeenieObject::RecordRequest @ 0x0058C220</c> and
/// <c>ACCWeenieObject::RecordResponse @ 0x0058CAB0</c>. The owner borrows the
/// canonical object table and never creates a second inventory collection.
/// </remarks>
public sealed class InventoryTransactionState : IDisposable
{
private readonly ClientObjectTable _objects;
private ulong _nextRequestToken;
private ulong _useReservationGeneration;
private PendingInventoryRequest? _pendingRequest;
private int _busyCount;
private long _dispatchFailureCount;
private bool _disposed;
public InventoryTransactionState(ClientObjectTable objects)
{
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_objects.ObjectMoved += OnObjectMoved;
_objects.MoveRequestFailed += OnMoveFailed;
_objects.ObjectRemoved += OnObjectRemoved;
_objects.StackSizeUpdated += OnStackSizeUpdated;
_objects.Cleared += OnObjectsCleared;
}
public event Action? StateChanged;
public event Action<PendingInventoryRequest>? RequestCompleted;
public event Action? ObjectTableCleared;
public ClientObjectTable Objects => _objects;
public int BusyCount => _busyCount;
public bool HasPendingRequest => _pendingRequest is not null;
public bool CanBeginRequest => _busyCount == 0 && _pendingRequest is null;
public bool IsDisposed => _disposed;
public long DispatchFailureCount =>
Interlocked.Read(ref _dispatchFailureCount);
public Exception? LastDispatchFailure { get; private set; }
public bool TryReserve(
InventoryRequestKind kind,
uint itemId,
out PendingInventoryRequest pending,
Action<PendingInventoryRequest>? beforeStateChanged = null)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (itemId == 0u || !CanBeginRequest)
{
pending = default;
return false;
}
ulong token = NextToken();
pending = new PendingInventoryRequest(
token,
kind,
itemId,
_objects.Get(itemId),
Dispatched: false);
_pendingRequest = pending;
if (beforeStateChanged is not null)
{
try
{
beforeStateChanged(pending);
}
catch
{
if (IsCurrent(pending))
_pendingRequest = null;
throw;
}
}
if (!IsCurrent(pending))
return false;
DispatchStateChanged();
return IsCurrent(pending);
}
public bool TryDispatch(
InventoryRequestKind kind,
uint itemId,
Func<bool> dispatch,
ulong reservationToken = 0u)
{
ArgumentNullException.ThrowIfNull(dispatch);
ObjectDisposedException.ThrowIf(_disposed, this);
if (itemId == 0u)
return false;
PendingInventoryRequest reserved;
if (reservationToken != 0u)
{
if (_pendingRequest is not { } current
|| current.Token != reservationToken
|| current.ItemId != itemId
|| current.Kind != kind
|| current.Dispatched)
{
return false;
}
reserved = current;
}
else if (!TryReserve(kind, itemId, out reserved))
{
return false;
}
bool dispatched;
try
{
dispatched = dispatch();
}
catch
{
ClearPending(reserved.Token);
throw;
}
if (!dispatched)
{
ClearPending(reserved.Token);
return false;
}
// A synchronous transport or re-entrant table callback may already
// have completed this exact request. Never resurrect it or overwrite a
// newer transaction acquired from that response.
if (_pendingRequest is { } currentRequest
&& currentRequest.Token == reserved.Token)
{
_pendingRequest = reserved with { Dispatched = true };
DispatchStateChanged();
}
return true;
}
public bool TryGetPending(out PendingInventoryRequest pending)
{
if (_pendingRequest is { } current)
{
pending = current;
return true;
}
pending = default;
return false;
}
public bool CancelBeforeDispatch(ulong token)
{
if (_pendingRequest is not { } pending
|| pending.Token != token
|| pending.Dispatched)
{
return false;
}
_pendingRequest = null;
DispatchStateChanged();
return true;
}
public void IncrementBusyCount()
{
ObjectDisposedException.ThrowIf(_disposed, this);
_busyCount++;
DispatchStateChanged();
}
public ItemUseRequestReservation BeginUseRequestReservation()
{
ObjectDisposedException.ThrowIf(_disposed, this);
ulong generation = _useReservationGeneration;
_busyCount++;
DispatchStateChanged();
return new ItemUseRequestReservation(dispatched =>
{
if (generation != _useReservationGeneration || dispatched)
return;
if (_busyCount > 0)
_busyCount--;
DispatchStateChanged();
});
}
public void CompleteUse(uint _)
{
if (_busyCount == 0)
return;
_busyCount--;
DispatchStateChanged();
}
public void ClearBusy()
{
if (_busyCount == 0)
return;
_useReservationGeneration++;
_busyCount = 0;
DispatchStateChanged();
}
public void ResetSession()
{
bool changed = _pendingRequest is not null || _busyCount != 0;
_pendingRequest = null;
_useReservationGeneration++;
_busyCount = 0;
if (changed)
DispatchStateChanged();
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
_objects.Cleared -= OnObjectsCleared;
_objects.StackSizeUpdated -= OnStackSizeUpdated;
_objects.ObjectRemoved -= OnObjectRemoved;
_objects.MoveRequestFailed -= OnMoveFailed;
_objects.ObjectMoved -= OnObjectMoved;
_pendingRequest = null;
_useReservationGeneration++;
_busyCount = 0;
StateChanged = null;
RequestCompleted = null;
ObjectTableCleared = null;
}
private ulong NextToken()
{
ulong token = ++_nextRequestToken;
if (token == 0u)
token = ++_nextRequestToken;
return token;
}
private bool IsCurrent(PendingInventoryRequest expected) =>
_pendingRequest is { } current
&& current.Token == expected.Token
&& current.ItemId == expected.ItemId
&& current.Kind == expected.Kind
&& !current.Dispatched;
private void ClearPending(ulong token)
{
if (_pendingRequest is not { } pending || pending.Token != token)
return;
_pendingRequest = null;
DispatchStateChanged();
}
private void OnObjectMoved(ClientObjectMove move)
{
if (move.Origin == ClientObjectMoveOrigin.AuthoritativeResponse)
CompleteInventoryResponse(move.ItemId, move.Item);
}
private void OnMoveFailed(MoveRequestFailure failure) =>
CompleteInventoryResponse(
failure.ItemId,
_objects.Get(failure.ItemId));
private void OnObjectRemoved(ClientObject item) =>
CompleteInventoryResponse(item.ObjectId, item);
private void OnStackSizeUpdated(ClientObject item) =>
CompleteInventoryResponse(item.ObjectId, item);
private void CompleteInventoryResponse(
uint itemId,
ClientObject? identity)
{
if (_pendingRequest is not { } request
|| request.ItemId != itemId
|| !MatchesIdentity(request.ItemIdentity, itemId, identity))
{
return;
}
// RecordResponse clears prevRequest before ItemList receives the
// response notice, so a completion observer may synchronously acquire
// the next request.
_pendingRequest = null;
Dispatch(RequestCompleted, request);
DispatchStateChanged();
}
private bool MatchesIdentity(
ClientObject? expected,
uint itemId,
ClientObject? actual)
{
if (expected is null)
return true;
if (actual is not null)
return ReferenceEquals(expected, actual);
return _objects.Get(itemId) is not { } current
|| ReferenceEquals(expected, current);
}
private void OnObjectsCleared()
{
bool changed = _pendingRequest is not null;
_pendingRequest = null;
Dispatch(ObjectTableCleared);
if (changed)
DispatchStateChanged();
}
private void DispatchStateChanged() => Dispatch(StateChanged);
private void Dispatch(Action? listeners)
{
if (listeners is null)
return;
foreach (Action listener in listeners.GetInvocationList())
{
try
{
listener();
}
catch (Exception error)
{
RecordDispatchFailure(error);
}
}
}
private void Dispatch<T>(Action<T>? listeners, T value)
{
if (listeners is null)
return;
foreach (Action<T> listener in listeners.GetInvocationList())
{
try
{
listener(value);
}
catch (Exception error)
{
RecordDispatchFailure(error);
}
}
}
private void RecordDispatchFailure(Exception error)
{
Interlocked.Increment(ref _dispatchFailureCount);
LastDispatchFailure = error;
}
}

View file

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

View file

@ -189,13 +189,11 @@ public sealed class InteractionRetainedUiCompositionTests
Combat: null!, Combat: null!,
CombatAttackOperations: null!, CombatAttackOperations: null!,
Selection: null!, Selection: null!,
ExternalContainers: null!, Inventory: null!,
Objects: null!,
MagicCatalog: null!, MagicCatalog: null!,
Spellbook: null!, Spellbook: null!,
Communication: null!, Communication: null!,
LocalPlayer: null!, LocalPlayer: null!,
ItemMana: null!,
StackSplitQuantity: null!, StackSplitQuantity: null!,
UiRegistry: null, UiRegistry: null,
CombatModeCommands: null!, CombatModeCommands: null!,
@ -203,7 +201,6 @@ public sealed class InteractionRetainedUiCompositionTests
PlayerController: null!, PlayerController: null!,
PlayerMode: null!, PlayerMode: null!,
CharacterOptions: null!, CharacterOptions: null!,
Shortcuts: null!,
SelectionCameraFactory: static _ => Stub<SelectionCameraSource>(), SelectionCameraFactory: static _ => Stub<SelectionCameraSource>(),
FrameDiagnostics: new DeferredRenderFrameDiagnosticsSource(), FrameDiagnostics: new DeferredRenderFrameDiagnosticsSource(),
ExistingVitals: null, ExistingVitals: null,

View file

@ -16,23 +16,6 @@ namespace AcDream.App.Tests.Composition;
public sealed class InteractionUiRuntimeSourcesTests public sealed class InteractionUiRuntimeSourcesTests
{ {
[Fact]
public void DesiredComponentSnapshotNormalizesNullAndClearsExactly()
{
var state = new DesiredComponentSnapshotState();
IReadOnlyList<(uint Id, uint Amount)> items =
[(0x01020304u, 7u)];
Assert.Empty(state.Items);
state.Items = items;
Assert.Same(items, state.Items);
state.Clear();
Assert.Empty(state.Items);
state.Items = null!;
Assert.Empty(state.Items);
}
[Fact] [Fact]
public void SessionAuthorityDefaultsBindUnbindRebindAndDeactivateExactly() public void SessionAuthorityDefaultsBindUnbindRebindAndDeactivateExactly()
{ {

View file

@ -281,6 +281,7 @@ public sealed class LiveSessionResetPlanTests
InteractionAndSelection = Stage( InteractionAndSelection = Stage(
"interaction and selection", "interaction and selection",
() => selection.Reset()), () => selection.Reset()),
InventoryTransactions = Stage("inventory transactions"),
SelectionPresentation = Stage("selection presentation"), SelectionPresentation = Stage("selection presentation"),
ObjectTable = Stage("object table", objects.Clear), ObjectTable = Stage("object table", objects.Clear),
Spellbook = Stage("spellbook", spells.Clear), Spellbook = Stage("spellbook", spells.Clear),
@ -364,6 +365,7 @@ public sealed class LiveSessionResetPlanTests
"equipped children", "equipped children",
"external container", "external container",
"interaction and selection", "interaction and selection",
"inventory transactions",
"selection presentation", "selection presentation",
"object table", "object table",
"spellbook", "spellbook",

View file

@ -511,6 +511,7 @@ public sealed class CurrentGameRuntimeAdapterTests
EquippedChildren = noop, EquippedChildren = noop,
ExternalContainer = noop, ExternalContainer = noop,
InteractionAndSelection = noop, InteractionAndSelection = noop,
InventoryTransactions = noop,
SelectionPresentation = noop, SelectionPresentation = noop,
ObjectTable = noop, ObjectTable = noop,
Spellbook = noop, Spellbook = noop,

View file

@ -0,0 +1,97 @@
namespace AcDream.App.Tests.Runtime;
public sealed class RuntimeInventoryOwnershipTests
{
[Fact]
public void ProductionConstructsOnlyTheRuntimeInventoryOwner()
{
string source = ReadSource("Rendering", "GameWindow.cs");
Assert.Contains(
"_runtimeInventory = new RuntimeInventoryState(_runtimeEntityObjects);",
source,
StringComparison.Ordinal);
Assert.DoesNotContain(
"new AcDream.Core.Items.ItemManaState",
source,
StringComparison.Ordinal);
Assert.DoesNotContain(
"new DesiredComponentSnapshotState",
source,
StringComparison.Ordinal);
Assert.DoesNotContain(
"new ShortcutSnapshotState",
source,
StringComparison.Ordinal);
Assert.DoesNotContain(
"new AcDream.Core.Items.ExternalContainerState",
source,
StringComparison.Ordinal);
}
[Fact]
public void UiSessionAndShutdownBorrowTheExactRuntimeOwner()
{
string ui = ReadSource(
"Composition",
"InteractionRetainedUiComposition.cs");
string session = ReadSource("Net", "LiveSessionRuntimeFactory.cs");
string shutdown = ReadSource("Rendering", "GameWindowLifetime.cs");
Assert.Contains(
"transactions: d.Inventory.Transactions",
ui,
StringComparison.Ordinal);
Assert.Contains(
"OnShortcuts: _domain.Inventory.Shortcuts.Replace",
session,
StringComparison.Ordinal);
Assert.Contains(
"_domain.Inventory.Transactions.CompleteUse(error)",
session,
StringComparison.Ordinal);
Assert.Contains(
"OnDesiredComponents: _domain.Inventory.DesiredComponents.Replace",
session,
StringComparison.Ordinal);
AssertAppearsInOrder(
shutdown,
"\"runtime inventory state\"",
"\"runtime entity/object lifetime\"");
}
private static string ReadSource(params string[] relative)
{
string root = FindRepositoryRoot();
return File.ReadAllText(Path.Combine(
[root, "src", "AcDream.App", .. relative]));
}
private static string FindRepositoryRoot()
{
var current = new DirectoryInfo(AppContext.BaseDirectory);
while (current is not null)
{
if (File.Exists(Path.Combine(current.FullName, "AcDream.slnx")))
return current.FullName;
current = current.Parent;
}
throw new DirectoryNotFoundException("AcDream.slnx was not found.");
}
private static void AssertAppearsInOrder(
string source,
params string[] fragments)
{
int cursor = 0;
foreach (string fragment in fragments)
{
int index = source.IndexOf(
fragment,
cursor,
StringComparison.Ordinal);
Assert.True(index >= 0, $"Missing source fragment: {fragment}");
cursor = index + fragment.Length;
}
}
}

View file

@ -31,6 +31,7 @@ public sealed class ItemInteractionControllerTests
public readonly List<CombatMode> CombatModeRequests = new(); public readonly List<CombatMode> CombatModeRequests = new();
public readonly CombatState Combat = new(); public readonly CombatState Combat = new();
public readonly StackSplitQuantityState SplitQuantity = new(); public readonly StackSplitQuantityState SplitQuantity = new();
public readonly InventoryTransactionState? SharedTransactions;
public uint SelectedObject; public uint SelectedObject;
public bool NonCombatMode; public bool NonCombatMode;
public bool DragOnPlayerOpensSecureTrade = true; public bool DragOnPlayerOpensSecureTrade = true;
@ -38,7 +39,8 @@ public sealed class ItemInteractionControllerTests
public long Now = 1_000; public long Now = 1_000;
public Harness( public Harness(
Action<uint, ItemUseRequestReservation>? requestUse = null) Action<uint, ItemUseRequestReservation>? requestUse = null,
bool sharedTransactions = false)
{ {
Objects.AddOrUpdate(new ClientObject Objects.AddOrUpdate(new ClientObject
{ {
@ -54,6 +56,9 @@ public sealed class ItemInteractionControllerTests
ItemsCapacity = 24, ItemsCapacity = 24,
}); });
Objects.MoveItem(Pack, Player, 0); Objects.MoveItem(Pack, Player, 0);
SharedTransactions = sharedTransactions
? new InventoryTransactionState(Objects)
: null;
Controller = new ItemInteractionController( Controller = new ItemInteractionController(
Objects, Objects,
@ -86,7 +91,8 @@ public sealed class ItemInteractionControllerTests
}, },
combatState: Combat, combatState: Combat,
sendChangeCombatMode: CombatModeRequests.Add, sendChangeCombatMode: CombatModeRequests.Add,
requestUse: requestUse); requestUse: requestUse,
transactions: SharedTransactions);
} }
public ItemInteractionController Controller { get; } public ItemInteractionController Controller { get; }
@ -312,6 +318,39 @@ public sealed class ItemInteractionControllerTests
Assert.False(h.Controller.IsAnyTargetModeActive); Assert.False(h.Controller.IsAnyTargetModeActive);
} }
[Fact]
public void BorrowedTransactionOwnerIsExactAndOutlivesController()
{
var h = new Harness(sharedTransactions: true);
h.Controller.IncrementBusyCount();
Assert.Equal(1, h.SharedTransactions!.BusyCount);
h.Controller.Dispose();
Assert.False(h.SharedTransactions.IsDisposed);
h.SharedTransactions.ClearBusy();
Assert.Equal(0, h.SharedTransactions.BusyCount);
h.SharedTransactions.Dispose();
}
[Fact]
public void BorrowedTransactionOwnerRejectsDifferentObjectTable()
{
var objects = new ClientObjectTable();
using var transactions =
new InventoryTransactionState(new ClientObjectTable());
Assert.Throws<ArgumentException>(() => new ItemInteractionController(
objects,
playerGuid: () => Player,
sendUse: null,
sendUseWithTarget: null,
sendWield: null,
sendDrop: null,
transactions: transactions));
}
[Fact] [Fact]
public void AppraisalResponse_releasesOneBusyReferenceAndAcceptsCurrentRefresh() public void AppraisalResponse_releasesOneBusyReferenceAndAcceptsCurrentRefresh()
{ {
@ -2019,6 +2058,67 @@ public sealed class ItemInteractionControllerTests
Assert.False(h.Controller.TryGetPendingInventoryRequest(out _)); Assert.False(h.Controller.TryGetPendingInventoryRequest(out _));
} }
[Fact]
public void ThrowingReservedDispatchWithdrawsBothOwnersBeforeRethrow()
{
var h = new Harness();
const uint item = 0x70000A27u;
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = item,
Name = "Loot",
Type = ItemType.Misc,
});
var cancelled = new List<PendingBackpackPlacement>();
h.Controller.PendingBackpackPlacementCancelled += cancelled.Add;
Assert.Throws<InvalidOperationException>(() =>
h.Controller.TryDispatchPendingBackpackPlacement(
item,
Player,
0,
InventoryRequestKind.Pickup,
static () => throw new InvalidOperationException("transport")));
Assert.False(h.Controller.TryGetPendingBackpackPlacement(item, out _));
Assert.False(h.Controller.TryGetPendingInventoryRequest(out _));
Assert.Single(cancelled);
}
[Fact]
public void ThrowingPendingProjectionObserverRollsBackEveryOwner()
{
var h = new Harness();
const uint item = 0x70000A28u;
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = item,
Name = "Loot",
Type = ItemType.Misc,
});
bool firstObserved = false;
bool cancelled = false;
h.Controller.PendingBackpackPlacementRequested += _ =>
firstObserved = true;
h.Controller.PendingBackpackPlacementRequested += _ =>
throw new InvalidOperationException("projection");
h.Controller.PendingBackpackPlacementCancelled += _ =>
cancelled = true;
Assert.Throws<AggregateException>(() =>
h.Controller.TryDispatchPendingBackpackPlacement(
item,
Player,
0,
InventoryRequestKind.Pickup,
static () => true));
Assert.True(firstObserved);
Assert.True(cancelled);
Assert.False(h.Controller.TryGetPendingBackpackPlacement(item, out _));
Assert.False(h.Controller.TryGetPendingInventoryRequest(out _));
}
[Fact] [Fact]
public void ResetSession_ClearsTargetBusyThrottleAndConsumedClickState() public void ResetSession_ClearsTargetBusyThrottleAndConsumedClickState()
{ {

View file

@ -0,0 +1,262 @@
using AcDream.Core.Items;
namespace AcDream.Core.Tests.Items;
public sealed class InventoryTransactionStateTests
{
private const uint Player = 0x50000001u;
private const uint Pack = 0x50000002u;
private const uint First = 0x60000001u;
private const uint Second = 0x60000002u;
[Fact]
public void ReserveClosesReentrantDispatchWindowAndSuccessfulSendCommits()
{
var objects = CreateTable();
using var state = new InventoryTransactionState(objects);
bool secondDispatched = true;
Assert.True(state.TryDispatch(
InventoryRequestKind.PutInContainer,
First,
() =>
{
secondDispatched = state.TryDispatch(
InventoryRequestKind.PutInContainer,
Second,
static () => true);
return true;
}));
Assert.False(secondDispatched);
Assert.True(state.TryGetPending(out PendingInventoryRequest pending));
Assert.Equal(First, pending.ItemId);
Assert.True(pending.Dispatched);
}
[Fact]
public void DispatchFailureOrExceptionReleasesExactProvisionalOwner()
{
var objects = CreateTable();
using var state = new InventoryTransactionState(objects);
Assert.False(state.TryDispatch(
InventoryRequestKind.PutInContainer,
First,
static () => false));
Assert.False(state.HasPendingRequest);
Assert.Throws<InvalidOperationException>(() => state.TryDispatch(
InventoryRequestKind.PutInContainer,
First,
static () => throw new InvalidOperationException("transport")));
Assert.False(state.HasPendingRequest);
Assert.True(state.CanBeginRequest);
}
[Fact]
public void FailedPrePublicationCallbackRollsBackOnlyItsReservation()
{
var objects = CreateTable();
using var state = new InventoryTransactionState(objects);
Assert.Throws<InvalidOperationException>(() =>
state.TryReserve(
InventoryRequestKind.Pickup,
First,
out _,
static _ => throw new InvalidOperationException("projection")));
Assert.False(state.HasPendingRequest);
Assert.True(state.CanBeginRequest);
}
[Fact]
public void AuthoritativeResponseClearsBeforeReentrantCompletionObserver()
{
var objects = CreateTable();
using var state = new InventoryTransactionState(objects);
bool replacementDispatched = false;
state.RequestCompleted += request =>
{
Assert.Equal(First, request.ItemId);
Assert.False(state.HasPendingRequest);
replacementDispatched = state.TryDispatch(
InventoryRequestKind.PutInContainer,
Second,
static () => true);
};
Assert.True(state.TryDispatch(
InventoryRequestKind.PutInContainer,
First,
static () => true));
Assert.True(objects.ApplyConfirmedServerMove(First, Player, 0u, 0));
Assert.True(replacementDispatched);
Assert.True(state.TryGetPending(out PendingInventoryRequest pending));
Assert.Equal(Second, pending.ItemId);
}
[Fact]
public void OptimisticMoveAndUnrelatedResponsesDoNotCompleteRequest()
{
var objects = CreateTable();
using var state = new InventoryTransactionState(objects);
Assert.True(state.TryDispatch(
InventoryRequestKind.PutInContainer,
First,
() => objects.MoveItemOptimistic(First, Player, 0)));
Assert.True(objects.ApplyConfirmedServerMove(Second, Player, 1u, 1));
Assert.True(state.HasPendingRequest);
Assert.True(objects.ApplyConfirmedServerMove(First, Player, 0u, 0));
Assert.False(state.HasPendingRequest);
}
[Fact]
public void ReusedGuidResponseCannotCompletePriorObjectIdentity()
{
var objects = CreateTable();
using var state = new InventoryTransactionState(objects);
ClientObject original = objects.Get(First)!;
Assert.True(state.TryDispatch(
InventoryRequestKind.PutInContainer,
First,
static () => true));
var replacement = new ClientObject
{
ObjectId = First,
Name = "replacement",
Type = ItemType.Misc,
};
objects.AddOrUpdate(replacement);
objects.MoveItem(First, Pack, 0);
Assert.NotSame(original, replacement);
Assert.True(objects.ApplyConfirmedServerMove(First, Player, 0u, 0));
Assert.True(state.HasPendingRequest);
}
[Fact]
public void UseReservationTransfersToUseDoneOrCancelsBeforeDispatch()
{
var objects = CreateTable();
using var state = new InventoryTransactionState(objects);
ItemUseRequestReservation cancelled = state.BeginUseRequestReservation();
Assert.Equal(1, state.BusyCount);
cancelled.CancelBeforeDispatch();
cancelled.CancelBeforeDispatch();
Assert.Equal(0, state.BusyCount);
ItemUseRequestReservation dispatched = state.BeginUseRequestReservation();
dispatched.MarkDispatched();
dispatched.CancelBeforeDispatch();
Assert.Equal(1, state.BusyCount);
state.CompleteUse(0u);
Assert.Equal(0, state.BusyCount);
}
[Fact]
public void ResetInvalidatesLateUseReservationAndClearsPendingRequest()
{
var objects = CreateTable();
using var state = new InventoryTransactionState(objects);
Assert.True(state.TryReserve(
InventoryRequestKind.Pickup,
First,
out _));
ItemUseRequestReservation stale = state.BeginUseRequestReservation();
state.ResetSession();
stale.CancelBeforeDispatch();
Assert.Equal(0, state.BusyCount);
Assert.False(state.HasPendingRequest);
Assert.True(state.CanBeginRequest);
}
[Fact]
public void ObserverFailureIsIsolatedAndRecorded()
{
var objects = CreateTable();
using var state = new InventoryTransactionState(objects);
int delivered = 0;
state.StateChanged += () =>
throw new InvalidOperationException("observer");
state.StateChanged += () => delivered++;
state.IncrementBusyCount();
Assert.Equal(1, delivered);
Assert.Equal(1, state.DispatchFailureCount);
Assert.IsType<InvalidOperationException>(state.LastDispatchFailure);
}
[Fact]
public void ObjectTableClearReleasesRequestAndNotifiesProjectionOwner()
{
var objects = CreateTable();
using var state = new InventoryTransactionState(objects);
int cleared = 0;
state.ObjectTableCleared += () => cleared++;
Assert.True(state.TryDispatch(
InventoryRequestKind.PutInContainer,
First,
static () => true));
objects.Clear();
Assert.Equal(1, cleared);
Assert.False(state.HasPendingRequest);
}
[Fact]
public void DisposeDetachesFromBorrowedObjectTable()
{
var objects = CreateTable();
var state = new InventoryTransactionState(objects);
int completed = 0;
state.RequestCompleted += _ => completed++;
Assert.True(state.TryDispatch(
InventoryRequestKind.PutInContainer,
First,
static () => true));
state.Dispose();
state.Dispose();
objects.ApplyConfirmedServerMove(First, Player, 0u, 0);
Assert.True(state.IsDisposed);
Assert.Equal(0, completed);
Assert.Throws<ObjectDisposedException>(() =>
state.TryReserve(
InventoryRequestKind.Pickup,
First,
out _));
}
private static ClientObjectTable CreateTable()
{
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject
{
ObjectId = First,
Name = "first",
Type = ItemType.Misc,
});
objects.AddOrUpdate(new ClientObject
{
ObjectId = Second,
Name = "second",
Type = ItemType.Misc,
});
objects.MoveItem(First, Pack, 0);
objects.MoveItem(Second, Pack, 1);
return objects;
}
}

View file

@ -0,0 +1,128 @@
using AcDream.Core.Items;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Gameplay;
namespace AcDream.Runtime.Tests.Gameplay;
public sealed class RuntimeInventoryStateTests
{
[Fact]
public void OwnsOneGameplayGraphOverExactEntityObjectTable()
{
using var entities = new RuntimeEntityObjectLifetime();
using var inventory = new RuntimeInventoryState(entities);
IReadOnlyList<ShortcutEntry> shortcuts =
[new ShortcutEntry(2, 0x50000001u, 0u)];
IReadOnlyList<(uint Id, uint Amount)> components =
[(0x68000001u, 12u)];
inventory.ExternalContainers.RequestOpen(0x70000001u);
inventory.ExternalContainers.ApplyViewContents(0x70000001u);
inventory.ItemMana.OnQueryItemManaResponse(0x50000001u, 0.75f, true);
inventory.Shortcuts.Replace(shortcuts);
inventory.DesiredComponents.Replace(components);
Assert.Same(entities.Objects, inventory.Objects);
Assert.Equal(0x70000001u, inventory.ExternalContainers.CurrentContainerId);
Assert.Equal(0.75f, inventory.ItemMana.GetManaPercent(0x50000001u));
Assert.Same(shortcuts, inventory.Shortcuts.Items);
Assert.Same(components, inventory.DesiredComponents.Items);
Assert.Equal(1, inventory.Shortcuts.Revision);
Assert.Equal(1, inventory.DesiredComponents.Revision);
}
[Fact]
public void ResetOperationsClearOnlyTheirOwnedLifetimeGroup()
{
using var entities = new RuntimeEntityObjectLifetime();
using var inventory = new RuntimeInventoryState(entities);
inventory.ExternalContainers.RequestOpen(0x70000001u);
inventory.ExternalContainers.ApplyViewContents(0x70000001u);
inventory.ItemMana.OnQueryItemManaResponse(0x50000001u, 0.75f, true);
inventory.Shortcuts.Replace([new ShortcutEntry(1, 2u, 3u)]);
inventory.DesiredComponents.Replace([(4u, 5u)]);
inventory.Transactions.IncrementBusyCount();
inventory.ResetExternalContainer();
Assert.Equal(0u, inventory.ExternalContainers.CurrentContainerId);
Assert.True(inventory.ItemMana.HasMana(0x50000001u));
Assert.NotEmpty(inventory.Shortcuts.Items);
Assert.Equal(1, inventory.Transactions.BusyCount);
inventory.ResetTransactions();
inventory.ResetItemMana();
inventory.ResetPlayerSnapshots();
Assert.Equal(0, inventory.Transactions.BusyCount);
Assert.False(inventory.ItemMana.HasMana(0x50000001u));
Assert.Empty(inventory.Shortcuts.Items);
Assert.Empty(inventory.DesiredComponents.Items);
}
[Fact]
public void IndependentRuntimeInstancesNeverShareState()
{
using var firstEntities = new RuntimeEntityObjectLifetime();
using var secondEntities = new RuntimeEntityObjectLifetime();
using var first = new RuntimeInventoryState(firstEntities);
using var second = new RuntimeInventoryState(secondEntities);
first.Shortcuts.Replace([new ShortcutEntry(1, 2u, 3u)]);
first.Transactions.IncrementBusyCount();
Assert.NotSame(first.Objects, second.Objects);
Assert.Single(first.Shortcuts.Items);
Assert.Empty(second.Shortcuts.Items);
Assert.Equal(1, first.Transactions.BusyCount);
Assert.Equal(0, second.Transactions.BusyCount);
}
[Fact]
public void DisposeDetachesTransactionsAndClearsOwnedState()
{
using var entities = new RuntimeEntityObjectLifetime();
var inventory = new RuntimeInventoryState(entities);
inventory.ExternalContainers.RequestOpen(0x70000001u);
inventory.ExternalContainers.ApplyViewContents(0x70000001u);
inventory.ItemMana.OnQueryItemManaResponse(0x50000001u, 0.5f, true);
inventory.Shortcuts.Replace([new ShortcutEntry(1, 2u, 3u)]);
inventory.DesiredComponents.Replace([(4u, 5u)]);
inventory.Dispose();
inventory.Dispose();
Assert.True(inventory.IsDisposed);
Assert.True(inventory.Transactions.IsDisposed);
Assert.Equal(0u, inventory.ExternalContainers.CurrentContainerId);
Assert.False(inventory.ItemMana.HasMana(0x50000001u));
Assert.Empty(inventory.Shortcuts.Items);
Assert.Empty(inventory.DesiredComponents.Items);
}
[Fact]
public void DisposalRetriesATransientOwnerObserverFailureToConvergence()
{
using var entities = new RuntimeEntityObjectLifetime();
var inventory = new RuntimeInventoryState(entities);
inventory.ExternalContainers.RequestOpen(0x70000001u);
inventory.ExternalContainers.ApplyViewContents(0x70000001u);
bool fail = true;
inventory.ExternalContainers.Changed += _ =>
{
if (fail)
{
fail = false;
throw new InvalidOperationException("transient");
}
};
Assert.Throws<AggregateException>(inventory.Dispose);
Assert.False(inventory.IsDisposed);
Assert.True(inventory.Transactions.IsDisposed);
inventory.Dispose();
Assert.True(inventory.IsDisposed);
Assert.Equal(0u, inventory.ExternalContainers.CurrentContainerId);
}
}