Extract reset, selection, entered-world, and route construction behind LiveSessionHost while preserving the sole LiveSessionController authority. Retain partial route and subscription cleanup for retry, and replace the embedded ACE-only shortcut with the exact named-retail unsigned skill formula. Co-authored-by: Codex <codex@openai.com>
391 lines
13 KiB
C#
391 lines
13 KiB
C#
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,
|
|
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 byte[]? Receive(TimeSpan timeout, out IPEndPoint? from)
|
|
{
|
|
from = null;
|
|
return null;
|
|
}
|
|
|
|
public void Dispose() { }
|
|
}
|
|
}
|