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