refactor(net): own complete live session lifecycle
This commit is contained in:
parent
23b43d1859
commit
d9ccf8a6b9
5 changed files with 1618 additions and 91 deletions
|
|
@ -1,114 +1,688 @@
|
||||||
using System;
|
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Sockets;
|
using System.Net.Sockets;
|
||||||
using AcDream.Core.Net;
|
using AcDream.Core.Net;
|
||||||
|
using AcDream.Core.Net.Messages;
|
||||||
|
using AcDream.UI.Abstractions;
|
||||||
|
|
||||||
namespace AcDream.App.Net;
|
namespace AcDream.App.Net;
|
||||||
|
|
||||||
/// <summary>
|
internal enum LiveSessionStartStatus
|
||||||
/// 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
|
|
||||||
{
|
{
|
||||||
/// <summary>
|
Disabled,
|
||||||
/// Active session, or <see langword="null"/> when offline / before
|
MissingCredentials,
|
||||||
/// <see cref="CreateAndWire"/> succeeded / after <see cref="Dispose"/>.
|
NoCharacters,
|
||||||
/// </summary>
|
Connected,
|
||||||
public WorldSession? Session { get; private set; }
|
Deferred,
|
||||||
|
Failed,
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
internal sealed record LiveSessionCharacterSelection(
|
||||||
/// Resolves the endpoint, instantiates the <see cref="WorldSession"/>,
|
int ActiveIndex,
|
||||||
/// hands it to <paramref name="wireEvents"/> for caller-side event
|
uint CharacterId,
|
||||||
/// subscriptions, and returns the live session. The caller is
|
string CharacterName,
|
||||||
/// responsible for the subsequent <c>Connect</c> /
|
string AccountName);
|
||||||
/// <c>EnterWorld</c> dance.
|
|
||||||
/// </summary>
|
internal sealed record LiveSessionStartResult(
|
||||||
public WorldSession? CreateAndWire(RuntimeOptions options, Action<WorldSession> wireEvents)
|
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));
|
Session = session ?? throw new ArgumentNullException(nameof(session));
|
||||||
if (wireEvents is null) throw new ArgumentNullException(nameof(wireEvents));
|
Commands = commands ?? throw new ArgumentNullException(nameof(commands));
|
||||||
|
_activateCommands = activateCommands ?? throw new ArgumentNullException(nameof(activateCommands));
|
||||||
if (!options.LiveMode) return null;
|
_deactivateCommands = deactivateCommands ?? throw new ArgumentNullException(nameof(deactivateCommands));
|
||||||
|
_detachEvents = detachEvents ?? throw new ArgumentNullException(nameof(detachEvents));
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
public WorldSession Session { get; }
|
||||||
/// Drains the inbound network queue. Proxies to
|
public ICommandBus Commands { get; }
|
||||||
/// <see cref="WorldSession.Tick"/>; no-op when <see cref="Session"/>
|
|
||||||
/// is <see langword="null"/>.
|
public void ActivateCommands()
|
||||||
/// </summary>
|
{
|
||||||
public void Tick() => Session?.Tick();
|
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()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
Session?.Dispose();
|
if (!_commandsDeactivated)
|
||||||
Session = null;
|
{
|
||||||
|
_deactivateCommands();
|
||||||
|
_commandsDeactivated = true;
|
||||||
|
}
|
||||||
|
if (!_eventsDetached)
|
||||||
|
{
|
||||||
|
_detachEvents();
|
||||||
|
_eventsDetached = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
internal interface ILiveSessionOperations
|
||||||
/// Resolve a host string (literal IP or DNS name) to an
|
{
|
||||||
/// <see cref="IPEndPoint"/>. Pre-refactor logic preserved exactly:
|
IPEndPoint ResolveEndpoint(string host, int port);
|
||||||
/// try <see cref="IPAddress.TryParse"/> first, fall back to
|
WorldSession CreateSession(IPEndPoint endpoint);
|
||||||
/// <see cref="Dns.GetHostAddresses"/>, prefer IPv4 (ACE + retail use
|
void Connect(WorldSession session, string user, string password);
|
||||||
/// IPv4 UDP exclusively), throw on empty resolution.
|
CharacterList.Parsed? GetCharacters(WorldSession session);
|
||||||
/// </summary>
|
void EnterWorld(WorldSession session, int activeCharacterIndex);
|
||||||
private static IPEndPoint ResolveEndpoint(string host, int port)
|
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;
|
IPAddress ip;
|
||||||
if (!IPAddress.TryParse(host, out ip!))
|
if (!IPAddress.TryParse(host, out ip!))
|
||||||
{
|
{
|
||||||
var addrs = Dns.GetHostAddresses(host);
|
IPAddress[] addresses = Dns.GetHostAddresses(host);
|
||||||
ip = Array.Find(addrs, a => a.AddressFamily == AddressFamily.InterNetwork)
|
ip = Array.Find(
|
||||||
?? (addrs.Length > 0
|
addresses,
|
||||||
? addrs[0]
|
static address => address.AddressFamily == AddressFamily.InterNetwork)
|
||||||
: throw new Exception($"DNS resolved no addresses for '{host}'"));
|
?? (addresses.Length != 0
|
||||||
|
? addresses[0]
|
||||||
|
: throw new InvalidOperationException(
|
||||||
|
$"DNS resolved no addresses for '{host}'"));
|
||||||
Console.WriteLine($"live: resolved {host} → {ip}");
|
Console.WriteLine($"live: resolved {host} → {ip}");
|
||||||
}
|
}
|
||||||
return new IPEndPoint(ip, port);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,5 +12,6 @@
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<InternalsVisibleTo Include="AcDream.Core.Net.Tests" />
|
<InternalsVisibleTo Include="AcDream.Core.Net.Tests" />
|
||||||
|
<InternalsVisibleTo Include="AcDream.App.Tests" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,29 @@ using AcDream.Core.Net.Packets;
|
||||||
|
|
||||||
namespace AcDream.Core.Net;
|
namespace AcDream.Core.Net;
|
||||||
|
|
||||||
|
internal interface IWorldSessionTransport : IDisposable
|
||||||
|
{
|
||||||
|
void Send(ReadOnlySpan<byte> datagram);
|
||||||
|
void Send(IPEndPoint remote, ReadOnlySpan<byte> 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<byte> datagram) => _client.Send(datagram);
|
||||||
|
|
||||||
|
public void Send(IPEndPoint endpoint, ReadOnlySpan<byte> datagram) =>
|
||||||
|
_client.Send(endpoint, datagram);
|
||||||
|
|
||||||
|
public byte[]? Receive(TimeSpan timeout, out IPEndPoint? from) =>
|
||||||
|
_client.Receive(timeout, out from);
|
||||||
|
|
||||||
|
public void Dispose() => _client.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// High-level AC client session: owns a <see cref="NetClient"/>, drives
|
/// High-level AC client session: owns a <see cref="NetClient"/>, drives
|
||||||
/// the full handshake + character-enter-world flow, and converts the
|
/// 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; }
|
public CharacterList.Parsed? Characters { get; private set; }
|
||||||
|
|
||||||
private readonly NetClient _net;
|
private readonly IWorldSessionTransport _net;
|
||||||
private long _lastInboundPacketTicks = Stopwatch.GetTimestamp();
|
private long _lastInboundPacketTicks = Stopwatch.GetTimestamp();
|
||||||
private long _lastPingRequestTicks;
|
private long _lastPingRequestTicks;
|
||||||
private long _lastPingRoundTripBits = BitConverter.DoubleToInt64Bits(double.NaN);
|
private long _lastPingRoundTripBits = BitConverter.DoubleToInt64Bits(double.NaN);
|
||||||
|
|
@ -676,10 +699,34 @@ public sealed class WorldSession : IDisposable
|
||||||
private uint _gameActionSequence;
|
private uint _gameActionSequence;
|
||||||
|
|
||||||
public WorldSession(IPEndPoint serverLogin)
|
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<IPEndPoint, IWorldSessionTransport> 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;
|
_loginEndpoint = serverLogin;
|
||||||
_connectEndpoint = new IPEndPoint(serverLogin.Address, serverLogin.Port + 1);
|
_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
|
// Phase I.6: SetTurbineChatChannels (0x0295) is a GameEvent
|
||||||
// sub-opcode of 0xF7B0, not a top-level opcode. Route it through
|
// sub-opcode of 0xF7B0, not a top-level opcode. Route it through
|
||||||
|
|
|
||||||
864
tests/AcDream.App.Tests/Net/LiveSessionControllerTests.cs
Normal file
864
tests/AcDream.App.Tests/Net/LiveSessionControllerTests.cs
Normal file
|
|
@ -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<byte> datagram) { }
|
||||||
|
|
||||||
|
public void Send(IPEndPoint remote, ReadOnlySpan<byte> 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>(T command) where T : notnull
|
||||||
|
{
|
||||||
|
if (Active)
|
||||||
|
PublishCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TestOperations(List<string> 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<WorldSession> Sessions { get; } = [];
|
||||||
|
public Dictionary<WorldSession, int> 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<string> 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<TestCommandBus> CommandBuses { get; } = [];
|
||||||
|
public List<LiveSessionCharacterSelection> 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<string>();
|
||||||
|
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<string>();
|
||||||
|
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<string>();
|
||||||
|
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<string>();
|
||||||
|
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<string>();
|
||||||
|
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<string>();
|
||||||
|
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<string>();
|
||||||
|
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<string>();
|
||||||
|
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<ObjectDisposedException>(() => controller.Start(LiveOptions(), host));
|
||||||
|
controller.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MismatchedBindingWithInterruptedDetachRetainsCleanupOwnershipForRetry()
|
||||||
|
{
|
||||||
|
var calls = new List<string>();
|
||||||
|
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<string>();
|
||||||
|
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<string>();
|
||||||
|
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<string>();
|
||||||
|
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<string>();
|
||||||
|
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<string>();
|
||||||
|
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<string>();
|
||||||
|
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<InvalidOperationException>(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<string>();
|
||||||
|
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<string>();
|
||||||
|
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<string>();
|
||||||
|
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<string>();
|
||||||
|
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<InvalidOperationException>(controller.Tick);
|
||||||
|
|
||||||
|
Assert.False(controller.IsInWorld);
|
||||||
|
Assert.Null(controller.CurrentSession);
|
||||||
|
Assert.Equal(1, operations.DisposeCounts[session]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DisposeIsIdempotentAndMakesOldCommandsInert()
|
||||||
|
{
|
||||||
|
var calls = new List<string>();
|
||||||
|
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<ObjectDisposedException>(() => controller.Start(LiveOptions(), host));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RuntimeOptions LiveOptions(
|
||||||
|
bool live = true,
|
||||||
|
string? user = "user")
|
||||||
|
{
|
||||||
|
var environment = new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["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);
|
||||||
|
}
|
||||||
|
|
@ -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<ArgumentNullException>(
|
||||||
|
() => new WorldSession(null!, Allocate));
|
||||||
|
Assert.Throws<ArgumentOutOfRangeException>(
|
||||||
|
() => new WorldSession(
|
||||||
|
new IPEndPoint(IPAddress.Loopback, ushort.MaxValue),
|
||||||
|
Allocate));
|
||||||
|
|
||||||
|
Assert.Equal(0, allocationCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TestTransport : IWorldSessionTransport
|
||||||
|
{
|
||||||
|
public void Send(ReadOnlySpan<byte> datagram) { }
|
||||||
|
|
||||||
|
public void Send(IPEndPoint remote, ReadOnlySpan<byte> datagram) { }
|
||||||
|
|
||||||
|
public byte[]? Receive(TimeSpan timeout, out IPEndPoint? from)
|
||||||
|
{
|
||||||
|
from = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() { }
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue