From 75930787741db40a83eab8663e4464dce8d687ba Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 25 Jul 2026 19:39:24 +0200 Subject: [PATCH] refactor(runtime): move session lifetime and ordered transport Move the canonical WorldSession generation, connect/enter/tick/stop transaction, inbound subscription owner, and retryable teardown acknowledgements into AcDream.Runtime. Keep App as a borrowing graphical host with a single inertable command projection and no mirrored session state. Validated by 79 Runtime tests, 3,776 App tests with three existing skips, the Release solution build, and 8,428 complete Release tests with five existing skips. Co-authored-by: Codex --- .../Combat/LiveCombatModeCommandController.cs | 1 + .../Composition/FrameRootComposition.cs | 3 +- .../Composition/SessionPlayerComposition.cs | 41 +++-- .../Net/LiveEntitySessionController.cs | 1 + src/AcDream.App/Net/LiveSessionAppSource.cs | 102 +++++++++++ .../Net/LiveSessionCommandRouter.cs | 20 +-- .../Net/LiveSessionRuntimeFactory.cs | 18 +- .../Rendering/DevToolsFramePresenter.cs | 12 +- src/AcDream.App/Rendering/GameWindow.cs | 10 +- .../Rendering/GameWindowLifetime.cs | 1 + .../Runtime/CurrentGameRuntimeAdapter.cs | 6 +- .../CurrentGameRuntimeCommandAdapter.cs | 93 ++-------- .../Runtime/CurrentGameRuntimeViewAdapter.cs | 4 +- .../LocalPlayerTeleportController.cs | 4 +- .../Update/LiveObjectFrameController.cs | 5 - .../World/RetailLiveFrameCoordinator.cs | 5 +- src/AcDream.Core.Net/AcDream.Core.Net.csproj | 1 + .../Session/LiveSessionContracts.cs | 25 +++ .../Session}/LiveSessionController.cs | 149 ++++++++++------ .../Session}/LiveSessionEventRouter.cs | 16 +- .../Session}/LiveSessionHost.cs | 129 +++++++++++--- .../Session}/LiveSessionLifecycleHost.cs | 12 +- .../Session/TurbineChatDisplayNames.cs | 22 +++ .../SessionPlayerCompositionTests.cs | 2 +- .../FramePhaseTestDoubles.cs | 3 +- .../GameWindowLiveSessionOwnershipTests.cs | 24 +++ .../Net/LiveSessionCommandRouterTests.cs | 25 +++ .../LiveSessionShutdownIntegrationTests.cs | 124 +++++++++++++ .../Rendering/DevToolsFramePresenterTests.cs | 11 +- .../GameWindowSlice8BoundaryTests.cs | 2 +- .../Runtime/CurrentGameRuntimeAdapterTests.cs | 16 +- .../Session}/LiveSessionControllerTests.cs | 105 ++++++----- .../Session}/LiveSessionEventRouterTests.cs | 4 +- .../Session}/LiveSessionHostTests.cs | 68 ++------ .../Session}/LiveSessionLifecycleHostTests.cs | 6 +- .../LiveSessionSubscriptionSetTests.cs | 4 +- .../RuntimeLiveSessionNoWindowTests.cs | 165 ++++++++++++++++++ 37 files changed, 884 insertions(+), 355 deletions(-) create mode 100644 src/AcDream.App/Net/LiveSessionAppSource.cs create mode 100644 src/AcDream.Runtime/Session/LiveSessionContracts.cs rename src/{AcDream.App/Net => AcDream.Runtime/Session}/LiveSessionController.cs (82%) rename src/{AcDream.App/Net => AcDream.Runtime/Session}/LiveSessionEventRouter.cs (97%) rename src/{AcDream.App/Net => AcDream.Runtime/Session}/LiveSessionHost.cs (67%) rename src/{AcDream.App/Net => AcDream.Runtime/Session}/LiveSessionLifecycleHost.cs (84%) create mode 100644 src/AcDream.Runtime/Session/TurbineChatDisplayNames.cs create mode 100644 tests/AcDream.App.Tests/Net/LiveSessionShutdownIntegrationTests.cs rename tests/{AcDream.App.Tests/Net => AcDream.Runtime.Tests/Session}/LiveSessionControllerTests.cs (93%) rename tests/{AcDream.App.Tests/Net => AcDream.Runtime.Tests/Session}/LiveSessionEventRouterTests.cs (99%) rename tests/{AcDream.App.Tests/Net => AcDream.Runtime.Tests/Session}/LiveSessionHostTests.cs (85%) rename tests/{AcDream.App.Tests/Net => AcDream.Runtime.Tests/Session}/LiveSessionLifecycleHostTests.cs (96%) rename tests/{AcDream.App.Tests/Net => AcDream.Runtime.Tests/Session}/LiveSessionSubscriptionSetTests.cs (96%) create mode 100644 tests/AcDream.Runtime.Tests/Session/RuntimeLiveSessionNoWindowTests.cs diff --git a/src/AcDream.App/Combat/LiveCombatModeCommandController.cs b/src/AcDream.App/Combat/LiveCombatModeCommandController.cs index 4ba48660..52cda267 100644 --- a/src/AcDream.App/Combat/LiveCombatModeCommandController.cs +++ b/src/AcDream.App/Combat/LiveCombatModeCommandController.cs @@ -3,6 +3,7 @@ using AcDream.App.Net; using AcDream.App.UI; using AcDream.Core.Combat; using AcDream.Core.Items; +using AcDream.Runtime.Session; namespace AcDream.App.Combat; diff --git a/src/AcDream.App/Composition/FrameRootComposition.cs b/src/AcDream.App/Composition/FrameRootComposition.cs index f588132c..bef3d3ed 100644 --- a/src/AcDream.App/Composition/FrameRootComposition.cs +++ b/src/AcDream.App/Composition/FrameRootComposition.cs @@ -7,6 +7,7 @@ using AcDream.App.Runtime; using AcDream.App.Settings; using AcDream.App.Update; using AcDream.App.World; +using AcDream.Runtime.Session; using AcDream.Core.Combat; using AcDream.Core.Lighting; using AcDream.Core.Physics; @@ -59,7 +60,7 @@ internal sealed record FrameRootResult( RenderFrameOrchestrator Render, FrameRootRuntimeBindings RuntimeBindings, IDisposable FrameGraphPublication, - AcDream.App.Net.LiveSessionHost SessionHost, + LiveSessionHost SessionHost, CurrentGameRuntimeAdapter GameRuntime); internal interface IGameWindowFrameRootPublication diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs index 104a51e0..da1e71d1 100644 --- a/src/AcDream.App/Composition/SessionPlayerComposition.cs +++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs @@ -26,6 +26,7 @@ using AcDream.Core.Player; using AcDream.Core.Social; using AcDream.Core.Spells; using AcDream.Core.World; +using AcDream.Runtime.Session; using Silk.NET.Windowing; namespace AcDream.App.Composition; @@ -437,13 +438,17 @@ internal sealed class SessionPlayerCompositionPhase static () => new LiveSessionController(), static value => value.Dispose()); LiveSessionController liveSession = liveSessionLease.Resource; + var liveSessionCommands = new LiveSessionCommandSurface(); + var liveSessionSource = new LiveSessionAppSource( + liveSession, + liveSessionCommands); bindings.Adopt( "retained-UI live session", - interaction.LateBindings.Session.Bind(liveSession)); + interaction.LateBindings.Session.Bind(liveSessionSource)); var localPhysicsTimestamps = new LiveSessionLocalPhysicsTimestampPublisher( d.PlayerIdentity, - liveSession); + liveSessionSource); Fault(SessionPlayerCompositionPoint.LiveSessionCreated); var teardown = new LiveEntityRuntimeTeardownController( @@ -520,7 +525,7 @@ internal sealed class SessionPlayerCompositionPhase d.PlayerHost, d.PlayerIdentity, d.UpdateClock, - liveSession, + liveSessionSource, localPhysicsTimestamps.Publish, d.MovementDiagnostics); var liveness = new LiveEntityLivenessController( @@ -569,8 +574,8 @@ internal sealed class SessionPlayerCompositionPhase d.Settings, d.PlayerController, d.PlayerOutbound, - liveSession, - liveSession, + liveSessionSource, + liveSessionSource, d.CombatFeedback))); Fault(SessionPlayerCompositionPoint.CombatOperationsBound); @@ -585,7 +590,7 @@ internal sealed class SessionPlayerCompositionPhase d.ChaseCameraInput, d.MovementInput, d.PlayerOutbound, - liveSession, + liveSessionSource, host.MouseLookCursor, new EnvironmentInputMonotonicClock()) : null; @@ -606,7 +611,7 @@ internal sealed class SessionPlayerCompositionPhase d.Options.LiveMode, d.PlayerMode, d.PlayerController, - liveSession, + liveSessionSource, d.WorldOrigin, networkUpdates, new FlyCameraStreamingObserverSource(host.CameraController), @@ -663,7 +668,7 @@ internal sealed class SessionPlayerCompositionPhase d.PlayerHost, localPlayerProjection, d.PlayerOutbound, - liveSession); + liveSessionSource); var localPlayerFrame = new RetailLocalPlayerFrameController( localPlayerFrameRuntime, d.MovementInput); @@ -703,7 +708,7 @@ internal sealed class SessionPlayerCompositionPhase localPlayerShadow, d.PlayerApproachCompletions, gameplayInput, - liveSession, + liveSessionSource, d.MovementDiagnostics, d.PlayerSkills, d.ViewportAspect); @@ -714,11 +719,11 @@ internal sealed class SessionPlayerCompositionPhase devTools.LateBindings.PlayerModeCommands.BindOwned(playerMode)); bindings.Adopt( "developer command bus", - devTools.CommandBus.BindOwned(liveSession)); + devTools.CommandBus.BindOwned(liveSessionSource)); } var playerModeAutoEntry = new PlayerModeAutoEntry( new LivePlayerModeAutoEntryContext( - liveSession, + liveSessionSource, live.LiveEntities, d.PlayerIdentity, worldReveal, @@ -750,7 +755,7 @@ internal sealed class SessionPlayerCompositionPhase d.ChaseCameraInput, d.WorldOrigin, liveSpatialReconciler), - new LocalPlayerTeleportSession(liveSession), + new LocalPlayerTeleportSession(liveSessionSource), new LocalPlayerTeleportPresentation(portalTunnel))); var teleportLease = scope.Own( "local-player teleport", @@ -845,8 +850,16 @@ internal sealed class SessionPlayerCompositionPhase live.Presentation, d.RemoteMovementObservations, live.RenderSceneShadow), + liveSessionCommands, d.Log); - LiveSessionHost sessionHost = sessionRuntimeFactory.Create(liveSession); + LiveSessionHost sessionHost = sessionRuntimeFactory.Create( + liveSession, + new LiveSessionConnectOptions( + d.Options.LiveMode, + d.Options.LiveHost, + d.Options.LivePort, + d.Options.LiveUser ?? string.Empty, + d.Options.LivePass ?? string.Empty)); Fault(SessionPlayerCompositionPoint.SessionHostCreated); Action? debugToast = d.SettingsDevTools.DevTools is { } devOwner @@ -867,9 +880,9 @@ internal sealed class SessionPlayerCompositionPhase "live combat-mode commands", d.CombatModeCommands.BindOwned(combatCommand)); var gameRuntime = new CurrentGameRuntimeAdapter( - d.Options, liveSession, sessionHost, + liveSessionCommands, d.PlayerIdentity, live.LiveEntities, d.Objects, diff --git a/src/AcDream.App/Net/LiveEntitySessionController.cs b/src/AcDream.App/Net/LiveEntitySessionController.cs index 473a08f7..d0722ae1 100644 --- a/src/AcDream.App/Net/LiveEntitySessionController.cs +++ b/src/AcDream.App/Net/LiveEntitySessionController.cs @@ -4,6 +4,7 @@ using AcDream.App.Streaming; using AcDream.App.World; using AcDream.Core.Net; using AcDream.Core.Net.Messages; +using AcDream.Runtime.Session; namespace AcDream.App.Net; diff --git a/src/AcDream.App/Net/LiveSessionAppSource.cs b/src/AcDream.App/Net/LiveSessionAppSource.cs new file mode 100644 index 00000000..f10e4e9e --- /dev/null +++ b/src/AcDream.App/Net/LiveSessionAppSource.cs @@ -0,0 +1,102 @@ +using AcDream.Core.Net; +using AcDream.Runtime.Session; +using AcDream.UI.Abstractions; + +namespace AcDream.App.Net; + +/// +/// Borrowed graphical view over the canonical Runtime live-session owner. +/// This adapter stores no generation, transport, or in-world state. +/// +internal sealed class LiveSessionAppSource + : ILiveInWorldSource, + ILiveWorldSessionSource, + ILiveUiSessionTarget +{ + private readonly LiveSessionController _session; + private readonly LiveSessionCommandSurface _commands; + + public LiveSessionAppSource( + LiveSessionController session, + LiveSessionCommandSurface commands) + { + _session = session ?? throw new ArgumentNullException(nameof(session)); + _commands = commands ?? throw new ArgumentNullException(nameof(commands)); + } + + public bool IsInWorld => _session.IsInWorld; + + public WorldSession? CurrentSession => _session.CurrentSession; + + public ICommandBus Commands => _commands; +} + +/// +/// Stable App command projection over one borrowed generation route. The +/// retained UI may keep this surface, while the displaced route itself becomes +/// inert before inbound subscriptions detach. +/// +internal sealed class LiveSessionCommandSurface : ICommandBus +{ + private readonly object _gate = new(); + private LiveSessionCommandRouter? _active; + + public ILiveSessionCommandRouting Attach(LiveSessionCommandRouter route) + { + ArgumentNullException.ThrowIfNull(route); + lock (_gate) + { + if (_active is not null) + { + throw new InvalidOperationException( + "A graphical live-session command route is already attached."); + } + + _active = route; + return new RouteLease(this, route); + } + } + + public void Publish(T command) where T : notnull + { + LiveSessionCommandRouter? route; + lock (_gate) + route = _active; + route?.Publish(command); + } + + private void Release(LiveSessionCommandRouter expected) + { + expected.Dispose(); + lock (_gate) + { + if (ReferenceEquals(_active, expected)) + _active = null; + } + } + + private sealed class RouteLease( + LiveSessionCommandSurface owner, + LiveSessionCommandRouter route) + : ILiveSessionCommandRouting + { + private readonly object _gate = new(); + private LiveSessionCommandSurface? _owner = owner; + + public void Activate() => route.Activate(); + + public void Dispose() + { + lock (_gate) + { + if (_owner is null) + return; + + // Retain the owner until the route has physically become + // inert so a failed teardown remains retryable. + _owner.Release(route); + _owner = null; + } + } + } +} diff --git a/src/AcDream.App/Net/LiveSessionCommandRouter.cs b/src/AcDream.App/Net/LiveSessionCommandRouter.cs index 50ab0949..14b4256c 100644 --- a/src/AcDream.App/Net/LiveSessionCommandRouter.cs +++ b/src/AcDream.App/Net/LiveSessionCommandRouter.cs @@ -1,6 +1,7 @@ using AcDream.App.UI; using AcDream.Core.Chat; using AcDream.Core.Net.Messages; +using AcDream.Runtime.Session; using AcDream.UI.Abstractions; namespace AcDream.App.Net; @@ -304,23 +305,4 @@ internal static class TurbineChatRouting : new TurbineResolution(Room, ChatType, Name); } - /// - /// Derive the retail-facing channel label from the inbound Turbine chat - /// type, retaining the numeric-room fallback for unknown extensions. - /// - public static string DisplayName(uint roomId, uint chatType) => - (TurbineChat.ChatType)chatType switch - { - TurbineChat.ChatType.Allegiance => "Allegiance", - TurbineChat.ChatType.General => "General", - TurbineChat.ChatType.Trade => "Trade", - TurbineChat.ChatType.Lfg => "LFG", - TurbineChat.ChatType.Roleplay => "Roleplay", - TurbineChat.ChatType.Society => "Society", - TurbineChat.ChatType.SocietyCelHan => "Celestial Hand", - TurbineChat.ChatType.SocietyEldWeb => "Eldrytch Web", - TurbineChat.ChatType.SocietyRadBlo => "Radiant Blood", - TurbineChat.ChatType.Olthoi => "Olthoi", - _ => $"Room 0x{roomId:X8}", - }; } diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs index 45a9a40f..c0bbd214 100644 --- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs +++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs @@ -23,6 +23,7 @@ using AcDream.Core.Social; using AcDream.Core.Spells; using AcDream.Core.World; using AcDream.Content; +using AcDream.Runtime.Session; using AcDream.UI.Abstractions.Panels.Chat; using AcDream.UI.Abstractions.Panels.Vitals; using DatReaderWriter; @@ -101,6 +102,7 @@ internal sealed class LiveSessionRuntimeFactory private readonly LiveSessionUiRuntime _ui; private readonly LiveSessionInteractionRuntime _interaction; private readonly LiveSessionWorldRuntime _world; + private readonly LiveSessionCommandSurface _commands; private readonly Action _log; public LiveSessionRuntimeFactory( @@ -109,6 +111,7 @@ internal sealed class LiveSessionRuntimeFactory LiveSessionUiRuntime ui, LiveSessionInteractionRuntime interaction, LiveSessionWorldRuntime world, + LiveSessionCommandSurface commands, Action log) { _player = player ?? throw new ArgumentNullException(nameof(player)); @@ -117,18 +120,22 @@ internal sealed class LiveSessionRuntimeFactory _interaction = interaction ?? throw new ArgumentNullException(nameof(interaction)); _world = world ?? throw new ArgumentNullException(nameof(world)); + _commands = commands ?? throw new ArgumentNullException(nameof(commands)); _log = log ?? throw new ArgumentNullException(nameof(log)); } - public LiveSessionHost Create(LiveSessionController controller) + public LiveSessionHost Create( + LiveSessionController controller, + LiveSessionConnectOptions connectOptions) { ArgumentNullException.ThrowIfNull(controller); + ArgumentNullException.ThrowIfNull(connectOptions); return new LiveSessionHost(controller, new LiveSessionHostBindings( Routing: new( CreateEventRouter, - session => new LiveSessionCommandRouter( - CreateCommandBindings(session))), - Reset: CreateResetBindings(), + session => _commands.Attach(new LiveSessionCommandRouter( + CreateCommandBindings(session)))), + Reset: LiveSessionResetManifest.Create(CreateResetBindings()).Execute, Selection: new( SetPlayerIdentity: id => _player.Identity.ServerGuid = id, SetVitalsIdentity: id => _ui.Vitals?.SetLocalPlayerGuid(id), @@ -149,7 +156,8 @@ internal sealed class LiveSessionRuntimeFactory Connected: () => _domain.Chat.OnSystemMessage( "connected — character list received", - chatType: 1))); + chatType: 1)), + connectOptions); } private LiveSessionResetBindings CreateResetBindings() => new() diff --git a/src/AcDream.App/Rendering/DevToolsFramePresenter.cs b/src/AcDream.App/Rendering/DevToolsFramePresenter.cs index 81ca471e..c6f2de91 100644 --- a/src/AcDream.App/Rendering/DevToolsFramePresenter.cs +++ b/src/AcDream.App/Rendering/DevToolsFramePresenter.cs @@ -181,7 +181,7 @@ internal sealed class DevToolsCameraMenuOperations : IDevToolsCameraMenuOperatio /// Resolves the reconnect-safe command bus at draw time. internal sealed class DevToolsCommandBusSource : IDevToolsCommandBusSource { - private LiveSessionController? _session; + private ILiveUiSessionTarget? _session; private bool _deactivated; public ICommandBus Current => @@ -189,7 +189,7 @@ internal sealed class DevToolsCommandBusSource : IDevToolsCommandBusSource ? session.Commands : NullCommandBus.Instance; - public void Bind(LiveSessionController session) + public void Bind(ILiveUiSessionTarget session) { ArgumentNullException.ThrowIfNull(session); ObjectDisposedException.ThrowIf(_deactivated, this); @@ -199,7 +199,7 @@ internal sealed class DevToolsCommandBusSource : IDevToolsCommandBusSource _session = session; } - public IDisposable BindOwned(LiveSessionController session) + public IDisposable BindOwned(ILiveUiSessionTarget session) { ArgumentNullException.ThrowIfNull(session); ObjectDisposedException.ThrowIf(_deactivated, this); @@ -213,7 +213,7 @@ internal sealed class DevToolsCommandBusSource : IDevToolsCommandBusSource return new Binding(this, session); } - public void Unbind(LiveSessionController session) + public void Unbind(ILiveUiSessionTarget session) { ArgumentNullException.ThrowIfNull(session); if (ReferenceEquals(_session, session)) @@ -229,11 +229,11 @@ internal sealed class DevToolsCommandBusSource : IDevToolsCommandBusSource private sealed class Binding : IDisposable { private DevToolsCommandBusSource? _owner; - private readonly LiveSessionController _expected; + private readonly ILiveUiSessionTarget _expected; public Binding( DevToolsCommandBusSource owner, - LiveSessionController expected) + ILiveUiSessionTarget expected) { _owner = owner; _expected = expected; diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 1ea5fab4..57ef5bf4 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -6,6 +6,7 @@ using AcDream.App.Rendering.Wb; using AcDream.App.Settings; using AcDream.App.World; using AcDream.Content; +using AcDream.Runtime.Session; using DatReaderWriter; using Silk.NET.Input; using Silk.NET.Maths; @@ -502,10 +503,11 @@ public sealed class GameWindow : // Phase 4.7: optional live connection to an ACE server. Enabled only when // ACDREAM_LIVE=1 is in the environment — fully backward compatible with // the offline rendering pipeline. - // Slice 3: the controller is the sole App owner. Outbound feature - // callbacks resolve this borrowed handle at call time and never cache it. - private AcDream.App.Net.LiveSessionController? _liveSessionController; - private AcDream.App.Net.LiveSessionHost? _liveSessionHost; + // Runtime owns the canonical session generation and transport lifetime. + // The window retains only the composition handles needed for startup and + // shutdown; graphical feature callbacks borrow state through App adapters. + private LiveSessionController? _liveSessionController; + private LiveSessionHost? _liveSessionHost; private AcDream.Core.Net.WorldSession? LiveSession => _liveSessionHost?.CurrentSession; private readonly AcDream.App.World.LiveWorldOriginState _liveWorldOrigin = new(); diff --git a/src/AcDream.App/Rendering/GameWindowLifetime.cs b/src/AcDream.App/Rendering/GameWindowLifetime.cs index fc94fd37..84aa8021 100644 --- a/src/AcDream.App/Rendering/GameWindowLifetime.cs +++ b/src/AcDream.App/Rendering/GameWindowLifetime.cs @@ -16,6 +16,7 @@ using AcDream.App.World; using AcDream.Content; using AcDream.Core.Audio; using AcDream.Core.Physics; +using AcDream.Runtime.Session; using AcDream.UI.Abstractions.Input; using DatReaderWriter; using Silk.NET.Input; diff --git a/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs b/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs index 328bc17c..9b10c541 100644 --- a/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs +++ b/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs @@ -8,6 +8,8 @@ using AcDream.Core.Chat; using AcDream.Core.Items; using AcDream.Core.Selection; using AcDream.Runtime; +using AcDream.Runtime.Session; +using AcDream.UI.Abstractions; namespace AcDream.App.Runtime; @@ -28,9 +30,9 @@ internal sealed class CurrentGameRuntimeAdapter private bool _disposed; public CurrentGameRuntimeAdapter( - RuntimeOptions options, LiveSessionController session, LiveSessionHost sessionHost, + ICommandBus commands, LocalPlayerIdentityState playerIdentity, LiveEntityRuntime entities, ClientObjectTable objects, @@ -59,9 +61,9 @@ internal sealed class CurrentGameRuntimeAdapter chat, clock); _commands = new CurrentGameRuntimeCommandAdapter( - options, session, sessionHost, + commands, _view, selectionState, selection, diff --git a/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs b/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs index a0748440..341129ae 100644 --- a/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs +++ b/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs @@ -4,6 +4,7 @@ using AcDream.App.Interaction; using AcDream.App.Net; using AcDream.Core.Selection; using AcDream.Runtime; +using AcDream.Runtime.Session; using AcDream.UI.Abstractions; using AcDream.UI.Abstractions.Input; @@ -21,9 +22,9 @@ internal sealed class CurrentGameRuntimeCommandAdapter IRuntimeChatCommands, IRuntimePortalCommands { - private readonly RuntimeOptions _options; private readonly LiveSessionController _session; private readonly LiveSessionHost _sessionHost; + private readonly ICommandBus _commands; private readonly CurrentGameRuntimeViewAdapter _view; private readonly SelectionState _selectionState; private readonly SelectionInteractionController _selection; @@ -32,9 +33,9 @@ internal sealed class CurrentGameRuntimeCommandAdapter private readonly ICurrentGameRuntimeEventSink _events; public CurrentGameRuntimeCommandAdapter( - RuntimeOptions options, LiveSessionController session, LiveSessionHost sessionHost, + ICommandBus commands, CurrentGameRuntimeViewAdapter view, SelectionState selectionState, SelectionInteractionController selection, @@ -42,9 +43,9 @@ internal sealed class CurrentGameRuntimeCommandAdapter 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)); + _commands = commands ?? throw new ArgumentNullException(nameof(commands)); _view = view ?? throw new ArgumentNullException(nameof(view)); _selectionState = selectionState ?? throw new ArgumentNullException(nameof(selectionState)); @@ -63,8 +64,8 @@ internal sealed class CurrentGameRuntimeCommandAdapter if (gate != RuntimeCommandStatus.Accepted) return RejectedStart(gate); - LiveSessionStartResult direct = _sessionHost.Start(_options); - RuntimeSessionStartResult result = Convert(direct); + RuntimeSessionStartResult result = + _sessionHost.Start(expectedGeneration); _events.EmitCommand( RuntimeCommandDomain.Session, operation: 0, @@ -83,8 +84,8 @@ internal sealed class CurrentGameRuntimeCommandAdapter if (gate != RuntimeCommandStatus.Accepted) return RejectedStart(gate); - LiveSessionStartResult direct = _sessionHost.Reconnect(_options); - RuntimeSessionStartResult result = Convert(direct); + RuntimeSessionStartResult result = + _sessionHost.Reconnect(expectedGeneration); _events.EmitCommand( RuntimeCommandDomain.Session, operation: 1, @@ -109,43 +110,15 @@ internal sealed class CurrentGameRuntimeCommandAdapter 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; - } + RuntimeTeardownAcknowledgement acknowledgement = + _sessionHost.Stop(expectedGeneration); + _events.EmitCommand( + RuntimeCommandDomain.Session, + operation: 2, + acknowledgement.Status, + text: acknowledgement.Error?.GetType().Name ?? string.Empty); + _events.EmitLifecycle(previous); + return acknowledgement; } public RuntimeCommandResult Execute( @@ -252,7 +225,7 @@ internal sealed class CurrentGameRuntimeCommandAdapter return Result(RuntimeCommandStatus.Unsupported); } - _session.Commands.Publish(new SendChatCmd( + _commands.Publish(new SendChatCmd( channel, command.TargetName, command.Text)); @@ -293,7 +266,7 @@ internal sealed class CurrentGameRuntimeCommandAdapter return Result(RuntimeCommandStatus.Unsupported); } - _session.Commands.Publish(new ExecuteClientCommandCmd( + _commands.Publish(new ExecuteClientCommandCmd( commandId.Value, string.Empty)); _events.EmitCommand( @@ -328,34 +301,6 @@ internal sealed class CurrentGameRuntimeCommandAdapter : 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 diff --git a/src/AcDream.App/Runtime/CurrentGameRuntimeViewAdapter.cs b/src/AcDream.App/Runtime/CurrentGameRuntimeViewAdapter.cs index 3a93d903..43a3e9f7 100644 --- a/src/AcDream.App/Runtime/CurrentGameRuntimeViewAdapter.cs +++ b/src/AcDream.App/Runtime/CurrentGameRuntimeViewAdapter.cs @@ -5,6 +5,7 @@ using AcDream.App.World; using AcDream.Core.Chat; using AcDream.Core.Items; using AcDream.Runtime; +using AcDream.Runtime.Session; namespace AcDream.App.Runtime; @@ -58,8 +59,7 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView internal bool IsActive => _active && !_session.IsDisposalComplete; - public RuntimeGenerationToken Generation => - new(_session.SessionGeneration); + public RuntimeGenerationToken Generation => _session.Generation; public RuntimeLifecycleSnapshot Lifecycle { diff --git a/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs b/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs index d78483bb..92b5f257 100644 --- a/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs +++ b/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs @@ -290,9 +290,9 @@ internal interface ILocalPlayerTeleportSession internal sealed class LocalPlayerTeleportSession : ILocalPlayerTeleportSession { - private readonly LiveSessionController _session; + private readonly ILiveWorldSessionSource _session; - public LocalPlayerTeleportSession(LiveSessionController session) => + public LocalPlayerTeleportSession(ILiveWorldSessionSource session) => _session = session ?? throw new ArgumentNullException(nameof(session)); public void SendLoginComplete() => diff --git a/src/AcDream.App/Update/LiveObjectFrameController.cs b/src/AcDream.App/Update/LiveObjectFrameController.cs index 92f3fcf2..fd8dd66a 100644 --- a/src/AcDream.App/Update/LiveObjectFrameController.cs +++ b/src/AcDream.App/Update/LiveObjectFrameController.cs @@ -19,11 +19,6 @@ internal interface ILiveObjectFramePhase void Tick(float deltaSeconds); } -internal interface ILiveSessionFramePhase -{ - void Tick(); -} - internal interface IPostNetworkCommandFramePhase { void RunPostNetworkCommandPhase(); diff --git a/src/AcDream.App/World/RetailLiveFrameCoordinator.cs b/src/AcDream.App/World/RetailLiveFrameCoordinator.cs index 6c6cb11f..a6b61d94 100644 --- a/src/AcDream.App/World/RetailLiveFrameCoordinator.cs +++ b/src/AcDream.App/World/RetailLiveFrameCoordinator.cs @@ -1,4 +1,5 @@ using AcDream.App.Update; +using AcDream.Runtime.Session; using AcDream.App.Input; using AcDream.App.Net; using AcDream.App.Streaming; @@ -21,7 +22,7 @@ internal sealed class RetailLiveFrameCoordinator : IRetailLiveFramePhase { private readonly ILiveObjectFramePhase _objects; private readonly GpuWorldState _worldState; - private readonly ILiveSessionFramePhase _session; + private readonly IRuntimeLiveSessionFramePhase _session; private readonly IPostNetworkCommandFramePhase _localPlayer; private readonly ILiveSpatialReconcilePhase _spatialReconciler; private readonly IWorldGenerationAvailability _availability; @@ -30,7 +31,7 @@ internal sealed class RetailLiveFrameCoordinator : IRetailLiveFramePhase public RetailLiveFrameCoordinator( ILiveObjectFramePhase objects, GpuWorldState worldState, - ILiveSessionFramePhase session, + IRuntimeLiveSessionFramePhase session, IPostNetworkCommandFramePhase localPlayer, ILiveSpatialReconcilePhase spatialReconciler, IWorldGenerationAvailability? availability = null, diff --git a/src/AcDream.Core.Net/AcDream.Core.Net.csproj b/src/AcDream.Core.Net/AcDream.Core.Net.csproj index e52d1627..a31b915c 100644 --- a/src/AcDream.Core.Net/AcDream.Core.Net.csproj +++ b/src/AcDream.Core.Net/AcDream.Core.Net.csproj @@ -13,5 +13,6 @@ + diff --git a/src/AcDream.Runtime/Session/LiveSessionContracts.cs b/src/AcDream.Runtime/Session/LiveSessionContracts.cs new file mode 100644 index 00000000..1a992acb --- /dev/null +++ b/src/AcDream.Runtime/Session/LiveSessionContracts.cs @@ -0,0 +1,25 @@ +using AcDream.Core.Net; + +namespace AcDream.Runtime.Session; + +public sealed record LiveSessionConnectOptions( + bool Enabled, + string Host, + int Port, + string User, + string Password); + +public interface IRuntimeLiveSessionFramePhase +{ + void Tick(); +} + +public interface ILiveSessionEventRouting : IDisposable +{ + void Attach(); +} + +public interface ILiveSessionCommandRouting : IDisposable +{ + void Activate(); +} diff --git a/src/AcDream.App/Net/LiveSessionController.cs b/src/AcDream.Runtime/Session/LiveSessionController.cs similarity index 82% rename from src/AcDream.App/Net/LiveSessionController.cs rename to src/AcDream.Runtime/Session/LiveSessionController.cs index 82bab1ad..75bb1048 100644 --- a/src/AcDream.App/Net/LiveSessionController.cs +++ b/src/AcDream.Runtime/Session/LiveSessionController.cs @@ -2,12 +2,10 @@ using System.Net; using System.Net.Sockets; using AcDream.Core.Net; using AcDream.Core.Net.Messages; -using AcDream.App.Update; -using AcDream.UI.Abstractions; -namespace AcDream.App.Net; +namespace AcDream.Runtime.Session; -internal enum LiveSessionStartStatus +public enum LiveSessionStartStatus { Disabled, MissingCredentials, @@ -17,23 +15,23 @@ internal enum LiveSessionStartStatus Failed, } -internal sealed record LiveSessionCharacterSelection( +public sealed record LiveSessionCharacterSelection( int ActiveIndex, uint CharacterId, string CharacterName, string AccountName); -internal sealed record LiveSessionStartResult( +public sealed record LiveSessionStartResult( LiveSessionStartStatus Status, LiveSessionCharacterSelection? Selection = null, Exception? Error = null); /// -/// Narrow App composition boundary for one exact WorldSession generation. -/// The host owns domain sinks and presentation state; the controller owns the -/// connect/enter/stop transaction and never reaches into GameWindow state. +/// Runtime boundary for the domain and presentation sinks attached to one +/// exact generation. The controller owns the +/// connect/enter/stop transaction and never reaches into graphical state. /// -internal interface ILiveSessionLifecycleHost +public interface ILiveSessionLifecycleHost { LiveSessionBinding BindSession(WorldSession session); void ResetSessionState(); @@ -49,7 +47,7 @@ internal interface ILiveSessionLifecycleHost /// retryable: outbound commands become inert before inbound subscriptions are /// detached. /// -internal sealed class LiveSessionBinding : IDisposable +public sealed class LiveSessionBinding : IDisposable { private readonly Action _activateCommands; private readonly Action _deactivateCommands; @@ -60,20 +58,19 @@ internal sealed class LiveSessionBinding : IDisposable public LiveSessionBinding( WorldSession session, - ICommandBus commands, Action activateCommands, Action deactivateCommands, Action detachEvents) { Session = session ?? throw new ArgumentNullException(nameof(session)); - Commands = commands ?? throw new ArgumentNullException(nameof(commands)); _activateCommands = activateCommands ?? throw new ArgumentNullException(nameof(activateCommands)); _deactivateCommands = deactivateCommands ?? throw new ArgumentNullException(nameof(deactivateCommands)); _detachEvents = detachEvents ?? throw new ArgumentNullException(nameof(detachEvents)); } public WorldSession Session { get; } - public ICommandBus Commands { get; } + public bool CommandsDeactivated => _commandsDeactivated; + public bool EventsDetached => _eventsDetached; public void ActivateCommands() { @@ -102,7 +99,7 @@ internal sealed class LiveSessionBinding : IDisposable } } -internal interface ILiveSessionOperations +public interface ILiveSessionOperations { IPEndPoint ResolveEndpoint(string host, int port); WorldSession CreateSession(IPEndPoint endpoint); @@ -153,17 +150,14 @@ internal sealed class ProductionLiveSessionOperations : ILiveSessionOperations } /// -/// Sole owner of the App lifetime for a live WorldSession: endpoint resolution, +/// Sole runtime owner of a live WorldSession lifetime: endpoint resolution, /// complete pre-Connect routing, character validation/entry, active command /// publication, exact-generation Tick, graceful replacement, and convergent /// teardown. Retail wire behavior remains inside WorldSession. /// -internal sealed class LiveSessionController +public sealed class LiveSessionController : IDisposable, - ILiveSessionFramePhase, - ILiveInWorldSource, - ILiveWorldSessionSource, - ILiveUiSessionTarget + IRuntimeLiveSessionFramePhase { private sealed class SessionScope( WorldSession session, @@ -175,17 +169,29 @@ internal sealed class LiveSessionController public ILiveSessionLifecycleHost Host { get; } = host; public LiveSessionBinding? Binding { get; set; } public bool HostAttached { get; set; } + public RuntimeTeardownStage CompletedStages { get; private set; } public void DrainTeardown(ILiveSessionOperations operations) { if (_teardownStage == 0) { - Binding?.Dispose(); + try + { + Binding?.Dispose(); + } + finally + { + if (Binding is null || Binding.CommandsDeactivated) + CompletedStages |= RuntimeTeardownStage.CommandsInert; + if (Binding is null || Binding.EventsDetached) + CompletedStages |= RuntimeTeardownStage.InboundDetached; + } _teardownStage = 1; } if (_teardownStage == 1) { operations.DisposeSession(Session); + CompletedStages |= RuntimeTeardownStage.TransportDisposed; _teardownStage = 2; } if (_teardownStage == 2) @@ -197,6 +203,7 @@ internal sealed class LiveSessionController if (_teardownStage == 3) { Host.ResetSessionState(); + CompletedStages |= RuntimeTeardownStage.HostReset; _teardownStage = 4; } } @@ -213,7 +220,7 @@ internal sealed class LiveSessionController private sealed record PendingOperation( PendingKind Kind, - RuntimeOptions? Options = null, + LiveSessionConnectOptions? Options = null, ILiveSessionLifecycleHost? Host = null); private readonly object _gate = new(); @@ -227,6 +234,7 @@ internal sealed class LiveSessionController private bool _disposeRequested; private bool _disposed; private ulong _generation; + private RuntimeTeardownStage _lastTeardownStages; private LiveSessionCharacterSelection? _activeSelection; public LiveSessionController() @@ -234,7 +242,7 @@ internal sealed class LiveSessionController { } - internal LiveSessionController(ILiveSessionOperations operations) + public LiveSessionController(ILiveSessionOperations operations) { _operations = operations ?? throw new ArgumentNullException(nameof(operations)); } @@ -244,17 +252,6 @@ internal sealed class LiveSessionController get { lock (_gate) return _scope?.Session; } } - public ICommandBus Commands - { - get - { - lock (_gate) - return _inWorld && _scope?.Binding is { } binding - ? binding.Commands - : NullCommandBus.Instance; - } - } - public bool IsInWorld { get { lock (_gate) return _inWorld; } @@ -265,13 +262,18 @@ internal sealed class LiveSessionController get { lock (_gate) return _generation; } } - internal bool IsDisposalComplete + public RuntimeGenerationToken Generation + { + get { lock (_gate) return new RuntimeGenerationToken(_generation); } + } + + public bool IsDisposalComplete { get { lock (_gate) return _disposed; } } - internal LiveSessionStartResult Start( - RuntimeOptions options, + public LiveSessionStartResult Start( + LiveSessionConnectOptions options, ILiveSessionLifecycleHost host) { ArgumentNullException.ThrowIfNull(options); @@ -287,8 +289,8 @@ internal sealed class LiveSessionController } } - internal LiveSessionStartResult Reconnect( - RuntimeOptions options, + public LiveSessionStartResult Reconnect( + LiveSessionConnectOptions options, ILiveSessionLifecycleHost host) { ArgumentNullException.ThrowIfNull(options); @@ -305,7 +307,7 @@ internal sealed class LiveSessionController } } - internal void Stop() + public void Stop() { lock (_gate) { @@ -320,6 +322,50 @@ internal sealed class LiveSessionController } } + public RuntimeTeardownAcknowledgement Stop( + RuntimeGenerationToken expectedGeneration) + { + lock (_gate) + { + RuntimeGenerationToken current = new(_generation); + if (_disposed) + { + return new RuntimeTeardownAcknowledgement( + expectedGeneration, + current, + RuntimeCommandStatus.Inactive, + _lastTeardownStages); + } + if (expectedGeneration != current) + { + return new RuntimeTeardownAcknowledgement( + expectedGeneration, + current, + RuntimeCommandStatus.StaleGeneration, + RuntimeTeardownStage.None); + } + + try + { + Stop(); + return new RuntimeTeardownAcknowledgement( + expectedGeneration, + new RuntimeGenerationToken(_generation), + RuntimeCommandStatus.Accepted, + _lastTeardownStages); + } + catch (Exception error) + { + return new RuntimeTeardownAcknowledgement( + expectedGeneration, + new RuntimeGenerationToken(_generation), + RuntimeCommandStatus.Rejected, + _retiredScope?.CompletedStages ?? _lastTeardownStages, + error); + } + } + } + public void Tick() { lock (_gate) @@ -368,7 +414,7 @@ internal sealed class LiveSessionController } private LiveSessionStartResult ReconnectCore( - RuntimeOptions options, + LiveSessionConnectOptions options, ILiveSessionLifecycleHost host) { ILiveSessionLifecycleHost? oldHost = @@ -388,7 +434,7 @@ internal sealed class LiveSessionController } private LiveSessionStartResult StartCore( - RuntimeOptions options, + LiveSessionConnectOptions options, ILiveSessionLifecycleHost host, bool resetHost) { @@ -408,18 +454,18 @@ internal sealed class LiveSessionController return new LiveSessionStartResult(LiveSessionStartStatus.Failed, Error: error); } - if (!options.LiveMode) + if (!options.Enabled) return new LiveSessionStartResult(LiveSessionStartStatus.Disabled); - if (string.IsNullOrEmpty(options.LiveUser) || string.IsNullOrEmpty(options.LivePass)) + if (string.IsNullOrEmpty(options.User) || string.IsNullOrEmpty(options.Password)) return new LiveSessionStartResult(LiveSessionStartStatus.MissingCredentials); SessionScope? scope = null; try { - IPEndPoint endpoint = _operations.ResolveEndpoint(options.LiveHost, options.LivePort); + IPEndPoint endpoint = _operations.ResolveEndpoint(options.Host, options.Port); if (_generation != generation) return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); - Console.WriteLine($"live: connecting to {endpoint} as {options.LiveUser}"); + Console.WriteLine($"live: connecting to {endpoint} as {options.User}"); WorldSession session = _operations.CreateSession(endpoint); scope = new SessionScope(session, host); _scope = scope; @@ -438,11 +484,11 @@ internal sealed class LiveSessionController if (!IsCurrent(scope, generation)) return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); - host.ReportConnecting(options.LiveHost, options.LivePort, options.LiveUser); + host.ReportConnecting(options.Host, options.Port, options.User); if (!IsCurrent(scope, generation)) return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); - _operations.Connect(session, options.LiveUser, options.LivePass); + _operations.Connect(session, options.User, options.Password); if (!IsCurrent(scope, generation)) return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); host.ReportConnected(); @@ -526,6 +572,8 @@ internal sealed class LiveSessionController } DrainRetiredScope(); DrainPendingInitialReset(); + if (_retiredScope is null && _pendingInitialResetHost is null) + _lastTeardownStages = RuntimeTeardownStage.Complete; } private void DrainRetiredScope() @@ -534,7 +582,10 @@ internal sealed class LiveSessionController return; retired.DrainTeardown(_operations); if (retired.IsTeardownComplete) + { + _lastTeardownStages = retired.CompletedStages; _retiredScope = null; + } } private void ResetHostBeforeStart( diff --git a/src/AcDream.App/Net/LiveSessionEventRouter.cs b/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs similarity index 97% rename from src/AcDream.App/Net/LiveSessionEventRouter.cs rename to src/AcDream.Runtime/Session/LiveSessionEventRouter.cs index 4d073262..75d73a9b 100644 --- a/src/AcDream.App/Net/LiveSessionEventRouter.cs +++ b/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs @@ -7,9 +7,9 @@ using AcDream.Core.Player; using AcDream.Core.Social; using AcDream.Core.Spells; -namespace AcDream.App.Net; +namespace AcDream.Runtime.Session; -internal sealed record LiveEntitySessionSink( +public sealed record LiveEntitySessionSink( Action Spawned, Action Deleted, Action PickedUp, @@ -23,11 +23,11 @@ internal sealed record LiveEntitySessionSink( Action PlayPhysicsScript, Action PlayPhysicsScriptType); -internal sealed record LiveEnvironmentSessionSink( +public sealed record LiveEnvironmentSessionSink( Action EnvironChanged, Action ServerTimeUpdated); -internal sealed record LiveInventorySessionBindings( +public sealed record LiveInventorySessionBindings( ClientObjectTable Objects, LocalPlayerState LocalPlayer, Func PlayerGuid, @@ -38,7 +38,7 @@ internal sealed record LiveInventorySessionBindings( ExternalContainerState? ExternalContainers, Action? OnAppraisal = null); -internal sealed record LiveCharacterSessionBindings( +public sealed record LiveCharacterSessionBindings( CombatState Combat, Spellbook Spellbook, Func, uint>? ResolveSkillFormulaBonus, @@ -48,7 +48,7 @@ internal sealed record LiveCharacterSessionBindings( Action? OnCharacterOptions, Func? ClientTime); -internal sealed record LiveSocialSessionBindings( +public sealed record LiveSocialSessionBindings( ChatLog Chat, TurbineChatState TurbineChat, FriendsState? Friends, @@ -58,7 +58,7 @@ internal sealed record LiveSocialSessionBindings( /// Owns every inbound subscription for one exact live session. Domain state /// remains in the supplied sinks; this class owns only routing and teardown. /// -internal sealed class LiveSessionEventRouter : ILiveSessionEventRouting +public sealed class LiveSessionEventRouter : ILiveSessionEventRouting { private readonly LiveSessionSubscriptionSet _subscriptions = new(); private readonly Action? _constructionCheckpoint; @@ -267,7 +267,7 @@ internal sealed class LiveSessionEventRouter : ILiveSessionEventRouting message.RoomId, message.SenderName, message.Message, - TurbineChatRouting.DisplayName(message.RoomId, message.ChatType)); + TurbineChatDisplayNames.Resolve(message.RoomId, message.ChatType)); } private static void Validate( diff --git a/src/AcDream.App/Net/LiveSessionHost.cs b/src/AcDream.Runtime/Session/LiveSessionHost.cs similarity index 67% rename from src/AcDream.App/Net/LiveSessionHost.cs rename to src/AcDream.Runtime/Session/LiveSessionHost.cs index 80523456..1a1a9def 100644 --- a/src/AcDream.App/Net/LiveSessionHost.cs +++ b/src/AcDream.Runtime/Session/LiveSessionHost.cs @@ -1,24 +1,13 @@ using System.Runtime.ExceptionServices; using AcDream.Core.Net; -using AcDream.UI.Abstractions; -namespace AcDream.App.Net; +namespace AcDream.Runtime.Session; -internal interface ILiveSessionEventRouting : IDisposable -{ - void Attach(); -} - -internal interface ILiveSessionCommandRouting : ICommandBus, IDisposable -{ - void Activate(); -} - -internal sealed record LiveSessionRoutingFactories( +public sealed record LiveSessionRoutingFactories( Func CreateEvents, Func CreateCommands); -internal sealed record LiveSessionSelectionBindings( +public sealed record LiveSessionSelectionBindings( Action SetPlayerIdentity, Action SetVitalsIdentity, Action SetChatIdentity, @@ -26,27 +15,28 @@ internal sealed record LiveSessionSelectionBindings( Action SetVanishProbeIdentity, Action ClearCombat); -internal sealed record LiveSessionEnteredWorldBindings( +public sealed record LiveSessionEnteredWorldBindings( Action SetActiveCharacter, Action RestoreLayout, Action SyncToolbar, Action LoadCharacterSettings, Action ArmPlayerModeAutoEntry); -internal sealed record LiveSessionHostBindings( +public sealed record LiveSessionHostBindings( LiveSessionRoutingFactories Routing, - LiveSessionResetBindings Reset, + Action Reset, LiveSessionSelectionBindings Selection, LiveSessionEnteredWorldBindings EnteredWorld, Action Connecting, Action Connected); /// -/// App composition owner for the one canonical . -/// It owns callback ordering and per-generation route factories, but never -/// mirrors session, generation, identity, routing, or command state. +/// Runtime host for the one canonical . +/// It owns callback ordering and per-generation route factories supplied by +/// composition, but never mirrors session, generation, identity, routing, or +/// command state. /// -internal sealed class LiveSessionHost +public sealed class LiveSessionHost : IRuntimeSessionCommands { private sealed class PendingRouteRollback( ILiveSessionCommandRouting? commands, @@ -90,18 +80,21 @@ internal sealed class LiveSessionHost } private readonly LiveSessionController _controller; + private readonly LiveSessionConnectOptions? _connectOptions; private readonly LiveSessionRoutingFactories _routing; private readonly LiveSessionSelectionBindings _selection; private readonly LiveSessionEnteredWorldBindings _enteredWorld; - private readonly LiveSessionResetPlan _resetPlan; + private readonly Action _reset; private readonly LiveSessionLifecycleHost _lifecycle; private PendingRouteRollback? _pendingRouteRollback; public LiveSessionHost( LiveSessionController controller, - LiveSessionHostBindings bindings) + LiveSessionHostBindings bindings, + LiveSessionConnectOptions? connectOptions = null) { _controller = controller ?? throw new ArgumentNullException(nameof(controller)); + _connectOptions = connectOptions; ArgumentNullException.ThrowIfNull(bindings); _routing = bindings.Routing ?? throw new ArgumentNullException(nameof(bindings.Routing)); _selection = bindings.Selection ?? throw new ArgumentNullException(nameof(bindings.Selection)); @@ -109,11 +102,12 @@ internal sealed class LiveSessionHost ?? throw new ArgumentNullException(nameof(bindings.EnteredWorld)); ArgumentNullException.ThrowIfNull(_routing.CreateEvents); ArgumentNullException.ThrowIfNull(_routing.CreateCommands); + ArgumentNullException.ThrowIfNull(bindings.Reset); ArgumentNullException.ThrowIfNull(bindings.Connecting); ArgumentNullException.ThrowIfNull(bindings.Connected); Validate(_selection, _enteredWorld); - _resetPlan = LiveSessionResetManifest.Create(bindings.Reset); + _reset = bindings.Reset; _lifecycle = new LiveSessionLifecycleHost(new LiveSessionLifecycleBindings( Bind: BindSession, Reset: ResetSessionState, @@ -124,15 +118,48 @@ internal sealed class LiveSessionHost } public WorldSession? CurrentSession => _controller.CurrentSession; - public ICommandBus Commands => _controller.Commands; public bool IsInWorld => _controller.IsInWorld; - public LiveSessionStartResult Start(RuntimeOptions options) => + public LiveSessionStartResult Start(LiveSessionConnectOptions options) => _controller.Start(options, _lifecycle); - public LiveSessionStartResult Reconnect(RuntimeOptions options) => + public LiveSessionStartResult Reconnect(LiveSessionConnectOptions options) => _controller.Reconnect(options, _lifecycle); + public RuntimeSessionStartResult Start( + RuntimeGenerationToken expectedGeneration) + { + if (!TryValidate(expectedGeneration, out RuntimeSessionStartResult rejected)) + return rejected; + if (_connectOptions is null) + { + return new RuntimeSessionStartResult( + RuntimeSessionStartStatus.Inactive, + _controller.Generation); + } + + return Convert(_controller.Start(_connectOptions, _lifecycle)); + } + + public RuntimeSessionStartResult Reconnect( + RuntimeGenerationToken expectedGeneration) + { + if (!TryValidate(expectedGeneration, out RuntimeSessionStartResult rejected)) + return rejected; + if (_connectOptions is null) + { + return new RuntimeSessionStartResult( + RuntimeSessionStartStatus.Inactive, + _controller.Generation); + } + + return Convert(_controller.Reconnect(_connectOptions, _lifecycle)); + } + + public RuntimeTeardownAcknowledgement Stop( + RuntimeGenerationToken expectedGeneration) => + _controller.Stop(expectedGeneration); + private LiveSessionBinding BindSession(WorldSession session) { DrainPendingRouteRollback(); @@ -150,7 +177,6 @@ internal sealed class LiveSessionHost "The live-session command factory returned null."); return new LiveSessionBinding( session, - commands, activateCommands: commands.Activate, deactivateCommands: commands.Dispose, detachEvents: events.Dispose); @@ -168,7 +194,7 @@ internal sealed class LiveSessionHost // state below. Treat physical route convergence as the same hard // barrier used by normal LiveSessionBinding teardown. DrainPendingRouteRollback(); - _resetPlan.Execute(); + _reset(); } private void ApplySelection(LiveSessionCharacterSelection selection) @@ -239,4 +265,49 @@ internal sealed class LiveSessionHost ArgumentNullException.ThrowIfNull(entered.LoadCharacterSettings); ArgumentNullException.ThrowIfNull(entered.ArmPlayerModeAutoEntry); } + + private bool TryValidate( + RuntimeGenerationToken expectedGeneration, + out RuntimeSessionStartResult rejected) + { + RuntimeGenerationToken current = _controller.Generation; + if (expectedGeneration != current) + { + rejected = new RuntimeSessionStartResult( + RuntimeSessionStartStatus.StaleGeneration, + current); + return false; + } + + rejected = default; + return true; + } + + 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, + _controller.Generation, + result.Selection?.CharacterId ?? 0u, + result.Selection?.CharacterName ?? string.Empty, + result.Error); + } } diff --git a/src/AcDream.App/Net/LiveSessionLifecycleHost.cs b/src/AcDream.Runtime/Session/LiveSessionLifecycleHost.cs similarity index 84% rename from src/AcDream.App/Net/LiveSessionLifecycleHost.cs rename to src/AcDream.Runtime/Session/LiveSessionLifecycleHost.cs index 87596b75..2e34741e 100644 --- a/src/AcDream.App/Net/LiveSessionLifecycleHost.cs +++ b/src/AcDream.Runtime/Session/LiveSessionLifecycleHost.cs @@ -1,8 +1,8 @@ using AcDream.Core.Net; -namespace AcDream.App.Net; +namespace AcDream.Runtime.Session; -internal sealed record LiveSessionLifecycleBindings( +public sealed record LiveSessionLifecycleBindings( Func Bind, Action Reset, Action Connecting, @@ -11,11 +11,11 @@ internal sealed record LiveSessionLifecycleBindings( Action Entered); /// -/// Focused adapter between the session lifetime owner and App composition. -/// It tracks only the exact borrowed session attached to the host; domain and -/// presentation state remain behind the supplied lifecycle callbacks. +/// Focused adapter between the session lifetime owner and its composition +/// callbacks. It tracks only the exact borrowed session attached to the host; +/// domain and presentation state remain behind the supplied callbacks. /// -internal sealed class LiveSessionLifecycleHost : ILiveSessionLifecycleHost +public sealed class LiveSessionLifecycleHost : ILiveSessionLifecycleHost { private readonly LiveSessionLifecycleBindings _bindings; private WorldSession? _boundSession; diff --git a/src/AcDream.Runtime/Session/TurbineChatDisplayNames.cs b/src/AcDream.Runtime/Session/TurbineChatDisplayNames.cs new file mode 100644 index 00000000..ba1e6fe4 --- /dev/null +++ b/src/AcDream.Runtime/Session/TurbineChatDisplayNames.cs @@ -0,0 +1,22 @@ +using AcDream.Core.Net.Messages; + +namespace AcDream.Runtime.Session; + +internal static class TurbineChatDisplayNames +{ + public static string Resolve(uint roomId, uint chatType) => + (TurbineChat.ChatType)chatType switch + { + TurbineChat.ChatType.Allegiance => "Allegiance", + TurbineChat.ChatType.General => "General", + TurbineChat.ChatType.Trade => "Trade", + TurbineChat.ChatType.Lfg => "LFG", + TurbineChat.ChatType.Roleplay => "Roleplay", + TurbineChat.ChatType.Society => "Society", + TurbineChat.ChatType.SocietyCelHan => "Celestial Hand", + TurbineChat.ChatType.SocietyEldWeb => "Eldrytch Web", + TurbineChat.ChatType.SocietyRadBlo => "Radiant Blood", + TurbineChat.ChatType.Olthoi => "Olthoi", + _ => $"Room 0x{roomId:X8}", + }; +} diff --git a/tests/AcDream.App.Tests/Composition/SessionPlayerCompositionTests.cs b/tests/AcDream.App.Tests/Composition/SessionPlayerCompositionTests.cs index 5d8bc9fe..d02fc386 100644 --- a/tests/AcDream.App.Tests/Composition/SessionPlayerCompositionTests.cs +++ b/tests/AcDream.App.Tests/Composition/SessionPlayerCompositionTests.cs @@ -116,7 +116,7 @@ public sealed class SessionPlayerCompositionTests "new PlayerModeController(", "d.PortalTunnelFallback.Transfer(", "d.TeleportSink.BindOwned(localTeleport)", - "sessionRuntimeFactory.Create(liveSession)", + "LiveSessionHost sessionHost = sessionRuntimeFactory.Create(", "d.CombatModeCommands.BindOwned(combatCommand)", "d.RuntimeDiagnosticCommands.BindOwned(runtimeDiagnostics)", "GameplayInputActionRouter.Create(", diff --git a/tests/AcDream.App.Tests/FramePhaseTestDoubles.cs b/tests/AcDream.App.Tests/FramePhaseTestDoubles.cs index 1ef2419f..3df1b96b 100644 --- a/tests/AcDream.App.Tests/FramePhaseTestDoubles.cs +++ b/tests/AcDream.App.Tests/FramePhaseTestDoubles.cs @@ -1,4 +1,5 @@ using AcDream.App.Update; +using AcDream.Runtime.Session; namespace AcDream.App.Tests; @@ -9,7 +10,7 @@ internal sealed class TestLiveObjectFramePhase(Action tick) } internal sealed class TestLiveSessionFramePhase(Action tick) - : ILiveSessionFramePhase + : IRuntimeLiveSessionFramePhase { public void Tick() => tick(); } diff --git a/tests/AcDream.App.Tests/Net/GameWindowLiveSessionOwnershipTests.cs b/tests/AcDream.App.Tests/Net/GameWindowLiveSessionOwnershipTests.cs index 6eb45476..508c3545 100644 --- a/tests/AcDream.App.Tests/Net/GameWindowLiveSessionOwnershipTests.cs +++ b/tests/AcDream.App.Tests/Net/GameWindowLiveSessionOwnershipTests.cs @@ -2,6 +2,8 @@ using System.Reflection; using AcDream.App.Rendering; using AcDream.App.Net; using AcDream.Core.Net; +using AcDream.Runtime; +using AcDream.Runtime.Session; namespace AcDream.App.Tests.Net; @@ -30,6 +32,28 @@ public sealed class GameWindowLiveSessionOwnershipTests Assert.DoesNotContain(fields, field => field.Name == "_liveSessionCommands"); } + [Fact] + public void GraphicalSessionSourceBorrowsCanonicalRuntimeState() + { + FieldInfo[] fields = typeof(LiveSessionAppSource).GetFields(PrivateInstance); + + Assert.Equal(2, fields.Length); + Assert.Contains( + fields, + field => field.Name == "_session" + && field.FieldType == typeof(LiveSessionController)); + Assert.Contains( + fields, + field => field.Name == "_commands" + && field.FieldType == typeof(LiveSessionCommandSurface)); + Assert.DoesNotContain(fields, field => field.FieldType == typeof(WorldSession)); + Assert.DoesNotContain(fields, field => field.FieldType == typeof(bool)); + Assert.DoesNotContain( + fields, + field => field.FieldType == typeof(RuntimeGenerationToken) + || field.FieldType == typeof(ulong)); + } + [Theory] [InlineData("TryStartLiveSession")] [InlineData("ClearInboundEntityState")] diff --git a/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs b/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs index 5674cac4..be2d729d 100644 --- a/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs +++ b/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs @@ -27,6 +27,31 @@ public sealed class LiveSessionCommandRouterTests Assert.False(router.IsActive); } + [Fact] + public void CommandSurfacePublishesToOneBorrowedRouteAndRetiresStaleRoute() + { + var sent = new List(); + var surface = new LiveSessionCommandSurface(); + var first = NewRouter(sendTalk: text => sent.Add($"first:{text}")); + var firstLease = surface.Attach(first); + firstLease.Activate(); + + surface.Publish(new SendServerCommandCmd("@one")); + var second = NewRouter(sendTalk: text => sent.Add($"second:{text}")); + Assert.Throws(() => surface.Attach(second)); + + firstLease.Dispose(); + first.Publish(new SendServerCommandCmd("@stale")); + var secondLease = surface.Attach(second); + secondLease.Activate(); + surface.Publish(new SendServerCommandCmd("@two")); + secondLease.Dispose(); + + Assert.Equal(["first:@one", "second:@two"], sent); + Assert.False(first.IsActive); + Assert.False(second.IsActive); + } + [Fact] public void TellAndLegacyChannel_PreserveOutboundAndEchoPolicy() { diff --git a/tests/AcDream.App.Tests/Net/LiveSessionShutdownIntegrationTests.cs b/tests/AcDream.App.Tests/Net/LiveSessionShutdownIntegrationTests.cs new file mode 100644 index 00000000..408c019b --- /dev/null +++ b/tests/AcDream.App.Tests/Net/LiveSessionShutdownIntegrationTests.cs @@ -0,0 +1,124 @@ +using System.Net; +using AcDream.App.Rendering; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Runtime.Session; + +namespace AcDream.App.Tests.Net; + +public sealed class LiveSessionShutdownIntegrationTests +{ + [Fact] + public void ReentrantShutdownRetainsDependentsUntilRuntimeDisposeCompletes() + { + var operations = new TestOperations(); + var host = new TestHost(); + var controller = new LiveSessionController(operations); + controller.Start( + new LiveSessionConnectOptions( + true, + "127.0.0.1", + 9000, + "user", + "password"), + host); + bool dependentDisposed = false; + var shutdown = new ResourceShutdownTransaction( + new ResourceShutdownStage("session lifetime", + [ + new("live session", () => + { + controller.Dispose(); + if (!controller.IsDisposalComplete) + { + throw new InvalidOperationException( + "live-session disposal is still deferred"); + } + }), + ]), + new ResourceShutdownStage("session dependents", + [ + new("streamer", () => dependentDisposed = true), + ])); + operations.OnTick = () => + { + Assert.Throws(shutdown.CompleteOrThrow); + Assert.Equal(0, shutdown.CurrentStage); + Assert.False(dependentDisposed); + }; + + controller.Tick(); + + Assert.True(controller.IsDisposalComplete); + Assert.False(dependentDisposed); + shutdown.CompleteOrThrow(); + Assert.True(shutdown.IsComplete); + Assert.True(dependentDisposed); + } + + private sealed class TestOperations : ILiveSessionOperations + { + public Action? OnTick { get; set; } + + 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, "Ready", 0u)], + [], + 11, + "Runtime", + true, + true); + + public void EnterWorld(WorldSession session, int activeCharacterIndex) { } + public void Tick(WorldSession session) => OnTick?.Invoke(); + public void DisposeSession(WorldSession session) => session.Dispose(); + } + + private sealed class TestHost : ILiveSessionLifecycleHost + { + public LiveSessionBinding BindSession(WorldSession session) => + new( + session, + activateCommands: static () => { }, + deactivateCommands: static () => { }, + detachEvents: static () => { }); + + public void ResetSessionState() { } + public void ReportConnecting(string host, int port, string user) { } + public void ReportConnected() { } + public void ApplySelectedCharacter(LiveSessionCharacterSelection selection) { } + public void ApplyEnteredWorld(LiveSessionCharacterSelection selection) { } + public void DetachSession(WorldSession session) { } + } + + 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() { } + } +} diff --git a/tests/AcDream.App.Tests/Rendering/DevToolsFramePresenterTests.cs b/tests/AcDream.App.Tests/Rendering/DevToolsFramePresenterTests.cs index 0dcd182a..4895eb4b 100644 --- a/tests/AcDream.App.Tests/Rendering/DevToolsFramePresenterTests.cs +++ b/tests/AcDream.App.Tests/Rendering/DevToolsFramePresenterTests.cs @@ -13,8 +13,8 @@ public sealed class DevToolsFramePresenterTests public void CommandBusOwnedBindingReleasesExactlyAndAllowsRebind() { var source = new DevToolsCommandBusSource(); - using var first = new LiveSessionController(); - using var second = new LiveSessionController(); + var first = new TestUiSessionTarget(); + var second = new TestUiSessionTarget(); IDisposable firstBinding = source.BindOwned(first); Assert.Same(first.Commands, source.Current); @@ -27,6 +27,13 @@ public sealed class DevToolsFramePresenterTests Assert.Same(second.Commands, source.Current); } + private sealed class TestUiSessionTarget : ILiveUiSessionTarget + { + public bool IsInWorld => false; + public AcDream.Core.Net.WorldSession? CurrentSession => null; + public ICommandBus Commands { get; } = new RecordingCommandBus(); + } + [Fact] public void Frame_PreservesBeginThenMenuPanelsAndDrawDataOrder() { diff --git a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs index 1b6b92de..d7480c34 100644 --- a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs +++ b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs @@ -89,7 +89,7 @@ public sealed class GameWindowSlice8BoundaryTests "SessionPlayerComposition.cs")); AssertAppearsInOrder( sessionPhase, - "sessionRuntimeFactory.Create(liveSession)", + "LiveSessionHost sessionHost = sessionRuntimeFactory.Create(", "d.CombatModeCommands.BindOwned(combatCommand)", "d.RuntimeDiagnosticCommands.BindOwned(runtimeDiagnostics)", "GameplayInputActionRouter.Create(", diff --git a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs index 7589049d..68a91bb3 100644 --- a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs +++ b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs @@ -17,6 +17,7 @@ using AcDream.Core.Physics; using AcDream.Core.Selection; using AcDream.Core.World; using AcDream.Runtime; +using AcDream.Runtime.Session; using AcDream.UI.Abstractions; using AcDream.UI.Abstractions.Input; @@ -227,9 +228,9 @@ public sealed class CurrentGameRuntimeAdapterTests _session = new LiveSessionController(new SessionOperations()); Host = CreateHost(_session, Commands, Identity); Runtime = new CurrentGameRuntimeAdapter( - Options, _session, Host, + Commands, Identity, Entities, Objects, @@ -313,7 +314,7 @@ public sealed class CurrentGameRuntimeAdapterTests new LiveSessionRoutingFactories( _ => new NoopEventRouting(), _ => commands), - reset, + LiveSessionResetManifest.Create(reset).Execute, new LiveSessionSelectionBindings( id => identity.ServerGuid = id, _ => { }, @@ -328,7 +329,13 @@ public sealed class CurrentGameRuntimeAdapterTests _ => { }, () => { }), (_, _, _) => { }, - () => { })); + () => { }), + new LiveSessionConnectOptions( + true, + "127.0.0.1", + 9000, + "user", + "password")); } private static RuntimeOptions LiveOptions() @@ -493,7 +500,8 @@ public sealed class CurrentGameRuntimeAdapterTests } internal sealed class RecordingCommandRouting - : ILiveSessionCommandRouting + : ILiveSessionCommandRouting, + ICommandBus { private bool _active; diff --git a/tests/AcDream.App.Tests/Net/LiveSessionControllerTests.cs b/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs similarity index 93% rename from tests/AcDream.App.Tests/Net/LiveSessionControllerTests.cs rename to tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs index 17c822e4..e51cbe62 100644 --- a/tests/AcDream.App.Tests/Net/LiveSessionControllerTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs @@ -1,11 +1,10 @@ using System.Net; -using AcDream.App.Net; -using AcDream.App.Rendering; using AcDream.Core.Net; using AcDream.Core.Net.Messages; -using AcDream.UI.Abstractions; +using AcDream.Runtime; +using AcDream.Runtime.Session; -namespace AcDream.App.Tests.Net; +namespace AcDream.Runtime.Tests.Session; public sealed class LiveSessionControllerTests { @@ -32,7 +31,7 @@ public sealed class LiveSessionControllerTests public void Dispose() { } } - private sealed class TestCommandBus : ICommandBus + private sealed class TestCommandBus { public bool Active { get; set; } public int PublishCount { get; private set; } @@ -169,7 +168,6 @@ public sealed class LiveSessionControllerTests CommandBuses.Add(commands); return new LiveSessionBinding( BindingSessionOverride ?? session, - commands, activateCommands: () => { calls.Add("activate"); @@ -298,7 +296,6 @@ public sealed class LiveSessionControllerTests result.Selection); Assert.True(controller.IsInWorld); Assert.Same(operations.Sessions[0], controller.CurrentSession); - Assert.Same(host.CommandBuses[0], controller.Commands); Assert.True(host.CommandBuses[0].Active); } @@ -319,7 +316,6 @@ public sealed class LiveSessionControllerTests Assert.Equal(2, host.ResetCount); Assert.Empty(operations.Sessions); - Assert.Equal(NullCommandBus.Instance, controller.Commands); } [Fact] @@ -573,7 +569,6 @@ public sealed class LiveSessionControllerTests Assert.Equal(LiveSessionStartStatus.Deferred, result.Status); Assert.False(controller.IsInWorld); Assert.Null(controller.CurrentSession); - Assert.Same(NullCommandBus.Instance, controller.Commands); if (operations.Sessions.Count == 0) { Assert.Contains( @@ -695,7 +690,6 @@ public sealed class LiveSessionControllerTests Assert.False(controller.IsInWorld); Assert.Null(controller.CurrentSession); Assert.Equal(1, operations.DisposeCounts[operations.Sessions[0]]); - Assert.Same(NullCommandBus.Instance, controller.Commands); controller.Dispose(); } @@ -843,69 +837,72 @@ public sealed class LiveSessionControllerTests Assert.Equal(1, operations.DisposeCounts[session]); Assert.Equal(0, commands.PublishCount); Assert.False(controller.IsInWorld); - Assert.Same(NullCommandBus.Instance, controller.Commands); Assert.Throws(() => controller.Start(LiveOptions(), host)); } [Fact] - public void ReentrantShutdownRetainsControllerAndDependentsUntilDeferredDisposeCompletes() + public void GenerationScopedStopAcknowledgesTheCompleteTeardownTransaction() { var calls = new List(); var operations = new TestOperations(calls); var host = new TestHost(calls); var controller = new LiveSessionController(operations); controller.Start(LiveOptions(), host); - bool dependentDisposed = false; - var shutdown = new ResourceShutdownTransaction( - new ResourceShutdownStage("session lifetime", - [ - new("live session", () => - { - controller.Dispose(); - if (!controller.IsDisposalComplete) - { - throw new InvalidOperationException( - "live-session disposal is still deferred"); - } - }), - ]), - new ResourceShutdownStage("session dependents", - [ - new("streamer", () => dependentDisposed = true), - ])); - operations.OnTick = () => - { - Assert.Throws(shutdown.CompleteOrThrow); - Assert.Equal(0, shutdown.CurrentStage); - Assert.False(dependentDisposed); - }; + RuntimeGenerationToken retired = controller.Generation; - controller.Tick(); + RuntimeTeardownAcknowledgement acknowledgement = + controller.Stop(retired); - Assert.True(controller.IsDisposalComplete); - Assert.False(dependentDisposed); - shutdown.CompleteOrThrow(); - Assert.True(shutdown.IsComplete); - Assert.True(dependentDisposed); + Assert.True(acknowledgement.IsComplete); + Assert.Equal(retired, acknowledgement.RetiredGeneration); + Assert.Equal(controller.Generation, acknowledgement.CurrentGeneration); + Assert.Equal( + RuntimeTeardownStage.Complete, + acknowledgement.CompletedStages); } - private static RuntimeOptions LiveOptions( - bool live = true, - string? user = "user") + [Fact] + public void FailedStopAcknowledgesOnlyCompletedPrefixAndRetryDrainsSuffix() { - var environment = new Dictionary + var calls = new List(); + var operations = new TestOperations(calls); + var host = new TestHost(calls) { - ["ACDREAM_LIVE"] = live ? "1" : "0", - ["ACDREAM_TEST_HOST"] = "127.0.0.1", - ["ACDREAM_TEST_PORT"] = "9000", - ["ACDREAM_TEST_USER"] = user, - ["ACDREAM_TEST_PASS"] = "password", + FailEventDetachOnce = true, }; - return RuntimeOptions.Parse( - "dat", - name => environment.GetValueOrDefault(name)); + var controller = new LiveSessionController(operations); + controller.Start(LiveOptions(), host); + + RuntimeTeardownAcknowledgement failed = + controller.Stop(controller.Generation); + + Assert.Equal(RuntimeCommandStatus.Rejected, failed.Status); + Assert.True( + (failed.CompletedStages & RuntimeTeardownStage.CommandsInert) != 0); + Assert.True( + (failed.CompletedStages & RuntimeTeardownStage.InboundDetached) == 0); + Assert.True( + (failed.CompletedStages & RuntimeTeardownStage.TransportDisposed) == 0); + + RuntimeTeardownAcknowledgement retry = + controller.Stop(failed.CurrentGeneration); + + Assert.True(retry.IsComplete); + Assert.Equal(1, host.DeactivateCount); + Assert.Equal(2, host.EventDetachCount); + Assert.Single(operations.DisposeCounts); } + private static LiveSessionConnectOptions LiveOptions( + bool live = true, + string? user = "user") => + new( + live, + "127.0.0.1", + 9000, + user ?? string.Empty, + "password"); + private static CharacterList.Parsed AvailableCharacters() => new( 0u, [ diff --git a/tests/AcDream.App.Tests/Net/LiveSessionEventRouterTests.cs b/tests/AcDream.Runtime.Tests/Session/LiveSessionEventRouterTests.cs similarity index 99% rename from tests/AcDream.App.Tests/Net/LiveSessionEventRouterTests.cs rename to tests/AcDream.Runtime.Tests/Session/LiveSessionEventRouterTests.cs index bda1e946..19a8868b 100644 --- a/tests/AcDream.App.Tests/Net/LiveSessionEventRouterTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/LiveSessionEventRouterTests.cs @@ -1,6 +1,5 @@ using System.Net; using System.Reflection; -using AcDream.App.Net; using AcDream.Core.Chat; using AcDream.Core.Combat; using AcDream.Core.Items; @@ -8,8 +7,9 @@ using AcDream.Core.Net; using AcDream.Core.Player; using AcDream.Core.Social; using AcDream.Core.Spells; +using AcDream.Runtime.Session; -namespace AcDream.App.Tests.Net; +namespace AcDream.Runtime.Tests.Session; public sealed class LiveSessionEventRouterTests { diff --git a/tests/AcDream.App.Tests/Net/LiveSessionHostTests.cs b/tests/AcDream.Runtime.Tests/Session/LiveSessionHostTests.cs similarity index 85% rename from tests/AcDream.App.Tests/Net/LiveSessionHostTests.cs rename to tests/AcDream.Runtime.Tests/Session/LiveSessionHostTests.cs index 747a5ec6..89efa166 100644 --- a/tests/AcDream.App.Tests/Net/LiveSessionHostTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/LiveSessionHostTests.cs @@ -1,10 +1,9 @@ using System.Net; -using AcDream.App.Net; -using AcDream.App.Rendering; using AcDream.Core.Net; using AcDream.Core.Net.Messages; +using AcDream.Runtime.Session; -namespace AcDream.App.Tests.Net; +namespace AcDream.Runtime.Tests.Session; public sealed class LiveSessionHostTests { @@ -45,7 +44,6 @@ public sealed class LiveSessionHostTests ], calls); Assert.Same(controller.CurrentSession, host.CurrentSession); - Assert.Same(commands, host.Commands); Assert.True(host.IsInWorld); controller.Dispose(); @@ -223,7 +221,7 @@ public sealed class LiveSessionHostTests Func createCommands) => new(controller, new LiveSessionHostBindings( Routing: new(createEvents, createCommands), - Reset: ResetBindings(() => calls.Add("reset")), + Reset: () => calls.Add("reset"), Selection: new( id => calls.Add($"player:{id}"), id => calls.Add($"vitals:{id}"), @@ -240,57 +238,15 @@ public sealed class LiveSessionHostTests Connecting: (_, _, _) => calls.Add("connecting"), Connected: () => calls.Add("connected"))); - private static LiveSessionResetBindings ResetBindings(Action reset) - { - Action noop = static () => { }; - return new LiveSessionResetBindings - { - MouseCapture = reset, - 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, - }; - } - - private static RuntimeOptions LiveOptions(bool live = true, string? user = "user") - { - var environment = new Dictionary - { - ["ACDREAM_LIVE"] = live ? "1" : "0", - ["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 LiveSessionConnectOptions LiveOptions( + bool live = true, + string? user = "user") => + new( + live, + "127.0.0.1", + 9000, + user ?? string.Empty, + "password"); private sealed class TestEventRouting(List calls) : ILiveSessionEventRouting diff --git a/tests/AcDream.App.Tests/Net/LiveSessionLifecycleHostTests.cs b/tests/AcDream.Runtime.Tests/Session/LiveSessionLifecycleHostTests.cs similarity index 96% rename from tests/AcDream.App.Tests/Net/LiveSessionLifecycleHostTests.cs rename to tests/AcDream.Runtime.Tests/Session/LiveSessionLifecycleHostTests.cs index 2d20f49a..bac7638e 100644 --- a/tests/AcDream.App.Tests/Net/LiveSessionLifecycleHostTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/LiveSessionLifecycleHostTests.cs @@ -1,9 +1,8 @@ using System.Net; -using AcDream.App.Net; using AcDream.Core.Net; -using AcDream.UI.Abstractions; +using AcDream.Runtime.Session; -namespace AcDream.App.Tests.Net; +namespace AcDream.Runtime.Tests.Session; public sealed class LiveSessionLifecycleHostTests { @@ -82,7 +81,6 @@ public sealed class LiveSessionLifecycleHostTests calls.Add("bind"); return new LiveSessionBinding( session, - NullCommandBus.Instance, activateCommands: () => calls.Add("activate"), deactivateCommands: () => calls.Add("deactivate"), detachEvents: () => calls.Add("detach-events")); diff --git a/tests/AcDream.App.Tests/Net/LiveSessionSubscriptionSetTests.cs b/tests/AcDream.Runtime.Tests/Session/LiveSessionSubscriptionSetTests.cs similarity index 96% rename from tests/AcDream.App.Tests/Net/LiveSessionSubscriptionSetTests.cs rename to tests/AcDream.Runtime.Tests/Session/LiveSessionSubscriptionSetTests.cs index e120aabc..503a2358 100644 --- a/tests/AcDream.App.Tests/Net/LiveSessionSubscriptionSetTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/LiveSessionSubscriptionSetTests.cs @@ -1,6 +1,6 @@ -using AcDream.App.Net; +using AcDream.Runtime.Session; -namespace AcDream.App.Tests.Net; +namespace AcDream.Runtime.Tests.Session; public sealed class LiveSessionSubscriptionSetTests { diff --git a/tests/AcDream.Runtime.Tests/Session/RuntimeLiveSessionNoWindowTests.cs b/tests/AcDream.Runtime.Tests/Session/RuntimeLiveSessionNoWindowTests.cs new file mode 100644 index 00000000..411de51b --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Session/RuntimeLiveSessionNoWindowTests.cs @@ -0,0 +1,165 @@ +using System.Net; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Runtime.Session; + +namespace AcDream.Runtime.Tests.Session; + +public sealed class RuntimeLiveSessionNoWindowTests +{ + [Fact] + public void RuntimeOnlyHostEntersAndTearsDownWithoutPresentationAssemblies() + { + var calls = new List(); + var operations = new TestOperations(calls); + using var controller = new LiveSessionController(operations); + var host = new LiveSessionHost( + controller, + new LiveSessionHostBindings( + new LiveSessionRoutingFactories( + _ => new EventRoute(calls), + _ => new CommandRoute(calls)), + () => calls.Add("reset"), + new LiveSessionSelectionBindings( + id => calls.Add($"player:{id}"), + _ => { }, + _ => { }, + _ => { }, + _ => { }, + () => { }), + new LiveSessionEnteredWorldBindings( + name => calls.Add($"entered:{name}"), + () => { }, + () => { }, + _ => { }, + () => { }), + (_, _, _) => calls.Add("connecting"), + () => calls.Add("connected")), + new LiveSessionConnectOptions( + true, + "127.0.0.1", + 9000, + "test", + "password")); + + RuntimeSessionStartResult start = + host.Start(RuntimeGenerationToken.Initial); + RuntimeTeardownAcknowledgement stop = host.Stop(start.Generation); + + Assert.Equal(RuntimeSessionStartStatus.Connected, start.Status); + Assert.Equal(0x50000001u, start.CharacterId); + Assert.True(stop.IsComplete); + Assert.Null(controller.CurrentSession); + Assert.False(controller.IsInWorld); + Assert.Equal( + [ + "reset", + "resolve", + "create", + "attach-events", + "connecting", + "connect", + "connected", + "characters", + "player:1342177281", + "enter:0", + "activate-commands", + "entered:Runtime", + "deactivate-commands", + "detach-events", + "dispose-session", + "reset", + ], + calls); + + string[] loaded = AppDomain.CurrentDomain.GetAssemblies() + .Select(static assembly => assembly.GetName().Name ?? string.Empty) + .ToArray(); + Assert.DoesNotContain(loaded, static name => + name.StartsWith("AcDream.App", StringComparison.OrdinalIgnoreCase) + || name.StartsWith("AcDream.UI.", StringComparison.OrdinalIgnoreCase) + || name.StartsWith("Silk.NET", StringComparison.OrdinalIgnoreCase) + || name.StartsWith("OpenAL", StringComparison.OrdinalIgnoreCase) + || name.StartsWith("Arch", StringComparison.OrdinalIgnoreCase) + || name.StartsWith("ImGui", StringComparison.OrdinalIgnoreCase)); + } + + private sealed class TestOperations(List calls) + : ILiveSessionOperations + { + public IPEndPoint ResolveEndpoint(string host, int port) + { + calls.Add("resolve"); + return new IPEndPoint(IPAddress.Loopback, port); + } + + public WorldSession CreateSession(IPEndPoint endpoint) + { + calls.Add("create"); + return new WorldSession(endpoint, new TestTransport()); + } + + public void Connect(WorldSession session, string user, string password) => + calls.Add("connect"); + + public CharacterList.Parsed GetCharacters(WorldSession session) + { + calls.Add("characters"); + return new CharacterList.Parsed( + 0u, + [new CharacterList.Character(0x50000001u, "Runtime", 0u)], + [], + 11, + "NoWindow", + true, + true); + } + + public void EnterWorld(WorldSession session, int activeCharacterIndex) => + calls.Add($"enter:{activeCharacterIndex}"); + + public void Tick(WorldSession session) { } + + public void DisposeSession(WorldSession session) + { + calls.Add("dispose-session"); + session.Dispose(); + } + } + + private sealed class EventRoute(List calls) + : ILiveSessionEventRouting + { + public void Attach() => calls.Add("attach-events"); + public void Dispose() => calls.Add("detach-events"); + } + + private sealed class CommandRoute(List calls) + : ILiveSessionCommandRouting + { + public void Activate() => calls.Add("activate-commands"); + public void Dispose() => calls.Add("deactivate-commands"); + } + + 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() { } + } +}