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

View file

@ -1,3 +1,6 @@
using System.Reflection;
using AcDream.Runtime.Gameplay;
namespace AcDream.Runtime.Tests;
public sealed class GameRuntimeContractTests
@ -137,4 +140,68 @@ public sealed class GameRuntimeContractTests
Assert.Equal("General|Grey|hello", entry.Text);
});
}
[Fact]
public void TraceRecorderIncludesJ4GameplayStateInCheckpoint()
{
var recorder = new RuntimeTraceRecorder();
var stamp = new RuntimeEventStamp(new(3), 1, 8);
var checkpoint = new RuntimeStateCheckpoint(
new RuntimeGenerationToken(3),
RuntimeLifecycleState.InWorld,
FrameNumber: 8,
EntityCount: 2,
MaterializedEntityCount: 2,
InventoryObjectCount: 1,
InventoryContainerCount: 1,
new RuntimeInventoryStateSnapshot(
0u, 0u, 0, true, null, 2, 4, 1, 3),
new RuntimeCharacterSnapshot(
CharacterRevision: 5,
SpellbookRevision: 6,
default,
default,
LearnedSpellCount: 7,
ActiveEnchantmentCount: 0,
DesiredComponentCount: 0,
SkillCount: 8,
SpellbookFilters: 0x3FFFu),
new RuntimeSocialSnapshot(9, 10, 11, 0, 0, 0, 0),
ChatRevision: 12,
ChatCount: 13,
default,
default);
recorder.AddCheckpoint(stamp, checkpoint);
RuntimeTraceEntry entry = Assert.Single(recorder.Entries);
Assert.Contains("inventory-state=2:4:1:3", entry.Text);
Assert.Contains("character=5:6:7:8:0:0", entry.Text);
Assert.Contains("social=9:10:11", entry.Text);
}
[Fact]
public void J4GameplayOwnersHaveNoStaticMutableSessionState()
{
Type[] owners =
[
typeof(RuntimeCommunicationState),
typeof(RuntimeInventoryState),
typeof(RuntimeCharacterState),
typeof(RuntimeCharacterOptionsState),
typeof(RuntimeMovementSkillState),
];
foreach (Type owner in owners)
{
FieldInfo[] mutable = owner
.GetFields(
BindingFlags.Static
| BindingFlags.Public
| BindingFlags.NonPublic)
.Where(static field => !field.IsLiteral)
.ToArray();
Assert.Empty(mutable);
}
}
}

View file

@ -1,4 +1,5 @@
using AcDream.Core.Spells;
using AcDream.Core.Player;
using AcDream.Runtime.Gameplay;
namespace AcDream.Runtime.Tests.Gameplay;
@ -152,4 +153,105 @@ public sealed class RuntimeCharacterStateTests
Assert.True(state.IsDisposed);
}
[Fact]
public void OwnsRetailCharacterOptionsAndMovementSkillProjection()
{
using var state = new RuntimeCharacterState();
Assert.Equal(
RuntimeCharacterOptionsState.DefaultOptions1,
state.Options.Options1);
Assert.Equal(
RuntimeCharacterOptionsState.DefaultOptions2,
state.Options.Options2);
Assert.False(state.MovementSkills.IsComplete);
state.Options.Replace(0x04000000u, 0x12345678u);
state.MovementSkills.Update(runSkill: 210, jumpSkill: -1);
state.MovementSkills.Update(runSkill: -1, jumpSkill: 165);
Assert.True(state.Options.DragItemOnPlayerOpensSecureTrade);
Assert.Equal(0x12345678u, state.Options.Options2);
Assert.Equal(
new RuntimeMovementSkillSnapshot(
210,
165,
state.MovementSkills.Revision),
state.MovementSkills.Snapshot);
Assert.True(state.MovementSkills.IsComplete);
state.ResetSession();
Assert.Equal(
RuntimeCharacterOptionsState.DefaultOptions1,
state.Options.Options1);
Assert.Equal(
RuntimeCharacterOptionsState.DefaultOptions2,
state.Options.Options2);
Assert.Equal(-1, state.MovementSkills.RunSkill);
Assert.Equal(-1, state.MovementSkills.JumpSkill);
}
[Fact]
public void CharacterViewBorrowsExactOwnersWithoutReconstructedState()
{
using var state = new RuntimeCharacterState();
state.LocalPlayer.OnAttributeUpdate(1u, 40u, 10u, 500u);
state.LocalPlayer.OnVitalUpdate(7u, 60u, 20u, 700u, 75u);
state.LocalPlayer.OnSkillUpdate(
6u,
30u,
2u,
800u,
10u,
0u,
5d,
12u);
state.Spellbook.OnSpellLearned(42u);
state.Spellbook.SetFavorite(0, 0, 42u);
state.Spellbook.SetDesiredComponent(0x68000001u, 11u);
RuntimeCharacterSnapshot summary = state.View.Snapshot;
Assert.Equal(1, summary.LearnedSpellCount);
Assert.Equal(1, summary.DesiredComponentCount);
Assert.Equal(1, summary.SkillCount);
Assert.True(state.View.KnowsSpell(42u));
Assert.True(state.View.TryGetFavorite(0, 0, out uint favorite));
Assert.Equal(42u, favorite);
Assert.True(state.View.TryGetDesiredComponent(
0x68000001u,
out uint desired));
Assert.Equal(11u, desired);
Assert.True(state.View.TryGetAttribute(
(int)LocalPlayerState.AttributeKind.Strength,
out RuntimeAttributeSnapshot attribute));
Assert.Equal(50u, attribute.Current);
Assert.True(state.View.TryGetVital(
(int)LocalPlayerState.VitalKind.Health,
out RuntimeVitalSnapshot vital));
Assert.Equal(75u, vital.Current);
Assert.True(state.View.TryGetSkill(6u, out RuntimeSkillSnapshot skill));
Assert.Equal(52u, skill.CurrentLevel);
}
[Fact]
public void TwoRuntimeInstancesIsolateOptionsSkillsAndViewRevisions()
{
using var first = new RuntimeCharacterState();
using var second = new RuntimeCharacterState();
first.Options.Replace(1u, 2u);
first.MovementSkills.Update(100, 200);
first.Spellbook.OnSpellLearned(9u);
Assert.NotEqual(
first.View.Snapshot.Options,
second.View.Snapshot.Options);
Assert.True(first.View.Snapshot.MovementSkills.IsComplete);
Assert.False(second.View.Snapshot.MovementSkills.IsComplete);
Assert.True(first.View.Snapshot.SpellbookRevision > 0);
Assert.Equal(0, second.View.Snapshot.SpellbookRevision);
}
}

