acdream/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs
Erik 5d247d5518 fix(chat): CH4 re-review fixes — dialog-queue reentrancy, settings option-bit chokepoint
Should-fix 1: RetailDialogFactory.CloseDialog's queued branch removed the
active DialogInfo, ran DialogDone (whose callback can synchronously open a
new dialog under the SAME queue key — the two-stage house-abandon
confirmation does exactly this), then called OpenNextDialog, which did an
unconditional Dictionary.Add on a key the reentrant dialog had already
re-occupied. Retail's HashTable::add tolerates the duplicate; Dictionary
throws. OpenNextDialog now returns early when the queue key is already
active — the reentrant dialog's own eventual close drains the queue.

Should-fix 2: @join/@leave wrote the local RuntimeCharacterOptionsState bit
before sending, but the Settings Chat toggles reached a second binding
(SendSingleCharacterOption) that only sent the wire message, leaving the
Turbine membership gate stale until the next PlayerDescription.
LiveSessionRuntimeFactory.CreateCommandBindings now has one shared local
function for both entrances.

Should-fix 3: corrected TS-68/#360 wording again — retail's DoAllegiance
dispatcher table EXECUTES boot/ban/officer/title/motd/name/lock/house/
chat/broadcast locally through their own handlers; acdream shows the
unrecognized-subcommand refusal for all nine pending the #360 port. What
matches retail is the ownership rule (the verb never reaches
DoChannelCommand/the server), not the subcommand behavior itself. Removed
the inaccurate "matching retail, not merely harmless" / "now matches
this" claims from both the register row and the issue.

Nits: corrected the HouseAbandonDialogCallback_First citation (0x00580E1A
is DoHouse's load site for the callback pointer, not the function entry —
the entry is 0x00580240, with the stage-2 confirmation string built at
0x005802D8) in both ClientCommandController.cs and the mirrored test
comment; added an InlineData case pinning "@clist allegiance" to
RequestChannelList(0x02000000); converted RetailClientCommandCatalog.
KnownVerbs from a plain array to a FrozenSet<string> with
StringComparer.OrdinalIgnoreCase, matching the file's other lookup tables.

Suite: 12,221 passed / 4 skipped / 0 failed (Release), up from CH4's
12,216/4/0 — net +5 tests, no removals.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 22:24:43 +02:00

692 lines
27 KiB
C#

