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 <codex@openai.com>
This commit is contained in:
Erik 2026-07-26 10:44:09 +02:00
parent bb45afef33
commit b298f99f91
38 changed files with 711 additions and 93 deletions

View file

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

View file

@ -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);

View file

@ -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) =>

View file

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

View file

@ -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<LiveEntityAnimationState>(
@ -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,

View file

@ -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),

View file

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

View file

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

View file

@ -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);

View file

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

View file

@ -1,4 +1,5 @@
using AcDream.Core.Combat;
using AcDream.Runtime.Gameplay;
namespace AcDream.App.UI;

View file

@ -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<uint> playerGuid,
Action<uint>? sendUse,
Action<uint, uint>? sendUseWithTarget,
@ -82,7 +84,6 @@ public sealed class ItemInteractionController : IDisposable
Action<uint>? sendExamine = null,
Func<long>? nowMs = null,
Action<string>? toast = null,
InteractionState? interactionState = null,
Func<bool>? readyForInventoryRequest = null,
Func<uint>? activeVendorId = null,
Func<uint>? 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))

View file

@ -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);
}

View file

@ -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}")));
}

View file

@ -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; }

View file

@ -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);
/// <summary>
/// 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.
/// </summary>
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
/// <summary>
/// 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.
/// </summary>
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<Exception>? failures = null;
foreach (Action<InteractionModeTransition> listener in listeners.GetInvocationList())
foreach (Action<InteractionModeTransition> 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)

View file

@ -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;
}
/// <summary>
/// Canonical presentation-independent owner for selection, combat
/// notifications/mode, and temporary interaction target mode. Graphical and
/// no-window hosts borrow these exact instances.
/// </summary>
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));
/// <summary>
/// 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.
/// </summary>
public void ResetSession()
{
ObjectDisposedException.ThrowIf(_disposed, this);
List<Exception>? 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<Exception>? 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<Exception>? 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;
}
}
}

View file

@ -1,19 +1,21 @@
namespace AcDream.Runtime.Gameplay;
/// <summary>
/// One allocation-free ledger over the complete J4 gameplay-state lifetime
/// group. It contains no state of its own; graphical and no-window hosts
/// capture the same three canonical owners.
/// 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.
/// </summary>
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());
}
}

View file

@ -16,6 +16,7 @@
<ItemGroup>
<Using Include="Xunit" />
<Using Include="AcDream.Runtime.Gameplay" />
<Using Include="AcDream.App.Tests.BoundedTestDatCollection" Alias="DatCollection" />
</ItemGroup>

View file

@ -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!,

View file

@ -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!;

View file

@ -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() =>

View file

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

View file

@ -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();
}
}

View file

@ -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;
}
}
}

View file

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

View file

@ -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<ArgumentException>(() => new ItemInteractionController(
objects,
transactions,
new InteractionState(),
playerGuid: () => Player,
sendUse: null,
sendUseWithTarget: null,

View file

@ -859,6 +859,7 @@ public sealed class AppraisalUiControllerTests
=> new(
objects,
new InventoryTransactionState(objects),
new InteractionState(),
playerGuid: () => 0x50000002u,
sendUse: null,
sendUseWithTarget: null,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<ArgumentOutOfRangeException>(() => state.EnterUseItemOnTarget(0));
Assert.Throws<ArgumentOutOfRangeException>(
() => state.EnterUseItemOnTarget(0));
Assert.Equal(InteractionMode.None, state.Current);
}

View file

@ -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<AggregateException>(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<AggregateException>(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<AggregateException>(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);
}
}

View file

@ -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<uint> { 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<AggregateException>(inventory.Dispose);
Assert.Throws<AggregateException>(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);