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>
326 lines
11 KiB
C#
326 lines
11 KiB
C#
using System.Runtime.CompilerServices;
|
|
using AcDream.App.Net;
|
|
using AcDream.App.UI;
|
|
using AcDream.Core.Chat;
|
|
using AcDream.Core.Social;
|
|
using AcDream.UI.Abstractions;
|
|
|
|
namespace AcDream.App.Tests.Net;
|
|
|
|
public sealed class LiveSessionCommandRouterTests
|
|
{
|
|
[Fact]
|
|
public void InactiveAndDisposedRouter_CannotReachTransport()
|
|
{
|
|
var sent = new List<string>();
|
|
var router = NewRouter(sendTalk: sent.Add);
|
|
|
|
router.Publish(new SendServerCommandCmd("@before"));
|
|
router.Activate();
|
|
router.Activate();
|
|
router.Publish(new SendServerCommandCmd("@active"));
|
|
router.Dispose();
|
|
router.Dispose();
|
|
router.Publish(new SendServerCommandCmd("@after"));
|
|
|
|
Assert.Equal(["@active"], sent);
|
|
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()
|
|
{
|
|
var tells = new List<(string Target, string Text)>();
|
|
var channels = new List<(uint Id, string Text)>();
|
|
var chat = new ChatLog();
|
|
var router = NewRouter(
|
|
chat: chat,
|
|
sendTell: (target, text) => tells.Add((target, text)),
|
|
sendChannel: (id, text) => channels.Add((id, text)));
|
|
router.Activate();
|
|
|
|
router.Publish(new SendChatCmd(ChatChannelKind.Tell, "Friend", "hello"));
|
|
router.Publish(new SendChatCmd(ChatChannelKind.Fellowship, null, "group"));
|
|
|
|
Assert.Equal([("Friend", "hello")], tells);
|
|
Assert.Equal([(0x00000800u, "group")], channels);
|
|
Assert.Collection(
|
|
chat.Snapshot(),
|
|
entry =>
|
|
{
|
|
Assert.Equal(ChatKind.Tell, entry.Kind);
|
|
Assert.Equal("Friend", entry.Sender);
|
|
},
|
|
entry =>
|
|
{
|
|
Assert.Equal(ChatKind.Channel, entry.Kind);
|
|
Assert.Equal("Fellowship", entry.ChannelName);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void TurbineChannel_UsesRuntimeRoomCookieAndNoOptimisticEcho()
|
|
{
|
|
var turbine = new TurbineChatState();
|
|
turbine.OnChannelsReceived(
|
|
allegianceRoom: 0u,
|
|
generalRoom: 0x70000001u,
|
|
tradeRoom: 0u,
|
|
lfgRoom: 0u,
|
|
roleplayRoom: 0u,
|
|
olthoiRoom: 0u,
|
|
societyRoom: 0u,
|
|
societyCelestialHandRoom: 0u,
|
|
societyEldrytchWebRoom: 0u,
|
|
societyRadiantBloodRoom: 0u);
|
|
var sent = new List<(uint Room, uint Type, uint Dispatch, uint Sender, string Text, uint Cookie)>();
|
|
var chat = new ChatLog();
|
|
var router = NewRouter(
|
|
chat: chat,
|
|
turbine: turbine,
|
|
playerGuid: () => 0x50000001u,
|
|
sendTurbine: (room, type, dispatch, sender, text, cookie) =>
|
|
sent.Add((room, type, dispatch, sender, text, cookie)));
|
|
router.Activate();
|
|
|
|
router.Publish(new SendChatCmd(ChatChannelKind.General, null, "world"));
|
|
|
|
Assert.Collection(sent, message =>
|
|
{
|
|
Assert.Equal(0x70000001u, message.Room);
|
|
Assert.Equal(0x02u, message.Type);
|
|
Assert.Equal(0x02u, message.Dispatch);
|
|
Assert.Equal(0x50000001u, message.Sender);
|
|
Assert.Equal("world", message.Text);
|
|
Assert.Equal(1u, message.Cookie);
|
|
});
|
|
Assert.Equal(0, chat.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public void ActivateAfterDispose_IsRejected()
|
|
{
|
|
var router = NewRouter();
|
|
router.Dispose();
|
|
|
|
Assert.Throws<ObjectDisposedException>(router.Activate);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ConcurrentDispose_WaitsForInFlightTransportThenMakesRouterInert()
|
|
{
|
|
using var sendEntered = new ManualResetEventSlim();
|
|
using var releaseSend = new ManualResetEventSlim();
|
|
var sent = new List<string>();
|
|
var router = NewRouter(sendTalk: text =>
|
|
{
|
|
sendEntered.Set();
|
|
releaseSend.Wait();
|
|
sent.Add(text);
|
|
});
|
|
router.Activate();
|
|
|
|
Task publish = Task.Run(() =>
|
|
router.Publish(new SendServerCommandCmd("@active")));
|
|
Assert.True(sendEntered.Wait(TimeSpan.FromSeconds(5)));
|
|
using var disposeStarted = new ManualResetEventSlim();
|
|
Task dispose = Task.Run(() =>
|
|
{
|
|
disposeStarted.Set();
|
|
router.Dispose();
|
|
});
|
|
Assert.True(disposeStarted.Wait(TimeSpan.FromSeconds(5)));
|
|
Task firstCompletion = await Task.WhenAny(
|
|
dispose,
|
|
Task.Delay(TimeSpan.FromMilliseconds(100)));
|
|
Assert.NotSame(dispose, firstCompletion);
|
|
|
|
releaseSend.Set();
|
|
await Task.WhenAll(publish, dispose);
|
|
router.Publish(new SendServerCommandCmd("@after"));
|
|
|
|
Assert.Equal(["@active"], sent);
|
|
Assert.False(router.IsActive);
|
|
}
|
|
|
|
[Fact]
|
|
public void ReentrantDisposeFromLog_PreventsLaterTransportAndEcho()
|
|
{
|
|
var turbine = new TurbineChatState();
|
|
turbine.OnChannelsReceived(
|
|
0u, 0x70000001u, 0u, 0u, 0u, 0u, 0u, 0u, 0u, 0u);
|
|
var sent = new List<string>();
|
|
var chat = new ChatLog();
|
|
LiveSessionCommandRouter? router = null;
|
|
router = NewRouter(
|
|
chat: chat,
|
|
turbine: turbine,
|
|
sendTurbine: (_, _, _, _, text, _) => sent.Add(text),
|
|
log: _ => router!.Dispose());
|
|
router.Activate();
|
|
|
|
router.Publish(new SendChatCmd(ChatChannelKind.General, null, "world"));
|
|
|
|
Assert.Empty(sent);
|
|
Assert.Equal(0, chat.Count);
|
|
Assert.False(router.IsActive);
|
|
}
|
|
|
|
[Fact]
|
|
public void RetainedDisposedRouter_ReleasesCapturedTransportGraph()
|
|
{
|
|
(LiveSessionCommandRouter router, WeakReference<object> transport) =
|
|
CreateRouterWithCapturedTransport();
|
|
|
|
router.Dispose();
|
|
ForceFullCollection();
|
|
|
|
Assert.False(transport.TryGetTarget(out _));
|
|
GC.KeepAlive(router);
|
|
}
|
|
|
|
[Fact]
|
|
public void DelayedConfirmationCallback_ReleasesTransportAndBecomesInert()
|
|
{
|
|
(LiveSessionCommandRouter router, WeakReference<object> transport, Action<bool> callback) =
|
|
CreateRouterWithDelayedConfirmation();
|
|
|
|
router.Dispose();
|
|
ForceFullCollection();
|
|
|
|
Assert.False(transport.TryGetTarget(out _));
|
|
callback(true);
|
|
Assert.False(router.IsActive);
|
|
GC.KeepAlive(callback);
|
|
GC.KeepAlive(router);
|
|
}
|
|
|
|
private static LiveSessionCommandRouter NewRouter(
|
|
ChatLog? chat = null,
|
|
TurbineChatState? turbine = null,
|
|
Func<uint>? playerGuid = null,
|
|
Action<string>? sendTalk = null,
|
|
Action<string, string>? sendTell = null,
|
|
Action<uint, string>? sendChannel = null,
|
|
Action<uint, uint, uint, uint, string, uint>? sendTurbine = null,
|
|
Action<string>? log = null,
|
|
ClientCommandController.Bindings? clientBindings = null) => new(
|
|
new LiveSessionCommandBindings(
|
|
clientBindings ?? NewClientBindings(),
|
|
chat ?? new ChatLog(),
|
|
turbine ?? new TurbineChatState(),
|
|
playerGuid ?? (() => 0u),
|
|
sendTalk ?? (_ => { }),
|
|
sendTell ?? ((_, _) => { }),
|
|
sendChannel ?? ((_, _) => { }),
|
|
sendTurbine ?? ((_, _, _, _, _, _) => { }),
|
|
log));
|
|
|
|
[MethodImpl(MethodImplOptions.NoInlining)]
|
|
private static (LiveSessionCommandRouter, WeakReference<object>)
|
|
CreateRouterWithCapturedTransport()
|
|
{
|
|
object transport = new object();
|
|
var weak = new WeakReference<object>(transport);
|
|
var router = NewRouter(sendTalk: _ => GC.KeepAlive(transport));
|
|
return (router, weak);
|
|
}
|
|
|
|
[MethodImpl(MethodImplOptions.NoInlining)]
|
|
private static (LiveSessionCommandRouter, WeakReference<object>, Action<bool>)
|
|
CreateRouterWithDelayedConfirmation()
|
|
{
|
|
object transport = new object();
|
|
var weak = new WeakReference<object>(transport);
|
|
Action<bool>? callback = null;
|
|
ClientCommandController.Bindings bindings = NewClientBindings() with
|
|
{
|
|
ShowConfirmation = (_, completion) => callback = completion,
|
|
Suicide = () => GC.KeepAlive(transport),
|
|
};
|
|
var router = NewRouter(clientBindings: bindings);
|
|
router.Activate();
|
|
router.Publish(new ExecuteClientCommandCmd(ClientCommandId.Die, string.Empty));
|
|
return (router, weak, Assert.IsType<Action<bool>>(callback));
|
|
}
|
|
|
|
[MethodImpl(MethodImplOptions.NoInlining)]
|
|
private static void ForceFullCollection()
|
|
{
|
|
GC.Collect();
|
|
GC.WaitForPendingFinalizers();
|
|
GC.Collect();
|
|
}
|
|
|
|
private static ClientCommandController.Bindings NewClientBindings() => new(
|
|
TeleportToLifestone: () => { },
|
|
TeleportToMarketplace: () => { },
|
|
TeleportToPkArena: () => { },
|
|
TeleportToPkLiteArena: () => { },
|
|
TeleportToHouse: () => { },
|
|
TeleportToMansion: () => { },
|
|
QueryAge: () => { },
|
|
QueryBirth: () => { },
|
|
ToggleFrameRate: () => { },
|
|
ToggleUiLock: () => { },
|
|
ShowSystemMessage: _ => { },
|
|
ShowWeenieError: _ => { },
|
|
PlayerPublicWeenieBitfield: () => null,
|
|
ClientVersion: () => "test",
|
|
CurrentPosition: () => null,
|
|
LastOutsideCorpsePosition: () => null,
|
|
ShowConfirmation: (_, _) => { },
|
|
Suicide: () => { },
|
|
ClearChat: _ => { },
|
|
SaveUi: _ => { },
|
|
LoadUi: _ => { },
|
|
SaveAutoUi: () => { },
|
|
LoadAutoUi: () => { },
|
|
IsAway: () => false,
|
|
SetAway: _ => { },
|
|
SetAwayMessage: _ => { },
|
|
AcceptLootPermits: () => false,
|
|
SetAcceptLootPermits: _ => { },
|
|
DisplayConsent: () => { },
|
|
ClearConsent: () => { },
|
|
RemoveConsent: _ => { },
|
|
SendEmote: _ => { },
|
|
Friends: new FriendsState(),
|
|
AddFriend: _ => { },
|
|
RemoveFriend: _ => { },
|
|
ClearFriends: () => { },
|
|
RequestLegacyFriends: () => { },
|
|
Squelch: new SquelchState(),
|
|
ModifyCharacterSquelch: (_, _, _, _) => { },
|
|
ModifyAccountSquelch: (_, _) => { },
|
|
ModifyGlobalSquelch: (_, _) => { },
|
|
LastTeller: () => null,
|
|
ClearDesiredComponents: () => { },
|
|
HasOpenVendor: () => false,
|
|
FillComponentBuyList: (_, _) => { });
|
|
}
|