feat(runtime): define borrowed views commands and ordered events

Establish the J1 presentation-independent contract with instance-scoped clocks and generations, immutable borrowed views, typed generation-gated commands, normalized ordered diagnostics, and teardown acknowledgements. Route graphical startup plus press-time selection, movement, and combat through focused App adapters over the exact existing owners without adding a queue or mirrored world.

Validated by the Release solution build, 13 Runtime tests, 3,838 App tests with three existing skips, and the complete 8,424-test Release suite with five existing skips.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-25 19:08:42 +02:00
parent afebbe3eca
commit 854d9e9cd1
21 changed files with 2594 additions and 44 deletions

View file

@ -3,6 +3,7 @@ using AcDream.App.Input;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Vfx;
using AcDream.App.Runtime;
using AcDream.App.Settings;
using AcDream.App.Update;
using AcDream.App.World;
@ -58,7 +59,8 @@ internal sealed record FrameRootResult(
RenderFrameOrchestrator Render,
FrameRootRuntimeBindings RuntimeBindings,
IDisposable FrameGraphPublication,
AcDream.App.Net.LiveSessionHost SessionHost);
AcDream.App.Net.LiveSessionHost SessionHost,
CurrentGameRuntimeAdapter GameRuntime);
internal interface IGameWindowFrameRootPublication
{
@ -566,7 +568,8 @@ internal sealed class FrameRootCompositionPhase
renderFrame,
bindings,
frameGraphPublication,
session.SessionHost);
session.SessionHost,
session.GameRuntime);
_publication.PublishFrameRoots(result);
graphLease.Transfer();
bindingsLease.Transfer();

View file

@ -8,6 +8,7 @@ using AcDream.App.Rendering;
using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Vfx;
using AcDream.App.Rendering.Wb;
using AcDream.App.Runtime;
using AcDream.App.Settings;
using AcDream.App.Streaming;
using AcDream.App.Update;
@ -115,6 +116,7 @@ internal sealed record SessionPlayerResult(
PlayerModeAutoEntry PlayerModeAutoEntry,
LocalPlayerTeleportController LocalTeleport,
LiveSessionHost SessionHost,
CurrentGameRuntimeAdapter GameRuntime,
GameplayInputActionRouter? GameplayActions,
SessionPlayerRuntimeBindings RuntimeBindings);
@ -864,6 +866,22 @@ internal sealed class SessionPlayerCompositionPhase
bindings.Adopt(
"live combat-mode commands",
d.CombatModeCommands.BindOwned(combatCommand));
var gameRuntime = new CurrentGameRuntimeAdapter(
d.Options,
liveSession,
sessionHost,
d.PlayerIdentity,
live.LiveEntities,
d.Objects,
d.Chat,
d.PlayerController,
worldReveal,
d.UpdateClock,
d.Selection,
live.SelectionInteractions,
gameplayInput,
combatCommand);
bindings.Adopt("current game runtime adapter", gameRuntime);
var nearbyDiagnostics = new NearbyWorldDiagnosticDumper(
new RuntimeNearbyWorldDiagnosticSource(
@ -903,13 +921,17 @@ internal sealed class SessionPlayerCompositionPhase
playerMode),
new ItemTargetModeCommands(interaction.ItemInteraction),
new GameplayCameraModeCommands(host.CameraController),
combatCommand,
gameRuntime,
gameRuntime.Combat,
new GameplayWindowCommands(d.Window.Close));
var targets = new RuntimeGameplayInputPriorityTargets(
gameplayInput,
pointer,
interaction.RetainedUi?.Runtime,
live.SelectionInteractions,
gameRuntime,
gameRuntime.Selection,
gameRuntime.MovementCommands,
commands);
GameplayInputActionRouter gameplayActions =
GameplayInputActionRouter.Create(
@ -954,6 +976,7 @@ internal sealed class SessionPlayerCompositionPhase
playerModeAutoEntry,
teleportLease.Resource,
sessionHost,
gameRuntime,
gameplayActionsLease?.Resource,
bindings);
_publication.PublishSessionPlayer(result);

View file

@ -1,9 +1,8 @@
using AcDream.App.Net;
using AcDream.Runtime;
namespace AcDream.App.Composition;
internal sealed record SessionStartDependencies(
RuntimeOptions Options,
Action<string> Log);
/// <summary>
@ -22,24 +21,23 @@ internal sealed class SessionStartCompositionPhase
public void Start(FrameRootResult frame)
{
ArgumentNullException.ThrowIfNull(frame);
LiveSessionStartResult result =
frame.SessionHost.Start(_dependencies.Options);
RuntimeSessionStartResult result =
frame.GameRuntime.Session.Start(frame.GameRuntime.Generation);
Report(result, _dependencies.Log);
}
internal static void Report(
LiveSessionStartResult result,
RuntimeSessionStartResult result,
Action<string> log)
{
ArgumentNullException.ThrowIfNull(result);
ArgumentNullException.ThrowIfNull(log);
switch (result.Status)
{
case LiveSessionStartStatus.MissingCredentials:
case RuntimeSessionStartStatus.MissingCredentials:
log(
"live: ACDREAM_LIVE set but TEST_USER/TEST_PASS missing; skipping");
break;
case LiveSessionStartStatus.Failed:
case RuntimeSessionStartStatus.Failed:
log($"live: session failed: {result.Error}");
break;
}

View file

@ -2,6 +2,7 @@ using AcDream.App.Interaction;
using AcDream.App.Rendering;
using AcDream.App.UI;
using AcDream.Core.Combat;
using AcDream.Runtime;
using AcDream.UI.Abstractions.Input;
namespace AcDream.App.Input;
@ -83,6 +84,9 @@ internal sealed class RuntimeGameplayInputPriorityTargets
private readonly CameraPointerInputController _pointer;
private readonly RetailUiRuntime? _retainedUi;
private readonly SelectionInteractionController? _selection;
private readonly IGameRuntimeView _runtimeView;
private readonly IRuntimeSelectionCommands _runtimeSelection;
private readonly IRuntimeMovementCommands _runtimeMovement;
private readonly IGameplayInputCommandTarget _commands;
public RuntimeGameplayInputPriorityTargets(
@ -90,12 +94,21 @@ internal sealed class RuntimeGameplayInputPriorityTargets
CameraPointerInputController pointer,
RetailUiRuntime? retainedUi,
SelectionInteractionController? selection,
IGameRuntimeView runtimeView,
IRuntimeSelectionCommands runtimeSelection,
IRuntimeMovementCommands runtimeMovement,
IGameplayInputCommandTarget commands)
{
_frame = frame ?? throw new ArgumentNullException(nameof(frame));
_pointer = pointer ?? throw new ArgumentNullException(nameof(pointer));
_retainedUi = retainedUi;
_selection = selection;
_runtimeView = runtimeView
?? throw new ArgumentNullException(nameof(runtimeView));
_runtimeSelection = runtimeSelection
?? throw new ArgumentNullException(nameof(runtimeSelection));
_runtimeMovement = runtimeMovement
?? throw new ArgumentNullException(nameof(runtimeMovement));
_commands = commands ?? throw new ArgumentNullException(nameof(commands));
}
@ -111,11 +124,48 @@ internal sealed class RuntimeGameplayInputPriorityTargets
public bool HandleRetainedUiAction(InputAction action) =>
_retainedUi?.HandleInputAction(action) == true;
public bool HandleSelectionAction(InputAction action) =>
_selection?.HandleInputAction(action) == true;
public bool HandleSelectionAction(InputAction action)
{
RuntimeSelectionCommand? command = action switch
{
InputAction.SelectionClosestMonster =>
RuntimeSelectionCommand.SelectClosestHostile,
InputAction.SelectionPreviousSelection =>
RuntimeSelectionCommand.SelectPrevious,
InputAction.SelectionExamine =>
RuntimeSelectionCommand.ExamineSelected,
InputAction.UseSelected =>
RuntimeSelectionCommand.UseSelected,
InputAction.SelectionPickUp =>
RuntimeSelectionCommand.PickUpSelected,
_ => null,
};
if (command is { } typed)
{
_runtimeSelection.Execute(_runtimeView.Generation, typed);
return true;
}
public bool HandlePressedMovementAction(InputAction action) =>
_frame.HandlePressedMovementAction(action);
return _selection?.HandleInputAction(action) == true;
}
public bool HandlePressedMovementAction(InputAction action)
{
RuntimeMovementCommand? command = action switch
{
InputAction.MovementRunLock =>
RuntimeMovementCommand.ToggleRunLock,
InputAction.MovementStop => RuntimeMovementCommand.Stop,
_ => null,
};
if (command is { } typed)
{
_runtimeMovement.Execute(_runtimeView.Generation, typed);
return true;
}
return _frame.HandlePressedMovementAction(action);
}
public void HandleCommand(InputAction action) =>
_commands.Handle(action);

View file

@ -2,6 +2,7 @@ using AcDream.App.Combat;
using AcDream.App.Diagnostics;
using AcDream.App.Rendering;
using AcDream.App.UI;
using AcDream.Runtime;
using AcDream.UI.Abstractions.Input;
namespace AcDream.App.Input;
@ -137,7 +138,8 @@ internal sealed class GameplayInputCommandController : IGameplayInputCommandTarg
private readonly IPlayerModeGameplayCommands _playerMode;
private readonly IItemTargetModeCommands _targetMode;
private readonly IGameplayCameraModeCommands _camera;
private readonly ILiveCombatModeCommand _combat;
private readonly IGameRuntimeView _runtimeView;
private readonly IRuntimeCombatCommands _combat;
private readonly IGameplayWindowCommands _window;
public GameplayInputCommandController(
@ -147,7 +149,8 @@ internal sealed class GameplayInputCommandController : IGameplayInputCommandTarg
IPlayerModeGameplayCommands playerMode,
IItemTargetModeCommands targetMode,
IGameplayCameraModeCommands camera,
ILiveCombatModeCommand combat,
IGameRuntimeView runtimeView,
IRuntimeCombatCommands combat,
IGameplayWindowCommands window)
{
_retained = retained ?? throw new ArgumentNullException(nameof(retained));
@ -156,6 +159,8 @@ internal sealed class GameplayInputCommandController : IGameplayInputCommandTarg
_playerMode = playerMode ?? throw new ArgumentNullException(nameof(playerMode));
_targetMode = targetMode ?? throw new ArgumentNullException(nameof(targetMode));
_camera = camera ?? throw new ArgumentNullException(nameof(camera));
_runtimeView = runtimeView
?? throw new ArgumentNullException(nameof(runtimeView));
_combat = combat ?? throw new ArgumentNullException(nameof(combat));
_window = window ?? throw new ArgumentNullException(nameof(window));
}
@ -186,7 +191,9 @@ internal sealed class GameplayInputCommandController : IGameplayInputCommandTarg
_devTools.ToggleSettingsPanel();
return true;
case InputAction.CombatToggleCombat:
_combat.Toggle();
_combat.Execute(
_runtimeView.Generation,
RuntimeCombatCommand.ToggleMode);
return true;
case InputAction.EscapeKey:
HandleEscape();

View file

@ -1483,7 +1483,7 @@ public sealed class GameWindow :
livePresentation,
sessionPlayer),
frameRoots => new SessionStartCompositionPhase(
new SessionStartDependencies(_options, Console.WriteLine))
new SessionStartDependencies(Console.WriteLine))
.Start(frameRoots));
}

