acdream/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs
Erik 86c0a7e0ee feat(runtime,net): Campaign OP slice OP1 — full character-option table, dirty model, real 0x01A1 blob builder
The retail Options panel (Campaign OP) needs a Runtime-owned option map
covering all 53 PlayerOption ids and the real batched SetCharacterOptions
(0x01A1) blob before any UI can be built on top of it. Today's surface only
modeled 6 ListenTo*Chat ids and the 0x01A1 builder was a malformed 16-byte
stub (deleted at Campaign CH slice CH3, docs/research/2026-08-09-chat-side-
channels-vs-ace.md).

- CharacterOptionTable.cs: the ONE typed table, PlayerOption id (0x00..0x34)
  -> (Options1/Options2 word, mask, IsAutoSave, ClientDefault), transcribed
  from acclient.h's verbatim CharacterOption/CharacterOptions2/PlayerOption
  enums and byte-verified against IsAutoSaveOption @0x0059A600 (the 21-id
  auto-save table) and GetDefaultOptionValue @0x005D2A30 (the Defaults-
  button table). Reconstructing CharacterOptions1/2 defaults from the
  ClientDefault column independently reproduces 0x50C4A54A / 0x00008700,
  cross-confirming the id-mask mapping. CharacterOptionId (SocialActions.cs)
  widened from 6 to all 53 ids to match.
- RuntimeCharacterOptionsState: SetOptionBit now resolves through the full
  table (was a 6-case switch). New TrySetOption is the ONE shared local-
  write-then-send/dirty seam — mirrors CPlayerModule::OnChanged exactly:
  write the bit locally first, then either send 0x0005 immediately (auto-
  save ids) or MarkDirty for the batched blob, no-op on an unchanged value
  (retail's own early-return) or an unmodeled id. New dirty model (IsDirty/
  FirstDirtiedAt/MarkDirty/TryFlush/TryFlushIfAutoSaveDue) uses an injected
  TimeProvider so it's fully unit-testable without a live clock.
- Both IRuntimeCharacterCommands.SetSingleOption adapters (Direct + Current)
  now route through TrySetOption instead of duplicating the write; this
  fixes the headless local-write gap the OP1 research flagged (the direct
  adapter previously sent the wire message without writing the bit first,
  same class of bug CH4 fixed for the graphical host). Both also reject an
  id outside the table instead of silently accepting it. LiveSessionRuntime
  Factory's SendSingleCharacterOption closure now delegates to the same
  seam instead of duplicating write-then-send inline.
- New IRuntimeCharacterCommands.SaveOptions(generation) — the explicit
  blob-flush verb (retail's SaveToServer(force: 0)) — wired end-to-end in
  both adapters, including a new SaveCharacterOptionsRuntimeCmd on the
  graphical router.
- SocialActions.BuildSetCharacterOptions + WorldSession.SendSetCharacterOptions:
  the real PlayerModule::Pack body per the wire research's field-by-field
  layout — header always 0x460 OR'd with 0x001/0x008 when shortcuts/desired
  comps are non-empty, favorite spells always 8 lists, never sets 0x100 or
  0x200. Echoes last-parsed shortcuts/favorites/desired-comps/spellbook
  filters (via new CharacterOptionsBlobSource) instead of zeroing them.
  Conformance: a hand-computed golden byte vector (not generated by the
  builder under test — the CH3 builder died of tests that pinned a wrong
  shape and looked green) plus a round-trip through PlayerDescriptionParser.

Contract deviation: the 480 s auto-save timer and the flush-before-logout
trigger are implemented as fully-tested pure state-machine logic
(TryFlushIfAutoSaveDue) but are NOT wired into either host's live per-frame
loop or graceful-shutdown sequence in this slice — only the explicit
SaveOptions verb is production-wired. Wiring the timer touches App's
UpdateFrameOrchestrator graph and Headless's tick loop (outside this
slice's Runtime/wire-layer scope); wiring logout risks the already-fragile
graceful-shutdown sequence CLAUDE.md flags. Filed as TS-71 per the plan's
own escape valve ("target: not deferred" with a register row if deferred).
Also filed: AP-193 (the 0x34 HearPKDeathMessages id/mask is ACE-sourced,
unverifiable against the 2013 binary) and AP-194 (GetDefaultOptionValue's
table disagrees with the constructor default for ConfirmVolatileRareUse/
ShowHelm/ShowCloak — retail's own quirk, reproduced not fixed).

Tests: table completeness x53, auto-save/client-default split pinned
id-by-id against the byte-verified tables, unknown/reserved-id rejection
(0x35/0x36 landmines), local-write-then-send on both adapters + the router,
the dirty/flush state machine, SaveOptions, and the wire golden vector +
PlayerDescriptionParser round-trip. Full Release suite: 12,745 passed / 4
skipped / 0 failed (baseline 12,611/4/0 — slice adds 134 passing tests,
zero regressions).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 23:31:34 +02:00

712 lines
28 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);
}
// ── OP1 (Campaign OP, 2026-08-10): SaveCharacterOptionsRuntimeCmd ────
[Fact]
public void SaveCharacterOptionsCommand_RoutesToBindingOnlyWhileActive()
{
int flushes = 0;
LiveSessionCommandRouter router = NewRouter(
saveCharacterOptions: () => flushes++);
router.Publish(new SaveCharacterOptionsRuntimeCmd());
router.Activate();
router.Publish(new SaveCharacterOptionsRuntimeCmd());
router.Dispose();
router.Publish(new SaveCharacterOptionsRuntimeCmd());
Assert.Equal(1, flushes);
}
// ── 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,
Action? saveCharacterOptions = 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 ?? ((_, _) => { }),
SaveCharacterOptions: saveCharacterOptions ?? (() => { }),
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: () => { });
}