648 lines
20 KiB
C#
648 lines
20 KiB
C#
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);
|
|
|
|
/// <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)
|
|
{
|
|
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();
|
|
}
|
|
|
|
/// <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>
|
|
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<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();
|
|
_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));
|
|
}
|
|
|
|
}
|