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:
parent
afebbe3eca
commit
854d9e9cd1
21 changed files with 2594 additions and 44 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -1483,7 +1483,7 @@ public sealed class GameWindow :
|
|||
livePresentation,
|
||||
sessionPlayer),
|
||||
frameRoots => new SessionStartCompositionPhase(
|
||||
new SessionStartDependencies(_options, Console.WriteLine))
|
||||
new SessionStartDependencies(Console.WriteLine))
|
||||
.Start(frameRoots));
|
||||
}
|
||||
|
||||
|
|
|
|||
106
src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs
Normal file
106
src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
394
src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs
Normal file
394
src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
288
src/AcDream.App/Runtime/CurrentGameRuntimeEventAdapter.cs
Normal file
288
src/AcDream.App/Runtime/CurrentGameRuntimeEventAdapter.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
271
src/AcDream.App/Runtime/CurrentGameRuntimeViewAdapter.cs
Normal file
271
src/AcDream.App/Runtime/CurrentGameRuntimeViewAdapter.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
45
src/AcDream.Runtime/GameRuntimeClock.cs
Normal file
45
src/AcDream.Runtime/GameRuntimeClock.cs
Normal 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;
|
||||
}
|
||||
151
src/AcDream.Runtime/GameRuntimeCommands.cs
Normal file
151
src/AcDream.Runtime/GameRuntimeCommands.cs
Normal 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; }
|
||||
}
|
||||
236
src/AcDream.Runtime/GameRuntimeEvents.cs
Normal file
236
src/AcDream.Runtime/GameRuntimeEvents.cs
Normal 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;
|
||||
}
|
||||
142
src/AcDream.Runtime/GameRuntimeViews.cs
Normal file
142
src/AcDream.Runtime/GameRuntimeViews.cs
Normal 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();
|
||||
}
|
||||
62
src/AcDream.Runtime/RuntimeGeneration.cs
Normal file
62
src/AcDream.Runtime/RuntimeGeneration.cs
Normal 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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
125
tests/AcDream.Runtime.Tests/GameRuntimeContractTests.cs
Normal file
125
tests/AcDream.Runtime.Tests/GameRuntimeContractTests.cs
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue