refactor(net): own live session composition

Extract reset, selection, entered-world, and route construction behind LiveSessionHost while preserving the sole LiveSessionController authority. Retain partial route and subscription cleanup for retry, and replace the embedded ACE-only shortcut with the exact named-retail unsigned skill formula.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-22 10:36:06 +02:00
parent 18d4b999de
commit 557eb7ef6b
22 changed files with 1430 additions and 236 deletions

View file

@ -0,0 +1,242 @@
using System.Runtime.ExceptionServices;
using AcDream.Core.Net;
using AcDream.UI.Abstractions;
namespace AcDream.App.Net;
internal interface ILiveSessionEventRouting : IDisposable
{
void Attach();
}
internal interface ILiveSessionCommandRouting : ICommandBus, IDisposable
{
void Activate();
}
internal sealed record LiveSessionRoutingFactories(
Func<WorldSession, ILiveSessionEventRouting> CreateEvents,
Func<WorldSession, ILiveSessionCommandRouting> CreateCommands);
internal sealed record LiveSessionSelectionBindings(
Action<uint> SetPlayerIdentity,
Action<uint> SetVitalsIdentity,
Action<uint> SetChatIdentity,
Action<uint> MarkPersistent,
Action<uint> SetVanishProbeIdentity,
Action ClearCombat);
internal sealed record LiveSessionEnteredWorldBindings(
Action<string> SetActiveCharacter,
Action RestoreLayout,
Action SyncToolbar,
Action<string> LoadCharacterSettings,
Action ArmPlayerModeAutoEntry);
internal sealed record LiveSessionHostBindings(
LiveSessionRoutingFactories Routing,
LiveSessionResetBindings Reset,
LiveSessionSelectionBindings Selection,
LiveSessionEnteredWorldBindings EnteredWorld,
Action<string, int, string> Connecting,
Action Connected);
/// <summary>
/// App composition owner for the one canonical <see cref="LiveSessionController"/>.
/// It owns callback ordering and per-generation route factories, but never
/// mirrors session, generation, identity, routing, or command state.
/// </summary>
internal sealed class LiveSessionHost
{
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 LiveSessionRoutingFactories _routing;
private readonly LiveSessionSelectionBindings _selection;
private readonly LiveSessionEnteredWorldBindings _enteredWorld;
private readonly LiveSessionResetPlan _resetPlan;
private readonly LiveSessionLifecycleHost _lifecycle;
private PendingRouteRollback? _pendingRouteRollback;
public LiveSessionHost(
LiveSessionController controller,
LiveSessionHostBindings bindings)
{
_controller = controller ?? throw new ArgumentNullException(nameof(controller));
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));
ArgumentNullException.ThrowIfNull(_routing.CreateEvents);
ArgumentNullException.ThrowIfNull(_routing.CreateCommands);
ArgumentNullException.ThrowIfNull(bindings.Connecting);
ArgumentNullException.ThrowIfNull(bindings.Connected);
Validate(_selection, _enteredWorld);
_resetPlan = LiveSessionResetManifest.Create(bindings.Reset);
_lifecycle = new LiveSessionLifecycleHost(new LiveSessionLifecycleBindings(
Bind: BindSession,
Reset: ResetSessionState,
Connecting: bindings.Connecting,
Connected: bindings.Connected,
Selected: ApplySelection,
Entered: ApplyEnteredWorld));
}
public WorldSession? CurrentSession => _controller.CurrentSession;
public ICommandBus Commands => _controller.Commands;
public bool IsInWorld => _controller.IsInWorld;
public LiveSessionStartResult Start(RuntimeOptions options) =>
_controller.Start(options, _lifecycle);
public LiveSessionStartResult Reconnect(RuntimeOptions options) =>
_controller.Reconnect(options, _lifecycle);
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,
commands,
activateCommands: commands.Activate,
deactivateCommands: commands.Dispose,
detachEvents: events.Dispose);
}
catch (Exception creationError)
{
RethrowWithRetryableRollback(creationError, commands, events);
throw;
}
}
private void ResetSessionState()
{
// 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();
_resetPlan.Execute();
}
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();
}
private void ApplyEnteredWorld(LiveSessionCharacterSelection selection)
{
string name = selection.CharacterName;
_enteredWorld.SetActiveCharacter(name);
_enteredWorld.RestoreLayout();
_enteredWorld.SyncToolbar();
_enteredWorld.LoadCharacterSettings(name);
_enteredWorld.ArmPlayerModeAutoEntry();
}
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);
}
}