View file

@ -0,0 +1,106 @@
using AcDream.App.Combat;
using AcDream.App.Input;
using AcDream.App.Interaction;
using AcDream.App.Net;
using AcDream.App.Streaming;
using AcDream.App.World;
using AcDream.Core.Chat;
using AcDream.Core.Items;
using AcDream.Core.Selection;
using AcDream.Runtime;
namespace AcDream.App.Runtime;
/// <summary>
/// App composition root for Slice J's borrowed runtime boundary. Focused
/// adapters project the existing canonical owners; this root owns only those
/// adapters and never owns or copies gameplay state.
/// </summary>
internal sealed class CurrentGameRuntimeAdapter
: IGameRuntimeView,
IGameRuntimeCommands,
IRuntimeEventSource,
IDisposable
{
private readonly CurrentGameRuntimeViewAdapter _view;
private readonly CurrentGameRuntimeEventAdapter _events;
private readonly CurrentGameRuntimeCommandAdapter _commands;
private bool _disposed;
public CurrentGameRuntimeAdapter(
RuntimeOptions options,
LiveSessionController session,
LiveSessionHost sessionHost,
LocalPlayerIdentityState playerIdentity,
LiveEntityRuntime entities,
ClientObjectTable objects,
ChatLog chat,
ILocalPlayerControllerSource playerController,
WorldRevealCoordinator worldReveal,
IGameRuntimeClock clock,
SelectionState selectionState,
SelectionInteractionController selection,
GameplayInputFrameController gameplayInput,
ILiveCombatModeCommand combat)
{
_view = new CurrentGameRuntimeViewAdapter(
session,
playerIdentity,
entities,
objects,
chat,
playerController,
worldReveal,
clock);
_events = new CurrentGameRuntimeEventAdapter(
_view,
entities,
objects,
chat,
clock);
_commands = new CurrentGameRuntimeCommandAdapter(
options,
session,
sessionHost,
_view,
selectionState,
selection,
gameplayInput,
combat,
_events);
}
public RuntimeGenerationToken Generation => _view.Generation;
public RuntimeLifecycleSnapshot Lifecycle => _view.Lifecycle;
public IGameRuntimeClock Clock => _view.Clock;
public IRuntimeEntityView Entities => _view.Entities;
public IRuntimeInventoryView Inventory => _view.Inventory;
public IRuntimeChatView Chat => _view.Chat;
public IRuntimeMovementView Movement => _view.Movement;
public IRuntimePortalView Portal => _view.Portal;
public IRuntimeSessionCommands Session => _commands;
public IRuntimeSelectionCommands Selection => _commands;
public IRuntimeCombatCommands Combat => _commands;
public IRuntimeMovementCommands MovementCommands => _commands;
IRuntimeMovementCommands IGameRuntimeCommands.Movement => _commands;
public IRuntimeChatCommands ChatCommands => _commands;
IRuntimeChatCommands IGameRuntimeCommands.Chat => _commands;
public IRuntimePortalCommands PortalCommands => _commands;
IRuntimePortalCommands IGameRuntimeCommands.Portal => _commands;
public RuntimeStateCheckpoint CaptureCheckpoint() =>
_view.CaptureCheckpoint();
public IDisposable Subscribe(IRuntimeEventObserver observer) =>
_events.Subscribe(observer);
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
_view.Deactivate();
_events.Dispose();
}
}

View file

