refactor(runtime): expose canonical gameplay state

Move character options and movement skills into the Runtime-owned character graph, expose borrowed inventory, character, and social views, and route retained UI state commands through generation-gated typed Runtime contracts. Preserve the existing synchronous wire path while deleting the App-owned option and skill mirrors and extending normalized parity checkpoints.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-26 09:12:30 +02:00
parent 9d0d9b07e0
commit dcb61efb5a
34 changed files with 2076 additions and 103 deletions

View file

@ -199,7 +199,6 @@ public sealed class InteractionRetainedUiCompositionTests
PlayerIdentity: null!,
PlayerController: null!,
PlayerMode: null!,
CharacterOptions: null!,
SelectionCameraFactory: static _ => Stub<SelectionCameraSource>(),
FrameDiagnostics: new DeferredRenderFrameDiagnosticsSource(),
ExistingVitals: null,

View file

@ -10,6 +10,7 @@ using AcDream.App.UI.Layout;
using AcDream.App.World;
using AcDream.Core.Net;
using AcDream.Core.World;
using AcDream.Runtime;
using AcDream.UI.Abstractions;
namespace AcDream.App.Tests.Composition;
@ -67,6 +68,44 @@ public sealed class InteractionUiRuntimeSourcesTests
Assert.Throws<ObjectDisposedException>(() => source.Bind(query, interactions));
}
[Fact]
public void GameRuntimeCommandsCaptureTheExactCurrentGeneration()
{
var source = new DeferredGameRuntimeStateCommands();
var first = new RuntimeTarget(new RuntimeGenerationToken(7));
var second = new RuntimeTarget(new RuntimeGenerationToken(9));
Assert.False(source.IsInWorld);
Assert.Equal(
RuntimeCommandStatus.Inactive,
source.Advance(RuntimeAdvancementKind.Skill, 6u, 100u).Status);
IDisposable stale = source.Bind(first, first);
Assert.True(source.IsInWorld);
Assert.True(source.Advance(
RuntimeAdvancementKind.Skill,
6u,
100u).Accepted);
Assert.Equal(new RuntimeGenerationToken(7), first.LastGeneration);
Assert.Throws<InvalidOperationException>(() =>
source.Bind(second, second));
stale.Dispose();
using IDisposable current = source.Bind(second, second);
stale.Dispose();
Assert.True(source.AddFavorite(0, 2, 42u).Accepted);
Assert.Equal(new RuntimeGenerationToken(9), second.LastGeneration);
Assert.Equal(
RuntimeCommandStatus.Rejected,
source.RemoveShortcut(uint.MaxValue).Status);
source.Deactivate();
Assert.Equal(
RuntimeCommandStatus.Inactive,
source.RemoveShortcut(3u).Status);
Assert.Throws<ObjectDisposedException>(() => source.Bind(first, first));
}
[Fact]
public void RadarNeverCachesAnUnboundBootstrapSnapshot()
{
@ -172,6 +211,109 @@ public sealed class InteractionUiRuntimeSourcesTests
public ICommandBus Commands { get; } = new LiveCommandBus();
}
private sealed class RuntimeTarget
: IGameRuntimeView,
IGameRuntimeCommands,
IRuntimeInventoryStateCommands,
IRuntimeSpellbookCommands,
IRuntimeCharacterCommands
{
public RuntimeTarget(RuntimeGenerationToken generation)
{
Generation = generation;
}
public RuntimeGenerationToken LastGeneration { get; private set; }
public RuntimeGenerationToken Generation { get; }
public RuntimeLifecycleSnapshot Lifecycle =>
new(Generation, RuntimeLifecycleState.InWorld, 1u, true);
public IGameRuntimeClock Clock => null!;
public IRuntimeEntityView Entities => null!;
public IRuntimeInventoryView Inventory => null!;
public IRuntimeInventoryStateView InventoryState => null!;
public IRuntimeCharacterView Character => null!;
public IRuntimeSocialView Social => null!;
public IRuntimeChatView Chat => null!;
public IRuntimeMovementView Movement => null!;
public IRuntimePortalView Portal => null!;
public IRuntimeSessionCommands Session => null!;
public IRuntimeSelectionCommands Selection => null!;
public IRuntimeCombatCommands Combat => null!;
IRuntimeMovementCommands IGameRuntimeCommands.Movement => null!;
IRuntimeChatCommands IGameRuntimeCommands.Chat => null!;
IRuntimePortalCommands IGameRuntimeCommands.Portal => null!;
IRuntimeInventoryStateCommands IGameRuntimeCommands.InventoryState => this;
IRuntimeSpellbookCommands IGameRuntimeCommands.Spellbook => this;
IRuntimeCharacterCommands IGameRuntimeCommands.Character => this;
IRuntimeSocialCommands IGameRuntimeCommands.Social => null!;
public RuntimeStateCheckpoint CaptureCheckpoint() => default;
public RuntimeCommandResult AddShortcut(
RuntimeGenerationToken expectedGeneration,
in RuntimeShortcutCommand command) =>
Accepted(expectedGeneration, command.ObjectId);
public RuntimeCommandResult RemoveShortcut(
RuntimeGenerationToken expectedGeneration,
int index) =>
Accepted(expectedGeneration);
public RuntimeCommandResult AddFavorite(
RuntimeGenerationToken expectedGeneration,
int tabIndex,
int position,
uint spellId) =>
Accepted(expectedGeneration, spellId);
public RuntimeCommandResult RemoveFavorite(
RuntimeGenerationToken expectedGeneration,
int tabIndex,
uint spellId) =>
Accepted(expectedGeneration, spellId);
public RuntimeCommandResult SetFilter(
RuntimeGenerationToken expectedGeneration,
uint filters) =>
Accepted(expectedGeneration);
public RuntimeCommandResult ForgetSpell(
RuntimeGenerationToken expectedGeneration,
uint spellId) =>
Accepted(expectedGeneration, spellId);
public RuntimeCommandResult SetDesiredComponent(
RuntimeGenerationToken expectedGeneration,
uint componentId,
uint amount) =>
Accepted(expectedGeneration, componentId);
public RuntimeCommandResult ClearDesiredComponents(
RuntimeGenerationToken expectedGeneration) =>
Accepted(expectedGeneration);
public RuntimeCommandResult Advance(
RuntimeGenerationToken expectedGeneration,
in RuntimeAdvancementCommand command) =>
Accepted(expectedGeneration, command.StatId);
public RuntimeCommandResult SetOptions1(
RuntimeGenerationToken expectedGeneration,
uint options) =>
Accepted(expectedGeneration);
private RuntimeCommandResult Accepted(
RuntimeGenerationToken generation,
uint objectId = 0u)
{
LastGeneration = generation;
return new RuntimeCommandResult(
RuntimeCommandStatus.Accepted,
generation,
objectId);
}
}
private sealed class SelectionQuery : IRetainedUiSelectionQuery
{
public bool ShouldShowHealth(uint serverGuid) => true;

View file

@ -193,6 +193,11 @@ public sealed class GameplayInputCommandControllerTests
public IGameRuntimeClock Clock => throw new NotSupportedException();
public IRuntimeEntityView Entities => throw new NotSupportedException();
public IRuntimeInventoryView Inventory => throw new NotSupportedException();
public IRuntimeInventoryStateView InventoryState =>
throw new NotSupportedException();
public IRuntimeCharacterView Character =>
throw new NotSupportedException();
public IRuntimeSocialView Social => throw new NotSupportedException();
public IRuntimeChatView Chat => throw new NotSupportedException();
public IRuntimeMovementView Movement => throw new NotSupportedException();
public IRuntimePortalView Portal => throw new NotSupportedException();

View file

@ -2,6 +2,7 @@ using System.Runtime.CompilerServices;
using AcDream.App.Net;
using AcDream.App.UI;
using AcDream.Core.Chat;
using AcDream.Core.Items;
using AcDream.Core.Social;
using AcDream.UI.Abstractions;
@ -220,6 +221,35 @@ public sealed class LiveSessionCommandRouterTests
GC.KeepAlive(router);
}
[Fact]
public void RuntimeStateCommandsUseActiveGenerationRouteAndBecomeInert()
{
var shortcuts = new List<ShortcutEntry>();
var options = new List<uint>();
LiveSessionCommandRouter router = NewRouter(
addShortcut: shortcuts.Add,
setCharacterOptions: options.Add);
router.Publish(new AddShortcutRuntimeCmd(
new ShortcutEntry(1, 0x80000001u, 0u)));
router.Activate();
router.Publish(new AddShortcutRuntimeCmd(
new ShortcutEntry(2, 0x80000002u, 0u)));
router.Publish(new SetCharacterOptionsRuntimeCmd(0x50C4A54Au));
router.Dispose();
router.Publish(new AddShortcutRuntimeCmd(
new ShortcutEntry(3, 0x80000003u, 0u)));
Assert.Collection(
shortcuts,
entry =>
{
Assert.Equal(2, entry.Index);
Assert.Equal(0x80000002u, entry.ObjectId);
});
Assert.Equal([0x50C4A54Au], options);
}
private static LiveSessionCommandRouter NewRouter(
ChatLog? chat = null,
TurbineChatState? turbine = null,
@ -228,6 +258,8 @@ public sealed class LiveSessionCommandRouterTests
Action<string, string>? sendTell = null,
Action<uint, string>? sendChannel = null,
Action<uint, uint, uint, uint, string, uint>? sendTurbine = null,
Action<ShortcutEntry>? addShortcut = null,
Action<uint>? setCharacterOptions = null,
Action<string>? log = null,
ClientCommandController.Bindings? clientBindings = null) => new(
new LiveSessionCommandBindings(
@ -239,7 +271,27 @@ public sealed class LiveSessionCommandRouterTests
sendTell ?? ((_, _) => { }),
sendChannel ?? ((_, _) => { }),
sendTurbine ?? ((_, _, _, _, _, _) => { }),
log));
AddShortcut: addShortcut ?? (_ => { }),
RemoveShortcut: _ => { },
AddFavorite: (_, _, _) => { },
RemoveFavorite: (_, _) => { },
SetSpellbookFilter: _ => { },
ForgetSpell: _ => { },
SetDesiredComponent: (_, _) => { },
ClearDesiredComponents: () => { },
RaiseAttribute: (_, _) => { },
RaiseVital: (_, _) => { },
RaiseSkill: (_, _) => { },
TrainSkill: (_, _) => { },
SetCharacterOptions: setCharacterOptions ?? (_ => { }),
AddFriend: _ => { },
RemoveFriend: _ => { },
ClearFriends: () => { },
RequestLegacyFriends: () => { },
ModifyCharacterSquelch: (_, _, _, _) => { },
ModifyAccountSquelch: (_, _) => { },
ModifyGlobalSquelch: (_, _) => { },
Log: log));
[MethodImpl(MethodImplOptions.NoInlining)]
private static (LiveSessionCommandRouter, WeakReference<object>)

