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(); 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(); 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(); 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(result.Error); Assert.Equal(1, events.DisposeCount); Assert.Contains("dispose-events", calls); Assert.Null(host.CurrentSession); } [Fact] public void AttachFailureRetainsThePublishedRouteUntilRollbackConverges() { var calls = new List(); 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(); 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(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(); 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 calls, Func createEvents, Func 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, 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 { ["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 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 calls) : ILiveSessionCommandRouting { public void Activate() => calls.Add("activate"); public void Publish(T command) where T : notnull { } public void Dispose() => calls.Add("dispose-commands"); } private sealed class TestOperations(List calls) : ILiveSessionOperations { public List 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 datagram) { } public void Send(IPEndPoint remote, ReadOnlySpan datagram) { } public byte[]? Receive(TimeSpan timeout, out IPEndPoint? from) { from = null; return null; } public void Dispose() { } } }