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:
Erik 2026-07-25 19:39:24 +02:00
parent ecc4816c5a
commit 7593078774
37 changed files with 884 additions and 355 deletions

View file

@ -116,7 +116,7 @@ public sealed class SessionPlayerCompositionTests
"new PlayerModeController(",
"d.PortalTunnelFallback.Transfer(",
"d.TeleportSink.BindOwned(localTeleport)",
"sessionRuntimeFactory.Create(liveSession)",
"LiveSessionHost sessionHost = sessionRuntimeFactory.Create(",
"d.CombatModeCommands.BindOwned(combatCommand)",
"d.RuntimeDiagnosticCommands.BindOwned(runtimeDiagnostics)",
"GameplayInputActionRouter.Create(",

View file

@ -1,4 +1,5 @@
using AcDream.App.Update;
using AcDream.Runtime.Session;
namespace AcDream.App.Tests;
@ -9,7 +10,7 @@ internal sealed class TestLiveObjectFramePhase(Action<float> tick)
}
internal sealed class TestLiveSessionFramePhase(Action tick)
: ILiveSessionFramePhase
: IRuntimeLiveSessionFramePhase
{
public void Tick() => tick();
}

View file

@ -2,6 +2,8 @@ using System.Reflection;
using AcDream.App.Rendering;
using AcDream.App.Net;
using AcDream.Core.Net;
using AcDream.Runtime;
using AcDream.Runtime.Session;
namespace AcDream.App.Tests.Net;
@ -30,6 +32,28 @@ public sealed class GameWindowLiveSessionOwnershipTests
Assert.DoesNotContain(fields, field => field.Name == "_liveSessionCommands");
}
[Fact]
public void GraphicalSessionSourceBorrowsCanonicalRuntimeState()
{
FieldInfo[] fields = typeof(LiveSessionAppSource).GetFields(PrivateInstance);
Assert.Equal(2, fields.Length);
Assert.Contains(
fields,
field => field.Name == "_session"
&& field.FieldType == typeof(LiveSessionController));
Assert.Contains(
fields,
field => field.Name == "_commands"
&& field.FieldType == typeof(LiveSessionCommandSurface));
Assert.DoesNotContain(fields, field => field.FieldType == typeof(WorldSession));
Assert.DoesNotContain(fields, field => field.FieldType == typeof(bool));
Assert.DoesNotContain(
fields,
field => field.FieldType == typeof(RuntimeGenerationToken)
|| field.FieldType == typeof(ulong));
}
[Theory]
[InlineData("TryStartLiveSession")]
[InlineData("ClearInboundEntityState")]

View file