@ -0,0 +1,394 @@
using AcDream.App.Combat;
using AcDream.App.Input;
using AcDream.App.Interaction;
using AcDream.App.Net;
using AcDream.Core.Selection;
using AcDream.Runtime;
using AcDream.UI.Abstractions;
using AcDream.UI.Abstractions.Input;
namespace AcDream.App.Runtime;
/// <summary>
/// Generation-gated synchronous command projection onto the same current
/// owners used by graphical input. It introduces no queue or frame boundary.
/// </summary>
internal sealed class CurrentGameRuntimeCommandAdapter
: IRuntimeSessionCommands,
IRuntimeSelectionCommands,
IRuntimeCombatCommands,
IRuntimeMovementCommands,
IRuntimeChatCommands,
IRuntimePortalCommands
{
private readonly RuntimeOptions _options;
private readonly LiveSessionController _session;
private readonly LiveSessionHost _sessionHost;
private readonly CurrentGameRuntimeViewAdapter _view;
private readonly SelectionState _selectionState;
private readonly SelectionInteractionController _selection;
private readonly GameplayInputFrameController _gameplayInput;
private readonly ILiveCombatModeCommand _combat;
private readonly ICurrentGameRuntimeEventSink _events;
public CurrentGameRuntimeCommandAdapter(
RuntimeOptions options,
LiveSessionController session,
LiveSessionHost sessionHost,
CurrentGameRuntimeViewAdapter view,
SelectionState selectionState,
SelectionInteractionController selection,
GameplayInputFrameController gameplayInput,
ILiveCombatModeCommand combat,
ICurrentGameRuntimeEventSink events)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_session = session ?? throw new ArgumentNullException(nameof(session));
_sessionHost = sessionHost ?? throw new ArgumentNullException(nameof(sessionHost));
_view = view ?? throw new ArgumentNullException(nameof(view));
_selectionState = selectionState
?? throw new ArgumentNullException(nameof(selectionState));
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
_gameplayInput = gameplayInput
?? throw new ArgumentNullException(nameof(gameplayInput));
_combat = combat ?? throw new ArgumentNullException(nameof(combat));
_events = events ?? throw new ArgumentNullException(nameof(events));
}
public RuntimeSessionStartResult Start(
RuntimeGenerationToken expectedGeneration)
{
RuntimeLifecycleState previous = _view.Lifecycle.State;
RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: false);
if (gate != RuntimeCommandStatus.Accepted)
return RejectedStart(gate);
LiveSessionStartResult direct = _sessionHost.Start(_options);
RuntimeSessionStartResult result = Convert(direct);
_events.EmitCommand(
RuntimeCommandDomain.Session,
operation: 0,
ToCommandStatus(result.Status),
result.CharacterId,
result.CharacterName);
_events.EmitLifecycle(previous);
return result;
}
public RuntimeSessionStartResult Reconnect(
RuntimeGenerationToken expectedGeneration)
{
RuntimeLifecycleState previous = _view.Lifecycle.State;
RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: false);
if (gate != RuntimeCommandStatus.Accepted)
return RejectedStart(gate);
LiveSessionStartResult direct = _sessionHost.Reconnect(_options);
RuntimeSessionStartResult result = Convert(direct);
_events.EmitCommand(
RuntimeCommandDomain.Session,
operation: 1,
ToCommandStatus(result.Status),
result.CharacterId,
result.CharacterName);
_events.EmitLifecycle(previous);
return result;
}
public RuntimeTeardownAcknowledgement Stop(
RuntimeGenerationToken expectedGeneration)
{
RuntimeLifecycleState previous = _view.Lifecycle.State;
RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: false);
if (gate != RuntimeCommandStatus.Accepted)
{
return new RuntimeTeardownAcknowledgement(
expectedGeneration,
_view.Generation,
gate,
RuntimeTeardownStage.None);
}
try
{
_session.Stop();
RuntimeGenerationToken current = _view.Generation;
RuntimeTeardownStage completed =
_session.CurrentSession is null && !_session.IsInWorld
? RuntimeTeardownStage.Complete
: RuntimeTeardownStage.CommandsInert
| RuntimeTeardownStage.InboundDetached;
var acknowledgement = new RuntimeTeardownAcknowledgement(
expectedGeneration,
current,
RuntimeCommandStatus.Accepted,
completed);
_events.EmitCommand(
RuntimeCommandDomain.Session,
operation: 2,
RuntimeCommandStatus.Accepted);
_events.EmitLifecycle(previous);
return acknowledgement;
}
catch (Exception error)
{
var acknowledgement = new RuntimeTeardownAcknowledgement(
expectedGeneration,
_view.Generation,
RuntimeCommandStatus.Rejected,
RuntimeTeardownStage.None,
error);
_events.EmitCommand(
RuntimeCommandDomain.Session,
operation: 2,
RuntimeCommandStatus.Rejected,
text: error.GetType().Name);
_events.EmitLifecycle(previous);
return acknowledgement;
}
}
public RuntimeCommandResult Execute(
RuntimeGenerationToken expectedGeneration,
RuntimeSelectionCommand command)
{
RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true);
if (gate != RuntimeCommandStatus.Accepted)
return Result(gate);
InputAction action = command switch
{
RuntimeSelectionCommand.SelectClosestHostile =>
InputAction.SelectionClosestMonster,
RuntimeSelectionCommand.SelectPrevious =>
InputAction.SelectionPreviousSelection,
RuntimeSelectionCommand.ExamineSelected =>
InputAction.SelectionExamine,
RuntimeSelectionCommand.UseSelected =>
InputAction.UseSelected,
RuntimeSelectionCommand.PickUpSelected =>
InputAction.SelectionPickUp,
_ => InputAction.None,
};
RuntimeCommandStatus status = action != InputAction.None
&& _selection.HandleInputAction(action)
? RuntimeCommandStatus.Accepted
: RuntimeCommandStatus.Unsupported;
uint selected = _selectionState.SelectedObjectId ?? 0u;
_events.EmitCommand(
RuntimeCommandDomain.Selection,
(int)command,
status,
selected);
return Result(status, selected);
}
public RuntimeCommandResult Execute(
RuntimeGenerationToken expectedGeneration,
RuntimeCombatCommand command)
{
RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true);
if (gate != RuntimeCommandStatus.Accepted)
return Result(gate);
RuntimeCommandStatus status;
if (command == RuntimeCombatCommand.ToggleMode)
{
_combat.Toggle();
status = RuntimeCommandStatus.Accepted;
}
else
{
status = RuntimeCommandStatus.Unsupported;
}
_events.EmitCommand(RuntimeCommandDomain.Combat, (int)command, status);
return Result(status);
}
public RuntimeCommandResult Execute(
RuntimeGenerationToken expectedGeneration,
RuntimeMovementCommand command)
{
RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true);
if (gate != RuntimeCommandStatus.Accepted)
return Result(gate);
InputAction action = command switch
{
RuntimeMovementCommand.ToggleRunLock => InputAction.MovementRunLock,
RuntimeMovementCommand.Stop => InputAction.MovementStop,
_ => InputAction.None,
};
RuntimeCommandStatus status;
if (action == InputAction.None)
{
status = RuntimeCommandStatus.Unsupported;
}
else
{
_gameplayInput.HandlePressedMovementAction(action);
status = RuntimeCommandStatus.Accepted;
}
_events.EmitCommand(RuntimeCommandDomain.Movement, (int)command, status);
return Result(status);
}
public RuntimeCommandResult Execute(
RuntimeGenerationToken expectedGeneration,
in RuntimeChatCommand command)
{
RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true);
if (gate != RuntimeCommandStatus.Accepted)
return Result(gate);
if (!TryMap(command.Channel, out ChatChannelKind channel))
{
_events.EmitCommand(
RuntimeCommandDomain.Chat,
(int)command.Channel,
RuntimeCommandStatus.Unsupported);
return Result(RuntimeCommandStatus.Unsupported);
}
_session.Commands.Publish(new SendChatCmd(
channel,
command.TargetName,
command.Text));
_events.EmitCommand(
RuntimeCommandDomain.Chat,
(int)command.Channel,
RuntimeCommandStatus.Accepted,
text: command.Text);
return Result(RuntimeCommandStatus.Accepted);
}
public RuntimeCommandResult Execute(
RuntimeGenerationToken expectedGeneration,
RuntimePortalCommand command)
{
RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true);
if (gate != RuntimeCommandStatus.Accepted)
return Result(gate);
ClientCommandId? commandId = command switch
{
RuntimePortalCommand.RecallLifestone =>
ClientCommandId.LifestoneRecall,
RuntimePortalCommand.RecallMarketplace =>
ClientCommandId.MarketplaceRecall,
RuntimePortalCommand.RecallHouse =>
ClientCommandId.HouseRecall,
RuntimePortalCommand.RecallMansion =>
ClientCommandId.MansionRecall,
_ => null,
};
if (commandId is null)
{
_events.EmitCommand(
RuntimeCommandDomain.Portal,
(int)command,
RuntimeCommandStatus.Unsupported);
return Result(RuntimeCommandStatus.Unsupported);
}
_session.Commands.Publish(new ExecuteClientCommandCmd(
commandId.Value,
string.Empty));
_events.EmitCommand(
RuntimeCommandDomain.Portal,
(int)command,
RuntimeCommandStatus.Accepted);
return Result(RuntimeCommandStatus.Accepted);
}
private RuntimeCommandStatus Validate(
RuntimeGenerationToken expectedGeneration,
bool requireWorld)
{
if (!_view.IsActive)
return RuntimeCommandStatus.Inactive;
if (expectedGeneration != _view.Generation)
return RuntimeCommandStatus.StaleGeneration;
if (requireWorld && !_session.IsInWorld)
return RuntimeCommandStatus.Inactive;
return RuntimeCommandStatus.Accepted;
}
private RuntimeCommandResult Result(
RuntimeCommandStatus status,
uint objectId = 0u) =>
new(status, _view.Generation, objectId);
private RuntimeSessionStartResult RejectedStart(RuntimeCommandStatus status) =>
new(
status == RuntimeCommandStatus.StaleGeneration
? RuntimeSessionStartStatus.StaleGeneration
: RuntimeSessionStartStatus.Inactive,
_view.Generation);
private RuntimeSessionStartResult Convert(LiveSessionStartResult result)
{
RuntimeSessionStartStatus status = result.Status switch
{
LiveSessionStartStatus.Disabled => RuntimeSessionStartStatus.Disabled,
LiveSessionStartStatus.MissingCredentials =>
RuntimeSessionStartStatus.MissingCredentials,
LiveSessionStartStatus.NoCharacters =>
RuntimeSessionStartStatus.NoCharacters,
LiveSessionStartStatus.Connected =>
RuntimeSessionStartStatus.Connected,
LiveSessionStartStatus.Deferred =>
RuntimeSessionStartStatus.Deferred,
LiveSessionStartStatus.Failed =>
RuntimeSessionStartStatus.Failed,
_ => throw new ArgumentOutOfRangeException(
nameof(result),
result.Status,
"Unknown live-session start result."),
};
return new RuntimeSessionStartResult(
status,
_view.Generation,
result.Selection?.CharacterId ?? 0u,
result.Selection?.CharacterName ?? string.Empty,
result.Error);
}
private static RuntimeCommandStatus ToCommandStatus(
RuntimeSessionStartStatus status) =>
status switch
{
RuntimeSessionStartStatus.Failed => RuntimeCommandStatus.Rejected,
RuntimeSessionStartStatus.Inactive => RuntimeCommandStatus.Inactive,
RuntimeSessionStartStatus.StaleGeneration =>
RuntimeCommandStatus.StaleGeneration,
_ => RuntimeCommandStatus.Accepted,
};
private static bool TryMap(
RuntimeChatChannel channel,
out ChatChannelKind result)
{
result = channel switch
{
RuntimeChatChannel.Say => ChatChannelKind.Say,
RuntimeChatChannel.Tell => ChatChannelKind.Tell,
RuntimeChatChannel.Fellowship => ChatChannelKind.Fellowship,
RuntimeChatChannel.Allegiance => ChatChannelKind.Allegiance,
RuntimeChatChannel.Vassals => ChatChannelKind.Vassals,
RuntimeChatChannel.Patron => ChatChannelKind.Patron,
RuntimeChatChannel.Monarch => ChatChannelKind.Monarch,
RuntimeChatChannel.CoVassals => ChatChannelKind.CoVassals,
RuntimeChatChannel.General => ChatChannelKind.General,
RuntimeChatChannel.Trade => ChatChannelKind.Trade,
RuntimeChatChannel.LookingForGroup => ChatChannelKind.Lfg,
RuntimeChatChannel.Roleplay => ChatChannelKind.Roleplay,
RuntimeChatChannel.Society => ChatChannelKind.Society,
RuntimeChatChannel.Olthoi => ChatChannelKind.Olthoi,
_ => ChatChannelKind.Unknown,
};
return result != ChatChannelKind.Unknown;
}
}

