The character-session reset manifest had no audio step: retail's logoff destroys the world's sound sources with the world, but our OpenAL world pool and ambient scheduler are process-lifetime — the continuous ambient beds played on at character select and the scheduler kept RE-FIRING deadlines against the stale listener (Suspend/StopAll had zero callers; WorldGenerationQuiescence only cycles around teleport-style generation replaces). New WorldAudioSessionGate: the reset manifest's 'world audio' step stops all sixteen world-pool voices (SuspendWorldAudio) and drops every ambient deadline (StopAll); the pool reopens at the entered-world edge through the new default-null LiveSessionEnteredWorldBindings.ResumeWorldAudio binding, invoked first in ApplyEnteredWorld. The ambient soundscape needs no explicit resume — the next objcell observation rebuilds it exactly as a cell change always did. Covers logout, reconnect, and full stop uniformly. UI-pool sounds (interface bank, portal cues) untouched by design. App tests 5568/3 skips, Runtime 1756/0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
388 lines
16 KiB
C#
388 lines
16 KiB
C#
using System.Runtime.ExceptionServices;
|
|
using AcDream.Core.Net;
|
|
using AcDream.Runtime.Chat;
|
|
|
|
namespace AcDream.Runtime.Session;
|
|
|
|
public sealed record LiveSessionRoutingFactories(
|
|
Func<WorldSession, ILiveSessionEventRouting> CreateEvents,
|
|
Func<WorldSession, ILiveSessionCommandRouting> CreateCommands);
|
|
|
|
public sealed record LiveSessionSelectionBindings(
|
|
Action<uint> SetPlayerIdentity,
|
|
Action<uint> SetVitalsIdentity,
|
|
Action<uint> SetChatIdentity,
|
|
Action<uint> MarkPersistent,
|
|
Action<uint> SetVanishProbeIdentity,
|
|
Action ClearCombat,
|
|
/// <summary>Enter-click round (2026-08-17): arms the graphical host's
|
|
/// login-wormhole presentation at the selected-character edge — the one
|
|
/// host callback every entry route (direct connect, roster Enter,
|
|
/// enter-after-create) fires immediately BEFORE the EnterWorld wire
|
|
/// send, so the tunnel covers the whole server round-trip (registered
|
|
/// user-directed deviation from retail's pre-CreatePlayer black).
|
|
/// Default no-op preserves headless and existing construction sites.
|
|
/// </summary>
|
|
Action? ArmLoginTunnel = null);
|
|
|
|
public sealed record LiveSessionEnteredWorldBindings(
|
|
Action<string> SetActiveCharacter,
|
|
Action RestoreLayout,
|
|
Action SyncToolbar,
|
|
Action<string> LoadCharacterSettings,
|
|
Action ArmPlayerModeAutoEntry,
|
|
/// <summary>Logout-audio round (2026-08-17): re-enables world-pool audio
|
|
/// after the session reset's suspend (retail's logoff tears the world's
|
|
/// sound sources down with the world; ours must re-open the pool at the
|
|
/// next world entry). Default no-op preserves headless and existing
|
|
/// construction sites.</summary>
|
|
Action? ResumeWorldAudio = null);
|
|
|
|
public sealed record LiveSessionHostBindings(
|
|
LiveSessionRoutingFactories Routing,
|
|
Action<RuntimeGenerationToken> Reset,
|
|
LiveSessionSelectionBindings Selection,
|
|
LiveSessionEnteredWorldBindings EnteredWorld,
|
|
Action<string, int, string> Connecting,
|
|
Action Connected,
|
|
/// <summary>Campaign LA slice LA1: reported once per successful
|
|
/// <c>CharacterList</c> receipt, right before character selection — see
|
|
/// <see cref="LiveSessionRosterReport"/>. Hosts forward this to their
|
|
/// status stream's <c>characterList</c> event.</summary>
|
|
Action<LiveSessionRosterReport> Roster,
|
|
/// <summary>Campaign LA slice LA1: reported once entered-world state is
|
|
/// applied, carrying the full selection (id + name) — unlike
|
|
/// <see cref="EnteredWorld"/>'s narrow <c>SetActiveCharacter(string)</c>
|
|
/// fan-out, this exists so a status writer can emit the
|
|
/// <c>enteredWorld</c> event's <c>characterId</c> field.</summary>
|
|
Action<LiveSessionCharacterSelection> CharacterEntered,
|
|
LoginCommandSequence? LoginCommands = null,
|
|
/// <summary>Campaign CC slice CC4: forwards
|
|
/// <see cref="ILiveSessionLifecycleHost.ApplyCharacterCreated"/> —
|
|
/// hosts wire this to <c>SessionStatusWriter.CharacterCreated</c>.
|
|
/// Default no-op.</summary>
|
|
Action<RuntimeCharacterCreationIdentity>? CharacterCreated = null,
|
|
/// <summary>Campaign CC slice CC4: forwards
|
|
/// <see cref="ILiveSessionLifecycleHost.ApplyCreationFailed"/> — hosts
|
|
/// wire this to <c>SessionStatusWriter.CreationFailed</c>.</summary>
|
|
Action<RuntimeCharacterCreationRejection>? CreationFailed = null);
|
|
|
|
/// <summary>
|
|
/// Runtime host for the one canonical <see cref="LiveSessionController"/>.
|
|
/// It owns callback ordering and per-generation route factories supplied by
|
|
/// composition, but never mirrors session, generation, identity, routing, or
|
|
/// command state.
|
|
/// </summary>
|
|
public sealed class LiveSessionHost
|
|
: IRuntimeSessionCommands,
|
|
IRuntimeLiveSessionFramePhase
|
|
{
|
|
private sealed class PendingRouteRollback(
|
|
ILiveSessionCommandRouting? commands,
|
|
ILiveSessionEventRouting? events)
|
|
{
|
|
private ILiveSessionCommandRouting? _commands = commands;
|
|
private ILiveSessionEventRouting? _events = events;
|
|
|
|
public bool IsComplete => _commands is null && _events is null;
|
|
|
|
public void Drain()
|
|
{
|
|
List<Exception>? failures = null;
|
|
TryDispose(ref _commands, ref failures);
|
|
TryDispose(ref _events, ref failures);
|
|
if (failures is not null)
|
|
{
|
|
throw new AggregateException(
|
|
"Live-session route rollback did not converge.",
|
|
failures);
|
|
}
|
|
}
|
|
|
|
private static void TryDispose<TOwner>(
|
|
ref TOwner? owner,
|
|
ref List<Exception>? failures)
|
|
where TOwner : class, IDisposable
|
|
{
|
|
if (owner is null)
|
|
return;
|
|
try
|
|
{
|
|
owner.Dispose();
|
|
owner = null;
|
|
}
|
|
catch (Exception error)
|
|
{
|
|
(failures ??= []).Add(error);
|
|
}
|
|
}
|
|
}
|
|
|
|
private readonly LiveSessionController _controller;
|
|
private readonly LiveSessionConnectOptions? _connectOptions;
|
|
private readonly LiveSessionRoutingFactories _routing;
|
|
private readonly LiveSessionSelectionBindings _selection;
|
|
private readonly LiveSessionEnteredWorldBindings _enteredWorld;
|
|
private readonly Action<LiveSessionCharacterSelection> _characterEntered;
|
|
private readonly Action<RuntimeGenerationToken> _reset;
|
|
private readonly LiveSessionLifecycleHost _lifecycle;
|
|
private readonly LoginCommandSequence? _loginCommands;
|
|
private PendingRouteRollback? _pendingRouteRollback;
|
|
|
|
public LiveSessionHost(
|
|
LiveSessionController controller,
|
|
LiveSessionHostBindings bindings,
|
|
LiveSessionConnectOptions? connectOptions = null)
|
|
{
|
|
_controller = controller ?? throw new ArgumentNullException(nameof(controller));
|
|
_connectOptions = connectOptions;
|
|
ArgumentNullException.ThrowIfNull(bindings);
|
|
_routing = bindings.Routing ?? throw new ArgumentNullException(nameof(bindings.Routing));
|
|
_selection = bindings.Selection ?? throw new ArgumentNullException(nameof(bindings.Selection));
|
|
_enteredWorld = bindings.EnteredWorld
|
|
?? throw new ArgumentNullException(nameof(bindings.EnteredWorld));
|
|
_characterEntered = bindings.CharacterEntered
|
|
?? throw new ArgumentNullException(nameof(bindings.CharacterEntered));
|
|
_loginCommands = bindings.LoginCommands;
|
|
ArgumentNullException.ThrowIfNull(_routing.CreateEvents);
|
|
ArgumentNullException.ThrowIfNull(_routing.CreateCommands);
|
|
ArgumentNullException.ThrowIfNull(bindings.Reset);
|
|
ArgumentNullException.ThrowIfNull(bindings.Connecting);
|
|
ArgumentNullException.ThrowIfNull(bindings.Connected);
|
|
ArgumentNullException.ThrowIfNull(bindings.Roster);
|
|
Validate(_selection, _enteredWorld);
|
|
|
|
_reset = bindings.Reset;
|
|
_lifecycle = new LiveSessionLifecycleHost(new LiveSessionLifecycleBindings(
|
|
Bind: BindSession,
|
|
Reset: ResetSessionState,
|
|
Connecting: bindings.Connecting,
|
|
Connected: bindings.Connected,
|
|
Roster: bindings.Roster,
|
|
Selected: ApplySelection,
|
|
Entered: ApplyEnteredWorld,
|
|
CharacterCreated: bindings.CharacterCreated,
|
|
CreationFailed: bindings.CreationFailed));
|
|
}
|
|
|
|
public WorldSession? CurrentSession => _controller.CurrentSession;
|
|
public bool IsInWorld => _controller.IsInWorld;
|
|
|
|
public LiveSessionStartResult Start(LiveSessionConnectOptions options) =>
|
|
_controller.Start(options, _lifecycle);
|
|
|
|
public LiveSessionStartResult Reconnect(LiveSessionConnectOptions options) =>
|
|
_controller.Reconnect(options, _lifecycle);
|
|
|
|
public RuntimeSessionStartResult Start(
|
|
RuntimeGenerationToken expectedGeneration)
|
|
{
|
|
if (!TryValidate(expectedGeneration, out RuntimeSessionStartResult rejected))
|
|
return rejected;
|
|
if (_connectOptions is null)
|
|
{
|
|
return new RuntimeSessionStartResult(
|
|
RuntimeSessionStartStatus.Inactive,
|
|
_controller.Generation);
|
|
}
|
|
|
|
return Convert(_controller.Start(_connectOptions, _lifecycle));
|
|
}
|
|
|
|
public RuntimeSessionStartResult Reconnect(
|
|
RuntimeGenerationToken expectedGeneration)
|
|
{
|
|
if (!TryValidate(expectedGeneration, out RuntimeSessionStartResult rejected))
|
|
return rejected;
|
|
if (_connectOptions is null)
|
|
{
|
|
return new RuntimeSessionStartResult(
|
|
RuntimeSessionStartStatus.Inactive,
|
|
_controller.Generation);
|
|
}
|
|
|
|
return Convert(_controller.Reconnect(_connectOptions, _lifecycle));
|
|
}
|
|
|
|
public RuntimeTeardownAcknowledgement Stop(
|
|
RuntimeGenerationToken expectedGeneration) =>
|
|
_controller.Stop(expectedGeneration);
|
|
|
|
/// <summary>
|
|
/// Pumps the canonical network session first, then any due login command.
|
|
/// Both graphical and headless frame loops use this host boundary so the
|
|
/// sequencing contract cannot drift between them.
|
|
/// </summary>
|
|
public void Tick()
|
|
{
|
|
_controller.Tick();
|
|
_loginCommands?.Tick(_controller.Generation, _controller.IsInWorld);
|
|
}
|
|
|
|
private LiveSessionBinding BindSession(WorldSession session)
|
|
{
|
|
DrainPendingRouteRollback();
|
|
|
|
ILiveSessionEventRouting? events = null;
|
|
ILiveSessionCommandRouting? commands = null;
|
|
try
|
|
{
|
|
events = _routing.CreateEvents(session)
|
|
?? throw new InvalidOperationException(
|
|
"The live-session event factory returned null.");
|
|
events.Attach();
|
|
commands = _routing.CreateCommands(session)
|
|
?? throw new InvalidOperationException(
|
|
"The live-session command factory returned null.");
|
|
return new LiveSessionBinding(
|
|
session,
|
|
activateCommands: commands.Activate,
|
|
deactivateCommands: commands.Dispose,
|
|
detachEvents: events.Dispose);
|
|
}
|
|
catch (Exception creationError)
|
|
{
|
|
RethrowWithRetryableRollback(creationError, commands, events);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
private void ResetSessionState(
|
|
RuntimeGenerationToken retiringGeneration)
|
|
{
|
|
// An incompletely detached route can still deliver callbacks into the
|
|
// state below. Treat physical route convergence as the same hard
|
|
// barrier used by normal LiveSessionBinding teardown.
|
|
DrainPendingRouteRollback();
|
|
_loginCommands?.Cancel(retiringGeneration);
|
|
_reset(retiringGeneration);
|
|
}
|
|
|
|
private void ApplySelection(LiveSessionCharacterSelection selection)
|
|
{
|
|
uint id = selection.CharacterId;
|
|
_selection.SetPlayerIdentity(id);
|
|
_selection.SetVitalsIdentity(id);
|
|
_selection.SetChatIdentity(id);
|
|
_selection.MarkPersistent(id);
|
|
_selection.SetVanishProbeIdentity(id);
|
|
_selection.ClearCombat();
|
|
// Enter-click round (2026-08-17): LAST, after identity wiring — the
|
|
// armed tunnel's own logging can then already attribute the session.
|
|
_selection.ArmLoginTunnel?.Invoke();
|
|
}
|
|
|
|
private void ApplyEnteredWorld(LiveSessionCharacterSelection selection)
|
|
{
|
|
string name = selection.CharacterName;
|
|
// FIRST: the world-audio pool must be open before any entered-world
|
|
// callback can emit a sound (retail never closes its pool across a
|
|
// logoff — the sources die with the world — so the reopened pool is
|
|
// the earliest faithful moment).
|
|
_enteredWorld.ResumeWorldAudio?.Invoke();
|
|
_enteredWorld.SetActiveCharacter(name);
|
|
_enteredWorld.RestoreLayout();
|
|
_enteredWorld.SyncToolbar();
|
|
_enteredWorld.LoadCharacterSettings(name);
|
|
_enteredWorld.ArmPlayerModeAutoEntry();
|
|
_characterEntered(selection);
|
|
_loginCommands?.EnteredWorld(_controller.Generation);
|
|
}
|
|
|
|
private void RethrowWithRetryableRollback(
|
|
Exception creationError,
|
|
ILiveSessionCommandRouting? commands,
|
|
ILiveSessionEventRouting? events)
|
|
{
|
|
_pendingRouteRollback = new PendingRouteRollback(commands, events);
|
|
try
|
|
{
|
|
DrainPendingRouteRollback();
|
|
}
|
|
catch (AggregateException cleanupError)
|
|
{
|
|
var failures = new List<Exception> { creationError };
|
|
failures.AddRange(cleanupError.InnerExceptions);
|
|
throw new AggregateException(
|
|
"Live-session route construction and rollback both failed.",
|
|
failures);
|
|
}
|
|
|
|
ExceptionDispatchInfo.Capture(creationError).Throw();
|
|
}
|
|
|
|
private void DrainPendingRouteRollback()
|
|
{
|
|
if (_pendingRouteRollback is not { } rollback)
|
|
return;
|
|
rollback.Drain();
|
|
if (rollback.IsComplete)
|
|
_pendingRouteRollback = null;
|
|
}
|
|
|
|
private static void Validate(
|
|
LiveSessionSelectionBindings selection,
|
|
LiveSessionEnteredWorldBindings entered)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(selection.SetPlayerIdentity);
|
|
ArgumentNullException.ThrowIfNull(selection.SetVitalsIdentity);
|
|
ArgumentNullException.ThrowIfNull(selection.SetChatIdentity);
|
|
ArgumentNullException.ThrowIfNull(selection.MarkPersistent);
|
|
ArgumentNullException.ThrowIfNull(selection.SetVanishProbeIdentity);
|
|
ArgumentNullException.ThrowIfNull(selection.ClearCombat);
|
|
ArgumentNullException.ThrowIfNull(entered.SetActiveCharacter);
|
|
ArgumentNullException.ThrowIfNull(entered.RestoreLayout);
|
|
ArgumentNullException.ThrowIfNull(entered.SyncToolbar);
|
|
ArgumentNullException.ThrowIfNull(entered.LoadCharacterSettings);
|
|
ArgumentNullException.ThrowIfNull(entered.ArmPlayerModeAutoEntry);
|
|
}
|
|
|
|
private bool TryValidate(
|
|
RuntimeGenerationToken expectedGeneration,
|
|
out RuntimeSessionStartResult rejected)
|
|
{
|
|
RuntimeGenerationToken current = _controller.Generation;
|
|
if (expectedGeneration != current)
|
|
{
|
|
rejected = new RuntimeSessionStartResult(
|
|
RuntimeSessionStartStatus.StaleGeneration,
|
|
current);
|
|
return false;
|
|
}
|
|
|
|
rejected = default;
|
|
return true;
|
|
}
|
|
|
|
private RuntimeSessionStartResult Convert(LiveSessionStartResult result)
|
|
{
|
|
RuntimeSessionStartStatus status = result.Status switch
|
|
{
|
|
LiveSessionStartStatus.Disabled => RuntimeSessionStartStatus.Disabled,
|
|
LiveSessionStartStatus.MissingCredentials =>
|
|
RuntimeSessionStartStatus.MissingCredentials,
|
|
LiveSessionStartStatus.NoCharacters =>
|
|
RuntimeSessionStartStatus.NoCharacters,
|
|
LiveSessionStartStatus.Connected =>
|
|
RuntimeSessionStartStatus.Connected,
|
|
LiveSessionStartStatus.Deferred =>
|
|
RuntimeSessionStartStatus.Deferred,
|
|
LiveSessionStartStatus.Failed =>
|
|
RuntimeSessionStartStatus.Failed,
|
|
LiveSessionStartStatus.ProbeComplete =>
|
|
RuntimeSessionStartStatus.ProbeComplete,
|
|
LiveSessionStartStatus.AwaitingCharacterSelection =>
|
|
RuntimeSessionStartStatus.AwaitingCharacterSelection,
|
|
_ => throw new ArgumentOutOfRangeException(
|
|
nameof(result),
|
|
result.Status,
|
|
"Unknown live-session start result."),
|
|
};
|
|
return new RuntimeSessionStartResult(
|
|
status,
|
|
_controller.Generation,
|
|
result.Selection?.CharacterId ?? 0u,
|
|
result.Selection?.CharacterName ?? string.Empty,
|
|
result.Error);
|
|
}
|
|
}
|