@ -27,6 +27,31 @@ public sealed class LiveSessionCommandRouterTests
Assert.False(router.IsActive);
}
[Fact]
public void CommandSurfacePublishesToOneBorrowedRouteAndRetiresStaleRoute()
{
var sent = new List<string>();
var surface = new LiveSessionCommandSurface();
var first = NewRouter(sendTalk: text => sent.Add($"first:{text}"));
var firstLease = surface.Attach(first);
firstLease.Activate();
surface.Publish(new SendServerCommandCmd("@one"));
var second = NewRouter(sendTalk: text => sent.Add($"second:{text}"));
Assert.Throws<InvalidOperationException>(() => surface.Attach(second));
firstLease.Dispose();
first.Publish(new SendServerCommandCmd("@stale"));
var secondLease = surface.Attach(second);
secondLease.Activate();
surface.Publish(new SendServerCommandCmd("@two"));
secondLease.Dispose();
Assert.Equal(["first:@one", "second:@two"], sent);
Assert.False(first.IsActive);
Assert.False(second.IsActive);
}
[Fact]
public void TellAndLegacyChannel_PreserveOutboundAndEchoPolicy()
{

View file

@ -0,0 +1,124 @@
using System.Net;
using AcDream.App.Rendering;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Runtime.Session;
namespace AcDream.App.Tests.Net;
public sealed class LiveSessionShutdownIntegrationTests
{
[Fact]
public void ReentrantShutdownRetainsDependentsUntilRuntimeDisposeCompletes()
{
var operations = new TestOperations();
var host = new TestHost();
var controller = new LiveSessionController(operations);
controller.Start(
new LiveSessionConnectOptions(
true,
"127.0.0.1",
9000,
"user",
"password"),
host);
bool dependentDisposed = false;
var shutdown = new ResourceShutdownTransaction(
new ResourceShutdownStage("session lifetime",
[
new("live session", () =>
{
controller.Dispose();
if (!controller.IsDisposalComplete)
{
throw new InvalidOperationException(
"live-session disposal is still deferred");
}
}),
]),
new ResourceShutdownStage("session dependents",
[
new("streamer", () => dependentDisposed = true),
]));
operations.OnTick = () =>
{
Assert.Throws<AggregateException>(shutdown.CompleteOrThrow);
Assert.Equal(0, shutdown.CurrentStage);
Assert.False(dependentDisposed);
};
controller.Tick();
Assert.True(controller.IsDisposalComplete);
Assert.False(dependentDisposed);
shutdown.CompleteOrThrow();
Assert.True(shutdown.IsComplete);
Assert.True(dependentDisposed);
}
private sealed class TestOperations : ILiveSessionOperations
{
public Action? OnTick { get; set; }
public IPEndPoint ResolveEndpoint(string host, int port) =>
new(IPAddress.Loopback, port);
public WorldSession CreateSession(IPEndPoint endpoint) =>
new(endpoint, new TestTransport());
public void Connect(WorldSession session, string user, string password) { }
public CharacterList.Parsed GetCharacters(WorldSession session) =>
new(
0u,
[new CharacterList.Character(0x50000001u, "Ready", 0u)],
[],
11,
"Runtime",
true,
true);
public void EnterWorld(WorldSession session, int activeCharacterIndex) { }
public void Tick(WorldSession session) => OnTick?.Invoke();
public void DisposeSession(WorldSession session) => session.Dispose();
}
private sealed class TestHost : ILiveSessionLifecycleHost
{
public LiveSessionBinding BindSession(WorldSession session) =>
new(
session,
activateCommands: static () => { },
deactivateCommands: static () => { },
detachEvents: static () => { });
public void ResetSessionState() { }
public void ReportConnecting(string host, int port, string user) { }
public void ReportConnected() { }
public void ApplySelectedCharacter(LiveSessionCharacterSelection selection) { }
public void ApplyEnteredWorld(LiveSessionCharacterSelection selection) { }
public void DetachSession(WorldSession session) { }
}
private sealed class TestTransport : IWorldSessionTransport
{
public void Send(ReadOnlySpan<byte> datagram) { }
public void Send(IPEndPoint remote, ReadOnlySpan<byte> datagram) { }
public int Receive(
Span<byte> destination,
TimeSpan timeout,
out IPEndPoint? from)
{
from = null;
return -1;
}
public ValueTask<NetReceiveResult> ReceiveAsync(
Memory<byte> destination,
CancellationToken cancellationToken) =>
throw new OperationCanceledException(cancellationToken);
public void Dispose() { }
}
}

View file

@ -13,8 +13,8 @@ public sealed class DevToolsFramePresenterTests
public void CommandBusOwnedBindingReleasesExactlyAndAllowsRebind()
{
var source = new DevToolsCommandBusSource();
using var first = new LiveSessionController();
using var second = new LiveSessionController();
var first = new TestUiSessionTarget();
var second = new TestUiSessionTarget();
IDisposable firstBinding = source.BindOwned(first);
Assert.Same(first.Commands, source.Current);
@ -27,6 +27,13 @@ public sealed class DevToolsFramePresenterTests
Assert.Same(second.Commands, source.Current);
}
private sealed class TestUiSessionTarget : ILiveUiSessionTarget
{
public bool IsInWorld => false;
public AcDream.Core.Net.WorldSession? CurrentSession => null;
public ICommandBus Commands { get; } = new RecordingCommandBus();
}
[Fact]
public void Frame_PreservesBeginThenMenuPanelsAndDrawDataOrder()
{

View file

@ -89,7 +89,7 @@ public sealed class GameWindowSlice8BoundaryTests
"SessionPlayerComposition.cs"));
AssertAppearsInOrder(
sessionPhase,
"sessionRuntimeFactory.Create(liveSession)",
"LiveSessionHost sessionHost = sessionRuntimeFactory.Create(",
"d.CombatModeCommands.BindOwned(combatCommand)",
"d.RuntimeDiagnosticCommands.BindOwned(runtimeDiagnostics)",
"GameplayInputActionRouter.Create(",

View file

@ -17,6 +17,7 @@ using AcDream.Core.Physics;
using AcDream.Core.Selection;
using AcDream.Core.World;
using AcDream.Runtime;
using AcDream.Runtime.Session;
using AcDream.UI.Abstractions;
using AcDream.UI.Abstractions.Input;
@ -227,9 +228,9 @@ public sealed class CurrentGameRuntimeAdapterTests
_session = new LiveSessionController(new SessionOperations());
Host = CreateHost(_session, Commands, Identity);
Runtime = new CurrentGameRuntimeAdapter(
Options,
_session,
Host,
Commands,
Identity,
Entities,
Objects,
@ -313,7 +314,7 @@ public sealed class CurrentGameRuntimeAdapterTests
new LiveSessionRoutingFactories(
_ => new NoopEventRouting(),
_ => commands),
reset,
LiveSessionResetManifest.Create(reset).Execute,
new LiveSessionSelectionBindings(
id => identity.ServerGuid = id,
_ => { },
@ -328,7 +329,13 @@ public sealed class CurrentGameRuntimeAdapterTests
_ => { },
() => { }),
(_, _, _) => { },
() => { }));
() => { }),
new LiveSessionConnectOptions(
true,
"127.0.0.1",
9000,
"user",
"password"));
}
private static RuntimeOptions LiveOptions()
@ -493,7 +500,8 @@ public sealed class CurrentGameRuntimeAdapterTests
}
internal sealed class RecordingCommandRouting
: ILiveSessionCommandRouting
: ILiveSessionCommandRouting,
ICommandBus
{
private bool _active;

View file

@ -1,11 +1,10 @@
using System.Net;
using AcDream.App.Net;
using AcDream.App.Rendering;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.UI.Abstractions;
using AcDream.Runtime;
using AcDream.Runtime.Session;
namespace AcDream.App.Tests.Net;
namespace AcDream.Runtime.Tests.Session;
public sealed class LiveSessionControllerTests
{
@ -32,7 +31,7 @@ public sealed class LiveSessionControllerTests
public void Dispose() { }
}
private sealed class TestCommandBus : ICommandBus
private sealed class TestCommandBus
{
public bool Active { get; set; }
public int PublishCount { get; private set; }
@ -169,7 +168,6 @@ public sealed class LiveSessionControllerTests
CommandBuses.Add(commands);
return new LiveSessionBinding(
BindingSessionOverride ?? session,
commands,
activateCommands: () =>
{
calls.Add("activate");
@ -298,7 +296,6 @@ public sealed class LiveSessionControllerTests
result.Selection);
Assert.True(controller.IsInWorld);
Assert.Same(operations.Sessions[0], controller.CurrentSession);
Assert.Same(host.CommandBuses[0], controller.Commands);
Assert.True(host.CommandBuses[0].Active);
}
@ -319,7 +316,6 @@ public sealed class LiveSessionControllerTests
Assert.Equal(2, host.ResetCount);
Assert.Empty(operations.Sessions);
Assert.Equal(NullCommandBus.Instance, controller.Commands);
}
[Fact]
@ -573,7 +569,6 @@ public sealed class LiveSessionControllerTests
Assert.Equal(LiveSessionStartStatus.Deferred, result.Status);
Assert.False(controller.IsInWorld);
Assert.Null(controller.CurrentSession);
Assert.Same(NullCommandBus.Instance, controller.Commands);
if (operations.Sessions.Count == 0)
{
Assert.Contains(
@ -695,7 +690,6 @@ public sealed class LiveSessionControllerTests
Assert.False(controller.IsInWorld);
Assert.Null(controller.CurrentSession);
Assert.Equal(1, operations.DisposeCounts[operations.Sessions[0]]);
Assert.Same(NullCommandBus.Instance, controller.Commands);
controller.Dispose();
}
@ -843,69 +837,72 @@ public sealed class LiveSessionControllerTests
Assert.Equal(1, operations.DisposeCounts[session]);
Assert.Equal(0, commands.PublishCount);
Assert.False(controller.IsInWorld);
Assert.Same(NullCommandBus.Instance, controller.Commands);
Assert.Throws<ObjectDisposedException>(() => controller.Start(LiveOptions(), host));
}
[Fact]
public void ReentrantShutdownRetainsControllerAndDependentsUntilDeferredDisposeCompletes()
public void GenerationScopedStopAcknowledgesTheCompleteTeardownTransaction()
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var host = new TestHost(calls);
var controller = new LiveSessionController(operations);
controller.Start(LiveOptions(), host);
bool dependentDisposed = false;
var shutdown = new ResourceShutdownTransaction(
new ResourceShutdownStage("session lifetime",
[
new("live session", () =>
{
controller.Dispose();
if (!controller.IsDisposalComplete)
{
throw new InvalidOperationException(
"live-session disposal is still deferred");
}
}),
]),
new ResourceShutdownStage("session dependents",
[
new("streamer", () => dependentDisposed = true),
]));
operations.OnTick = () =>
{
Assert.Throws<AggregateException>(shutdown.CompleteOrThrow);
Assert.Equal(0, shutdown.CurrentStage);
Assert.False(dependentDisposed);
};
RuntimeGenerationToken retired = controller.Generation;
controller.Tick();
RuntimeTeardownAcknowledgement acknowledgement =
controller.Stop(retired);
Assert.True(controller.IsDisposalComplete);
Assert.False(dependentDisposed);
shutdown.CompleteOrThrow();
Assert.True(shutdown.IsComplete);
Assert.True(dependentDisposed);
Assert.True(acknowledgement.IsComplete);
Assert.Equal(retired, acknowledgement.RetiredGeneration);
Assert.Equal(controller.Generation, acknowledgement.CurrentGeneration);
Assert.Equal(
RuntimeTeardownStage.Complete,
acknowledgement.CompletedStages);
}
private static RuntimeOptions LiveOptions(
bool live = true,
string? user = "user")
[Fact]
public void FailedStopAcknowledgesOnlyCompletedPrefixAndRetryDrainsSuffix()
{
var environment = new Dictionary<string, string?>
var calls = new List<string>();
var operations = new TestOperations(calls);
var host = new TestHost(calls)
{
["ACDREAM_LIVE"] = live ? "1" : "0",
["ACDREAM_TEST_HOST"] = "127.0.0.1",
["ACDREAM_TEST_PORT"] = "9000",
["ACDREAM_TEST_USER"] = user,
["ACDREAM_TEST_PASS"] = "password",
FailEventDetachOnce = true,
};
return RuntimeOptions.Parse(
"dat",
name => environment.GetValueOrDefault(name));
var controller = new LiveSessionController(operations);
controller.Start(LiveOptions(), host);
RuntimeTeardownAcknowledgement failed =
controller.Stop(controller.Generation);
Assert.Equal(RuntimeCommandStatus.Rejected, failed.Status);
Assert.True(
(failed.CompletedStages & RuntimeTeardownStage.CommandsInert) != 0);
Assert.True(
(failed.CompletedStages & RuntimeTeardownStage.InboundDetached) == 0);
Assert.True(
(failed.CompletedStages & RuntimeTeardownStage.TransportDisposed) == 0);
RuntimeTeardownAcknowledgement retry =
controller.Stop(failed.CurrentGeneration);
Assert.True(retry.IsComplete);
Assert.Equal(1, host.DeactivateCount);
Assert.Equal(2, host.EventDetachCount);
Assert.Single(operations.DisposeCounts);
}
private static LiveSessionConnectOptions LiveOptions(
bool live = true,
string? user = "user") =>
new(
live,
"127.0.0.1",
9000,
user ?? string.Empty,
"password");
private static CharacterList.Parsed AvailableCharacters() => new(
0u,
[

View file

@ -1,6 +1,5 @@
using System.Net;
using System.Reflection;
using AcDream.App.Net;
using AcDream.Core.Chat;
using AcDream.Core.Combat;
using AcDream.Core.Items;
@ -8,8 +7,9 @@ using AcDream.Core.Net;
using AcDream.Core.Player;
using AcDream.Core.Social;
using AcDream.Core.Spells;
using AcDream.Runtime.Session;
namespace AcDream.App.Tests.Net;
namespace AcDream.Runtime.Tests.Session;
public sealed class LiveSessionEventRouterTests
{

View file

@ -1,10 +1,9 @@
using System.Net;
using AcDream.App.Net;
using AcDream.App.Rendering;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Runtime.Session;
namespace AcDream.App.Tests.Net;
namespace AcDream.Runtime.Tests.Session;
public sealed class LiveSessionHostTests
{
@ -45,7 +44,6 @@ public sealed class LiveSessionHostTests
],
calls);
Assert.Same(controller.CurrentSession, host.CurrentSession);
Assert.Same(commands, host.Commands);
Assert.True(host.IsInWorld);
controller.Dispose();
@ -223,7 +221,7 @@ public sealed class LiveSessionHostTests
Func<WorldSession, ILiveSessionCommandRouting> createCommands) =>
new(controller, new LiveSessionHostBindings(
Routing: new(createEvents, createCommands),
Reset: ResetBindings(() => calls.Add("reset")),
Reset: () => calls.Add("reset"),
Selection: new(
id => calls.Add($"player:{id}"),
id => calls.Add($"vitals:{id}"),
@ -240,57 +238,15 @@ public sealed class LiveSessionHostTests
Connecting: (_, _, _) => calls.Add("connecting"),
Connected: () => calls.Add("connected")));
private static LiveSessionResetBindings ResetBindings(Action reset)
{
Action noop = static () => { };
return new LiveSessionResetBindings
{
MouseCapture = reset,
PlayerPresentation = noop,
TeleportTransit = noop,
SessionDialogs = noop,
ChatCommandTargets = noop,
SettingsCharacterContext = noop,
EquippedChildren = noop,
ExternalContainer = noop,
InteractionAndSelection = noop,
SelectionPresentation = noop,
ObjectTable = noop,
Spellbook = noop,
MagicRuntime = noop,
CombatAttack = noop,
CombatState = noop,
ItemMana = noop,
LocalPlayer = noop,
Friends = noop,
Squelch = noop,
TurbineChat = noop,
ParticleVisibility = noop,
InboundEventFifo = noop,
LiveLiveness = noop,
LiveRuntime = noop,
RenderSceneProjection = noop,
SessionIdentity = noop,
RemoteTeleport = noop,
NetworkEffects = noop,
AnimationHookFrames = noop,
LivePresentation = noop,
RemoteMovementDiagnostics = noop,
};
}
private static RuntimeOptions LiveOptions(bool live = true, string? user = "user")
{
var environment = new Dictionary<string, string?>
{
["ACDREAM_LIVE"] = live ? "1" : "0",
["ACDREAM_TEST_HOST"] = "127.0.0.1",
["ACDREAM_TEST_PORT"] = "9000",
["ACDREAM_TEST_USER"] = user,
["ACDREAM_TEST_PASS"] = "password",
};
return RuntimeOptions.Parse("dat", environment.GetValueOrDefault);
}
private static LiveSessionConnectOptions LiveOptions(
bool live = true,
string? user = "user") =>
new(
live,
"127.0.0.1",
9000,
user ?? string.Empty,
"password");
private sealed class TestEventRouting(List<string> calls)
: ILiveSessionEventRouting

View file

@ -1,9 +1,8 @@
using System.Net;
using AcDream.App.Net;
using AcDream.Core.Net;
using AcDream.UI.Abstractions;
using AcDream.Runtime.Session;
namespace AcDream.App.Tests.Net;
namespace AcDream.Runtime.Tests.Session;
public sealed class LiveSessionLifecycleHostTests
{
@ -82,7 +81,6 @@ public sealed class LiveSessionLifecycleHostTests
calls.Add("bind");
return new LiveSessionBinding(
session,
NullCommandBus.Instance,
activateCommands: () => calls.Add("activate"),
deactivateCommands: () => calls.Add("deactivate"),
detachEvents: () => calls.Add("detach-events"));

View file

@ -1,6 +1,6 @@
using AcDream.App.Net;
using AcDream.Runtime.Session;
namespace AcDream.App.Tests.Net;
namespace AcDream.Runtime.Tests.Session;
public sealed class LiveSessionSubscriptionSetTests
{

View file

@ -0,0 +1,165 @@
using System.Net;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Runtime.Session;
namespace AcDream.Runtime.Tests.Session;
public sealed class RuntimeLiveSessionNoWindowTests
{
[Fact]
public void RuntimeOnlyHostEntersAndTearsDownWithoutPresentationAssemblies()
{
var calls = new List<string>();
var operations = new TestOperations(calls);
using var controller = new LiveSessionController(operations);
var host = new LiveSessionHost(
controller,
new LiveSessionHostBindings(
new LiveSessionRoutingFactories(
_ => new EventRoute(calls),
_ => new CommandRoute(calls)),
() => calls.Add("reset"),
new LiveSessionSelectionBindings(
id => calls.Add($"player:{id}"),
_ => { },
_ => { },
_ => { },
_ => { },
() => { }),
new LiveSessionEnteredWorldBindings(
name => calls.Add($"entered:{name}"),
() => { },
() => { },
_ => { },
() => { }),
(_, _, _) => calls.Add("connecting"),
() => calls.Add("connected")),
new LiveSessionConnectOptions(
true,
"127.0.0.1",
9000,
"test",
"password"));
RuntimeSessionStartResult start =
host.Start(RuntimeGenerationToken.Initial);
RuntimeTeardownAcknowledgement stop = host.Stop(start.Generation);
Assert.Equal(RuntimeSessionStartStatus.Connected, start.Status);
Assert.Equal(0x50000001u, start.CharacterId);
Assert.True(stop.IsComplete);
Assert.Null(controller.CurrentSession);
Assert.False(controller.IsInWorld);
Assert.Equal(
[
"reset",
"resolve",
"create",
"attach-events",
"connecting",
"connect",
"connected",
"characters",
"player:1342177281",
"enter:0",
"activate-commands",
"entered:Runtime",
"deactivate-commands",
"detach-events",
"dispose-session",
"reset",
],
calls);
string[] loaded = AppDomain.CurrentDomain.GetAssemblies()
.Select(static assembly => assembly.GetName().Name ?? string.Empty)
.ToArray();
Assert.DoesNotContain(loaded, static name =>
name.StartsWith("AcDream.App", StringComparison.OrdinalIgnoreCase)
|| name.StartsWith("AcDream.UI.", StringComparison.OrdinalIgnoreCase)
|| name.StartsWith("Silk.NET", StringComparison.OrdinalIgnoreCase)
|| name.StartsWith("OpenAL", StringComparison.OrdinalIgnoreCase)
|| name.StartsWith("Arch", StringComparison.OrdinalIgnoreCase)
|| name.StartsWith("ImGui", StringComparison.OrdinalIgnoreCase));
}
private sealed class TestOperations(List<string> calls)
: ILiveSessionOperations
{
public IPEndPoint ResolveEndpoint(string host, int port)
{
calls.Add("resolve");
return new IPEndPoint(IPAddress.Loopback, port);
}
public WorldSession CreateSession(IPEndPoint endpoint)
{
calls.Add("create");
return new WorldSession(endpoint, new TestTransport());
}
public void Connect(WorldSession session, string user, string password) =>
calls.Add("connect");
public CharacterList.Parsed GetCharacters(WorldSession session)
{
calls.Add("characters");
return new CharacterList.Parsed(
0u,
[new CharacterList.Character(0x50000001u, "Runtime", 0u)],
[],
11,
"NoWindow",
true,
true);
}
public void EnterWorld(WorldSession session, int activeCharacterIndex) =>
calls.Add($"enter:{activeCharacterIndex}");
public void Tick(WorldSession session) { }
public void DisposeSession(WorldSession session)
{
calls.Add("dispose-session");
session.Dispose();
}
}
private sealed class EventRoute(List<string> calls)
: ILiveSessionEventRouting
{
public void Attach() => calls.Add("attach-events");
public void Dispose() => calls.Add("detach-events");
}
private sealed class CommandRoute(List<string> calls)
: ILiveSessionCommandRouting
{
public void Activate() => calls.Add("activate-commands");
public void Dispose() => calls.Add("deactivate-commands");
}
private sealed class TestTransport : IWorldSessionTransport
{
public void Send(ReadOnlySpan<byte> datagram) { }
public void Send(IPEndPoint remote, ReadOnlySpan<byte> datagram) { }
public int Receive(
Span<byte> destination,
TimeSpan timeout,
out IPEndPoint? from)
{
from = null;
return -1;
}
public ValueTask<NetReceiveResult> ReceiveAsync(
Memory<byte> destination,
CancellationToken cancellationToken) =>
throw new OperationCanceledException(cancellationToken);
public void Dispose() { }
}
}