acdream/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs
Erik 09029f9f4b fix(runtime,net): OP1 review fixes — server-seed gate, tick-wired auto-save/logout flush, fellowship mutual exclusion
Closes the two mechanism-lens and blast-lens dual reviews of Campaign OP
slice OP1 (86c0a7e0): docs/research/2026-08-10-op1-review-mechanism.md and
docs/research/2026-08-10-op1-review-blast.md.

MUST-FIX M1 (blast): RuntimeCharacterOptionsState gains a HasServerSeed
latch, set by Replace (the PlayerDescription seed) and cleared by
ResetSession. TryFlush/TryFlushIfAutoSaveDue now refuse before the seed
arrives — closing the window where a bot (or, after this commit, the
timer/logout triggers) could flush client-default option words over a
character's real server-side options before any PlayerDescription ever
landed.

MUST-FIX 1 (mechanism): the 480 s auto-save timer and the pre-logoff
flush are now wired into production, closing TS-71 (retired). Both ride
LiveSessionController's own tick/stop transaction via two new hooks
(ConfigureAutoSaveTick/ConfigurePreLogoffFlush), wired once by
GameRuntime's constructor — a Runtime-internal change requiring zero
host edits, exactly as the review identified. The flush body talks to
WorldSession directly rather than through App's LiveSessionCommandRouter,
which is what keeps this off the S2 lock-order hazard (below). Filed
TS-73 for the two OnChanged side-effect cases (weather/day/combat-
target/fog) TrySetOption still doesn't model — pre-anchored to OP4's
Group B consumer binds.

SHOULD-FIX S2 (blast, prerequisite for MUST-FIX 1): TryFlush/
TryFlushIfAutoSaveDue no longer invoke the flush callback while holding
_dirtyGate — the decision is made and cleared under the lock, but the
callback itself runs outside it, closing the lock-inversion hazard the
natural timer wiring would have hit (Runtime tick's _dirtyGate-then-
_gate vs the router's _gate-then-_dirtyGate).

SHOULD-FIX MF-2 (mechanism): TrySetOption now ports the two
PlayerModule-state-mutating cases of CPlayerModule::OnChanged's local
side-effect switch — turning ON IgnoreFellowshipRequests or
FellowshipAutoAcceptRequests clears the other through a real recursive
TrySetOption call, reproducing retail's second 0x0005 (the clear's send
reaches the wire before the primary option's own send, matching the
nested-call order in the decomp). The signature widened from
Action sendAutoSave to Action<uint,bool> so the recursion can send a
different (id, value) than the caller's own; every production call site
now passes WorldSession.SendSetSingleCharacterOption directly.

SHOULD-FIX MF-3 (mechanism): a hand-transcribed 53-row (id, isOptions1,
mask) theory in CharacterOptionTableTests, independently re-derived from
acclient.h's PlayerOption/CharacterOption/CharacterOptions2 enums rather
than copied from CharacterOptionTable.cs — closes the one column with no
id-by-id pin. Also added the pairwise-distinctness check blast NOTE N7
named.

SHOULD-FIX S1 (blast): LiveSessionCommandRouterTests' CH3/CH4 regression
test now drives the REAL TrySetOption binding instead of a hand-rolled
SetOptionBit substitute that had silently drifted from production after
OP1.

SHOULD-FIX S3 (blast): RuntimeCharacterOwnershipSnapshot gains
OptionsAreClean (!Options.IsDirty), included in IsConverged — a module
whose two words happen to cycle back to their default bit pattern while
still dirty is now caught by the combined ownership ledger, not just by
OptionsAreDefaults.

SHOULD-FIX S4 (blast): SaveOptions no longer encodes "did it actually
flush" as PrimaryObjectId 1u/0u (which read as object guid 0x00000001 in
the K2 event stream). Both host adapters now report the identical shape
(Accepted, objectId 0) — the graphical host never could report this
anyway (LiveCommandBus.Publish has no return channel).

SHOULD-FIX S5 (blast): Replace (the server-seed arrival) now also clears
IsDirty/FirstDirtiedAt — a wholesale re-seed supersedes any pending
batched-but-unflushed local intent (retail's own PlayerModule has no
partial-merge path either), documented at the member.

SHOULD-FIX S6 (blast): a cross-check theory asserting CharacterOptionTable's
masks equal PlayerDescriptionParser.CharacterOptions1/2's independently
(the write path vs the read path TurbineChatMembershipGate/
RuntimeSettingsController consume) — guards the exact CH3 failure class.

Also fixed a real allocation regression found while landing MUST-FIX 1:
the naive per-tick flush closure would have allocated on EVERY
LiveSessionController.Tick() call regardless of dirty state, which broke
the K4 headless 30-session resource-envelope gate. GameRuntime.
FlushCharacterOptions now pre-checks Options.IsDirty (itself retail-
faithful — CPlayerModule::UseTime opens with the identical m_bDirty byte
compare) before allocating the flush closure, so the allocation only
happens on the rare tick that might actually flush.

Dispositions on findings not changed this round:
- Mechanism NOTE 6 / not independently re-flagged: a re-entrant MarkDirty
  from inside a flush callback can still be erased by the trailing
  "_isDirty = false" — pre-existing, unchanged by the S2 lock restructure
  (same outcome whether the callback runs inside or outside the lock),
  not reachable from any current caller, not a one-liner to close
  correctly (needs a per-dirty-period generation token). Left as documented
  in the review; worth closing before the Options panel ever flushes from
  inside a change handler.
- Mechanism NOTE 9, blast N2/N3/N4/N5/N6/N8: informational or require
  touching files this round doesn't otherwise edit (SocialActions.cs,
  CharacterOptionsBlobSource.cs, GameRuntimeContractTests.cs) — left per
  the "one-liner in a file already being edited" instruction.

Register: TS-71 retired (both remaining SetCharacterOptions flush
triggers now production-wired); TS-73 filed (the two unmodeled OnChanged
presentation-binding cases, pre-anchored to OP4).

Quality bar: Release build green; full solution suite 12,853 passed / 4
skipped / 0 failed (baseline 12,770/4/0 post-OP2 — 83 new tests added,
zero regressions).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 00:39:51 +02:00

718 lines
29 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)>();
// S1 (Campaign OP OP1 review fix, blast lens, 2026-08-11): drives the
// REAL production binding — RuntimeCharacterOptionsState.
// TrySetOption, the SAME shared local-write-then-send/dirty seam
// LiveSessionRuntimeFactory.CreateCommandBindings' SendSingleCharacterOption
// local function calls — instead of a hand-rolled substitute that had
// silently drifted from it after OP1 (the previous shape called
// SetOptionBit directly, which does not run TrySetOption's
// unchanged-value early return or its MF-2 fellowship
// mutual-exclusion side effect).
LiveSessionCommandRouter router = NewRouter(
characterState: characterState,
sendSingleCharacterOption: (id, value) =>
characterState.Options.TrySetOption(
id,
value,
sendAutoSave: (sentId, sentValue) => sent.Add((sentId, sentValue))));
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: () => { });
}