From b298f99f913249d299796e6a71f7a9b6b283ac1a Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 26 Jul 2026 10:44:09 +0200 Subject: [PATCH] refactor(runtime): own canonical action state Move selection, combat, and interaction target mode under one Runtime owner; make plugins, retained UI, session routing, and typed runtime views borrow its exact children; and add failure-safe reset, instance isolation, source ownership, and normalized checkpoint coverage without changing retail ordering. Co-authored-by: Codex --- .../InteractionRetainedUiComposition.cs | 55 +++--- .../Composition/SessionPlayerComposition.cs | 19 +- .../Net/LiveSessionRuntimeFactory.cs | 8 +- src/AcDream.App/Program.cs | 14 +- src/AcDream.App/Rendering/GameWindow.cs | 24 +-- .../Rendering/GameWindowLifetime.cs | 4 + .../Runtime/CurrentGameRuntimeAdapter.cs | 8 +- .../CurrentGameRuntimeCommandAdapter.cs | 11 +- .../Runtime/CurrentGameRuntimeViewAdapter.cs | 6 + src/AcDream.App/Studio/FixtureProvider.cs | 1 + .../UI/CursorFeedbackController.cs | 1 + .../UI/ItemInteractionController.cs | 6 +- src/AcDream.Runtime/GameRuntimeActionViews.cs | 23 +++ src/AcDream.Runtime/GameRuntimeEvents.cs | 8 + src/AcDream.Runtime/GameRuntimeViews.cs | 3 + .../Gameplay}/InteractionState.cs | 35 +++- .../Gameplay/RuntimeActionState.cs | 177 ++++++++++++++++++ .../Gameplay/RuntimeGameplayOwnership.cs | 19 +- .../AcDream.App.Tests.csproj | 1 + .../InteractionRetainedUiCompositionTests.cs | 3 +- .../InteractionUiRuntimeSourcesTests.cs | 1 + .../GameplayInputCommandControllerTests.cs | 1 + .../SelectionInteractionControllerTests.cs | 1 + .../Runtime/CurrentGameRuntimeAdapterTests.cs | 14 +- .../Runtime/RuntimeActionOwnershipTests.cs | 159 ++++++++++++++++ .../UI/CursorFeedbackControllerTests.cs | 4 + .../UI/ItemInteractionControllerTests.cs | 2 + .../UI/Layout/AppraisalUiControllerTests.cs | 1 + .../ExternalContainerControllerTests.cs | 1 + .../UI/Layout/InventoryControllerTests.cs | 12 ++ .../UI/Layout/PaperdollControllerTests.cs | 1 + .../UI/Layout/ToolbarControllerTests.cs | 9 + .../RetailItemConfirmationControllerTests.cs | 3 + .../UI/RetailUiInteractionFlowTests.cs | 2 + .../GameRuntimeContractTests.cs | 16 ++ .../Gameplay}/InteractionStateTests.cs | 7 +- .../Gameplay/RuntimeActionStateTests.cs | 120 ++++++++++++ .../Gameplay/RuntimeGameplayOwnershipTests.cs | 24 ++- 38 files changed, 711 insertions(+), 93 deletions(-) create mode 100644 src/AcDream.Runtime/GameRuntimeActionViews.cs rename src/{AcDream.App/UI => AcDream.Runtime/Gameplay}/InteractionState.cs (66%) create mode 100644 src/AcDream.Runtime/Gameplay/RuntimeActionState.cs create mode 100644 tests/AcDream.App.Tests/Runtime/RuntimeActionOwnershipTests.cs rename tests/{AcDream.App.Tests/UI => AcDream.Runtime.Tests/Gameplay}/InteractionStateTests.cs (90%) create mode 100644 tests/AcDream.Runtime.Tests/Gameplay/RuntimeActionStateTests.cs diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs index 5f67b8fd..cee206a4 100644 --- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs +++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs @@ -44,9 +44,8 @@ internal sealed record InteractionRetainedUiDependencies( RetainedUiInputCaptureSlot RetainedInputCapture, InputDispatcher? InputDispatcher, RuntimeSettingsController Settings, - CombatState Combat, + RuntimeActionState Actions, ICombatAttackOperations CombatAttackOperations, - SelectionState Selection, RuntimeInventoryState Inventory, MagicCatalog MagicCatalog, RuntimeCharacterState Character, @@ -257,14 +256,14 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory { public CombatAttackController CreateCombatAttack( InteractionRetainedUiDependencies d) => - new(d.Combat, d.CombatAttackOperations); + new(d.Actions.Combat, d.CombatAttackOperations); public CombatTargetController CreateCombatTarget( InteractionRetainedUiDependencies d, DeferredSelectionUiAuthority selection) => new( - d.Combat, - d.Selection, + d.Actions.Combat, + d.Actions.Selection, autoTarget: () => d.Settings.Gameplay.AutoTarget, selectClosestTarget: () => selection.SelectClosestCombatTarget(showToast: false)); @@ -286,6 +285,7 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory return new ItemInteractionController( d.Inventory.Objects, d.Inventory.Transactions, + d.Actions.Interaction, playerGuid: () => d.PlayerIdentity.ServerGuid, sendUse: null, sendExamine: guid => session.CurrentSession?.SendAppraise(guid), @@ -304,8 +304,8 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory d.PlayerMode.IsPlayerMode && d.PlayerController.Controller is { IsAirborne: false }, inNonCombatMode: () => - d.Combat.CurrentMode == CombatMode.NonCombat, - combatState: d.Combat, + d.Actions.Combat.CurrentMode == CombatMode.NonCombat, + combatState: d.Actions.Combat, sendChangeCombatMode: mode => session.CurrentSession?.SendChangeCombatMode(mode), isComponentPack: d.MagicCatalog.IsComponentPack, @@ -316,7 +316,8 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory d.Inventory.ExternalContainers.CurrentContainerId, sendSplitToWorld: (item, amount) => session.CurrentSession?.SendStackableSplitTo3D(item, amount), - selectedObjectId: () => d.Selection.SelectedObjectId ?? 0u, + selectedObjectId: () => + d.Actions.Selection.SelectedObjectId ?? 0u, stackSplitQuantity: d.StackSplitQuantity, systemMessage: text => d.Communication.Chat.OnSystemMessage(text, 0x1Au), @@ -351,7 +352,9 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory try { VitalsVM vitals = d.ExistingVitals - ?? new VitalsVM(d.Combat, d.Character.LocalPlayer); + ?? new VitalsVM( + d.Actions.Combat, + d.Character.LocalPlayer); UiHost host = lease.AcquireHost( () => new UiHost( d.Gl, @@ -367,7 +370,8 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory itemInteraction, worldTargetProvider: () => late.Selection.PickAtCursor(includeSelf: true) ?? 0u, - combatModeProvider: () => d.Combat.CurrentMode); + combatModeProvider: () => + d.Actions.Combat.CurrentMode); var cursorManager = new RetailCursorManager(d.Dats, d.DatLock); checkpoint(InteractionRetainedUiCompositionPoint.CursorAssetsCreated); @@ -404,7 +408,8 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory d.MagicCatalog, d.Character.Spellbook, d.Inventory.Objects, - selectedObject: () => d.Selection.SelectedObjectId, + selectedObject: () => + d.Actions.Selection.SelectedObjectId, localPlayerId: () => d.PlayerIdentity.ServerGuid, accountName: () => late.Session.AccountName ?? d.Options.LiveUser @@ -533,10 +538,10 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory Chat: new ChatRuntimeBindings(chat, () => late.Session.Commands), Radar: new RadarRuntimeBindings( late.Radar.Snapshot, - d.Selection, + d.Actions.Selection, d.Settings.SetUiLocked), Combat: new CombatRuntimeBindings( - d.Combat, + d.Actions.Combat, combatAttack, () => d.Settings.Gameplay, d.Settings.SetCombatGameplay), @@ -550,11 +555,13 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory iconComposer.GetDragIcon, iconComposer.GetSpellIcon, iconComposer.GetSpellComponentIcon, - d.Selection, + d.Actions.Selection, d.MagicCatalog.GetSpellLevel, magic.GetExamineComponents, MagicSkillLevel, - guid => d.Selection.Select(guid, SelectionChangeSource.Inventory), + guid => d.Actions.Selection.Select( + guid, + SelectionChangeSource.Inventory), guid => late.Session.TryUseItem(guid, d.Log), (tab, position, spellId) => late.GameRuntime.AddFavorite(tab, position, spellId), @@ -574,7 +581,7 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory () => 1.0, () => d.Settings.DisplayPreview.ShowFps), VividTarget: new VividTargetRuntimeBindings( - d.Selection, + d.Actions.Selection, () => d.PlayerIdentity.ServerGuid, () => d.Settings.Gameplay.VividTargetingIndicator, late.Selection.ResolveVividTargetInfo, @@ -596,19 +603,19 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory iconComposer.GetIcon, iconComposer.GetDragIcon, guid => late.Session.TryUseItem(guid, d.Log), - d.Combat, + d.Actions.Combat, d.Inventory.ItemMana, d.CombatModeCommands.Toggle, itemInteraction, entry => late.GameRuntime.AddShortcut(entry), index => late.GameRuntime.RemoveShortcut(index), - d.Selection, - handler => d.Combat.HealthChanged += handler, - handler => d.Combat.HealthChanged -= handler, + d.Actions.Selection, + handler => d.Actions.Combat.HealthChanged += handler, + handler => d.Actions.Combat.HealthChanged -= handler, late.Selection.ShouldShowHealth, guid => d.Inventory.Objects.Get(guid)?.GetAppropriateName(), - d.Combat.GetHealthPercent, - d.Combat.HasHealth, + d.Actions.Combat.GetHealthPercent, + d.Actions.Combat.HasHealth, guid => (uint)(d.Inventory.Objects.Get(guid)?.StackSize ?? 0), guid => late.Session.CurrentSession?.SendQueryHealth(guid), @@ -646,14 +653,14 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory target, amount), itemInteraction, - d.Selection), + d.Actions.Selection), ExternalContainer: new ExternalContainerRuntimeBindings( d.Inventory.ExternalContainers, d.Inventory.Objects, iconComposer.GetIcon, iconComposer.GetDragIcon, itemInteraction, - d.Selection, + d.Actions.Selection, guid => late.Session.CurrentSession?.SendUse(guid), (item, container, placement) => late.Session.CurrentSession?.SendPutItemInContainer( diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs index 11562712..36f0026d 100644 --- a/src/AcDream.App/Composition/SessionPlayerComposition.cs +++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs @@ -48,7 +48,7 @@ internal sealed record SessionPlayerDependencies( PhysicsDataCache PhysicsDataCache, WorldGameState WorldGameState, WorldEvents WorldEvents, - SelectionState Selection, + RuntimeActionState Actions, RuntimeEntityObjectLifetime EntityObjects, EntityClassificationCache ClassificationCache, LiveEntityRuntimeSlot RuntimeSlot, @@ -80,7 +80,6 @@ internal sealed record SessionPlayerDependencies( UpdateFrameClock UpdateClock, MovementTruthDiagnosticController MovementDiagnostics, CombatAttackOperationsSlot CombatAttackOperations, - CombatState Combat, CombatFeedbackSlot CombatFeedback, RuntimeCommunicationState Communication, RuntimeCharacterState Character, @@ -326,7 +325,7 @@ internal sealed class SessionPlayerCompositionPhase d.DatLock); var worldQuiescence = new WorldGenerationQuiescence( live.WorldAvailability, - d.Selection, + d.Actions.Selection, live.WorldState, guid => live.LiveEntities.TryGetProjectionKey( guid, @@ -455,7 +454,7 @@ internal sealed class SessionPlayerCompositionPhase live.EntityEffects, live.RemoteTeleport, live.SelectionInteractions, - d.Selection, + d.Actions.Selection, d.AnimatedEntities, d.RemoteMovementObservations, d.TranslucencyFades, @@ -563,9 +562,9 @@ internal sealed class SessionPlayerCompositionPhase "combat attack operations", d.CombatAttackOperations.BindOwned( new LiveCombatAttackOperations( - d.Combat, + d.Actions.Combat, new CombatAttackTargetSource( - d.Selection, + d.Actions.Selection, live.LiveEntities, d.EntityObjects.Objects, d.PlayerIdentity), @@ -798,7 +797,7 @@ internal sealed class SessionPlayerCompositionPhase new LiveSessionDomainRuntime( d.EntityObjects, d.Character, - d.Combat, + d.Actions, d.Inventory, d.Communication), new LiveSessionUiRuntime( @@ -856,7 +855,7 @@ internal sealed class SessionPlayerCompositionPhase new LocalPlayerCombatEquipmentSource( d.EntityObjects.Objects, d.PlayerIdentity), - d.Combat, + d.Actions.Combat, new ItemInteractionCombatModeIntentSink( interaction.ItemInteraction), d.Log, @@ -875,10 +874,10 @@ internal sealed class SessionPlayerCompositionPhase d.Inventory, d.Character, d.Communication, + d.Actions, d.PlayerController, worldReveal, d.UpdateClock, - d.Selection, live.SelectionInteractions, gameplayInput, combatCommand); @@ -940,7 +939,7 @@ internal sealed class SessionPlayerCompositionPhase GameplayInputActionRouter gameplayActions = GameplayInputActionRouter.Create( dispatcher, - d.Combat, + d.Actions.Combat, targets, d.HostQuiescence, d.Log); diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs index 3b0ac1bb..ee17faca 100644 --- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs +++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs @@ -41,7 +41,7 @@ internal sealed record LiveSessionPlayerRuntime( internal sealed record LiveSessionDomainRuntime( RuntimeEntityObjectLifetime EntityObjects, RuntimeCharacterState Character, - CombatState Combat, + RuntimeActionState Actions, RuntimeInventoryState Inventory, RuntimeCommunicationState Communication); @@ -134,7 +134,7 @@ internal sealed class LiveSessionRuntimeFactory SetChatIdentity: _domain.Communication.Chat.SetLocalPlayerGuid, MarkPersistent: _world.WorldState.MarkPersistent, SetVanishProbeIdentity: id => EntityVanishProbe.PlayerGuid = id, - ClearCombat: _domain.Combat.Clear), + ClearCombat: _domain.Actions.Combat.Clear), EnteredWorld: new( SetActiveCharacter: _interaction.Settings.SetActiveCharacter, RestoreLayout: () => _ui.RetailUi?.RestoreLayout(), @@ -170,7 +170,7 @@ internal sealed class LiveSessionRuntimeFactory Spellbook = _domain.Character.ResetSpellbook, MagicRuntime = () => _ui.Magic?.Reset(), CombatAttack = _interaction.CombatAttack.ResetSession, - CombatState = _domain.Combat.Clear, + CombatState = _domain.Actions.Combat.Clear, ItemMana = _domain.Inventory.ResetItemMana, LocalPlayer = _domain.Character.ResetLocalPlayer, Friends = _domain.Communication.ResetFriends, @@ -275,7 +275,7 @@ internal sealed class LiveSessionRuntimeFactory { var skillCreditResolver = new LiveSkillCreditResolver(skillTable); return new LiveCharacterSessionBindings( - _domain.Combat, + _domain.Actions.Combat, _domain.Character, ResolveSkillFormulaBonus: skillCreditResolver.Resolve, OnSkillsUpdated: (runSkill, jumpSkill) => diff --git a/src/AcDream.App/Program.cs b/src/AcDream.App/Program.cs index 5aa89c32..a6ef26ca 100644 --- a/src/AcDream.App/Program.cs +++ b/src/AcDream.App/Program.cs @@ -31,10 +31,18 @@ var runtimeOptions = RuntimeOptions.FromEnvironment(datDir); var worldGameState = new AcDream.Core.Plugins.WorldGameState(); var worldEvents = new AcDream.Core.Plugins.WorldEvents(); -var selection = new AcDream.Core.Selection.SelectionState(); var uiRegistry = new AcDream.App.Plugins.BufferedUiRegistry(); +using var window = new GameWindow( + runtimeOptions, + worldGameState, + worldEvents, + uiRegistry); var host = new AppPluginHost( - new SerilogAdapter(Log.Logger), worldGameState, worldEvents, selection, uiRegistry); + new SerilogAdapter(Log.Logger), + worldGameState, + worldEvents, + window.Selection, + uiRegistry); var pluginsDir = Path.Combine(AppContext.BaseDirectory, "plugins"); Log.Information("scanning plugins in {PluginsDir}", pluginsDir); @@ -67,8 +75,6 @@ try catch (Exception ex) { Log.Error(ex, "plugin enable failed: {Id}", plugin.Manifest.Id); } } - using var window = new GameWindow( - runtimeOptions, worldGameState, worldEvents, selection, uiRegistry); window.Run(); } finally diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index b564b1f8..c800ee57 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -38,7 +38,6 @@ public sealed class GameWindow : private readonly string _datDir; private readonly WorldGameState _worldGameState; private readonly WorldEvents _worldEvents; - private readonly AcDream.Core.Selection.SelectionState _selection; private readonly HostQuiescenceGate _hostQuiescence = new(); private IWindow? _window; private SilkWindowCallbackBinding? _windowCallbacks; @@ -324,9 +323,12 @@ public sealed class GameWindow : private AcDream.App.World.LiveEntityRuntime? _liveEntities; private AcDream.App.World.LiveEntityLivenessController? _liveEntityLiveness; - // J4.1: Runtime owns communication/social state. App, UI, plugins, and - // live-session routing borrow these exact instances. + // J4/J5.1: Runtime owns communication, selection, combat, and target-mode + // state. App, UI, plugins, and live-session routing borrow exact children. private readonly RuntimeCommunicationState _runtimeCommunication = new(); + private readonly RuntimeActionState _runtimeActions = new(); + public AcDream.Core.Selection.SelectionState Selection => + _runtimeActions.Selection; public AcDream.Core.Chat.ChatLog Chat => _runtimeCommunication.Chat; public AcDream.Core.Chat.TurbineChatState TurbineChat => _runtimeCommunication.TurbineChat; @@ -334,7 +336,8 @@ public sealed class GameWindow : _runtimeCommunication.Friends; public AcDream.Core.Social.SquelchState Squelch => _runtimeCommunication.Squelch; - public readonly AcDream.Core.Combat.CombatState Combat = new(); + public AcDream.Core.Combat.CombatState Combat => + _runtimeActions.Combat; private readonly RuntimeEntityObjectLifetime _runtimeEntityObjects = new(); private readonly RuntimeInventoryState _runtimeInventory; private readonly RuntimeCharacterState _runtimeCharacter; @@ -554,7 +557,6 @@ public sealed class GameWindow : AcDream.App.RuntimeOptions options, WorldGameState worldGameState, WorldEvents worldEvents, - AcDream.Core.Selection.SelectionState selection, AcDream.App.Plugins.BufferedUiRegistry? uiRegistry = null) { _options = options ?? throw new System.ArgumentNullException(nameof(options)); @@ -584,7 +586,6 @@ public sealed class GameWindow : new JsonRuntimeSettingsStorage( AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath()), log: Console.WriteLine); - _selection = selection ?? throw new System.ArgumentNullException(nameof(selection)); _animationDiagnostics = AnimationPresentationDiagnostics.FromEnvironment(); _uiRegistry = uiRegistry; _animatedEntities = new LiveEntityAnimationRuntimeView( @@ -1252,9 +1253,8 @@ public sealed class GameWindow : _retainedInputCapture, hostInputCamera.InputDispatcher, _runtimeSettings, - Combat, + _runtimeActions, _combatAttackOperations, - _selection, _runtimeInventory, contentEffectsAudio.MagicCatalog, _runtimeCharacter, @@ -1304,7 +1304,7 @@ public sealed class GameWindow : _physicsDataCache, _worldGameState, _worldEvents, - _selection, + Selection, _runtimeEntityObjects, _liveEntityRuntimeSlot, _liveEntityMotionBindings, @@ -1362,7 +1362,7 @@ public sealed class GameWindow : _physicsDataCache, _worldGameState, _worldEvents, - _selection, + _runtimeActions, _runtimeEntityObjects, _classificationCache, _liveEntityRuntimeSlot, @@ -1394,7 +1394,6 @@ public sealed class GameWindow : _updateFrameClock, _movementTruthDiagnostics, _combatAttackOperations, - Combat, _combatFeedback, _runtimeCommunication, _runtimeCharacter, @@ -1444,7 +1443,7 @@ public sealed class GameWindow : _debugVmRenderFacts, _inputCapture, _cameraInput, - _selection, + Selection, _animatedEntities, _updateFrameClock, Combat, @@ -1575,6 +1574,7 @@ public sealed class GameWindow : _runtimeInventory, _runtimeCharacter, _runtimeCommunication, + _runtimeActions, _renderSceneShadow, _livePresentationBindings, _entityEffectAdvance, diff --git a/src/AcDream.App/Rendering/GameWindowLifetime.cs b/src/AcDream.App/Rendering/GameWindowLifetime.cs index 90a72eeb..ee818ba1 100644 --- a/src/AcDream.App/Rendering/GameWindowLifetime.cs +++ b/src/AcDream.App/Rendering/GameWindowLifetime.cs @@ -87,6 +87,7 @@ internal sealed record LiveShutdownRoots( RuntimeInventoryState Inventory, RuntimeCharacterState Character, RuntimeCommunicationState Communication, + RuntimeActionState Actions, RenderSceneShadowRuntime? RenderSceneShadow, LivePresentationRuntimeBindings? PresentationBindings, DeferredEntityEffectAdvanceSource EffectAdvance, @@ -398,6 +399,9 @@ internal static class GameWindowShutdownManifest Hard( "runtime inventory state", live.Inventory.Dispose), + Hard( + "runtime action state", + live.Actions.Dispose), Hard( "runtime entity/object lifetime", live.EntityObjects.Dispose), diff --git a/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs b/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs index 98ead4d3..a52d4fea 100644 --- a/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs +++ b/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs @@ -4,8 +4,6 @@ using AcDream.App.Interaction; using AcDream.App.Net; using AcDream.App.Streaming; using AcDream.App.World; -using AcDream.Core.Chat; -using AcDream.Core.Selection; using AcDream.Runtime; using AcDream.Runtime.Entities; using AcDream.Runtime.Gameplay; @@ -40,10 +38,10 @@ internal sealed class CurrentGameRuntimeAdapter RuntimeInventoryState inventory, RuntimeCharacterState character, RuntimeCommunicationState communication, + RuntimeActionState actions, ILocalPlayerControllerSource playerController, WorldRevealCoordinator worldReveal, IGameRuntimeClock clock, - SelectionState selectionState, SelectionInteractionController selection, GameplayInputFrameController gameplayInput, ILiveCombatModeCommand combat) @@ -56,6 +54,7 @@ internal sealed class CurrentGameRuntimeAdapter inventory, character, communication, + actions, playerController, worldReveal, clock); @@ -73,7 +72,7 @@ internal sealed class CurrentGameRuntimeAdapter _view, inventory, character, - selectionState, + actions, selection, gameplayInput, combat, @@ -89,6 +88,7 @@ internal sealed class CurrentGameRuntimeAdapter public IRuntimeCharacterView Character => _view.Character; public IRuntimeSocialView Social => _view.Social; public IRuntimeChatView Chat => _view.Chat; + public IRuntimeActionView Actions => _view.Actions; public IRuntimeMovementView Movement => _view.Movement; public IRuntimePortalView Portal => _view.Portal; diff --git a/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs b/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs index 1004a191..773acb69 100644 --- a/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs +++ b/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs @@ -2,7 +2,6 @@ using AcDream.App.Combat; using AcDream.App.Input; using AcDream.App.Interaction; using AcDream.App.Net; -using AcDream.Core.Selection; using AcDream.Core.Items; using AcDream.Runtime; using AcDream.Runtime.Gameplay; @@ -34,7 +33,7 @@ internal sealed class CurrentGameRuntimeCommandAdapter private readonly CurrentGameRuntimeViewAdapter _view; private readonly RuntimeInventoryState _inventory; private readonly RuntimeCharacterState _character; - private readonly SelectionState _selectionState; + private readonly RuntimeActionState _actions; private readonly SelectionInteractionController _selection; private readonly GameplayInputFrameController _gameplayInput; private readonly ILiveCombatModeCommand _combat; @@ -47,7 +46,7 @@ internal sealed class CurrentGameRuntimeCommandAdapter CurrentGameRuntimeViewAdapter view, RuntimeInventoryState inventory, RuntimeCharacterState character, - SelectionState selectionState, + RuntimeActionState actions, SelectionInteractionController selection, GameplayInputFrameController gameplayInput, ILiveCombatModeCommand combat, @@ -59,8 +58,8 @@ internal sealed class CurrentGameRuntimeCommandAdapter _view = view ?? throw new ArgumentNullException(nameof(view)); _inventory = inventory ?? throw new ArgumentNullException(nameof(inventory)); _character = character ?? throw new ArgumentNullException(nameof(character)); - _selectionState = selectionState - ?? throw new ArgumentNullException(nameof(selectionState)); + _actions = actions + ?? throw new ArgumentNullException(nameof(actions)); _selection = selection ?? throw new ArgumentNullException(nameof(selection)); _gameplayInput = gameplayInput ?? throw new ArgumentNullException(nameof(gameplayInput)); @@ -159,7 +158,7 @@ internal sealed class CurrentGameRuntimeCommandAdapter && _selection.HandleInputAction(action) ? RuntimeCommandStatus.Accepted : RuntimeCommandStatus.Unsupported; - uint selected = _selectionState.SelectedObjectId ?? 0u; + uint selected = _actions.Selection.SelectedObjectId ?? 0u; _events.EmitCommand( RuntimeCommandDomain.Selection, (int)command, diff --git a/src/AcDream.App/Runtime/CurrentGameRuntimeViewAdapter.cs b/src/AcDream.App/Runtime/CurrentGameRuntimeViewAdapter.cs index ce470196..cae659a3 100644 --- a/src/AcDream.App/Runtime/CurrentGameRuntimeViewAdapter.cs +++ b/src/AcDream.App/Runtime/CurrentGameRuntimeViewAdapter.cs @@ -28,6 +28,7 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView private readonly IRuntimeCharacterView _characterView; private readonly IRuntimeSocialView _socialView; private readonly IRuntimeChatView _chatView; + private readonly IRuntimeActionView _actionView; private readonly MovementView _movementView; private readonly PortalView _portalView; private bool _active = true; @@ -40,6 +41,7 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView RuntimeInventoryState inventory, RuntimeCharacterState character, RuntimeCommunicationState communication, + RuntimeActionState actions, ILocalPlayerControllerSource playerController, WorldRevealCoordinator worldReveal, IGameRuntimeClock clock) @@ -61,6 +63,8 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView character ?? throw new ArgumentNullException(nameof(character))).View; _chatView = communication.View; _socialView = communication.SocialView; + _actionView = ( + actions ?? throw new ArgumentNullException(nameof(actions))).View; _movementView = new MovementView( playerController ?? throw new ArgumentNullException(nameof(playerController)), @@ -104,6 +108,7 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView public IRuntimeCharacterView Character => _characterView; public IRuntimeSocialView Social => _socialView; public IRuntimeChatView Chat => _chatView; + public IRuntimeActionView Actions => _actionView; public IRuntimeMovementView Movement => _movementView; public IRuntimePortalView Portal => _portalView; @@ -121,6 +126,7 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView _socialView.Snapshot, _chatView.Revision, _chatView.Count, + _actionView.Snapshot, _movementView.Snapshot, _portalView.Snapshot); diff --git a/src/AcDream.App/Studio/FixtureProvider.cs b/src/AcDream.App/Studio/FixtureProvider.cs index 35b32246..4a4f8ac5 100644 --- a/src/AcDream.App/Studio/FixtureProvider.cs +++ b/src/AcDream.App/Studio/FixtureProvider.cs @@ -142,6 +142,7 @@ public static class FixtureProvider var itemInteraction = new ItemInteractionController( objects, new InventoryTransactionState(objects), + new AcDream.Runtime.Gameplay.InteractionState(), () => SampleData.PlayerGuid, sendUse: null, sendUseWithTarget: null, diff --git a/src/AcDream.App/UI/CursorFeedbackController.cs b/src/AcDream.App/UI/CursorFeedbackController.cs index d1deb4f3..babab5d6 100644 --- a/src/AcDream.App/UI/CursorFeedbackController.cs +++ b/src/AcDream.App/UI/CursorFeedbackController.cs @@ -1,4 +1,5 @@ using AcDream.Core.Combat; +using AcDream.Runtime.Gameplay; namespace AcDream.App.UI; diff --git a/src/AcDream.App/UI/ItemInteractionController.cs b/src/AcDream.App/UI/ItemInteractionController.cs index 6726167e..51c6cfb0 100644 --- a/src/AcDream.App/UI/ItemInteractionController.cs +++ b/src/AcDream.App/UI/ItemInteractionController.cs @@ -1,6 +1,7 @@ using System; using AcDream.Core.Combat; using AcDream.Core.Items; +using AcDream.Runtime.Gameplay; namespace AcDream.App.UI; @@ -74,6 +75,7 @@ public sealed class ItemInteractionController : IDisposable public ItemInteractionController( ClientObjectTable objects, InventoryTransactionState transactions, + InteractionState interactionState, Func playerGuid, Action? sendUse, Action? sendUseWithTarget, @@ -82,7 +84,6 @@ public sealed class ItemInteractionController : IDisposable Action? sendExamine = null, Func? nowMs = null, Action? toast = null, - InteractionState? interactionState = null, Func? readyForInventoryRequest = null, Func? activeVendorId = null, Func? groundObjectId = null, @@ -133,7 +134,8 @@ public sealed class ItemInteractionController : IDisposable _dragOnPlayerOpensSecureTrade = dragOnPlayerOpensSecureTrade ?? (() => true); _systemMessage = systemMessage; _requestUse = requestUse; - _interactionState = interactionState ?? new InteractionState(); + _interactionState = interactionState + ?? throw new ArgumentNullException(nameof(interactionState)); _transactions = transactions ?? throw new ArgumentNullException(nameof(transactions)); if (!ReferenceEquals(_transactions.Objects, _objects)) diff --git a/src/AcDream.Runtime/GameRuntimeActionViews.cs b/src/AcDream.Runtime/GameRuntimeActionViews.cs new file mode 100644 index 00000000..721d3e64 --- /dev/null +++ b/src/AcDream.Runtime/GameRuntimeActionViews.cs @@ -0,0 +1,23 @@ +using AcDream.Core.Combat; +using AcDream.Runtime.Gameplay; + +namespace AcDream.Runtime; + +public readonly record struct RuntimeActionSnapshot( + long SelectionRevision, + uint SelectedObjectId, + uint PreviousObjectId, + uint PreviousValidObjectId, + long CombatRevision, + CombatMode CombatMode, + int TrackedTargetHealthCount, + long InteractionRevision, + InteractionModeKind InteractionMode, + uint InteractionSourceObjectId); + +public interface IRuntimeActionView +{ + RuntimeActionSnapshot Snapshot { get; } + + bool TryGetHealth(uint objectId, out float healthPercent); +} diff --git a/src/AcDream.Runtime/GameRuntimeEvents.cs b/src/AcDream.Runtime/GameRuntimeEvents.cs index ac02eb8f..483ec131 100644 --- a/src/AcDream.Runtime/GameRuntimeEvents.cs +++ b/src/AcDream.Runtime/GameRuntimeEvents.cs @@ -162,6 +162,14 @@ public sealed class RuntimeTraceRecorder : IRuntimeEventObserver $"social={checkpoint.Social.FriendsRevision}:" + $"{checkpoint.Social.FriendCount}:" + $"{checkpoint.Social.SquelchRevision};" + + $"actions={checkpoint.Actions.SelectionRevision}:" + + $"{checkpoint.Actions.SelectedObjectId:X8}:" + + $"{checkpoint.Actions.CombatRevision}:" + + $"{(int)checkpoint.Actions.CombatMode}:" + + $"{checkpoint.Actions.TrackedTargetHealthCount}:" + + $"{checkpoint.Actions.InteractionRevision}:" + + $"{(int)checkpoint.Actions.InteractionMode}:" + + $"{checkpoint.Actions.InteractionSourceObjectId:X8};" + $"portal={checkpoint.Portal.Generation}:{(int)checkpoint.Portal.Kind}:" + $"{checkpoint.Portal.DestinationCell:X8}:{checkpoint.Portal.IsReady}"))); } diff --git a/src/AcDream.Runtime/GameRuntimeViews.cs b/src/AcDream.Runtime/GameRuntimeViews.cs index df12eadf..28496368 100644 --- a/src/AcDream.Runtime/GameRuntimeViews.cs +++ b/src/AcDream.Runtime/GameRuntimeViews.cs @@ -115,6 +115,7 @@ public readonly record struct RuntimeStateCheckpoint( RuntimeSocialSnapshot Social, long ChatRevision, int ChatCount, + RuntimeActionSnapshot Actions, RuntimeMovementSnapshot Movement, RuntimePortalSnapshot Portal); @@ -138,6 +139,8 @@ public interface IGameRuntimeView IRuntimeChatView Chat { get; } + IRuntimeActionView Actions { get; } + IRuntimeMovementView Movement { get; } IRuntimePortalView Portal { get; } diff --git a/src/AcDream.App/UI/InteractionState.cs b/src/AcDream.Runtime/Gameplay/InteractionState.cs similarity index 66% rename from src/AcDream.App/UI/InteractionState.cs rename to src/AcDream.Runtime/Gameplay/InteractionState.cs index 2c80651d..99ced37b 100644 --- a/src/AcDream.App/UI/InteractionState.cs +++ b/src/AcDream.Runtime/Gameplay/InteractionState.cs @@ -1,4 +1,4 @@ -namespace AcDream.App.UI; +namespace AcDream.Runtime.Gameplay; public enum InteractionModeKind { @@ -20,8 +20,9 @@ public readonly record struct InteractionModeTransition( InteractionMode Current); /// -/// Single App-layer owner for retail's pointer interaction mode. Core selection -/// remains session truth; this state represents temporary UI orchestration. +/// Canonical presentation-independent owner for retail's temporary target +/// mode. App projects this state into a cursor image; it does not own or copy +/// the mode. /// public sealed class InteractionState { @@ -31,13 +32,16 @@ public sealed class InteractionState public bool EnterUse() => Set(new InteractionMode(InteractionModeKind.Use)); - public bool EnterExamine() => Set(new InteractionMode(InteractionModeKind.Examine)); + public bool EnterExamine() => + Set(new InteractionMode(InteractionModeKind.Examine)); public bool EnterUseItemOnTarget(uint sourceObjectId) { if (sourceObjectId == 0) throw new ArgumentOutOfRangeException(nameof(sourceObjectId)); - return Set(new InteractionMode(InteractionModeKind.UseItemOnTarget, sourceObjectId)); + return Set(new InteractionMode( + InteractionModeKind.UseItemOnTarget, + sourceObjectId)); } public bool Clear() => Set(InteractionMode.None); @@ -45,6 +49,7 @@ public sealed class InteractionState /// /// Publishes the session-reset edge even when the mode is already clear so /// a retry can repair any observer that missed an earlier notification. + /// State commits before observers run and every observer is attempted. /// public void ResetSession() { @@ -54,17 +59,29 @@ public sealed class InteractionState if (listeners is null) return; - var transition = new InteractionModeTransition(previous, InteractionMode.None); + var transition = new InteractionModeTransition( + previous, + InteractionMode.None); List? failures = null; - foreach (Action listener in listeners.GetInvocationList()) + foreach (Action listener + in listeners.GetInvocationList()) { - try { listener(transition); } - catch (Exception error) { (failures ??= []).Add(error); } + try + { + listener(transition); + } + catch (Exception error) + { + (failures ??= []).Add(error); + } } + if (failures is not null) + { throw new AggregateException( "One or more interaction-mode reset observers failed.", failures); + } } private bool Set(InteractionMode mode) diff --git a/src/AcDream.Runtime/Gameplay/RuntimeActionState.cs b/src/AcDream.Runtime/Gameplay/RuntimeActionState.cs new file mode 100644 index 00000000..d40d7d3e --- /dev/null +++ b/src/AcDream.Runtime/Gameplay/RuntimeActionState.cs @@ -0,0 +1,177 @@ +using AcDream.Core.Combat; +using AcDream.Core.Selection; + +namespace AcDream.Runtime.Gameplay; + +public readonly record struct RuntimeActionOwnershipSnapshot( + bool IsDisposed, + bool InternalSubscriptionsAttached, + uint SelectedObjectId, + uint PreviousObjectId, + uint PreviousValidObjectId, + CombatMode CombatMode, + int TrackedTargetHealthCount, + InteractionMode InteractionMode, + long SelectionRevision, + long CombatRevision, + long InteractionRevision) +{ + public bool IsConverged => + IsDisposed + && !InternalSubscriptionsAttached + && SelectedObjectId == 0u + && PreviousObjectId == 0u + && PreviousValidObjectId == 0u + && CombatMode == CombatMode.NonCombat + && TrackedTargetHealthCount == 0 + && InteractionMode == InteractionMode.None; +} + +/// +/// Canonical presentation-independent owner for selection, combat +/// notifications/mode, and temporary interaction target mode. Graphical and +/// no-window hosts borrow these exact instances. +/// +public sealed class RuntimeActionState : IDisposable +{ + private bool _disposed; + private bool _internalSubscriptionsAttached; + private long _selectionRevision; + private long _combatRevision; + private long _interactionRevision; + + public RuntimeActionState() + { + Selection = new SelectionState(); + Combat = new CombatState(); + Interaction = new InteractionState(); + View = new ActionView(this); + + Selection.Changed += OnSelectionChanged; + Combat.CombatModeChanged += OnCombatModeChanged; + Combat.HealthChanged += OnHealthChanged; + Interaction.Changed += OnInteractionChanged; + _internalSubscriptionsAttached = true; + } + + public SelectionState Selection { get; } + public CombatState Combat { get; } + public InteractionState Interaction { get; } + public IRuntimeActionView View { get; } + public bool IsDisposed => _disposed; + + public RuntimeActionOwnershipSnapshot CaptureOwnership() => new( + _disposed, + _internalSubscriptionsAttached, + Selection.SelectedObjectId ?? 0u, + Selection.PreviousObjectId ?? 0u, + Selection.PreviousValidObjectId ?? 0u, + Combat.CurrentMode, + Combat.TrackedTargetCount, + Interaction.Current, + Interlocked.Read(ref _selectionRevision), + Interlocked.Read(ref _combatRevision), + Interlocked.Read(ref _interactionRevision)); + + /// + /// Restores the action group to the equivalent of a fresh retail character + /// session. Each owner commits before notifying observers, so every suffix + /// is attempted and a retry converges after observer failure. + /// + public void ResetSession() + { + ObjectDisposedException.ThrowIf(_disposed, this); + List? failures = null; + Try(Interaction.ResetSession, ref failures); + Try(() => Selection.Reset(), ref failures); + Try(Combat.Clear, ref failures); + if (failures is not null) + { + throw new AggregateException( + "Runtime action state did not converge during reset.", + failures); + } + } + + public void Dispose() + { + if (_disposed) + return; + + List? failures = null; + try + { + Try(Interaction.ResetSession, ref failures); + Try(() => Selection.Reset(), ref failures); + Try(Combat.Clear, ref failures); + } + finally + { + Selection.Changed -= OnSelectionChanged; + Combat.CombatModeChanged -= OnCombatModeChanged; + Combat.HealthChanged -= OnHealthChanged; + Interaction.Changed -= OnInteractionChanged; + _internalSubscriptionsAttached = false; + _disposed = true; + } + + if (failures is not null) + { + throw new AggregateException( + "Runtime action state did not converge during disposal.", + failures); + } + } + + private void OnSelectionChanged(SelectionTransition _) => + Interlocked.Increment(ref _selectionRevision); + + private void OnCombatModeChanged(CombatMode _) => + Interlocked.Increment(ref _combatRevision); + + private void OnHealthChanged(uint _, float __) => + Interlocked.Increment(ref _combatRevision); + + private void OnInteractionChanged(InteractionModeTransition _) => + Interlocked.Increment(ref _interactionRevision); + + private static void Try(Action action, ref List? failures) + { + try + { + action(); + } + catch (Exception error) + { + (failures ??= []).Add(error); + } + } + + private sealed class ActionView(RuntimeActionState owner) + : IRuntimeActionView + { + public RuntimeActionSnapshot Snapshot => new( + Interlocked.Read(ref owner._selectionRevision), + owner.Selection.SelectedObjectId ?? 0u, + owner.Selection.PreviousObjectId ?? 0u, + owner.Selection.PreviousValidObjectId ?? 0u, + Interlocked.Read(ref owner._combatRevision), + owner.Combat.CurrentMode, + owner.Combat.TrackedTargetCount, + Interlocked.Read(ref owner._interactionRevision), + owner.Interaction.Current.Kind, + owner.Interaction.Current.SourceObjectId); + + public bool TryGetHealth(uint objectId, out float healthPercent) + { + if (!owner.Combat.HasHealth(objectId)) + { + healthPercent = 0f; + return false; + } + + healthPercent = owner.Combat.GetHealthPercent(objectId); + return true; + } + } +} diff --git a/src/AcDream.Runtime/Gameplay/RuntimeGameplayOwnership.cs b/src/AcDream.Runtime/Gameplay/RuntimeGameplayOwnership.cs index c00fa76c..1235eb42 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeGameplayOwnership.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeGameplayOwnership.cs @@ -1,19 +1,21 @@ namespace AcDream.Runtime.Gameplay; /// -/// One allocation-free ledger over the complete J4 gameplay-state lifetime -/// group. It contains no state of its own; graphical and no-window hosts -/// capture the same three canonical owners. +/// One allocation-free ledger over the gameplay-state lifetime group through +/// J5.1. It contains no state of its own; graphical and no-window hosts capture +/// the same four canonical owners. /// public readonly record struct RuntimeGameplayOwnershipSnapshot( RuntimeInventoryOwnershipSnapshot Inventory, RuntimeCharacterOwnershipSnapshot Character, - RuntimeCommunicationOwnershipSnapshot Communication) + RuntimeCommunicationOwnershipSnapshot Communication, + RuntimeActionOwnershipSnapshot Actions) { public bool IsConverged => Inventory.IsConverged && Character.IsConverged - && Communication.IsConverged; + && Communication.IsConverged + && Actions.IsConverged; } public static class RuntimeGameplayOwnership @@ -21,14 +23,17 @@ public static class RuntimeGameplayOwnership public static RuntimeGameplayOwnershipSnapshot Capture( RuntimeInventoryState inventory, RuntimeCharacterState character, - RuntimeCommunicationState communication) + RuntimeCommunicationState communication, + RuntimeActionState actions) { ArgumentNullException.ThrowIfNull(inventory); ArgumentNullException.ThrowIfNull(character); ArgumentNullException.ThrowIfNull(communication); + ArgumentNullException.ThrowIfNull(actions); return new RuntimeGameplayOwnershipSnapshot( inventory.CaptureOwnership(), character.CaptureOwnership(), - communication.CaptureOwnership()); + communication.CaptureOwnership(), + actions.CaptureOwnership()); } } diff --git a/tests/AcDream.App.Tests/AcDream.App.Tests.csproj b/tests/AcDream.App.Tests/AcDream.App.Tests.csproj index 260829c6..186d903d 100644 --- a/tests/AcDream.App.Tests/AcDream.App.Tests.csproj +++ b/tests/AcDream.App.Tests/AcDream.App.Tests.csproj @@ -16,6 +16,7 @@ + diff --git a/tests/AcDream.App.Tests/Composition/InteractionRetainedUiCompositionTests.cs b/tests/AcDream.App.Tests/Composition/InteractionRetainedUiCompositionTests.cs index 2a257f37..4366d3e3 100644 --- a/tests/AcDream.App.Tests/Composition/InteractionRetainedUiCompositionTests.cs +++ b/tests/AcDream.App.Tests/Composition/InteractionRetainedUiCompositionTests.cs @@ -186,9 +186,8 @@ public sealed class InteractionRetainedUiCompositionTests RetainedInputCapture: null!, InputDispatcher: null, Settings: null!, - Combat: null!, + Actions: null!, CombatAttackOperations: null!, - Selection: null!, Inventory: null!, MagicCatalog: null!, Character: null!, diff --git a/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs b/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs index 4aacf352..57f7fe8e 100644 --- a/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs +++ b/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs @@ -234,6 +234,7 @@ public sealed class InteractionUiRuntimeSourcesTests public IRuntimeCharacterView Character => null!; public IRuntimeSocialView Social => null!; public IRuntimeChatView Chat => null!; + public IRuntimeActionView Actions => null!; public IRuntimeMovementView Movement => null!; public IRuntimePortalView Portal => null!; public IRuntimeSessionCommands Session => null!; diff --git a/tests/AcDream.App.Tests/Input/GameplayInputCommandControllerTests.cs b/tests/AcDream.App.Tests/Input/GameplayInputCommandControllerTests.cs index 04af1c64..6ca906dc 100644 --- a/tests/AcDream.App.Tests/Input/GameplayInputCommandControllerTests.cs +++ b/tests/AcDream.App.Tests/Input/GameplayInputCommandControllerTests.cs @@ -199,6 +199,7 @@ public sealed class GameplayInputCommandControllerTests throw new NotSupportedException(); public IRuntimeSocialView Social => throw new NotSupportedException(); public IRuntimeChatView Chat => throw new NotSupportedException(); + public IRuntimeActionView Actions => throw new NotSupportedException(); public IRuntimeMovementView Movement => throw new NotSupportedException(); public IRuntimePortalView Portal => throw new NotSupportedException(); public RuntimeStateCheckpoint CaptureCheckpoint() => diff --git a/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs b/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs index 61690c0a..29b21eef 100644 --- a/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs +++ b/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs @@ -161,6 +161,7 @@ public sealed class SelectionInteractionControllerTests Items = new ItemInteractionController( Objects, new InventoryTransactionState(Objects), + new InteractionState(), () => Player, sendUse: null, sendUseWithTarget: null, diff --git a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs index e8de2cb1..801b0bd1 100644 --- a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs +++ b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs @@ -42,6 +42,7 @@ public sealed class CurrentGameRuntimeAdapterTests Assert.Equal(new RuntimeGenerationToken(1), start.Generation); Assert.Equal(Harness.PlayerGuid, harness.Identity.ServerGuid); Assert.Equal(RuntimeLifecycleState.InWorld, harness.Runtime.Lifecycle.State); + Assert.Same(harness.Actions.View, harness.Runtime.Actions); LiveEntityRecord liveRecord = harness.Entities.RegisterAndMaterializeProjection(Spawn( @@ -117,6 +118,10 @@ public sealed class CurrentGameRuntimeAdapterTests Assert.Equal(1, checkpoint.ChatCount); Assert.Equal(1L, checkpoint.ChatRevision); Assert.Equal(1UL, checkpoint.FrameNumber); + Assert.Equal( + Harness.TargetGuid, + checkpoint.Actions.SelectedObjectId); + Assert.Equal(1, checkpoint.Actions.SelectionRevision); Assert.Equal(RuntimePortalKind.Portal, checkpoint.Portal.Kind); Assert.Equal(0x12340001u, checkpoint.Portal.DestinationCell); Assert.True(checkpoint.Portal.IsReady); @@ -676,7 +681,7 @@ public sealed class CurrentGameRuntimeAdapterTests EntityObjects); Objects = EntityObjects.Objects; Communication = new RuntimeCommunicationState(); - Selection = new SelectionState(); + Actions = new RuntimeActionState(); MovementInput = new DispatcherMovementInputSource(); GameplayInput = new GameplayInputFrameController( dispatcher: null, @@ -697,6 +702,7 @@ public sealed class CurrentGameRuntimeAdapterTests _items = new ItemInteractionController( Objects, InventoryState.Transactions, + Actions.Interaction, () => PlayerGuid, sendUse: null, sendUseWithTarget: null, @@ -726,10 +732,10 @@ public sealed class CurrentGameRuntimeAdapterTests InventoryState, Character, Communication, + Actions, new LocalPlayerControllerSlot(), WorldReveal, Clock, - Selection, selectionController, GameplayInput, Combat); @@ -744,7 +750,8 @@ public sealed class CurrentGameRuntimeAdapterTests public ClientObjectTable Objects { get; } public RuntimeCommunicationState Communication { get; } public ChatLog Chat => Communication.Chat; - public SelectionState Selection { get; } + public RuntimeActionState Actions { get; } + public SelectionState Selection => Actions.Selection; public DispatcherMovementInputSource MovementInput { get; } public GameplayInputFrameController GameplayInput { get; } public RecordingCombatCommand Combat { get; } @@ -763,6 +770,7 @@ public sealed class CurrentGameRuntimeAdapterTests Communication.Dispose(); _session.Dispose(); _items.Dispose(); + Actions.Dispose(); Entities.Clear(); } } diff --git a/tests/AcDream.App.Tests/Runtime/RuntimeActionOwnershipTests.cs b/tests/AcDream.App.Tests/Runtime/RuntimeActionOwnershipTests.cs new file mode 100644 index 00000000..75d1f880 --- /dev/null +++ b/tests/AcDream.App.Tests/Runtime/RuntimeActionOwnershipTests.cs @@ -0,0 +1,159 @@ +using System.Text.RegularExpressions; + +namespace AcDream.App.Tests.Runtime; + +public sealed class RuntimeActionOwnershipTests +{ + [Fact] + public void ProductionConstructsOneCanonicalActionOwner() + { + string root = FindRepositoryRoot(); + string gameWindow = ReadAppSource( + root, + "Rendering", + "GameWindow.cs"); + string program = ReadAppSource(root, "Program.cs"); + + Assert.Contains( + "private readonly RuntimeActionState _runtimeActions = new();", + gameWindow, + StringComparison.Ordinal); + Assert.Contains( + "_runtimeActions.Selection;", + gameWindow, + StringComparison.Ordinal); + Assert.Contains( + "_runtimeActions.Combat;", + gameWindow, + StringComparison.Ordinal); + Assert.Contains( + "window.Selection,", + program, + StringComparison.Ordinal); + + string[] productionFiles = Directory + .EnumerateFiles( + Path.Combine(root, "src", "AcDream.App"), + "*.cs", + SearchOption.AllDirectories) + .Where(static path => + !path.Contains( + $"{Path.DirectorySeparatorChar}Studio{Path.DirectorySeparatorChar}", + StringComparison.Ordinal)) + .ToArray(); + string production = string.Join( + "\n", + productionFiles.Select(File.ReadAllText)); + + Assert.Empty(Regex.Matches( + production, + @"\bnew\s+(?:AcDream\.Core\.Selection\.)?SelectionState\s*\(")); + Assert.Empty(Regex.Matches( + production, + @"\bnew\s+(?:AcDream\.Core\.Combat\.)?CombatState\s*\(")); + Assert.Empty(Regex.Matches( + production, + @"\bnew\s+(?:AcDream\.Runtime\.Gameplay\.)?InteractionState\s*\(")); + Assert.Equal( + 1, + Regex.Matches( + production, + @"\bnew\s+RuntimeActionState\s*\(").Count + + Regex.Matches( + production, + @"RuntimeActionState\s+\w+\s*=\s*new\s*\(\s*\)").Count); + } + + [Fact] + public void UiSessionRuntimeAndShutdownBorrowTheExactActionChildren() + { + string root = FindRepositoryRoot(); + string ui = ReadAppSource( + root, + "Composition", + "InteractionRetainedUiComposition.cs"); + string session = ReadAppSource( + root, + "Composition", + "SessionPlayerComposition.cs"); + string liveSession = ReadAppSource( + root, + "Net", + "LiveSessionRuntimeFactory.cs"); + string commands = ReadAppSource( + root, + "Runtime", + "CurrentGameRuntimeCommandAdapter.cs"); + string itemInteraction = ReadAppSource( + root, + "UI", + "ItemInteractionController.cs"); + string shutdown = ReadAppSource( + root, + "Rendering", + "GameWindowLifetime.cs"); + + Assert.Contains("d.Actions.Interaction,", ui, StringComparison.Ordinal); + Assert.Contains("d.Actions.Selection", ui, StringComparison.Ordinal); + Assert.Contains("d.Actions.Combat", ui, StringComparison.Ordinal); + Assert.Contains("d.Actions.Selection", session, StringComparison.Ordinal); + Assert.Contains("d.Actions.Combat", session, StringComparison.Ordinal); + Assert.Contains("_domain.Actions.Combat", liveSession, StringComparison.Ordinal); + Assert.Contains("_actions.Selection", commands, StringComparison.Ordinal); + Assert.Contains( + "InteractionState interactionState,", + itemInteraction, + StringComparison.Ordinal); + Assert.DoesNotContain( + "new InteractionState", + itemInteraction, + StringComparison.Ordinal); + Assert.Contains( + "\"runtime action state\"", + shutdown, + StringComparison.Ordinal); + AssertAppearsInOrder( + shutdown, + "\"runtime inventory state\"", + "\"runtime action state\"", + "\"runtime entity/object lifetime\""); + Assert.False(File.Exists(Path.Combine( + root, + "src", + "AcDream.App", + "UI", + "InteractionState.cs"))); + } + + private static string ReadAppSource(string root, params string[] relative) => + 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; + } + } +} diff --git a/tests/AcDream.App.Tests/UI/CursorFeedbackControllerTests.cs b/tests/AcDream.App.Tests/UI/CursorFeedbackControllerTests.cs index cf07327f..f6d7b1d6 100644 --- a/tests/AcDream.App.Tests/UI/CursorFeedbackControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/CursorFeedbackControllerTests.cs @@ -154,6 +154,7 @@ public sealed class CursorFeedbackControllerTests var interaction = new ItemInteractionController( objects, new InventoryTransactionState(objects), + new InteractionState(), playerGuid: () => Player, sendUse: null, sendUseWithTarget: null, @@ -190,6 +191,7 @@ public sealed class CursorFeedbackControllerTests var interaction = new ItemInteractionController( objects, new InventoryTransactionState(objects), + new InteractionState(), playerGuid: () => Player, sendUse: null, sendUseWithTarget: null, @@ -229,6 +231,7 @@ public sealed class CursorFeedbackControllerTests var interaction = new ItemInteractionController( objects, new InventoryTransactionState(objects), + new InteractionState(), playerGuid: () => Player, sendUse: null, sendUseWithTarget: null, @@ -258,6 +261,7 @@ public sealed class CursorFeedbackControllerTests var interaction = new ItemInteractionController( objects, new InventoryTransactionState(objects), + new InteractionState(), playerGuid: () => Player, sendUse: null, sendUseWithTarget: null, diff --git a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs index 85fd3fac..f00cb8af 100644 --- a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs @@ -60,6 +60,7 @@ public sealed class ItemInteractionControllerTests Controller = new ItemInteractionController( Objects, SharedTransactions, + new InteractionState(), playerGuid: () => Player, sendUse: requestUse is null ? Uses.Add : null, sendExamine: Examines.Add, @@ -341,6 +342,7 @@ public sealed class ItemInteractionControllerTests Assert.Throws(() => new ItemInteractionController( objects, transactions, + new InteractionState(), playerGuid: () => Player, sendUse: null, sendUseWithTarget: null, diff --git a/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs index eb79e34b..d317baa8 100644 --- a/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs @@ -859,6 +859,7 @@ public sealed class AppraisalUiControllerTests => new( objects, new InventoryTransactionState(objects), + new InteractionState(), playerGuid: () => 0x50000002u, sendUse: null, sendUseWithTarget: null, diff --git a/tests/AcDream.App.Tests/UI/Layout/ExternalContainerControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ExternalContainerControllerTests.cs index fde91696..d230f78d 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ExternalContainerControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ExternalContainerControllerTests.cs @@ -107,6 +107,7 @@ public sealed class ExternalContainerControllerTests Interaction = new ItemInteractionController( Objects, new InventoryTransactionState(Objects), + new InteractionState(), playerGuid: () => Player, sendUse: Uses.Add, sendUseWithTarget: null, diff --git a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs index a318d0f4..6f569e41 100644 --- a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs @@ -509,6 +509,7 @@ public class InventoryControllerTests using var interaction = new ItemInteractionController( objects, new InventoryTransactionState(objects), + new InteractionState(), playerGuid: () => Player, sendUse: null, sendUseWithTarget: null, @@ -546,6 +547,7 @@ public class InventoryControllerTests var interaction = new ItemInteractionController( objects, new InventoryTransactionState(objects), + new InteractionState(), playerGuid: () => Player, sendUse: null, sendUseWithTarget: null, @@ -715,6 +717,7 @@ public class InventoryControllerTests using var interaction = new ItemInteractionController( objects, new InventoryTransactionState(objects), + new InteractionState(), playerGuid: () => Player, sendUse: null, sendUseWithTarget: null, @@ -760,6 +763,7 @@ public class InventoryControllerTests using var interaction = new ItemInteractionController( objects, new InventoryTransactionState(objects), + new InteractionState(), playerGuid: () => Player, sendUse: null, sendUseWithTarget: null, @@ -812,6 +816,7 @@ public class InventoryControllerTests using var interaction = new ItemInteractionController( objects, new InventoryTransactionState(objects), + new InteractionState(), playerGuid: () => Player, sendUse: null, sendUseWithTarget: null, @@ -870,6 +875,7 @@ public class InventoryControllerTests using var interaction = new ItemInteractionController( objects, new InventoryTransactionState(objects), + new InteractionState(), playerGuid: () => Player, sendUse: null, sendUseWithTarget: null, @@ -939,6 +945,7 @@ public class InventoryControllerTests using var interaction = new ItemInteractionController( objects, new InventoryTransactionState(objects), + new InteractionState(), playerGuid: () => Player, sendUse: null, sendUseWithTarget: null, @@ -1002,6 +1009,7 @@ public class InventoryControllerTests using var interaction = new ItemInteractionController( objects, new InventoryTransactionState(objects), + new InteractionState(), playerGuid: () => Player, sendUse: null, sendUseWithTarget: null, @@ -1062,6 +1070,7 @@ public class InventoryControllerTests using var interaction = new ItemInteractionController( objects, new InventoryTransactionState(objects), + new InteractionState(), playerGuid: () => Player, sendUse: null, sendUseWithTarget: null, @@ -1109,6 +1118,7 @@ public class InventoryControllerTests using var interaction = new ItemInteractionController( objects, new InventoryTransactionState(objects), + new InteractionState(), playerGuid: () => Player, sendUse: null, sendUseWithTarget: null, @@ -1149,6 +1159,7 @@ public class InventoryControllerTests using var interaction = new ItemInteractionController( objects, new InventoryTransactionState(objects), + new InteractionState(), playerGuid: () => Player, sendUse: null, sendUseWithTarget: null, @@ -1188,6 +1199,7 @@ public class InventoryControllerTests using var interaction = new ItemInteractionController( objects, new InventoryTransactionState(objects), + new InteractionState(), playerGuid: () => Player, sendUse: null, sendUseWithTarget: null, diff --git a/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs index e8ab8706..e8b3b99a 100644 --- a/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs @@ -53,6 +53,7 @@ public class PaperdollControllerTests var itemInteraction = new ItemInteractionController( objects, new InventoryTransactionState(objects), + new InteractionState(), () => Player, sendUse: null, sendUseWithTarget: null, diff --git a/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs index dac72355..768ef4cf 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs @@ -266,6 +266,7 @@ public class ToolbarControllerTests var interaction = new ItemInteractionController( repo, new InventoryTransactionState(repo), + new InteractionState(), playerGuid: () => player, sendUse: null, sendUseWithTarget: (source, target) => useWithTarget.Add((source, target)), @@ -332,6 +333,7 @@ public class ToolbarControllerTests using var interaction = new ItemInteractionController( repo, new InventoryTransactionState(repo), + new InteractionState(), playerGuid: () => player, sendUse: null, sendUseWithTarget: null, @@ -381,6 +383,7 @@ public class ToolbarControllerTests using var interaction = new ItemInteractionController( repo, new InventoryTransactionState(repo), + new InteractionState(), playerGuid: () => player, sendUse: null, sendUseWithTarget: null, @@ -510,6 +513,7 @@ public class ToolbarControllerTests var interaction = new ItemInteractionController( repo, new InventoryTransactionState(repo), + new InteractionState(), () => player, sendUse: uses.Add, sendUseWithTarget: null, @@ -568,6 +572,7 @@ public class ToolbarControllerTests using var interaction = new ItemInteractionController( repo, new InventoryTransactionState(repo), + new InteractionState(), () => player, sendUse: null, sendUseWithTarget: null, @@ -633,6 +638,7 @@ public class ToolbarControllerTests using var interaction = new ItemInteractionController( repo, new InventoryTransactionState(repo), + new InteractionState(), () => player, sendUse: null, sendUseWithTarget: null, @@ -799,6 +805,7 @@ public class ToolbarControllerTests using var interaction = new ItemInteractionController( repo, new InventoryTransactionState(repo), + new InteractionState(), playerGuid: () => player, sendUse: null, sendUseWithTarget: null, @@ -848,6 +855,7 @@ public class ToolbarControllerTests var interaction = new ItemInteractionController( repo, new InventoryTransactionState(repo), + new InteractionState(), () => player, sendUse: null, sendUseWithTarget: (s, t) => (sentSource, sentTarget) = (s, t), @@ -890,6 +898,7 @@ public class ToolbarControllerTests var interaction = new ItemInteractionController( repo, new InventoryTransactionState(repo), + new InteractionState(), () => player, sendUse: null, sendUseWithTarget: null, diff --git a/tests/AcDream.App.Tests/UI/RetailItemConfirmationControllerTests.cs b/tests/AcDream.App.Tests/UI/RetailItemConfirmationControllerTests.cs index c7527f5f..fb382edf 100644 --- a/tests/AcDream.App.Tests/UI/RetailItemConfirmationControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/RetailItemConfirmationControllerTests.cs @@ -19,6 +19,7 @@ public sealed class RetailItemConfirmationControllerTests var items = new ItemInteractionController( objects, new InventoryTransactionState(objects), + new InteractionState(), playerGuid: () => Player, sendUse: uses.Add, sendUseWithTarget: null, @@ -54,6 +55,7 @@ public sealed class RetailItemConfirmationControllerTests var items = new ItemInteractionController( objects, new InventoryTransactionState(objects), + new InteractionState(), playerGuid: () => Player, sendUse: uses.Add, sendUseWithTarget: null, @@ -83,6 +85,7 @@ public sealed class RetailItemConfirmationControllerTests var items = new ItemInteractionController( objects, new InventoryTransactionState(objects), + new InteractionState(), playerGuid: () => Player, sendUse: uses.Add, sendUseWithTarget: null, diff --git a/tests/AcDream.App.Tests/UI/RetailUiInteractionFlowTests.cs b/tests/AcDream.App.Tests/UI/RetailUiInteractionFlowTests.cs index 9f4409fa..3c8d217f 100644 --- a/tests/AcDream.App.Tests/UI/RetailUiInteractionFlowTests.cs +++ b/tests/AcDream.App.Tests/UI/RetailUiInteractionFlowTests.cs @@ -164,6 +164,7 @@ public sealed class RetailUiInteractionFlowTests var interaction = new ItemInteractionController( Objects, new InventoryTransactionState(Objects), + new InteractionState(), playerGuid: () => Player, sendUse: Uses.Add, sendUseWithTarget: (source, target) => UseWithTarget.Add((source, target)), @@ -199,6 +200,7 @@ public sealed class RetailUiInteractionFlowTests itemInteraction ??= new ItemInteractionController( Objects, new InventoryTransactionState(Objects), + new InteractionState(), () => Player, sendUse: null, sendUseWithTarget: null, diff --git a/tests/AcDream.Runtime.Tests/GameRuntimeContractTests.cs b/tests/AcDream.Runtime.Tests/GameRuntimeContractTests.cs index c762055c..033282fd 100644 --- a/tests/AcDream.Runtime.Tests/GameRuntimeContractTests.cs +++ b/tests/AcDream.Runtime.Tests/GameRuntimeContractTests.cs @@ -169,6 +169,17 @@ public sealed class GameRuntimeContractTests new RuntimeSocialSnapshot(9, 10, 11, 0, 0, 0, 0), ChatRevision: 12, ChatCount: 13, + new RuntimeActionSnapshot( + SelectionRevision: 14, + SelectedObjectId: 0x50000001u, + PreviousObjectId: 0u, + PreviousValidObjectId: 0u, + CombatRevision: 15, + CombatMode: AcDream.Core.Combat.CombatMode.Missile, + TrackedTargetHealthCount: 1, + InteractionRevision: 16, + InteractionMode: InteractionModeKind.Use, + InteractionSourceObjectId: 0u), default, default); @@ -178,6 +189,9 @@ public sealed class GameRuntimeContractTests Assert.Contains("inventory-state=2:4:1:3", entry.Text); Assert.Contains("character=5:6:7:8:0:0", entry.Text); Assert.Contains("social=9:10:11", entry.Text); + Assert.Contains( + "actions=14:50000001:15:4:1:16:1:00000000", + entry.Text); } [Fact] @@ -190,6 +204,8 @@ public sealed class GameRuntimeContractTests typeof(RuntimeCharacterState), typeof(RuntimeCharacterOptionsState), typeof(RuntimeMovementSkillState), + typeof(RuntimeActionState), + typeof(InteractionState), ]; foreach (Type owner in owners) diff --git a/tests/AcDream.App.Tests/UI/InteractionStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/InteractionStateTests.cs similarity index 90% rename from tests/AcDream.App.Tests/UI/InteractionStateTests.cs rename to tests/AcDream.Runtime.Tests/Gameplay/InteractionStateTests.cs index 2fdeb2dd..1c5de82e 100644 --- a/tests/AcDream.App.Tests/UI/InteractionStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/InteractionStateTests.cs @@ -1,6 +1,6 @@ -using AcDream.App.UI; +using AcDream.Runtime.Gameplay; -namespace AcDream.App.Tests.UI; +namespace AcDream.Runtime.Tests.Gameplay; public sealed class InteractionStateTests { @@ -26,7 +26,8 @@ public sealed class InteractionStateTests public void UseItemOnTarget_RejectsMissingSource() { var state = new InteractionState(); - Assert.Throws(() => state.EnterUseItemOnTarget(0)); + Assert.Throws( + () => state.EnterUseItemOnTarget(0)); Assert.Equal(InteractionMode.None, state.Current); } diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeActionStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeActionStateTests.cs new file mode 100644 index 00000000..d6cf7dd8 --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeActionStateTests.cs @@ -0,0 +1,120 @@ +using AcDream.Core.Combat; +using AcDream.Core.Selection; +using AcDream.Runtime.Gameplay; + +namespace AcDream.Runtime.Tests.Gameplay; + +public sealed class RuntimeActionStateTests +{ + [Fact] + public void ViewProjectsTheExactCanonicalChildrenAndRevisions() + { + using var actions = new RuntimeActionState(); + + actions.Selection.Select( + 0x50000001u, + SelectionChangeSource.World); + actions.Combat.SetCombatMode(CombatMode.Missile); + actions.Combat.OnUpdateHealth(0x50000001u, 0.625f); + actions.Interaction.EnterUseItemOnTarget(0x70000001u); + + RuntimeActionSnapshot snapshot = actions.View.Snapshot; + + Assert.Equal(1, snapshot.SelectionRevision); + Assert.Equal(0x50000001u, snapshot.SelectedObjectId); + Assert.Equal(2, snapshot.CombatRevision); + Assert.Equal(CombatMode.Missile, snapshot.CombatMode); + Assert.Equal(1, snapshot.TrackedTargetHealthCount); + Assert.Equal(1, snapshot.InteractionRevision); + Assert.Equal( + InteractionModeKind.UseItemOnTarget, + snapshot.InteractionMode); + Assert.Equal(0x70000001u, snapshot.InteractionSourceObjectId); + Assert.True( + actions.View.TryGetHealth(0x50000001u, out float health)); + Assert.Equal(0.625f, health); + } + + [Fact] + public void ResetSessionConvergesEveryChildAfterObserverFailures() + { + var actions = new RuntimeActionState(); + actions.Selection.Select( + 0x50000001u, + SelectionChangeSource.World); + actions.Combat.SetCombatMode(CombatMode.Melee); + actions.Combat.OnUpdateHealth(0x50000001u, 0.25f); + actions.Interaction.EnterExamine(); + + actions.Selection.Changed += static _ => + throw new InvalidOperationException("selection observer"); + actions.Combat.CombatModeChanged += static _ => + throw new InvalidOperationException("combat observer"); + actions.Interaction.Changed += static _ => + throw new InvalidOperationException("interaction observer"); + + Assert.Throws(actions.ResetSession); + + RuntimeActionOwnershipSnapshot snapshot = + actions.CaptureOwnership(); + Assert.Equal(0u, snapshot.SelectedObjectId); + Assert.Equal(0u, snapshot.PreviousObjectId); + Assert.Equal(0u, snapshot.PreviousValidObjectId); + Assert.Equal(CombatMode.NonCombat, snapshot.CombatMode); + Assert.Equal(0, snapshot.TrackedTargetHealthCount); + Assert.Equal(InteractionMode.None, snapshot.InteractionMode); + Assert.Throws(actions.Dispose); + } + + [Fact] + public void DisposeIsTerminalAndConvergedAfterObserverFailures() + { + var actions = new RuntimeActionState(); + actions.Selection.Select( + 0x50000001u, + SelectionChangeSource.World); + actions.Combat.SetCombatMode(CombatMode.Magic); + actions.Combat.OnUpdateHealth(0x50000001u, 0.5f); + actions.Interaction.EnterUse(); + actions.Selection.Changed += static _ => + throw new InvalidOperationException("selection observer"); + actions.Combat.CombatModeChanged += static _ => + throw new InvalidOperationException("combat observer"); + actions.Interaction.Changed += static _ => + throw new InvalidOperationException("interaction observer"); + + Assert.Throws(actions.Dispose); + + RuntimeActionOwnershipSnapshot snapshot = + actions.CaptureOwnership(); + Assert.True(snapshot.IsConverged); + actions.Dispose(); + } + + [Fact] + public void InstancesAreFullyIsolated() + { + using var first = new RuntimeActionState(); + using var second = new RuntimeActionState(); + + first.Selection.Select( + 0x50000001u, + SelectionChangeSource.World); + first.Combat.SetCombatMode(CombatMode.Missile); + first.Interaction.EnterUse(); + + Assert.Equal( + new RuntimeActionSnapshot( + 0, + 0u, + 0u, + 0u, + 0, + CombatMode.NonCombat, + 0, + 0, + InteractionModeKind.None, + 0u), + second.View.Snapshot); + } +} diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeGameplayOwnershipTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeGameplayOwnershipTests.cs index 16b3b344..2a0ade8b 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeGameplayOwnershipTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeGameplayOwnershipTests.cs @@ -15,6 +15,7 @@ public sealed class RuntimeGameplayOwnershipTests var inventory = new RuntimeInventoryState(entities); var character = new RuntimeCharacterState(); var communication = new RuntimeCommunicationState(); + var actions = new RuntimeActionState(); inventory.Shortcuts.Changed += static () => { }; inventory.Shortcuts.Load([new ShortcutEntry(1, 2u, 3u)]); inventory.ItemMana.OnQueryItemManaResponse(2u, 0.5f, true); @@ -60,12 +61,19 @@ public sealed class RuntimeGameplayOwnershipTests new HashSet { 4u }))); IDisposable subscription = communication.Events.Subscribe(new NoopObserver()); + actions.Selection.Select( + 0x50000002u, + AcDream.Core.Selection.SelectionChangeSource.World); + actions.Combat.SetCombatMode(AcDream.Core.Combat.CombatMode.Melee); + actions.Combat.OnUpdateHealth(0x50000002u, 0.75f); + actions.Interaction.EnterUse(); RuntimeGameplayOwnershipSnapshot populated = RuntimeGameplayOwnership.Capture( inventory, character, - communication); + communication, + actions); Assert.False(populated.IsConverged); Assert.Equal(1, populated.Inventory.ShortcutCount); @@ -73,7 +81,13 @@ public sealed class RuntimeGameplayOwnershipTests Assert.Equal(1, populated.Communication.StreamSubscriberCount); Assert.True(populated.Communication.HasReplyTarget); Assert.True(populated.Communication.HasRetellTarget); + Assert.Equal(0x50000002u, populated.Actions.SelectedObjectId); + Assert.Equal( + AcDream.Core.Combat.CombatMode.Melee, + populated.Actions.CombatMode); + Assert.Equal(1, populated.Actions.TrackedTargetHealthCount); + actions.Dispose(); communication.Dispose(); character.Dispose(); inventory.Dispose(); @@ -83,7 +97,8 @@ public sealed class RuntimeGameplayOwnershipTests RuntimeGameplayOwnership.Capture( inventory, character, - communication); + communication, + actions); Assert.True(retired.IsConverged); Assert.Equal(0, retired.Inventory.ShortcutSubscriberCount); @@ -99,6 +114,7 @@ public sealed class RuntimeGameplayOwnershipTests var inventory = new RuntimeInventoryState(entities); var character = new RuntimeCharacterState(); var communication = new RuntimeCommunicationState(); + var actions = new RuntimeActionState(); inventory.ExternalContainers.RequestOpen(0x70000001u); inventory.ExternalContainers.ApplyViewContents(0x70000001u); @@ -119,12 +135,14 @@ public sealed class RuntimeGameplayOwnershipTests Assert.Throws(inventory.Dispose); Assert.Throws(character.Dispose); communication.Dispose(); + actions.Dispose(); RuntimeGameplayOwnershipSnapshot retired = RuntimeGameplayOwnership.Capture( inventory, character, - communication); + communication, + actions); Assert.True(retired.IsConverged); Assert.Equal(1, retired.Communication.DispatchFailureCount);