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

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

@ -1,59 +0,0 @@
using AcDream.App.UI;
namespace AcDream.App.Tests.UI;
public sealed class InteractionStateTests
{
[Fact]
public void Modes_HaveOneOwnerAndOneDeduplicatedTransitionStream()
{
var state = new InteractionState();
var changes = new List<InteractionModeTransition>();
state.Changed += changes.Add;
Assert.True(state.EnterUse());
Assert.False(state.EnterUse());
Assert.True(state.EnterExamine());
Assert.True(state.EnterUseItemOnTarget(0x100u));
Assert.True(state.Clear());
Assert.Equal(4, changes.Count);
Assert.Equal(InteractionModeKind.None, state.Current.Kind);
Assert.Equal(0x100u, changes[2].Current.SourceObjectId);
}
[Fact]
public void UseItemOnTarget_RejectsMissingSource()
{
var state = new InteractionState();
Assert.Throws<ArgumentOutOfRangeException>(() => state.EnterUseItemOnTarget(0));
Assert.Equal(InteractionMode.None, state.Current);
}
[Fact]
public void ResetSession_RetryRepublishesAndOneObserverCannotStarveAnother()
{
var state = new InteractionState();
state.EnterExamine();
bool fail = true;
int delivered = 0;
state.Changed += _ =>
{
if (fail)
{
fail = false;
throw new InvalidOperationException("transient");
}
};
state.Changed += transition =>
{
Assert.Equal(InteractionMode.None, transition.Current);
delivered++;
};
Assert.Throws<AggregateException>(state.ResetSession);
Assert.Equal(1, delivered);
state.ResetSession();
Assert.Equal(2, delivered);
}
}

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,