using System.Net; using System.Net.Sockets; using AcDream.Core.Net; using AcDream.Core.Net.Messages; using AcDream.App.Update; using AcDream.UI.Abstractions; namespace AcDream.App.Net; internal enum LiveSessionStartStatus { Disabled, MissingCredentials, NoCharacters, Connected, Deferred, Failed, } 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) { Session = session ?? throw new ArgumentNullException(nameof(session)); Commands = commands ?? throw new ArgumentNullException(nameof(commands)); _activateCommands = activateCommands ?? throw new ArgumentNullException(nameof(activateCommands)); _deactivateCommands = deactivateCommands ?? throw new ArgumentNullException(nameof(deactivateCommands)); _detachEvents = detachEvents ?? throw new ArgumentNullException(nameof(detachEvents)); } public WorldSession Session { get; } public ICommandBus Commands { get; } public void ActivateCommands() { if (_commandsDeactivated || _eventsDetached) throw new ObjectDisposedException(nameof(LiveSessionBinding)); if (_commandsActivated) return; _activateCommands(); if (_commandsDeactivated || _eventsDetached) return; _commandsActivated = true; } public void Dispose() { if (!_commandsDeactivated) { _deactivateCommands(); _commandsDeactivated = true; } if (!_eventsDetached) { _detachEvents(); _eventsDetached = true; } } } 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!)) { 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. /// internal sealed class LiveSessionController : IDisposable, ILiveSessionFramePhase, ILiveInWorldSource, ILiveWorldSessionSource { 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 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; } } 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 bool IsDisposalComplete { get { lock (_gate) return _disposed; } } 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 (!_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(); _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)); } }