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, RetainedUiInputCaptureSlot RetainedInputCapture,
InputDispatcher? InputDispatcher, InputDispatcher? InputDispatcher,
RuntimeSettingsController Settings, RuntimeSettingsController Settings,
CombatState Combat, RuntimeActionState Actions,
ICombatAttackOperations CombatAttackOperations, ICombatAttackOperations CombatAttackOperations,
SelectionState Selection,
RuntimeInventoryState Inventory, RuntimeInventoryState Inventory,
MagicCatalog MagicCatalog, MagicCatalog MagicCatalog,
RuntimeCharacterState Character, RuntimeCharacterState Character,
@ -257,14 +256,14 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
{ {
public CombatAttackController CreateCombatAttack( public CombatAttackController CreateCombatAttack(
InteractionRetainedUiDependencies d) => InteractionRetainedUiDependencies d) =>
new(d.Combat, d.CombatAttackOperations); new(d.Actions.Combat, d.CombatAttackOperations);
public CombatTargetController CreateCombatTarget( public CombatTargetController CreateCombatTarget(
InteractionRetainedUiDependencies d, InteractionRetainedUiDependencies d,
DeferredSelectionUiAuthority selection) => DeferredSelectionUiAuthority selection) =>
new( new(
d.Combat, d.Actions.Combat,
d.Selection, d.Actions.Selection,
autoTarget: () => d.Settings.Gameplay.AutoTarget, autoTarget: () => d.Settings.Gameplay.AutoTarget,
selectClosestTarget: () => selectClosestTarget: () =>
selection.SelectClosestCombatTarget(showToast: false)); selection.SelectClosestCombatTarget(showToast: false));
@ -286,6 +285,7 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
return new ItemInteractionController( return new ItemInteractionController(
d.Inventory.Objects, d.Inventory.Objects,
d.Inventory.Transactions, d.Inventory.Transactions,
d.Actions.Interaction,
playerGuid: () => d.PlayerIdentity.ServerGuid, playerGuid: () => d.PlayerIdentity.ServerGuid,
sendUse: null, sendUse: null,
sendExamine: guid => session.CurrentSession?.SendAppraise(guid), sendExamine: guid => session.CurrentSession?.SendAppraise(guid),
@ -304,8 +304,8 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
d.PlayerMode.IsPlayerMode d.PlayerMode.IsPlayerMode
&& d.PlayerController.Controller is { IsAirborne: false }, && d.PlayerController.Controller is { IsAirborne: false },
inNonCombatMode: () => inNonCombatMode: () =>
d.Combat.CurrentMode == CombatMode.NonCombat, d.Actions.Combat.CurrentMode == CombatMode.NonCombat,
combatState: d.Combat, combatState: d.Actions.Combat,
sendChangeCombatMode: mode => sendChangeCombatMode: mode =>
session.CurrentSession?.SendChangeCombatMode(mode), session.CurrentSession?.SendChangeCombatMode(mode),
isComponentPack: d.MagicCatalog.IsComponentPack, isComponentPack: d.MagicCatalog.IsComponentPack,
@ -316,7 +316,8 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
d.Inventory.ExternalContainers.CurrentContainerId, d.Inventory.ExternalContainers.CurrentContainerId,
sendSplitToWorld: (item, amount) => sendSplitToWorld: (item, amount) =>
session.CurrentSession?.SendStackableSplitTo3D(item, amount), session.CurrentSession?.SendStackableSplitTo3D(item, amount),
selectedObjectId: () => d.Selection.SelectedObjectId ?? 0u, selectedObjectId: () =>
d.Actions.Selection.SelectedObjectId ?? 0u,
stackSplitQuantity: d.StackSplitQuantity, stackSplitQuantity: d.StackSplitQuantity,
systemMessage: systemMessage:
text => d.Communication.Chat.OnSystemMessage(text, 0x1Au), text => d.Communication.Chat.OnSystemMessage(text, 0x1Au),
@ -351,7 +352,9 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
try try
{ {
VitalsVM vitals = d.ExistingVitals VitalsVM vitals = d.ExistingVitals
?? new VitalsVM(d.Combat, d.Character.LocalPlayer); ?? new VitalsVM(
d.Actions.Combat,
d.Character.LocalPlayer);
UiHost host = lease.AcquireHost( UiHost host = lease.AcquireHost(
() => new UiHost( () => new UiHost(
d.Gl, d.Gl,
@ -367,7 +370,8 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
itemInteraction, itemInteraction,
worldTargetProvider: () => worldTargetProvider: () =>
late.Selection.PickAtCursor(includeSelf: true) ?? 0u, late.Selection.PickAtCursor(includeSelf: true) ?? 0u,
combatModeProvider: () => d.Combat.CurrentMode); combatModeProvider: () =>
d.Actions.Combat.CurrentMode);
var cursorManager = new RetailCursorManager(d.Dats, d.DatLock); var cursorManager = new RetailCursorManager(d.Dats, d.DatLock);
checkpoint(InteractionRetainedUiCompositionPoint.CursorAssetsCreated); checkpoint(InteractionRetainedUiCompositionPoint.CursorAssetsCreated);
@ -404,7 +408,8 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
d.MagicCatalog, d.MagicCatalog,
d.Character.Spellbook, d.Character.Spellbook,
d.Inventory.Objects, d.Inventory.Objects,
selectedObject: () => d.Selection.SelectedObjectId, selectedObject: () =>
d.Actions.Selection.SelectedObjectId,
localPlayerId: () => d.PlayerIdentity.ServerGuid, localPlayerId: () => d.PlayerIdentity.ServerGuid,
accountName: () => late.Session.AccountName accountName: () => late.Session.AccountName
?? d.Options.LiveUser ?? d.Options.LiveUser
@ -533,10 +538,10 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
Chat: new ChatRuntimeBindings(chat, () => late.Session.Commands), Chat: new ChatRuntimeBindings(chat, () => late.Session.Commands),
Radar: new RadarRuntimeBindings( Radar: new RadarRuntimeBindings(
late.Radar.Snapshot, late.Radar.Snapshot,
d.Selection, d.Actions.Selection,
d.Settings.SetUiLocked), d.Settings.SetUiLocked),
Combat: new CombatRuntimeBindings( Combat: new CombatRuntimeBindings(
d.Combat, d.Actions.Combat,
combatAttack, combatAttack,
() => d.Settings.Gameplay, () => d.Settings.Gameplay,
d.Settings.SetCombatGameplay), d.Settings.SetCombatGameplay),
@ -550,11 +555,13 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
iconComposer.GetDragIcon, iconComposer.GetDragIcon,
iconComposer.GetSpellIcon, iconComposer.GetSpellIcon,
iconComposer.GetSpellComponentIcon, iconComposer.GetSpellComponentIcon,
d.Selection, d.Actions.Selection,
d.MagicCatalog.GetSpellLevel, d.MagicCatalog.GetSpellLevel,
magic.GetExamineComponents, magic.GetExamineComponents,
MagicSkillLevel, MagicSkillLevel,
guid => d.Selection.Select(guid, SelectionChangeSource.Inventory), guid => d.Actions.Selection.Select(
guid,
SelectionChangeSource.Inventory),
guid => late.Session.TryUseItem(guid, d.Log), guid => late.Session.TryUseItem(guid, d.Log),
(tab, position, spellId) => (tab, position, spellId) =>
late.GameRuntime.AddFavorite(tab, position, spellId), late.GameRuntime.AddFavorite(tab, position, spellId),
@ -574,7 +581,7 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
() => 1.0, () => 1.0,
() => d.Settings.DisplayPreview.ShowFps), () => d.Settings.DisplayPreview.ShowFps),
VividTarget: new VividTargetRuntimeBindings( VividTarget: new VividTargetRuntimeBindings(
d.Selection, d.Actions.Selection,
() => d.PlayerIdentity.ServerGuid, () => d.PlayerIdentity.ServerGuid,
() => d.Settings.Gameplay.VividTargetingIndicator, () => d.Settings.Gameplay.VividTargetingIndicator,
late.Selection.ResolveVividTargetInfo, late.Selection.ResolveVividTargetInfo,
@ -596,19 +603,19 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
iconComposer.GetIcon, iconComposer.GetIcon,
iconComposer.GetDragIcon, iconComposer.GetDragIcon,
guid => late.Session.TryUseItem(guid, d.Log), guid => late.Session.TryUseItem(guid, d.Log),
d.Combat, d.Actions.Combat,
d.Inventory.ItemMana, d.Inventory.ItemMana,
d.CombatModeCommands.Toggle, d.CombatModeCommands.Toggle,
itemInteraction, itemInteraction,
entry => late.GameRuntime.AddShortcut(entry), entry => late.GameRuntime.AddShortcut(entry),
index => late.GameRuntime.RemoveShortcut(index), index => late.GameRuntime.RemoveShortcut(index),
d.Selection, d.Actions.Selection,
handler => d.Combat.HealthChanged += handler, handler => d.Actions.Combat.HealthChanged += handler,
handler => d.Combat.HealthChanged -= handler, handler => d.Actions.Combat.HealthChanged -= handler,
late.Selection.ShouldShowHealth, late.Selection.ShouldShowHealth,
guid => d.Inventory.Objects.Get(guid)?.GetAppropriateName(), guid => d.Inventory.Objects.Get(guid)?.GetAppropriateName(),
d.Combat.GetHealthPercent, d.Actions.Combat.GetHealthPercent,
d.Combat.HasHealth, d.Actions.Combat.HasHealth,
guid => guid =>
(uint)(d.Inventory.Objects.Get(guid)?.StackSize ?? 0), (uint)(d.Inventory.Objects.Get(guid)?.StackSize ?? 0),
guid => late.Session.CurrentSession?.SendQueryHealth(guid), guid => late.Session.CurrentSession?.SendQueryHealth(guid),
@ -646,14 +653,14 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
target, target,
amount), amount),
itemInteraction, itemInteraction,
d.Selection), d.Actions.Selection),
ExternalContainer: new ExternalContainerRuntimeBindings( ExternalContainer: new ExternalContainerRuntimeBindings(
d.Inventory.ExternalContainers, d.Inventory.ExternalContainers,
d.Inventory.Objects, d.Inventory.Objects,
iconComposer.GetIcon, iconComposer.GetIcon,
iconComposer.GetDragIcon, iconComposer.GetDragIcon,
itemInteraction, itemInteraction,
d.Selection, d.Actions.Selection,
guid => late.Session.CurrentSession?.SendUse(guid), guid => late.Session.CurrentSession?.SendUse(guid),
(item, container, placement) => (item, container, placement) =>
late.Session.CurrentSession?.SendPutItemInContainer( late.Session.CurrentSession?.SendPutItemInContainer(

View file

@ -48,7 +48,7 @@ internal sealed record SessionPlayerDependencies(
PhysicsDataCache PhysicsDataCache, PhysicsDataCache PhysicsDataCache,
WorldGameState WorldGameState, WorldGameState WorldGameState,
WorldEvents WorldEvents, WorldEvents WorldEvents,
SelectionState Selection, RuntimeActionState Actions,
RuntimeEntityObjectLifetime EntityObjects, RuntimeEntityObjectLifetime EntityObjects,
EntityClassificationCache ClassificationCache, EntityClassificationCache ClassificationCache,
LiveEntityRuntimeSlot RuntimeSlot, LiveEntityRuntimeSlot RuntimeSlot,
@ -80,7 +80,6 @@ internal sealed record SessionPlayerDependencies(
UpdateFrameClock UpdateClock, UpdateFrameClock UpdateClock,
MovementTruthDiagnosticController MovementDiagnostics, MovementTruthDiagnosticController MovementDiagnostics,
CombatAttackOperationsSlot CombatAttackOperations, CombatAttackOperationsSlot CombatAttackOperations,
CombatState Combat,
CombatFeedbackSlot CombatFeedback, CombatFeedbackSlot CombatFeedback,
RuntimeCommunicationState Communication, RuntimeCommunicationState Communication,
RuntimeCharacterState Character, RuntimeCharacterState Character,
@ -326,7 +325,7 @@ internal sealed class SessionPlayerCompositionPhase
d.DatLock); d.DatLock);
var worldQuiescence = new WorldGenerationQuiescence( var worldQuiescence = new WorldGenerationQuiescence(
live.WorldAvailability, live.WorldAvailability,
d.Selection, d.Actions.Selection,
live.WorldState, live.WorldState,
guid => live.LiveEntities.TryGetProjectionKey( guid => live.LiveEntities.TryGetProjectionKey(
guid, guid,
@ -455,7 +454,7 @@ internal sealed class SessionPlayerCompositionPhase
live.EntityEffects, live.EntityEffects,
live.RemoteTeleport, live.RemoteTeleport,
live.SelectionInteractions, live.SelectionInteractions,
d.Selection, d.Actions.Selection,
d.AnimatedEntities, d.AnimatedEntities,
d.RemoteMovementObservations, d.RemoteMovementObservations,
d.TranslucencyFades, d.TranslucencyFades,
@ -563,9 +562,9 @@ internal sealed class SessionPlayerCompositionPhase
"combat attack operations", "combat attack operations",
d.CombatAttackOperations.BindOwned( d.CombatAttackOperations.BindOwned(
new LiveCombatAttackOperations( new LiveCombatAttackOperations(
d.Combat, d.Actions.Combat,
new CombatAttackTargetSource( new CombatAttackTargetSource(
d.Selection, d.Actions.Selection,
live.LiveEntities, live.LiveEntities,
d.EntityObjects.Objects, d.EntityObjects.Objects,
d.PlayerIdentity), d.PlayerIdentity),
@ -798,7 +797,7 @@ internal sealed class SessionPlayerCompositionPhase
new LiveSessionDomainRuntime( new LiveSessionDomainRuntime(
d.EntityObjects, d.EntityObjects,
d.Character, d.Character,
d.Combat, d.Actions,
d.Inventory, d.Inventory,
d.Communication), d.Communication),
new LiveSessionUiRuntime( new LiveSessionUiRuntime(
@ -856,7 +855,7 @@ internal sealed class SessionPlayerCompositionPhase
new LocalPlayerCombatEquipmentSource( new LocalPlayerCombatEquipmentSource(
d.EntityObjects.Objects, d.EntityObjects.Objects,
d.PlayerIdentity), d.PlayerIdentity),
d.Combat, d.Actions.Combat,
new ItemInteractionCombatModeIntentSink( new ItemInteractionCombatModeIntentSink(
interaction.ItemInteraction), interaction.ItemInteraction),
d.Log, d.Log,
@ -875,10 +874,10 @@ internal sealed class SessionPlayerCompositionPhase
d.Inventory, d.Inventory,
d.Character, d.Character,
d.Communication, d.Communication,
d.Actions,
d.PlayerController, d.PlayerController,
worldReveal, worldReveal,
d.UpdateClock, d.UpdateClock,
d.Selection,
live.SelectionInteractions, live.SelectionInteractions,
gameplayInput, gameplayInput,
combatCommand); combatCommand);
@ -940,7 +939,7 @@ internal sealed class SessionPlayerCompositionPhase
GameplayInputActionRouter gameplayActions = GameplayInputActionRouter gameplayActions =
GameplayInputActionRouter.Create( GameplayInputActionRouter.Create(
dispatcher, dispatcher,
d.Combat, d.Actions.Combat,
targets, targets,
d.HostQuiescence, d.HostQuiescence,
d.Log); d.Log);

View file

@ -41,7 +41,7 @@ internal sealed record LiveSessionPlayerRuntime(
internal sealed record LiveSessionDomainRuntime( internal sealed record LiveSessionDomainRuntime(
RuntimeEntityObjectLifetime EntityObjects, RuntimeEntityObjectLifetime EntityObjects,
RuntimeCharacterState Character, RuntimeCharacterState Character,
CombatState Combat, RuntimeActionState Actions,
RuntimeInventoryState Inventory, RuntimeInventoryState Inventory,
RuntimeCommunicationState Communication); RuntimeCommunicationState Communication);
@ -134,7 +134,7 @@ internal sealed class LiveSessionRuntimeFactory
SetChatIdentity: _domain.Communication.Chat.SetLocalPlayerGuid, SetChatIdentity: _domain.Communication.Chat.SetLocalPlayerGuid,
MarkPersistent: _world.WorldState.MarkPersistent, MarkPersistent: _world.WorldState.MarkPersistent,
SetVanishProbeIdentity: id => EntityVanishProbe.PlayerGuid = id, SetVanishProbeIdentity: id => EntityVanishProbe.PlayerGuid = id,
ClearCombat: _domain.Combat.Clear), ClearCombat: _domain.Actions.Combat.Clear),
EnteredWorld: new( EnteredWorld: new(
SetActiveCharacter: _interaction.Settings.SetActiveCharacter, SetActiveCharacter: _interaction.Settings.SetActiveCharacter,
RestoreLayout: () => _ui.RetailUi?.RestoreLayout(), RestoreLayout: () => _ui.RetailUi?.RestoreLayout(),
@ -170,7 +170,7 @@ internal sealed class LiveSessionRuntimeFactory
Spellbook = _domain.Character.ResetSpellbook, Spellbook = _domain.Character.ResetSpellbook,
MagicRuntime = () => _ui.Magic?.Reset(), MagicRuntime = () => _ui.Magic?.Reset(),
CombatAttack = _interaction.CombatAttack.ResetSession, CombatAttack = _interaction.CombatAttack.ResetSession,
CombatState = _domain.Combat.Clear, CombatState = _domain.Actions.Combat.Clear,
ItemMana = _domain.Inventory.ResetItemMana, ItemMana = _domain.Inventory.ResetItemMana,
LocalPlayer = _domain.Character.ResetLocalPlayer, LocalPlayer = _domain.Character.ResetLocalPlayer,
Friends = _domain.Communication.ResetFriends, Friends = _domain.Communication.ResetFriends,
@ -275,7 +275,7 @@ internal sealed class LiveSessionRuntimeFactory
{ {
var skillCreditResolver = new LiveSkillCreditResolver(skillTable); var skillCreditResolver = new LiveSkillCreditResolver(skillTable);
return new LiveCharacterSessionBindings( return new LiveCharacterSessionBindings(
_domain.Combat, _domain.Actions.Combat,
_domain.Character, _domain.Character,
ResolveSkillFormulaBonus: skillCreditResolver.Resolve, ResolveSkillFormulaBonus: skillCreditResolver.Resolve,
OnSkillsUpdated: (runSkill, jumpSkill) => OnSkillsUpdated: (runSkill, jumpSkill) =>

View file

@ -31,10 +31,18 @@ var runtimeOptions = RuntimeOptions.FromEnvironment(datDir);
var worldGameState = new AcDream.Core.Plugins.WorldGameState(); var worldGameState = new AcDream.Core.Plugins.WorldGameState();
var worldEvents = new AcDream.Core.Plugins.WorldEvents(); var worldEvents = new AcDream.Core.Plugins.WorldEvents();
var selection = new AcDream.Core.Selection.SelectionState();
var uiRegistry = new AcDream.App.Plugins.BufferedUiRegistry(); var uiRegistry = new AcDream.App.Plugins.BufferedUiRegistry();
using var window = new GameWindow(
runtimeOptions,
worldGameState,
worldEvents,
uiRegistry);
var host = new AppPluginHost( 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"); var pluginsDir = Path.Combine(AppContext.BaseDirectory, "plugins");
Log.Information("scanning plugins in {PluginsDir}", pluginsDir); 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); } 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(); window.Run();
} }
finally finally

View file

@ -38,7 +38,6 @@ public sealed class GameWindow :
private readonly string _datDir; private readonly string _datDir;
private readonly WorldGameState _worldGameState; private readonly WorldGameState _worldGameState;
private readonly WorldEvents _worldEvents; private readonly WorldEvents _worldEvents;
private readonly AcDream.Core.Selection.SelectionState _selection;
private readonly HostQuiescenceGate _hostQuiescence = new(); private readonly HostQuiescenceGate _hostQuiescence = new();
private IWindow? _window; private IWindow? _window;
private SilkWindowCallbackBinding? _windowCallbacks; private SilkWindowCallbackBinding? _windowCallbacks;
@ -324,9 +323,12 @@ public sealed class GameWindow :
private AcDream.App.World.LiveEntityRuntime? _liveEntities; private AcDream.App.World.LiveEntityRuntime? _liveEntities;
private AcDream.App.World.LiveEntityLivenessController? _liveEntityLiveness; private AcDream.App.World.LiveEntityLivenessController? _liveEntityLiveness;
// J4.1: Runtime owns communication/social state. App, UI, plugins, and // J4/J5.1: Runtime owns communication, selection, combat, and target-mode
// live-session routing borrow these exact instances. // state. App, UI, plugins, and live-session routing borrow exact children.
private readonly RuntimeCommunicationState _runtimeCommunication = new(); 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.ChatLog Chat => _runtimeCommunication.Chat;
public AcDream.Core.Chat.TurbineChatState TurbineChat => public AcDream.Core.Chat.TurbineChatState TurbineChat =>
_runtimeCommunication.TurbineChat; _runtimeCommunication.TurbineChat;
@ -334,7 +336,8 @@ public sealed class GameWindow :
_runtimeCommunication.Friends; _runtimeCommunication.Friends;
public AcDream.Core.Social.SquelchState Squelch => public AcDream.Core.Social.SquelchState Squelch =>
_runtimeCommunication.Squelch; _runtimeCommunication.Squelch;
public readonly AcDream.Core.Combat.CombatState Combat = new(); public AcDream.Core.Combat.CombatState Combat =>
_runtimeActions.Combat;
private readonly RuntimeEntityObjectLifetime _runtimeEntityObjects = new(); private readonly RuntimeEntityObjectLifetime _runtimeEntityObjects = new();
private readonly RuntimeInventoryState _runtimeInventory; private readonly RuntimeInventoryState _runtimeInventory;
private readonly RuntimeCharacterState _runtimeCharacter; private readonly RuntimeCharacterState _runtimeCharacter;
@ -554,7 +557,6 @@ public sealed class GameWindow :
AcDream.App.RuntimeOptions options, AcDream.App.RuntimeOptions options,
WorldGameState worldGameState, WorldGameState worldGameState,
WorldEvents worldEvents, WorldEvents worldEvents,
AcDream.Core.Selection.SelectionState selection,
AcDream.App.Plugins.BufferedUiRegistry? uiRegistry = null) AcDream.App.Plugins.BufferedUiRegistry? uiRegistry = null)
{ {
_options = options ?? throw new System.ArgumentNullException(nameof(options)); _options = options ?? throw new System.ArgumentNullException(nameof(options));
@ -584,7 +586,6 @@ public sealed class GameWindow :
new JsonRuntimeSettingsStorage( new JsonRuntimeSettingsStorage(
AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath()), AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath()),
log: Console.WriteLine); log: Console.WriteLine);
_selection = selection ?? throw new System.ArgumentNullException(nameof(selection));
_animationDiagnostics = AnimationPresentationDiagnostics.FromEnvironment(); _animationDiagnostics = AnimationPresentationDiagnostics.FromEnvironment();
_uiRegistry = uiRegistry; _uiRegistry = uiRegistry;
_animatedEntities = new LiveEntityAnimationRuntimeView<LiveEntityAnimationState>( _animatedEntities = new LiveEntityAnimationRuntimeView<LiveEntityAnimationState>(
@ -1252,9 +1253,8 @@ public sealed class GameWindow :
_retainedInputCapture, _retainedInputCapture,
hostInputCamera.InputDispatcher, hostInputCamera.InputDispatcher,
_runtimeSettings, _runtimeSettings,
Combat, _runtimeActions,
_combatAttackOperations, _combatAttackOperations,
_selection,
_runtimeInventory, _runtimeInventory,
contentEffectsAudio.MagicCatalog, contentEffectsAudio.MagicCatalog,
_runtimeCharacter, _runtimeCharacter,
@ -1304,7 +1304,7 @@ public sealed class GameWindow :
_physicsDataCache, _physicsDataCache,
_worldGameState, _worldGameState,
_worldEvents, _worldEvents,
_selection, Selection,
_runtimeEntityObjects, _runtimeEntityObjects,
_liveEntityRuntimeSlot, _liveEntityRuntimeSlot,
_liveEntityMotionBindings, _liveEntityMotionBindings,
@ -1362,7 +1362,7 @@ public sealed class GameWindow :
_physicsDataCache, _physicsDataCache,
_worldGameState, _worldGameState,
_worldEvents, _worldEvents,
_selection, _runtimeActions,
_runtimeEntityObjects, _runtimeEntityObjects,
_classificationCache, _classificationCache,
_liveEntityRuntimeSlot, _liveEntityRuntimeSlot,
@ -1394,7 +1394,6 @@ public sealed class GameWindow :
_updateFrameClock, _updateFrameClock,
_movementTruthDiagnostics, _movementTruthDiagnostics,
_combatAttackOperations, _combatAttackOperations,
Combat,
_combatFeedback, _combatFeedback,
_runtimeCommunication, _runtimeCommunication,
_runtimeCharacter, _runtimeCharacter,
@ -1444,7 +1443,7 @@ public sealed class GameWindow :
_debugVmRenderFacts, _debugVmRenderFacts,
_inputCapture, _inputCapture,
_cameraInput, _cameraInput,
_selection, Selection,
_animatedEntities, _animatedEntities,
_updateFrameClock, _updateFrameClock,
Combat, Combat,
@ -1575,6 +1574,7 @@ public sealed class GameWindow :
_runtimeInventory, _runtimeInventory,
_runtimeCharacter, _runtimeCharacter,
_runtimeCommunication, _runtimeCommunication,
_runtimeActions,
_renderSceneShadow, _renderSceneShadow,
_livePresentationBindings, _livePresentationBindings,
_entityEffectAdvance, _entityEffectAdvance,

View file

@ -87,6 +87,7 @@ internal sealed record LiveShutdownRoots(
RuntimeInventoryState Inventory, RuntimeInventoryState Inventory,
RuntimeCharacterState Character, RuntimeCharacterState Character,
RuntimeCommunicationState Communication, RuntimeCommunicationState Communication,
RuntimeActionState Actions,
RenderSceneShadowRuntime? RenderSceneShadow, RenderSceneShadowRuntime? RenderSceneShadow,
LivePresentationRuntimeBindings? PresentationBindings, LivePresentationRuntimeBindings? PresentationBindings,
DeferredEntityEffectAdvanceSource EffectAdvance, DeferredEntityEffectAdvanceSource EffectAdvance,
@ -398,6 +399,9 @@ internal static class GameWindowShutdownManifest
Hard( Hard(
"runtime inventory state", "runtime inventory state",
live.Inventory.Dispose), live.Inventory.Dispose),
Hard(
"runtime action state",
live.Actions.Dispose),
Hard( Hard(
"runtime entity/object lifetime", "runtime entity/object lifetime",
live.EntityObjects.Dispose), live.EntityObjects.Dispose),

View file

@ -4,8 +4,6 @@ using AcDream.App.Interaction;
using AcDream.App.Net; using AcDream.App.Net;
using AcDream.App.Streaming; using AcDream.App.Streaming;
using AcDream.App.World; using AcDream.App.World;
using AcDream.Core.Chat;
using AcDream.Core.Selection;
using AcDream.Runtime; using AcDream.Runtime;
using AcDream.Runtime.Entities; using AcDream.Runtime.Entities;
using AcDream.Runtime.Gameplay; using AcDream.Runtime.Gameplay;
@ -40,10 +38,10 @@ internal sealed class CurrentGameRuntimeAdapter
RuntimeInventoryState inventory, RuntimeInventoryState inventory,
RuntimeCharacterState character, RuntimeCharacterState character,
RuntimeCommunicationState communication, RuntimeCommunicationState communication,
RuntimeActionState actions,
ILocalPlayerControllerSource playerController, ILocalPlayerControllerSource playerController,
WorldRevealCoordinator worldReveal, WorldRevealCoordinator worldReveal,
IGameRuntimeClock clock, IGameRuntimeClock clock,
SelectionState selectionState,
SelectionInteractionController selection, SelectionInteractionController selection,
GameplayInputFrameController gameplayInput, GameplayInputFrameController gameplayInput,
ILiveCombatModeCommand combat) ILiveCombatModeCommand combat)
@ -56,6 +54,7 @@ internal sealed class CurrentGameRuntimeAdapter
inventory, inventory,
character, character,
communication, communication,
actions,
playerController, playerController,
worldReveal, worldReveal,
clock); clock);
@ -73,7 +72,7 @@ internal sealed class CurrentGameRuntimeAdapter
_view, _view,
inventory, inventory,
character, character,
selectionState, actions,
selection, selection,
gameplayInput, gameplayInput,
combat, combat,
@ -89,6 +88,7 @@ internal sealed class CurrentGameRuntimeAdapter
public IRuntimeCharacterView Character => _view.Character; public IRuntimeCharacterView Character => _view.Character;
public IRuntimeSocialView Social => _view.Social; public IRuntimeSocialView Social => _view.Social;
public IRuntimeChatView Chat => _view.Chat; public IRuntimeChatView Chat => _view.Chat;
public IRuntimeActionView Actions => _view.Actions;
public IRuntimeMovementView Movement => _view.Movement; public IRuntimeMovementView Movement => _view.Movement;
public IRuntimePortalView Portal => _view.Portal; public IRuntimePortalView Portal => _view.Portal;

View file

@ -2,7 +2,6 @@ using AcDream.App.Combat;
using AcDream.App.Input; using AcDream.App.Input;
using AcDream.App.Interaction; using AcDream.App.Interaction;
using AcDream.App.Net; using AcDream.App.Net;
using AcDream.Core.Selection;
using AcDream.Core.Items; using AcDream.Core.Items;
using AcDream.Runtime; using AcDream.Runtime;
using AcDream.Runtime.Gameplay; using AcDream.Runtime.Gameplay;
@ -34,7 +33,7 @@ internal sealed class CurrentGameRuntimeCommandAdapter
private readonly CurrentGameRuntimeViewAdapter _view; private readonly CurrentGameRuntimeViewAdapter _view;
private readonly RuntimeInventoryState _inventory; private readonly RuntimeInventoryState _inventory;
private readonly RuntimeCharacterState _character; private readonly RuntimeCharacterState _character;
private readonly SelectionState _selectionState; private readonly RuntimeActionState _actions;
private readonly SelectionInteractionController _selection; private readonly SelectionInteractionController _selection;
private readonly GameplayInputFrameController _gameplayInput; private readonly GameplayInputFrameController _gameplayInput;
private readonly ILiveCombatModeCommand _combat; private readonly ILiveCombatModeCommand _combat;
@ -47,7 +46,7 @@ internal sealed class CurrentGameRuntimeCommandAdapter
CurrentGameRuntimeViewAdapter view, CurrentGameRuntimeViewAdapter view,
RuntimeInventoryState inventory, RuntimeInventoryState inventory,
RuntimeCharacterState character, RuntimeCharacterState character,
SelectionState selectionState, RuntimeActionState actions,
SelectionInteractionController selection, SelectionInteractionController selection,
GameplayInputFrameController gameplayInput, GameplayInputFrameController gameplayInput,
ILiveCombatModeCommand combat, ILiveCombatModeCommand combat,
@ -59,8 +58,8 @@ internal sealed class CurrentGameRuntimeCommandAdapter
_view = view ?? throw new ArgumentNullException(nameof(view)); _view = view ?? throw new ArgumentNullException(nameof(view));
_inventory = inventory ?? throw new ArgumentNullException(nameof(inventory)); _inventory = inventory ?? throw new ArgumentNullException(nameof(inventory));
_character = character ?? throw new ArgumentNullException(nameof(character)); _character = character ?? throw new ArgumentNullException(nameof(character));
_selectionState = selectionState _actions = actions
?? throw new ArgumentNullException(nameof(selectionState)); ?? throw new ArgumentNullException(nameof(actions));
_selection = selection ?? throw new ArgumentNullException(nameof(selection)); _selection = selection ?? throw new ArgumentNullException(nameof(selection));
_gameplayInput = gameplayInput _gameplayInput = gameplayInput
?? throw new ArgumentNullException(nameof(gameplayInput)); ?? throw new ArgumentNullException(nameof(gameplayInput));
@ -159,7 +158,7 @@ internal sealed class CurrentGameRuntimeCommandAdapter
&& _selection.HandleInputAction(action) && _selection.HandleInputAction(action)
? RuntimeCommandStatus.Accepted ? RuntimeCommandStatus.Accepted
: RuntimeCommandStatus.Unsupported; : RuntimeCommandStatus.Unsupported;
uint selected = _selectionState.SelectedObjectId ?? 0u; uint selected = _actions.Selection.SelectedObjectId ?? 0u;
_events.EmitCommand( _events.EmitCommand(
RuntimeCommandDomain.Selection, RuntimeCommandDomain.Selection,
(int)command, (int)command,

View file

@ -28,6 +28,7 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView
private readonly IRuntimeCharacterView _characterView; private readonly IRuntimeCharacterView _characterView;
private readonly IRuntimeSocialView _socialView; private readonly IRuntimeSocialView _socialView;
private readonly IRuntimeChatView _chatView; private readonly IRuntimeChatView _chatView;
private readonly IRuntimeActionView _actionView;
private readonly MovementView _movementView; private readonly MovementView _movementView;
private readonly PortalView _portalView; private readonly PortalView _portalView;
private bool _active = true; private bool _active = true;
@ -40,6 +41,7 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView
RuntimeInventoryState inventory, RuntimeInventoryState inventory,
RuntimeCharacterState character, RuntimeCharacterState character,
RuntimeCommunicationState communication, RuntimeCommunicationState communication,
RuntimeActionState actions,
ILocalPlayerControllerSource playerController, ILocalPlayerControllerSource playerController,
WorldRevealCoordinator worldReveal, WorldRevealCoordinator worldReveal,
IGameRuntimeClock clock) IGameRuntimeClock clock)
@ -61,6 +63,8 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView
character ?? throw new ArgumentNullException(nameof(character))).View; character ?? throw new ArgumentNullException(nameof(character))).View;
_chatView = communication.View; _chatView = communication.View;
_socialView = communication.SocialView; _socialView = communication.SocialView;
_actionView = (
actions ?? throw new ArgumentNullException(nameof(actions))).View;
_movementView = new MovementView( _movementView = new MovementView(
playerController playerController
?? throw new ArgumentNullException(nameof(playerController)), ?? throw new ArgumentNullException(nameof(playerController)),
@ -104,6 +108,7 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView
public IRuntimeCharacterView Character => _characterView; public IRuntimeCharacterView Character => _characterView;
public IRuntimeSocialView Social => _socialView; public IRuntimeSocialView Social => _socialView;
public IRuntimeChatView Chat => _chatView; public IRuntimeChatView Chat => _chatView;
public IRuntimeActionView Actions => _actionView;
public IRuntimeMovementView Movement => _movementView; public IRuntimeMovementView Movement => _movementView;
public IRuntimePortalView Portal => _portalView; public IRuntimePortalView Portal => _portalView;
@ -121,6 +126,7 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView
_socialView.Snapshot, _socialView.Snapshot,
_chatView.Revision, _chatView.Revision,
_chatView.Count, _chatView.Count,
_actionView.Snapshot,
_movementView.Snapshot, _movementView.Snapshot,
_portalView.Snapshot); _portalView.Snapshot);

View file

@ -142,6 +142,7 @@ public static class FixtureProvider
var itemInteraction = new ItemInteractionController( var itemInteraction = new ItemInteractionController(
objects, objects,
new InventoryTransactionState(objects), new InventoryTransactionState(objects),
new AcDream.Runtime.Gameplay.InteractionState(),
() => SampleData.PlayerGuid, () => SampleData.PlayerGuid,
sendUse: null, sendUse: null,
sendUseWithTarget: null, sendUseWithTarget: null,

View file

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

View file

@ -1,6 +1,7 @@
using System; using System;
using AcDream.Core.Combat; using AcDream.Core.Combat;
using AcDream.Core.Items; using AcDream.Core.Items;
using AcDream.Runtime.Gameplay;
namespace AcDream.App.UI; namespace AcDream.App.UI;
@ -74,6 +75,7 @@ public sealed class ItemInteractionController : IDisposable
public ItemInteractionController( public ItemInteractionController(
ClientObjectTable objects, ClientObjectTable objects,
InventoryTransactionState transactions, InventoryTransactionState transactions,
InteractionState interactionState,
Func<uint> playerGuid, Func<uint> playerGuid,
Action<uint>? sendUse, Action<uint>? sendUse,
Action<uint, uint>? sendUseWithTarget, Action<uint, uint>? sendUseWithTarget,
@ -82,7 +84,6 @@ public sealed class ItemInteractionController : IDisposable
Action<uint>? sendExamine = null, Action<uint>? sendExamine = null,
Func<long>? nowMs = null, Func<long>? nowMs = null,
Action<string>? toast = null, Action<string>? toast = null,
InteractionState? interactionState = null,
Func<bool>? readyForInventoryRequest = null, Func<bool>? readyForInventoryRequest = null,
Func<uint>? activeVendorId = null, Func<uint>? activeVendorId = null,
Func<uint>? groundObjectId = null, Func<uint>? groundObjectId = null,
@ -133,7 +134,8 @@ public sealed class ItemInteractionController : IDisposable
_dragOnPlayerOpensSecureTrade = dragOnPlayerOpensSecureTrade ?? (() => true); _dragOnPlayerOpensSecureTrade = dragOnPlayerOpensSecureTrade ?? (() => true);
_systemMessage = systemMessage; _systemMessage = systemMessage;
_requestUse = requestUse; _requestUse = requestUse;
_interactionState = interactionState ?? new InteractionState(); _interactionState = interactionState
?? throw new ArgumentNullException(nameof(interactionState));
_transactions = transactions _transactions = transactions
?? throw new ArgumentNullException(nameof(transactions)); ?? throw new ArgumentNullException(nameof(transactions));
if (!ReferenceEquals(_transactions.Objects, _objects)) 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}:" + $"social={checkpoint.Social.FriendsRevision}:" +
$"{checkpoint.Social.FriendCount}:" + $"{checkpoint.Social.FriendCount}:" +
$"{checkpoint.Social.SquelchRevision};" + $"{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}:" + $"portal={checkpoint.Portal.Generation}:{(int)checkpoint.Portal.Kind}:" +
$"{checkpoint.Portal.DestinationCell:X8}:{checkpoint.Portal.IsReady}"))); $"{checkpoint.Portal.DestinationCell:X8}:{checkpoint.Portal.IsReady}")));
} }

View file

@ -115,6 +115,7 @@ public readonly record struct RuntimeStateCheckpoint(
RuntimeSocialSnapshot Social, RuntimeSocialSnapshot Social,
long ChatRevision, long ChatRevision,
int ChatCount, int ChatCount,
RuntimeActionSnapshot Actions,
RuntimeMovementSnapshot Movement, RuntimeMovementSnapshot Movement,
RuntimePortalSnapshot Portal); RuntimePortalSnapshot Portal);
@ -138,6 +139,8 @@ public interface IGameRuntimeView
IRuntimeChatView Chat { get; } IRuntimeChatView Chat { get; }
IRuntimeActionView Actions { get; }
IRuntimeMovementView Movement { get; } IRuntimeMovementView Movement { get; }
IRuntimePortalView Portal { get; } IRuntimePortalView Portal { get; }

View file

@ -1,4 +1,4 @@
namespace AcDream.App.UI; namespace AcDream.Runtime.Gameplay;
public enum InteractionModeKind public enum InteractionModeKind
{ {
@ -20,8 +20,9 @@ public readonly record struct InteractionModeTransition(
InteractionMode Current); InteractionMode Current);
/// <summary> /// <summary>
/// Single App-layer owner for retail's pointer interaction mode. Core selection /// Canonical presentation-independent owner for retail's temporary target
/// remains session truth; this state represents temporary UI orchestration. /// mode. App projects this state into a cursor image; it does not own or copy
/// the mode.
/// </summary> /// </summary>
public sealed class InteractionState public sealed class InteractionState
{ {
@ -31,13 +32,16 @@ public sealed class InteractionState
public bool EnterUse() => Set(new InteractionMode(InteractionModeKind.Use)); 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) public bool EnterUseItemOnTarget(uint sourceObjectId)
{ {
if (sourceObjectId == 0) if (sourceObjectId == 0)
throw new ArgumentOutOfRangeException(nameof(sourceObjectId)); 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); public bool Clear() => Set(InteractionMode.None);
@ -45,6 +49,7 @@ public sealed class InteractionState
/// <summary> /// <summary>
/// Publishes the session-reset edge even when the mode is already clear so /// Publishes the session-reset edge even when the mode is already clear so
/// a retry can repair any observer that missed an earlier notification. /// a retry can repair any observer that missed an earlier notification.
/// State commits before observers run and every observer is attempted.
/// </summary> /// </summary>
public void ResetSession() public void ResetSession()
{ {
@ -54,18 +59,30 @@ public sealed class InteractionState
if (listeners is null) if (listeners is null)
return; return;
var transition = new InteractionModeTransition(previous, InteractionMode.None); var transition = new InteractionModeTransition(
previous,
InteractionMode.None);
List<Exception>? failures = null; List<Exception>? failures = null;
foreach (Action<InteractionModeTransition> listener in listeners.GetInvocationList()) foreach (Action<InteractionModeTransition> listener
in listeners.GetInvocationList())
{ {
try { listener(transition); } try
catch (Exception error) { (failures ??= []).Add(error); } {
listener(transition);
} }
catch (Exception error)
{
(failures ??= []).Add(error);
}
}
if (failures is not null) if (failures is not null)
{
throw new AggregateException( throw new AggregateException(
"One or more interaction-mode reset observers failed.", "One or more interaction-mode reset observers failed.",
failures); failures);
} }
}
private bool Set(InteractionMode mode) 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; namespace AcDream.Runtime.Gameplay;
/// <summary> /// <summary>
/// One allocation-free ledger over the complete J4 gameplay-state lifetime /// One allocation-free ledger over the gameplay-state lifetime group through
/// group. It contains no state of its own; graphical and no-window hosts /// J5.1. It contains no state of its own; graphical and no-window hosts capture
/// capture the same three canonical owners. /// the same four canonical owners.
/// </summary> /// </summary>
public readonly record struct RuntimeGameplayOwnershipSnapshot( public readonly record struct RuntimeGameplayOwnershipSnapshot(
RuntimeInventoryOwnershipSnapshot Inventory, RuntimeInventoryOwnershipSnapshot Inventory,
RuntimeCharacterOwnershipSnapshot Character, RuntimeCharacterOwnershipSnapshot Character,
RuntimeCommunicationOwnershipSnapshot Communication) RuntimeCommunicationOwnershipSnapshot Communication,
RuntimeActionOwnershipSnapshot Actions)
{ {
public bool IsConverged => public bool IsConverged =>
Inventory.IsConverged Inventory.IsConverged
&& Character.IsConverged && Character.IsConverged
&& Communication.IsConverged; && Communication.IsConverged
&& Actions.IsConverged;
} }
public static class RuntimeGameplayOwnership public static class RuntimeGameplayOwnership
@ -21,14 +23,17 @@ public static class RuntimeGameplayOwnership
public static RuntimeGameplayOwnershipSnapshot Capture( public static RuntimeGameplayOwnershipSnapshot Capture(
RuntimeInventoryState inventory, RuntimeInventoryState inventory,
RuntimeCharacterState character, RuntimeCharacterState character,
RuntimeCommunicationState communication) RuntimeCommunicationState communication,
RuntimeActionState actions)
{ {
ArgumentNullException.ThrowIfNull(inventory); ArgumentNullException.ThrowIfNull(inventory);
ArgumentNullException.ThrowIfNull(character); ArgumentNullException.ThrowIfNull(character);
ArgumentNullException.ThrowIfNull(communication); ArgumentNullException.ThrowIfNull(communication);
ArgumentNullException.ThrowIfNull(actions);
return new RuntimeGameplayOwnershipSnapshot( return new RuntimeGameplayOwnershipSnapshot(
inventory.CaptureOwnership(), inventory.CaptureOwnership(),
character.CaptureOwnership(), character.CaptureOwnership(),
communication.CaptureOwnership()); communication.CaptureOwnership(),
actions.CaptureOwnership());
} }
} }

View file

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

View file

@ -186,9 +186,8 @@ public sealed class InteractionRetainedUiCompositionTests
RetainedInputCapture: null!, RetainedInputCapture: null!,
InputDispatcher: null, InputDispatcher: null,
Settings: null!, Settings: null!,
Combat: null!, Actions: null!,
CombatAttackOperations: null!, CombatAttackOperations: null!,
Selection: null!,
Inventory: null!, Inventory: null!,
MagicCatalog: null!, MagicCatalog: null!,
Character: null!, Character: null!,

View file

@ -234,6 +234,7 @@ public sealed class InteractionUiRuntimeSourcesTests
public IRuntimeCharacterView Character => null!; public IRuntimeCharacterView Character => null!;
public IRuntimeSocialView Social => null!; public IRuntimeSocialView Social => null!;
public IRuntimeChatView Chat => null!; public IRuntimeChatView Chat => null!;
public IRuntimeActionView Actions => null!;
public IRuntimeMovementView Movement => null!; public IRuntimeMovementView Movement => null!;
public IRuntimePortalView Portal => null!; public IRuntimePortalView Portal => null!;
public IRuntimeSessionCommands Session => null!; public IRuntimeSessionCommands Session => null!;

View file

@ -199,6 +199,7 @@ public sealed class GameplayInputCommandControllerTests
throw new NotSupportedException(); throw new NotSupportedException();
public IRuntimeSocialView Social => throw new NotSupportedException(); public IRuntimeSocialView Social => throw new NotSupportedException();
public IRuntimeChatView Chat => throw new NotSupportedException(); public IRuntimeChatView Chat => throw new NotSupportedException();
public IRuntimeActionView Actions => throw new NotSupportedException();
public IRuntimeMovementView Movement => throw new NotSupportedException(); public IRuntimeMovementView Movement => throw new NotSupportedException();
public IRuntimePortalView Portal => throw new NotSupportedException(); public IRuntimePortalView Portal => throw new NotSupportedException();
public RuntimeStateCheckpoint CaptureCheckpoint() => public RuntimeStateCheckpoint CaptureCheckpoint() =>

View file

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

View file

@ -42,6 +42,7 @@ public sealed class CurrentGameRuntimeAdapterTests
Assert.Equal(new RuntimeGenerationToken(1), start.Generation); Assert.Equal(new RuntimeGenerationToken(1), start.Generation);
Assert.Equal(Harness.PlayerGuid, harness.Identity.ServerGuid); Assert.Equal(Harness.PlayerGuid, harness.Identity.ServerGuid);
Assert.Equal(RuntimeLifecycleState.InWorld, harness.Runtime.Lifecycle.State); Assert.Equal(RuntimeLifecycleState.InWorld, harness.Runtime.Lifecycle.State);
Assert.Same(harness.Actions.View, harness.Runtime.Actions);
LiveEntityRecord liveRecord = LiveEntityRecord liveRecord =
harness.Entities.RegisterAndMaterializeProjection(Spawn( harness.Entities.RegisterAndMaterializeProjection(Spawn(
@ -117,6 +118,10 @@ public sealed class CurrentGameRuntimeAdapterTests
Assert.Equal(1, checkpoint.ChatCount); Assert.Equal(1, checkpoint.ChatCount);
Assert.Equal(1L, checkpoint.ChatRevision); Assert.Equal(1L, checkpoint.ChatRevision);
Assert.Equal(1UL, checkpoint.FrameNumber); 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(RuntimePortalKind.Portal, checkpoint.Portal.Kind);
Assert.Equal(0x12340001u, checkpoint.Portal.DestinationCell); Assert.Equal(0x12340001u, checkpoint.Portal.DestinationCell);
Assert.True(checkpoint.Portal.IsReady); Assert.True(checkpoint.Portal.IsReady);
@ -676,7 +681,7 @@ public sealed class CurrentGameRuntimeAdapterTests
EntityObjects); EntityObjects);
Objects = EntityObjects.Objects; Objects = EntityObjects.Objects;
Communication = new RuntimeCommunicationState(); Communication = new RuntimeCommunicationState();
Selection = new SelectionState(); Actions = new RuntimeActionState();
MovementInput = new DispatcherMovementInputSource(); MovementInput = new DispatcherMovementInputSource();
GameplayInput = new GameplayInputFrameController( GameplayInput = new GameplayInputFrameController(
dispatcher: null, dispatcher: null,
@ -697,6 +702,7 @@ public sealed class CurrentGameRuntimeAdapterTests
_items = new ItemInteractionController( _items = new ItemInteractionController(
Objects, Objects,
InventoryState.Transactions, InventoryState.Transactions,
Actions.Interaction,
() => PlayerGuid, () => PlayerGuid,
sendUse: null, sendUse: null,
sendUseWithTarget: null, sendUseWithTarget: null,
@ -726,10 +732,10 @@ public sealed class CurrentGameRuntimeAdapterTests
InventoryState, InventoryState,
Character, Character,
Communication, Communication,
Actions,
new LocalPlayerControllerSlot(), new LocalPlayerControllerSlot(),
WorldReveal, WorldReveal,
Clock, Clock,
Selection,
selectionController, selectionController,
GameplayInput, GameplayInput,
Combat); Combat);
@ -744,7 +750,8 @@ public sealed class CurrentGameRuntimeAdapterTests
public ClientObjectTable Objects { get; } public ClientObjectTable Objects { get; }
public RuntimeCommunicationState Communication { get; } public RuntimeCommunicationState Communication { get; }
public ChatLog Chat => Communication.Chat; public ChatLog Chat => Communication.Chat;
public SelectionState Selection { get; } public RuntimeActionState Actions { get; }
public SelectionState Selection => Actions.Selection;
public DispatcherMovementInputSource MovementInput { get; } public DispatcherMovementInputSource MovementInput { get; }
public GameplayInputFrameController GameplayInput { get; } public GameplayInputFrameController GameplayInput { get; }
public RecordingCombatCommand Combat { get; } public RecordingCombatCommand Combat { get; }
@ -763,6 +770,7 @@ public sealed class CurrentGameRuntimeAdapterTests
Communication.Dispose(); Communication.Dispose();
_session.Dispose(); _session.Dispose();
_items.Dispose(); _items.Dispose();
Actions.Dispose();
Entities.Clear(); 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( var interaction = new ItemInteractionController(
objects, objects,
new InventoryTransactionState(objects), new InventoryTransactionState(objects),
new InteractionState(),
playerGuid: () => Player, playerGuid: () => Player,
sendUse: null, sendUse: null,
sendUseWithTarget: null, sendUseWithTarget: null,
@ -190,6 +191,7 @@ public sealed class CursorFeedbackControllerTests
var interaction = new ItemInteractionController( var interaction = new ItemInteractionController(
objects, objects,
new InventoryTransactionState(objects), new InventoryTransactionState(objects),
new InteractionState(),
playerGuid: () => Player, playerGuid: () => Player,
sendUse: null, sendUse: null,
sendUseWithTarget: null, sendUseWithTarget: null,
@ -229,6 +231,7 @@ public sealed class CursorFeedbackControllerTests
var interaction = new ItemInteractionController( var interaction = new ItemInteractionController(
objects, objects,
new InventoryTransactionState(objects), new InventoryTransactionState(objects),
new InteractionState(),
playerGuid: () => Player, playerGuid: () => Player,
sendUse: null, sendUse: null,
sendUseWithTarget: null, sendUseWithTarget: null,
@ -258,6 +261,7 @@ public sealed class CursorFeedbackControllerTests
var interaction = new ItemInteractionController( var interaction = new ItemInteractionController(
objects, objects,
new InventoryTransactionState(objects), new InventoryTransactionState(objects),
new InteractionState(),
playerGuid: () => Player, playerGuid: () => Player,
sendUse: null, sendUse: null,
sendUseWithTarget: null, sendUseWithTarget: null,

View file

@ -60,6 +60,7 @@ public sealed class ItemInteractionControllerTests
Controller = new ItemInteractionController( Controller = new ItemInteractionController(
Objects, Objects,
SharedTransactions, SharedTransactions,
new InteractionState(),
playerGuid: () => Player, playerGuid: () => Player,
sendUse: requestUse is null ? Uses.Add : null, sendUse: requestUse is null ? Uses.Add : null,
sendExamine: Examines.Add, sendExamine: Examines.Add,
@ -341,6 +342,7 @@ public sealed class ItemInteractionControllerTests
Assert.Throws<ArgumentException>(() => new ItemInteractionController( Assert.Throws<ArgumentException>(() => new ItemInteractionController(
objects, objects,
transactions, transactions,
new InteractionState(),
playerGuid: () => Player, playerGuid: () => Player,
sendUse: null, sendUse: null,
sendUseWithTarget: null, sendUseWithTarget: null,

View file

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

View file

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

View file

@ -509,6 +509,7 @@ public class InventoryControllerTests
using var interaction = new ItemInteractionController( using var interaction = new ItemInteractionController(
objects, objects,
new InventoryTransactionState(objects), new InventoryTransactionState(objects),
new InteractionState(),
playerGuid: () => Player, playerGuid: () => Player,
sendUse: null, sendUse: null,
sendUseWithTarget: null, sendUseWithTarget: null,
@ -546,6 +547,7 @@ public class InventoryControllerTests
var interaction = new ItemInteractionController( var interaction = new ItemInteractionController(
objects, objects,
new InventoryTransactionState(objects), new InventoryTransactionState(objects),
new InteractionState(),
playerGuid: () => Player, playerGuid: () => Player,
sendUse: null, sendUse: null,
sendUseWithTarget: null, sendUseWithTarget: null,
@ -715,6 +717,7 @@ public class InventoryControllerTests
using var interaction = new ItemInteractionController( using var interaction = new ItemInteractionController(
objects, objects,
new InventoryTransactionState(objects), new InventoryTransactionState(objects),
new InteractionState(),
playerGuid: () => Player, playerGuid: () => Player,
sendUse: null, sendUse: null,
sendUseWithTarget: null, sendUseWithTarget: null,
@ -760,6 +763,7 @@ public class InventoryControllerTests
using var interaction = new ItemInteractionController( using var interaction = new ItemInteractionController(
objects, objects,
new InventoryTransactionState(objects), new InventoryTransactionState(objects),
new InteractionState(),
playerGuid: () => Player, playerGuid: () => Player,
sendUse: null, sendUse: null,
sendUseWithTarget: null, sendUseWithTarget: null,
@ -812,6 +816,7 @@ public class InventoryControllerTests
using var interaction = new ItemInteractionController( using var interaction = new ItemInteractionController(
objects, objects,
new InventoryTransactionState(objects), new InventoryTransactionState(objects),
new InteractionState(),
playerGuid: () => Player, playerGuid: () => Player,
sendUse: null, sendUse: null,
sendUseWithTarget: null, sendUseWithTarget: null,
@ -870,6 +875,7 @@ public class InventoryControllerTests
using var interaction = new ItemInteractionController( using var interaction = new ItemInteractionController(
objects, objects,
new InventoryTransactionState(objects), new InventoryTransactionState(objects),
new InteractionState(),
playerGuid: () => Player, playerGuid: () => Player,
sendUse: null, sendUse: null,
sendUseWithTarget: null, sendUseWithTarget: null,
@ -939,6 +945,7 @@ public class InventoryControllerTests
using var interaction = new ItemInteractionController( using var interaction = new ItemInteractionController(
objects, objects,
new InventoryTransactionState(objects), new InventoryTransactionState(objects),
new InteractionState(),
playerGuid: () => Player, playerGuid: () => Player,
sendUse: null, sendUse: null,
sendUseWithTarget: null, sendUseWithTarget: null,
@ -1002,6 +1009,7 @@ public class InventoryControllerTests
using var interaction = new ItemInteractionController( using var interaction = new ItemInteractionController(
objects, objects,
new InventoryTransactionState(objects), new InventoryTransactionState(objects),
new InteractionState(),
playerGuid: () => Player, playerGuid: () => Player,
sendUse: null, sendUse: null,
sendUseWithTarget: null, sendUseWithTarget: null,
@ -1062,6 +1070,7 @@ public class InventoryControllerTests
using var interaction = new ItemInteractionController( using var interaction = new ItemInteractionController(
objects, objects,
new InventoryTransactionState(objects), new InventoryTransactionState(objects),
new InteractionState(),
playerGuid: () => Player, playerGuid: () => Player,
sendUse: null, sendUse: null,
sendUseWithTarget: null, sendUseWithTarget: null,
@ -1109,6 +1118,7 @@ public class InventoryControllerTests
using var interaction = new ItemInteractionController( using var interaction = new ItemInteractionController(
objects, objects,
new InventoryTransactionState(objects), new InventoryTransactionState(objects),
new InteractionState(),
playerGuid: () => Player, playerGuid: () => Player,
sendUse: null, sendUse: null,
sendUseWithTarget: null, sendUseWithTarget: null,
@ -1149,6 +1159,7 @@ public class InventoryControllerTests
using var interaction = new ItemInteractionController( using var interaction = new ItemInteractionController(
objects, objects,
new InventoryTransactionState(objects), new InventoryTransactionState(objects),
new InteractionState(),
playerGuid: () => Player, playerGuid: () => Player,
sendUse: null, sendUse: null,
sendUseWithTarget: null, sendUseWithTarget: null,
@ -1188,6 +1199,7 @@ public class InventoryControllerTests
using var interaction = new ItemInteractionController( using var interaction = new ItemInteractionController(
objects, objects,
new InventoryTransactionState(objects), new InventoryTransactionState(objects),
new InteractionState(),
playerGuid: () => Player, playerGuid: () => Player,
sendUse: null, sendUse: null,
sendUseWithTarget: null, sendUseWithTarget: null,

View file

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

View file

@ -266,6 +266,7 @@ public class ToolbarControllerTests
var interaction = new ItemInteractionController( var interaction = new ItemInteractionController(
repo, repo,
new InventoryTransactionState(repo), new InventoryTransactionState(repo),
new InteractionState(),
playerGuid: () => player, playerGuid: () => player,
sendUse: null, sendUse: null,
sendUseWithTarget: (source, target) => useWithTarget.Add((source, target)), sendUseWithTarget: (source, target) => useWithTarget.Add((source, target)),
@ -332,6 +333,7 @@ public class ToolbarControllerTests
using var interaction = new ItemInteractionController( using var interaction = new ItemInteractionController(
repo, repo,
new InventoryTransactionState(repo), new InventoryTransactionState(repo),
new InteractionState(),
playerGuid: () => player, playerGuid: () => player,
sendUse: null, sendUse: null,
sendUseWithTarget: null, sendUseWithTarget: null,
@ -381,6 +383,7 @@ public class ToolbarControllerTests
using var interaction = new ItemInteractionController( using var interaction = new ItemInteractionController(
repo, repo,
new InventoryTransactionState(repo), new InventoryTransactionState(repo),
new InteractionState(),
playerGuid: () => player, playerGuid: () => player,
sendUse: null, sendUse: null,
sendUseWithTarget: null, sendUseWithTarget: null,
@ -510,6 +513,7 @@ public class ToolbarControllerTests
var interaction = new ItemInteractionController( var interaction = new ItemInteractionController(
repo, repo,
new InventoryTransactionState(repo), new InventoryTransactionState(repo),
new InteractionState(),
() => player, () => player,
sendUse: uses.Add, sendUse: uses.Add,
sendUseWithTarget: null, sendUseWithTarget: null,
@ -568,6 +572,7 @@ public class ToolbarControllerTests
using var interaction = new ItemInteractionController( using var interaction = new ItemInteractionController(
repo, repo,
new InventoryTransactionState(repo), new InventoryTransactionState(repo),
new InteractionState(),
() => player, () => player,
sendUse: null, sendUse: null,
sendUseWithTarget: null, sendUseWithTarget: null,
@ -633,6 +638,7 @@ public class ToolbarControllerTests
using var interaction = new ItemInteractionController( using var interaction = new ItemInteractionController(
repo, repo,
new InventoryTransactionState(repo), new InventoryTransactionState(repo),
new InteractionState(),
() => player, () => player,
sendUse: null, sendUse: null,
sendUseWithTarget: null, sendUseWithTarget: null,
@ -799,6 +805,7 @@ public class ToolbarControllerTests
using var interaction = new ItemInteractionController( using var interaction = new ItemInteractionController(
repo, repo,
new InventoryTransactionState(repo), new InventoryTransactionState(repo),
new InteractionState(),
playerGuid: () => player, playerGuid: () => player,
sendUse: null, sendUse: null,
sendUseWithTarget: null, sendUseWithTarget: null,
@ -848,6 +855,7 @@ public class ToolbarControllerTests
var interaction = new ItemInteractionController( var interaction = new ItemInteractionController(
repo, repo,
new InventoryTransactionState(repo), new InventoryTransactionState(repo),
new InteractionState(),
() => player, () => player,
sendUse: null, sendUse: null,
sendUseWithTarget: (s, t) => (sentSource, sentTarget) = (s, t), sendUseWithTarget: (s, t) => (sentSource, sentTarget) = (s, t),
@ -890,6 +898,7 @@ public class ToolbarControllerTests
var interaction = new ItemInteractionController( var interaction = new ItemInteractionController(
repo, repo,
new InventoryTransactionState(repo), new InventoryTransactionState(repo),
new InteractionState(),
() => player, () => player,
sendUse: null, sendUse: null,
sendUseWithTarget: null, sendUseWithTarget: null,

View file

@ -19,6 +19,7 @@ public sealed class RetailItemConfirmationControllerTests
var items = new ItemInteractionController( var items = new ItemInteractionController(
objects, objects,
new InventoryTransactionState(objects), new InventoryTransactionState(objects),
new InteractionState(),
playerGuid: () => Player, playerGuid: () => Player,
sendUse: uses.Add, sendUse: uses.Add,
sendUseWithTarget: null, sendUseWithTarget: null,
@ -54,6 +55,7 @@ public sealed class RetailItemConfirmationControllerTests
var items = new ItemInteractionController( var items = new ItemInteractionController(
objects, objects,
new InventoryTransactionState(objects), new InventoryTransactionState(objects),
new InteractionState(),
playerGuid: () => Player, playerGuid: () => Player,
sendUse: uses.Add, sendUse: uses.Add,
sendUseWithTarget: null, sendUseWithTarget: null,
@ -83,6 +85,7 @@ public sealed class RetailItemConfirmationControllerTests
var items = new ItemInteractionController( var items = new ItemInteractionController(
objects, objects,
new InventoryTransactionState(objects), new InventoryTransactionState(objects),
new InteractionState(),
playerGuid: () => Player, playerGuid: () => Player,
sendUse: uses.Add, sendUse: uses.Add,
sendUseWithTarget: null, sendUseWithTarget: null,

View file

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

View file

@ -169,6 +169,17 @@ public sealed class GameRuntimeContractTests
new RuntimeSocialSnapshot(9, 10, 11, 0, 0, 0, 0), new RuntimeSocialSnapshot(9, 10, 11, 0, 0, 0, 0),
ChatRevision: 12, ChatRevision: 12,
ChatCount: 13, 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,
default); default);
@ -178,6 +189,9 @@ public sealed class GameRuntimeContractTests
Assert.Contains("inventory-state=2:4:1:3", entry.Text); Assert.Contains("inventory-state=2:4:1:3", entry.Text);
Assert.Contains("character=5:6:7:8:0:0", entry.Text); Assert.Contains("character=5:6:7:8:0:0", entry.Text);
Assert.Contains("social=9:10:11", entry.Text); Assert.Contains("social=9:10:11", entry.Text);
Assert.Contains(
"actions=14:50000001:15:4:1:16:1:00000000",
entry.Text);
} }
[Fact] [Fact]
@ -190,6 +204,8 @@ public sealed class GameRuntimeContractTests
typeof(RuntimeCharacterState), typeof(RuntimeCharacterState),
typeof(RuntimeCharacterOptionsState), typeof(RuntimeCharacterOptionsState),
typeof(RuntimeMovementSkillState), typeof(RuntimeMovementSkillState),
typeof(RuntimeActionState),
typeof(InteractionState),
]; ];
foreach (Type owner in owners) 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 public sealed class InteractionStateTests
{ {
@ -26,7 +26,8 @@ public sealed class InteractionStateTests
public void UseItemOnTarget_RejectsMissingSource() public void UseItemOnTarget_RejectsMissingSource()
{ {
var state = new InteractionState(); var state = new InteractionState();
Assert.Throws<ArgumentOutOfRangeException>(() => state.EnterUseItemOnTarget(0)); Assert.Throws<ArgumentOutOfRangeException>(
() => state.EnterUseItemOnTarget(0));
Assert.Equal(InteractionMode.None, state.Current); 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 inventory = new RuntimeInventoryState(entities);
var character = new RuntimeCharacterState(); var character = new RuntimeCharacterState();
var communication = new RuntimeCommunicationState(); var communication = new RuntimeCommunicationState();
var actions = new RuntimeActionState();
inventory.Shortcuts.Changed += static () => { }; inventory.Shortcuts.Changed += static () => { };
inventory.Shortcuts.Load([new ShortcutEntry(1, 2u, 3u)]); inventory.Shortcuts.Load([new ShortcutEntry(1, 2u, 3u)]);
inventory.ItemMana.OnQueryItemManaResponse(2u, 0.5f, true); inventory.ItemMana.OnQueryItemManaResponse(2u, 0.5f, true);
@ -60,12 +61,19 @@ public sealed class RuntimeGameplayOwnershipTests
new HashSet<uint> { 4u }))); new HashSet<uint> { 4u })));
IDisposable subscription = IDisposable subscription =
communication.Events.Subscribe(new NoopObserver()); 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 = RuntimeGameplayOwnershipSnapshot populated =
RuntimeGameplayOwnership.Capture( RuntimeGameplayOwnership.Capture(
inventory, inventory,
character, character,
communication); communication,
actions);
Assert.False(populated.IsConverged); Assert.False(populated.IsConverged);
Assert.Equal(1, populated.Inventory.ShortcutCount); Assert.Equal(1, populated.Inventory.ShortcutCount);
@ -73,7 +81,13 @@ public sealed class RuntimeGameplayOwnershipTests
Assert.Equal(1, populated.Communication.StreamSubscriberCount); Assert.Equal(1, populated.Communication.StreamSubscriberCount);
Assert.True(populated.Communication.HasReplyTarget); Assert.True(populated.Communication.HasReplyTarget);
Assert.True(populated.Communication.HasRetellTarget); 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(); communication.Dispose();
character.Dispose(); character.Dispose();
inventory.Dispose(); inventory.Dispose();
@ -83,7 +97,8 @@ public sealed class RuntimeGameplayOwnershipTests
RuntimeGameplayOwnership.Capture( RuntimeGameplayOwnership.Capture(
inventory, inventory,
character, character,
communication); communication,
actions);
Assert.True(retired.IsConverged); Assert.True(retired.IsConverged);
Assert.Equal(0, retired.Inventory.ShortcutSubscriberCount); Assert.Equal(0, retired.Inventory.ShortcutSubscriberCount);
@ -99,6 +114,7 @@ public sealed class RuntimeGameplayOwnershipTests
var inventory = new RuntimeInventoryState(entities); var inventory = new RuntimeInventoryState(entities);
var character = new RuntimeCharacterState(); var character = new RuntimeCharacterState();
var communication = new RuntimeCommunicationState(); var communication = new RuntimeCommunicationState();
var actions = new RuntimeActionState();
inventory.ExternalContainers.RequestOpen(0x70000001u); inventory.ExternalContainers.RequestOpen(0x70000001u);
inventory.ExternalContainers.ApplyViewContents(0x70000001u); inventory.ExternalContainers.ApplyViewContents(0x70000001u);
@ -119,12 +135,14 @@ public sealed class RuntimeGameplayOwnershipTests
Assert.Throws<AggregateException>(inventory.Dispose); Assert.Throws<AggregateException>(inventory.Dispose);
Assert.Throws<AggregateException>(character.Dispose); Assert.Throws<AggregateException>(character.Dispose);
communication.Dispose(); communication.Dispose();
actions.Dispose();
RuntimeGameplayOwnershipSnapshot retired = RuntimeGameplayOwnershipSnapshot retired =
RuntimeGameplayOwnership.Capture( RuntimeGameplayOwnership.Capture(
inventory, inventory,
character, character,
communication); communication,
actions);
Assert.True(retired.IsConverged); Assert.True(retired.IsConverged);
Assert.Equal(1, retired.Communication.DispatchFailureCount); Assert.Equal(1, retired.Communication.DispatchFailureCount);