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() { } + } +}