refactor(net): own live session composition
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>
This commit is contained in:
parent
18d4b999de
commit
557eb7ef6b
22 changed files with 1430 additions and 236 deletions
391
tests/AcDream.App.Tests/Net/LiveSessionHostTests.cs
Normal file
391
tests/AcDream.App.Tests/Net/LiveSessionHostTests.cs
Normal file
|
|
@ -0,0 +1,391 @@
|
|||
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() { }
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue