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
|
|
@ -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(",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,920 +0,0 @@
|
|||
using System.Net;
|
||||
using AcDream.App.Net;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.UI.Abstractions;
|
||||
|
||||
namespace AcDream.App.Tests.Net;
|
||||
|
||||
public sealed class LiveSessionControllerTests
|
||||
{
|
||||
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() { }
|
||||
}
|
||||
|
||||
private sealed class TestCommandBus : ICommandBus
|
||||
{
|
||||
public bool Active { get; set; }
|
||||
public int PublishCount { get; private set; }
|
||||
|
||||
public void Publish<T>(T command) where T : notnull
|
||||
{
|
||||
if (Active)
|
||||
PublishCount++;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestOperations(List<string> calls) : ILiveSessionOperations
|
||||
{
|
||||
public CharacterList.Parsed? Characters { get; set; } = AvailableCharacters();
|
||||
public Action? OnResolve { get; set; }
|
||||
public Action? OnCreate { get; set; }
|
||||
public Action? OnConnect { get; set; }
|
||||
public Action? OnCharacters { get; set; }
|
||||
public Action? OnEnterWorld { get; set; }
|
||||
public Action? OnTick { get; set; }
|
||||
public Action? OnDispose { get; set; }
|
||||
public bool ThrowOnConnect { get; set; }
|
||||
public bool ThrowOnCreate { get; set; }
|
||||
public bool ThrowOnCharacters { get; set; }
|
||||
public bool ThrowOnEnterWorld { get; set; }
|
||||
public bool ThrowOnTick { get; set; }
|
||||
public bool FailDisposeOnce { get; set; }
|
||||
public List<WorldSession> Sessions { get; } = [];
|
||||
public Dictionary<WorldSession, int> DisposeCounts { get; } = [];
|
||||
public int EnterWorldCount { get; private set; }
|
||||
public int TickCount { get; private set; }
|
||||
|
||||
public IPEndPoint ResolveEndpoint(string host, int port)
|
||||
{
|
||||
calls.Add("resolve");
|
||||
OnResolve?.Invoke();
|
||||
return new IPEndPoint(IPAddress.Loopback, port);
|
||||
}
|
||||
|
||||
public WorldSession CreateSession(IPEndPoint endpoint)
|
||||
{
|
||||
calls.Add("create");
|
||||
OnCreate?.Invoke();
|
||||
if (ThrowOnCreate)
|
||||
throw new InvalidOperationException("create failure");
|
||||
var session = new WorldSession(endpoint, new TestTransport());
|
||||
Sessions.Add(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
public void Connect(WorldSession session, string user, string password)
|
||||
{
|
||||
calls.Add("connect");
|
||||
OnConnect?.Invoke();
|
||||
if (ThrowOnConnect)
|
||||
throw new InvalidOperationException("connect failure");
|
||||
}
|
||||
|
||||
public CharacterList.Parsed? GetCharacters(WorldSession session)
|
||||
{
|
||||
OnCharacters?.Invoke();
|
||||
if (ThrowOnCharacters)
|
||||
throw new InvalidOperationException("characters failure");
|
||||
return Characters;
|
||||
}
|
||||
|
||||
public void EnterWorld(WorldSession session, int activeCharacterIndex)
|
||||
{
|
||||
calls.Add($"enter:{activeCharacterIndex}");
|
||||
EnterWorldCount++;
|
||||
OnEnterWorld?.Invoke();
|
||||
if (ThrowOnEnterWorld)
|
||||
throw new InvalidOperationException("enter failure");
|
||||
}
|
||||
|
||||
public void Tick(WorldSession session)
|
||||
{
|
||||
calls.Add("tick");
|
||||
TickCount++;
|
||||
OnTick?.Invoke();
|
||||
if (ThrowOnTick)
|
||||
throw new InvalidOperationException("tick failure");
|
||||
}
|
||||
|
||||
public void DisposeSession(WorldSession session)
|
||||
{
|
||||
calls.Add("dispose-session");
|
||||
OnDispose?.Invoke();
|
||||
if (FailDisposeOnce)
|
||||
{
|
||||
FailDisposeOnce = false;
|
||||
throw new InvalidOperationException("dispose failure");
|
||||
}
|
||||
DisposeCounts[session] = DisposeCounts.GetValueOrDefault(session) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestHost(List<string> calls) : ILiveSessionLifecycleHost
|
||||
{
|
||||
public Action? OnBind { get; set; }
|
||||
public Action? OnReset { get; set; }
|
||||
public Action? OnConnecting { get; set; }
|
||||
public Action? OnConnected { get; set; }
|
||||
public Action? OnSelected { get; set; }
|
||||
public Action? OnActivate { get; set; }
|
||||
public Action? OnEntered { get; set; }
|
||||
public Action? OnDetach { get; set; }
|
||||
public bool FailResetOnce { get; set; }
|
||||
public bool FailDetachOnce { get; set; }
|
||||
public bool FailDeactivateOnce { get; set; }
|
||||
public bool FailEventDetachOnce { get; set; }
|
||||
public bool ThrowOnBind { get; set; }
|
||||
public bool ThrowOnConnecting { get; set; }
|
||||
public bool ThrowOnConnected { get; set; }
|
||||
public bool ThrowOnSelected { get; set; }
|
||||
public bool ThrowOnActivate { get; set; }
|
||||
public bool ThrowOnEntered { get; set; }
|
||||
public WorldSession? BindingSessionOverride { get; set; }
|
||||
public int ResetCount { get; private set; }
|
||||
public int DetachCount { get; private set; }
|
||||
public int DeactivateCount { get; private set; }
|
||||
public int EventDetachCount { get; private set; }
|
||||
public int ActivateCount { get; private set; }
|
||||
public List<TestCommandBus> CommandBuses { get; } = [];
|
||||
public List<LiveSessionCharacterSelection> Selections { get; } = [];
|
||||
|
||||
public LiveSessionBinding BindSession(WorldSession session)
|
||||
{
|
||||
calls.Add("bind");
|
||||
OnBind?.Invoke();
|
||||
if (ThrowOnBind)
|
||||
throw new InvalidOperationException("bind failure");
|
||||
var commands = new TestCommandBus();
|
||||
CommandBuses.Add(commands);
|
||||
return new LiveSessionBinding(
|
||||
BindingSessionOverride ?? session,
|
||||
commands,
|
||||
activateCommands: () =>
|
||||
{
|
||||
calls.Add("activate");
|
||||
ActivateCount++;
|
||||
commands.Active = true;
|
||||
OnActivate?.Invoke();
|
||||
if (ThrowOnActivate)
|
||||
throw new InvalidOperationException("activate failure");
|
||||
},
|
||||
deactivateCommands: () =>
|
||||
{
|
||||
calls.Add("deactivate");
|
||||
DeactivateCount++;
|
||||
commands.Active = false;
|
||||
if (FailDeactivateOnce)
|
||||
{
|
||||
FailDeactivateOnce = false;
|
||||
throw new InvalidOperationException("deactivate failure");
|
||||
}
|
||||
},
|
||||
detachEvents: () =>
|
||||
{
|
||||
calls.Add("detach-events");
|
||||
EventDetachCount++;
|
||||
if (FailEventDetachOnce)
|
||||
{
|
||||
FailEventDetachOnce = false;
|
||||
throw new InvalidOperationException("event detach failure");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void ResetSessionState()
|
||||
{
|
||||
calls.Add("reset");
|
||||
ResetCount++;
|
||||
OnReset?.Invoke();
|
||||
if (FailResetOnce)
|
||||
{
|
||||
FailResetOnce = false;
|
||||
throw new InvalidOperationException("reset failure");
|
||||
}
|
||||
}
|
||||
|
||||
public void ReportConnecting(string host, int port, string user)
|
||||
{
|
||||
calls.Add("report-connecting");
|
||||
OnConnecting?.Invoke();
|
||||
if (ThrowOnConnecting)
|
||||
throw new InvalidOperationException("connecting failure");
|
||||
}
|
||||
|
||||
public void ReportConnected()
|
||||
{
|
||||
calls.Add("report-connected");
|
||||
OnConnected?.Invoke();
|
||||
if (ThrowOnConnected)
|
||||
throw new InvalidOperationException("connected failure");
|
||||
}
|
||||
|
||||
public void ApplySelectedCharacter(LiveSessionCharacterSelection selection)
|
||||
{
|
||||
calls.Add("selected");
|
||||
Selections.Add(selection);
|
||||
OnSelected?.Invoke();
|
||||
if (ThrowOnSelected)
|
||||
throw new InvalidOperationException("selected failure");
|
||||
}
|
||||
|
||||
public void ApplyEnteredWorld(LiveSessionCharacterSelection selection)
|
||||
{
|
||||
calls.Add("entered");
|
||||
OnEntered?.Invoke();
|
||||
if (ThrowOnEntered)
|
||||
throw new InvalidOperationException("entered failure");
|
||||
}
|
||||
|
||||
public void DetachSession(WorldSession session)
|
||||
{
|
||||
calls.Add("detach-session");
|
||||
DetachCount++;
|
||||
OnDetach?.Invoke();
|
||||
if (FailDetachOnce)
|
||||
{
|
||||
FailDetachOnce = false;
|
||||
throw new InvalidOperationException("detach failure");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum ReentrantStopPoint
|
||||
{
|
||||
Reset,
|
||||
Resolve,
|
||||
Create,
|
||||
Bind,
|
||||
Connecting,
|
||||
Connect,
|
||||
Connected,
|
||||
Selected,
|
||||
EnterWorld,
|
||||
Activate,
|
||||
Entered,
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Start_BindsBeforeConnectAndPublishesCanonicalSelectionAfterEnterWorld()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var operations = new TestOperations(calls);
|
||||
var host = new TestHost(calls);
|
||||
var controller = new LiveSessionController(operations);
|
||||
|
||||
LiveSessionStartResult result = controller.Start(LiveOptions(), host);
|
||||
|
||||
Assert.Equal(LiveSessionStartStatus.Connected, result.Status);
|
||||
Assert.Equal(
|
||||
[
|
||||
"reset", "resolve", "create", "bind", "report-connecting",
|
||||
"connect", "report-connected", "selected", "enter:1",
|
||||
"activate", "entered",
|
||||
],
|
||||
calls);
|
||||
Assert.Equal(
|
||||
new LiveSessionCharacterSelection(1, 0x50000002u, "Ready", "Canonical"),
|
||||
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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Start_DisabledAndMissingCredentialsResetButNeverConstructSession()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var operations = new TestOperations(calls);
|
||||
var host = new TestHost(calls);
|
||||
var controller = new LiveSessionController(operations);
|
||||
|
||||
Assert.Equal(
|
||||
LiveSessionStartStatus.Disabled,
|
||||
controller.Start(LiveOptions(live: false), host).Status);
|
||||
Assert.Equal(
|
||||
LiveSessionStartStatus.MissingCredentials,
|
||||
controller.Start(LiveOptions(user: null), host).Status);
|
||||
|
||||
Assert.Equal(2, host.ResetCount);
|
||||
Assert.Empty(operations.Sessions);
|
||||
Assert.Equal(NullCommandBus.Instance, controller.Commands);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Start_NoAvailableCharacterTearsDownExactScope()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var operations = new TestOperations(calls)
|
||||
{
|
||||
Characters = new CharacterList.Parsed(
|
||||
0u,
|
||||
[new CharacterList.Character(0x50000001u, "Grey", 5u)],
|
||||
[],
|
||||
11,
|
||||
"Canonical",
|
||||
true,
|
||||
true),
|
||||
};
|
||||
var host = new TestHost(calls);
|
||||
var controller = new LiveSessionController(operations);
|
||||
|
||||
LiveSessionStartResult result = controller.Start(LiveOptions(), host);
|
||||
|
||||
Assert.Equal(LiveSessionStartStatus.NoCharacters, result.Status);
|
||||
Assert.False(controller.IsInWorld);
|
||||
Assert.Null(controller.CurrentSession);
|
||||
Assert.Equal(1, host.DeactivateCount);
|
||||
Assert.Equal(1, host.EventDetachCount);
|
||||
Assert.Equal(1, host.DetachCount);
|
||||
Assert.Equal(2, host.ResetCount);
|
||||
Assert.Equal(1, operations.DisposeCounts[operations.Sessions[0]]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Start_ConnectFailureConvergesOffline()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var operations = new TestOperations(calls) { ThrowOnConnect = true };
|
||||
var host = new TestHost(calls);
|
||||
var controller = new LiveSessionController(operations);
|
||||
|
||||
LiveSessionStartResult result = controller.Start(LiveOptions(), host);
|
||||
|
||||
Assert.Equal(LiveSessionStartStatus.Failed, result.Status);
|
||||
Assert.Contains("connect failure", result.Error!.ToString());
|
||||
Assert.False(controller.IsInWorld);
|
||||
Assert.Null(controller.CurrentSession);
|
||||
Assert.Equal(1, operations.DisposeCounts[operations.Sessions[0]]);
|
||||
Assert.Equal(1, host.DetachCount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("create")]
|
||||
[InlineData("bind")]
|
||||
[InlineData("enter")]
|
||||
public void Start_OtherConstructionAndEntryFailuresConvergeOffline(string phase)
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var operations = new TestOperations(calls)
|
||||
{
|
||||
ThrowOnCreate = phase == "create",
|
||||
ThrowOnEnterWorld = phase == "enter",
|
||||
};
|
||||
var host = new TestHost(calls) { ThrowOnBind = phase == "bind" };
|
||||
var controller = new LiveSessionController(operations);
|
||||
|
||||
LiveSessionStartResult result = controller.Start(LiveOptions(), host);
|
||||
|
||||
Assert.Equal(LiveSessionStartStatus.Failed, result.Status);
|
||||
Assert.Contains($"{phase} failure", result.Error!.ToString());
|
||||
Assert.False(controller.IsInWorld);
|
||||
Assert.Null(controller.CurrentSession);
|
||||
if (phase == "create")
|
||||
{
|
||||
Assert.Empty(operations.Sessions);
|
||||
Assert.Equal(1, host.ResetCount);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Single(operations.Sessions);
|
||||
Assert.Equal(1, operations.DisposeCounts[operations.Sessions[0]]);
|
||||
Assert.Equal(2, host.ResetCount);
|
||||
}
|
||||
Assert.Equal(phase == "enter" ? 1 : 0, host.DetachCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Start_HealthyDuplicateIsIdempotent()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var operations = new TestOperations(calls);
|
||||
var host = new TestHost(calls);
|
||||
var controller = new LiveSessionController(operations);
|
||||
LiveSessionStartResult first = controller.Start(LiveOptions(), host);
|
||||
|
||||
LiveSessionStartResult duplicate = controller.Start(LiveOptions(), host);
|
||||
|
||||
Assert.Equal(LiveSessionStartStatus.Connected, duplicate.Status);
|
||||
Assert.Equal(first.Selection, duplicate.Selection);
|
||||
Assert.Single(operations.Sessions);
|
||||
Assert.Equal(1, host.ResetCount);
|
||||
Assert.Equal(1, host.ActivateCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reconnect_QuiescesAThenDisposesDetachesResetsBeforeConstructingB()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var operations = new TestOperations(calls);
|
||||
var host = new TestHost(calls);
|
||||
var controller = new LiveSessionController(operations);
|
||||
controller.Start(LiveOptions(), host);
|
||||
WorldSession sessionA = operations.Sessions[0];
|
||||
TestCommandBus commandsA = host.CommandBuses[0];
|
||||
calls.Clear();
|
||||
|
||||
LiveSessionStartResult result = controller.Reconnect(LiveOptions(), host);
|
||||
|
||||
Assert.Equal(LiveSessionStartStatus.Connected, result.Status);
|
||||
Assert.Equal(
|
||||
[
|
||||
"deactivate", "detach-events", "dispose-session", "detach-session",
|
||||
"reset", "resolve", "create", "bind", "report-connecting",
|
||||
"connect", "report-connected", "selected", "enter:1",
|
||||
"activate", "entered",
|
||||
],
|
||||
calls);
|
||||
commandsA.Publish(new object());
|
||||
Assert.Equal(0, commandsA.PublishCount);
|
||||
Assert.Equal(1, operations.DisposeCounts[sessionA]);
|
||||
Assert.Equal(2, operations.Sessions.Count);
|
||||
Assert.Same(operations.Sessions[1], controller.CurrentSession);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("detach", "stop")]
|
||||
[InlineData("detach", "dispose")]
|
||||
[InlineData("detach", "reconnect")]
|
||||
[InlineData("reset", "stop")]
|
||||
[InlineData("reset", "dispose")]
|
||||
[InlineData("reset", "reconnect")]
|
||||
public void Reconnect_ReentrantTeardownRequestNeverPublishesSupersededB(
|
||||
string callbackPhase,
|
||||
string request)
|
||||
{
|
||||
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 requested = false;
|
||||
Action callback = () =>
|
||||
{
|
||||
if (requested)
|
||||
return;
|
||||
requested = true;
|
||||
switch (request)
|
||||
{
|
||||
case "stop": controller.Stop(); break;
|
||||
case "dispose": controller.Dispose(); break;
|
||||
case "reconnect":
|
||||
Assert.Equal(
|
||||
LiveSessionStartStatus.Deferred,
|
||||
controller.Reconnect(LiveOptions(), host).Status);
|
||||
break;
|
||||
}
|
||||
};
|
||||
if (callbackPhase == "detach")
|
||||
host.OnDetach = callback;
|
||||
else
|
||||
host.OnReset = callback;
|
||||
|
||||
LiveSessionStartResult outer = controller.Reconnect(LiveOptions(), host);
|
||||
|
||||
Assert.Equal(LiveSessionStartStatus.Deferred, outer.Status);
|
||||
Assert.Equal(request == "reconnect" ? 2 : 1, operations.Sessions.Count);
|
||||
Assert.Equal(request == "reconnect", controller.IsInWorld);
|
||||
if (request == "dispose")
|
||||
Assert.Throws<ObjectDisposedException>(() => controller.Start(LiveOptions(), host));
|
||||
controller.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MismatchedBindingWithInterruptedDetachRetainsCleanupOwnershipForRetry()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var operations = new TestOperations(calls);
|
||||
using var mismatchedSession = new WorldSession(
|
||||
new IPEndPoint(IPAddress.Loopback, 9001),
|
||||
new TestTransport());
|
||||
var host = new TestHost(calls)
|
||||
{
|
||||
BindingSessionOverride = mismatchedSession,
|
||||
FailEventDetachOnce = true,
|
||||
};
|
||||
var controller = new LiveSessionController(operations);
|
||||
|
||||
LiveSessionStartResult result = controller.Start(LiveOptions(), host);
|
||||
|
||||
Assert.Equal(LiveSessionStartStatus.Failed, result.Status);
|
||||
Assert.Contains("different session", result.Error!.ToString());
|
||||
Assert.Equal(0, operations.DisposeCounts.GetValueOrDefault(operations.Sessions[0]));
|
||||
Assert.Equal(1, host.DeactivateCount);
|
||||
Assert.Equal(1, host.EventDetachCount);
|
||||
|
||||
controller.Stop();
|
||||
|
||||
Assert.Equal(1, operations.DisposeCounts[operations.Sessions[0]]);
|
||||
Assert.Equal(1, host.DeactivateCount);
|
||||
Assert.Equal(2, host.EventDetachCount);
|
||||
Assert.Equal(1, host.DetachCount);
|
||||
controller.Dispose();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(ReentrantStopPoint.Reset)]
|
||||
[InlineData(ReentrantStopPoint.Resolve)]
|
||||
[InlineData(ReentrantStopPoint.Create)]
|
||||
[InlineData(ReentrantStopPoint.Bind)]
|
||||
[InlineData(ReentrantStopPoint.Connecting)]
|
||||
[InlineData(ReentrantStopPoint.Connect)]
|
||||
[InlineData(ReentrantStopPoint.Connected)]
|
||||
[InlineData(ReentrantStopPoint.Selected)]
|
||||
[InlineData(ReentrantStopPoint.EnterWorld)]
|
||||
[InlineData(ReentrantStopPoint.Activate)]
|
||||
[InlineData(ReentrantStopPoint.Entered)]
|
||||
public void ReentrantStop_InvalidatesOuterGenerationAndCannotResurrect(
|
||||
ReentrantStopPoint point)
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var operations = new TestOperations(calls);
|
||||
var host = new TestHost(calls);
|
||||
var controller = new LiveSessionController(operations);
|
||||
Action stop = controller.Stop;
|
||||
switch (point)
|
||||
{
|
||||
case ReentrantStopPoint.Reset: host.OnReset = stop; break;
|
||||
case ReentrantStopPoint.Resolve: operations.OnResolve = stop; break;
|
||||
case ReentrantStopPoint.Create: operations.OnCreate = stop; break;
|
||||
case ReentrantStopPoint.Bind: host.OnBind = stop; break;
|
||||
case ReentrantStopPoint.Connecting: host.OnConnecting = stop; break;
|
||||
case ReentrantStopPoint.Connect: operations.OnConnect = stop; break;
|
||||
case ReentrantStopPoint.Connected: host.OnConnected = stop; break;
|
||||
case ReentrantStopPoint.Selected: host.OnSelected = stop; break;
|
||||
case ReentrantStopPoint.EnterWorld: operations.OnEnterWorld = stop; break;
|
||||
case ReentrantStopPoint.Activate: host.OnActivate = stop; break;
|
||||
case ReentrantStopPoint.Entered: host.OnEntered = stop; break;
|
||||
}
|
||||
|
||||
LiveSessionStartResult result = controller.Start(LiveOptions(), host);
|
||||
|
||||
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(
|
||||
point,
|
||||
new[] { ReentrantStopPoint.Reset, ReentrantStopPoint.Resolve });
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Equal(1, operations.DisposeCounts[operations.Sessions[0]]);
|
||||
}
|
||||
Assert.Equal(
|
||||
point is ReentrantStopPoint.Reset
|
||||
or ReentrantStopPoint.Resolve
|
||||
or ReentrantStopPoint.Create
|
||||
? 0
|
||||
: 1,
|
||||
host.DetachCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReentrantDuplicateStartIsDeferredWithoutDisturbingOuterAttempt()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var operations = new TestOperations(calls);
|
||||
var host = new TestHost(calls);
|
||||
var controller = new LiveSessionController(operations);
|
||||
LiveSessionStartResult? nested = null;
|
||||
host.OnSelected = () => nested = controller.Start(LiveOptions(), host);
|
||||
|
||||
LiveSessionStartResult outer = controller.Start(LiveOptions(), host);
|
||||
|
||||
Assert.Equal(LiveSessionStartStatus.Deferred, nested!.Status);
|
||||
Assert.Equal(LiveSessionStartStatus.Connected, outer.Status);
|
||||
Assert.True(controller.IsInWorld);
|
||||
Assert.Single(operations.Sessions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReentrantStartFromFailingEnteredCallbackCannotObserveUncommittedSession()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var operations = new TestOperations(calls);
|
||||
var host = new TestHost(calls);
|
||||
var controller = new LiveSessionController(operations);
|
||||
LiveSessionStartResult? nested = null;
|
||||
host.OnEntered = () =>
|
||||
{
|
||||
nested = controller.Start(LiveOptions(), host);
|
||||
throw new InvalidOperationException("entered failure");
|
||||
};
|
||||
|
||||
LiveSessionStartResult outer = controller.Start(LiveOptions(), host);
|
||||
|
||||
Assert.Equal(LiveSessionStartStatus.Deferred, nested!.Status);
|
||||
Assert.Equal(LiveSessionStartStatus.Failed, outer.Status);
|
||||
Assert.False(controller.IsInWorld);
|
||||
Assert.Null(controller.CurrentSession);
|
||||
controller.Dispose();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("reconnect")]
|
||||
[InlineData("dispose")]
|
||||
public void ReentrantLifecycleRequestDuringActivationDoesNotLeakActiveCommands(
|
||||
string request)
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var operations = new TestOperations(calls);
|
||||
var host = new TestHost(calls);
|
||||
var controller = new LiveSessionController(operations);
|
||||
bool requested = false;
|
||||
host.OnActivate = () =>
|
||||
{
|
||||
if (requested)
|
||||
return;
|
||||
requested = true;
|
||||
if (request == "reconnect")
|
||||
controller.Reconnect(LiveOptions(), host);
|
||||
else
|
||||
controller.Dispose();
|
||||
};
|
||||
|
||||
LiveSessionStartResult outer = controller.Start(LiveOptions(), host);
|
||||
|
||||
Assert.Equal(LiveSessionStartStatus.Deferred, outer.Status);
|
||||
Assert.False(host.CommandBuses[0].Active);
|
||||
Assert.Equal(request == "reconnect" ? 2 : 1, operations.Sessions.Count);
|
||||
Assert.Equal(request == "reconnect", controller.IsInWorld);
|
||||
controller.Dispose();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("connecting")]
|
||||
[InlineData("connected")]
|
||||
[InlineData("characters")]
|
||||
[InlineData("selected")]
|
||||
[InlineData("activate")]
|
||||
[InlineData("entered")]
|
||||
public void Start_CallbackFailureConvergesOffline(string phase)
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var operations = new TestOperations(calls);
|
||||
var host = new TestHost(calls);
|
||||
switch (phase)
|
||||
{
|
||||
case "connecting": host.ThrowOnConnecting = true; break;
|
||||
case "connected": host.ThrowOnConnected = true; break;
|
||||
case "characters": operations.ThrowOnCharacters = true; break;
|
||||
case "selected": host.ThrowOnSelected = true; break;
|
||||
case "activate": host.ThrowOnActivate = true; break;
|
||||
case "entered": host.ThrowOnEntered = true; break;
|
||||
}
|
||||
var controller = new LiveSessionController(operations);
|
||||
|
||||
LiveSessionStartResult result = controller.Start(LiveOptions(), host);
|
||||
|
||||
Assert.Equal(LiveSessionStartStatus.Failed, result.Status);
|
||||
Assert.Contains($"{phase} failure", result.Error!.ToString());
|
||||
Assert.False(controller.IsInWorld);
|
||||
Assert.Null(controller.CurrentSession);
|
||||
Assert.Equal(1, operations.DisposeCounts[operations.Sessions[0]]);
|
||||
Assert.Same(NullCommandBus.Instance, controller.Commands);
|
||||
controller.Dispose();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("deactivate")]
|
||||
[InlineData("events")]
|
||||
[InlineData("session")]
|
||||
[InlineData("detach")]
|
||||
[InlineData("reset")]
|
||||
public void Stop_RetriesExactFailedTeardownStageWithoutRepeatingCompletedWork(
|
||||
string phase)
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var operations = new TestOperations(calls);
|
||||
var host = new TestHost(calls);
|
||||
var controller = new LiveSessionController(operations);
|
||||
controller.Start(LiveOptions(), host);
|
||||
WorldSession session = operations.Sessions[0];
|
||||
switch (phase)
|
||||
{
|
||||
case "deactivate": host.FailDeactivateOnce = true; break;
|
||||
case "events": host.FailEventDetachOnce = true; break;
|
||||
case "session": operations.FailDisposeOnce = true; break;
|
||||
case "detach": host.FailDetachOnce = true; break;
|
||||
case "reset": host.FailResetOnce = true; break;
|
||||
}
|
||||
|
||||
Assert.Throws<InvalidOperationException>(controller.Stop);
|
||||
controller.Stop();
|
||||
|
||||
Assert.False(controller.IsInWorld);
|
||||
Assert.Null(controller.CurrentSession);
|
||||
Assert.Equal(1, operations.DisposeCounts[session]);
|
||||
Assert.Equal(phase == "detach" ? 2 : 1, host.DetachCount);
|
||||
Assert.Equal(phase == "reset" ? 3 : 2, host.ResetCount);
|
||||
controller.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReconnectRequestedDuringTickRunsAfterTickAndReplacesExactGeneration()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var operations = new TestOperations(calls);
|
||||
var host = new TestHost(calls);
|
||||
var controller = new LiveSessionController(operations);
|
||||
controller.Start(LiveOptions(), host);
|
||||
WorldSession sessionA = operations.Sessions[0];
|
||||
bool requested = false;
|
||||
operations.OnTick = () =>
|
||||
{
|
||||
if (requested)
|
||||
return;
|
||||
requested = true;
|
||||
LiveSessionStartResult nested = controller.Reconnect(LiveOptions(), host);
|
||||
Assert.Equal(LiveSessionStartStatus.Deferred, nested.Status);
|
||||
};
|
||||
|
||||
controller.Tick();
|
||||
|
||||
Assert.True(controller.IsInWorld);
|
||||
Assert.Equal(2, operations.Sessions.Count);
|
||||
Assert.Equal(1, operations.DisposeCounts[sessionA]);
|
||||
Assert.Same(operations.Sessions[1], controller.CurrentSession);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResetFailureBlocksConstructionUntilRetryConverges()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var operations = new TestOperations(calls);
|
||||
var host = new TestHost(calls) { FailResetOnce = true };
|
||||
var controller = new LiveSessionController(operations);
|
||||
|
||||
LiveSessionStartResult first = controller.Start(LiveOptions(), host);
|
||||
LiveSessionStartResult second = controller.Start(LiveOptions(), host);
|
||||
|
||||
Assert.Equal(LiveSessionStartStatus.Failed, first.Status);
|
||||
Assert.Equal(LiveSessionStartStatus.Connected, second.Status);
|
||||
Assert.Equal(2, host.ResetCount);
|
||||
Assert.Single(operations.Sessions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FailedDetachRetainsRetiredScopeAndBlocksBFactoryUntilRetry()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var operations = new TestOperations(calls);
|
||||
var host = new TestHost(calls) { FailDetachOnce = true };
|
||||
var controller = new LiveSessionController(operations);
|
||||
controller.Start(LiveOptions(), host);
|
||||
WorldSession sessionA = operations.Sessions[0];
|
||||
|
||||
LiveSessionStartResult first = controller.Reconnect(LiveOptions(), host);
|
||||
|
||||
Assert.Equal(LiveSessionStartStatus.Failed, first.Status);
|
||||
Assert.Single(operations.Sessions);
|
||||
Assert.Equal(1, operations.DisposeCounts[sessionA]);
|
||||
Assert.False(controller.IsInWorld);
|
||||
|
||||
LiveSessionStartResult retry = controller.Reconnect(LiveOptions(), host);
|
||||
|
||||
Assert.Equal(LiveSessionStartStatus.Connected, retry.Status);
|
||||
Assert.Equal(2, operations.Sessions.Count);
|
||||
Assert.Equal(1, operations.DisposeCounts[sessionA]);
|
||||
Assert.Equal(2, host.DetachCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TickFailureCleansScopeBeforeRethrowing()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var operations = new TestOperations(calls) { ThrowOnTick = true };
|
||||
var host = new TestHost(calls);
|
||||
var controller = new LiveSessionController(operations);
|
||||
controller.Start(LiveOptions(), host);
|
||||
WorldSession session = operations.Sessions[0];
|
||||
|
||||
InvalidOperationException error =
|
||||
Assert.Throws<InvalidOperationException>(controller.Tick);
|
||||
|
||||
Assert.False(controller.IsInWorld);
|
||||
Assert.Null(controller.CurrentSession);
|
||||
Assert.Equal(1, operations.DisposeCounts[session]);
|
||||
Assert.Contains(
|
||||
$"{nameof(TestOperations)}.{nameof(TestOperations.Tick)}",
|
||||
error.StackTrace,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DisposeIsIdempotentAndMakesOldCommandsInert()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var operations = new TestOperations(calls);
|
||||
var host = new TestHost(calls);
|
||||
var controller = new LiveSessionController(operations);
|
||||
controller.Start(LiveOptions(), host);
|
||||
WorldSession session = operations.Sessions[0];
|
||||
TestCommandBus commands = host.CommandBuses[0];
|
||||
|
||||
controller.Dispose();
|
||||
controller.Dispose();
|
||||
commands.Publish(new object());
|
||||
|
||||
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()
|
||||
{
|
||||
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);
|
||||
};
|
||||
|
||||
controller.Tick();
|
||||
|
||||
Assert.True(controller.IsDisposalComplete);
|
||||
Assert.False(dependentDisposed);
|
||||
shutdown.CompleteOrThrow();
|
||||
Assert.True(shutdown.IsComplete);
|
||||
Assert.True(dependentDisposed);
|
||||
}
|
||||
|
||||
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",
|
||||
name => environment.GetValueOrDefault(name));
|
||||
}
|
||||
|
||||
private static CharacterList.Parsed AvailableCharacters() => new(
|
||||
0u,
|
||||
[
|
||||
new CharacterList.Character(0x50000001u, "Grey", 10u),
|
||||
new CharacterList.Character(0x50000002u, "Ready", 0u),
|
||||
],
|
||||
[new CharacterList.Character(0x50000003u, "Deleted", 0u)],
|
||||
11,
|
||||
"Canonical",
|
||||
true,
|
||||
true);
|
||||
}
|
||||
|
|
@ -1,331 +0,0 @@
|
|||
using System.Net;
|
||||
using System.Reflection;
|
||||
using AcDream.App.Net;
|
||||
using AcDream.Core.Chat;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Player;
|
||||
using AcDream.Core.Social;
|
||||
using AcDream.Core.Spells;
|
||||
|
||||
namespace AcDream.App.Tests.Net;
|
||||
|
||||
public sealed class LiveSessionEventRouterTests
|
||||
{
|
||||
[Fact]
|
||||
public void CreatedRouterPublishesNoHandlersUntilExplicitAttach()
|
||||
{
|
||||
using var session = NewSession();
|
||||
int baselineGameEvents = session.GameEvents.RegisteredHandlerCount;
|
||||
var router = new LiveSessionEventRouter(
|
||||
session,
|
||||
new LiveEntitySessionSink(
|
||||
_ => { }, _ => { }, _ => { }, _ => { }, _ => { }, _ => { },
|
||||
_ => { }, _ => { }, _ => { }, _ => { }, _ => { }, _ => { }),
|
||||
new LiveEnvironmentSessionSink(_ => { }, _ => { }),
|
||||
NewInventoryBindings(),
|
||||
NewCharacterBindings(),
|
||||
NewSocialBindings());
|
||||
|
||||
AssertSessionHandlerCounts(session, multiplier: 0);
|
||||
Assert.Equal(baselineGameEvents, session.GameEvents.RegisteredHandlerCount);
|
||||
|
||||
router.Attach();
|
||||
AssertSessionHandlerCounts(session, multiplier: 1);
|
||||
Assert.True(session.GameEvents.RegisteredHandlerCount > baselineGameEvents);
|
||||
|
||||
router.Dispose();
|
||||
AssertSessionHandlerCounts(session, multiplier: 0);
|
||||
Assert.Equal(baselineGameEvents, session.GameEvents.RegisteredHandlerCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_DetachesExactHandlersAndSilencesCopiedDelegates()
|
||||
{
|
||||
using var session = NewSession();
|
||||
int baselineGameEvents = session.GameEvents.RegisteredHandlerCount;
|
||||
var counters = new Counters();
|
||||
var combat = new CombatState();
|
||||
var chat = new ChatLog();
|
||||
var router = NewRouter(session, counters, combat: combat, chat: chat);
|
||||
|
||||
AssertSessionHandlerCounts(session, multiplier: 1);
|
||||
Assert.True(session.GameEvents.RegisteredHandlerCount > baselineGameEvents);
|
||||
Action<double> copiedTime = EventDelegate<Action<double>>(
|
||||
session,
|
||||
nameof(session.ServerTimeUpdated));
|
||||
Action<uint> copiedTeleport = EventDelegate<Action<uint>>(
|
||||
session,
|
||||
nameof(session.TeleportStarted));
|
||||
Action<WorldSession.PlayerIntPropertyUpdate> copiedPlayerInt =
|
||||
EventDelegate<Action<WorldSession.PlayerIntPropertyUpdate>>(
|
||||
session,
|
||||
nameof(session.PlayerIntPropertyUpdated));
|
||||
Action<CombatState.DamageDealt> copiedDamage =
|
||||
EventDelegate<CombatState, Action<CombatState.DamageDealt>>(
|
||||
combat,
|
||||
nameof(combat.DamageDealtAccepted));
|
||||
copiedTime(123d);
|
||||
copiedTeleport(0x50000001u);
|
||||
copiedPlayerInt(new WorldSession.PlayerIntPropertyUpdate(
|
||||
CombatStateWiring.CombatModePropertyId,
|
||||
(int)CombatMode.Melee));
|
||||
copiedDamage(new CombatState.DamageDealt("Drudge", 1u, 5u, 0.1f));
|
||||
Assert.Equal(1, counters.ServerTime);
|
||||
Assert.Equal(1, counters.Teleport);
|
||||
Assert.Equal(CombatMode.Melee, combat.CurrentMode);
|
||||
Assert.Equal(1, chat.Count);
|
||||
|
||||
router.Dispose();
|
||||
router.Dispose();
|
||||
AssertSessionHandlerCounts(session, multiplier: 0);
|
||||
Assert.Equal(baselineGameEvents, session.GameEvents.RegisteredHandlerCount);
|
||||
|
||||
copiedTime(456d);
|
||||
copiedTeleport(0x50000002u);
|
||||
copiedPlayerInt(new WorldSession.PlayerIntPropertyUpdate(
|
||||
CombatStateWiring.CombatModePropertyId,
|
||||
(int)CombatMode.Magic));
|
||||
copiedDamage(new CombatState.DamageDealt("Drudge", 1u, 7u, 0.2f));
|
||||
Assert.Equal(1, counters.ServerTime);
|
||||
Assert.Equal(1, counters.Teleport);
|
||||
Assert.Equal(CombatMode.Melee, combat.CurrentMode);
|
||||
Assert.Equal(1, chat.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NestedRouters_DisposeOlderFirstLeavesOnlyNewerRouter()
|
||||
{
|
||||
using var session = NewSession();
|
||||
int baselineGameEvents = session.GameEvents.RegisteredHandlerCount;
|
||||
var first = new Counters();
|
||||
var second = new Counters();
|
||||
var routerA = NewRouter(session, first);
|
||||
var routerB = NewRouter(session, second);
|
||||
|
||||
AssertSessionHandlerCounts(session, multiplier: 2);
|
||||
EventDelegate<Action<double>>(session, nameof(session.ServerTimeUpdated))(1d);
|
||||
Assert.Equal(1, first.ServerTime);
|
||||
Assert.Equal(1, second.ServerTime);
|
||||
|
||||
routerA.Dispose();
|
||||
AssertSessionHandlerCounts(session, multiplier: 1);
|
||||
EventDelegate<Action<double>>(session, nameof(session.ServerTimeUpdated))(2d);
|
||||
Assert.Equal(1, first.ServerTime);
|
||||
Assert.Equal(2, second.ServerTime);
|
||||
|
||||
routerB.Dispose();
|
||||
AssertSessionHandlerCounts(session, multiplier: 0);
|
||||
Assert.Equal(baselineGameEvents, session.GameEvents.RegisteredHandlerCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NestedRouters_DisposeNewerFirstLeavesOnlyOlderRouter()
|
||||
{
|
||||
using var session = NewSession();
|
||||
int baselineGameEvents = session.GameEvents.RegisteredHandlerCount;
|
||||
var first = new Counters();
|
||||
var second = new Counters();
|
||||
var routerA = NewRouter(session, first);
|
||||
var routerB = NewRouter(session, second);
|
||||
|
||||
routerB.Dispose();
|
||||
AssertSessionHandlerCounts(session, multiplier: 1);
|
||||
EventDelegate<Action<double>>(session, nameof(session.ServerTimeUpdated))(1d);
|
||||
Assert.Equal(1, first.ServerTime);
|
||||
Assert.Equal(0, second.ServerTime);
|
||||
|
||||
routerA.Dispose();
|
||||
AssertSessionHandlerCounts(session, multiplier: 0);
|
||||
Assert.Equal(baselineGameEvents, session.GameEvents.RegisteredHandlerCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ThrowingSink_DoesNotCorruptLaterTeardown()
|
||||
{
|
||||
using var session = NewSession();
|
||||
int baselineGameEvents = session.GameEvents.RegisteredHandlerCount;
|
||||
var router = NewRouter(
|
||||
session,
|
||||
new Counters { ThrowOnServerTime = true });
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
EventDelegate<Action<double>>(
|
||||
session,
|
||||
nameof(session.ServerTimeUpdated))(1d));
|
||||
|
||||
router.Dispose();
|
||||
AssertSessionHandlerCounts(session, multiplier: 0);
|
||||
Assert.Equal(baselineGameEvents, session.GameEvents.RegisteredHandlerCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConstructionFailure_UnwindsEveryPriorRegistration()
|
||||
{
|
||||
using var session = NewSession();
|
||||
int baselineGameEvents = session.GameEvents.RegisteredHandlerCount;
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => NewRouter(
|
||||
session,
|
||||
new Counters(),
|
||||
step =>
|
||||
{
|
||||
if (step == 20)
|
||||
throw new InvalidOperationException("injected construction failure");
|
||||
}));
|
||||
|
||||
AssertSessionHandlerCounts(session, multiplier: 0);
|
||||
Assert.Equal(baselineGameEvents, session.GameEvents.RegisteredHandlerCount);
|
||||
}
|
||||
|
||||
private static LiveSessionEventRouter NewRouter(
|
||||
WorldSession session,
|
||||
Counters counters,
|
||||
Action<int>? constructionCheckpoint = null,
|
||||
CombatState? combat = null,
|
||||
ChatLog? chat = null)
|
||||
{
|
||||
var router = new LiveSessionEventRouter(
|
||||
session,
|
||||
new LiveEntitySessionSink(
|
||||
Spawned: _ => { },
|
||||
Deleted: _ => { },
|
||||
PickedUp: _ => { },
|
||||
MotionUpdated: _ => { },
|
||||
PositionUpdated: _ => { },
|
||||
VectorUpdated: _ => { },
|
||||
StateUpdated: _ => { },
|
||||
ParentUpdated: _ => { },
|
||||
TeleportStarted: _ => counters.Teleport++,
|
||||
AppearanceUpdated: _ => { },
|
||||
PlayPhysicsScript: _ => { },
|
||||
PlayPhysicsScriptType: _ => { }),
|
||||
new LiveEnvironmentSessionSink(
|
||||
EnvironChanged: _ => { },
|
||||
ServerTimeUpdated: _ =>
|
||||
{
|
||||
if (counters.ThrowOnServerTime)
|
||||
throw new InvalidOperationException("injected sink failure");
|
||||
counters.ServerTime++;
|
||||
}),
|
||||
NewInventoryBindings(),
|
||||
NewCharacterBindings(combat),
|
||||
NewSocialBindings(chat),
|
||||
constructionCheckpoint);
|
||||
try
|
||||
{
|
||||
router.Attach();
|
||||
return router;
|
||||
}
|
||||
catch
|
||||
{
|
||||
router.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static LiveInventorySessionBindings NewInventoryBindings() => new(
|
||||
new ClientObjectTable(),
|
||||
new LocalPlayerState(),
|
||||
PlayerGuid: () => 0x50000001u,
|
||||
OnShortcuts: null,
|
||||
OnUseDone: null,
|
||||
ItemMana: new ItemManaState(),
|
||||
OnDesiredComponents: null,
|
||||
ExternalContainers: new ExternalContainerState());
|
||||
|
||||
private static LiveCharacterSessionBindings NewCharacterBindings(
|
||||
CombatState? combat = null) => new(
|
||||
combat ?? new CombatState(),
|
||||
new Spellbook(),
|
||||
ResolveSkillFormulaBonus: null,
|
||||
OnSkillsUpdated: null,
|
||||
OnConfirmationRequest: null,
|
||||
OnConfirmationDone: null,
|
||||
OnCharacterOptions: null,
|
||||
ClientTime: () => 0d);
|
||||
|
||||
private static LiveSocialSessionBindings NewSocialBindings(
|
||||
ChatLog? chat = null) => new(
|
||||
chat ?? new ChatLog(),
|
||||
new TurbineChatState(),
|
||||
new FriendsState(),
|
||||
new SquelchState());
|
||||
|
||||
private static WorldSession NewSession() =>
|
||||
new(new IPEndPoint(IPAddress.Loopback, 9));
|
||||
|
||||
private static void AssertSessionHandlerCounts(
|
||||
WorldSession session,
|
||||
int multiplier)
|
||||
{
|
||||
string[] directEvents =
|
||||
[
|
||||
nameof(session.EntitySpawned),
|
||||
nameof(session.EntityDeleted),
|
||||
nameof(session.EntityPickedUp),
|
||||
nameof(session.MotionUpdated),
|
||||
nameof(session.PositionUpdated),
|
||||
nameof(session.VectorUpdated),
|
||||
nameof(session.StateUpdated),
|
||||
nameof(session.ParentUpdated),
|
||||
nameof(session.TeleportStarted),
|
||||
nameof(session.AppearanceUpdated),
|
||||
nameof(session.PlayPhysicsScriptReceived),
|
||||
nameof(session.PlayPhysicsScriptTypeReceived),
|
||||
nameof(session.EnvironChanged),
|
||||
nameof(session.ServerTimeUpdated),
|
||||
nameof(session.SpeechHeard),
|
||||
nameof(session.ServerMessageReceived),
|
||||
nameof(session.EmoteHeard),
|
||||
nameof(session.SoulEmoteHeard),
|
||||
nameof(session.PlayerKilledReceived),
|
||||
nameof(session.TurbineChatReceived),
|
||||
nameof(session.VitalUpdated),
|
||||
nameof(session.VitalCurrentUpdated),
|
||||
];
|
||||
foreach (string eventName in directEvents)
|
||||
Assert.Equal(multiplier, HandlerCount(session, eventName));
|
||||
|
||||
Assert.Equal(multiplier, HandlerCount(session, nameof(session.ObjectIntPropertyUpdated)));
|
||||
Assert.Equal(multiplier * 2, HandlerCount(session, nameof(session.PlayerIntPropertyUpdated)));
|
||||
Assert.Equal(multiplier, HandlerCount(session, nameof(session.PlayerInt64PropertyUpdated)));
|
||||
Assert.Equal(multiplier, HandlerCount(session, nameof(session.StackSizeUpdated)));
|
||||
Assert.Equal(multiplier, HandlerCount(session, nameof(session.InventoryObjectRemoved)));
|
||||
}
|
||||
|
||||
private static int HandlerCount(WorldSession session, string eventName) =>
|
||||
(EventField(session, eventName).GetValue(session) as MulticastDelegate)?
|
||||
.GetInvocationList()
|
||||
.Length ?? 0;
|
||||
|
||||
private static TDelegate EventDelegate<TDelegate>(
|
||||
WorldSession session,
|
||||
string eventName)
|
||||
where TDelegate : Delegate =>
|
||||
Assert.IsType<TDelegate>(EventField(session, eventName).GetValue(session));
|
||||
|
||||
private static TDelegate EventDelegate<TOwner, TDelegate>(
|
||||
TOwner owner,
|
||||
string eventName)
|
||||
where TOwner : class
|
||||
where TDelegate : Delegate =>
|
||||
Assert.IsType<TDelegate>(
|
||||
typeof(TOwner).GetField(
|
||||
eventName,
|
||||
BindingFlags.Instance | BindingFlags.NonPublic)?.GetValue(owner));
|
||||
|
||||
private static FieldInfo EventField(WorldSession session, string eventName) =>
|
||||
typeof(WorldSession).GetField(
|
||||
eventName,
|
||||
BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
?? throw new InvalidOperationException($"event field {eventName} not found");
|
||||
|
||||
private sealed class Counters
|
||||
{
|
||||
public int ServerTime;
|
||||
public int Teleport;
|
||||
public bool ThrowOnServerTime;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,400 +0,0 @@
|
|||
using System.Net;
|
||||
using AcDream.App.Net;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
|
||||
namespace AcDream.App.Tests.Net;
|
||||
|
||||
public sealed class LiveSessionHostTests
|
||||
{
|
||||
[Fact]
|
||||
public void HostOwnsRouteSelectionAndEntryOrderingWithoutMirroringSession()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var operations = new TestOperations(calls);
|
||||
var controller = new LiveSessionController(operations);
|
||||
var events = new TestEventRouting(calls);
|
||||
var commands = new TestCommandRouting(calls);
|
||||
LiveSessionHost host = CreateHost(
|
||||
controller,
|
||||
calls,
|
||||
_ =>
|
||||
{
|
||||
calls.Add("events");
|
||||
return events;
|
||||
},
|
||||
_ =>
|
||||
{
|
||||
calls.Add("commands");
|
||||
return commands;
|
||||
});
|
||||
|
||||
LiveSessionStartResult result = host.Start(LiveOptions());
|
||||
|
||||
Assert.Equal(LiveSessionStartStatus.Connected, result.Status);
|
||||
Assert.Equal(
|
||||
[
|
||||
"reset", "resolve", "create", "events", "attach-events", "commands",
|
||||
"connecting", "connect", "connected",
|
||||
"player:1342177282", "vitals:1342177282",
|
||||
"chat:1342177282", "persistent:1342177282",
|
||||
"vanish:1342177282", "clear-combat", "enter:1",
|
||||
"activate", "active:Ready", "restore-layout",
|
||||
"sync-toolbar", "load-settings:Ready", "arm-auto-entry",
|
||||
],
|
||||
calls);
|
||||
Assert.Same(controller.CurrentSession, host.CurrentSession);
|
||||
Assert.Same(commands, host.Commands);
|
||||
Assert.True(host.IsInWorld);
|
||||
|
||||
controller.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DisabledAndMissingCredentialStartsExecuteTheCanonicalResetPlan()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var operations = new TestOperations(calls);
|
||||
var controller = new LiveSessionController(operations);
|
||||
LiveSessionHost host = CreateHost(
|
||||
controller,
|
||||
calls,
|
||||
_ => throw new InvalidOperationException("must not bind"),
|
||||
_ => throw new InvalidOperationException("must not bind"));
|
||||
|
||||
Assert.Equal(
|
||||
LiveSessionStartStatus.Disabled,
|
||||
host.Start(LiveOptions(live: false)).Status);
|
||||
Assert.Equal(
|
||||
LiveSessionStartStatus.MissingCredentials,
|
||||
host.Start(LiveOptions(user: null)).Status);
|
||||
|
||||
Assert.Equal(["reset", "reset"], calls);
|
||||
Assert.Null(host.CurrentSession);
|
||||
Assert.False(host.IsInWorld);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CommandFactoryFailureRollsBackTheAlreadyAttachedEventRoute()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var operations = new TestOperations(calls);
|
||||
var controller = new LiveSessionController(operations);
|
||||
var events = new TestEventRouting(calls);
|
||||
LiveSessionHost host = CreateHost(
|
||||
controller,
|
||||
calls,
|
||||
_ => events,
|
||||
_ => throw new InvalidOperationException("command failure"));
|
||||
|
||||
LiveSessionStartResult result = host.Start(LiveOptions());
|
||||
|
||||
Assert.Equal(LiveSessionStartStatus.Failed, result.Status);
|
||||
Assert.IsType<InvalidOperationException>(result.Error);
|
||||
Assert.Equal(1, events.DisposeCount);
|
||||
Assert.Contains("dispose-events", calls);
|
||||
Assert.Null(host.CurrentSession);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AttachFailureRetainsThePublishedRouteUntilRollbackConverges()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var operations = new TestOperations(calls);
|
||||
var controller = new LiveSessionController(operations);
|
||||
var firstEvents = new TestEventRouting(calls)
|
||||
{
|
||||
AttachFailuresRemaining = 1,
|
||||
FailuresRemaining = 1,
|
||||
};
|
||||
int eventFactoryCount = 0;
|
||||
int commandFactoryCount = 0;
|
||||
LiveSessionHost host = CreateHost(
|
||||
controller,
|
||||
calls,
|
||||
_ => eventFactoryCount++ == 0
|
||||
? firstEvents
|
||||
: new TestEventRouting(calls),
|
||||
_ =>
|
||||
{
|
||||
commandFactoryCount++;
|
||||
return new TestCommandRouting(calls);
|
||||
});
|
||||
|
||||
LiveSessionStartResult failed = host.Start(LiveOptions());
|
||||
|
||||
Assert.Equal(LiveSessionStartStatus.Failed, failed.Status);
|
||||
Assert.Equal(1, firstEvents.AttachCount);
|
||||
Assert.Equal(2, firstEvents.DisposeCount);
|
||||
Assert.Equal(0, commandFactoryCount);
|
||||
|
||||
LiveSessionStartResult retry = host.Start(LiveOptions());
|
||||
|
||||
Assert.Equal(LiveSessionStartStatus.Connected, retry.Status);
|
||||
Assert.Equal(2, eventFactoryCount);
|
||||
Assert.Equal(1, commandFactoryCount);
|
||||
controller.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TransientRollbackFailureIsReportedRetriedAndConvergesBeforeNextStart()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var operations = new TestOperations(calls);
|
||||
var controller = new LiveSessionController(operations);
|
||||
var firstEvents = new TestEventRouting(calls) { FailuresRemaining = 1 };
|
||||
int eventFactoryCount = 0;
|
||||
bool failCommands = true;
|
||||
LiveSessionHost host = CreateHost(
|
||||
controller,
|
||||
calls,
|
||||
_ => eventFactoryCount++ == 0
|
||||
? firstEvents
|
||||
: new TestEventRouting(calls),
|
||||
_ =>
|
||||
{
|
||||
if (failCommands)
|
||||
{
|
||||
failCommands = false;
|
||||
throw new InvalidOperationException("command failure");
|
||||
}
|
||||
return new TestCommandRouting(calls);
|
||||
});
|
||||
|
||||
LiveSessionStartResult failed = host.Start(LiveOptions());
|
||||
|
||||
AggregateException error = Assert.IsType<AggregateException>(failed.Error);
|
||||
Assert.Equal(2, error.InnerExceptions.Count);
|
||||
Assert.Contains(error.InnerExceptions, e => e.Message == "command failure");
|
||||
Assert.Contains(error.InnerExceptions, e => e.Message == "event cleanup failure");
|
||||
Assert.Equal(2, firstEvents.DisposeCount);
|
||||
|
||||
LiveSessionStartResult retry = host.Start(LiveOptions());
|
||||
|
||||
Assert.Equal(LiveSessionStartStatus.Connected, retry.Status);
|
||||
Assert.Equal(2, eventFactoryCount);
|
||||
Assert.Equal(2, operations.Sessions.Count);
|
||||
controller.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PersistentRollbackFailureBlocksEveryLaterGeneration()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var operations = new TestOperations(calls);
|
||||
var controller = new LiveSessionController(operations);
|
||||
var events = new TestEventRouting(calls)
|
||||
{
|
||||
FailuresRemaining = int.MaxValue,
|
||||
};
|
||||
int eventFactoryCount = 0;
|
||||
int commandFactoryCount = 0;
|
||||
LiveSessionHost host = CreateHost(
|
||||
controller,
|
||||
calls,
|
||||
_ =>
|
||||
{
|
||||
eventFactoryCount++;
|
||||
return events;
|
||||
},
|
||||
_ =>
|
||||
{
|
||||
commandFactoryCount++;
|
||||
throw new InvalidOperationException("command failure");
|
||||
});
|
||||
|
||||
LiveSessionStartResult first = host.Start(LiveOptions());
|
||||
LiveSessionStartResult second = host.Start(LiveOptions());
|
||||
|
||||
Assert.Equal(LiveSessionStartStatus.Failed, first.Status);
|
||||
Assert.Equal(LiveSessionStartStatus.Failed, second.Status);
|
||||
Assert.Equal(3, events.DisposeCount);
|
||||
Assert.Equal(1, eventFactoryCount);
|
||||
Assert.Equal(1, commandFactoryCount);
|
||||
Assert.Single(operations.Sessions);
|
||||
Assert.Null(host.CurrentSession);
|
||||
}
|
||||
|
||||
private static LiveSessionHost CreateHost(
|
||||
LiveSessionController controller,
|
||||
List<string> calls,
|
||||
Func<WorldSession, ILiveSessionEventRouting> createEvents,
|
||||
Func<WorldSession, ILiveSessionCommandRouting> createCommands) =>
|
||||
new(controller, new LiveSessionHostBindings(
|
||||
Routing: new(createEvents, createCommands),
|
||||
Reset: ResetBindings(() => calls.Add("reset")),
|
||||
Selection: new(
|
||||
id => calls.Add($"player:{id}"),
|
||||
id => calls.Add($"vitals:{id}"),
|
||||
id => calls.Add($"chat:{id}"),
|
||||
id => calls.Add($"persistent:{id}"),
|
||||
id => calls.Add($"vanish:{id}"),
|
||||
() => calls.Add("clear-combat")),
|
||||
EnteredWorld: new(
|
||||
name => calls.Add($"active:{name}"),
|
||||
() => calls.Add("restore-layout"),
|
||||
() => calls.Add("sync-toolbar"),
|
||||
name => calls.Add($"load-settings:{name}"),
|
||||
() => calls.Add("arm-auto-entry")),
|
||||
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 sealed class TestEventRouting(List<string> calls)
|
||||
: ILiveSessionEventRouting
|
||||
{
|
||||
public int FailuresRemaining { get; set; }
|
||||
public int AttachFailuresRemaining { get; set; }
|
||||
public int AttachCount { get; private set; }
|
||||
public int DisposeCount { get; private set; }
|
||||
|
||||
public void Attach()
|
||||
{
|
||||
AttachCount++;
|
||||
calls.Add("attach-events");
|
||||
if (AttachFailuresRemaining > 0)
|
||||
{
|
||||
AttachFailuresRemaining--;
|
||||
throw new InvalidOperationException("event attach failure");
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
DisposeCount++;
|
||||
calls.Add("dispose-events");
|
||||
if (FailuresRemaining > 0)
|
||||
{
|
||||
FailuresRemaining--;
|
||||
throw new InvalidOperationException("event cleanup failure");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestCommandRouting(List<string> calls)
|
||||
: ILiveSessionCommandRouting
|
||||
{
|
||||
public void Activate() => calls.Add("activate");
|
||||
public void Publish<T>(T command) where T : notnull { }
|
||||
public void Dispose() => calls.Add("dispose-commands");
|
||||
}
|
||||
|
||||
private sealed class TestOperations(List<string> calls) : ILiveSessionOperations
|
||||
{
|
||||
public List<WorldSession> Sessions { get; } = [];
|
||||
|
||||
public IPEndPoint ResolveEndpoint(string host, int port)
|
||||
{
|
||||
calls.Add("resolve");
|
||||
return new IPEndPoint(IPAddress.Loopback, port);
|
||||
}
|
||||
|
||||
public WorldSession CreateSession(IPEndPoint endpoint)
|
||||
{
|
||||
calls.Add("create");
|
||||
var session = new WorldSession(endpoint, new TestTransport());
|
||||
Sessions.Add(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
public void Connect(WorldSession session, string user, string password) =>
|
||||
calls.Add("connect");
|
||||
|
||||
public CharacterList.Parsed? GetCharacters(WorldSession session) => new(
|
||||
0u,
|
||||
[
|
||||
new CharacterList.Character(0x50000001u, "Grey", 10u),
|
||||
new CharacterList.Character(0x50000002u, "Ready", 0u),
|
||||
],
|
||||
[],
|
||||
11,
|
||||
"Canonical",
|
||||
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 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() { }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,118 +0,0 @@
|
|||
using System.Net;
|
||||
using AcDream.App.Net;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.UI.Abstractions;
|
||||
|
||||
namespace AcDream.App.Tests.Net;
|
||||
|
||||
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,
|
||||
NullCommandBus.Instance,
|
||||
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() { }
|
||||
}
|
||||
}
|
||||
|
|
@ -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() { }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
using AcDream.App.Net;
|
||||
|
||||
namespace AcDream.App.Tests.Net;
|
||||
|
||||
public sealed class LiveSessionSubscriptionSetTests
|
||||
{
|
||||
[Fact]
|
||||
public void Dispose_RetriesOnlyFailedEdgesUntilTheyConverge()
|
||||
{
|
||||
var order = new List<int>();
|
||||
var subscriptions = new LiveSessionSubscriptionSet();
|
||||
subscriptions.Add(() => order.Add(1));
|
||||
int secondFailures = 1;
|
||||
subscriptions.Add(() =>
|
||||
{
|
||||
order.Add(2);
|
||||
if (secondFailures-- > 0)
|
||||
throw new InvalidOperationException("second failed");
|
||||
});
|
||||
int thirdFailures = 1;
|
||||
subscriptions.Add(() =>
|
||||
{
|
||||
order.Add(3);
|
||||
if (thirdFailures-- > 0)
|
||||
throw new IOException("third failed");
|
||||
});
|
||||
|
||||
AggregateException error = Assert.Throws<AggregateException>(
|
||||
subscriptions.Dispose);
|
||||
|
||||
Assert.Equal([3, 2, 1], order);
|
||||
Assert.Collection(
|
||||
error.InnerExceptions,
|
||||
exception => Assert.IsType<IOException>(exception),
|
||||
exception => Assert.IsType<InvalidOperationException>(exception));
|
||||
|
||||
subscriptions.Dispose();
|
||||
subscriptions.Dispose();
|
||||
|
||||
Assert.Equal([3, 2, 1, 3, 2], order);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_PersistentFailureNeverReplaysSuccessfulEdges()
|
||||
{
|
||||
var order = new List<int>();
|
||||
var subscriptions = new LiveSessionSubscriptionSet();
|
||||
subscriptions.Add(() => order.Add(1));
|
||||
subscriptions.Add(() =>
|
||||
{
|
||||
order.Add(2);
|
||||
throw new InvalidOperationException("persistent");
|
||||
});
|
||||
|
||||
Assert.Throws<AggregateException>(subscriptions.Dispose);
|
||||
Assert.Throws<AggregateException>(subscriptions.Dispose);
|
||||
|
||||
Assert.Equal([2, 1, 2], order);
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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(",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue