Move the canonical WorldSession generation, connect/enter/tick/stop transaction, inbound subscription owner, and retryable teardown acknowledgements into AcDream.Runtime. Keep App as a borrowing graphical host with a single inertable command projection and no mirrored session state. Validated by 79 Runtime tests, 3,776 App tests with three existing skips, the Release solution build, and 8,428 complete Release tests with five existing skips. Co-authored-by: Codex <codex@openai.com>
621 lines
20 KiB
C#
621 lines
20 KiB
C#
using System.Net;
|
|
using System.Numerics;
|
|
using AcDream.App.Combat;
|
|
using AcDream.App.Input;
|
|
using AcDream.App.Interaction;
|
|
using AcDream.App.Net;
|
|
using AcDream.App.Runtime;
|
|
using AcDream.App.Streaming;
|
|
using AcDream.App.UI;
|
|
using AcDream.App.Update;
|
|
using AcDream.App.World;
|
|
using AcDream.Core.Chat;
|
|
using AcDream.Core.Items;
|
|
using AcDream.Core.Net;
|
|
using AcDream.Core.Net.Messages;
|
|
using AcDream.Core.Physics;
|
|
using AcDream.Core.Selection;
|
|
using AcDream.Core.World;
|
|
using AcDream.Runtime;
|
|
using AcDream.Runtime.Session;
|
|
using AcDream.UI.Abstractions;
|
|
using AcDream.UI.Abstractions.Input;
|
|
|
|
namespace AcDream.App.Tests.Runtime;
|
|
|
|
public sealed class CurrentGameRuntimeAdapterTests
|
|
{
|
|
[Fact]
|
|
public void AdapterBorrowsCurrentOwnersAndCommandsExecuteAtPressTime()
|
|
{
|
|
using var harness = new Harness();
|
|
var trace = new RuntimeTraceRecorder();
|
|
using IDisposable subscription = harness.Runtime.Subscribe(trace);
|
|
RuntimeGenerationToken initial = harness.Runtime.Generation;
|
|
|
|
RuntimeSessionStartResult start = harness.Runtime.Session.Start(initial);
|
|
|
|
Assert.Equal(RuntimeSessionStartStatus.Connected, start.Status);
|
|
Assert.Equal(new RuntimeGenerationToken(1), start.Generation);
|
|
Assert.Equal(Harness.PlayerGuid, harness.Identity.ServerGuid);
|
|
Assert.Equal(RuntimeLifecycleState.InWorld, harness.Runtime.Lifecycle.State);
|
|
|
|
LiveEntityRegistrationResult registration =
|
|
harness.Entities.RegisterLiveEntity(Spawn(
|
|
Harness.TargetGuid,
|
|
instance: 3,
|
|
cell: 0x12340001u));
|
|
Assert.NotNull(registration.Record);
|
|
|
|
var item = new ClientObject
|
|
{
|
|
ObjectId = Harness.TargetGuid,
|
|
Name = "Runtime fixture",
|
|
StackSize = 4,
|
|
Value = 25,
|
|
ContainerId = Harness.PlayerGuid,
|
|
ContainerSlot = 2,
|
|
};
|
|
harness.Objects.AddOrUpdate(item);
|
|
harness.Chat.OnSystemMessage("runtime parity", 0x1Au);
|
|
harness.WorldReveal.Begin(WorldRevealKind.Portal, 0x12340001u);
|
|
WorldRevealReadinessSnapshot readiness =
|
|
harness.WorldReveal.PrepareAndEvaluate(0x12340001u);
|
|
Assert.True(readiness.IsReady);
|
|
harness.Clock.Advance(new UpdateFrameInput(0.125));
|
|
|
|
RuntimeGenerationToken generation = harness.Runtime.Generation;
|
|
RuntimeCommandResult selection = harness.Runtime.Selection.Execute(
|
|
generation,
|
|
RuntimeSelectionCommand.SelectClosestHostile);
|
|
RuntimeCommandResult movement =
|
|
((IGameRuntimeCommands)harness.Runtime).Movement.Execute(
|
|
generation,
|
|
RuntimeMovementCommand.ToggleRunLock);
|
|
RuntimeCommandResult combat = harness.Runtime.Combat.Execute(
|
|
generation,
|
|
RuntimeCombatCommand.ToggleMode);
|
|
RuntimeCommandResult chat =
|
|
((IGameRuntimeCommands)harness.Runtime).Chat.Execute(
|
|
generation,
|
|
new RuntimeChatCommand(
|
|
RuntimeChatChannel.General,
|
|
"hello runtime"));
|
|
RuntimeCommandResult portal =
|
|
((IGameRuntimeCommands)harness.Runtime).Portal.Execute(
|
|
generation,
|
|
RuntimePortalCommand.RecallLifestone);
|
|
|
|
Assert.True(selection.Accepted);
|
|
Assert.True(movement.Accepted);
|
|
Assert.True(combat.Accepted);
|
|
Assert.True(chat.Accepted);
|
|
Assert.True(portal.Accepted);
|
|
Assert.Equal(Harness.TargetGuid, harness.Selection.SelectedObjectId);
|
|
Assert.True(harness.MovementInput.AutoRunActive);
|
|
Assert.Equal(1, harness.Combat.ToggleCount);
|
|
Assert.Contains(
|
|
harness.Commands.Published,
|
|
static command => command is SendChatCmd
|
|
{
|
|
Channel: ChatChannelKind.General,
|
|
Text: "hello runtime",
|
|
});
|
|
Assert.Contains(
|
|
harness.Commands.Published,
|
|
static command => command is ExecuteClientCommandCmd
|
|
{
|
|
Command: ClientCommandId.LifestoneRecall,
|
|
});
|
|
|
|
RuntimeStateCheckpoint checkpoint = harness.Runtime.CaptureCheckpoint();
|
|
Assert.Equal(1, checkpoint.EntityCount);
|
|
Assert.Equal(1, checkpoint.InventoryObjectCount);
|
|
Assert.Equal(1, checkpoint.ChatCount);
|
|
Assert.Equal(1L, checkpoint.ChatRevision);
|
|
Assert.Equal(1UL, checkpoint.FrameNumber);
|
|
Assert.Equal(RuntimePortalKind.Portal, checkpoint.Portal.Kind);
|
|
Assert.Equal(0x12340001u, checkpoint.Portal.DestinationCell);
|
|
Assert.True(checkpoint.Portal.IsReady);
|
|
|
|
Assert.True(harness.Runtime.Entities.TryGet(
|
|
Harness.TargetGuid,
|
|
out RuntimeEntitySnapshot entity));
|
|
Assert.Equal((ushort)3, entity.Identity.Incarnation);
|
|
Assert.True(harness.Runtime.Inventory.TryGet(
|
|
Harness.TargetGuid,
|
|
out RuntimeInventoryItemSnapshot inventory));
|
|
Assert.Equal((ushort)3, inventory.Incarnation);
|
|
Assert.Equal(4, inventory.StackSize);
|
|
|
|
RuntimeTraceKind[] kinds = trace.Entries
|
|
.Select(static entry => entry.Kind)
|
|
.ToArray();
|
|
Assert.Equal(RuntimeTraceKind.Command, kinds[0]);
|
|
Assert.Equal(RuntimeTraceKind.Lifecycle, kinds[1]);
|
|
Assert.Contains(RuntimeTraceKind.Inventory, kinds);
|
|
Assert.Contains(RuntimeTraceKind.Chat, kinds);
|
|
Assert.Equal(
|
|
[
|
|
RuntimeCommandDomain.Session,
|
|
RuntimeCommandDomain.Selection,
|
|
RuntimeCommandDomain.Movement,
|
|
RuntimeCommandDomain.Combat,
|
|
RuntimeCommandDomain.Chat,
|
|
RuntimeCommandDomain.Portal,
|
|
],
|
|
trace.Entries
|
|
.Where(static entry => entry.Kind == RuntimeTraceKind.Command)
|
|
.Select(static entry =>
|
|
(RuntimeCommandDomain)(entry.Code >> 16))
|
|
.ToArray());
|
|
}
|
|
|
|
[Fact]
|
|
public void GenerationGateRejectsStaleCommandsAndStopAcknowledgesTeardown()
|
|
{
|
|
using var harness = new Harness();
|
|
RuntimeSessionStartResult start =
|
|
harness.Runtime.Session.Start(harness.Runtime.Generation);
|
|
Assert.Equal(RuntimeSessionStartStatus.Connected, start.Status);
|
|
RuntimeGenerationToken active = harness.Runtime.Generation;
|
|
|
|
RuntimeTeardownAcknowledgement stopped =
|
|
harness.Runtime.Session.Stop(active);
|
|
RuntimeCommandResult stale = harness.Runtime.Combat.Execute(
|
|
active,
|
|
RuntimeCombatCommand.ToggleMode);
|
|
|
|
Assert.True(stopped.IsComplete);
|
|
Assert.Equal(active, stopped.RetiredGeneration);
|
|
Assert.Equal(new RuntimeGenerationToken(2), stopped.CurrentGeneration);
|
|
Assert.Equal(RuntimeCommandStatus.StaleGeneration, stale.Status);
|
|
Assert.Equal(0, harness.Combat.ToggleCount);
|
|
Assert.Equal(RuntimeLifecycleState.Stopped, harness.Runtime.Lifecycle.State);
|
|
}
|
|
|
|
private sealed class Harness : IDisposable
|
|
{
|
|
public const uint PlayerGuid = 0x50000002u;
|
|
public const uint TargetGuid = 0x70000001u;
|
|
|
|
private readonly ItemInteractionController _items;
|
|
private readonly LiveSessionController _session;
|
|
|
|
public Harness()
|
|
{
|
|
Identity = new LocalPlayerIdentityState();
|
|
Entities = new LiveEntityRuntime(
|
|
new GpuWorldState(),
|
|
new NoopEntityResources());
|
|
Objects = new ClientObjectTable();
|
|
Chat = new ChatLog();
|
|
Selection = new SelectionState();
|
|
MovementInput = new DispatcherMovementInputSource();
|
|
GameplayInput = new GameplayInputFrameController(
|
|
dispatcher: null,
|
|
MovementInput,
|
|
mouseLook: null,
|
|
new NoopCombatInput());
|
|
Combat = new RecordingCombatCommand();
|
|
Clock = new UpdateFrameClock();
|
|
WorldReveal = new WorldRevealCoordinator(
|
|
static (_, _) => true,
|
|
static _ => true,
|
|
static (_, _) => true,
|
|
static () => true,
|
|
static (_, _) => { },
|
|
static () => { },
|
|
static _ => false);
|
|
|
|
_items = new ItemInteractionController(
|
|
Objects,
|
|
() => PlayerGuid,
|
|
sendUse: null,
|
|
sendUseWithTarget: null,
|
|
sendWield: null,
|
|
sendDrop: null);
|
|
var query = new SelectionQuery(TargetGuid);
|
|
var selectionController = new SelectionInteractionController(
|
|
Selection,
|
|
query,
|
|
_items,
|
|
new SelectionTransport(() => _session?.IsInWorld == true),
|
|
new NoopInteractionMovement());
|
|
|
|
Options = LiveOptions();
|
|
Commands = new RecordingCommandRouting();
|
|
_session = new LiveSessionController(new SessionOperations());
|
|
Host = CreateHost(_session, Commands, Identity);
|
|
Runtime = new CurrentGameRuntimeAdapter(
|
|
_session,
|
|
Host,
|
|
Commands,
|
|
Identity,
|
|
Entities,
|
|
Objects,
|
|
Chat,
|
|
new LocalPlayerControllerSlot(),
|
|
WorldReveal,
|
|
Clock,
|
|
Selection,
|
|
selectionController,
|
|
GameplayInput,
|
|
Combat);
|
|
}
|
|
|
|
public RuntimeOptions Options { get; }
|
|
public LocalPlayerIdentityState Identity { get; }
|
|
public LiveEntityRuntime Entities { get; }
|
|
public ClientObjectTable Objects { get; }
|
|
public ChatLog Chat { get; }
|
|
public SelectionState Selection { get; }
|
|
public DispatcherMovementInputSource MovementInput { get; }
|
|
public GameplayInputFrameController GameplayInput { get; }
|
|
public RecordingCombatCommand Combat { get; }
|
|
public UpdateFrameClock Clock { get; }
|
|
public WorldRevealCoordinator WorldReveal { get; }
|
|
public RecordingCommandRouting Commands { get; }
|
|
public LiveSessionHost Host { get; }
|
|
public CurrentGameRuntimeAdapter Runtime { get; }
|
|
|
|
public void Dispose()
|
|
{
|
|
Runtime.Dispose();
|
|
_session.Dispose();
|
|
_items.Dispose();
|
|
Entities.Clear();
|
|
}
|
|
}
|
|
|
|
private static LiveSessionHost CreateHost(
|
|
LiveSessionController controller,
|
|
RecordingCommandRouting commands,
|
|
LocalPlayerIdentityState identity)
|
|
{
|
|
Action noop = static () => { };
|
|
var reset = new LiveSessionResetBindings
|
|
{
|
|
MouseCapture = noop,
|
|
PlayerPresentation = noop,
|
|
TeleportTransit = noop,
|
|
SessionDialogs = noop,
|
|
ChatCommandTargets = noop,
|
|
SettingsCharacterContext = noop,
|
|
EquippedChildren = noop,
|
|
ExternalContainer = noop,
|
|
InteractionAndSelection = noop,
|
|
SelectionPresentation = noop,
|
|
ObjectTable = noop,
|
|
Spellbook = noop,
|
|
MagicRuntime = noop,
|
|
CombatAttack = noop,
|
|
CombatState = noop,
|
|
ItemMana = noop,
|
|
LocalPlayer = noop,
|
|
Friends = noop,
|
|
Squelch = noop,
|
|
TurbineChat = noop,
|
|
ParticleVisibility = noop,
|
|
InboundEventFifo = noop,
|
|
LiveLiveness = noop,
|
|
LiveRuntime = noop,
|
|
RenderSceneProjection = noop,
|
|
SessionIdentity = noop,
|
|
RemoteTeleport = noop,
|
|
NetworkEffects = noop,
|
|
AnimationHookFrames = noop,
|
|
LivePresentation = noop,
|
|
RemoteMovementDiagnostics = noop,
|
|
};
|
|
return new LiveSessionHost(
|
|
controller,
|
|
new LiveSessionHostBindings(
|
|
new LiveSessionRoutingFactories(
|
|
_ => new NoopEventRouting(),
|
|
_ => commands),
|
|
LiveSessionResetManifest.Create(reset).Execute,
|
|
new LiveSessionSelectionBindings(
|
|
id => identity.ServerGuid = id,
|
|
_ => { },
|
|
_ => { },
|
|
_ => { },
|
|
_ => { },
|
|
() => { }),
|
|
new LiveSessionEnteredWorldBindings(
|
|
_ => { },
|
|
() => { },
|
|
() => { },
|
|
_ => { },
|
|
() => { }),
|
|
(_, _, _) => { },
|
|
() => { }),
|
|
new LiveSessionConnectOptions(
|
|
true,
|
|
"127.0.0.1",
|
|
9000,
|
|
"user",
|
|
"password"));
|
|
}
|
|
|
|
private static RuntimeOptions LiveOptions()
|
|
{
|
|
var environment = new Dictionary<string, string?>
|
|
{
|
|
["ACDREAM_LIVE"] = "1",
|
|
["ACDREAM_TEST_HOST"] = "127.0.0.1",
|
|
["ACDREAM_TEST_PORT"] = "9000",
|
|
["ACDREAM_TEST_USER"] = "user",
|
|
["ACDREAM_TEST_PASS"] = "password",
|
|
};
|
|
return RuntimeOptions.Parse("dat", environment.GetValueOrDefault);
|
|
}
|
|
|
|
private static WorldSession.EntitySpawn Spawn(
|
|
uint guid,
|
|
ushort instance,
|
|
uint cell)
|
|
{
|
|
var position = new CreateObject.ServerPosition(
|
|
cell,
|
|
10f,
|
|
10f,
|
|
5f,
|
|
1f,
|
|
0f,
|
|
0f,
|
|
0f);
|
|
var timestamps = new PhysicsTimestamps(
|
|
Position: 1,
|
|
Movement: 1,
|
|
State: 1,
|
|
Vector: 1,
|
|
Teleport: 0,
|
|
ServerControlledMove: 1,
|
|
ForcePosition: 0,
|
|
ObjDesc: 1,
|
|
Instance: instance);
|
|
var physics = new PhysicsSpawnData(
|
|
RawState: (uint)PhysicsStateFlags.ReportCollisions,
|
|
Position: position,
|
|
Movement: null,
|
|
AnimationFrame: null,
|
|
SetupTableId: 0x02000001u,
|
|
MotionTableId: 0x09000001u,
|
|
SoundTableId: null,
|
|
PhysicsScriptTableId: null,
|
|
Parent: null,
|
|
Children: null,
|
|
Scale: null,
|
|
Friction: null,
|
|
Elasticity: null,
|
|
Translucency: null,
|
|
Velocity: null,
|
|
Acceleration: null,
|
|
AngularVelocity: null,
|
|
DefaultScriptType: null,
|
|
DefaultScriptIntensity: null,
|
|
Timestamps: timestamps);
|
|
return new WorldSession.EntitySpawn(
|
|
guid,
|
|
position,
|
|
0x02000001u,
|
|
Array.Empty<CreateObject.AnimPartChange>(),
|
|
Array.Empty<CreateObject.TextureChange>(),
|
|
Array.Empty<CreateObject.SubPaletteSwap>(),
|
|
null,
|
|
null,
|
|
"runtime fixture",
|
|
null,
|
|
null,
|
|
0x09000001u,
|
|
PhysicsState: (uint)PhysicsStateFlags.ReportCollisions,
|
|
InstanceSequence: instance,
|
|
MovementSequence: 1,
|
|
ServerControlSequence: 1,
|
|
PositionSequence: 1,
|
|
Physics: physics);
|
|
}
|
|
|
|
private sealed class SessionOperations : ILiveSessionOperations
|
|
{
|
|
public IPEndPoint ResolveEndpoint(string host, int port) =>
|
|
new(IPAddress.Loopback, port);
|
|
|
|
public WorldSession CreateSession(IPEndPoint endpoint) =>
|
|
new(endpoint, new TestTransport());
|
|
|
|
public void Connect(WorldSession session, string user, string password)
|
|
{
|
|
}
|
|
|
|
public CharacterList.Parsed GetCharacters(WorldSession session) =>
|
|
new(
|
|
0u,
|
|
[
|
|
new CharacterList.Character(
|
|
0x50000001u,
|
|
"Unavailable",
|
|
30u),
|
|
new CharacterList.Character(
|
|
Harness.PlayerGuid,
|
|
"Runtime",
|
|
0u),
|
|
],
|
|
[],
|
|
11,
|
|
"Runtime",
|
|
true,
|
|
true);
|
|
|
|
public void EnterWorld(WorldSession session, int activeCharacterIndex)
|
|
{
|
|
}
|
|
|
|
public void Tick(WorldSession session)
|
|
{
|
|
}
|
|
|
|
public void DisposeSession(WorldSession session) => session.Dispose();
|
|
}
|
|
|
|
private sealed class TestTransport : IWorldSessionTransport
|
|
{
|
|
public void Send(ReadOnlySpan<byte> datagram)
|
|
{
|
|
}
|
|
|
|
public void Send(IPEndPoint remote, ReadOnlySpan<byte> datagram)
|
|
{
|
|
}
|
|
|
|
public int Receive(
|
|
Span<byte> destination,
|
|
TimeSpan timeout,
|
|
out IPEndPoint? from)
|
|
{
|
|
from = null;
|
|
return -1;
|
|
}
|
|
|
|
public ValueTask<NetReceiveResult> ReceiveAsync(
|
|
Memory<byte> destination,
|
|
CancellationToken cancellationToken) =>
|
|
throw new OperationCanceledException(cancellationToken);
|
|
|
|
public void Dispose()
|
|
{
|
|
}
|
|
}
|
|
|
|
private sealed class NoopEventRouting : ILiveSessionEventRouting
|
|
{
|
|
public void Attach()
|
|
{
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
}
|
|
}
|
|
|
|
internal sealed class RecordingCommandRouting
|
|
: ILiveSessionCommandRouting,
|
|
ICommandBus
|
|
{
|
|
private bool _active;
|
|
|
|
public List<object> Published { get; } = [];
|
|
|
|
public void Activate() => _active = true;
|
|
|
|
public void Publish<T>(T command) where T : notnull
|
|
{
|
|
if (_active)
|
|
Published.Add(command);
|
|
}
|
|
|
|
public void Dispose() => _active = false;
|
|
}
|
|
|
|
internal sealed class RecordingCombatCommand : ILiveCombatModeCommand
|
|
{
|
|
public int ToggleCount { get; private set; }
|
|
public void Toggle() => ToggleCount++;
|
|
}
|
|
|
|
private sealed class NoopCombatInput : ICombatInputFrameController
|
|
{
|
|
public void Tick()
|
|
{
|
|
}
|
|
|
|
public void HandleMovementInput(
|
|
InputAction action,
|
|
ActivationType activation)
|
|
{
|
|
}
|
|
|
|
public bool HandleInputAction(
|
|
InputAction action,
|
|
ActivationType activation) => false;
|
|
}
|
|
|
|
private sealed class SelectionQuery(uint target) : IWorldSelectionQuery
|
|
{
|
|
public uint? PickAtCursor(bool includeSelf) => target;
|
|
public uint? PickAt(float mouseX, float mouseY, bool includeSelf) => target;
|
|
public void BeginLightingPulse(uint serverGuid)
|
|
{
|
|
}
|
|
|
|
public bool TryCaptureIdentity(uint serverGuid, out uint localEntityId)
|
|
{
|
|
localEntityId = 1u;
|
|
return serverGuid == target;
|
|
}
|
|
|
|
public bool IsCurrent(uint serverGuid, uint localEntityId) =>
|
|
serverGuid == target && localEntityId == 1u;
|
|
|
|
public string Describe(uint serverGuid) => "Runtime target";
|
|
public bool IsCreature(uint serverGuid) => serverGuid == target;
|
|
public bool IsHostileMonster(uint serverGuid) => serverGuid == target;
|
|
public ClosestCombatTarget? FindClosestHostileMonster() =>
|
|
new(target, DistanceSquared: 4f);
|
|
public bool IsUseable(uint serverGuid) => serverGuid == target;
|
|
public bool IsPickupable(uint serverGuid) => false;
|
|
|
|
public bool TryGetApproach(
|
|
uint serverGuid,
|
|
out InteractionApproach approach)
|
|
{
|
|
approach = default;
|
|
return false;
|
|
}
|
|
|
|
public Vector3? GetCombatCameraTargetPoint(uint serverGuid) => null;
|
|
}
|
|
|
|
private sealed class SelectionTransport(Func<bool> isInWorld)
|
|
: ISelectionInteractionTransport
|
|
{
|
|
public bool IsInWorld => isInWorld();
|
|
|
|
public bool TrySendUse(uint serverGuid, out uint sequence)
|
|
{
|
|
sequence = 1u;
|
|
return IsInWorld;
|
|
}
|
|
|
|
public bool TrySendPickup(
|
|
uint itemGuid,
|
|
uint destinationContainerId,
|
|
int placement,
|
|
out uint sequence)
|
|
{
|
|
sequence = 1u;
|
|
return IsInWorld;
|
|
}
|
|
}
|
|
|
|
private sealed class NoopInteractionMovement
|
|
: IPlayerInteractionMovementSink
|
|
{
|
|
public bool BeginApproach(
|
|
InteractionApproach approach,
|
|
Action<PlayerApproachToken>? armAfterCancel = null) => false;
|
|
}
|
|
|
|
private sealed class NoopEntityResources
|
|
: ILiveEntityResourceLifecycle
|
|
{
|
|
public void Register(WorldEntity entity)
|
|
{
|
|
}
|
|
|
|
public void Unregister(WorldEntity entity)
|
|
{
|
|
}
|
|
}
|
|
}
|