refactor(net): own complete live session lifecycle

This commit is contained in:
Erik 2026-07-21 12:20:55 +02:00
parent 23b43d1859
commit d9ccf8a6b9
5 changed files with 1618 additions and 91 deletions

View file

@ -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;
/// <summary>
/// Owns the network-side lifecycle of a live <see cref="WorldSession"/> —
/// DNS resolution, endpoint construction, session instantiation, per-frame
/// <c>Tick</c>, and disposal. The post-construction work (event wiring,
/// <c>Connect</c>, character validation, <c>EnterWorld</c>, post-login UI
/// state setup) stays in <c>GameWindow</c> for now because it touches
/// renderer / chat / player-controller state that hasn't been extracted yet.
/// </summary>
/// <remarks>
/// <para>
/// Step 2 of the extraction sequence described in
/// <c>docs/architecture/code-structure.md</c> §4. Future expansions can
/// fold more of <c>TryStartLiveSession</c> into this controller as the
/// surrounding state (event handlers, command bus, settings VM) gets
/// extracted in later steps.
/// </para>
/// <para>
/// <strong>Behavior preservation contract:</strong> 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.
/// </para>
/// </remarks>
public sealed class LiveSessionController : IDisposable
internal enum LiveSessionStartStatus
{
/// <summary>
/// Active session, or <see langword="null"/> when offline / before
/// <see cref="CreateAndWire"/> succeeded / after <see cref="Dispose"/>.
/// </summary>
public WorldSession? Session { get; private set; }
Disabled,
MissingCredentials,
NoCharacters,
Connected,
Deferred,
Failed,
}
/// <summary>
/// Resolves the endpoint, instantiates the <see cref="WorldSession"/>,
/// hands it to <paramref name="wireEvents"/> for caller-side event
/// subscriptions, and returns the live session. The caller is
/// responsible for the subsequent <c>Connect</c> /
/// <c>EnterWorld</c> dance.
/// </summary>
public WorldSession? CreateAndWire(RuntimeOptions options, Action<WorldSession> 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);
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// Owns routing and commands for one exact session. Teardown is ordered and
/// retryable: outbound commands become inert before inbound subscriptions are
/// detached.
/// </summary>
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));
}
/// <summary>
/// Drains the inbound network queue. Proxies to
/// <see cref="WorldSession.Tick"/>; no-op when <see cref="Session"/>
/// is <see langword="null"/>.
/// </summary>
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;
}
/// <summary>
/// Tears down the live session. Safe to call multiple times.
/// </summary>
public void Dispose()
{
Session?.Dispose();
Session = null;
if (!_commandsDeactivated)
{
_deactivateCommands();
_commandsDeactivated = true;
}
if (!_eventsDetached)
{
_detachEvents();
_eventsDetached = true;
}
}
}
/// <summary>
/// Resolve a host string (literal IP or DNS name) to an
/// <see cref="IPEndPoint"/>. Pre-refactor logic preserved exactly:
/// try <see cref="IPAddress.TryParse"/> first, fall back to
/// <see cref="Dns.GetHostAddresses"/>, prefer IPv4 (ACE + retail use
/// IPv4 UDP exclusively), throw on empty resolution.
/// </summary>
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();
}
/// <summary>
/// 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.
/// </summary>
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; }
}
/// <summary>Temporary Slice-3 compatibility alias; removed at GameWindow cutover.</summary>
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<T>(Func<T> 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<WorldSession> 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;
}
}
}
}