fix(chat): CH4 review fixes — allegiance ownership guard, house-abandon confirmation

Blocker 1: an unrecognized "@allegiance <sub>" subcommand escaped
TryMatchAllegiance (which only claimed "info"/"hometown") and fell through
the unregistered-tag channel fallback, broadcasting the raw subcommand
text to the Allegiance chat channel (0x02000000). Retail's own
DoAllegiance never reaches DoChannelCommand for an unrecognized
subcommand — it claims the whole verb and prints its own client-local
refusal. TryMatchAllegiance now claims "allegiance"/"all" unconditionally
and shows retail's "Please see @help Allegiance..." text; ChatCommandRouter
also gained a blanket RetailClientCommandCatalog.KnownVerbs ownership
guard in TryDispatchChannelFallback as defense in depth.

Blocker 2: "@house abandon" sent 0x021F immediately with no confirmation.
Retail runs a real two-stage dialog before Event_AbandonHouse(); ported
both verbatim strings and chained two ShowConfirmation calls.

Should-fixes: a bare unregistered tag with no text now passes through
silently instead of showing a refusal that belongs to a different retail
function; @join/@leave update RuntimeCharacterOptionsState locally (new
SetOptionBit) before the wire push so the Turbine membership gate stops
refusing a just-joined room; @permit accepts multi-word names; @clist/
@on/@off validate shape only and raise WeenieError 0x422 for an unknown
tag; @mr/@pr help text is now the verbatim retail strings; corrected
issue #360, register row TS-68, the campaign doc's B.7 note, and a stale
RetailChannelTagTable comment; filed issue #363 + register row AP-183 for
the deferred error-typing debt.

Nits: fixed TryMatchHouse's stale doc comment, the AP-182/@title "stores
the value" comments (the binding is a no-op), IsUnregisteredFallbackTag's
olthoi false-positive, added /g and /rp binding-level conformance pins,
made @index ignore extra arguments, and noted the six removed invented
verbs in ISSUES.md.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-09 21:59:35 +02:00
parent 090825e703
commit 724ef2d389
17 changed files with 853 additions and 85 deletions

View file

@ -1,4 +1,5 @@
using AcDream.Core.Items;
using AcDream.Core.Net.Messages;
using AcDream.Core.Properties;
using AcDream.Core.Spells;
using AcDream.Core.Player;
@ -231,6 +232,60 @@ public sealed class RuntimeCharacterStateTests
Assert.Equal(-1, state.MovementSkills.JumpSkill);
}
// ── CH4 REJECT-review SHOULD-FIX 4 (2026-08-09) ────────────────────
[Theory]
[InlineData(CharacterOptionId.ListenToGeneralChat, PlayerDescriptionParser.CharacterOptions2.HearGeneralChat)]
[InlineData(CharacterOptionId.ListenToTradeChat, PlayerDescriptionParser.CharacterOptions2.HearTradeChat)]
[InlineData(CharacterOptionId.ListenToLFGChat, PlayerDescriptionParser.CharacterOptions2.HearLFGChat)]
[InlineData(CharacterOptionId.ListenToRoleplayChat, PlayerDescriptionParser.CharacterOptions2.HearRoleplayChat)]
[InlineData(CharacterOptionId.ListenToSocietyChat, PlayerDescriptionParser.CharacterOptions2.HearSocietyChat)]
public void SetOptionBit_Options2Ids_ToggleOnlyTheirOwnBit(
CharacterOptionId optionId, PlayerDescriptionParser.CharacterOptions2 bit)
{
var options = new RuntimeCharacterOptionsState();
options.Replace(options.Options1, 0u); // every Hear*Chat bit off
options.SetOptionBit((uint)optionId, true);
Assert.Equal((uint)bit, options.Options2 & (uint)bit);
Assert.Equal(RuntimeCharacterOptionsState.DefaultOptions1, options.Options1);
options.SetOptionBit((uint)optionId, false);
Assert.Equal(0u, options.Options2 & (uint)bit);
}
[Fact]
public void SetOptionBit_AllegianceId_TogglesOptions1NotOptions2()
{
var options = new RuntimeCharacterOptionsState();
options.Replace(0u, options.Options2); // HearAllegianceChat off
options.SetOptionBit((uint)CharacterOptionId.ListenToAllegianceChat, true);
Assert.Equal(
(uint)PlayerDescriptionParser.CharacterOptions1.HearAllegianceChat,
options.Options1 & (uint)PlayerDescriptionParser.CharacterOptions1.HearAllegianceChat);
options.SetOptionBit((uint)CharacterOptionId.ListenToAllegianceChat, false);
Assert.Equal(
0u,
options.Options1 & (uint)PlayerDescriptionParser.CharacterOptions1.HearAllegianceChat);
}
[Fact]
public void SetOptionBit_UnrecognizedId_IsANoOp()
{
var options = new RuntimeCharacterOptionsState();
uint before1 = options.Options1;
uint before2 = options.Options2;
long beforeRevision = options.Revision;
options.SetOptionBit(0xFFFFu, true);
Assert.Equal(before1, options.Options1);
Assert.Equal(before2, options.Options2);
Assert.Equal(beforeRevision, options.Revision);
}
// ── Campaign P Slice P1 (2026-07-30): burden/stamina/vitae-adjusted ───
// ── run/jump skill (pseudocode doc §9) ─────────────────────────────

View file

@ -207,6 +207,51 @@ public sealed class TurbineChatMembershipGateTests
Assert.Equal(expectedType, refusal.Value.Type);
}
// ── CH4 REJECT-review SHOULD-FIX 4 (2026-08-09) ────────────────────
// @join/@leave must update RuntimeCharacterOptionsState locally so
// this SAME-SESSION gate stops refusing without waiting on a fresh
// PlayerDescription (retail's PlayerModule::SetHear*Chat family
// writes the bit locally FIRST, then notifies).
[Fact]
public void JoinChannel_SetOptionBit_FlipsGateToAllowed_WithoutFreshPlayerDescription()
{
TurbineChatState turbine = ReceivedRooms();
var options = new RuntimeCharacterOptionsState();
options.Replace(options.Options1, 0u); // every Hear*Chat bit off — starts refused
Assert.Equal(
TurbineChatGateStatus.NotListening,
TurbineChatMembershipGate.Evaluate(
ChatChannelKindLite.General, turbine, options, isOlthoiPlayer: false).Status);
options.SetOptionBit((uint)CharacterOptionId.ListenToGeneralChat, true);
Assert.Equal(
TurbineChatGateStatus.Allowed,
TurbineChatMembershipGate.Evaluate(
ChatChannelKindLite.General, turbine, options, isOlthoiPlayer: false).Status);
}
[Fact]
public void LeaveChannel_SetOptionBit_FlipsGateToNotListening()
{
TurbineChatState turbine = ReceivedRooms();
var options = new RuntimeCharacterOptionsState(); // General on by default
Assert.Equal(
TurbineChatGateStatus.Allowed,
TurbineChatMembershipGate.Evaluate(
ChatChannelKindLite.General, turbine, options, isOlthoiPlayer: false).Status);
options.SetOptionBit((uint)CharacterOptionId.ListenToGeneralChat, false);
Assert.Equal(
TurbineChatGateStatus.NotListening,
TurbineChatMembershipGate.Evaluate(
ChatChannelKindLite.General, turbine, options, isOlthoiPlayer: false).Status);
}
private static TurbineChatState ReceivedRooms(
uint allegianceRoom = 0x10u,
uint generalRoom = 0x11u,