fix(chat): CH4 re-review fixes — dialog-queue reentrancy, settings option-bit chokepoint

Should-fix 1: RetailDialogFactory.CloseDialog's queued branch removed the
active DialogInfo, ran DialogDone (whose callback can synchronously open a
new dialog under the SAME queue key — the two-stage house-abandon
confirmation does exactly this), then called OpenNextDialog, which did an
unconditional Dictionary.Add on a key the reentrant dialog had already
re-occupied. Retail's HashTable::add tolerates the duplicate; Dictionary
throws. OpenNextDialog now returns early when the queue key is already
active — the reentrant dialog's own eventual close drains the queue.

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-09 22:24:43 +02:00
parent 59c053ee47
commit 5d247d5518
10 changed files with 204 additions and 34 deletions

View file

@ -3,6 +3,7 @@ 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;
@ -437,6 +438,66 @@ public sealed class LiveSessionCommandRouterTests
Assert.Equal([(0x26u, true)], options);
}
// ── CH4 re-review SHOULD-FIX 2 (2026-08-09) ─────────────────────────
// The Settings Chat toggles reach this same SetSingleCharacterOptionRuntimeCmd
// route (RuntimeSettingsController.PublishHearOptionChange ->
// RuntimeSettingsTargets.SetSingleCharacterOption ->
// SetSingleCharacterOptionRuntimeCmd -> here -> bindings.SendSingleCharacterOption)
// as a second entrance alongside @join/@leave. LiveSessionRuntimeFactory now
// wires SendSingleCharacterOption through the SAME local-write chokepoint as
// the @join/@leave binding, so this mirrors
// TurbineChatMembershipGateTests.JoinChannel_SetOptionBit_FlipsGateToAllowed_
// WithoutFreshPlayerDescription, but drives the write through the router's
// Settings-route command instead of calling RuntimeCharacterOptionsState
// directly.
[Fact]
public void SettingsRouteSetSingleCharacterOption_FlipsGateWithoutFreshPlayerDescription()
{
var characterState = new RuntimeCharacterState();
characterState.Options.Replace(characterState.Options.Options1, 0u); // every Options2 bit off — General starts refused
var turbine = new TurbineChatState();
turbine.OnChannelsReceived(
allegianceRoom: 0x10u,
generalRoom: 0x11u,
tradeRoom: 0x12u,
lfgRoom: 0x13u,
roleplayRoom: 0x14u,
olthoiRoom: 0x15u,
societyRoom: 0x16u,
societyCelestialHandRoom: 0u,
societyEldrytchWebRoom: 0u,
societyRadiantBloodRoom: 0u);
var sent = new List<(uint OptionId, bool Value)>();
// Mirrors LiveSessionRuntimeFactory.CreateCommandBindings' shared
// SendSingleCharacterOption local function: local write FIRST, then
// the wire send.
LiveSessionCommandRouter router = NewRouter(
characterState: characterState,
sendSingleCharacterOption: (id, value) =>
{
characterState.Options.SetOptionBit(id, value);
sent.Add((id, value));
});
router.Activate();
Assert.Equal(
TurbineChatGateStatus.NotListening,
TurbineChatMembershipGate.Evaluate(
ChatChannelKindLite.General, turbine, characterState.Options,
isOlthoiPlayer: false).Status);
router.Publish(new SetSingleCharacterOptionRuntimeCmd(
(uint)CharacterOptionId.ListenToGeneralChat, true));
Assert.Equal([((uint)CharacterOptionId.ListenToGeneralChat, true)], sent);
Assert.Equal(
TurbineChatGateStatus.Allowed,
TurbineChatMembershipGate.Evaluate(
ChatChannelKindLite.General, turbine, characterState.Options,
isOlthoiPlayer: false).Status);
}
[Fact]
public void ShowWeenieErrorFriendsFull_ResolvesThroughAddText_AndLandsInSpewBoxNotChat()
{