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:
parent
59c053ee47
commit
5d247d5518
10 changed files with 204 additions and 34 deletions
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -303,9 +303,12 @@ public sealed class ClientCommandControllerTests
|
|||
// ── CH4 REJECT-review Blocker 2 (2026-08-09) ────────────────────────
|
||||
// "@house abandon" must run retail's real two-stage confirmation
|
||||
// (DoHouse's abandon branch @0x00580D58 → HouseAbandonDialogCallback_
|
||||
// First @0x00580E1A → HouseAbandonDialogCallback_Second @0x0057BE90,
|
||||
// First, function entry @0x00580240 (0x00580E1A is only DoHouse's load
|
||||
// site for the callback pointer), whose own stage-2 string site
|
||||
// @0x005802D8 opens HouseAbandonDialogCallback_Second @0x0057BE90,
|
||||
// the ONLY Event_AbandonHouse() call site @0x0057BF01) before sending
|
||||
// 0x021F — previously it sent immediately with no confirmation at all.
|
||||
// Citations corrected 2026-08-09, CH4 re-review nit 4.
|
||||
|
||||
[Fact]
|
||||
public void HouseAbandon_BothStagesAccepted_ShowsBothPromptsThenSendsExactlyOnce()
|
||||
|
|
@ -398,24 +401,26 @@ public sealed class ClientCommandControllerTests
|
|||
controller.Execute(new ExecuteClientCommandCmd(id, arguments));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChannelArgumentCommands_KnownTag_SendsWithoutError()
|
||||
[Theory]
|
||||
[InlineData(ClientCommandId.ListChannel, "fellowship", "clist:2048")]
|
||||
[InlineData(ClientCommandId.OnChannel, "admin", "on:2")]
|
||||
[InlineData(ClientCommandId.OffChannel, "sentinel", "off:512")]
|
||||
// CH4 re-review nit 5 (2026-08-09): pins "@clist allegiance" to
|
||||
// RequestChannelList(0x02000000) — retail's real Allegiance channel id
|
||||
// (RetailChannelTagTable's "allegiance"/"a"/"ab" entries), distinct
|
||||
// from the small legacy-channel ids the rows above cover.
|
||||
[InlineData(ClientCommandId.ListChannel, "allegiance", "clist:33554432")]
|
||||
public void ChannelArgumentCommands_KnownTag_SendsWithoutError(
|
||||
ClientCommandId id, string tag, string expectedCall)
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var errors = new List<uint>();
|
||||
var controller = NewController(calls, errors);
|
||||
|
||||
Execute(ClientCommandId.ListChannel, "fellowship");
|
||||
Execute(ClientCommandId.OnChannel, "admin");
|
||||
Execute(ClientCommandId.OffChannel, "sentinel");
|
||||
controller.Execute(new ExecuteClientCommandCmd(id, tag));
|
||||
|
||||
Assert.Empty(errors);
|
||||
Assert.Equal(
|
||||
["clist:2048", "on:2", "off:512"],
|
||||
calls);
|
||||
|
||||
void Execute(ClientCommandId id, string arguments) =>
|
||||
controller.Execute(new ExecuteClientCommandCmd(id, arguments));
|
||||
Assert.Equal([expectedCall], calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -255,6 +255,51 @@ public sealed class RetailDialogFactoryTests
|
|||
Assert.Empty(root.Children);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AcceptingChainedReentrantDialogDoesNotThrowAndDrainsQueueAfterChainCompletes()
|
||||
{
|
||||
// CH4 re-review should-fix 1: a callback that synchronously reopens a new
|
||||
// dialog under the SAME queue key (retail's two-stage house-abandon
|
||||
// confirmation) used to crash OpenNextDialog with a duplicate-key
|
||||
// ArgumentException whenever a third confirmation was already queued
|
||||
// behind the chain. See RetailDialogFactory.OpenNextDialog.
|
||||
var root = new UiRoot { Width = 800f, Height = 600f };
|
||||
var layouts = new List<ImportedLayout>();
|
||||
var factory = CreateFactory(root, layouts);
|
||||
var completed = new List<string>();
|
||||
|
||||
factory.MakeConfirmation("stage1", _ =>
|
||||
{
|
||||
completed.Add("stage1");
|
||||
factory.MakeConfirmation("stage2", _ => completed.Add("stage2"));
|
||||
});
|
||||
factory.MakeConfirmation("queued behind chain", _ => completed.Add("queued"));
|
||||
|
||||
Assert.Equal(1, factory.PendingCount);
|
||||
|
||||
Exception? thrown = Record.Exception(() => Accept(layouts[0]));
|
||||
|
||||
Assert.Null(thrown);
|
||||
Assert.Equal(["stage1"], completed);
|
||||
Assert.Equal(2, layouts.Count);
|
||||
Assert.Equal("stage2", Message(layouts[1]));
|
||||
Assert.Same(layouts[1].Root, root.Modal);
|
||||
Assert.Equal(1, factory.PendingCount);
|
||||
|
||||
Accept(layouts[1]);
|
||||
|
||||
Assert.Equal(["stage1", "stage2"], completed);
|
||||
Assert.Equal(3, layouts.Count);
|
||||
Assert.Equal("queued behind chain", Message(layouts[2]));
|
||||
Assert.Same(layouts[2].Root, root.Modal);
|
||||
Assert.Equal(0, factory.PendingCount);
|
||||
|
||||
Accept(layouts[2]);
|
||||
|
||||
Assert.Equal(["stage1", "stage2", "queued"], completed);
|
||||
Assert.False(factory.IsOpen);
|
||||
}
|
||||
|
||||
private static RetailDialogFactory CreateFactory(
|
||||
UiRoot root,
|
||||
List<ImportedLayout> layouts)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue