diff --git a/src/AcDream.App/Composition/FrameRootComposition.cs b/src/AcDream.App/Composition/FrameRootComposition.cs index 0a24b161..f588132c 100644 --- a/src/AcDream.App/Composition/FrameRootComposition.cs +++ b/src/AcDream.App/Composition/FrameRootComposition.cs @@ -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(); diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs index ad134d1b..104a51e0 100644 --- a/src/AcDream.App/Composition/SessionPlayerComposition.cs +++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs @@ -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); diff --git a/src/AcDream.App/Composition/SessionStartComposition.cs b/src/AcDream.App/Composition/SessionStartComposition.cs index 2a994e79..fa352db9 100644 --- a/src/AcDream.App/Composition/SessionStartComposition.cs +++ b/src/AcDream.App/Composition/SessionStartComposition.cs @@ -1,9 +1,8 @@ -using AcDream.App.Net; +using AcDream.Runtime; namespace AcDream.App.Composition; internal sealed record SessionStartDependencies( - RuntimeOptions Options, Action Log); /// @@ -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 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; } diff --git a/src/AcDream.App/Input/GameplayInputActionRouter.cs b/src/AcDream.App/Input/GameplayInputActionRouter.cs index c788afa0..9840e0b1 100644 --- a/src/AcDream.App/Input/GameplayInputActionRouter.cs +++ b/src/AcDream.App/Input/GameplayInputActionRouter.cs @@ -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); diff --git a/src/AcDream.App/Input/GameplayInputCommandController.cs b/src/AcDream.App/Input/GameplayInputCommandController.cs index 457bbf55..ce07257a 100644 --- a/src/AcDream.App/Input/GameplayInputCommandController.cs +++ b/src/AcDream.App/Input/GameplayInputCommandController.cs @@ -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(); diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 97e7d927..1ea5fab4 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1483,7 +1483,7 @@ public sealed class GameWindow : livePresentation, sessionPlayer), frameRoots => new SessionStartCompositionPhase( - new SessionStartDependencies(_options, Console.WriteLine)) + new SessionStartDependencies(Console.WriteLine)) .Start(frameRoots)); } diff --git a/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs b/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs new file mode 100644 index 00000000..328bc17c --- /dev/null +++ b/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs @@ -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; + +/// +/// 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. +/// +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(); + } +} diff --git a/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs b/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs new file mode 100644 index 00000000..a0748440 --- /dev/null +++ b/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs @@ -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; + +/// +/// Generation-gated synchronous command projection onto the same current +/// owners used by graphical input. It introduces no queue or frame boundary. +/// +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; + } +} diff --git a/src/AcDream.App/Runtime/CurrentGameRuntimeEventAdapter.cs b/src/AcDream.App/Runtime/CurrentGameRuntimeEventAdapter.cs new file mode 100644 index 00000000..2c76761a --- /dev/null +++ b/src/AcDream.App/Runtime/CurrentGameRuntimeEventAdapter.cs @@ -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); +} + +/// +/// Lazily projects existing owner notifications into one ordered diagnostic +/// stream. With no subscribers it attaches no callbacks and adds no per-event +/// production work. +/// +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); + } +} diff --git a/src/AcDream.App/Runtime/CurrentGameRuntimeViewAdapter.cs b/src/AcDream.App/Runtime/CurrentGameRuntimeViewAdapter.cs new file mode 100644 index 00000000..3a93d903 --- /dev/null +++ b/src/AcDream.App/Runtime/CurrentGameRuntimeViewAdapter.cs @@ -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; + +/// +/// Read-only borrowed projections over the current App owners. Enumeration is +/// synchronous and allocation-free; no mutable collection is copied or owned. +/// +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); + } + } + } +} diff --git a/src/AcDream.App/Update/UpdateFrameOrchestrator.cs b/src/AcDream.App/Update/UpdateFrameOrchestrator.cs index 2e6a029a..5bf0cdd2 100644 --- a/src/AcDream.App/Update/UpdateFrameOrchestrator.cs +++ b/src/AcDream.App/Update/UpdateFrameOrchestrator.cs @@ -1,6 +1,7 @@ namespace AcDream.App.Update; using AcDream.App.Streaming; +using AcDream.Runtime; /// /// 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. /// -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 diff --git a/src/AcDream.Runtime/GameRuntimeClock.cs b/src/AcDream.Runtime/GameRuntimeClock.cs new file mode 100644 index 00000000..efd9a3d3 --- /dev/null +++ b/src/AcDream.Runtime/GameRuntimeClock.cs @@ -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; } +} + +/// +/// Instance-scoped simulation clock shared by graphical and future headless +/// hosts. It owns no wall-clock/global state. +/// +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; +} diff --git a/src/AcDream.Runtime/GameRuntimeCommands.cs b/src/AcDream.Runtime/GameRuntimeCommands.cs new file mode 100644 index 00000000..3ddb51b8 --- /dev/null +++ b/src/AcDream.Runtime/GameRuntimeCommands.cs @@ -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; } +} diff --git a/src/AcDream.Runtime/GameRuntimeEvents.cs b/src/AcDream.Runtime/GameRuntimeEvents.cs new file mode 100644 index 00000000..6287751f --- /dev/null +++ b/src/AcDream.Runtime/GameRuntimeEvents.cs @@ -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, +} + +/// +/// Stable normalized trace row used to compare a direct host with an adapter +/// host without retaining either owner's object graph. +/// +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 _entries = []; + + public IReadOnlyList 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}")); +} + +/// Monotonic sequence owner for one runtime instance. +public sealed class RuntimeEventSequencer +{ + private ulong _sequence; + + public RuntimeEventStamp Next( + RuntimeGenerationToken generation, + ulong frameNumber) => + new(generation, checked(++_sequence), frameNumber); + + public ulong LastSequence => _sequence; +} diff --git a/src/AcDream.Runtime/GameRuntimeViews.cs b/src/AcDream.Runtime/GameRuntimeViews.cs new file mode 100644 index 00000000..7a30e0e0 --- /dev/null +++ b/src/AcDream.Runtime/GameRuntimeViews.cs @@ -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); + + /// + /// Visits the canonical owner synchronously without copying its mutable + /// collection. The visitor must not retain references or mutate runtime + /// ownership during the call. + /// + 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); + + /// + 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(); +} diff --git a/src/AcDream.Runtime/RuntimeGeneration.cs b/src/AcDream.Runtime/RuntimeGeneration.cs new file mode 100644 index 00000000..b1005935 --- /dev/null +++ b/src/AcDream.Runtime/RuntimeGeneration.cs @@ -0,0 +1,62 @@ +namespace AcDream.Runtime; + +/// +/// Identifies one exact client-session generation. Default is the +/// pre-session generation and remains a valid compare token. +/// +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, +} + +/// +/// A generation-scoped acknowledgement. A caller may retry only the missing +/// suffix; completed teardown stages never replay. +/// +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; +} diff --git a/tests/AcDream.App.Tests/Composition/SessionStartCompositionTests.cs b/tests/AcDream.App.Tests/Composition/SessionStartCompositionTests.cs index fddddfc6..d1b004cf 100644 --- a/tests/AcDream.App.Tests/Composition/SessionStartCompositionTests.cs +++ b/tests/AcDream.App.Tests/Composition/SessionStartCompositionTests.cs @@ -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(); 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); diff --git a/tests/AcDream.App.Tests/Input/GameplayInputCommandControllerTests.cs b/tests/AcDream.App.Tests/Input/GameplayInputCommandControllerTests.cs index 5bc9543f..6af9813b 100644 --- a/tests/AcDream.App.Tests/Input/GameplayInputCommandControllerTests.cs +++ b/tests/AcDream.App.Tests/Input/GameplayInputCommandControllerTests.cs @@ -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 calls) : ILiveCombatModeCommand + private sealed class FakeCombat(List 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 calls) : IGameplayWindowCommands diff --git a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs index e90c7cb8..1b6b92de 100644 --- a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs +++ b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs @@ -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); diff --git a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs new file mode 100644 index 00000000..7589049d --- /dev/null +++ b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs @@ -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 + { + ["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(), + Array.Empty(), + Array.Empty(), + 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 datagram) + { + } + + public void Send(IPEndPoint remote, ReadOnlySpan datagram) + { + } + + public int Receive( + Span destination, + TimeSpan timeout, + out IPEndPoint? from) + { + from = null; + return -1; + } + + public ValueTask ReceiveAsync( + Memory 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 Published { get; } = []; + + public void Activate() => _active = true; + + public void Publish(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 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? armAfterCancel = null) => false; + } + + private sealed class NoopEntityResources + : ILiveEntityResourceLifecycle + { + public void Register(WorldEntity entity) + { + } + + public void Unregister(WorldEntity entity) + { + } + } +} diff --git a/tests/AcDream.Runtime.Tests/GameRuntimeContractTests.cs b/tests/AcDream.Runtime.Tests/GameRuntimeContractTests.cs new file mode 100644 index 00000000..2d72f02c --- /dev/null +++ b/tests/AcDream.Runtime.Tests/GameRuntimeContractTests.cs @@ -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); + }); + } +}