Applies the Opus review of Campaign CH slice CH3 (614a1e05):
- B1: UN-9 was a phantom divergence — ACE's CharacterOptions1.cs:47
OR-sum is 0x50C4A54A (its own comment confirms 1355064650), identical
to acdream's literal. The wrong 0x50C48D4A existed only in the research
doc. Row deleted, register §5 reverted to 4 rows, research doc corrected
with dated notes.
- S1/S4: AllegianceBroadcast (0x02000000) is a server-echoing channel —
ACE's GameActionChatChannel handler includes the sender in its real-name
Allegiance.Members broadcast (retail's DoAllegianceBroadcast has no
AddTextToScroll), so the client must skip its local optimistic echo, not
keep it. ChatChannelInfo.Legacy.IsSelfEchoChannel() now returns true for
it; RouteLegacyChannel's comment corrected; Turbine.IsSelfEchoChannel()'s
backwards comment rewritten truthfully.
- S3: retail's /a stays on the legacy AllegianceBroadcast bitflag until
StartupTurbineChatSystem successfully starts Turbine chat — "never
started" (TurbineChatState.Enabled == false) now falls back to legacy in
both LiveSessionCommandRouter.RouteChat and
DirectGameRuntimeCommandAdapter.TrySendChannel, while "enabled but no
allegiance room" still correctly refuses locally.
- S5: added a LiveSessionEventRouter test proving the Options.Replace ->
OnCharacterOptionsChanged seeding order, and RuntimeSettingsTargets /
GameWindowLiveSessionOwnershipTests tests proving the concrete
ICommandBus.Publish wiring and the single LiveSessionCommandSurface
construction site.
- S6: AP-181 rewritten to name both of retail's omitted pre-send checks
(IsMessageSafe silent-drop, then IsMessageSpam) and stop misattributing
either to RouteLegacyChannel, which has no such gates.
- N1-N7: CharacterOptionId moved below SocialActions so its doc comment
re-attaches; TurbineChatMembershipGate reuses TurbineChatDisplayNames
instead of a duplicate table; the gate-to-refusal-text mapping is now
shared via TurbineChatMembershipGate.ResolveRefusalText instead of
duplicated in both hosts; ChatSettings.Default now matches ACE's real
CharacterOptions2.Default (Roleplay/Society start off); a doc-comment
clarifies only the five Hear toggles are server-backed; the register's
§3 header recounted 129 -> 128.
Suite: 11,964 passed / 4 skipped / 0 failed (baseline 11,957/4/0 + 7 new
tests). Campaign ledger CH3 review column updated to APPROVE-WITH-FIXES.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
618 lines
24 KiB
C#
618 lines
24 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 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);
|
|
}
|
|
|
|
[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: () => { });
|
|
}
|