View file

@ -6,6 +6,44 @@ namespace AcDream.Runtime.Tests.Gameplay;
public sealed class RuntimeCommunicationStateTests
{
[Fact]
public void SocialViewBorrowsFriendsAndSquelchOwners()
{
using var state = new RuntimeCommunicationState();
state.Friends.Apply(new AcDream.Core.Social.FriendsUpdate(
AcDream.Core.Social.FriendsUpdateType.Full,
[
new AcDream.Core.Social.FriendEntry(
0x50000001u,
"Friend",
Online: true,
AppearOffline: false,
Friends: [],
FriendOf: []),
]));
state.Squelch.Replace(new AcDream.Core.Social.SquelchDatabase(
new Dictionary<string, uint>(StringComparer.OrdinalIgnoreCase)
{
["account"] = 1u,
},
new Dictionary<uint, AcDream.Core.Social.SquelchInfo>(),
new AcDream.Core.Social.SquelchInfo(
string.Empty,
false,
new HashSet<uint> { 3u })));
RuntimeSocialSnapshot snapshot = state.SocialView.Snapshot;
Assert.Equal(1, snapshot.FriendCount);
Assert.Equal(1, snapshot.SquelchedAccountCount);
Assert.Equal(1, snapshot.GlobalSquelchTypeCount);
Assert.True(state.SocialView.TryGetFriend(
0x50000001u,
out RuntimeFriendSnapshot friend));
Assert.Equal("Friend", friend.Name);
Assert.True(friend.Online);
}
[Fact]
public void OwnsOneExactCommunicationGraphAndPublishesCommittedEntries()
{

View file

@ -6,6 +6,44 @@ namespace AcDream.Runtime.Tests.Gameplay;
public sealed class RuntimeInventoryStateTests
{
[Fact]
public void ViewBorrowsTransactionShortcutAndManaOwners()
{
using var entities = new RuntimeEntityObjectLifetime();
using var inventory = new RuntimeInventoryState(entities);
inventory.Shortcuts.Replace(
[
new AcDream.Core.Items.ShortcutEntry(3, 0x80000001u, 0u),
new AcDream.Core.Items.ShortcutEntry(4, 0u, 42u),
]);
inventory.ItemMana.OnQueryItemManaResponse(
0x80000001u,
0.75f,
valid: true);
inventory.Transactions.IncrementBusyCount();
RuntimeInventoryStateSnapshot snapshot = inventory.View.Snapshot;
Assert.Equal(2, snapshot.ShortcutCount);
Assert.Equal(1, snapshot.ItemManaCount);
Assert.Equal(1, snapshot.BusyCount);
Assert.False(snapshot.CanBeginRequest);
Assert.True(inventory.View.TryGetShortcut(
3,
out RuntimeShortcutSnapshot itemShortcut));
Assert.Equal(
new RuntimeShortcutSnapshot(3, 0x80000001u, 0u),
itemShortcut);
Assert.True(inventory.View.TryGetShortcut(
4,
out RuntimeShortcutSnapshot spellShortcut));
Assert.Equal(new RuntimeShortcutSnapshot(4, 0u, 42u), spellShortcut);
Assert.True(inventory.View.TryGetItemMana(
itemShortcut.ObjectId,
out float fraction));
Assert.Equal(0.75f, fraction);
}
[Fact]
public void OwnsOneGameplayGraphOverExactEntityObjectTable()
{

View file

@ -242,7 +242,6 @@ public sealed class LiveSessionEventRouterTests
OnSkillsUpdated: null,
OnConfirmationRequest: null,
OnConfirmationDone: null,
OnCharacterOptions: null,
ClientTime: () => 0d);
private static LiveSocialSessionBindings NewSocialBindings(