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:
parent
595d5e6b01
commit
011efbeaa7
18 changed files with 1337 additions and 406 deletions
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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: (_, _) => { }),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
406
src/AcDream.Core/Items/InventoryTransactionState.cs
Normal file
406
src/AcDream.Core/Items/InventoryTransactionState.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
114
src/AcDream.Runtime/Gameplay/RuntimeInventoryState.cs
Normal file
114
src/AcDream.Runtime/Gameplay/RuntimeInventoryState.cs
Normal 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)>());
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
262
tests/AcDream.Core.Tests/Items/InventoryTransactionStateTests.cs
Normal file
262
tests/AcDream.Core.Tests/Items/InventoryTransactionStateTests.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue