Ports retail's SendTurbineChat (@0x0057db10) local pre-send membership gate so Roleplay/Society/Olthoi stop silently swallowing outbound chat: a new TurbineChatMembershipGate checks Turbine availability and the player's own Hear*Chat option before sending, raising "Turbine chat is not available." or the 0x0551 YouAreNotListeningTo_Channel refusal through the CH2 AddText chokepoint instead. Wired into both the graphical (LiveSessionCommandRouter) and headless (DirectGameRuntimeCommandAdapter) send paths so they can't diverge. Retracts the 26-day-old false "ACE doesn't run a TurbineChat server" claim from ISSUES.md, the roadmap, and project_chat_pipeline.md — ACE's TurbineChat implementation is complete and on by default; the real bug was treating Hear*Chat as a display filter instead of room membership. Also: implements SetSingleCharacterOption (0x0005), the only wire message that actually joins/leaves a Turbine room, and wires the five Settings Chat toggles to it (publish on Save, changed bits only) plus seeds ChatSettings from the server's own CharacterOptions2 on every PlayerDescription. Fixes the legacy-channel double-print (Fellow/Vassals/Patron/Monarch/CoVassals skip the local echo now that ChatChannelInfo.IsSelfEchoChannel is finally consulted). Routes /a to Turbine unconditionally (retail's @a never falls back to the legacy bitflag) and adds /ab for the legacy AllegianceBroadcast verb retail actually has. Surfaces a nonzero TurbineChat ack HResult instead of discarding it silently. Deletes the malformed, callerless SetCharacterOptions (0x01A1) and AddChannel/RemoveChannel (0x0145/0x0146) builders. Files every AC-specific algorithm change cites the named retail decomp (SendTurbineChat 0x0057db10, StartupTurbineChatSystem 0x0057EFB0, GameActionSetSingleCharacterOption) plus ACE/holtburger cross-checks. Register rows AP-181 (no client-side spam throttle) and UN-9 (an incidentally-discovered CharacterOptions1.Default literal mismatch, not investigated further) filed per the divergence-register rule. 11,957 passed / 4 skipped / 0 failed (full Release suite, up from the 11,916/4/0 baseline). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
576 lines
22 KiB
C#
576 lines
22 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.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 AllegianceWithNoRoom_RefusesLocally_NeverDowngradesToLegacyChannel()
|
|
{
|
|
// The CH3 headline /a bug (research doc §5.3): retail's @a is bound
|
|
// unconditionally to Turbine — no allegiance must refuse locally,
|
|
// NOT silently fall through to the legacy AllegianceBroadcast
|
|
// bitflag.
|
|
var communication = new RuntimeCommunicationState();
|
|
var legacySent = new List<(uint Id, string Text)>();
|
|
var turbineSent = new List<string>();
|
|
var router = NewRouter(
|
|
chat: communication.Chat,
|
|
communication: communication,
|
|
turbine: new TurbineChatState(), // AllegianceRoom stays 0
|
|
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 AllegianceBroadcast_AbVerbChannel_RoutesLegacyWithSelfEcho()
|
|
{
|
|
// /ab (retail DoAllegianceBroadcast) rides the legacy 0x0147 pipe —
|
|
// distinct from /a, which is always Turbine now.
|
|
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);
|
|
Assert.Collection(
|
|
chat.Snapshot(),
|
|
entry =>
|
|
{
|
|
Assert.Equal(ChatKind.Channel, entry.Kind);
|
|
Assert.Equal("Allegiance", entry.ChannelName);
|
|
});
|
|
}
|
|
|
|
// ── 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_KeepsLocalOptimisticEcho()
|
|
{
|
|
var chat = new ChatLog();
|
|
var router = NewRouter(
|
|
chat: chat,
|
|
sendChannel: (_, _) => { });
|
|
router.Activate();
|
|
|
|
router.Publish(new SendChatCmd(
|
|
ChatChannelKind.AllegianceBroadcast, null, "hi"));
|
|
|
|
Assert.Equal(1, chat.Count); // real-name broadcast includes the
|
|
// sender — no separate "" echo, so the
|
|
// client keeps its own.
|
|
}
|
|
|
|
[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);
|
|
}
|
|
|
|
[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: () => { });
|
|
}
|