From d9ccf8a6b94cd0691aa3900e853c9d4ea7169026 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 12:20:55 +0200 Subject: [PATCH] refactor(net): own complete live session lifecycle --- src/AcDream.App/Net/LiveSessionController.cs | 752 +++++++++++++-- src/AcDream.Core.Net/AcDream.Core.Net.csproj | 1 + src/AcDream.Core.Net/WorldSession.cs | 51 +- .../Net/LiveSessionControllerTests.cs | 864 ++++++++++++++++++ .../WorldSessionConstructionTests.cs | 41 + 5 files changed, 1618 insertions(+), 91 deletions(-) create mode 100644 tests/AcDream.App.Tests/Net/LiveSessionControllerTests.cs create mode 100644 tests/AcDream.Core.Net.Tests/WorldSessionConstructionTests.cs diff --git a/src/AcDream.App/Net/LiveSessionController.cs b/src/AcDream.App/Net/LiveSessionController.cs index eb714f05..9204f8b4 100644 --- a/src/AcDream.App/Net/LiveSessionController.cs +++ b/src/AcDream.App/Net/LiveSessionController.cs @@ -1,114 +1,688 @@ -using System; using System.Net; using System.Net.Sockets; using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.UI.Abstractions; namespace AcDream.App.Net; -/// -/// Owns the network-side lifecycle of a live โ€” -/// DNS resolution, endpoint construction, session instantiation, per-frame -/// Tick, and disposal. The post-construction work (event wiring, -/// Connect, character validation, EnterWorld, post-login UI -/// state setup) stays in GameWindow for now because it touches -/// renderer / chat / player-controller state that hasn't been extracted yet. -/// -/// -/// -/// Step 2 of the extraction sequence described in -/// docs/architecture/code-structure.md ยง4. Future expansions can -/// fold more of TryStartLiveSession into this controller as the -/// surrounding state (event handlers, command bus, settings VM) gets -/// extracted in later steps. -/// -/// -/// Behavior preservation contract: this class produces -/// byte-identical console output and event-wireup sequencing to the -/// pre-refactor inline code path. The DNS-resolution lines, the -/// "live: connecting to ..." line, and the wiring-vs-Connect ordering -/// all match the previous flow. -/// -/// -public sealed class LiveSessionController : IDisposable +internal enum LiveSessionStartStatus { - /// - /// Active session, or when offline / before - /// succeeded / after . - /// - public WorldSession? Session { get; private set; } + Disabled, + MissingCredentials, + NoCharacters, + Connected, + Deferred, + Failed, +} - /// - /// Resolves the endpoint, instantiates the , - /// hands it to for caller-side event - /// subscriptions, and returns the live session. The caller is - /// responsible for the subsequent Connect / - /// EnterWorld dance. - /// - public WorldSession? CreateAndWire(RuntimeOptions options, Action wireEvents) +internal sealed record LiveSessionCharacterSelection( + int ActiveIndex, + uint CharacterId, + string CharacterName, + string AccountName); + +internal 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. +/// +internal interface ILiveSessionLifecycleHost +{ + LiveSessionBinding BindSession(WorldSession session); + void ResetSessionState(); + void ReportConnecting(string host, int port, string user); + void ReportConnected(); + void ApplySelectedCharacter(LiveSessionCharacterSelection selection); + void ApplyEnteredWorld(LiveSessionCharacterSelection selection); + void DetachSession(WorldSession session); +} + +/// +/// Owns routing and commands for one exact session. Teardown is ordered and +/// retryable: outbound commands become inert before inbound subscriptions are +/// detached. +/// +internal sealed class LiveSessionBinding : IDisposable +{ + private readonly Action _activateCommands; + private readonly Action _deactivateCommands; + private readonly Action _detachEvents; + private bool _commandsDeactivated; + private bool _eventsDetached; + private bool _commandsActivated; + + public LiveSessionBinding( + WorldSession session, + ICommandBus commands, + Action activateCommands, + Action deactivateCommands, + Action detachEvents) { - if (options is null) throw new ArgumentNullException(nameof(options)); - if (wireEvents is null) throw new ArgumentNullException(nameof(wireEvents)); - - if (!options.LiveMode) return null; - - if (string.IsNullOrEmpty(options.LiveUser) || string.IsNullOrEmpty(options.LivePass)) - { - Console.WriteLine("live: ACDREAM_LIVE set but TEST_USER/TEST_PASS missing; skipping"); - return null; - } - - try - { - var endpoint = ResolveEndpoint(options.LiveHost, options.LivePort); - Console.WriteLine($"live: connecting to {endpoint} as {options.LiveUser}"); - Session = new WorldSession(endpoint); - wireEvents(Session); - return Session; - } - catch (Exception ex) - { - Console.WriteLine($"live: session setup failed: {ex.Message}"); - Session?.Dispose(); - Session = null; - return null; - } + 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)); } - /// - /// Drains the inbound network queue. Proxies to - /// ; no-op when - /// is . - /// - public void Tick() => Session?.Tick(); + public WorldSession Session { get; } + public ICommandBus Commands { get; } + + public void ActivateCommands() + { + if (_commandsDeactivated || _eventsDetached) + throw new ObjectDisposedException(nameof(LiveSessionBinding)); + if (_commandsActivated) + return; + _activateCommands(); + if (_commandsDeactivated || _eventsDetached) + return; + _commandsActivated = true; + } - /// - /// Tears down the live session. Safe to call multiple times. - /// public void Dispose() { - Session?.Dispose(); - Session = null; + if (!_commandsDeactivated) + { + _deactivateCommands(); + _commandsDeactivated = true; + } + if (!_eventsDetached) + { + _detachEvents(); + _eventsDetached = true; + } } +} - /// - /// Resolve a host string (literal IP or DNS name) to an - /// . Pre-refactor logic preserved exactly: - /// try first, fall back to - /// , prefer IPv4 (ACE + retail use - /// IPv4 UDP exclusively), throw on empty resolution. - /// - private static IPEndPoint ResolveEndpoint(string host, int port) +internal interface ILiveSessionOperations +{ + IPEndPoint ResolveEndpoint(string host, int port); + WorldSession CreateSession(IPEndPoint endpoint); + void Connect(WorldSession session, string user, string password); + CharacterList.Parsed? GetCharacters(WorldSession session); + void EnterWorld(WorldSession session, int activeCharacterIndex); + void Tick(WorldSession session); + void DisposeSession(WorldSession session); +} + +internal sealed class ProductionLiveSessionOperations : ILiveSessionOperations +{ + public static ProductionLiveSessionOperations Instance { get; } = new(); + + private ProductionLiveSessionOperations() { } + + public IPEndPoint ResolveEndpoint(string host, int port) { IPAddress ip; if (!IPAddress.TryParse(host, out ip!)) { - var addrs = Dns.GetHostAddresses(host); - ip = Array.Find(addrs, a => a.AddressFamily == AddressFamily.InterNetwork) - ?? (addrs.Length > 0 - ? addrs[0] - : throw new Exception($"DNS resolved no addresses for '{host}'")); + IPAddress[] addresses = Dns.GetHostAddresses(host); + ip = Array.Find( + addresses, + static address => address.AddressFamily == AddressFamily.InterNetwork) + ?? (addresses.Length != 0 + ? addresses[0] + : throw new InvalidOperationException( + $"DNS resolved no addresses for '{host}'")); Console.WriteLine($"live: resolved {host} โ†’ {ip}"); } return new IPEndPoint(ip, port); } + + public WorldSession CreateSession(IPEndPoint endpoint) => new(endpoint); + + public void Connect(WorldSession session, string user, string password) => + session.Connect(user, password); + + public CharacterList.Parsed? GetCharacters(WorldSession session) => session.Characters; + + public void EnterWorld(WorldSession session, int activeCharacterIndex) => + session.EnterWorld(activeCharacterIndex); + + public void Tick(WorldSession session) => session.Tick(); + + public void DisposeSession(WorldSession session) => session.Dispose(); +} + +/// +/// Sole owner of the App lifetime for a live WorldSession: 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. +/// +public sealed class LiveSessionController : IDisposable +{ + private sealed class SessionScope( + WorldSession session, + ILiveSessionLifecycleHost host) + { + private int _teardownStage; + + public WorldSession Session { get; } = session; + public ILiveSessionLifecycleHost Host { get; } = host; + public LiveSessionBinding? Binding { get; set; } + public bool HostAttached { get; set; } + + public void DrainTeardown(ILiveSessionOperations operations) + { + if (_teardownStage == 0) + { + Binding?.Dispose(); + _teardownStage = 1; + } + if (_teardownStage == 1) + { + operations.DisposeSession(Session); + _teardownStage = 2; + } + if (_teardownStage == 2) + { + if (HostAttached) + Host.DetachSession(Session); + _teardownStage = 3; + } + if (_teardownStage == 3) + { + Host.ResetSessionState(); + _teardownStage = 4; + } + } + + public bool IsTeardownComplete => _teardownStage == 4; + } + + private enum PendingKind + { + Stop, + Reconnect, + Dispose, + } + + private sealed record PendingOperation( + PendingKind Kind, + RuntimeOptions? Options = null, + ILiveSessionLifecycleHost? Host = null); + + private readonly object _gate = new(); + private readonly ILiveSessionOperations _operations; + private SessionScope? _scope; + private SessionScope? _retiredScope; + private ILiveSessionLifecycleHost? _pendingInitialResetHost; + private PendingOperation? _pendingOperation; + private WorldSession? _legacySession; + private int _operationDepth; + private bool _inWorld; + private bool _disposeRequested; + private bool _disposed; + private ulong _generation; + private LiveSessionCharacterSelection? _activeSelection; + + public LiveSessionController() + : this(ProductionLiveSessionOperations.Instance) + { + } + + internal LiveSessionController(ILiveSessionOperations operations) + { + _operations = operations ?? throw new ArgumentNullException(nameof(operations)); + } + + public WorldSession? CurrentSession + { + get { lock (_gate) return _scope?.Session ?? _legacySession; } + } + + /// Temporary Slice-3 compatibility alias; removed at GameWindow cutover. + public WorldSession? Session => CurrentSession; + + public ICommandBus Commands + { + get + { + lock (_gate) + return _inWorld && _scope?.Binding is { } binding + ? binding.Commands + : NullCommandBus.Instance; + } + } + + public bool IsInWorld + { + get { lock (_gate) return _inWorld; } + } + + public ulong SessionGeneration + { + get { lock (_gate) return _generation; } + } + + internal LiveSessionStartResult Start( + RuntimeOptions options, + ILiveSessionLifecycleHost host) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(host); + lock (_gate) + { + ThrowIfDisposing(); + if (_operationDepth != 0) + return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); + if (_inWorld) + return ConnectedResult(); + return RunTopLevel(() => StartCore(options, host, resetHost: true)); + } + } + + internal LiveSessionStartResult Reconnect( + RuntimeOptions options, + ILiveSessionLifecycleHost host) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(host); + lock (_gate) + { + ThrowIfDisposing(); + if (_operationDepth != 0) + { + Schedule(new PendingOperation(PendingKind.Reconnect, options, host)); + return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); + } + return RunTopLevel(() => ReconnectCore(options, host)); + } + } + + internal void Stop() + { + lock (_gate) + { + if (_disposed) + return; + if (_operationDepth != 0) + { + Schedule(new PendingOperation(PendingKind.Stop)); + return; + } + RunTopLevel(StopCore); + } + } + + public void Tick() + { + lock (_gate) + { + if (_disposed) + return; + if (_legacySession is not null && _scope is null) + { + _legacySession.Tick(); + return; + } + if (!_inWorld || _scope is null || _operationDepth != 0) + return; + + RunTopLevel(() => + { + SessionScope scope = _scope; + ulong generation = _generation; + try + { + _operations.Tick(scope.Session); + if (!IsCurrent(scope, generation)) + return; + } + catch (Exception tickError) + { + Exception error = StopAfterFailure(tickError); + throw error; + } + }); + } + } + + public void Dispose() + { + lock (_gate) + { + if (_disposed) + return; + _disposeRequested = true; + if (_operationDepth != 0) + { + Schedule(new PendingOperation(PendingKind.Dispose)); + return; + } + + RunTopLevel(DisposeCore); + } + } + + private LiveSessionStartResult ReconnectCore( + RuntimeOptions options, + ILiveSessionLifecycleHost host) + { + ILiveSessionLifecycleHost? oldHost = + _scope?.Host ?? _retiredScope?.Host ?? _pendingInitialResetHost; + ulong generationBeforeStop = _generation; + try + { + StopCore(); + } + catch (Exception error) + { + return new LiveSessionStartResult(LiveSessionStartStatus.Failed, Error: error); + } + if (_generation != unchecked(generationBeforeStop + 1)) + return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); + return StartCore(options, host, resetHost: !ReferenceEquals(oldHost, host)); + } + + private LiveSessionStartResult StartCore( + RuntimeOptions options, + ILiveSessionLifecycleHost host, + bool resetHost) + { + ulong generation = ++_generation; + try + { + DrainRetiredScope(); + if (_generation != generation) + return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); + if (resetHost) + ResetHostBeforeStart(host, generation); + if (_generation != generation) + return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); + } + catch (Exception error) + { + return new LiveSessionStartResult(LiveSessionStartStatus.Failed, Error: error); + } + + if (!options.LiveMode) + return new LiveSessionStartResult(LiveSessionStartStatus.Disabled); + if (string.IsNullOrEmpty(options.LiveUser) || string.IsNullOrEmpty(options.LivePass)) + return new LiveSessionStartResult(LiveSessionStartStatus.MissingCredentials); + + SessionScope? scope = null; + try + { + IPEndPoint endpoint = _operations.ResolveEndpoint(options.LiveHost, options.LivePort); + if (_generation != generation) + return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); + Console.WriteLine($"live: connecting to {endpoint} as {options.LiveUser}"); + WorldSession session = _operations.CreateSession(endpoint); + scope = new SessionScope(session, host); + _scope = scope; + _inWorld = false; + if (!IsCurrent(scope, generation)) + return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); + + LiveSessionBinding binding = host.BindSession(session); + scope.Binding = binding; + scope.HostAttached = true; + if (!ReferenceEquals(binding.Session, session)) + { + throw new InvalidOperationException( + "The live-session host returned a binding for a different session."); + } + if (!IsCurrent(scope, generation)) + return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); + + host.ReportConnecting(options.LiveHost, options.LivePort, options.LiveUser); + if (!IsCurrent(scope, generation)) + return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); + + _operations.Connect(session, options.LiveUser, options.LivePass); + if (!IsCurrent(scope, generation)) + return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); + host.ReportConnected(); + if (!IsCurrent(scope, generation)) + return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); + + CharacterList.Parsed? characters = _operations.GetCharacters(session); + if (characters is null + || !CharacterList.TrySelectFirstAvailable(characters, out CharacterList.Selection selected)) + { + Console.WriteLine("live: no available characters on account; disconnecting"); + StopCore(); + return new LiveSessionStartResult(LiveSessionStartStatus.NoCharacters); + } + + var selection = new LiveSessionCharacterSelection( + selected.ActiveIndex, + selected.Character.Id, + selected.Character.Name, + characters.AccountName); + host.ApplySelectedCharacter(selection); + if (!IsCurrent(scope, generation)) + return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); + + Console.WriteLine( + $"live: entering world as 0x{selection.CharacterId:X8} {selection.CharacterName}"); + _operations.EnterWorld(session, selection.ActiveIndex); + if (!IsCurrent(scope, generation)) + return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); + + binding.ActivateCommands(); + if (!IsCurrent(scope, generation)) + return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); + _inWorld = true; + _activeSelection = selection; + host.ApplyEnteredWorld(selection); + if (!IsCurrent(scope, generation)) + return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); + + Console.WriteLine("live: in world โ€” CreateObject stream active"); + return new LiveSessionStartResult( + LiveSessionStartStatus.Connected, + selection); + } + catch (Exception startError) + { + return new LiveSessionStartResult( + LiveSessionStartStatus.Failed, + Error: StopAfterFailure(startError)); + } + } + + private Exception StopAfterFailure(Exception operationError) + { + try + { + StopCore(); + return operationError; + } + catch (Exception cleanupError) + { + return new AggregateException( + "Live-session operation and cleanup both failed.", + operationError, + cleanupError); + } + } + + private void StopCore() + { + ++_generation; + _inWorld = false; + _activeSelection = null; + if (_scope is { } scope) + { + _scope = null; + if (_retiredScope is not null && !ReferenceEquals(_retiredScope, scope)) + throw new InvalidOperationException( + "A second live-session scope cannot retire before the first converges."); + _retiredScope = scope; + } + DrainRetiredScope(); + DrainPendingInitialReset(); + } + + private void DrainRetiredScope() + { + if (_retiredScope is not { } retired) + return; + retired.DrainTeardown(_operations); + if (retired.IsTeardownComplete) + _retiredScope = null; + } + + private void ResetHostBeforeStart( + ILiveSessionLifecycleHost host, + ulong generation) + { + bool requestedHostAlreadyReset = false; + if (_pendingInitialResetHost is { } pending) + { + pending.ResetSessionState(); + _pendingInitialResetHost = null; + requestedHostAlreadyReset = ReferenceEquals(pending, host); + } + if (requestedHostAlreadyReset || _generation != generation) + return; + + try + { + host.ResetSessionState(); + } + catch + { + _pendingInitialResetHost = host; + throw; + } + } + + private void DrainPendingInitialReset() + { + if (_pendingInitialResetHost is not { } host) + return; + host.ResetSessionState(); + _pendingInitialResetHost = null; + } + + private void Schedule(PendingOperation operation) + { + if (_pendingOperation?.Kind == PendingKind.Dispose) + return; + _pendingOperation = operation; + ++_generation; + _inWorld = false; + _scope?.Binding?.Dispose(); + } + + private T RunTopLevel(Func operation) + { + _operationDepth++; + try + { + return operation(); + } + finally + { + _operationDepth--; + if (_operationDepth == 0) + DrainPendingOperations(); + } + } + + private void RunTopLevel(Action operation) => + RunTopLevel(() => + { + operation(); + return true; + }); + + private void DrainPendingOperations() + { + while (_pendingOperation is { } pending) + { + _pendingOperation = null; + _operationDepth++; + try + { + switch (pending.Kind) + { + case PendingKind.Stop: + StopCore(); + break; + case PendingKind.Reconnect: + _ = ReconnectCore(pending.Options!, pending.Host!); + break; + case PendingKind.Dispose: + DisposeCore(); + break; + } + } + finally + { + _operationDepth--; + } + } + } + + private void DisposeCore() + { + StopCore(); + if (_legacySession is { } legacy) + { + _operations.DisposeSession(legacy); + _legacySession = null; + } + _disposed = true; + } + + private bool IsCurrent(SessionScope scope, ulong generation) => + ReferenceEquals(_scope, scope) && _generation == generation; + + private LiveSessionStartResult ConnectedResult() + => new(LiveSessionStartStatus.Connected, _activeSelection); + + private void ThrowIfDisposing() + { + if (_disposeRequested || _disposed) + throw new ObjectDisposedException(nameof(LiveSessionController)); + } + + // Temporary compatibility surface for Slice 3 Commit E. The complete + // controller above is exercised independently before GameWindow cuts over. + public WorldSession? CreateAndWire(RuntimeOptions options, Action wireEvents) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(wireEvents); + lock (_gate) + { + ThrowIfDisposing(); + if (!options.LiveMode) + return null; + if (string.IsNullOrEmpty(options.LiveUser) || string.IsNullOrEmpty(options.LivePass)) + { + Console.WriteLine( + "live: ACDREAM_LIVE set but TEST_USER/TEST_PASS missing; skipping"); + return null; + } + + try + { + IPEndPoint endpoint = _operations.ResolveEndpoint(options.LiveHost, options.LivePort); + Console.WriteLine($"live: connecting to {endpoint} as {options.LiveUser}"); + _legacySession = _operations.CreateSession(endpoint); + wireEvents(_legacySession); + return _legacySession; + } + catch (Exception error) + { + Console.WriteLine($"live: session setup failed: {error.Message}"); + if (_legacySession is not null) + _operations.DisposeSession(_legacySession); + _legacySession = null; + return null; + } + } + } } diff --git a/src/AcDream.Core.Net/AcDream.Core.Net.csproj b/src/AcDream.Core.Net/AcDream.Core.Net.csproj index 49c20a3d..e52d1627 100644 --- a/src/AcDream.Core.Net/AcDream.Core.Net.csproj +++ b/src/AcDream.Core.Net/AcDream.Core.Net.csproj @@ -12,5 +12,6 @@ + diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 1f27bc25..e000ee0d 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -10,6 +10,29 @@ using AcDream.Core.Net.Packets; namespace AcDream.Core.Net; +internal interface IWorldSessionTransport : IDisposable +{ + void Send(ReadOnlySpan datagram); + void Send(IPEndPoint remote, ReadOnlySpan datagram); + byte[]? Receive(TimeSpan timeout, out IPEndPoint? from); +} + +internal sealed class NetClientWorldSessionTransport(IPEndPoint remote) + : IWorldSessionTransport +{ + private readonly NetClient _client = new(remote); + + public void Send(ReadOnlySpan datagram) => _client.Send(datagram); + + public void Send(IPEndPoint endpoint, ReadOnlySpan datagram) => + _client.Send(endpoint, datagram); + + public byte[]? Receive(TimeSpan timeout, out IPEndPoint? from) => + _client.Receive(timeout, out from); + + public void Dispose() => _client.Dispose(); +} + /// /// High-level AC client session: owns a , drives /// the full handshake + character-enter-world flow, and converts the @@ -600,7 +623,7 @@ public sealed class WorldSession : IDisposable public CharacterList.Parsed? Characters { get; private set; } - private readonly NetClient _net; + private readonly IWorldSessionTransport _net; private long _lastInboundPacketTicks = Stopwatch.GetTimestamp(); private long _lastPingRequestTicks; private long _lastPingRoundTripBits = BitConverter.DoubleToInt64Bits(double.NaN); @@ -676,10 +699,34 @@ public sealed class WorldSession : IDisposable private uint _gameActionSequence; public WorldSession(IPEndPoint serverLogin) + : this( + serverLogin, + static endpoint => new NetClientWorldSessionTransport(endpoint)) { + } + + internal WorldSession( + IPEndPoint serverLogin, + IWorldSessionTransport transport) + : this(serverLogin, _ => transport) + { + } + + internal WorldSession( + IPEndPoint serverLogin, + Func transportFactory) + { + ArgumentNullException.ThrowIfNull(serverLogin); + ArgumentNullException.ThrowIfNull(transportFactory); + if (serverLogin.Port == ushort.MaxValue) + throw new ArgumentOutOfRangeException( + nameof(serverLogin), + "The login endpoint must leave room for the adjacent connect port."); + _loginEndpoint = serverLogin; _connectEndpoint = new IPEndPoint(serverLogin.Address, serverLogin.Port + 1); - _net = new NetClient(serverLogin); + _net = transportFactory(serverLogin) + ?? throw new InvalidOperationException("The session transport factory returned null."); // Phase I.6: SetTurbineChatChannels (0x0295) is a GameEvent // sub-opcode of 0xF7B0, not a top-level opcode. Route it through diff --git a/tests/AcDream.App.Tests/Net/LiveSessionControllerTests.cs b/tests/AcDream.App.Tests/Net/LiveSessionControllerTests.cs new file mode 100644 index 00000000..3c56847d --- /dev/null +++ b/tests/AcDream.App.Tests/Net/LiveSessionControllerTests.cs @@ -0,0 +1,864 @@ +using System.Net; +using AcDream.App.Net; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.UI.Abstractions; + +namespace AcDream.App.Tests.Net; + +public sealed class LiveSessionControllerTests +{ + private sealed class TestTransport : IWorldSessionTransport + { + public void Send(ReadOnlySpan datagram) { } + + public void Send(IPEndPoint remote, ReadOnlySpan datagram) { } + + public byte[]? Receive(TimeSpan timeout, out IPEndPoint? from) + { + from = null; + return null; + } + + public void Dispose() { } + } + + private sealed class TestCommandBus : ICommandBus + { + public bool Active { get; set; } + public int PublishCount { get; private set; } + + public void Publish(T command) where T : notnull + { + if (Active) + PublishCount++; + } + } + + private sealed class TestOperations(List calls) : ILiveSessionOperations + { + public CharacterList.Parsed? Characters { get; set; } = AvailableCharacters(); + public Action? OnResolve { get; set; } + public Action? OnCreate { get; set; } + public Action? OnConnect { get; set; } + public Action? OnCharacters { get; set; } + public Action? OnEnterWorld { get; set; } + public Action? OnTick { get; set; } + public Action? OnDispose { get; set; } + public bool ThrowOnConnect { get; set; } + public bool ThrowOnCreate { get; set; } + public bool ThrowOnCharacters { get; set; } + public bool ThrowOnEnterWorld { get; set; } + public bool ThrowOnTick { get; set; } + public bool FailDisposeOnce { get; set; } + public List Sessions { get; } = []; + public Dictionary DisposeCounts { get; } = []; + public int EnterWorldCount { get; private set; } + public int TickCount { get; private set; } + + public IPEndPoint ResolveEndpoint(string host, int port) + { + calls.Add("resolve"); + OnResolve?.Invoke(); + return new IPEndPoint(IPAddress.Loopback, port); + } + + public WorldSession CreateSession(IPEndPoint endpoint) + { + calls.Add("create"); + OnCreate?.Invoke(); + if (ThrowOnCreate) + throw new InvalidOperationException("create failure"); + var session = new WorldSession(endpoint, new TestTransport()); + Sessions.Add(session); + return session; + } + + public void Connect(WorldSession session, string user, string password) + { + calls.Add("connect"); + OnConnect?.Invoke(); + if (ThrowOnConnect) + throw new InvalidOperationException("connect failure"); + } + + public CharacterList.Parsed? GetCharacters(WorldSession session) + { + OnCharacters?.Invoke(); + if (ThrowOnCharacters) + throw new InvalidOperationException("characters failure"); + return Characters; + } + + public void EnterWorld(WorldSession session, int activeCharacterIndex) + { + calls.Add($"enter:{activeCharacterIndex}"); + EnterWorldCount++; + OnEnterWorld?.Invoke(); + if (ThrowOnEnterWorld) + throw new InvalidOperationException("enter failure"); + } + + public void Tick(WorldSession session) + { + calls.Add("tick"); + TickCount++; + OnTick?.Invoke(); + if (ThrowOnTick) + throw new InvalidOperationException("tick failure"); + } + + public void DisposeSession(WorldSession session) + { + calls.Add("dispose-session"); + OnDispose?.Invoke(); + if (FailDisposeOnce) + { + FailDisposeOnce = false; + throw new InvalidOperationException("dispose failure"); + } + DisposeCounts[session] = DisposeCounts.GetValueOrDefault(session) + 1; + } + } + + private sealed class TestHost(List calls) : ILiveSessionLifecycleHost + { + public Action? OnBind { get; set; } + public Action? OnReset { get; set; } + public Action? OnConnecting { get; set; } + public Action? OnConnected { get; set; } + public Action? OnSelected { get; set; } + public Action? OnActivate { get; set; } + public Action? OnEntered { get; set; } + public Action? OnDetach { get; set; } + public bool FailResetOnce { get; set; } + public bool FailDetachOnce { get; set; } + public bool FailDeactivateOnce { get; set; } + public bool FailEventDetachOnce { get; set; } + public bool ThrowOnBind { get; set; } + public bool ThrowOnConnecting { get; set; } + public bool ThrowOnConnected { get; set; } + public bool ThrowOnSelected { get; set; } + public bool ThrowOnActivate { get; set; } + public bool ThrowOnEntered { get; set; } + public WorldSession? BindingSessionOverride { get; set; } + public int ResetCount { get; private set; } + public int DetachCount { get; private set; } + public int DeactivateCount { get; private set; } + public int EventDetachCount { get; private set; } + public int ActivateCount { get; private set; } + public List CommandBuses { get; } = []; + public List Selections { get; } = []; + + public LiveSessionBinding BindSession(WorldSession session) + { + calls.Add("bind"); + OnBind?.Invoke(); + if (ThrowOnBind) + throw new InvalidOperationException("bind failure"); + var commands = new TestCommandBus(); + CommandBuses.Add(commands); + return new LiveSessionBinding( + BindingSessionOverride ?? session, + commands, + activateCommands: () => + { + calls.Add("activate"); + ActivateCount++; + commands.Active = true; + OnActivate?.Invoke(); + if (ThrowOnActivate) + throw new InvalidOperationException("activate failure"); + }, + deactivateCommands: () => + { + calls.Add("deactivate"); + DeactivateCount++; + commands.Active = false; + if (FailDeactivateOnce) + { + FailDeactivateOnce = false; + throw new InvalidOperationException("deactivate failure"); + } + }, + detachEvents: () => + { + calls.Add("detach-events"); + EventDetachCount++; + if (FailEventDetachOnce) + { + FailEventDetachOnce = false; + throw new InvalidOperationException("event detach failure"); + } + }); + } + + public void ResetSessionState() + { + calls.Add("reset"); + ResetCount++; + OnReset?.Invoke(); + if (FailResetOnce) + { + FailResetOnce = false; + throw new InvalidOperationException("reset failure"); + } + } + + public void ReportConnecting(string host, int port, string user) + { + calls.Add("report-connecting"); + OnConnecting?.Invoke(); + if (ThrowOnConnecting) + throw new InvalidOperationException("connecting failure"); + } + + public void ReportConnected() + { + calls.Add("report-connected"); + OnConnected?.Invoke(); + if (ThrowOnConnected) + throw new InvalidOperationException("connected failure"); + } + + public void ApplySelectedCharacter(LiveSessionCharacterSelection selection) + { + calls.Add("selected"); + Selections.Add(selection); + OnSelected?.Invoke(); + if (ThrowOnSelected) + throw new InvalidOperationException("selected failure"); + } + + public void ApplyEnteredWorld(LiveSessionCharacterSelection selection) + { + calls.Add("entered"); + OnEntered?.Invoke(); + if (ThrowOnEntered) + throw new InvalidOperationException("entered failure"); + } + + public void DetachSession(WorldSession session) + { + calls.Add("detach-session"); + DetachCount++; + OnDetach?.Invoke(); + if (FailDetachOnce) + { + FailDetachOnce = false; + throw new InvalidOperationException("detach failure"); + } + } + } + + public enum ReentrantStopPoint + { + Reset, + Resolve, + Create, + Bind, + Connecting, + Connect, + Connected, + Selected, + EnterWorld, + Activate, + Entered, + } + + [Fact] + public void Start_BindsBeforeConnectAndPublishesCanonicalSelectionAfterEnterWorld() + { + var calls = new List(); + var operations = new TestOperations(calls); + var host = new TestHost(calls); + var controller = new LiveSessionController(operations); + + LiveSessionStartResult result = controller.Start(LiveOptions(), host); + + Assert.Equal(LiveSessionStartStatus.Connected, result.Status); + Assert.Equal( + [ + "reset", "resolve", "create", "bind", "report-connecting", + "connect", "report-connected", "selected", "enter:1", + "activate", "entered", + ], + calls); + Assert.Equal( + new LiveSessionCharacterSelection(1, 0x50000002u, "Ready", "Canonical"), + 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); + } + + [Fact] + public void Start_DisabledAndMissingCredentialsResetButNeverConstructSession() + { + var calls = new List(); + var operations = new TestOperations(calls); + var host = new TestHost(calls); + var controller = new LiveSessionController(operations); + + Assert.Equal( + LiveSessionStartStatus.Disabled, + controller.Start(LiveOptions(live: false), host).Status); + Assert.Equal( + LiveSessionStartStatus.MissingCredentials, + controller.Start(LiveOptions(user: null), host).Status); + + Assert.Equal(2, host.ResetCount); + Assert.Empty(operations.Sessions); + Assert.Equal(NullCommandBus.Instance, controller.Commands); + } + + [Fact] + public void Start_NoAvailableCharacterTearsDownExactScope() + { + var calls = new List(); + var operations = new TestOperations(calls) + { + Characters = new CharacterList.Parsed( + 0u, + [new CharacterList.Character(0x50000001u, "Grey", 5u)], + [], + 11, + "Canonical", + true, + true), + }; + var host = new TestHost(calls); + var controller = new LiveSessionController(operations); + + LiveSessionStartResult result = controller.Start(LiveOptions(), host); + + Assert.Equal(LiveSessionStartStatus.NoCharacters, result.Status); + Assert.False(controller.IsInWorld); + Assert.Null(controller.CurrentSession); + Assert.Equal(1, host.DeactivateCount); + Assert.Equal(1, host.EventDetachCount); + Assert.Equal(1, host.DetachCount); + Assert.Equal(2, host.ResetCount); + Assert.Equal(1, operations.DisposeCounts[operations.Sessions[0]]); + } + + [Fact] + public void Start_ConnectFailureConvergesOffline() + { + var calls = new List(); + var operations = new TestOperations(calls) { ThrowOnConnect = true }; + var host = new TestHost(calls); + var controller = new LiveSessionController(operations); + + LiveSessionStartResult result = controller.Start(LiveOptions(), host); + + Assert.Equal(LiveSessionStartStatus.Failed, result.Status); + Assert.Contains("connect failure", result.Error!.ToString()); + Assert.False(controller.IsInWorld); + Assert.Null(controller.CurrentSession); + Assert.Equal(1, operations.DisposeCounts[operations.Sessions[0]]); + Assert.Equal(1, host.DetachCount); + } + + [Theory] + [InlineData("create")] + [InlineData("bind")] + [InlineData("enter")] + public void Start_OtherConstructionAndEntryFailuresConvergeOffline(string phase) + { + var calls = new List(); + var operations = new TestOperations(calls) + { + ThrowOnCreate = phase == "create", + ThrowOnEnterWorld = phase == "enter", + }; + var host = new TestHost(calls) { ThrowOnBind = phase == "bind" }; + var controller = new LiveSessionController(operations); + + LiveSessionStartResult result = controller.Start(LiveOptions(), host); + + Assert.Equal(LiveSessionStartStatus.Failed, result.Status); + Assert.Contains($"{phase} failure", result.Error!.ToString()); + Assert.False(controller.IsInWorld); + Assert.Null(controller.CurrentSession); + if (phase == "create") + { + Assert.Empty(operations.Sessions); + Assert.Equal(1, host.ResetCount); + } + else + { + Assert.Single(operations.Sessions); + Assert.Equal(1, operations.DisposeCounts[operations.Sessions[0]]); + Assert.Equal(2, host.ResetCount); + } + Assert.Equal(phase == "enter" ? 1 : 0, host.DetachCount); + } + + [Fact] + public void Start_HealthyDuplicateIsIdempotent() + { + var calls = new List(); + var operations = new TestOperations(calls); + var host = new TestHost(calls); + var controller = new LiveSessionController(operations); + LiveSessionStartResult first = controller.Start(LiveOptions(), host); + + LiveSessionStartResult duplicate = controller.Start(LiveOptions(), host); + + Assert.Equal(LiveSessionStartStatus.Connected, duplicate.Status); + Assert.Equal(first.Selection, duplicate.Selection); + Assert.Single(operations.Sessions); + Assert.Equal(1, host.ResetCount); + Assert.Equal(1, host.ActivateCount); + } + + [Fact] + public void Reconnect_QuiescesAThenDisposesDetachesResetsBeforeConstructingB() + { + var calls = new List(); + var operations = new TestOperations(calls); + var host = new TestHost(calls); + var controller = new LiveSessionController(operations); + controller.Start(LiveOptions(), host); + WorldSession sessionA = operations.Sessions[0]; + TestCommandBus commandsA = host.CommandBuses[0]; + calls.Clear(); + + LiveSessionStartResult result = controller.Reconnect(LiveOptions(), host); + + Assert.Equal(LiveSessionStartStatus.Connected, result.Status); + Assert.Equal( + [ + "deactivate", "detach-events", "dispose-session", "detach-session", + "reset", "resolve", "create", "bind", "report-connecting", + "connect", "report-connected", "selected", "enter:1", + "activate", "entered", + ], + calls); + commandsA.Publish(new object()); + Assert.Equal(0, commandsA.PublishCount); + Assert.Equal(1, operations.DisposeCounts[sessionA]); + Assert.Equal(2, operations.Sessions.Count); + Assert.Same(operations.Sessions[1], controller.CurrentSession); + } + + [Theory] + [InlineData("detach", "stop")] + [InlineData("detach", "dispose")] + [InlineData("detach", "reconnect")] + [InlineData("reset", "stop")] + [InlineData("reset", "dispose")] + [InlineData("reset", "reconnect")] + public void Reconnect_ReentrantTeardownRequestNeverPublishesSupersededB( + string callbackPhase, + string request) + { + var calls = new List(); + var operations = new TestOperations(calls); + var host = new TestHost(calls); + var controller = new LiveSessionController(operations); + controller.Start(LiveOptions(), host); + bool requested = false; + Action callback = () => + { + if (requested) + return; + requested = true; + switch (request) + { + case "stop": controller.Stop(); break; + case "dispose": controller.Dispose(); break; + case "reconnect": + Assert.Equal( + LiveSessionStartStatus.Deferred, + controller.Reconnect(LiveOptions(), host).Status); + break; + } + }; + if (callbackPhase == "detach") + host.OnDetach = callback; + else + host.OnReset = callback; + + LiveSessionStartResult outer = controller.Reconnect(LiveOptions(), host); + + Assert.Equal(LiveSessionStartStatus.Deferred, outer.Status); + Assert.Equal(request == "reconnect" ? 2 : 1, operations.Sessions.Count); + Assert.Equal(request == "reconnect", controller.IsInWorld); + if (request == "dispose") + Assert.Throws(() => controller.Start(LiveOptions(), host)); + controller.Dispose(); + } + + [Fact] + public void MismatchedBindingWithInterruptedDetachRetainsCleanupOwnershipForRetry() + { + var calls = new List(); + var operations = new TestOperations(calls); + using var mismatchedSession = new WorldSession( + new IPEndPoint(IPAddress.Loopback, 9001), + new TestTransport()); + var host = new TestHost(calls) + { + BindingSessionOverride = mismatchedSession, + FailEventDetachOnce = true, + }; + var controller = new LiveSessionController(operations); + + LiveSessionStartResult result = controller.Start(LiveOptions(), host); + + Assert.Equal(LiveSessionStartStatus.Failed, result.Status); + Assert.Contains("different session", result.Error!.ToString()); + Assert.Equal(0, operations.DisposeCounts.GetValueOrDefault(operations.Sessions[0])); + Assert.Equal(1, host.DeactivateCount); + Assert.Equal(1, host.EventDetachCount); + + controller.Stop(); + + Assert.Equal(1, operations.DisposeCounts[operations.Sessions[0]]); + Assert.Equal(1, host.DeactivateCount); + Assert.Equal(2, host.EventDetachCount); + Assert.Equal(1, host.DetachCount); + controller.Dispose(); + } + + [Theory] + [InlineData(ReentrantStopPoint.Reset)] + [InlineData(ReentrantStopPoint.Resolve)] + [InlineData(ReentrantStopPoint.Create)] + [InlineData(ReentrantStopPoint.Bind)] + [InlineData(ReentrantStopPoint.Connecting)] + [InlineData(ReentrantStopPoint.Connect)] + [InlineData(ReentrantStopPoint.Connected)] + [InlineData(ReentrantStopPoint.Selected)] + [InlineData(ReentrantStopPoint.EnterWorld)] + [InlineData(ReentrantStopPoint.Activate)] + [InlineData(ReentrantStopPoint.Entered)] + public void ReentrantStop_InvalidatesOuterGenerationAndCannotResurrect( + ReentrantStopPoint point) + { + var calls = new List(); + var operations = new TestOperations(calls); + var host = new TestHost(calls); + var controller = new LiveSessionController(operations); + Action stop = controller.Stop; + switch (point) + { + case ReentrantStopPoint.Reset: host.OnReset = stop; break; + case ReentrantStopPoint.Resolve: operations.OnResolve = stop; break; + case ReentrantStopPoint.Create: operations.OnCreate = stop; break; + case ReentrantStopPoint.Bind: host.OnBind = stop; break; + case ReentrantStopPoint.Connecting: host.OnConnecting = stop; break; + case ReentrantStopPoint.Connect: operations.OnConnect = stop; break; + case ReentrantStopPoint.Connected: host.OnConnected = stop; break; + case ReentrantStopPoint.Selected: host.OnSelected = stop; break; + case ReentrantStopPoint.EnterWorld: operations.OnEnterWorld = stop; break; + case ReentrantStopPoint.Activate: host.OnActivate = stop; break; + case ReentrantStopPoint.Entered: host.OnEntered = stop; break; + } + + LiveSessionStartResult result = controller.Start(LiveOptions(), host); + + 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( + point, + new[] { ReentrantStopPoint.Reset, ReentrantStopPoint.Resolve }); + } + else + { + Assert.Equal(1, operations.DisposeCounts[operations.Sessions[0]]); + } + Assert.Equal( + point is ReentrantStopPoint.Reset + or ReentrantStopPoint.Resolve + or ReentrantStopPoint.Create + ? 0 + : 1, + host.DetachCount); + } + + [Fact] + public void ReentrantDuplicateStartIsDeferredWithoutDisturbingOuterAttempt() + { + var calls = new List(); + var operations = new TestOperations(calls); + var host = new TestHost(calls); + var controller = new LiveSessionController(operations); + LiveSessionStartResult? nested = null; + host.OnSelected = () => nested = controller.Start(LiveOptions(), host); + + LiveSessionStartResult outer = controller.Start(LiveOptions(), host); + + Assert.Equal(LiveSessionStartStatus.Deferred, nested!.Status); + Assert.Equal(LiveSessionStartStatus.Connected, outer.Status); + Assert.True(controller.IsInWorld); + Assert.Single(operations.Sessions); + } + + [Fact] + public void ReentrantStartFromFailingEnteredCallbackCannotObserveUncommittedSession() + { + var calls = new List(); + var operations = new TestOperations(calls); + var host = new TestHost(calls); + var controller = new LiveSessionController(operations); + LiveSessionStartResult? nested = null; + host.OnEntered = () => + { + nested = controller.Start(LiveOptions(), host); + throw new InvalidOperationException("entered failure"); + }; + + LiveSessionStartResult outer = controller.Start(LiveOptions(), host); + + Assert.Equal(LiveSessionStartStatus.Deferred, nested!.Status); + Assert.Equal(LiveSessionStartStatus.Failed, outer.Status); + Assert.False(controller.IsInWorld); + Assert.Null(controller.CurrentSession); + controller.Dispose(); + } + + [Theory] + [InlineData("reconnect")] + [InlineData("dispose")] + public void ReentrantLifecycleRequestDuringActivationDoesNotLeakActiveCommands( + string request) + { + var calls = new List(); + var operations = new TestOperations(calls); + var host = new TestHost(calls); + var controller = new LiveSessionController(operations); + bool requested = false; + host.OnActivate = () => + { + if (requested) + return; + requested = true; + if (request == "reconnect") + controller.Reconnect(LiveOptions(), host); + else + controller.Dispose(); + }; + + LiveSessionStartResult outer = controller.Start(LiveOptions(), host); + + Assert.Equal(LiveSessionStartStatus.Deferred, outer.Status); + Assert.False(host.CommandBuses[0].Active); + Assert.Equal(request == "reconnect" ? 2 : 1, operations.Sessions.Count); + Assert.Equal(request == "reconnect", controller.IsInWorld); + controller.Dispose(); + } + + [Theory] + [InlineData("connecting")] + [InlineData("connected")] + [InlineData("characters")] + [InlineData("selected")] + [InlineData("activate")] + [InlineData("entered")] + public void Start_CallbackFailureConvergesOffline(string phase) + { + var calls = new List(); + var operations = new TestOperations(calls); + var host = new TestHost(calls); + switch (phase) + { + case "connecting": host.ThrowOnConnecting = true; break; + case "connected": host.ThrowOnConnected = true; break; + case "characters": operations.ThrowOnCharacters = true; break; + case "selected": host.ThrowOnSelected = true; break; + case "activate": host.ThrowOnActivate = true; break; + case "entered": host.ThrowOnEntered = true; break; + } + var controller = new LiveSessionController(operations); + + LiveSessionStartResult result = controller.Start(LiveOptions(), host); + + Assert.Equal(LiveSessionStartStatus.Failed, result.Status); + Assert.Contains($"{phase} failure", result.Error!.ToString()); + Assert.False(controller.IsInWorld); + Assert.Null(controller.CurrentSession); + Assert.Equal(1, operations.DisposeCounts[operations.Sessions[0]]); + Assert.Same(NullCommandBus.Instance, controller.Commands); + controller.Dispose(); + } + + [Theory] + [InlineData("deactivate")] + [InlineData("events")] + [InlineData("session")] + [InlineData("detach")] + [InlineData("reset")] + public void Stop_RetriesExactFailedTeardownStageWithoutRepeatingCompletedWork( + string phase) + { + var calls = new List(); + var operations = new TestOperations(calls); + var host = new TestHost(calls); + var controller = new LiveSessionController(operations); + controller.Start(LiveOptions(), host); + WorldSession session = operations.Sessions[0]; + switch (phase) + { + case "deactivate": host.FailDeactivateOnce = true; break; + case "events": host.FailEventDetachOnce = true; break; + case "session": operations.FailDisposeOnce = true; break; + case "detach": host.FailDetachOnce = true; break; + case "reset": host.FailResetOnce = true; break; + } + + Assert.Throws(controller.Stop); + controller.Stop(); + + Assert.False(controller.IsInWorld); + Assert.Null(controller.CurrentSession); + Assert.Equal(1, operations.DisposeCounts[session]); + Assert.Equal(phase == "detach" ? 2 : 1, host.DetachCount); + Assert.Equal(phase == "reset" ? 3 : 2, host.ResetCount); + controller.Dispose(); + } + + [Fact] + public void ReconnectRequestedDuringTickRunsAfterTickAndReplacesExactGeneration() + { + var calls = new List(); + var operations = new TestOperations(calls); + var host = new TestHost(calls); + var controller = new LiveSessionController(operations); + controller.Start(LiveOptions(), host); + WorldSession sessionA = operations.Sessions[0]; + bool requested = false; + operations.OnTick = () => + { + if (requested) + return; + requested = true; + LiveSessionStartResult nested = controller.Reconnect(LiveOptions(), host); + Assert.Equal(LiveSessionStartStatus.Deferred, nested.Status); + }; + + controller.Tick(); + + Assert.True(controller.IsInWorld); + Assert.Equal(2, operations.Sessions.Count); + Assert.Equal(1, operations.DisposeCounts[sessionA]); + Assert.Same(operations.Sessions[1], controller.CurrentSession); + } + + [Fact] + public void ResetFailureBlocksConstructionUntilRetryConverges() + { + var calls = new List(); + var operations = new TestOperations(calls); + var host = new TestHost(calls) { FailResetOnce = true }; + var controller = new LiveSessionController(operations); + + LiveSessionStartResult first = controller.Start(LiveOptions(), host); + LiveSessionStartResult second = controller.Start(LiveOptions(), host); + + Assert.Equal(LiveSessionStartStatus.Failed, first.Status); + Assert.Equal(LiveSessionStartStatus.Connected, second.Status); + Assert.Equal(2, host.ResetCount); + Assert.Single(operations.Sessions); + } + + [Fact] + public void FailedDetachRetainsRetiredScopeAndBlocksBFactoryUntilRetry() + { + var calls = new List(); + var operations = new TestOperations(calls); + var host = new TestHost(calls) { FailDetachOnce = true }; + var controller = new LiveSessionController(operations); + controller.Start(LiveOptions(), host); + WorldSession sessionA = operations.Sessions[0]; + + LiveSessionStartResult first = controller.Reconnect(LiveOptions(), host); + + Assert.Equal(LiveSessionStartStatus.Failed, first.Status); + Assert.Single(operations.Sessions); + Assert.Equal(1, operations.DisposeCounts[sessionA]); + Assert.False(controller.IsInWorld); + + LiveSessionStartResult retry = controller.Reconnect(LiveOptions(), host); + + Assert.Equal(LiveSessionStartStatus.Connected, retry.Status); + Assert.Equal(2, operations.Sessions.Count); + Assert.Equal(1, operations.DisposeCounts[sessionA]); + Assert.Equal(2, host.DetachCount); + } + + [Fact] + public void TickFailureCleansScopeBeforeRethrowing() + { + var calls = new List(); + var operations = new TestOperations(calls) { ThrowOnTick = true }; + var host = new TestHost(calls); + var controller = new LiveSessionController(operations); + controller.Start(LiveOptions(), host); + WorldSession session = operations.Sessions[0]; + + Assert.Throws(controller.Tick); + + Assert.False(controller.IsInWorld); + Assert.Null(controller.CurrentSession); + Assert.Equal(1, operations.DisposeCounts[session]); + } + + [Fact] + public void DisposeIsIdempotentAndMakesOldCommandsInert() + { + var calls = new List(); + var operations = new TestOperations(calls); + var host = new TestHost(calls); + var controller = new LiveSessionController(operations); + controller.Start(LiveOptions(), host); + WorldSession session = operations.Sessions[0]; + TestCommandBus commands = host.CommandBuses[0]; + + controller.Dispose(); + controller.Dispose(); + commands.Publish(new object()); + + 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)); + } + + 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", + name => environment.GetValueOrDefault(name)); + } + + private static CharacterList.Parsed AvailableCharacters() => new( + 0u, + [ + new CharacterList.Character(0x50000001u, "Grey", 10u), + new CharacterList.Character(0x50000002u, "Ready", 0u), + ], + [new CharacterList.Character(0x50000003u, "Deleted", 0u)], + 11, + "Canonical", + true, + true); +} diff --git a/tests/AcDream.Core.Net.Tests/WorldSessionConstructionTests.cs b/tests/AcDream.Core.Net.Tests/WorldSessionConstructionTests.cs new file mode 100644 index 00000000..0718a742 --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/WorldSessionConstructionTests.cs @@ -0,0 +1,41 @@ +using System.Net; + +namespace AcDream.Core.Net.Tests; + +public sealed class WorldSessionConstructionTests +{ + [Fact] + public void InvalidEndpointIsRejectedBeforeTransportAllocation() + { + int allocationCount = 0; + IWorldSessionTransport Allocate(IPEndPoint _) + { + allocationCount++; + return new TestTransport(); + } + + Assert.Throws( + () => new WorldSession(null!, Allocate)); + Assert.Throws( + () => new WorldSession( + new IPEndPoint(IPAddress.Loopback, ushort.MaxValue), + Allocate)); + + Assert.Equal(0, allocationCount); + } + + private sealed class TestTransport : IWorldSessionTransport + { + public void Send(ReadOnlySpan datagram) { } + + public void Send(IPEndPoint remote, ReadOnlySpan datagram) { } + + public byte[]? Receive(TimeSpan timeout, out IPEndPoint? from) + { + from = null; + return null; + } + + public void Dispose() { } + } +}