View file

@ -0,0 +1,288 @@
using AcDream.App.World;
using AcDream.Core.Chat;
using AcDream.Core.Items;
using AcDream.Core.Physics;
using AcDream.Runtime;
namespace AcDream.App.Runtime;
internal interface ICurrentGameRuntimeEventSink
{
void EmitCommand(
RuntimeCommandDomain domain,
int operation,
RuntimeCommandStatus status,
uint primaryObjectId = 0u,
string? text = null);
void EmitLifecycle(RuntimeLifecycleState previous);
}
/// <summary>
/// Lazily projects existing owner notifications into one ordered diagnostic
/// stream. With no subscribers it attaches no callbacks and adds no per-event
/// production work.
/// </summary>
internal sealed class CurrentGameRuntimeEventAdapter
: IRuntimeEventSource,
ICurrentGameRuntimeEventSink,
IDisposable
{
private readonly CurrentGameRuntimeViewAdapter _view;
private readonly LiveEntityRuntime _entities;
private readonly ClientObjectTable _objects;
private readonly ChatLog _chat;
private readonly IGameRuntimeClock _clock;
private readonly RuntimeEventSequencer _events = new();
private readonly object _observerGate = new();
private IRuntimeEventObserver[] _observers = [];
private bool _ownerEventsAttached;
private bool _disposed;
public CurrentGameRuntimeEventAdapter(
CurrentGameRuntimeViewAdapter view,
LiveEntityRuntime entities,
ClientObjectTable objects,
ChatLog chat,
IGameRuntimeClock clock)
{
_view = view ?? throw new ArgumentNullException(nameof(view));
_entities = entities ?? throw new ArgumentNullException(nameof(entities));
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_chat = chat ?? throw new ArgumentNullException(nameof(chat));
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
}
public IDisposable Subscribe(IRuntimeEventObserver observer)
{
ArgumentNullException.ThrowIfNull(observer);
lock (_observerGate)
{
ObjectDisposedException.ThrowIf(_disposed, this);
IRuntimeEventObserver[] current = _observers;
if (Array.IndexOf(current, observer) >= 0)
{
throw new InvalidOperationException(
"The runtime observer is already subscribed.");
}
var replacement = new IRuntimeEventObserver[current.Length + 1];
Array.Copy(current, replacement, current.Length);
replacement[^1] = observer;
Volatile.Write(ref _observers, replacement);
if (!_ownerEventsAttached)
AttachOwnerEvents();
}
return new ObserverSubscription(this, observer);
}
public void Dispose()
{
lock (_observerGate)
{
if (_disposed)
return;
_disposed = true;
Volatile.Write(ref _observers, []);
DetachOwnerEvents();
}
}
public void EmitCommand(
RuntimeCommandDomain domain,
int operation,
RuntimeCommandStatus status,
uint primaryObjectId = 0u,
string? text = null)
{
if (!TryObservers(out IRuntimeEventObserver[] observers))
return;
var delta = new RuntimeCommandDelta(
NextStamp(),
domain,
operation,
status,
primaryObjectId,
text);
foreach (IRuntimeEventObserver observer in observers)
observer.OnCommand(in delta);
}
public void EmitLifecycle(RuntimeLifecycleState previous)
{
RuntimeLifecycleState current = _view.Lifecycle.State;
if (current == previous
|| !TryObservers(out IRuntimeEventObserver[] observers))
{
return;
}
var delta = new RuntimeLifecycleDelta(
NextStamp(),
previous,
current);
foreach (IRuntimeEventObserver observer in observers)
observer.OnLifecycle(in delta);
}
private void Unsubscribe(IRuntimeEventObserver observer)
{
lock (_observerGate)
{
IRuntimeEventObserver[] current = _observers;
int index = Array.IndexOf(current, observer);
if (index < 0)
return;
if (current.Length == 1)
{
Volatile.Write(ref _observers, []);
DetachOwnerEvents();
return;
}
var replacement = new IRuntimeEventObserver[current.Length - 1];
if (index != 0)
Array.Copy(current, 0, replacement, 0, index);
if (index != current.Length - 1)
{
Array.Copy(
current,
index + 1,
replacement,
index,
current.Length - index - 1);
}
Volatile.Write(ref _observers, replacement);
}
}
private void AttachOwnerEvents()
{
_entities.ProjectionVisibilityChanged += OnEntityVisibilityChanged;
_objects.ObjectAdded += OnObjectAdded;
_objects.ObjectUpdated += OnObjectUpdated;
_objects.ObjectMoved += OnObjectMoved;
_objects.ObjectRemovalClassified += OnObjectRemoved;
_objects.Cleared += OnObjectsCleared;
_chat.EntryAppended += OnChatEntry;
_ownerEventsAttached = true;
}
private void DetachOwnerEvents()
{
if (!_ownerEventsAttached)
return;
_chat.EntryAppended -= OnChatEntry;
_objects.Cleared -= OnObjectsCleared;
_objects.ObjectRemovalClassified -= OnObjectRemoved;
_objects.ObjectMoved -= OnObjectMoved;
_objects.ObjectUpdated -= OnObjectUpdated;
_objects.ObjectAdded -= OnObjectAdded;
_entities.ProjectionVisibilityChanged -= OnEntityVisibilityChanged;
_ownerEventsAttached = false;
}
private void OnEntityVisibilityChanged(LiveEntityRecord record, bool visible)
{
if (!TryObservers(out IRuntimeEventObserver[] observers))
return;
RuntimeEntityChange change = visible
? RuntimeEntityChange.Updated
: (record.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0
? RuntimeEntityChange.Hidden
: RuntimeEntityChange.Withdrawn;
var delta = new RuntimeEntityDelta(
NextStamp(),
change,
CurrentGameRuntimeViewAdapter.Snapshot(record));
foreach (IRuntimeEventObserver observer in observers)
observer.OnEntity(in delta);
}
private void OnObjectAdded(ClientObject item) =>
EmitInventory(RuntimeInventoryChange.Added, item);
private void OnObjectUpdated(ClientObject item) =>
EmitInventory(RuntimeInventoryChange.Updated, item);
private void OnObjectMoved(ClientObjectMove move)
{
ClientObject? item = move.Item ?? _objects.Get(move.ItemId);
if (item is not null)
EmitInventory(RuntimeInventoryChange.Moved, item);
}
private void OnObjectRemoved(ClientObjectRemoval removal) =>
EmitInventory(
RuntimeInventoryChange.Removed,
removal.Object,
removal.Generation);
private void OnObjectsCleared()
{
if (!TryObservers(out IRuntimeEventObserver[] observers))
return;
var delta = new RuntimeInventoryDelta(
NextStamp(),
RuntimeInventoryChange.Cleared,
default);
foreach (IRuntimeEventObserver observer in observers)
observer.OnInventory(in delta);
}
private void EmitInventory(
RuntimeInventoryChange change,
ClientObject item,
ushort? exactGeneration = null)
{
if (!TryObservers(out IRuntimeEventObserver[] observers))
return;
var delta = new RuntimeInventoryDelta(
NextStamp(),
change,
CurrentGameRuntimeViewAdapter.Snapshot(
item,
_entities,
exactGeneration));
foreach (IRuntimeEventObserver observer in observers)
observer.OnInventory(in delta);
}
private void OnChatEntry(ChatEntry entry)
{
if (!TryObservers(out IRuntimeEventObserver[] observers))
return;
var delta = new RuntimeChatDelta(
NextStamp(),
new RuntimeChatEntry(
_chat.Revision,
entry.SenderGuid,
(int)entry.Kind,
entry.Sender,
entry.Text,
entry.ChannelName));
foreach (IRuntimeEventObserver observer in observers)
observer.OnChat(in delta);
}
private RuntimeEventStamp NextStamp() =>
_events.Next(_view.Generation, _clock.FrameNumber);
private bool TryObservers(out IRuntimeEventObserver[] observers)
{
observers = Volatile.Read(ref _observers);
return observers.Length != 0;
}
private sealed class ObserverSubscription(
CurrentGameRuntimeEventAdapter owner,
IRuntimeEventObserver observer)
: IDisposable
{
private CurrentGameRuntimeEventAdapter? _owner = owner;
public void Dispose() =>
Interlocked.Exchange(ref _owner, null)?.Unsubscribe(observer);
}
}

View file

@ -0,0 +1,271 @@
using AcDream.App.Input;
using AcDream.App.Net;
using AcDream.App.Streaming;
using AcDream.App.World;
using AcDream.Core.Chat;
using AcDream.Core.Items;
using AcDream.Runtime;
namespace AcDream.App.Runtime;
/// <summary>
/// Read-only borrowed projections over the current App owners. Enumeration is
/// synchronous and allocation-free; no mutable collection is copied or owned.
/// </summary>
internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView
{
private readonly LiveSessionController _session;
private readonly LocalPlayerIdentityState _playerIdentity;
private readonly LiveEntityRuntime _entities;
private readonly ClientObjectTable _objects;
private readonly ChatLog _chat;
private readonly IGameRuntimeClock _clock;
private readonly EntityView _entityView;
private readonly InventoryView _inventoryView;
private readonly ChatView _chatView;
private readonly MovementView _movementView;
private readonly PortalView _portalView;
private bool _active = true;
public CurrentGameRuntimeViewAdapter(
LiveSessionController session,
LocalPlayerIdentityState playerIdentity,
LiveEntityRuntime entities,
ClientObjectTable objects,
ChatLog chat,
ILocalPlayerControllerSource playerController,
WorldRevealCoordinator worldReveal,
IGameRuntimeClock clock)
{
_session = session ?? throw new ArgumentNullException(nameof(session));
_playerIdentity = playerIdentity
?? throw new ArgumentNullException(nameof(playerIdentity));
_entities = entities ?? throw new ArgumentNullException(nameof(entities));
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_chat = chat ?? throw new ArgumentNullException(nameof(chat));
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
_entityView = new EntityView(_entities);
_inventoryView = new InventoryView(_objects, _entities);
_chatView = new ChatView(_chat);
_movementView = new MovementView(
playerController
?? throw new ArgumentNullException(nameof(playerController)),
_clock);
_portalView = new PortalView(
worldReveal ?? throw new ArgumentNullException(nameof(worldReveal)));
}
internal bool IsActive => _active && !_session.IsDisposalComplete;
public RuntimeGenerationToken Generation =>
new(_session.SessionGeneration);
public RuntimeLifecycleSnapshot Lifecycle
{
get
{
RuntimeLifecycleState state;
if (!IsActive)
state = RuntimeLifecycleState.Disposed;
else if (_session.IsInWorld)
state = RuntimeLifecycleState.InWorld;
else if (_session.CurrentSession is not null)
state = RuntimeLifecycleState.Starting;
else if (_session.SessionGeneration == 0UL)
state = RuntimeLifecycleState.Constructed;
else
state = RuntimeLifecycleState.Stopped;
return new RuntimeLifecycleSnapshot(
Generation,
state,
_playerIdentity.ServerGuid,
_session.CurrentSession is not null);
}
}
public IGameRuntimeClock Clock => _clock;
public IRuntimeEntityView Entities => _entityView;
public IRuntimeInventoryView Inventory => _inventoryView;
public IRuntimeChatView Chat => _chatView;
public IRuntimeMovementView Movement => _movementView;
public IRuntimePortalView Portal => _portalView;
public RuntimeStateCheckpoint CaptureCheckpoint() =>
new(
Generation,
Lifecycle.State,
_clock.FrameNumber,
_entities.Count,
_entities.MaterializedCount,
_objects.ObjectCount,
_objects.ContainerCount,
_chat.Revision,
_chat.Count,
_movementView.Snapshot,
_portalView.Snapshot);
internal void Deactivate() => _active = false;
internal static RuntimeEntitySnapshot Snapshot(LiveEntityRecord record) =>
new(
new RuntimeEntityIdentity(
record.ServerGuid,
record.LocalEntityId ?? 0u,
record.Generation),
record.FullCellId,
(uint)record.FinalPhysicsState,
record.PhysicsBody?.CellPosition,
record.LocalEntityId.HasValue,
record.IsSpatiallyVisible,
record.InitialHydrationCompleted);
internal static RuntimeInventoryItemSnapshot Snapshot(
ClientObject item,
LiveEntityRuntime entities,
ushort? exactGeneration = null)
{
ushort generation = exactGeneration
?? (entities.TryGetRecord(item.ObjectId, out LiveEntityRecord record)
? record.Generation
: (ushort)0);
return new RuntimeInventoryItemSnapshot(
item.ObjectId,
generation,
item.Name,
item.ContainerId,
item.ContainerSlot,
item.WielderId,
(uint)item.CurrentlyEquippedLocation,
item.StackSize,
item.Value);
}
private sealed class EntityView(LiveEntityRuntime owner)
: IRuntimeEntityView
{
public int Count => owner.Count;
public int MaterializedCount => owner.MaterializedCount;
public bool TryGet(uint serverGuid, out RuntimeEntitySnapshot entity)
{
if (owner.TryGetRecord(serverGuid, out LiveEntityRecord record))
{
entity = Snapshot(record);
return true;
}
entity = default;
return false;
}
public void Visit(IRuntimeEntityVisitor visitor)
{
ArgumentNullException.ThrowIfNull(visitor);
foreach (LiveEntityRecord record in owner.Records)
{
RuntimeEntitySnapshot entity = Snapshot(record);
visitor.Visit(in entity);
}
}
}
private sealed class InventoryView(
ClientObjectTable owner,
LiveEntityRuntime entities)
: IRuntimeInventoryView
{
public int ObjectCount => owner.ObjectCount;
public int ContainerCount => owner.ContainerCount;
public bool TryGet(
uint objectId,
out RuntimeInventoryItemSnapshot item)
{
ClientObject? found = owner.Get(objectId);
if (found is not null)
{
item = Snapshot(found, entities);
return true;
}
item = default;
return false;
}
public void Visit(IRuntimeInventoryVisitor visitor)
{
ArgumentNullException.ThrowIfNull(visitor);
foreach (ClientObject item in owner.Objects)
{
RuntimeInventoryItemSnapshot snapshot = Snapshot(item, entities);
visitor.Visit(in snapshot);
}
}
}
private sealed class ChatView(ChatLog owner) : IRuntimeChatView
{
public long Revision => owner.Revision;
public int Count => owner.Count;
}
private sealed class MovementView(
ILocalPlayerControllerSource owner,
IGameRuntimeClock clock)
: IRuntimeMovementView
{
public RuntimeMovementSnapshot Snapshot
{
get
{
PlayerMovementController? controller = owner.Controller;
return controller is null
? new RuntimeMovementSnapshot(
false,
0u,
default,
default,
false,
clock.SimulationTimeSeconds)
: new RuntimeMovementSnapshot(
true,
controller.LocalEntityId,
controller.CellPosition,
controller.BodyVelocity,
controller.IsAirborne,
clock.SimulationTimeSeconds);
}
}
}
private sealed class PortalView(WorldRevealCoordinator owner)
: IRuntimePortalView
{
public RuntimePortalSnapshot Snapshot
{
get
{
WorldRevealLifecycleSnapshot snapshot = owner.Snapshot;
RuntimePortalKind kind = snapshot.Generation == 0
? RuntimePortalKind.None
: snapshot.Kind switch
{
WorldRevealKind.Login => RuntimePortalKind.Login,
WorldRevealKind.Portal => RuntimePortalKind.Portal,
_ => RuntimePortalKind.None,
};
return new RuntimePortalSnapshot(
snapshot.Generation,
kind,
snapshot.Readiness.DestinationCell,
snapshot.IsReady,
snapshot.Materialized,
snapshot.Completed,
snapshot.Cancelled,
snapshot.WorldViewportObserved);
}
}
}
}

View file

@ -1,6 +1,7 @@
namespace AcDream.App.Update;
using AcDream.App.Streaming;
using AcDream.Runtime;
/// <summary>
/// The host-supplied input for one update callback.
@ -26,29 +27,31 @@ internal readonly record struct UpdateFrameTiming(
/// hooks, and the later script drain. Absolute liveness time and raw-mouse
/// idle time intentionally do not enter this clock.
/// </remarks>
internal sealed class UpdateFrameClock : IPhysicsScriptTimeSource
internal sealed class UpdateFrameClock
: IPhysicsScriptTimeSource,
IGameRuntimeClock
{
public double CurrentScriptTime { get; private set; }
private readonly GameRuntimeClock _runtime = new();
public ulong FrameNumber => _runtime.FrameNumber;
public double SimulationTimeSeconds => _runtime.SimulationTimeSeconds;
public double CurrentScriptTime => _runtime.SimulationTimeSeconds;
public UpdateFrameTiming Advance(
UpdateFrameInput input,
bool advanceScriptClock = true)
{
double deltaSeconds = NormalizeDeltaSeconds(input.HostDeltaSeconds);
if (advanceScriptClock)
CurrentScriptTime += deltaSeconds;
RuntimeFrameTime frame = _runtime.Advance(
input.HostDeltaSeconds,
advanceScriptClock);
return new UpdateFrameTiming(
deltaSeconds,
(float)deltaSeconds,
CurrentScriptTime);
frame.DeltaSeconds,
(float)frame.DeltaSeconds,
frame.SimulationTimeSeconds);
}
public static double NormalizeDeltaSeconds(double deltaSeconds) =>
double.IsFinite(deltaSeconds)
&& deltaSeconds > 0.0
&& deltaSeconds <= float.MaxValue
? deltaSeconds
: 0.0;
GameRuntimeClock.NormalizeDeltaSeconds(deltaSeconds);
}
internal interface IPhysicsScriptTimeSource

View file

@ -0,0 +1,45 @@
namespace AcDream.Runtime;
public readonly record struct RuntimeFrameTime(
ulong FrameNumber,
double DeltaSeconds,
double SimulationTimeSeconds);
public interface IGameRuntimeClock
{
ulong FrameNumber { get; }
double SimulationTimeSeconds { get; }
}
/// <summary>
/// Instance-scoped simulation clock shared by graphical and future headless
/// hosts. It owns no wall-clock/global state.
/// </summary>
public sealed class GameRuntimeClock : IGameRuntimeClock
{
public ulong FrameNumber { get; private set; }
public double SimulationTimeSeconds { get; private set; }
public RuntimeFrameTime Advance(
double hostDeltaSeconds,
bool advanceSimulationTime = true)
{
double deltaSeconds = NormalizeDeltaSeconds(hostDeltaSeconds);
FrameNumber = checked(FrameNumber + 1UL);
if (advanceSimulationTime)
SimulationTimeSeconds += deltaSeconds;
return new RuntimeFrameTime(
FrameNumber,
deltaSeconds,
SimulationTimeSeconds);
}
public static double NormalizeDeltaSeconds(double deltaSeconds) =>
double.IsFinite(deltaSeconds)
&& deltaSeconds > 0.0
&& deltaSeconds <= float.MaxValue
? deltaSeconds
: 0.0;
}

View file

@ -0,0 +1,151 @@
namespace AcDream.Runtime;
public enum RuntimeCommandStatus
{
Accepted,
Inactive,
StaleGeneration,
Unsupported,
Rejected,
}
public readonly record struct RuntimeCommandResult(
RuntimeCommandStatus Status,
RuntimeGenerationToken Generation,
uint ResultObjectId = 0u)
{
public bool Accepted => Status == RuntimeCommandStatus.Accepted;
}
public enum RuntimeSessionStartStatus
{
Disabled,
MissingCredentials,
NoCharacters,
Connected,
Deferred,
Failed,
Inactive,
StaleGeneration,
}
public readonly record struct RuntimeSessionStartResult(
RuntimeSessionStartStatus Status,
RuntimeGenerationToken Generation,
uint CharacterId = 0u,
string CharacterName = "",
Exception? Error = null);
public enum RuntimeSelectionCommand
{
SelectClosestHostile,
SelectPrevious,
ExamineSelected,
UseSelected,
PickUpSelected,
}
public enum RuntimeCombatCommand
{
ToggleMode,
}
public enum RuntimeMovementCommand
{
ToggleRunLock,
Stop,
Ready,
Sit,
Crouch,
Sleep,
}
public enum RuntimeChatChannel
{
Say,
Tell,
Fellowship,
Allegiance,
Vassals,
Patron,
Monarch,
CoVassals,
General,
Trade,
LookingForGroup,
Roleplay,
Society,
Olthoi,
}
public readonly record struct RuntimeChatCommand(
RuntimeChatChannel Channel,
string Text,
string? TargetName = null);
public enum RuntimePortalCommand
{
RecallLifestone,
RecallMarketplace,
RecallHouse,
RecallMansion,
}
public interface IRuntimeSessionCommands
{
RuntimeSessionStartResult Start(RuntimeGenerationToken expectedGeneration);
RuntimeSessionStartResult Reconnect(RuntimeGenerationToken expectedGeneration);
RuntimeTeardownAcknowledgement Stop(RuntimeGenerationToken expectedGeneration);
}
public interface IRuntimeSelectionCommands
{
RuntimeCommandResult Execute(
RuntimeGenerationToken expectedGeneration,
RuntimeSelectionCommand command);
}
public interface IRuntimeCombatCommands
{
RuntimeCommandResult Execute(
RuntimeGenerationToken expectedGeneration,
RuntimeCombatCommand command);
}
public interface IRuntimeMovementCommands
{
RuntimeCommandResult Execute(
RuntimeGenerationToken expectedGeneration,
RuntimeMovementCommand command);
}
public interface IRuntimeChatCommands
{
RuntimeCommandResult Execute(
RuntimeGenerationToken expectedGeneration,
in RuntimeChatCommand command);
}
public interface IRuntimePortalCommands
{
RuntimeCommandResult Execute(
RuntimeGenerationToken expectedGeneration,
RuntimePortalCommand command);
}
public interface IGameRuntimeCommands
{
IRuntimeSessionCommands Session { get; }
IRuntimeSelectionCommands Selection { get; }
IRuntimeCombatCommands Combat { get; }
IRuntimeMovementCommands Movement { get; }
IRuntimeChatCommands Chat { get; }
IRuntimePortalCommands Portal { get; }
}

View file

@ -0,0 +1,236 @@
namespace AcDream.Runtime;
public readonly record struct RuntimeEventStamp(
RuntimeGenerationToken Generation,
ulong Sequence,
ulong FrameNumber);
public enum RuntimeCommandDomain
{
Session,
Selection,
Combat,
Movement,
Chat,
Portal,
}
public readonly record struct RuntimeLifecycleDelta(
RuntimeEventStamp Stamp,
RuntimeLifecycleState Previous,
RuntimeLifecycleState Current);
public readonly record struct RuntimeCommandDelta(
RuntimeEventStamp Stamp,
RuntimeCommandDomain Domain,
int Operation,
RuntimeCommandStatus Status,
uint PrimaryObjectId = 0u,
string? Text = null);
public enum RuntimeEntityChange
{
Registered,
Updated,
Rebucketed,
Hidden,
Withdrawn,
Deleted,
}
public readonly record struct RuntimeEntityDelta(
RuntimeEventStamp Stamp,
RuntimeEntityChange Change,
RuntimeEntitySnapshot Entity);
public enum RuntimeInventoryChange
{
Added,
Updated,
Moved,
Removed,
Cleared,
}
public readonly record struct RuntimeInventoryDelta(
RuntimeEventStamp Stamp,
RuntimeInventoryChange Change,
RuntimeInventoryItemSnapshot Item);
public readonly record struct RuntimeChatEntry(
long Revision,
uint SenderGuid,
int Kind,
string Sender,
string Text,
string ChannelName);
public readonly record struct RuntimeChatDelta(
RuntimeEventStamp Stamp,
RuntimeChatEntry Entry);
public readonly record struct RuntimeMovementDelta(
RuntimeEventStamp Stamp,
RuntimeMovementSnapshot Movement);
public readonly record struct RuntimePortalDelta(
RuntimeEventStamp Stamp,
RuntimePortalSnapshot Portal);
public interface IRuntimeEventObserver
{
void OnLifecycle(in RuntimeLifecycleDelta delta);
void OnCommand(in RuntimeCommandDelta delta);
void OnEntity(in RuntimeEntityDelta delta);
void OnInventory(in RuntimeInventoryDelta delta);
void OnChat(in RuntimeChatDelta delta);
void OnMovement(in RuntimeMovementDelta delta);
void OnPortal(in RuntimePortalDelta delta);
}
public interface IRuntimeEventSource
{
IDisposable Subscribe(IRuntimeEventObserver observer);
}
public enum RuntimeTraceKind
{
Lifecycle,
Command,
Entity,
Inventory,
Chat,
Movement,
Portal,
Checkpoint,
}
/// <summary>
/// Stable normalized trace row used to compare a direct host with an adapter
/// host without retaining either owner's object graph.
/// </summary>
public readonly record struct RuntimeTraceEntry(
RuntimeEventStamp Stamp,
RuntimeTraceKind Kind,
int Code,
long Value,
uint PrimaryObjectId,
uint SecondaryObjectId,
string Text);
public sealed class RuntimeTraceRecorder : IRuntimeEventObserver
{
private readonly List<RuntimeTraceEntry> _entries = [];
public IReadOnlyList<RuntimeTraceEntry> Entries => _entries;
public void AddCheckpoint(
RuntimeEventStamp stamp,
in RuntimeStateCheckpoint checkpoint)
{
_entries.Add(new RuntimeTraceEntry(
stamp,
RuntimeTraceKind.Checkpoint,
(int)checkpoint.Lifecycle,
checkpoint.ChatRevision,
(uint)checkpoint.EntityCount,
(uint)checkpoint.InventoryObjectCount,
string.Create(
System.Globalization.CultureInfo.InvariantCulture,
$"frame={checkpoint.FrameNumber};materialized={checkpoint.MaterializedEntityCount};" +
$"containers={checkpoint.InventoryContainerCount};chat={checkpoint.ChatCount};" +
$"portal={checkpoint.Portal.Generation}:{(int)checkpoint.Portal.Kind}:" +
$"{checkpoint.Portal.DestinationCell:X8}:{checkpoint.Portal.IsReady}")));
}
public void OnLifecycle(in RuntimeLifecycleDelta delta) =>
_entries.Add(new RuntimeTraceEntry(
delta.Stamp,
RuntimeTraceKind.Lifecycle,
(int)delta.Current,
(int)delta.Previous,
0u,
0u,
string.Empty));
public void OnCommand(in RuntimeCommandDelta delta) =>
_entries.Add(new RuntimeTraceEntry(
delta.Stamp,
RuntimeTraceKind.Command,
((int)delta.Domain << 16) | (delta.Operation & 0xFFFF),
(int)delta.Status,
delta.PrimaryObjectId,
0u,
delta.Text ?? string.Empty));
public void OnEntity(in RuntimeEntityDelta delta) =>
_entries.Add(new RuntimeTraceEntry(
delta.Stamp,
RuntimeTraceKind.Entity,
(int)delta.Change,
delta.Entity.Identity.Incarnation,
delta.Entity.Identity.ServerGuid,
delta.Entity.CellId,
string.Empty));
public void OnInventory(in RuntimeInventoryDelta delta) =>
_entries.Add(new RuntimeTraceEntry(
delta.Stamp,
RuntimeTraceKind.Inventory,
(int)delta.Change,
delta.Item.StackSize,
delta.Item.ObjectId,
delta.Item.ContainerId,
delta.Item.Name ?? string.Empty));
public void OnChat(in RuntimeChatDelta delta) =>
_entries.Add(new RuntimeTraceEntry(
delta.Stamp,
RuntimeTraceKind.Chat,
delta.Entry.Kind,
delta.Entry.Revision,
delta.Entry.SenderGuid,
0u,
$"{delta.Entry.ChannelName}|{delta.Entry.Sender}|{delta.Entry.Text}"));
public void OnMovement(in RuntimeMovementDelta delta) =>
_entries.Add(new RuntimeTraceEntry(
delta.Stamp,
RuntimeTraceKind.Movement,
delta.Movement.IsAirborne ? 1 : 0,
BitConverter.DoubleToInt64Bits(delta.Movement.SimulationTimeSeconds),
delta.Movement.LocalEntityId,
delta.Movement.Position.ObjCellId,
string.Empty));
public void OnPortal(in RuntimePortalDelta delta) =>
_entries.Add(new RuntimeTraceEntry(
delta.Stamp,
RuntimeTraceKind.Portal,
(int)delta.Portal.Kind,
delta.Portal.Generation,
delta.Portal.DestinationCell,
0u,
$"{delta.Portal.IsReady}:{delta.Portal.IsMaterialized}:" +
$"{delta.Portal.IsCompleted}:{delta.Portal.IsCancelled}:" +
$"{delta.Portal.IsWorldVisible}"));
}
/// <summary>Monotonic sequence owner for one runtime instance.</summary>
public sealed class RuntimeEventSequencer
{
private ulong _sequence;
public RuntimeEventStamp Next(
RuntimeGenerationToken generation,
ulong frameNumber) =>
new(generation, checked(++_sequence), frameNumber);
public ulong LastSequence => _sequence;
}

View file

@ -0,0 +1,142 @@
using AcDream.Core.Physics;
namespace AcDream.Runtime;
public readonly record struct RuntimeEntityIdentity(
uint ServerGuid,
uint LocalEntityId,
ushort Incarnation);
public readonly record struct RuntimeEntitySnapshot(
RuntimeEntityIdentity Identity,
uint CellId,
uint PhysicsState,
Position? Position,
bool IsMaterialized,
bool IsSpatiallyVisible,
bool IsHydrated);
public interface IRuntimeEntityVisitor
{
void Visit(in RuntimeEntitySnapshot entity);
}
public interface IRuntimeEntityView
{
int Count { get; }
int MaterializedCount { get; }
bool TryGet(uint serverGuid, out RuntimeEntitySnapshot entity);
/// <summary>
/// Visits the canonical owner synchronously without copying its mutable
/// collection. The visitor must not retain references or mutate runtime
/// ownership during the call.
/// </summary>
void Visit(IRuntimeEntityVisitor visitor);
}
public readonly record struct RuntimeInventoryItemSnapshot(
uint ObjectId,
ushort Incarnation,
string Name,
uint ContainerId,
int ContainerSlot,
uint WielderId,
uint EquipLocation,
int StackSize,
int Value);
public interface IRuntimeInventoryVisitor
{
void Visit(in RuntimeInventoryItemSnapshot item);
}
public interface IRuntimeInventoryView
{
int ObjectCount { get; }
int ContainerCount { get; }
bool TryGet(uint objectId, out RuntimeInventoryItemSnapshot item);
/// <inheritdoc cref="IRuntimeEntityView.Visit"/>
void Visit(IRuntimeInventoryVisitor visitor);
}
public interface IRuntimeChatView
{
long Revision { get; }
int Count { get; }
}
public readonly record struct RuntimeMovementSnapshot(
bool HasController,
uint LocalEntityId,
Position Position,
System.Numerics.Vector3 Velocity,
bool IsAirborne,
double SimulationTimeSeconds);
public interface IRuntimeMovementView
{
RuntimeMovementSnapshot Snapshot { get; }
}
public enum RuntimePortalKind
{
None,
Login,
Portal,
}
public readonly record struct RuntimePortalSnapshot(
long Generation,
RuntimePortalKind Kind,
uint DestinationCell,
bool IsReady,
bool IsMaterialized,
bool IsCompleted,
bool IsCancelled,
bool IsWorldVisible);
public interface IRuntimePortalView
{
RuntimePortalSnapshot Snapshot { get; }
}
public readonly record struct RuntimeStateCheckpoint(
RuntimeGenerationToken Generation,
RuntimeLifecycleState Lifecycle,
ulong FrameNumber,
int EntityCount,
int MaterializedEntityCount,
int InventoryObjectCount,
int InventoryContainerCount,
long ChatRevision,
int ChatCount,
RuntimeMovementSnapshot Movement,
RuntimePortalSnapshot Portal);
public interface IGameRuntimeView
{
RuntimeGenerationToken Generation { get; }
RuntimeLifecycleSnapshot Lifecycle { get; }
IGameRuntimeClock Clock { get; }
IRuntimeEntityView Entities { get; }
IRuntimeInventoryView Inventory { get; }
IRuntimeChatView Chat { get; }
IRuntimeMovementView Movement { get; }
IRuntimePortalView Portal { get; }
RuntimeStateCheckpoint CaptureCheckpoint();
}

View file

@ -0,0 +1,62 @@
namespace AcDream.Runtime;
/// <summary>
/// Identifies one exact client-session generation. Default is the
/// pre-session generation and remains a valid compare token.
/// </summary>
public readonly record struct RuntimeGenerationToken(ulong Value)
{
public static RuntimeGenerationToken Initial => default;
public RuntimeGenerationToken Next() => new(checked(Value + 1UL));
public override string ToString() => Value.ToString(
System.Globalization.CultureInfo.InvariantCulture);
}
public enum RuntimeLifecycleState
{
Constructed,
Stopped,
Starting,
InWorld,
Stopping,
Faulted,
Disposed,
}
public readonly record struct RuntimeLifecycleSnapshot(
RuntimeGenerationToken Generation,
RuntimeLifecycleState State,
uint PlayerGuid,
bool HasTransport);
[Flags]
public enum RuntimeTeardownStage
{
None = 0,
CommandsInert = 1 << 0,
InboundDetached = 1 << 1,
TransportDisposed = 1 << 2,
HostReset = 1 << 3,
Complete = CommandsInert | InboundDetached | TransportDisposed | HostReset,
}
/// <summary>
/// A generation-scoped acknowledgement. A caller may retry only the missing
/// suffix; completed teardown stages never replay.
/// </summary>
public readonly record struct RuntimeTeardownAcknowledgement(
RuntimeGenerationToken RetiredGeneration,
RuntimeGenerationToken CurrentGeneration,
RuntimeCommandStatus Status,
RuntimeTeardownStage CompletedStages,
Exception? Error = null)
{
public bool IsComplete =>
Status == RuntimeCommandStatus.Accepted
&&
(CompletedStages & RuntimeTeardownStage.Complete)
== RuntimeTeardownStage.Complete
&& Error is null;
}

View file

@ -1,5 +1,5 @@
using AcDream.App.Composition;
using AcDream.App.Net;
using AcDream.Runtime;
namespace AcDream.App.Tests.Composition;
@ -11,19 +11,25 @@ public sealed class SessionStartCompositionTests
var messages = new List<string>();
SessionStartCompositionPhase.Report(
new LiveSessionStartResult(
LiveSessionStartStatus.MissingCredentials),
new RuntimeSessionStartResult(
RuntimeSessionStartStatus.MissingCredentials,
default),
messages.Add);
SessionStartCompositionPhase.Report(
new LiveSessionStartResult(
LiveSessionStartStatus.Failed,
new RuntimeSessionStartResult(
RuntimeSessionStartStatus.Failed,
default,
Error: new InvalidOperationException("boom")),
messages.Add);
SessionStartCompositionPhase.Report(
new LiveSessionStartResult(LiveSessionStartStatus.Disabled),
new RuntimeSessionStartResult(
RuntimeSessionStartStatus.Disabled,
default),
messages.Add);
SessionStartCompositionPhase.Report(
new LiveSessionStartResult(LiveSessionStartStatus.Connected),
new RuntimeSessionStartResult(
RuntimeSessionStartStatus.Connected,
default),
messages.Add);
Assert.Equal(2, messages.Count);

View file

@ -1,6 +1,7 @@
using AcDream.App.Combat;
using AcDream.App.Diagnostics;
using AcDream.App.Input;
using AcDream.Runtime;
using AcDream.UI.Abstractions.Input;
namespace AcDream.App.Tests.Input;
@ -86,6 +87,7 @@ public sealed class GameplayInputCommandControllerTests
TargetMode = new FakeTargetMode(Calls);
Camera = new FakeCamera(Calls);
Combat = new FakeCombat(Calls);
Runtime = new FakeRuntimeView();
Window = new FakeWindow(Calls);
Controller = new GameplayInputCommandController(
Retained,
@ -94,6 +96,7 @@ public sealed class GameplayInputCommandControllerTests
Player,
TargetMode,
Camera,
Runtime,
Combat,
Window);
}
@ -106,6 +109,7 @@ public sealed class GameplayInputCommandControllerTests
public FakeTargetMode TargetMode { get; }
public FakeCamera Camera { get; }
public FakeCombat Combat { get; }
public FakeRuntimeView Runtime { get; }
public FakeWindow Window { get; }
public GameplayInputCommandController Controller { get; }
}
@ -168,9 +172,32 @@ public sealed class GameplayInputCommandControllerTests
public void ExitFlyMode() => calls.Add("exit-fly");
}
private sealed class FakeCombat(List<string> calls) : ILiveCombatModeCommand
private sealed class FakeCombat(List<string> calls) : IRuntimeCombatCommands
{
public void Toggle() => calls.Add("combat");
public RuntimeCommandResult Execute(
RuntimeGenerationToken expectedGeneration,
RuntimeCombatCommand command)
{
Assert.Equal(RuntimeCombatCommand.ToggleMode, command);
calls.Add("combat");
return new RuntimeCommandResult(
RuntimeCommandStatus.Accepted,
expectedGeneration);
}
}
private sealed class FakeRuntimeView : IGameRuntimeView
{
public RuntimeGenerationToken Generation => new(7);
public RuntimeLifecycleSnapshot Lifecycle => throw new NotSupportedException();
public IGameRuntimeClock Clock => throw new NotSupportedException();
public IRuntimeEntityView Entities => throw new NotSupportedException();
public IRuntimeInventoryView Inventory => throw new NotSupportedException();
public IRuntimeChatView Chat => throw new NotSupportedException();
public IRuntimeMovementView Movement => throw new NotSupportedException();
public IRuntimePortalView Portal => throw new NotSupportedException();
public RuntimeStateCheckpoint CaptureCheckpoint() =>
throw new NotSupportedException();
}
private sealed class FakeWindow(List<string> calls) : IGameplayWindowCommands

