refactor(runtime): move session lifetime and ordered transport
Move the canonical WorldSession generation, connect/enter/tick/stop transaction, inbound subscription owner, and retryable teardown acknowledgements into AcDream.Runtime. Keep App as a borrowing graphical host with a single inertable command projection and no mirrored session state. Validated by 79 Runtime tests, 3,776 App tests with three existing skips, the Release solution build, and 8,428 complete Release tests with five existing skips. Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
parent
ecc4816c5a
commit
7593078774
37 changed files with 884 additions and 355 deletions
|
|
@ -3,6 +3,7 @@ using AcDream.App.Net;
|
|||
using AcDream.App.UI;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Runtime.Session;
|
||||
|
||||
namespace AcDream.App.Combat;
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ using AcDream.App.Runtime;
|
|||
using AcDream.App.Settings;
|
||||
using AcDream.App.Update;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Runtime.Session;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.Core.Lighting;
|
||||
using AcDream.Core.Physics;
|
||||
|
|
@ -59,7 +60,7 @@ internal sealed record FrameRootResult(
|
|||
RenderFrameOrchestrator Render,
|
||||
FrameRootRuntimeBindings RuntimeBindings,
|
||||
IDisposable FrameGraphPublication,
|
||||
AcDream.App.Net.LiveSessionHost SessionHost,
|
||||
LiveSessionHost SessionHost,
|
||||
CurrentGameRuntimeAdapter GameRuntime);
|
||||
|
||||
internal interface IGameWindowFrameRootPublication
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ using AcDream.Core.Player;
|
|||
using AcDream.Core.Social;
|
||||
using AcDream.Core.Spells;
|
||||
using AcDream.Core.World;
|
||||
using AcDream.Runtime.Session;
|
||||
using Silk.NET.Windowing;
|
||||
|
||||
namespace AcDream.App.Composition;
|
||||
|
|
@ -437,13 +438,17 @@ internal sealed class SessionPlayerCompositionPhase
|
|||
static () => new LiveSessionController(),
|
||||
static value => value.Dispose());
|
||||
LiveSessionController liveSession = liveSessionLease.Resource;
|
||||
var liveSessionCommands = new LiveSessionCommandSurface();
|
||||
var liveSessionSource = new LiveSessionAppSource(
|
||||
liveSession,
|
||||
liveSessionCommands);
|
||||
bindings.Adopt(
|
||||
"retained-UI live session",
|
||||
interaction.LateBindings.Session.Bind(liveSession));
|
||||
interaction.LateBindings.Session.Bind(liveSessionSource));
|
||||
var localPhysicsTimestamps =
|
||||
new LiveSessionLocalPhysicsTimestampPublisher(
|
||||
d.PlayerIdentity,
|
||||
liveSession);
|
||||
liveSessionSource);
|
||||
Fault(SessionPlayerCompositionPoint.LiveSessionCreated);
|
||||
|
||||
var teardown = new LiveEntityRuntimeTeardownController(
|
||||
|
|
@ -520,7 +525,7 @@ internal sealed class SessionPlayerCompositionPhase
|
|||
d.PlayerHost,
|
||||
d.PlayerIdentity,
|
||||
d.UpdateClock,
|
||||
liveSession,
|
||||
liveSessionSource,
|
||||
localPhysicsTimestamps.Publish,
|
||||
d.MovementDiagnostics);
|
||||
var liveness = new LiveEntityLivenessController(
|
||||
|
|
@ -569,8 +574,8 @@ internal sealed class SessionPlayerCompositionPhase
|
|||
d.Settings,
|
||||
d.PlayerController,
|
||||
d.PlayerOutbound,
|
||||
liveSession,
|
||||
liveSession,
|
||||
liveSessionSource,
|
||||
liveSessionSource,
|
||||
d.CombatFeedback)));
|
||||
Fault(SessionPlayerCompositionPoint.CombatOperationsBound);
|
||||
|
||||
|
|
@ -585,7 +590,7 @@ internal sealed class SessionPlayerCompositionPhase
|
|||
d.ChaseCameraInput,
|
||||
d.MovementInput,
|
||||
d.PlayerOutbound,
|
||||
liveSession,
|
||||
liveSessionSource,
|
||||
host.MouseLookCursor,
|
||||
new EnvironmentInputMonotonicClock())
|
||||
: null;
|
||||
|
|
@ -606,7 +611,7 @@ internal sealed class SessionPlayerCompositionPhase
|
|||
d.Options.LiveMode,
|
||||
d.PlayerMode,
|
||||
d.PlayerController,
|
||||
liveSession,
|
||||
liveSessionSource,
|
||||
d.WorldOrigin,
|
||||
networkUpdates,
|
||||
new FlyCameraStreamingObserverSource(host.CameraController),
|
||||
|
|
@ -663,7 +668,7 @@ internal sealed class SessionPlayerCompositionPhase
|
|||
d.PlayerHost,
|
||||
localPlayerProjection,
|
||||
d.PlayerOutbound,
|
||||
liveSession);
|
||||
liveSessionSource);
|
||||
var localPlayerFrame = new RetailLocalPlayerFrameController(
|
||||
localPlayerFrameRuntime,
|
||||
d.MovementInput);
|
||||
|
|
@ -703,7 +708,7 @@ internal sealed class SessionPlayerCompositionPhase
|
|||
localPlayerShadow,
|
||||
d.PlayerApproachCompletions,
|
||||
gameplayInput,
|
||||
liveSession,
|
||||
liveSessionSource,
|
||||
d.MovementDiagnostics,
|
||||
d.PlayerSkills,
|
||||
d.ViewportAspect);
|
||||
|
|
@ -714,11 +719,11 @@ internal sealed class SessionPlayerCompositionPhase
|
|||
devTools.LateBindings.PlayerModeCommands.BindOwned(playerMode));
|
||||
bindings.Adopt(
|
||||
"developer command bus",
|
||||
devTools.CommandBus.BindOwned(liveSession));
|
||||
devTools.CommandBus.BindOwned(liveSessionSource));
|
||||
}
|
||||
var playerModeAutoEntry = new PlayerModeAutoEntry(
|
||||
new LivePlayerModeAutoEntryContext(
|
||||
liveSession,
|
||||
liveSessionSource,
|
||||
live.LiveEntities,
|
||||
d.PlayerIdentity,
|
||||
worldReveal,
|
||||
|
|
@ -750,7 +755,7 @@ internal sealed class SessionPlayerCompositionPhase
|
|||
d.ChaseCameraInput,
|
||||
d.WorldOrigin,
|
||||
liveSpatialReconciler),
|
||||
new LocalPlayerTeleportSession(liveSession),
|
||||
new LocalPlayerTeleportSession(liveSessionSource),
|
||||
new LocalPlayerTeleportPresentation(portalTunnel)));
|
||||
var teleportLease = scope.Own(
|
||||
"local-player teleport",
|
||||
|
|
@ -845,8 +850,16 @@ internal sealed class SessionPlayerCompositionPhase
|
|||
live.Presentation,
|
||||
d.RemoteMovementObservations,
|
||||
live.RenderSceneShadow),
|
||||
liveSessionCommands,
|
||||
d.Log);
|
||||
LiveSessionHost sessionHost = sessionRuntimeFactory.Create(liveSession);
|
||||
LiveSessionHost sessionHost = sessionRuntimeFactory.Create(
|
||||
liveSession,
|
||||
new LiveSessionConnectOptions(
|
||||
d.Options.LiveMode,
|
||||
d.Options.LiveHost,
|
||||
d.Options.LivePort,
|
||||
d.Options.LiveUser ?? string.Empty,
|
||||
d.Options.LivePass ?? string.Empty));
|
||||
Fault(SessionPlayerCompositionPoint.SessionHostCreated);
|
||||
|
||||
Action<string>? debugToast = d.SettingsDevTools.DevTools is { } devOwner
|
||||
|
|
@ -867,9 +880,9 @@ internal sealed class SessionPlayerCompositionPhase
|
|||
"live combat-mode commands",
|
||||
d.CombatModeCommands.BindOwned(combatCommand));
|
||||
var gameRuntime = new CurrentGameRuntimeAdapter(
|
||||
d.Options,
|
||||
liveSession,
|
||||
sessionHost,
|
||||
liveSessionCommands,
|
||||
d.PlayerIdentity,
|
||||
live.LiveEntities,
|
||||
d.Objects,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ using AcDream.App.Streaming;
|
|||
using AcDream.App.World;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Runtime.Session;
|
||||
|
||||
namespace AcDream.App.Net;
|
||||
|
||||
|
|
|
|||
102
src/AcDream.App/Net/LiveSessionAppSource.cs
Normal file
102
src/AcDream.App/Net/LiveSessionAppSource.cs
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
using AcDream.Core.Net;
|
||||
using AcDream.Runtime.Session;
|
||||
using AcDream.UI.Abstractions;
|
||||
|
||||
namespace AcDream.App.Net;
|
||||
|
||||
/// <summary>
|
||||
/// Borrowed graphical view over the canonical Runtime live-session owner.
|
||||
/// This adapter stores no generation, transport, or in-world state.
|
||||
/// </summary>
|
||||
internal sealed class LiveSessionAppSource
|
||||
: ILiveInWorldSource,
|
||||
ILiveWorldSessionSource,
|
||||
ILiveUiSessionTarget
|
||||
{
|
||||
private readonly LiveSessionController _session;
|
||||
private readonly LiveSessionCommandSurface _commands;
|
||||
|
||||
public LiveSessionAppSource(
|
||||
LiveSessionController session,
|
||||
LiveSessionCommandSurface commands)
|
||||
{
|
||||
_session = session ?? throw new ArgumentNullException(nameof(session));
|
||||
_commands = commands ?? throw new ArgumentNullException(nameof(commands));
|
||||
}
|
||||
|
||||
public bool IsInWorld => _session.IsInWorld;
|
||||
|
||||
public WorldSession? CurrentSession => _session.CurrentSession;
|
||||
|
||||
public ICommandBus Commands => _commands;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stable App command projection over one borrowed generation route. The
|
||||
/// retained UI may keep this surface, while the displaced route itself becomes
|
||||
/// inert before inbound subscriptions detach.
|
||||
/// </summary>
|
||||
internal sealed class LiveSessionCommandSurface : ICommandBus
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private LiveSessionCommandRouter? _active;
|
||||
|
||||
public ILiveSessionCommandRouting Attach(LiveSessionCommandRouter route)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(route);
|
||||
lock (_gate)
|
||||
{
|
||||
if (_active is not null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"A graphical live-session command route is already attached.");
|
||||
}
|
||||
|
||||
_active = route;
|
||||
return new RouteLease(this, route);
|
||||
}
|
||||
}
|
||||
|
||||
public void Publish<T>(T command) where T : notnull
|
||||
{
|
||||
LiveSessionCommandRouter? route;
|
||||
lock (_gate)
|
||||
route = _active;
|
||||
route?.Publish(command);
|
||||
}
|
||||
|
||||
private void Release(LiveSessionCommandRouter expected)
|
||||
{
|
||||
expected.Dispose();
|
||||
lock (_gate)
|
||||
{
|
||||
if (ReferenceEquals(_active, expected))
|
||||
_active = null;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RouteLease(
|
||||
LiveSessionCommandSurface owner,
|
||||
LiveSessionCommandRouter route)
|
||||
: ILiveSessionCommandRouting
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private LiveSessionCommandSurface? _owner = owner;
|
||||
|
||||
public void Activate() => route.Activate();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_owner is null)
|
||||
return;
|
||||
|
||||
// Retain the owner until the route has physically become
|
||||
// inert so a failed teardown remains retryable.
|
||||
_owner.Release(route);
|
||||
_owner = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
using AcDream.App.UI;
|
||||
using AcDream.Core.Chat;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Runtime.Session;
|
||||
using AcDream.UI.Abstractions;
|
||||
|
||||
namespace AcDream.App.Net;
|
||||
|
|
@ -304,23 +305,4 @@ internal static class TurbineChatRouting
|
|||
: new TurbineResolution(Room, ChatType, Name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Derive the retail-facing channel label from the inbound Turbine chat
|
||||
/// type, retaining the numeric-room fallback for unknown extensions.
|
||||
/// </summary>
|
||||
public static string DisplayName(uint roomId, uint chatType) =>
|
||||
(TurbineChat.ChatType)chatType switch
|
||||
{
|
||||
TurbineChat.ChatType.Allegiance => "Allegiance",
|
||||
TurbineChat.ChatType.General => "General",
|
||||
TurbineChat.ChatType.Trade => "Trade",
|
||||
TurbineChat.ChatType.Lfg => "LFG",
|
||||
TurbineChat.ChatType.Roleplay => "Roleplay",
|
||||
TurbineChat.ChatType.Society => "Society",
|
||||
TurbineChat.ChatType.SocietyCelHan => "Celestial Hand",
|
||||
TurbineChat.ChatType.SocietyEldWeb => "Eldrytch Web",
|
||||
TurbineChat.ChatType.SocietyRadBlo => "Radiant Blood",
|
||||
TurbineChat.ChatType.Olthoi => "Olthoi",
|
||||
_ => $"Room 0x{roomId:X8}",
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,651 +0,0 @@
|
|||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.App.Update;
|
||||
using AcDream.UI.Abstractions;
|
||||
|
||||
namespace AcDream.App.Net;
|
||||
|
||||
internal enum LiveSessionStartStatus
|
||||
{
|
||||
Disabled,
|
||||
MissingCredentials,
|
||||
NoCharacters,
|
||||
Connected,
|
||||
Deferred,
|
||||
Failed,
|
||||
}
|
||||
|
||||
internal sealed record LiveSessionCharacterSelection(
|
||||
int ActiveIndex,
|
||||
uint CharacterId,
|
||||
string CharacterName,
|
||||
string AccountName);
|
||||
|
||||
internal sealed record LiveSessionStartResult(
|
||||
LiveSessionStartStatus Status,
|
||||
LiveSessionCharacterSelection? Selection = null,
|
||||
Exception? Error = null);
|
||||
|
||||
/// <summary>
|
||||
/// Narrow App composition boundary for one exact WorldSession generation.
|
||||
/// The host owns domain sinks and presentation state; the controller owns the
|
||||
/// connect/enter/stop transaction and never reaches into GameWindow state.
|
||||
/// </summary>
|
||||
internal interface ILiveSessionLifecycleHost
|
||||
{
|
||||
LiveSessionBinding BindSession(WorldSession session);
|
||||
void ResetSessionState();
|
||||
void ReportConnecting(string host, int port, string user);
|
||||
void ReportConnected();
|
||||
void ApplySelectedCharacter(LiveSessionCharacterSelection selection);
|
||||
void ApplyEnteredWorld(LiveSessionCharacterSelection selection);
|
||||
void DetachSession(WorldSession session);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Owns routing and commands for one exact session. Teardown is ordered and
|
||||
/// retryable: outbound commands become inert before inbound subscriptions are
|
||||
/// detached.
|
||||
/// </summary>
|
||||
internal sealed class LiveSessionBinding : IDisposable
|
||||
{
|
||||
private readonly Action _activateCommands;
|
||||
private readonly Action _deactivateCommands;
|
||||
private readonly Action _detachEvents;
|
||||
private bool _commandsDeactivated;
|
||||
private bool _eventsDetached;
|
||||
private bool _commandsActivated;
|
||||
|
||||
public LiveSessionBinding(
|
||||
WorldSession session,
|
||||
ICommandBus commands,
|
||||
Action activateCommands,
|
||||
Action deactivateCommands,
|
||||
Action detachEvents)
|
||||
{
|
||||
Session = session ?? throw new ArgumentNullException(nameof(session));
|
||||
Commands = commands ?? throw new ArgumentNullException(nameof(commands));
|
||||
_activateCommands = activateCommands ?? throw new ArgumentNullException(nameof(activateCommands));
|
||||
_deactivateCommands = deactivateCommands ?? throw new ArgumentNullException(nameof(deactivateCommands));
|
||||
_detachEvents = detachEvents ?? throw new ArgumentNullException(nameof(detachEvents));
|
||||
}
|
||||
|
||||
public WorldSession Session { get; }
|
||||
public ICommandBus Commands { get; }
|
||||
|
||||
public void ActivateCommands()
|
||||
{
|
||||
if (_commandsDeactivated || _eventsDetached)
|
||||
throw new ObjectDisposedException(nameof(LiveSessionBinding));
|
||||
if (_commandsActivated)
|
||||
return;
|
||||
_activateCommands();
|
||||
if (_commandsDeactivated || _eventsDetached)
|
||||
return;
|
||||
_commandsActivated = true;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (!_commandsDeactivated)
|
||||
{
|
||||
_deactivateCommands();
|
||||
_commandsDeactivated = true;
|
||||
}
|
||||
if (!_eventsDetached)
|
||||
{
|
||||
_detachEvents();
|
||||
_eventsDetached = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal interface ILiveSessionOperations
|
||||
{
|
||||
IPEndPoint ResolveEndpoint(string host, int port);
|
||||
WorldSession CreateSession(IPEndPoint endpoint);
|
||||
void Connect(WorldSession session, string user, string password);
|
||||
CharacterList.Parsed? GetCharacters(WorldSession session);
|
||||
void EnterWorld(WorldSession session, int activeCharacterIndex);
|
||||
void Tick(WorldSession session);
|
||||
void DisposeSession(WorldSession session);
|
||||
}
|
||||
|
||||
internal sealed class ProductionLiveSessionOperations : ILiveSessionOperations
|
||||
{
|
||||
public static ProductionLiveSessionOperations Instance { get; } = new();
|
||||
|
||||
private ProductionLiveSessionOperations() { }
|
||||
|
||||
public IPEndPoint ResolveEndpoint(string host, int port)
|
||||
{
|
||||
IPAddress ip;
|
||||
if (!IPAddress.TryParse(host, out ip!))
|
||||
{
|
||||
IPAddress[] addresses = Dns.GetHostAddresses(host);
|
||||
ip = Array.Find(
|
||||
addresses,
|
||||
static address => address.AddressFamily == AddressFamily.InterNetwork)
|
||||
?? (addresses.Length != 0
|
||||
? addresses[0]
|
||||
: throw new InvalidOperationException(
|
||||
$"DNS resolved no addresses for '{host}'"));
|
||||
Console.WriteLine($"live: resolved {host} → {ip}");
|
||||
}
|
||||
return new IPEndPoint(ip, port);
|
||||
}
|
||||
|
||||
public WorldSession CreateSession(IPEndPoint endpoint) => new(endpoint);
|
||||
|
||||
public void Connect(WorldSession session, string user, string password) =>
|
||||
session.Connect(user, password);
|
||||
|
||||
public CharacterList.Parsed? GetCharacters(WorldSession session) => session.Characters;
|
||||
|
||||
public void EnterWorld(WorldSession session, int activeCharacterIndex) =>
|
||||
session.EnterWorld(activeCharacterIndex);
|
||||
|
||||
public void Tick(WorldSession session) => session.Tick();
|
||||
|
||||
public void DisposeSession(WorldSession session) => session.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sole owner of the App lifetime for a live WorldSession: endpoint resolution,
|
||||
/// complete pre-Connect routing, character validation/entry, active command
|
||||
/// publication, exact-generation Tick, graceful replacement, and convergent
|
||||
/// teardown. Retail wire behavior remains inside WorldSession.
|
||||
/// </summary>
|
||||
internal sealed class LiveSessionController
|
||||
: IDisposable,
|
||||
ILiveSessionFramePhase,
|
||||
ILiveInWorldSource,
|
||||
ILiveWorldSessionSource,
|
||||
ILiveUiSessionTarget
|
||||
{
|
||||
private sealed class SessionScope(
|
||||
WorldSession session,
|
||||
ILiveSessionLifecycleHost host)
|
||||
{
|
||||
private int _teardownStage;
|
||||
|
||||
public WorldSession Session { get; } = session;
|
||||
public ILiveSessionLifecycleHost Host { get; } = host;
|
||||
public LiveSessionBinding? Binding { get; set; }
|
||||
public bool HostAttached { get; set; }
|
||||
|
||||
public void DrainTeardown(ILiveSessionOperations operations)
|
||||
{
|
||||
if (_teardownStage == 0)
|
||||
{
|
||||
Binding?.Dispose();
|
||||
_teardownStage = 1;
|
||||
}
|
||||
if (_teardownStage == 1)
|
||||
{
|
||||
operations.DisposeSession(Session);
|
||||
_teardownStage = 2;
|
||||
}
|
||||
if (_teardownStage == 2)
|
||||
{
|
||||
if (HostAttached)
|
||||
Host.DetachSession(Session);
|
||||
_teardownStage = 3;
|
||||
}
|
||||
if (_teardownStage == 3)
|
||||
{
|
||||
Host.ResetSessionState();
|
||||
_teardownStage = 4;
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsTeardownComplete => _teardownStage == 4;
|
||||
}
|
||||
|
||||
private enum PendingKind
|
||||
{
|
||||
Stop,
|
||||
Reconnect,
|
||||
Dispose,
|
||||
}
|
||||
|
||||
private sealed record PendingOperation(
|
||||
PendingKind Kind,
|
||||
RuntimeOptions? Options = null,
|
||||
ILiveSessionLifecycleHost? Host = null);
|
||||
|
||||
private readonly object _gate = new();
|
||||
private readonly ILiveSessionOperations _operations;
|
||||
private SessionScope? _scope;
|
||||
private SessionScope? _retiredScope;
|
||||
private ILiveSessionLifecycleHost? _pendingInitialResetHost;
|
||||
private PendingOperation? _pendingOperation;
|
||||
private int _operationDepth;
|
||||
private bool _inWorld;
|
||||
private bool _disposeRequested;
|
||||
private bool _disposed;
|
||||
private ulong _generation;
|
||||
private LiveSessionCharacterSelection? _activeSelection;
|
||||
|
||||
public LiveSessionController()
|
||||
: this(ProductionLiveSessionOperations.Instance)
|
||||
{
|
||||
}
|
||||
|
||||
internal LiveSessionController(ILiveSessionOperations operations)
|
||||
{
|
||||
_operations = operations ?? throw new ArgumentNullException(nameof(operations));
|
||||
}
|
||||
|
||||
public WorldSession? CurrentSession
|
||||
{
|
||||
get { lock (_gate) return _scope?.Session; }
|
||||
}
|
||||
|
||||
public ICommandBus Commands
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
return _inWorld && _scope?.Binding is { } binding
|
||||
? binding.Commands
|
||||
: NullCommandBus.Instance;
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsInWorld
|
||||
{
|
||||
get { lock (_gate) return _inWorld; }
|
||||
}
|
||||
|
||||
public ulong SessionGeneration
|
||||
{
|
||||
get { lock (_gate) return _generation; }
|
||||
}
|
||||
|
||||
internal bool IsDisposalComplete
|
||||
{
|
||||
get { lock (_gate) return _disposed; }
|
||||
}
|
||||
|
||||
internal LiveSessionStartResult Start(
|
||||
RuntimeOptions options,
|
||||
ILiveSessionLifecycleHost host)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(host);
|
||||
lock (_gate)
|
||||
{
|
||||
ThrowIfDisposing();
|
||||
if (_operationDepth != 0)
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||
if (_inWorld)
|
||||
return ConnectedResult();
|
||||
return RunTopLevel(() => StartCore(options, host, resetHost: true));
|
||||
}
|
||||
}
|
||||
|
||||
internal LiveSessionStartResult Reconnect(
|
||||
RuntimeOptions options,
|
||||
ILiveSessionLifecycleHost host)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(host);
|
||||
lock (_gate)
|
||||
{
|
||||
ThrowIfDisposing();
|
||||
if (_operationDepth != 0)
|
||||
{
|
||||
Schedule(new PendingOperation(PendingKind.Reconnect, options, host));
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||
}
|
||||
return RunTopLevel(() => ReconnectCore(options, host));
|
||||
}
|
||||
}
|
||||
|
||||
internal void Stop()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
if (_operationDepth != 0)
|
||||
{
|
||||
Schedule(new PendingOperation(PendingKind.Stop));
|
||||
return;
|
||||
}
|
||||
RunTopLevel(StopCore);
|
||||
}
|
||||
}
|
||||
|
||||
public void Tick()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
if (!_inWorld || _scope is null || _operationDepth != 0)
|
||||
return;
|
||||
|
||||
RunTopLevel(() =>
|
||||
{
|
||||
SessionScope scope = _scope;
|
||||
ulong generation = _generation;
|
||||
try
|
||||
{
|
||||
_operations.Tick(scope.Session);
|
||||
if (!IsCurrent(scope, generation))
|
||||
return;
|
||||
}
|
||||
catch (Exception tickError)
|
||||
{
|
||||
Exception error = StopAfterFailure(tickError);
|
||||
if (ReferenceEquals(error, tickError))
|
||||
throw;
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
_disposeRequested = true;
|
||||
if (_operationDepth != 0)
|
||||
{
|
||||
Schedule(new PendingOperation(PendingKind.Dispose));
|
||||
return;
|
||||
}
|
||||
|
||||
RunTopLevel(DisposeCore);
|
||||
}
|
||||
}
|
||||
|
||||
private LiveSessionStartResult ReconnectCore(
|
||||
RuntimeOptions options,
|
||||
ILiveSessionLifecycleHost host)
|
||||
{
|
||||
ILiveSessionLifecycleHost? oldHost =
|
||||
_scope?.Host ?? _retiredScope?.Host ?? _pendingInitialResetHost;
|
||||
ulong generationBeforeStop = _generation;
|
||||
try
|
||||
{
|
||||
StopCore();
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.Failed, Error: error);
|
||||
}
|
||||
if (_generation != unchecked(generationBeforeStop + 1))
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||
return StartCore(options, host, resetHost: !ReferenceEquals(oldHost, host));
|
||||
}
|
||||
|
||||
private LiveSessionStartResult StartCore(
|
||||
RuntimeOptions options,
|
||||
ILiveSessionLifecycleHost host,
|
||||
bool resetHost)
|
||||
{
|
||||
ulong generation = ++_generation;
|
||||
try
|
||||
{
|
||||
DrainRetiredScope();
|
||||
if (_generation != generation)
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||
if (resetHost)
|
||||
ResetHostBeforeStart(host, generation);
|
||||
if (_generation != generation)
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.Failed, Error: error);
|
||||
}
|
||||
|
||||
if (!options.LiveMode)
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.Disabled);
|
||||
if (string.IsNullOrEmpty(options.LiveUser) || string.IsNullOrEmpty(options.LivePass))
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.MissingCredentials);
|
||||
|
||||
SessionScope? scope = null;
|
||||
try
|
||||
{
|
||||
IPEndPoint endpoint = _operations.ResolveEndpoint(options.LiveHost, options.LivePort);
|
||||
if (_generation != generation)
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||
Console.WriteLine($"live: connecting to {endpoint} as {options.LiveUser}");
|
||||
WorldSession session = _operations.CreateSession(endpoint);
|
||||
scope = new SessionScope(session, host);
|
||||
_scope = scope;
|
||||
_inWorld = false;
|
||||
if (!IsCurrent(scope, generation))
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||
|
||||
LiveSessionBinding binding = host.BindSession(session);
|
||||
scope.Binding = binding;
|
||||
scope.HostAttached = true;
|
||||
if (!ReferenceEquals(binding.Session, session))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The live-session host returned a binding for a different session.");
|
||||
}
|
||||
if (!IsCurrent(scope, generation))
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||
|
||||
host.ReportConnecting(options.LiveHost, options.LivePort, options.LiveUser);
|
||||
if (!IsCurrent(scope, generation))
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||
|
||||
_operations.Connect(session, options.LiveUser, options.LivePass);
|
||||
if (!IsCurrent(scope, generation))
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||
host.ReportConnected();
|
||||
if (!IsCurrent(scope, generation))
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||
|
||||
CharacterList.Parsed? characters = _operations.GetCharacters(session);
|
||||
if (characters is null
|
||||
|| !CharacterList.TrySelectFirstAvailable(characters, out CharacterList.Selection selected))
|
||||
{
|
||||
Console.WriteLine("live: no available characters on account; disconnecting");
|
||||
StopCore();
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.NoCharacters);
|
||||
}
|
||||
|
||||
var selection = new LiveSessionCharacterSelection(
|
||||
selected.ActiveIndex,
|
||||
selected.Character.Id,
|
||||
selected.Character.Name,
|
||||
characters.AccountName);
|
||||
host.ApplySelectedCharacter(selection);
|
||||
if (!IsCurrent(scope, generation))
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||
|
||||
Console.WriteLine(
|
||||
$"live: entering world as 0x{selection.CharacterId:X8} {selection.CharacterName}");
|
||||
_operations.EnterWorld(session, selection.ActiveIndex);
|
||||
if (!IsCurrent(scope, generation))
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||
|
||||
binding.ActivateCommands();
|
||||
if (!IsCurrent(scope, generation))
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||
_inWorld = true;
|
||||
_activeSelection = selection;
|
||||
host.ApplyEnteredWorld(selection);
|
||||
if (!IsCurrent(scope, generation))
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||
|
||||
Console.WriteLine("live: in world — CreateObject stream active");
|
||||
return new LiveSessionStartResult(
|
||||
LiveSessionStartStatus.Connected,
|
||||
selection);
|
||||
}
|
||||
catch (Exception startError)
|
||||
{
|
||||
return new LiveSessionStartResult(
|
||||
LiveSessionStartStatus.Failed,
|
||||
Error: StopAfterFailure(startError));
|
||||
}
|
||||
}
|
||||
|
||||
private Exception StopAfterFailure(Exception operationError)
|
||||
{
|
||||
try
|
||||
{
|
||||
StopCore();
|
||||
return operationError;
|
||||
}
|
||||
catch (Exception cleanupError)
|
||||
{
|
||||
return new AggregateException(
|
||||
"Live-session operation and cleanup both failed.",
|
||||
operationError,
|
||||
cleanupError);
|
||||
}
|
||||
}
|
||||
|
||||
private void StopCore()
|
||||
{
|
||||
++_generation;
|
||||
_inWorld = false;
|
||||
_activeSelection = null;
|
||||
if (_scope is { } scope)
|
||||
{
|
||||
_scope = null;
|
||||
if (_retiredScope is not null && !ReferenceEquals(_retiredScope, scope))
|
||||
throw new InvalidOperationException(
|
||||
"A second live-session scope cannot retire before the first converges.");
|
||||
_retiredScope = scope;
|
||||
}
|
||||
DrainRetiredScope();
|
||||
DrainPendingInitialReset();
|
||||
}
|
||||
|
||||
private void DrainRetiredScope()
|
||||
{
|
||||
if (_retiredScope is not { } retired)
|
||||
return;
|
||||
retired.DrainTeardown(_operations);
|
||||
if (retired.IsTeardownComplete)
|
||||
_retiredScope = null;
|
||||
}
|
||||
|
||||
private void ResetHostBeforeStart(
|
||||
ILiveSessionLifecycleHost host,
|
||||
ulong generation)
|
||||
{
|
||||
bool requestedHostAlreadyReset = false;
|
||||
if (_pendingInitialResetHost is { } pending)
|
||||
{
|
||||
pending.ResetSessionState();
|
||||
_pendingInitialResetHost = null;
|
||||
requestedHostAlreadyReset = ReferenceEquals(pending, host);
|
||||
}
|
||||
if (requestedHostAlreadyReset || _generation != generation)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
host.ResetSessionState();
|
||||
}
|
||||
catch
|
||||
{
|
||||
_pendingInitialResetHost = host;
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private void DrainPendingInitialReset()
|
||||
{
|
||||
if (_pendingInitialResetHost is not { } host)
|
||||
return;
|
||||
host.ResetSessionState();
|
||||
_pendingInitialResetHost = null;
|
||||
}
|
||||
|
||||
private void Schedule(PendingOperation operation)
|
||||
{
|
||||
if (_pendingOperation?.Kind == PendingKind.Dispose)
|
||||
return;
|
||||
_pendingOperation = operation;
|
||||
++_generation;
|
||||
_inWorld = false;
|
||||
_scope?.Binding?.Dispose();
|
||||
}
|
||||
|
||||
private T RunTopLevel<T>(Func<T> operation)
|
||||
{
|
||||
_operationDepth++;
|
||||
try
|
||||
{
|
||||
return operation();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_operationDepth--;
|
||||
if (_operationDepth == 0)
|
||||
DrainPendingOperations();
|
||||
}
|
||||
}
|
||||
|
||||
private void RunTopLevel(Action operation) =>
|
||||
RunTopLevel(() =>
|
||||
{
|
||||
operation();
|
||||
return true;
|
||||
});
|
||||
|
||||
private void DrainPendingOperations()
|
||||
{
|
||||
while (_pendingOperation is { } pending)
|
||||
{
|
||||
_pendingOperation = null;
|
||||
_operationDepth++;
|
||||
try
|
||||
{
|
||||
switch (pending.Kind)
|
||||
{
|
||||
case PendingKind.Stop:
|
||||
StopCore();
|
||||
break;
|
||||
case PendingKind.Reconnect:
|
||||
_ = ReconnectCore(pending.Options!, pending.Host!);
|
||||
break;
|
||||
case PendingKind.Dispose:
|
||||
DisposeCore();
|
||||
break;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_operationDepth--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DisposeCore()
|
||||
{
|
||||
StopCore();
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
private bool IsCurrent(SessionScope scope, ulong generation) =>
|
||||
ReferenceEquals(_scope, scope) && _generation == generation;
|
||||
|
||||
private LiveSessionStartResult ConnectedResult()
|
||||
=> new(LiveSessionStartStatus.Connected, _activeSelection);
|
||||
|
||||
private void ThrowIfDisposing()
|
||||
{
|
||||
if (_disposeRequested || _disposed)
|
||||
throw new ObjectDisposedException(nameof(LiveSessionController));
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,425 +0,0 @@
|
|||
using AcDream.Core.Chat;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Player;
|
||||
using AcDream.Core.Social;
|
||||
using AcDream.Core.Spells;
|
||||
|
||||
namespace AcDream.App.Net;
|
||||
|
||||
internal sealed record LiveEntitySessionSink(
|
||||
Action<WorldSession.EntitySpawn> Spawned,
|
||||
Action<DeleteObject.Parsed> Deleted,
|
||||
Action<PickupEvent.Parsed> PickedUp,
|
||||
Action<WorldSession.EntityMotionUpdate> MotionUpdated,
|
||||
Action<WorldSession.EntityPositionUpdate> PositionUpdated,
|
||||
Action<VectorUpdate.Parsed> VectorUpdated,
|
||||
Action<SetState.Parsed> StateUpdated,
|
||||
Action<ParentEvent.Parsed> ParentUpdated,
|
||||
Action<uint> TeleportStarted,
|
||||
Action<ObjDescEvent.Parsed> AppearanceUpdated,
|
||||
Action<PlayPhysicsScript> PlayPhysicsScript,
|
||||
Action<PlayPhysicsScriptType> PlayPhysicsScriptType);
|
||||
|
||||
internal sealed record LiveEnvironmentSessionSink(
|
||||
Action<uint> EnvironChanged,
|
||||
Action<double> ServerTimeUpdated);
|
||||
|
||||
internal sealed record LiveInventorySessionBindings(
|
||||
ClientObjectTable Objects,
|
||||
LocalPlayerState LocalPlayer,
|
||||
Func<uint> PlayerGuid,
|
||||
Action<IReadOnlyList<ShortcutEntry>>? OnShortcuts,
|
||||
Action<uint>? OnUseDone,
|
||||
ItemManaState? ItemMana,
|
||||
Action<IReadOnlyList<(uint Id, uint Amount)>>? OnDesiredComponents,
|
||||
ExternalContainerState? ExternalContainers,
|
||||
Action<AppraiseInfoParser.Parsed>? OnAppraisal = null);
|
||||
|
||||
internal sealed record LiveCharacterSessionBindings(
|
||||
CombatState Combat,
|
||||
Spellbook Spellbook,
|
||||
Func<uint, IReadOnlyDictionary<uint, uint>, uint>? ResolveSkillFormulaBonus,
|
||||
Action<int, int>? OnSkillsUpdated,
|
||||
Action<GameEvents.CharacterConfirmationRequest>? OnConfirmationRequest,
|
||||
Action<GameEvents.CharacterConfirmationDone>? OnConfirmationDone,
|
||||
Action<uint, uint>? OnCharacterOptions,
|
||||
Func<double>? ClientTime);
|
||||
|
||||
internal sealed record LiveSocialSessionBindings(
|
||||
ChatLog Chat,
|
||||
TurbineChatState TurbineChat,
|
||||
FriendsState? Friends,
|
||||
SquelchState? Squelch);
|
||||
|
||||
/// <summary>
|
||||
/// Owns every inbound subscription for one exact live session. Domain state
|
||||
/// remains in the supplied sinks; this class owns only routing and teardown.
|
||||
/// </summary>
|
||||
internal sealed class LiveSessionEventRouter : ILiveSessionEventRouting
|
||||
{
|
||||
private readonly LiveSessionSubscriptionSet _subscriptions = new();
|
||||
private readonly Action<int>? _constructionCheckpoint;
|
||||
private readonly WorldSession _session;
|
||||
private readonly LiveEntitySessionSink _entities;
|
||||
private readonly LiveEnvironmentSessionSink _environment;
|
||||
private readonly LiveInventorySessionBindings _inventory;
|
||||
private readonly LiveCharacterSessionBindings _character;
|
||||
private readonly LiveSocialSessionBindings _social;
|
||||
private int _constructionStep;
|
||||
private int _accepting;
|
||||
private int _lifecycleState; // 0 created, 1 attaching, 2 attached, 3 disposed
|
||||
|
||||
public LiveSessionEventRouter(
|
||||
WorldSession session,
|
||||
LiveEntitySessionSink entities,
|
||||
LiveEnvironmentSessionSink environment,
|
||||
LiveInventorySessionBindings inventory,
|
||||
LiveCharacterSessionBindings character,
|
||||
LiveSocialSessionBindings social,
|
||||
Action<int>? constructionCheckpoint = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(session);
|
||||
Validate(entities, environment, inventory, character, social);
|
||||
_session = session;
|
||||
_entities = entities;
|
||||
_environment = environment;
|
||||
_inventory = inventory;
|
||||
_character = character;
|
||||
_social = social;
|
||||
_constructionCheckpoint = constructionCheckpoint;
|
||||
}
|
||||
|
||||
public void Attach()
|
||||
{
|
||||
if (Interlocked.CompareExchange(ref _lifecycleState, 1, 0) != 0)
|
||||
throw new InvalidOperationException(
|
||||
"Live-session event routing can only attach once.");
|
||||
|
||||
Interlocked.Exchange(ref _accepting, 1);
|
||||
WorldSession session = _session;
|
||||
LiveEntitySessionSink entities = _entities;
|
||||
LiveEnvironmentSessionSink environment = _environment;
|
||||
LiveInventorySessionBindings inventory = _inventory;
|
||||
LiveCharacterSessionBindings character = _character;
|
||||
LiveSocialSessionBindings social = _social;
|
||||
|
||||
try
|
||||
{
|
||||
// Preserve the shipped pre-Connect registration order. Property
|
||||
// state is installed before lifecycle packets can be dispatched.
|
||||
_subscriptions.Add(ObjectTableWiring.Wire(
|
||||
session,
|
||||
inventory.Objects,
|
||||
inventory.PlayerGuid,
|
||||
inventory.LocalPlayer,
|
||||
IsAccepting));
|
||||
ConstructionCheckpoint();
|
||||
_subscriptions.Add(CombatStateWiring.Wire(
|
||||
session,
|
||||
character.Combat,
|
||||
IsAccepting));
|
||||
ConstructionCheckpoint();
|
||||
|
||||
Subscribe(h => session.EntitySpawned += h, h => session.EntitySpawned -= h, entities.Spawned);
|
||||
Subscribe(h => session.EntityDeleted += h, h => session.EntityDeleted -= h, entities.Deleted);
|
||||
Subscribe(h => session.EntityPickedUp += h, h => session.EntityPickedUp -= h, entities.PickedUp);
|
||||
Subscribe(h => session.MotionUpdated += h, h => session.MotionUpdated -= h, entities.MotionUpdated);
|
||||
Subscribe(h => session.PositionUpdated += h, h => session.PositionUpdated -= h, entities.PositionUpdated);
|
||||
Subscribe(h => session.VectorUpdated += h, h => session.VectorUpdated -= h, entities.VectorUpdated);
|
||||
Subscribe(h => session.StateUpdated += h, h => session.StateUpdated -= h, entities.StateUpdated);
|
||||
Subscribe(h => session.ParentUpdated += h, h => session.ParentUpdated -= h, entities.ParentUpdated);
|
||||
Subscribe(h => session.TeleportStarted += h, h => session.TeleportStarted -= h, entities.TeleportStarted);
|
||||
Subscribe(h => session.AppearanceUpdated += h, h => session.AppearanceUpdated -= h, entities.AppearanceUpdated);
|
||||
Subscribe(
|
||||
h => session.PlayPhysicsScriptReceived += h,
|
||||
h => session.PlayPhysicsScriptReceived -= h,
|
||||
entities.PlayPhysicsScript);
|
||||
Subscribe(
|
||||
h => session.PlayPhysicsScriptTypeReceived += h,
|
||||
h => session.PlayPhysicsScriptTypeReceived -= h,
|
||||
entities.PlayPhysicsScriptType);
|
||||
|
||||
Subscribe(h => session.EnvironChanged += h, h => session.EnvironChanged -= h, environment.EnvironChanged);
|
||||
Subscribe(h => session.ServerTimeUpdated += h, h => session.ServerTimeUpdated -= h, environment.ServerTimeUpdated);
|
||||
|
||||
_subscriptions.Add(GameEventWiring.WireAll(
|
||||
session.GameEvents,
|
||||
inventory.Objects,
|
||||
character.Combat,
|
||||
character.Spellbook,
|
||||
social.Chat,
|
||||
inventory.LocalPlayer,
|
||||
social.TurbineChat,
|
||||
onSkillsUpdated: character.OnSkillsUpdated,
|
||||
resolveSkillFormulaBonus: character.ResolveSkillFormulaBonus,
|
||||
onShortcuts: inventory.OnShortcuts,
|
||||
playerGuid: inventory.PlayerGuid,
|
||||
onUseDone: inventory.OnUseDone,
|
||||
onAppraisal: inventory.OnAppraisal,
|
||||
itemMana: inventory.ItemMana,
|
||||
onConfirmationRequest: character.OnConfirmationRequest,
|
||||
onConfirmationDone: character.OnConfirmationDone,
|
||||
friends: social.Friends,
|
||||
squelch: social.Squelch,
|
||||
onDesiredComponents: inventory.OnDesiredComponents,
|
||||
onCharacterOptions: character.OnCharacterOptions,
|
||||
clientTime: character.ClientTime,
|
||||
externalContainers: inventory.ExternalContainers,
|
||||
accepting: IsAccepting));
|
||||
ConstructionCheckpoint();
|
||||
_subscriptions.Add(new CombatChatTranslator(
|
||||
character.Combat,
|
||||
social.Chat,
|
||||
IsAccepting));
|
||||
ConstructionCheckpoint();
|
||||
|
||||
Subscribe<HearSpeech.Parsed>(h => session.SpeechHeard += h, h => session.SpeechHeard -= h, speech =>
|
||||
social.Chat.OnLocalSpeech(
|
||||
speech.SenderName,
|
||||
speech.Text,
|
||||
speech.SenderGuid,
|
||||
speech.IsRanged));
|
||||
Subscribe<ServerMessage.Parsed>(
|
||||
h => session.ServerMessageReceived += h,
|
||||
h => session.ServerMessageReceived -= h,
|
||||
message => social.Chat.OnSystemMessage(message.Message, message.ChatType));
|
||||
Subscribe<EmoteText.Parsed>(h => session.EmoteHeard += h, h => session.EmoteHeard -= h, emote =>
|
||||
social.Chat.OnEmote(emote.SenderName, emote.Text, emote.SenderGuid));
|
||||
Subscribe<SoulEmote.Parsed>(h => session.SoulEmoteHeard += h, h => session.SoulEmoteHeard -= h, emote =>
|
||||
social.Chat.OnSoulEmote(emote.SenderName, emote.Text, emote.SenderGuid));
|
||||
Subscribe<PlayerKilled.Parsed>(
|
||||
h => session.PlayerKilledReceived += h,
|
||||
h => session.PlayerKilledReceived -= h,
|
||||
killed => social.Chat.OnPlayerKilled(
|
||||
killed.DeathMessage,
|
||||
killed.VictimGuid,
|
||||
killed.KillerGuid));
|
||||
Subscribe<TurbineChat.Parsed>(
|
||||
h => session.TurbineChatReceived += h,
|
||||
h => session.TurbineChatReceived -= h,
|
||||
parsed => RouteTurbineChat(social.Chat, parsed));
|
||||
Subscribe<PrivateUpdateVital.ParsedFull>(h => session.VitalUpdated += h, h => session.VitalUpdated -= h, vital =>
|
||||
inventory.LocalPlayer.OnVitalUpdate(
|
||||
vital.VitalId,
|
||||
vital.Ranks,
|
||||
vital.Start,
|
||||
vital.Xp,
|
||||
vital.Current));
|
||||
Subscribe<PrivateUpdateVital.ParsedCurrent>(
|
||||
h => session.VitalCurrentUpdated += h,
|
||||
h => session.VitalCurrentUpdated -= h,
|
||||
vital => inventory.LocalPlayer.OnVitalCurrent(vital.VitalId, vital.Current));
|
||||
|
||||
if (Interlocked.CompareExchange(ref _lifecycleState, 2, 1) != 1)
|
||||
throw new ObjectDisposedException(nameof(LiveSessionEventRouter));
|
||||
}
|
||||
catch
|
||||
{
|
||||
Interlocked.Exchange(ref _accepting, 0);
|
||||
Interlocked.Exchange(ref _lifecycleState, 3);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public bool Accepting => IsAccepting();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Interlocked.Exchange(ref _accepting, 0);
|
||||
Interlocked.Exchange(ref _lifecycleState, 3);
|
||||
_subscriptions.Dispose();
|
||||
}
|
||||
|
||||
private void Subscribe<T>(
|
||||
Action<Action<T>> attach,
|
||||
Action<Action<T>> detach,
|
||||
Action<T> sink)
|
||||
{
|
||||
Action<T> handler = value =>
|
||||
{
|
||||
if (Volatile.Read(ref _accepting) != 0)
|
||||
sink(value);
|
||||
};
|
||||
|
||||
attach(handler);
|
||||
// Add assumes cleanup ownership before it can call an external detach.
|
||||
// If the set is already closing, it retains/retries that exact edge;
|
||||
// calling detach again here would replay a successful removal.
|
||||
_subscriptions.Add(() => detach(handler));
|
||||
|
||||
ConstructionCheckpoint();
|
||||
}
|
||||
|
||||
private void ConstructionCheckpoint() =>
|
||||
_constructionCheckpoint?.Invoke(++_constructionStep);
|
||||
|
||||
private bool IsAccepting() => Volatile.Read(ref _accepting) != 0;
|
||||
|
||||
private static void RouteTurbineChat(ChatLog chat, TurbineChat.Parsed parsed)
|
||||
{
|
||||
if (parsed.Body is not TurbineChat.Payload.EventSendToRoom message)
|
||||
return;
|
||||
|
||||
chat.OnChannelBroadcast(
|
||||
message.RoomId,
|
||||
message.SenderName,
|
||||
message.Message,
|
||||
TurbineChatRouting.DisplayName(message.RoomId, message.ChatType));
|
||||
}
|
||||
|
||||
private static void Validate(
|
||||
LiveEntitySessionSink entities,
|
||||
LiveEnvironmentSessionSink environment,
|
||||
LiveInventorySessionBindings inventory,
|
||||
LiveCharacterSessionBindings character,
|
||||
LiveSocialSessionBindings social)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entities);
|
||||
ArgumentNullException.ThrowIfNull(environment);
|
||||
ArgumentNullException.ThrowIfNull(inventory);
|
||||
ArgumentNullException.ThrowIfNull(character);
|
||||
ArgumentNullException.ThrowIfNull(social);
|
||||
ArgumentNullException.ThrowIfNull(entities.Spawned);
|
||||
ArgumentNullException.ThrowIfNull(entities.Deleted);
|
||||
ArgumentNullException.ThrowIfNull(entities.PickedUp);
|
||||
ArgumentNullException.ThrowIfNull(entities.MotionUpdated);
|
||||
ArgumentNullException.ThrowIfNull(entities.PositionUpdated);
|
||||
ArgumentNullException.ThrowIfNull(entities.VectorUpdated);
|
||||
ArgumentNullException.ThrowIfNull(entities.StateUpdated);
|
||||
ArgumentNullException.ThrowIfNull(entities.ParentUpdated);
|
||||
ArgumentNullException.ThrowIfNull(entities.TeleportStarted);
|
||||
ArgumentNullException.ThrowIfNull(entities.AppearanceUpdated);
|
||||
ArgumentNullException.ThrowIfNull(entities.PlayPhysicsScript);
|
||||
ArgumentNullException.ThrowIfNull(entities.PlayPhysicsScriptType);
|
||||
ArgumentNullException.ThrowIfNull(environment.EnvironChanged);
|
||||
ArgumentNullException.ThrowIfNull(environment.ServerTimeUpdated);
|
||||
ArgumentNullException.ThrowIfNull(inventory.Objects);
|
||||
ArgumentNullException.ThrowIfNull(inventory.LocalPlayer);
|
||||
ArgumentNullException.ThrowIfNull(inventory.PlayerGuid);
|
||||
ArgumentNullException.ThrowIfNull(character.Combat);
|
||||
ArgumentNullException.ThrowIfNull(character.Spellbook);
|
||||
ArgumentNullException.ThrowIfNull(social.Chat);
|
||||
ArgumentNullException.ThrowIfNull(social.TurbineChat);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class LiveSessionSubscriptionSet : IDisposable
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly List<RetryableSubscription> _subscriptions = [];
|
||||
private bool _disposeRequested;
|
||||
|
||||
public void Add(IDisposable subscription)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(subscription);
|
||||
AddRetained(new RetryableSubscription(subscription.Dispose));
|
||||
}
|
||||
|
||||
public void Add(Action unsubscribe)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(unsubscribe);
|
||||
AddRetained(new RetryableSubscription(unsubscribe));
|
||||
}
|
||||
|
||||
private void AddRetained(RetryableSubscription retained)
|
||||
{
|
||||
bool disposeNow;
|
||||
lock (_gate)
|
||||
{
|
||||
disposeNow = _disposeRequested;
|
||||
_subscriptions.Add(retained);
|
||||
}
|
||||
|
||||
if (!disposeNow)
|
||||
return;
|
||||
retained.Dispose();
|
||||
throw new ObjectDisposedException(nameof(LiveSessionSubscriptionSet));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
RetryableSubscription[] subscriptions;
|
||||
lock (_gate)
|
||||
{
|
||||
_disposeRequested = true;
|
||||
subscriptions = _subscriptions.ToArray();
|
||||
}
|
||||
|
||||
List<Exception>? errors = null;
|
||||
for (int index = subscriptions.Length - 1; index >= 0; index--)
|
||||
{
|
||||
try
|
||||
{
|
||||
subscriptions[index].Dispose();
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
(errors ??= []).Add(error);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors is not null)
|
||||
throw new AggregateException(
|
||||
"one or more live-session subscriptions failed to detach",
|
||||
errors);
|
||||
}
|
||||
|
||||
private sealed class RetryableSubscription(Action dispose) : IDisposable
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private Action? _dispose = dispose;
|
||||
private bool _executing;
|
||||
private int _executingThreadId;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Action? operation;
|
||||
int threadId = Environment.CurrentManagedThreadId;
|
||||
lock (_gate)
|
||||
{
|
||||
while (_executing)
|
||||
{
|
||||
if (_executingThreadId == threadId)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Live-session subscription cleanup cannot complete reentrantly.");
|
||||
}
|
||||
Monitor.Wait(_gate);
|
||||
}
|
||||
|
||||
operation = _dispose;
|
||||
if (operation is null)
|
||||
return;
|
||||
_executing = true;
|
||||
_executingThreadId = threadId;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
operation();
|
||||
}
|
||||
catch
|
||||
{
|
||||
CompleteAttempt(succeeded: false);
|
||||
throw;
|
||||
}
|
||||
|
||||
CompleteAttempt(succeeded: true);
|
||||
}
|
||||
|
||||
private void CompleteAttempt(bool succeeded)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (succeeded)
|
||||
_dispose = null;
|
||||
_executing = false;
|
||||
_executingThreadId = 0;
|
||||
Monitor.PulseAll(_gate);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,242 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
using AcDream.Core.Net;
|
||||
|
||||
namespace AcDream.App.Net;
|
||||
|
||||
internal sealed record LiveSessionLifecycleBindings(
|
||||
Func<WorldSession, LiveSessionBinding> Bind,
|
||||
Action Reset,
|
||||
Action<string, int, string> Connecting,
|
||||
Action Connected,
|
||||
Action<LiveSessionCharacterSelection> Selected,
|
||||
Action<LiveSessionCharacterSelection> Entered);
|
||||
|
||||
/// <summary>
|
||||
/// Focused adapter between the session lifetime owner and App composition.
|
||||
/// It tracks only the exact borrowed session attached to the host; domain and
|
||||
/// presentation state remain behind the supplied lifecycle callbacks.
|
||||
/// </summary>
|
||||
internal sealed class LiveSessionLifecycleHost : ILiveSessionLifecycleHost
|
||||
{
|
||||
private readonly LiveSessionLifecycleBindings _bindings;
|
||||
private WorldSession? _boundSession;
|
||||
|
||||
public LiveSessionLifecycleHost(LiveSessionLifecycleBindings bindings)
|
||||
{
|
||||
_bindings = bindings ?? throw new ArgumentNullException(nameof(bindings));
|
||||
ArgumentNullException.ThrowIfNull(bindings.Bind);
|
||||
ArgumentNullException.ThrowIfNull(bindings.Reset);
|
||||
ArgumentNullException.ThrowIfNull(bindings.Connecting);
|
||||
ArgumentNullException.ThrowIfNull(bindings.Connected);
|
||||
ArgumentNullException.ThrowIfNull(bindings.Selected);
|
||||
ArgumentNullException.ThrowIfNull(bindings.Entered);
|
||||
}
|
||||
|
||||
public LiveSessionBinding BindSession(WorldSession session)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(session);
|
||||
if (_boundSession is not null)
|
||||
throw new InvalidOperationException("A live session is already attached to this host.");
|
||||
|
||||
LiveSessionBinding binding = _bindings.Bind(session);
|
||||
_boundSession = session;
|
||||
return binding;
|
||||
}
|
||||
|
||||
public void ResetSessionState() => _bindings.Reset();
|
||||
|
||||
public void ReportConnecting(string host, int port, string user) =>
|
||||
_bindings.Connecting(host, port, user);
|
||||
|
||||
public void ReportConnected() => _bindings.Connected();
|
||||
|
||||
public void ApplySelectedCharacter(LiveSessionCharacterSelection selection) =>
|
||||
_bindings.Selected(selection);
|
||||
|
||||
public void ApplyEnteredWorld(LiveSessionCharacterSelection selection) =>
|
||||
_bindings.Entered(selection);
|
||||
|
||||
public void DetachSession(WorldSession session)
|
||||
{
|
||||
if (!ReferenceEquals(_boundSession, session))
|
||||
throw new InvalidOperationException(
|
||||
"The live-session controller attempted to detach a session that is not bound.");
|
||||
_boundSession = null;
|
||||
}
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ using AcDream.Core.Social;
|
|||
using AcDream.Core.Spells;
|
||||
using AcDream.Core.World;
|
||||
using AcDream.Content;
|
||||
using AcDream.Runtime.Session;
|
||||
using AcDream.UI.Abstractions.Panels.Chat;
|
||||
using AcDream.UI.Abstractions.Panels.Vitals;
|
||||
using DatReaderWriter;
|
||||
|
|
@ -101,6 +102,7 @@ internal sealed class LiveSessionRuntimeFactory
|
|||
private readonly LiveSessionUiRuntime _ui;
|
||||
private readonly LiveSessionInteractionRuntime _interaction;
|
||||
private readonly LiveSessionWorldRuntime _world;
|
||||
private readonly LiveSessionCommandSurface _commands;
|
||||
private readonly Action<string> _log;
|
||||
|
||||
public LiveSessionRuntimeFactory(
|
||||
|
|
@ -109,6 +111,7 @@ internal sealed class LiveSessionRuntimeFactory
|
|||
LiveSessionUiRuntime ui,
|
||||
LiveSessionInteractionRuntime interaction,
|
||||
LiveSessionWorldRuntime world,
|
||||
LiveSessionCommandSurface commands,
|
||||
Action<string> log)
|
||||
{
|
||||
_player = player ?? throw new ArgumentNullException(nameof(player));
|
||||
|
|
@ -117,18 +120,22 @@ internal sealed class LiveSessionRuntimeFactory
|
|||
_interaction = interaction
|
||||
?? throw new ArgumentNullException(nameof(interaction));
|
||||
_world = world ?? throw new ArgumentNullException(nameof(world));
|
||||
_commands = commands ?? throw new ArgumentNullException(nameof(commands));
|
||||
_log = log ?? throw new ArgumentNullException(nameof(log));
|
||||
}
|
||||
|
||||
public LiveSessionHost Create(LiveSessionController controller)
|
||||
public LiveSessionHost Create(
|
||||
LiveSessionController controller,
|
||||
LiveSessionConnectOptions connectOptions)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(controller);
|
||||
ArgumentNullException.ThrowIfNull(connectOptions);
|
||||
return new LiveSessionHost(controller, new LiveSessionHostBindings(
|
||||
Routing: new(
|
||||
CreateEventRouter,
|
||||
session => new LiveSessionCommandRouter(
|
||||
CreateCommandBindings(session))),
|
||||
Reset: CreateResetBindings(),
|
||||
session => _commands.Attach(new LiveSessionCommandRouter(
|
||||
CreateCommandBindings(session)))),
|
||||
Reset: LiveSessionResetManifest.Create(CreateResetBindings()).Execute,
|
||||
Selection: new(
|
||||
SetPlayerIdentity: id => _player.Identity.ServerGuid = id,
|
||||
SetVitalsIdentity: id => _ui.Vitals?.SetLocalPlayerGuid(id),
|
||||
|
|
@ -149,7 +156,8 @@ internal sealed class LiveSessionRuntimeFactory
|
|||
Connected: () =>
|
||||
_domain.Chat.OnSystemMessage(
|
||||
"connected — character list received",
|
||||
chatType: 1)));
|
||||
chatType: 1)),
|
||||
connectOptions);
|
||||
}
|
||||
|
||||
private LiveSessionResetBindings CreateResetBindings() => new()
|
||||
|
|
|
|||
|
|
@ -181,7 +181,7 @@ internal sealed class DevToolsCameraMenuOperations : IDevToolsCameraMenuOperatio
|
|||
/// <summary>Resolves the reconnect-safe command bus at draw time.</summary>
|
||||
internal sealed class DevToolsCommandBusSource : IDevToolsCommandBusSource
|
||||
{
|
||||
private LiveSessionController? _session;
|
||||
private ILiveUiSessionTarget? _session;
|
||||
private bool _deactivated;
|
||||
|
||||
public ICommandBus Current =>
|
||||
|
|
@ -189,7 +189,7 @@ internal sealed class DevToolsCommandBusSource : IDevToolsCommandBusSource
|
|||
? session.Commands
|
||||
: NullCommandBus.Instance;
|
||||
|
||||
public void Bind(LiveSessionController session)
|
||||
public void Bind(ILiveUiSessionTarget session)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(session);
|
||||
ObjectDisposedException.ThrowIf(_deactivated, this);
|
||||
|
|
@ -199,7 +199,7 @@ internal sealed class DevToolsCommandBusSource : IDevToolsCommandBusSource
|
|||
_session = session;
|
||||
}
|
||||
|
||||
public IDisposable BindOwned(LiveSessionController session)
|
||||
public IDisposable BindOwned(ILiveUiSessionTarget session)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(session);
|
||||
ObjectDisposedException.ThrowIf(_deactivated, this);
|
||||
|
|
@ -213,7 +213,7 @@ internal sealed class DevToolsCommandBusSource : IDevToolsCommandBusSource
|
|||
return new Binding(this, session);
|
||||
}
|
||||
|
||||
public void Unbind(LiveSessionController session)
|
||||
public void Unbind(ILiveUiSessionTarget session)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(session);
|
||||
if (ReferenceEquals(_session, session))
|
||||
|
|
@ -229,11 +229,11 @@ internal sealed class DevToolsCommandBusSource : IDevToolsCommandBusSource
|
|||
private sealed class Binding : IDisposable
|
||||
{
|
||||
private DevToolsCommandBusSource? _owner;
|
||||
private readonly LiveSessionController _expected;
|
||||
private readonly ILiveUiSessionTarget _expected;
|
||||
|
||||
public Binding(
|
||||
DevToolsCommandBusSource owner,
|
||||
LiveSessionController expected)
|
||||
ILiveUiSessionTarget expected)
|
||||
{
|
||||
_owner = owner;
|
||||
_expected = expected;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ using AcDream.App.Rendering.Wb;
|
|||
using AcDream.App.Settings;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Content;
|
||||
using AcDream.Runtime.Session;
|
||||
using DatReaderWriter;
|
||||
using Silk.NET.Input;
|
||||
using Silk.NET.Maths;
|
||||
|
|
@ -502,10 +503,11 @@ public sealed class GameWindow :
|
|||
// Phase 4.7: optional live connection to an ACE server. Enabled only when
|
||||
// ACDREAM_LIVE=1 is in the environment — fully backward compatible with
|
||||
// the offline rendering pipeline.
|
||||
// Slice 3: the controller is the sole App owner. Outbound feature
|
||||
// callbacks resolve this borrowed handle at call time and never cache it.
|
||||
private AcDream.App.Net.LiveSessionController? _liveSessionController;
|
||||
private AcDream.App.Net.LiveSessionHost? _liveSessionHost;
|
||||
// Runtime owns the canonical session generation and transport lifetime.
|
||||
// The window retains only the composition handles needed for startup and
|
||||
// shutdown; graphical feature callbacks borrow state through App adapters.
|
||||
private LiveSessionController? _liveSessionController;
|
||||
private LiveSessionHost? _liveSessionHost;
|
||||
private AcDream.Core.Net.WorldSession? LiveSession =>
|
||||
_liveSessionHost?.CurrentSession;
|
||||
private readonly AcDream.App.World.LiveWorldOriginState _liveWorldOrigin = new();
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ using AcDream.App.World;
|
|||
using AcDream.Content;
|
||||
using AcDream.Core.Audio;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Runtime.Session;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
using DatReaderWriter;
|
||||
using Silk.NET.Input;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ using AcDream.Core.Chat;
|
|||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Selection;
|
||||
using AcDream.Runtime;
|
||||
using AcDream.Runtime.Session;
|
||||
using AcDream.UI.Abstractions;
|
||||
|
||||
namespace AcDream.App.Runtime;
|
||||
|
||||
|
|
@ -28,9 +30,9 @@ internal sealed class CurrentGameRuntimeAdapter
|
|||
private bool _disposed;
|
||||
|
||||
public CurrentGameRuntimeAdapter(
|
||||
RuntimeOptions options,
|
||||
LiveSessionController session,
|
||||
LiveSessionHost sessionHost,
|
||||
ICommandBus commands,
|
||||
LocalPlayerIdentityState playerIdentity,
|
||||
LiveEntityRuntime entities,
|
||||
ClientObjectTable objects,
|
||||
|
|
@ -59,9 +61,9 @@ internal sealed class CurrentGameRuntimeAdapter
|
|||
chat,
|
||||
clock);
|
||||
_commands = new CurrentGameRuntimeCommandAdapter(
|
||||
options,
|
||||
session,
|
||||
sessionHost,
|
||||
commands,
|
||||
_view,
|
||||
selectionState,
|
||||
selection,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ using AcDream.App.Interaction;
|
|||
using AcDream.App.Net;
|
||||
using AcDream.Core.Selection;
|
||||
using AcDream.Runtime;
|
||||
using AcDream.Runtime.Session;
|
||||
using AcDream.UI.Abstractions;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
|
||||
|
|
@ -21,9 +22,9 @@ internal sealed class CurrentGameRuntimeCommandAdapter
|
|||
IRuntimeChatCommands,
|
||||
IRuntimePortalCommands
|
||||
{
|
||||
private readonly RuntimeOptions _options;
|
||||
private readonly LiveSessionController _session;
|
||||
private readonly LiveSessionHost _sessionHost;
|
||||
private readonly ICommandBus _commands;
|
||||
private readonly CurrentGameRuntimeViewAdapter _view;
|
||||
private readonly SelectionState _selectionState;
|
||||
private readonly SelectionInteractionController _selection;
|
||||
|
|
@ -32,9 +33,9 @@ internal sealed class CurrentGameRuntimeCommandAdapter
|
|||
private readonly ICurrentGameRuntimeEventSink _events;
|
||||
|
||||
public CurrentGameRuntimeCommandAdapter(
|
||||
RuntimeOptions options,
|
||||
LiveSessionController session,
|
||||
LiveSessionHost sessionHost,
|
||||
ICommandBus commands,
|
||||
CurrentGameRuntimeViewAdapter view,
|
||||
SelectionState selectionState,
|
||||
SelectionInteractionController selection,
|
||||
|
|
@ -42,9 +43,9 @@ internal sealed class CurrentGameRuntimeCommandAdapter
|
|||
ILiveCombatModeCommand combat,
|
||||
ICurrentGameRuntimeEventSink events)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_session = session ?? throw new ArgumentNullException(nameof(session));
|
||||
_sessionHost = sessionHost ?? throw new ArgumentNullException(nameof(sessionHost));
|
||||
_commands = commands ?? throw new ArgumentNullException(nameof(commands));
|
||||
_view = view ?? throw new ArgumentNullException(nameof(view));
|
||||
_selectionState = selectionState
|
||||
?? throw new ArgumentNullException(nameof(selectionState));
|
||||
|
|
@ -63,8 +64,8 @@ internal sealed class CurrentGameRuntimeCommandAdapter
|
|||
if (gate != RuntimeCommandStatus.Accepted)
|
||||
return RejectedStart(gate);
|
||||
|
||||
LiveSessionStartResult direct = _sessionHost.Start(_options);
|
||||
RuntimeSessionStartResult result = Convert(direct);
|
||||
RuntimeSessionStartResult result =
|
||||
_sessionHost.Start(expectedGeneration);
|
||||
_events.EmitCommand(
|
||||
RuntimeCommandDomain.Session,
|
||||
operation: 0,
|
||||
|
|
@ -83,8 +84,8 @@ internal sealed class CurrentGameRuntimeCommandAdapter
|
|||
if (gate != RuntimeCommandStatus.Accepted)
|
||||
return RejectedStart(gate);
|
||||
|
||||
LiveSessionStartResult direct = _sessionHost.Reconnect(_options);
|
||||
RuntimeSessionStartResult result = Convert(direct);
|
||||
RuntimeSessionStartResult result =
|
||||
_sessionHost.Reconnect(expectedGeneration);
|
||||
_events.EmitCommand(
|
||||
RuntimeCommandDomain.Session,
|
||||
operation: 1,
|
||||
|
|
@ -109,43 +110,15 @@ internal sealed class CurrentGameRuntimeCommandAdapter
|
|||
RuntimeTeardownStage.None);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_session.Stop();
|
||||
RuntimeGenerationToken current = _view.Generation;
|
||||
RuntimeTeardownStage completed =
|
||||
_session.CurrentSession is null && !_session.IsInWorld
|
||||
? RuntimeTeardownStage.Complete
|
||||
: RuntimeTeardownStage.CommandsInert
|
||||
| RuntimeTeardownStage.InboundDetached;
|
||||
var acknowledgement = new RuntimeTeardownAcknowledgement(
|
||||
expectedGeneration,
|
||||
current,
|
||||
RuntimeCommandStatus.Accepted,
|
||||
completed);
|
||||
_events.EmitCommand(
|
||||
RuntimeCommandDomain.Session,
|
||||
operation: 2,
|
||||
RuntimeCommandStatus.Accepted);
|
||||
_events.EmitLifecycle(previous);
|
||||
return acknowledgement;
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
var acknowledgement = new RuntimeTeardownAcknowledgement(
|
||||
expectedGeneration,
|
||||
_view.Generation,
|
||||
RuntimeCommandStatus.Rejected,
|
||||
RuntimeTeardownStage.None,
|
||||
error);
|
||||
_events.EmitCommand(
|
||||
RuntimeCommandDomain.Session,
|
||||
operation: 2,
|
||||
RuntimeCommandStatus.Rejected,
|
||||
text: error.GetType().Name);
|
||||
_events.EmitLifecycle(previous);
|
||||
return acknowledgement;
|
||||
}
|
||||
RuntimeTeardownAcknowledgement acknowledgement =
|
||||
_sessionHost.Stop(expectedGeneration);
|
||||
_events.EmitCommand(
|
||||
RuntimeCommandDomain.Session,
|
||||
operation: 2,
|
||||
acknowledgement.Status,
|
||||
text: acknowledgement.Error?.GetType().Name ?? string.Empty);
|
||||
_events.EmitLifecycle(previous);
|
||||
return acknowledgement;
|
||||
}
|
||||
|
||||
public RuntimeCommandResult Execute(
|
||||
|
|
@ -252,7 +225,7 @@ internal sealed class CurrentGameRuntimeCommandAdapter
|
|||
return Result(RuntimeCommandStatus.Unsupported);
|
||||
}
|
||||
|
||||
_session.Commands.Publish(new SendChatCmd(
|
||||
_commands.Publish(new SendChatCmd(
|
||||
channel,
|
||||
command.TargetName,
|
||||
command.Text));
|
||||
|
|
@ -293,7 +266,7 @@ internal sealed class CurrentGameRuntimeCommandAdapter
|
|||
return Result(RuntimeCommandStatus.Unsupported);
|
||||
}
|
||||
|
||||
_session.Commands.Publish(new ExecuteClientCommandCmd(
|
||||
_commands.Publish(new ExecuteClientCommandCmd(
|
||||
commandId.Value,
|
||||
string.Empty));
|
||||
_events.EmitCommand(
|
||||
|
|
@ -328,34 +301,6 @@ internal sealed class CurrentGameRuntimeCommandAdapter
|
|||
: RuntimeSessionStartStatus.Inactive,
|
||||
_view.Generation);
|
||||
|
||||
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,
|
||||
_ => throw new ArgumentOutOfRangeException(
|
||||
nameof(result),
|
||||
result.Status,
|
||||
"Unknown live-session start result."),
|
||||
};
|
||||
return new RuntimeSessionStartResult(
|
||||
status,
|
||||
_view.Generation,
|
||||
result.Selection?.CharacterId ?? 0u,
|
||||
result.Selection?.CharacterName ?? string.Empty,
|
||||
result.Error);
|
||||
}
|
||||
|
||||
private static RuntimeCommandStatus ToCommandStatus(
|
||||
RuntimeSessionStartStatus status) =>
|
||||
status switch
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ using AcDream.App.World;
|
|||
using AcDream.Core.Chat;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Runtime;
|
||||
using AcDream.Runtime.Session;
|
||||
|
||||
namespace AcDream.App.Runtime;
|
||||
|
||||
|
|
@ -58,8 +59,7 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView
|
|||
|
||||
internal bool IsActive => _active && !_session.IsDisposalComplete;
|
||||
|
||||
public RuntimeGenerationToken Generation =>
|
||||
new(_session.SessionGeneration);
|
||||
public RuntimeGenerationToken Generation => _session.Generation;
|
||||
|
||||
public RuntimeLifecycleSnapshot Lifecycle
|
||||
{
|
||||
|
|
|
|||
|
|
@ -290,9 +290,9 @@ internal interface ILocalPlayerTeleportSession
|
|||
|
||||
internal sealed class LocalPlayerTeleportSession : ILocalPlayerTeleportSession
|
||||
{
|
||||
private readonly LiveSessionController _session;
|
||||
private readonly ILiveWorldSessionSource _session;
|
||||
|
||||
public LocalPlayerTeleportSession(LiveSessionController session) =>
|
||||
public LocalPlayerTeleportSession(ILiveWorldSessionSource session) =>
|
||||
_session = session ?? throw new ArgumentNullException(nameof(session));
|
||||
|
||||
public void SendLoginComplete() =>
|
||||
|
|
|
|||
|
|
@ -19,11 +19,6 @@ internal interface ILiveObjectFramePhase
|
|||
void Tick(float deltaSeconds);
|
||||
}
|
||||
|
||||
internal interface ILiveSessionFramePhase
|
||||
{
|
||||
void Tick();
|
||||
}
|
||||
|
||||
internal interface IPostNetworkCommandFramePhase
|
||||
{
|
||||
void RunPostNetworkCommandPhase();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using AcDream.App.Update;
|
||||
using AcDream.Runtime.Session;
|
||||
using AcDream.App.Input;
|
||||
using AcDream.App.Net;
|
||||
using AcDream.App.Streaming;
|
||||
|
|
@ -21,7 +22,7 @@ internal sealed class RetailLiveFrameCoordinator : IRetailLiveFramePhase
|
|||
{
|
||||
private readonly ILiveObjectFramePhase _objects;
|
||||
private readonly GpuWorldState _worldState;
|
||||
private readonly ILiveSessionFramePhase _session;
|
||||
private readonly IRuntimeLiveSessionFramePhase _session;
|
||||
private readonly IPostNetworkCommandFramePhase _localPlayer;
|
||||
private readonly ILiveSpatialReconcilePhase _spatialReconciler;
|
||||
private readonly IWorldGenerationAvailability _availability;
|
||||
|
|
@ -30,7 +31,7 @@ internal sealed class RetailLiveFrameCoordinator : IRetailLiveFramePhase
|
|||
public RetailLiveFrameCoordinator(
|
||||
ILiveObjectFramePhase objects,
|
||||
GpuWorldState worldState,
|
||||
ILiveSessionFramePhase session,
|
||||
IRuntimeLiveSessionFramePhase session,
|
||||
IPostNetworkCommandFramePhase localPlayer,
|
||||
ILiveSpatialReconcilePhase spatialReconciler,
|
||||
IWorldGenerationAvailability? availability = null,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue