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

@ -0,0 +1,116 @@
using System.Net;
using AcDream.Core.Net;
using AcDream.Runtime.Session;
namespace AcDream.Runtime.Tests.Session;
public sealed class LiveSessionLifecycleHostTests
{
[Fact]
public void HostRoutesLifecycleAndReleasesOnlyTheExactBoundSession()
{
var calls = new List<string>();
using var sessionA = CreateSession(9000);
using var sessionB = CreateSession(9001);
var host = CreateHost(calls);
LiveSessionBinding binding = host.BindSession(sessionA);
host.ResetSessionState();
host.ReportConnecting("host", 9000, "user");
host.ReportConnected();
var selection = new LiveSessionCharacterSelection(2, 3u, "toon", "account");
host.ApplySelectedCharacter(selection);
binding.ActivateCommands();
host.ApplyEnteredWorld(selection);
Assert.Throws<InvalidOperationException>(() => host.DetachSession(sessionB));
binding.Dispose();
host.DetachSession(sessionA);
LiveSessionBinding replacement = host.BindSession(sessionB);
Assert.Equal(
[
"bind", "reset", "connecting:host:9000:user",
"connected", "selected:toon", "activate", "entered:toon",
"deactivate", "detach-events", "bind",
],
calls);
replacement.Dispose();
host.DetachSession(sessionB);
}
[Fact]
public void FailedBindingFactoryDoesNotClaimTheHost()
{
var calls = new List<string>();
using var session = CreateSession(9000);
bool fail = true;
LiveSessionLifecycleHost host = CreateHost(calls, _ =>
{
if (fail)
{
fail = false;
throw new InvalidOperationException("bind failure");
}
return CreateBinding(session, calls);
});
Assert.Throws<InvalidOperationException>(() => host.BindSession(session));
LiveSessionBinding retry = host.BindSession(session);
retry.Dispose();
host.DetachSession(session);
}
private static LiveSessionLifecycleHost CreateHost(
List<string> calls,
Func<WorldSession, LiveSessionBinding>? bind = null) =>
new(new LiveSessionLifecycleBindings(
Bind: bind ?? (session => CreateBinding(session, calls)),
Reset: () => calls.Add("reset"),
Connecting: (host, port, user) =>
calls.Add($"connecting:{host}:{port}:{user}"),
Connected: () => calls.Add("connected"),
Selected: selection => calls.Add($"selected:{selection.CharacterName}"),
Entered: selection => calls.Add($"entered:{selection.CharacterName}")));
private static LiveSessionBinding CreateBinding(
WorldSession session,
List<string> calls)
{
calls.Add("bind");
return new LiveSessionBinding(
session,
activateCommands: () => calls.Add("activate"),
deactivateCommands: () => calls.Add("deactivate"),
detachEvents: () => calls.Add("detach-events"));
}
private static WorldSession CreateSession(int port) =>
new(
new IPEndPoint(IPAddress.Loopback, port),
new TestTransport());
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() { }
}
}