View file

@ -125,10 +125,10 @@ public sealed class GameWindowSlice8BoundaryTests
"SessionStartComposition.cs"));
AssertAppearsInOrder(
startPhase,
"frame.SessionHost.Start(_dependencies.Options)",
"frame.GameRuntime.Session.Start(frame.GameRuntime.Generation)",
"switch (result.Status)",
"case LiveSessionStartStatus.MissingCredentials:",
"case LiveSessionStartStatus.Failed:");
"case RuntimeSessionStartStatus.MissingCredentials:",
"case RuntimeSessionStartStatus.Failed:");
int start = body.IndexOf(
".Start(frameRoots));",
StringComparison.Ordinal);

View file

@ -0,0 +1,613 @@
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.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(
Options,
_session,
Host,
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),
reset,
new LiveSessionSelectionBindings(
id => identity.ServerGuid = id,
_ => { },
_ => { },
_ => { },
_ => { },
() => { }),
new LiveSessionEnteredWorldBindings(
_ => { },
() => { },
() => { },
_ => { },
() => { }),
(_, _, _) => { },
() => { }));
}
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
{
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)
{
}
}
}

View file

@ -0,0 +1,125 @@
namespace AcDream.Runtime.Tests;
public sealed class GameRuntimeContractTests
{
[Fact]
public void InstanceClock_NormalizesHostTimeAndCanFreezeSimulation()
{
var first = new GameRuntimeClock();
var second = new GameRuntimeClock();
RuntimeFrameTime frame1 = first.Advance(0.25);
RuntimeFrameTime frozen = first.Advance(0.5, advanceSimulationTime: false);
RuntimeFrameTime invalid = first.Advance(double.NaN);
Assert.Equal(1UL, frame1.FrameNumber);
Assert.Equal(0.25, frame1.DeltaSeconds);
Assert.Equal(0.25, frame1.SimulationTimeSeconds);
Assert.Equal(2UL, frozen.FrameNumber);
Assert.Equal(0.5, frozen.DeltaSeconds);
Assert.Equal(0.25, frozen.SimulationTimeSeconds);
Assert.Equal(3UL, invalid.FrameNumber);
Assert.Equal(0.0, invalid.DeltaSeconds);
Assert.Equal(0.25, invalid.SimulationTimeSeconds);
Assert.Equal(0UL, second.FrameNumber);
Assert.Equal(0.0, second.SimulationTimeSeconds);
}
[Theory]
[InlineData(double.NegativeInfinity)]
[InlineData(double.PositiveInfinity)]
[InlineData(double.NaN)]
[InlineData(-1.0)]
[InlineData(0.0)]
public void InstanceClock_InvalidDeltaNormalizesToZero(double input)
{
Assert.Equal(0.0, GameRuntimeClock.NormalizeDeltaSeconds(input));
}
[Fact]
public void GenerationAndTeardownAcknowledgementAreExact()
{
RuntimeGenerationToken first = RuntimeGenerationToken.Initial.Next();
var complete = new RuntimeTeardownAcknowledgement(
first,
first.Next(),
RuntimeCommandStatus.Accepted,
RuntimeTeardownStage.Complete);
var stale = complete with
{
Status = RuntimeCommandStatus.StaleGeneration,
};
var partial = complete with
{
CompletedStages =
RuntimeTeardownStage.CommandsInert
| RuntimeTeardownStage.InboundDetached,
};
Assert.Equal(1UL, first.Value);
Assert.True(complete.IsComplete);
Assert.False(stale.IsComplete);
Assert.False(partial.IsComplete);
}
[Fact]
public void EventSequencersAreMonotonicAndInstanceScoped()
{
var first = new RuntimeEventSequencer();
var second = new RuntimeEventSequencer();
var generation = new RuntimeGenerationToken(7);
RuntimeEventStamp one = first.Next(generation, frameNumber: 11);
RuntimeEventStamp two = first.Next(generation, frameNumber: 12);
RuntimeEventStamp independent = second.Next(generation, frameNumber: 99);
Assert.Equal(1UL, one.Sequence);
Assert.Equal(2UL, two.Sequence);
Assert.Equal(1UL, independent.Sequence);
Assert.Equal(12UL, two.FrameNumber);
Assert.Equal(99UL, independent.FrameNumber);
}
[Fact]
public void TraceRecorderNormalizesTypedDeltasWithoutRetainingOwners()
{
var recorder = new RuntimeTraceRecorder();
var stamp = new RuntimeEventStamp(
new RuntimeGenerationToken(3),
Sequence: 1,
FrameNumber: 8);
var command = new RuntimeCommandDelta(
stamp,
RuntimeCommandDomain.Selection,
(int)RuntimeSelectionCommand.SelectClosestHostile,
RuntimeCommandStatus.Accepted,
PrimaryObjectId: 0x50000001u);
var chat = new RuntimeChatDelta(
stamp with { Sequence = 2 },
new RuntimeChatEntry(
Revision: 4,
SenderGuid: 0x50000002u,
Kind: 2,
Sender: "Grey",
Text: "hello",
ChannelName: "General"));
recorder.OnCommand(in command);
recorder.OnChat(in chat);
Assert.Collection(
recorder.Entries,
entry =>
{
Assert.Equal(RuntimeTraceKind.Command, entry.Kind);
Assert.Equal(0x50000001u, entry.PrimaryObjectId);
Assert.Equal((long)RuntimeCommandStatus.Accepted, entry.Value);
},
entry =>
{
Assert.Equal(RuntimeTraceKind.Chat, entry.Kind);
Assert.Equal(4L, entry.Value);
Assert.Equal("General|Grey|hello", entry.Text);
});
}
}