View file

@ -15,6 +15,7 @@ using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.Selection;
using AcDream.Core.Social;
using AcDream.Core.World;
using AcDream.Runtime;
using AcDream.Runtime.Entities;
@ -363,6 +364,157 @@ public sealed class CurrentGameRuntimeAdapterTests
Assert.Equal(0, harness.EntityObjects.Events.SubscriberCount);
}
[Fact]
public void AdapterBorrowsExactJ4ViewsAndCheckpointState()
{
using var harness = new Harness();
harness.Character.Options.Replace(0x04000000u, 0x00948700u);
harness.Character.MovementSkills.Update(200, 175);
harness.Character.Spellbook.OnSpellLearned(42u);
harness.InventoryState.Shortcuts.Replace(
[
new ShortcutEntry(1, 0x80000001u, 0u),
]);
harness.Communication.Friends.Apply(new FriendsUpdate(
FriendsUpdateType.Full,
[
new FriendEntry(
0x50000003u,
"Borrowed",
true,
false,
[],
[]),
]));
Assert.Same(harness.Character.View, harness.Runtime.Character);
Assert.Same(
harness.InventoryState.View,
harness.Runtime.InventoryState);
Assert.Same(
harness.Communication.SocialView,
harness.Runtime.Social);
RuntimeStateCheckpoint checkpoint =
harness.Runtime.CaptureCheckpoint();
Assert.Equal(1, checkpoint.Character.LearnedSpellCount);
Assert.True(checkpoint.Character.MovementSkills.IsComplete);
Assert.Equal(1, checkpoint.InventoryState.ShortcutCount);
Assert.Equal(1, checkpoint.Social.FriendCount);
}
[Fact]
public void J4StateCommandsAreGenerationGatedAndReachCurrentTransport()
{
using var harness = new Harness();
var trace = new RuntimeTraceRecorder();
using IDisposable subscription = harness.Runtime.Subscribe(trace);
_ = harness.Runtime.Session.Start(harness.Runtime.Generation);
RuntimeGenerationToken generation = harness.Runtime.Generation;
IGameRuntimeCommands commands = harness.Runtime;
RuntimeCommandResult shortcut = commands.InventoryState.AddShortcut(
generation,
new RuntimeShortcutCommand(2, 0x80000001u, 0u));
RuntimeCommandResult favorite = commands.Spellbook.AddFavorite(
generation,
tabIndex: 0,
position: 0,
spellId: 42u);
RuntimeCommandResult filter = commands.Spellbook.SetFilter(
generation,
0x3FFEu);
RuntimeCommandResult desired = commands.Spellbook.SetDesiredComponent(
generation,
0x68000001u,
10u);
RuntimeCommandResult advancement = commands.Character.Advance(
generation,
new RuntimeAdvancementCommand(
RuntimeAdvancementKind.Skill,
StatId: 6u,
Cost: 500u));
RuntimeCommandResult options = commands.Character.SetOptions1(
generation,
0x50C4A54Au);
RuntimeCommandResult friend = commands.Social.Execute(
generation,
new RuntimeFriendCommand(
RuntimeFriendCommandKind.Add,
Name: "Runtime Friend"));
RuntimeCommandResult squelch = commands.Social.Execute(
generation,
new RuntimeSquelchCommand(
RuntimeSquelchScope.Global,
Add: true,
MessageType: 3u));
Assert.All(
new[]
{
shortcut,
favorite,
filter,
desired,
advancement,
options,
friend,
squelch,
},
static result => Assert.True(result.Accepted));
Assert.Contains(
harness.Commands.Published,
static command => command is AddShortcutRuntimeCmd);
Assert.Contains(
harness.Commands.Published,
static command => command is AddFavoriteRuntimeCmd);
Assert.Contains(
harness.Commands.Published,
static command => command is SetSpellbookFilterRuntimeCmd);
Assert.Contains(
harness.Commands.Published,
static command => command is SetDesiredComponentRuntimeCmd);
Assert.Contains(
harness.Commands.Published,
static command => command is RaiseSkillRuntimeCmd);
Assert.Contains(
harness.Commands.Published,
static command => command is SetCharacterOptionsRuntimeCmd);
Assert.Contains(
harness.Commands.Published,
static command => command is AddFriendRuntimeCmd);
Assert.Contains(
harness.Commands.Published,
static command => command is ModifyGlobalSquelchRuntimeCmd);
Assert.Contains(
trace.Entries,
static entry => entry.Kind == RuntimeTraceKind.Command
&& (entry.Code >> 16)
== (int)RuntimeCommandDomain.InventoryState);
Assert.Contains(
trace.Entries,
static entry => entry.Kind == RuntimeTraceKind.Command
&& (entry.Code >> 16)
== (int)RuntimeCommandDomain.Spellbook);
Assert.Contains(
trace.Entries,
static entry => entry.Kind == RuntimeTraceKind.Command
&& (entry.Code >> 16)
== (int)RuntimeCommandDomain.Character);
Assert.Contains(
trace.Entries,
static entry => entry.Kind == RuntimeTraceKind.Command
&& (entry.Code >> 16)
== (int)RuntimeCommandDomain.Social);
int published = harness.Commands.Published.Count;
RuntimeCommandResult stale = commands.Character.SetOptions1(
new RuntimeGenerationToken(generation.Value - 1),
0u);
Assert.Equal(RuntimeCommandStatus.StaleGeneration, stale.Status);
Assert.Equal(published, harness.Commands.Published.Count);
}
private static ClientObject Object(WorldSession.EntitySpawn spawn) => new()
{
ObjectId = spawn.Guid,
@ -407,6 +559,8 @@ public sealed class CurrentGameRuntimeAdapterTests
{
Identity = new LocalPlayerIdentityState();
EntityObjects = new RuntimeEntityObjectLifetime();
InventoryState = new RuntimeInventoryState(EntityObjects);
Character = new RuntimeCharacterState();
Entities = new LiveEntityRuntime(
new GpuWorldState(),
new NoopEntityResources(),
@ -448,7 +602,9 @@ public sealed class CurrentGameRuntimeAdapterTests
Options = LiveOptions();
Commands = new RecordingCommandRouting();
_session = new LiveSessionController(new SessionOperations());
Transport = new TestTransport();
_session = new LiveSessionController(
new SessionOperations(Transport));
Host = CreateHost(_session, Commands, Identity);
Runtime = new CurrentGameRuntimeAdapter(
_session,
@ -457,6 +613,8 @@ public sealed class CurrentGameRuntimeAdapterTests
Identity,
Entities,
EntityObjects,
InventoryState,
Character,
Communication,
new LocalPlayerControllerSlot(),
WorldReveal,
@ -470,6 +628,8 @@ public sealed class CurrentGameRuntimeAdapterTests
public RuntimeOptions Options { get; }
public LocalPlayerIdentityState Identity { get; }
public RuntimeEntityObjectLifetime EntityObjects { get; }
public RuntimeInventoryState InventoryState { get; }
public RuntimeCharacterState Character { get; }
public LiveEntityRuntime Entities { get; }
public ClientObjectTable Objects { get; }
public RuntimeCommunicationState Communication { get; }
@ -481,12 +641,15 @@ public sealed class CurrentGameRuntimeAdapterTests
public UpdateFrameClock Clock { get; }
public WorldRevealCoordinator WorldReveal { get; }
public RecordingCommandRouting Commands { get; }
public TestTransport Transport { get; }
public LiveSessionHost Host { get; }
public CurrentGameRuntimeAdapter Runtime { get; }
public void Dispose()
{
Runtime.Dispose();
Character.Dispose();
InventoryState.Dispose();
Communication.Dispose();
_session.Dispose();
_items.Dispose();
@ -644,13 +807,14 @@ public sealed class CurrentGameRuntimeAdapterTests
Physics: physics);
}
private sealed class SessionOperations : ILiveSessionOperations
private sealed class SessionOperations(TestTransport transport)
: ILiveSessionOperations
{
public IPEndPoint ResolveEndpoint(string host, int port) =>
new(IPAddress.Loopback, port);
public WorldSession CreateSession(IPEndPoint endpoint) =>
new(endpoint, new TestTransport());
new(endpoint, transport);
public void Connect(WorldSession session, string user, string password)
{
@ -686,14 +850,18 @@ public sealed class CurrentGameRuntimeAdapterTests
public void DisposeSession(WorldSession session) => session.Dispose();
}
private sealed class TestTransport : IWorldSessionTransport
internal sealed class TestTransport : IWorldSessionTransport
{
public List<byte[]> Sent { get; } = [];
public void Send(ReadOnlySpan<byte> datagram)
{
Sent.Add(datagram.ToArray());
}
public void Send(IPEndPoint remote, ReadOnlySpan<byte> datagram)
{
Sent.Add(datagram.ToArray());
}
public int Receive(

View file

@ -81,6 +81,37 @@ public sealed class RuntimeCharacterOwnershipTests
StringComparison.Ordinal);
}
[Fact]
public void AppCharacterOptionAndMovementSkillOwnersWereDeleted()
{
string gameWindow = ReadSource("Rendering", "GameWindow.cs");
string sources = ReadSource(
"Composition",
"InteractionUiRuntimeSources.cs");
string playerState = ReadSource("Input", "LocalPlayerRuntimeState.cs");
string session = ReadSource("Net", "LiveSessionRuntimeFactory.cs");
string router = ReadRuntimeSource(
"Session",
"LiveSessionEventRouter.cs");
Assert.DoesNotContain(
"PlayerCharacterOptionsState",
gameWindow + sources + session,
StringComparison.Ordinal);
Assert.DoesNotContain(
"LocalPlayerSkillState",
gameWindow + playerState + session,
StringComparison.Ordinal);
Assert.Contains(
"character.Character.Options.Replace",
router,
StringComparison.Ordinal);
Assert.Contains(
"character.Character.MovementSkills.Update",
router,
StringComparison.Ordinal);
}
private static string ReadSource(params string[] relative) =>
File.ReadAllText(Path.Combine(
[FindRepositoryRoot(), "src", "AcDream.App", .. relative]));