using System.Net;
using System.Net.Sockets;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
namespace AcDream.Runtime.Session;
public enum LiveSessionStartStatus
{
Disabled,
MissingCredentials,
NoCharacters,
Connected,
Deferred,
Failed,
///
/// Campaign LA slice LA2: a
/// session connected, received (and reported) the character roster, and
/// gracefully disconnected BEFORE selection/EnterWorld — deliberately a
/// SUCCESS variant of the early-exit shape
/// (same StopCore teardown), not a failure.
///
ProbeComplete,
}
public readonly record struct LiveSessionOwnershipSnapshot(
bool IsDisposed,
bool IsDisposeRequested,
bool IsInWorld,
bool HasActiveSession,
bool HasRetiredSession,
bool HasPendingInitialReset,
bool HasPendingOperation,
int OperationDepth,
ulong Generation,
RuntimeTeardownStage LastTeardownStages)
{
public bool IsConverged =>
IsDisposed
&& IsDisposeRequested
&& !IsInWorld
&& !HasActiveSession
&& !HasRetiredSession
&& !HasPendingInitialReset
&& !HasPendingOperation
&& OperationDepth == 0
&& LastTeardownStages == RuntimeTeardownStage.Complete;
}
public sealed record LiveSessionCharacterSelection(
int ActiveIndex,
uint CharacterId,
string CharacterName,
string AccountName);
public sealed record LiveSessionStartResult(
LiveSessionStartStatus Status,
LiveSessionCharacterSelection? Selection = null,
Exception? Error = null);
///
/// Campaign LA slice LA1: one roster entry as reported by
/// — decoupled from the wire type so the
/// lifecycle-host seam does not leak AcDream.Core.Net.Messages shapes
/// into every consumer.
///
public readonly record struct LiveSessionRosterEntry(
uint Id,
string Name,
uint SecondsGreyedOut);
///
/// Campaign LA slice LA1: the account's active-character roster, reported to
/// right after
/// CharacterList arrives and BEFORE selection — see
/// docs/plans/2026-08-14-launcher-campaign.md LA1 item 2. Hosts forward
/// this to their status stream (characterList event) and, later
/// (LA7/LA8), to the character-select screen. Deleted characters are
/// deliberately excluded — the same candidate set
/// already uses.
///
public sealed record LiveSessionRosterReport(
string AccountName,
int SlotCount,
IReadOnlyList Entries);
///
/// Runtime boundary for the domain and presentation sinks attached to one
/// exact generation. The controller owns the
/// connect/enter/stop transaction and never reaches into graphical state.
///
public interface ILiveSessionLifecycleHost
{
LiveSessionBinding BindSession(WorldSession session);
void ResetSessionState(RuntimeGenerationToken retiringGeneration);
void ReportConnecting(string host, int port, string user);
void ReportConnected();
/// Campaign LA slice LA1: reported once per successful
/// CharacterList receipt, right before character selection. See
/// .
void ReportRoster(LiveSessionRosterReport roster);
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.
///
public 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,
Action activateCommands,
Action deactivateCommands,
Action detachEvents)
{
Session = session ?? throw new ArgumentNullException(nameof(session));
_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 bool CommandsDeactivated => _commandsDeactivated;
public bool EventsDetached => _eventsDetached;
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;
}
}
}
public 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 runtime owner of a live WorldSession lifetime: endpoint resolution,
/// complete pre-Connect routing, character validation/entry, active command
/// publication, exact-generation Tick, graceful replacement, and convergent
/// teardown. Retail wire behavior remains inside WorldSession.
///
public sealed class LiveSessionController
: IDisposable,
IRuntimeLiveSessionFramePhase
{
private sealed class SessionScope(
WorldSession session,
ILiveSessionLifecycleHost host,
RuntimeGenerationToken generation)
{
private int _teardownStage;
public WorldSession Session { get; } = session;
public ILiveSessionLifecycleHost Host { get; } = host;
public RuntimeGenerationToken Generation { get; } = generation;
public LiveSessionBinding? Binding { get; set; }
public bool HostAttached { get; set; }
public RuntimeTeardownStage CompletedStages { get; private set; }
public void DrainTeardown(ILiveSessionOperations operations)
{
if (_teardownStage == 0)
{
try
{
Binding?.Dispose();
}
finally
{
if (Binding is null || Binding.CommandsDeactivated)
CompletedStages |= RuntimeTeardownStage.CommandsInert;
if (Binding is null || Binding.EventsDetached)
CompletedStages |= RuntimeTeardownStage.InboundDetached;
}
_teardownStage = 1;
}
if (_teardownStage == 1)
{
operations.DisposeSession(Session);
CompletedStages |= RuntimeTeardownStage.TransportDisposed;
_teardownStage = 2;
}
if (_teardownStage == 2)
{
if (HostAttached)
Host.DetachSession(Session);
_teardownStage = 3;
}
if (_teardownStage == 3)
{
Host.ResetSessionState(Generation);
CompletedStages |= RuntimeTeardownStage.HostReset;
_teardownStage = 4;
}
}
public bool IsTeardownComplete => _teardownStage == 4;
}
private enum PendingKind
{
Stop,
Reconnect,
Dispose,
}
private sealed record PendingOperation(
PendingKind Kind,
LiveSessionConnectOptions? Options = null,
ILiveSessionLifecycleHost? Host = null);
private sealed record PendingHostReset(
ILiveSessionLifecycleHost Host,
RuntimeGenerationToken Generation);
private readonly object _gate = new();
private readonly ILiveSessionOperations _operations;
private SessionScope? _scope;
private SessionScope? _retiredScope;
private PendingHostReset? _pendingInitialReset;
private PendingOperation? _pendingOperation;
private int _operationDepth;
private bool _inWorld;
private bool _disposeRequested;
private bool _disposed;
private ulong _generation;
private RuntimeTeardownStage _lastTeardownStages;
private LiveSessionCharacterSelection? _activeSelection;
private Action? _autoSaveTickHook;
private Action? _preLogoffFlushHook;
public LiveSessionController()
: this(ProductionLiveSessionOperations.Instance)
{
}
public LiveSessionController(ILiveSessionOperations operations)
{
_operations = operations ?? throw new ArgumentNullException(nameof(operations));
}
public WorldSession? CurrentSession
{
get { lock (_gate) return _scope?.Session; }
}
public bool IsInWorld
{
get { lock (_gate) return _inWorld; }
}
public ulong SessionGeneration
{
get { lock (_gate) return _generation; }
}
public RuntimeGenerationToken Generation
{
get { lock (_gate) return new RuntimeGenerationToken(_generation); }
}
///
/// MUST-FIX 1 (Campaign OP OP1 review fix, 2026-08-11 — mechanism lens
/// finding): reaches retail's CPlayerModule::UseTime (the 480 s
/// batched character-option auto-save) from the SAME per-session tick
/// both the graphical host (RetailLiveFrameCoordinator) and the
/// no-window host (HeadlessSessionHost.Tick) already call —
/// — with ZERO host edits. GameRuntime wires
/// this once, after constructing CharacterOwner/
/// InventoryOwner, with a flush body that talks to the
/// directly rather than through App's
/// LiveSessionCommandRouter/LiveCommandBus — which is what
/// keeps this wiring off the S2 lock-order hazard the blast-lens finding
/// named (this hook never touches the router's own gate). A hook
/// throwing is caught and logged (), never treated
/// as a tick failure — a transient send error on a background auto-save
/// must not tear down the whole live session.
///
internal void ConfigureAutoSaveTick(Action hook) =>
_autoSaveTickHook = hook ?? throw new ArgumentNullException(nameof(hook));
///
/// MUST-FIX 1: reaches retail's CPlayerSystem::LogOffCharacter →
/// SaveToServer ordering — flush the batched option module BEFORE
/// the character-logoff wire request goes out — from Runtime's own
/// Stop/teardown transaction (), using the
/// CURRENT (not-yet-retired) session. Caught and logged rather than
/// propagated: a failed flush must not block the graceful-shutdown
/// sequence CLAUDE.md flags as ACE-timing-sensitive.
///
internal void ConfigurePreLogoffFlush(Action hook) =>
_preLogoffFlushHook = hook ?? throw new ArgumentNullException(nameof(hook));
public bool IsDisposalComplete
{
get { lock (_gate) return _disposed; }
}
public LiveSessionOwnershipSnapshot CaptureOwnership()
{
lock (_gate)
{
return new LiveSessionOwnershipSnapshot(
_disposed,
_disposeRequested,
_inWorld,
_scope is not null,
_retiredScope is not null,
_pendingInitialReset is not null,
_pendingOperation is not null,
_operationDepth,
_generation,
_lastTeardownStages);
}
}
public LiveSessionStartResult Start(
LiveSessionConnectOptions 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));
}
}
public LiveSessionStartResult Reconnect(
LiveSessionConnectOptions 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));
}
}
public void Stop()
{
lock (_gate)
{
if (_disposed)
return;
if (_operationDepth != 0)
{
Schedule(new PendingOperation(PendingKind.Stop));
return;
}
RunTopLevel(StopCore);
}
}
public RuntimeTeardownAcknowledgement Stop(
RuntimeGenerationToken expectedGeneration)
{
lock (_gate)
{
RuntimeGenerationToken current = new(_generation);
if (_disposed)
{
return new RuntimeTeardownAcknowledgement(
expectedGeneration,
current,
RuntimeCommandStatus.Inactive,
_lastTeardownStages);
}
if (expectedGeneration != current)
{
return new RuntimeTeardownAcknowledgement(
expectedGeneration,
current,
RuntimeCommandStatus.StaleGeneration,
RuntimeTeardownStage.None);
}
try
{
Stop();
return new RuntimeTeardownAcknowledgement(
expectedGeneration,
new RuntimeGenerationToken(_generation),
RuntimeCommandStatus.Accepted,
_lastTeardownStages);
}
catch (Exception error)
{
return new RuntimeTeardownAcknowledgement(
expectedGeneration,
new RuntimeGenerationToken(_generation),
RuntimeCommandStatus.Rejected,
_retiredScope?.CompletedStages ?? _lastTeardownStages,
error);
}
}
}
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);
if (ReferenceEquals(error, tickError))
throw;
throw error;
}
// MUST-FIX 1: TS-71's 480 s auto-save timer half. Runs after
// the protocol pump above and only when the scope/generation
// are still current — a reconnect that happened mid-tick
// must not flush against a retired session.
InvokeAutoSaveTick(scope.Session);
});
}
}
private void InvokeAutoSaveTick(WorldSession session)
{
if (_autoSaveTickHook is not { } hook)
return;
try
{
hook(session);
}
catch (Exception error)
{
Console.Error.WriteLine(
$"live: auto-save character-options tick failed: {error.Message}");
}
}
public void Dispose()
{
lock (_gate)
{
if (_disposed)
return;
_disposeRequested = true;
if (_operationDepth != 0)
{
Schedule(new PendingOperation(PendingKind.Dispose));
return;
}
RunTopLevel(DisposeCore);
}
}
private LiveSessionStartResult ReconnectCore(
LiveSessionConnectOptions options,
ILiveSessionLifecycleHost host)
{
ILiveSessionLifecycleHost? oldHost =
_scope?.Host
?? _retiredScope?.Host
?? _pendingInitialReset?.Host;
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(
LiveSessionConnectOptions options,
ILiveSessionLifecycleHost host,
bool resetHost)
{
RuntimeGenerationToken resetGeneration = new(_generation);
ulong generation = ++_generation;
try
{
DrainRetiredScope();
if (_generation != generation)
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
if (resetHost)
{
ResetHostBeforeStart(
host,
generation,
resetGeneration);
}
if (_generation != generation)
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
}
catch (Exception error)
{
return new LiveSessionStartResult(LiveSessionStartStatus.Failed, Error: error);
}
if (!options.Enabled)
return new LiveSessionStartResult(LiveSessionStartStatus.Disabled);
if (string.IsNullOrEmpty(options.User) || string.IsNullOrEmpty(options.Password))
return new LiveSessionStartResult(LiveSessionStartStatus.MissingCredentials);
SessionScope? scope = null;
try
{
IPEndPoint endpoint = _operations.ResolveEndpoint(options.Host, options.Port);
if (_generation != generation)
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
Console.WriteLine($"live: connecting to {endpoint} as {options.User}");
WorldSession session = _operations.CreateSession(endpoint);
scope = new SessionScope(
session,
host,
new RuntimeGenerationToken(generation));
_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.Host, options.Port, options.User);
if (!IsCurrent(scope, generation))
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
_operations.Connect(session, options.User, options.Password);
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 not null)
{
host.ReportRoster(BuildRosterReport(characters));
if (!IsCurrent(scope, generation))
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
}
// Campaign LA slice LA2: the probe short-circuit lands here —
// right after the roster report, before TrySelectCharacter ever
// runs — so a probe session never reaches selection,
// ApplySelectedCharacter, or EnterWorld. This mirrors the
// NoCharacters early-exit immediately below (same StopCore
// teardown), the deliberate difference being the returned status
// is a SUCCESS, not a failure. Non-probe callers (options.Probe
// is false by default) fall straight through to the unchanged
// selection/enter path below — byte-identical to pre-LA2
// behavior.
if (options.Probe)
{
Console.WriteLine(
"live: probe complete — disconnecting before EnterWorld");
StopCore();
return new LiveSessionStartResult(LiveSessionStartStatus.ProbeComplete);
}
if (characters is null
|| !TrySelectCharacter(
characters,
options.Character,
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()
{
// MUST-FIX 1: TS-71's logout-flush half — retail's
// CPlayerSystem::LogOffCharacter calls SaveToServer BEFORE the
// character-logoff wire request, so this runs before anything below
// touches the scope (teardown, generation bump). Gated on _inWorld
// so a failed/never-entered-world Stop (still connecting, no
// character session) never fires it — matching retail's own call
// site, which only exists on an actual in-world character.
if (_inWorld && _scope is { } activeScope)
InvokePreLogoffFlush(activeScope.Session);
++_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();
if (_retiredScope is null && _pendingInitialReset is null)
_lastTeardownStages = RuntimeTeardownStage.Complete;
}
private void InvokePreLogoffFlush(WorldSession session)
{
if (_preLogoffFlushHook is not { } hook)
return;
try
{
hook(session);
}
catch (Exception error)
{
Console.Error.WriteLine(
$"live: pre-logoff character-options flush failed: {error.Message}");
}
}
private void DrainRetiredScope()
{
if (_retiredScope is not { } retired)
return;
retired.DrainTeardown(_operations);
if (retired.IsTeardownComplete)
{
_lastTeardownStages = retired.CompletedStages;
_retiredScope = null;
}
}
private void ResetHostBeforeStart(
ILiveSessionLifecycleHost host,
ulong generation,
RuntimeGenerationToken resetGeneration)
{
bool requestedHostAlreadyReset = false;
if (_pendingInitialReset is { } pending)
{
pending.Host.ResetSessionState(pending.Generation);
_pendingInitialReset = null;
requestedHostAlreadyReset =
ReferenceEquals(pending.Host, host);
}
if (requestedHostAlreadyReset || _generation != generation)
return;
try
{
host.ResetSessionState(resetGeneration);
}
catch
{
_pendingInitialReset =
new PendingHostReset(host, resetGeneration);
throw;
}
}
private void DrainPendingInitialReset()
{
if (_pendingInitialReset is not { } pending)
return;
pending.Host.ResetSessionState(pending.Generation);
_pendingInitialReset = 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);
/// Campaign LA slice LA1: projects the wire-shaped
/// into the decoupled
/// . Deleted characters are
/// excluded, matching 's
/// candidate set.
private static LiveSessionRosterReport BuildRosterReport(
CharacterList.Parsed characters)
{
var entries = new LiveSessionRosterEntry[characters.Characters.Count];
for (int i = 0; i < entries.Length; i++)
{
CharacterList.Character character = characters.Characters[i];
entries[i] = new LiveSessionRosterEntry(
character.Id,
character.Name,
character.SecondsGreyedOut);
}
return new LiveSessionRosterReport(
characters.AccountName,
characters.SlotCount,
entries);
}
private static bool TrySelectCharacter(
CharacterList.Parsed characters,
LiveSessionCharacterSelector? selector,
out CharacterList.Selection selection)
{
ArgumentNullException.ThrowIfNull(characters);
if (selector is null)
{
return CharacterList.TrySelectFirstAvailable(
characters,
out selection);
}
int selectorCount =
(selector.ActiveIndex.HasValue ? 1 : 0)
+ (selector.CharacterId.HasValue ? 1 : 0)
+ (!string.IsNullOrWhiteSpace(selector.CharacterName) ? 1 : 0);
if (selectorCount != 1)
{
selection = default;
return false;
}
for (int index = 0; index < characters.Characters.Count; index++)
{
CharacterList.Character character =
characters.Characters[index];
if (!CharacterList.IsAvailableActiveIdentity(character))
continue;
if (selector.ActiveIndex is { } requestedIndex
&& requestedIndex != index)
{
continue;
}
if (selector.CharacterId is { } requestedId
&& requestedId != character.Id)
{
continue;
}
if (selector.CharacterName is { } requestedName
&& !string.Equals(
requestedName,
character.Name,
StringComparison.OrdinalIgnoreCase))
{
continue;
}
selection = new CharacterList.Selection(index, character);
return true;
}
selection = default;
return false;
}
private void ThrowIfDisposing()
{
if (_disposeRequested || _disposed)
throw new ObjectDisposedException(nameof(LiveSessionController));
}
}