using System.Runtime.CompilerServices;
using AcDream.App.Net;
using AcDream.App.UI;
using AcDream.Core.Chat;
using AcDream.Core.Items;
using AcDream.Core.Net.Messages;
using AcDream.Core.Social;
using AcDream.Runtime.Gameplay;
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);
// CH3 (2026-08-09): Fellowship is one of the ACE server-echoing
// legacy channels (resends with an empty sender) — the router must
// NOT also emit a local optimistic echo, or the line double-prints.
// Only the Tell echo (which the server never resends) survives.
Assert.Collection(
chat.Snapshot(),
entry =>
{
Assert.Equal(ChatKind.Tell, entry.Kind);
Assert.Equal("Friend", entry.Sender);
});
}
[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);
}
// ── Campaign CH slice CH3 (2026-08-09): Turbine membership gate ──
[Fact]
public void TurbineUnavailable_RaisesRetailStringThroughAddText_AndSendsNothing()
{
var communication = new RuntimeCommunicationState();
var sent = new List<string>();
var router = NewRouter(
chat: communication.Chat,
communication: communication,
turbine: new TurbineChatState(), // never received SetTurbineChatChannels
sendTurbine: (_, _, _, _, text, _) => sent.Add(text));
router.Activate();
router.Publish(new SendChatCmd(ChatChannelKind.General, null, "hello"));
Assert.Empty(sent);
Assert.Equal(1, communication.Chat.Count);
Assert.Equal(
"Turbine chat is not available.",
communication.Chat.Snapshot()[0].Text);
}
[Fact]
public void HearOptionOff_RaisesNotListeningRefusal_AndSendsNothing()
{
// Fresh RuntimeCharacterOptionsState defaults omit HearRoleplayChat
// (matches ACE CharacterOptions2.Default) — a populated Roleplay
// room must still refuse locally rather than send.
var turbine = new TurbineChatState();
turbine.OnChannelsReceived(
0u, 0u, 0u, 0u, roleplayRoom: 0x14u, 0u, 0u, 0u, 0u, 0u);
var communication = new RuntimeCommunicationState();
var sent = new List<string>();
var router = NewRouter(
chat: communication.Chat,
communication: communication,
turbine: turbine,
sendTurbine: (_, _, _, _, text, _) => sent.Add(text));
router.Activate();
router.Publish(new SendChatCmd(ChatChannelKind.Roleplay, null, "hi"));
Assert.Empty(sent);
Assert.Equal(1, communication.Chat.Count);
Assert.Equal(
"You are not listening to the Roleplay channel!",
communication.Chat.Snapshot()[0].Text);
}
[Fact]
public void AllegianceTurbineEnabledButNoRoom_RefusesLocally_NeverDowngradesToLegacyChannel()
{
// S3 (CH3 Opus review, 2026-08-09) corrected the original CH3
// headline /a fix (research doc §5.3): retail's @a is bound to
// Turbine only once StartupTurbineChatSystem succeeds — i.e. once
// Turbine chat is ENABLED. With Turbine up but no allegiance room
// (this test), retail hits SendTurbineChat's own roomId<=0 branch
// and refuses locally — it must NOT fall through to the legacy
// AllegianceBroadcast bitflag. (Contrast the Enabled==false case
// below, which DOES fall back — Turbine was never started at all.)
var communication = new RuntimeCommunicationState();
var legacySent = new List<(uint Id, string Text)>();
var turbineSent = new List<string>();
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); // Enabled = true, AllegianceRoom stays 0
var router = NewRouter(
chat: communication.Chat,
communication: communication,
turbine: turbine,
sendChannel: (id, text) => legacySent.Add((id, text)),
sendTurbine: (_, _, _, _, text, _) => turbineSent.Add(text));
router.Activate();
router.Publish(new SendChatCmd(ChatChannelKind.Allegiance, null, "guild hi"));
Assert.Empty(legacySent);
Assert.Empty(turbineSent);
Assert.Equal(
"Turbine chat is not available.",
communication.Chat.Snapshot()[0].Text);
}
[Fact]
public void AllegianceTurbineNeverStarted_FallsBackToLegacyAllegianceBroadcast()
{
// S3 (CH3 Opus review, 2026-08-09): retail's BASE binding keeps /a
// on the legacy AllegianceBroadcast (0x02000000) channel until
// Turbine chat successfully starts and StartupTurbineChatSystem
// rebinds it (research doc §4.3) — with NO 0x0295
// SetTurbineChatChannels ever received, retail does not print
// "Turbine chat is not available." (that refusal only exists once
// the Turbine-bound verb has actually been armed).
var chat = new ChatLog();
var legacySent = new List<(uint Id, string Text)>();
var turbineSent = new List<string>();
var router = NewRouter(
chat: chat,
turbine: new TurbineChatState(), // never received SetTurbineChatChannels
sendChannel: (id, text) => legacySent.Add((id, text)),
sendTurbine: (_, _, _, _, text, _) => turbineSent.Add(text));
router.Activate();
router.Publish(new SendChatCmd(ChatChannelKind.Allegiance, null, "guild hi"));
Assert.Equal([(0x02000000u, "guild hi")], legacySent);
Assert.Empty(turbineSent);
// S1: AllegianceBroadcast is now a server-echoing channel — no
// local optimistic echo.
Assert.Equal(0, chat.Count);
}
[Fact]
public void AllegianceBroadcast_AbVerbChannel_RoutesLegacyWithNoLocalEcho()
{
// /ab (retail DoAllegianceBroadcast) rides the legacy 0x0147 pipe —
// distinct from /a, which routes through Turbine whenever Turbine
// chat is enabled (see the two Allegiance tests above).
var chat = new ChatLog();
var legacySent = new List<(uint Id, string Text)>();
var router = NewRouter(
chat: chat,
sendChannel: (id, text) => legacySent.Add((id, text)));
router.Activate();
router.Publish(new SendChatCmd(
ChatChannelKind.AllegianceBroadcast, null, "to the whole allegiance"));
Assert.Equal([(0x02000000u, "to the whole allegiance")], legacySent);
// S1 (CH3 Opus review): ACE's GameActionChatChannel handler
// includes the sender in its real-name Allegiance.Members
// broadcast, so the client must NOT also echo locally.
Assert.Equal(0, chat.Count);
}
// ── Campaign CH slice CH3: per-channel self-echo matrix ──
[Theory]
[InlineData(ChatChannelKind.Fellowship, 0x00000800u)]
[InlineData(ChatChannelKind.Vassals, 0x00001000u)]
[InlineData(ChatChannelKind.Patron, 0x00002000u)]
[InlineData(ChatChannelKind.Monarch, 0x00004000u)]
[InlineData(ChatChannelKind.CoVassals, 0x01000000u)]
public void ServerEchoingLegacyChannels_SkipLocalOptimisticEcho(
ChatChannelKind kind,
uint expectedId)
{
var chat = new ChatLog();
var legacySent = new List<(uint Id, string Text)>();
var router = NewRouter(
chat: chat,
sendChannel: (id, text) => legacySent.Add((id, text)));
router.Activate();
router.Publish(new SendChatCmd(kind, null, "hi"));
Assert.Equal([(expectedId, "hi")], legacySent);
Assert.Equal(0, chat.Count); // no local echo — the server's own
// "" -sender resend is the only echo.
}
[Fact]
public void AllegianceBroadcast_SkipsLocalOptimisticEcho()
{
// S1 (CH3 Opus review, 2026-08-09): flips this test's original
// (wrong) premise. ACE's GameActionChatChannel handler iterates
// player.Allegiance.Members — the sender IS a member, so their own
// line comes back with their real name through the SAME broadcast
// every other member gets. Retail agrees:
// ClientCommunicationSystem::DoAllegianceBroadcast @0x005761F0 has
// no AddTextToScroll of its own. Keeping a local echo double-prints.
var chat = new ChatLog();
var router = NewRouter(
chat: chat,
sendChannel: (_, _) => { });
router.Activate();
router.Publish(new SendChatCmd(
ChatChannelKind.AllegianceBroadcast, null, "hi"));
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);
}
[Fact]
public void RuntimeStateCommandsUseActiveGenerationRouteAndBecomeInert()
{
var shortcuts = new List<ShortcutEntry>();
var options = new List<(uint OptionId, bool Value)>();
LiveSessionCommandRouter router = NewRouter(
addShortcut: shortcuts.Add,
sendSingleCharacterOption: (id, value) => options.Add((id, value)));
router.Publish(new AddShortcutRuntimeCmd(
new ShortcutEntry(1, 0x80000001u, 0u)));
router.Activate();
router.Publish(new AddShortcutRuntimeCmd(
new ShortcutEntry(2, 0x80000002u, 0u)));
router.Publish(new SetSingleCharacterOptionRuntimeCmd(0x26u, true));
router.Dispose();
router.Publish(new AddShortcutRuntimeCmd(
new ShortcutEntry(3, 0x80000003u, 0u)));
Assert.Collection(
shortcuts,
entry =>
{
Assert.Equal(2, entry.Index);
Assert.Equal(0x80000002u, entry.ObjectId);
});
Assert.Equal([(0x26u, true)], options);
}
// ── CH4 re-review SHOULD-FIX 2 (2026-08-09) ─────────────────────────
// The Settings Chat toggles reach this same SetSingleCharacterOptionRuntimeCmd
// route (RuntimeSettingsController.PublishHearOptionChange ->
// RuntimeSettingsTargets.SetSingleCharacterOption ->
// SetSingleCharacterOptionRuntimeCmd -> here -> bindings.SendSingleCharacterOption)
// as a second entrance alongside @join/@leave. LiveSessionRuntimeFactory now
// wires SendSingleCharacterOption through the SAME local-write chokepoint as
// the @join/@leave binding, so this mirrors
// TurbineChatMembershipGateTests.JoinChannel_SetOptionBit_FlipsGateToAllowed_
// WithoutFreshPlayerDescription, but drives the write through the router's
// Settings-route command instead of calling RuntimeCharacterOptionsState
// directly.
[Fact]
public void SettingsRouteSetSingleCharacterOption_FlipsGateWithoutFreshPlayerDescription()
{
var characterState = new RuntimeCharacterState();
characterState.Options.Replace(characterState.Options.Options1, 0u); // every Options2 bit off — General starts refused
var turbine = new TurbineChatState();
turbine.OnChannelsReceived(
allegianceRoom: 0x10u,
generalRoom: 0x11u,
tradeRoom: 0x12u,
lfgRoom: 0x13u,
roleplayRoom: 0x14u,
olthoiRoom: 0x15u,
societyRoom: 0x16u,
societyCelestialHandRoom: 0u,
societyEldrytchWebRoom: 0u,
societyRadiantBloodRoom: 0u);
var sent = new List<(uint OptionId, bool Value)>();
// Mirrors LiveSessionRuntimeFactory.CreateCommandBindings' shared
// SendSingleCharacterOption local function: local write FIRST, then
// the wire send.
LiveSessionCommandRouter router = NewRouter(
characterState: characterState,
sendSingleCharacterOption: (id, value) =>
{
characterState.Options.SetOptionBit(id, value);
sent.Add((id, value));
});
router.Activate();
Assert.Equal(
TurbineChatGateStatus.NotListening,
TurbineChatMembershipGate.Evaluate(
ChatChannelKindLite.General, turbine, characterState.Options,
isOlthoiPlayer: false).Status);
router.Publish(new SetSingleCharacterOptionRuntimeCmd(
(uint)CharacterOptionId.ListenToGeneralChat, true));
Assert.Equal([((uint)CharacterOptionId.ListenToGeneralChat, true)], sent);
Assert.Equal(
TurbineChatGateStatus.Allowed,
TurbineChatMembershipGate.Evaluate(
ChatChannelKindLite.General, turbine, characterState.Options,
isOlthoiPlayer: false).Status);
}
[Fact]
public void ShowWeenieErrorFriendsFull_ResolvesThroughAddText_AndLandsInSpewBoxNotChat()
{
// CH2 re-review nit 7 (docs/plans/2026-08-09-chat-parity-campaign.md):
// exercises the FULL retail path for id 0x0561 (the 50-friends-cap
// refusal): ClientCommandController.AddFriend's cap guard calls
// ShowWeenieError(0x0561u); the binding below mirrors
// LiveSessionRuntimeFactory.CreateCommandBindings' ShowWeenieError
// closure exactly (WeenieErrorMessages.Resolve then
// RuntimeCommunicationState.AddText) — the same chokepoint that
// fans a ClientLocal line out to SpewBox instead of chat
// (RuntimeCommunicationStateTests pins that half in isolation
// already). This is "the closest seam" per the review: every other
// LiveSessionRuntimeFactory test in this tree is a source-text
// conformance grep (ReadSource), not an instantiation — the real
// factory needs a live WorldSession/domain graph no test builds.
var communication = new RuntimeCommunicationState();
ClientCommandController.Bindings bindings = NewClientBindings() with
{
Friends = communication.Friends,
ShowWeenieError = code =>
{
(string? text, RetailLogTextType type) = WeenieErrorMessages.Resolve(code, null);
if (text is not null)
communication.AddText(text, type);
},
};
LiveSessionCommandRouter router = NewRouter(clientBindings: bindings);
router.Activate();
// Seed 50 friends so AddFriend's cap guard fires
// (ClientCommandController.cs's AddFriend).
var seeded = new List<FriendEntry>();
for (uint i = 0; i < 50; i++)
seeded.Add(new FriendEntry(i + 1, $"Friend{i}", true, false, [], []));
communication.Friends.Apply(new FriendsUpdate(FriendsUpdateType.Full, seeded));
router.Publish(new ExecuteClientCommandCmd(ClientCommandId.FriendsAdd, "OneTooMany"));
communication.SpewBox.Tick(0d);
Assert.Equal(1, communication.SpewBox.Count);
Assert.Equal(
"You may only have a maximum of 50 friends at once. If you wish to add more friends, you must first remove some.",
communication.SpewBox.Snapshot()[0].Text);
Assert.Equal(0, communication.Chat.Count);
Assert.Equal(50, communication.Friends.Count);
}
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<ShortcutEntry>? addShortcut = null,
Action<string>? log = null,
ClientCommandController.Bindings? clientBindings = null,
RuntimeCommunicationState? communication = null,
RuntimeCharacterState? characterState = null,
Action<uint, bool>? sendSingleCharacterOption = null) => new(
new LiveSessionCommandBindings(
clientBindings ?? NewClientBindings(),
chat ?? new ChatLog(),
turbine ?? new TurbineChatState(),
playerGuid ?? (() => 0u),
sendTalk ?? (_ => { }),
sendTell ?? ((_, _) => { }),
sendChannel ?? ((_, _) => { }),
sendTurbine ?? ((_, _, _, _, _, _) => { }),
AddShortcut: addShortcut ?? (_ => { }),
RemoveShortcut: _ => { },
AddFavorite: (_, _, _) => { },
RemoveFavorite: (_, _) => { },
SetSpellbookFilter: _ => { },
ForgetSpell: _ => { },
SetDesiredComponent: (_, _) => { },
ClearDesiredComponents: () => { },
RaiseAttribute: (_, _) => { },
RaiseVital: (_, _) => { },
RaiseSkill: (_, _) => { },
TrainSkill: (_, _) => { },
AddFriend: _ => { },
RemoveFriend: _ => { },
ClearFriends: () => { },
RequestLegacyFriends: () => { },
ModifyCharacterSquelch: (_, _, _, _) => { },
ModifyAccountSquelch: (_, _) => { },
ModifyGlobalSquelch: (_, _) => { },
Communication: communication ?? new RuntimeCommunicationState(),
CharacterState: characterState ?? new RuntimeCharacterState(),
SendSingleCharacterOption: sendSingleCharacterOption ?? ((_, _) => { }),
Log: 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: (_, _) => { },
EnterPkLite: () => { },
IsUsingTurbineChat: () => false,
SetChatTitle: _ => { },
SetSingleCharacterOption: (_, _) => { },
AddPlayerPermission: _ => { },
RemovePlayerPermission: _ => { },
RequestAvailableHouses: _ => { },
RequestChannelIndex: () => { },
RequestChannelList: _ => { },
JoinGmChannel: _ => { },
LeaveGmChannel: _ => { },
RecallAllegianceHometown: () => { },
RequestAllegianceInfo: _ => { },
AbandonHouse: () => { });
}