acdream/tests/AcDream.App.Tests/UI/ClientCommandControllerTests.cs
Erik 090825e703 feat(chat): Campaign CH slice CH4 — command registry completion
Brings acdream's / and @ command parsing to parity with the complete
retail registry (130 registered verbs + 22 unregistered GetChannelID
fallback tags = 152 client-parsed verbs), per
docs/research/2026-08-09-chat-retail-command-registry.md.

Parser semantics (retail OnChatCommand/DoCommand):
- : and ; rewrite to "@emote <rest>" before dispatch.
- Verb trailing-comma trim ("@f, hi" == "@f hi") applied at every
  verb-lookup site in the catalog and the parser.
- @tell/aliases split the target on the FIRST COMMA, not the first
  whitespace token, so multi-word names work ("@tell Aunt Agatha, hi").
- The 22 unregistered GM/faction channel tags (admin, sentinel,
  celestialhand, ...) now broadcast for real via a new
  RetailChannelTagTable + SendRawChannelCmd bypass, reusing the existing
  BuildChatChannel wire builder.

Binding corrections:
- /g, /group, /party -> Fellowship (0x800), not General.
- /rp -> reply alias (retail's own help text confirms "@r or @rp"), not
  Roleplay; /role (an acdream invention) deleted.
- /allegiance, /all -> the allegiance management command
  (RetailClientCommandCatalog), not a channel verb.
- /house no longer swallows unrecognized subcommands with a local usage
  error; they now correctly fall through to ACE.
- @mr/@pr pinned as permanently non-executable (retail registers them
  with a null function pointer).

New verbs with real local execution: endurance, speaker, title (silent,
AP-182), chat, notell, join, leave, permit, hslist, index, clist, on,
off, alh/ah (+ "@allegiance hometown"/"ho"), "@allegiance info",
"@house abandon"; a missing-alias sweep across pkl/hou/message_types/
msgtypes/msg_types/rt/send/whisper/w/vassal/covassal/co-vassals/c/
fellows/group/party/guild/gu/cg/ct/clfg/crp/soc/o; the non-retail
inventions gen/cv/lookingforgroup/tr/role/h are deleted. New Core.Net
wire builders (IndexChannels, ListChannels, AddChannel, RemoveChannel,
RecallAllegianceHometown, AllegianceInfoRequest, ListAvailableHouses,
AddPlayerPermission, RemovePlayerPermission, AbandonHouse) are all
parameterless or single-field payloads cross-checked against ACE's
GameAction readers, not guessed.

Deferred (filed as #360/#361/#362, register rows TS-68/TS-69/TS-70):
the ~22 remaining allegiance/house subcommands + standalone @motd
(largest single item, needs its own slice per the doc), the three
still-inert pure-local commands (day/log/render), and the inbound
GameEvent responses for the new outbound requests. All correctly fall
through to ACE server-passthrough rather than being silently swallowed
or faking success.

RetailCommandRegistryConformanceTests pins the complete 152-verb
registry against production: every verb resolves through exactly one
production surface if Implemented, through none if HelpOnly/
ServerPassthrough, and two reverse-direction tests fail the build if
RetailClientCommandCatalog or ChatInputParser ever claims a verb
outside this registry again. Final tally: 138 Implemented / 5
ServerPassthrough / 9 HelpOnly = 152.

Release suite: 12,190 passed / 4 skipped / 0 failed (up from CH3's
11,964/4/0).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 21:10:17 +02:00

395 lines
14 KiB
C#

using AcDream.App.UI;
using AcDream.Core.Physics;
using AcDream.Core.Social;
using AcDream.UI.Abstractions;
namespace AcDream.App.Tests.UI;
public sealed class ClientCommandControllerTests
{
[Fact]
public void RecallAndQueryCommands_ExecuteTheirExactBindings()
{
var calls = new List<string>();
var controller = NewController(calls: calls);
Execute(ClientCommandId.LifestoneRecall);
Execute(ClientCommandId.MarketplaceRecall);
Execute(ClientCommandId.PkArenaRecall);
Execute(ClientCommandId.PkLiteArenaRecall);
Execute(ClientCommandId.HouseRecall);
Execute(ClientCommandId.MansionRecall);
Execute(ClientCommandId.QueryAge);
Execute(ClientCommandId.QueryBirth);
Assert.Equal(
["ls", "mp", "pka", "pla", "house", "mansion", "age", "birth"],
calls);
void Execute(ClientCommandId id) => controller.Execute(
new ExecuteClientCommandCmd(id, Arguments: string.Empty));
}
[Fact]
public void PkArena_NonPk_ShowsRetailFailureWithoutSending()
{
var calls = new List<string>();
var errors = new List<uint>();
var controller = NewController(calls, errors, playerBitfield: 0x8u);
controller.Execute(new ExecuteClientCommandCmd(
ClientCommandId.PkArenaRecall, string.Empty));
Assert.Empty(calls);
Assert.Equal([0x055Fu], errors);
}
[Fact]
public void PkLiteArena_NonPkLite_ShowsRetailFailureWithoutSending()
{
var calls = new List<string>();
var errors = new List<uint>();
var controller = NewController(calls, errors, playerBitfield: 0x8u);
controller.Execute(new ExecuteClientCommandCmd(
ClientCommandId.PkLiteArenaRecall, string.Empty));
Assert.Empty(calls);
Assert.Equal([0x0560u], errors);
}
[Fact]
public void EnterPkLite_NonPk_SendsTheRetailGameAction()
{
var calls = new List<string>();
var errors = new List<uint>();
var controller = NewController(calls, errors, playerBitfield: 0x8u);
controller.Execute(new ExecuteClientCommandCmd(
ClientCommandId.EnterPkLite, string.Empty));
Assert.Equal(["pklite"], calls);
Assert.Empty(errors);
}
[Fact]
public void EnterPkLite_AlreadyPk_ShowsRetailFailureWithoutSending()
{
var calls = new List<string>();
var errors = new List<uint>();
// 0x20 = ACCWeenieObject::IsPK bit only.
var controller = NewController(calls, errors, playerBitfield: 0x20u);
controller.Execute(new ExecuteClientCommandCmd(
ClientCommandId.EnterPkLite, string.Empty));
Assert.Empty(calls);
Assert.Equal([0x0507u], errors);
}
[Fact]
public void EnterPkLite_AlreadyPkLite_ShowsRetailFailureWithoutSending()
{
var calls = new List<string>();
var errors = new List<uint>();
// 0x2000000 = ACCWeenieObject::IsPKLite bit only.
var controller = NewController(calls, errors, playerBitfield: 0x2000000u);
controller.Execute(new ExecuteClientCommandCmd(
ClientCommandId.EnterPkLite, string.Empty));
Assert.Empty(calls);
Assert.Equal([0x0507u], errors);
}
[Fact]
public void EnterPkLite_UnknownPlayerDescription_SendsRatherThanRejects()
{
// Tri-state decision: retail's DoPKLite only rejects when
// IsPlayerKiller() is TRUE. An indeterminate PublicWeenieDesc (not
// yet arrived) must send, not reject -- the opposite direction from
// the arena recalls, which gate on the flag being known-true.
var calls = new List<string>();
var errors = new List<uint>();
var controller = NewController(calls, errors, playerBitfield: null);
controller.Execute(new ExecuteClientCommandCmd(
ClientCommandId.EnterPkLite, string.Empty));
Assert.Equal(["pklite"], calls);
Assert.Empty(errors);
}
[Fact]
public void MissingPlayerDescription_DoesNotInventAClientRejection()
{
var calls = new List<string>();
var controller = NewController(calls, playerBitfield: null);
controller.Execute(new ExecuteClientCommandCmd(
ClientCommandId.PkArenaRecall, string.Empty));
Assert.Equal(["pka"], calls);
}
[Fact]
public void LocalPresentationCommands_UseApplicationServices()
{
var calls = new List<string>();
var messages = new List<string>();
var controller = NewController(calls, messages: messages);
controller.Execute(new ExecuteClientCommandCmd(ClientCommandId.ToggleFrameRate, ""));
controller.Execute(new ExecuteClientCommandCmd(ClientCommandId.ToggleUiLock, ""));
controller.Execute(new ExecuteClientCommandCmd(ClientCommandId.ShowVersion, ""));
Assert.Equal(["fps", "lock"], calls);
Assert.Equal(["Client version 1.2.3"], messages);
}
[Fact]
public void Die_RequiresRetailConfirmationBeforeSuicide()
{
var calls = new List<string>();
var controller = NewController(calls);
controller.Execute(new ExecuteClientCommandCmd(ClientCommandId.Die, ""));
Assert.Equal(
[
"confirm:Do you really want to kill your character? You may drop items and accrue a vitae penalty.",
"suicide",
],
calls);
}
[Fact]
public void ChatAndLayoutCommands_UseNamedAndAutomaticPersistenceBindings()
{
var calls = new List<string>();
var messages = new List<string>();
var controller = NewController(calls, messages: messages);
Execute(ClientCommandId.ClearChat, "all");
Execute(ClientCommandId.SaveUi, "hunt");
Execute(ClientCommandId.LoadUi, "hunt");
Execute(ClientCommandId.SaveAutoUi, string.Empty);
Execute(ClientCommandId.LoadAutoUi, string.Empty);
Execute(ClientCommandId.SaveUi, "this-name-is-over-16-characters");
Assert.Equal(
["clear:True", "saveui:hunt", "loadui:hunt", "saveautoui", "loadautoui"],
calls);
Assert.Equal(["The file name must be 16 characters or less."], messages);
void Execute(ClientCommandId id, string arguments) =>
controller.Execute(new ExecuteClientCommandCmd(id, arguments));
}
[Fact]
public void AwayCommands_RespectCurrentModeAndPackRetailMessageForm()
{
var calls = new List<string>();
var messages = new List<string>();
var controller = NewController(calls, messages: messages, isAway: false);
controller.Execute(new ExecuteClientCommandCmd(ClientCommandId.Away, "on"));
controller.Execute(new ExecuteClientCommandCmd(ClientCommandId.Away, "msg Stepped away"));
Assert.Equal(["afk:True", "afkmsg:Stepped away\n"], calls);
Assert.Equal(["New AFK message set: Stepped away\n"], messages);
calls.Clear();
controller = NewController(calls, isAway: true);
controller.Execute(new ExecuteClientCommandCmd(ClientCommandId.Away, "off"));
Assert.Equal(["afk:False"], calls);
}
[Fact]
public void ConsentAndEmoteCommands_RouteTheirExactActions()
{
var calls = new List<string>();
var controller = NewController(calls, acceptsLootPermits: false);
Execute(ClientCommandId.Consent, "on");
Execute(ClientCommandId.Consent, "who");
Execute(ClientCommandId.Consent, "clear");
Execute(ClientCommandId.Consent, "remove Alice Example");
Execute(ClientCommandId.Emote, "waves happily");
Assert.Equal(
[
"consentmode:True",
"consentwho",
"consentclear",
"consentremove:Alice Example",
"emote:waves happily",
],
calls);
void Execute(ClientCommandId id, string arguments) =>
controller.Execute(new ExecuteClientCommandCmd(id, arguments));
}
[Fact]
public void FriendsCommands_ReadAuthoritativeStateAndSendObjectId()
{
var friends = new FriendsState();
friends.Apply(new FriendsUpdate(
FriendsUpdateType.Full,
[
new FriendEntry(0x50000001u, "Alice", true, false, [], []),
new FriendEntry(0x50000002u, "Bjørn", false, false, [], []),
]));
var calls = new List<string>();
var messages = new List<string>();
var controller = NewController(calls, messages: messages, friends: friends);
Execute(ClientCommandId.Friends, "online");
Execute(ClientCommandId.FriendsRemove, "alice");
Execute(ClientCommandId.FriendsAdd, "Cara");
Execute(ClientCommandId.Friends, "old");
Assert.Contains("Alice (Online)", messages.Single());
Assert.DoesNotContain("Bjørn", messages.Single());
Assert.Equal(
["friendremove:1342177281", "friendadd:Cara", "friendsold"],
calls);
void Execute(ClientCommandId id, string arguments) =>
controller.Execute(new ExecuteClientCommandCmd(id, arguments));
}
[Fact]
public void SquelchAndFilterCommands_ParseRetailOptions()
{
var calls = new List<string>();
var controller = NewController(calls, lastTeller: "Recent Teller");
Execute(ClientCommandId.Squelch, "-account Alice");
Execute(ClientCommandId.Unsquelch, "-reply");
Execute(ClientCommandId.Squelch, "-Magic Caster Name");
Execute(ClientCommandId.Filter, "-Combat_Self");
Execute(ClientCommandId.Unfilter, "-Tell");
Assert.Equal(
[
"accountsquelch:True:Alice",
"charsquelch:False:0:Recent Teller:1",
"charsquelch:True:0:Caster Name:7",
"globalsquelch:True:22",
"globalsquelch:False:3",
],
calls);
void Execute(ClientCommandId id, string arguments) =>
controller.Execute(new ExecuteClientCommandCmd(id, arguments));
}
[Fact]
public void FillComponents_ClearWorksWithoutVendorAndBuyingRequiresOne()
{
var calls = new List<string>();
var messages = new List<string>();
var controller = NewController(calls, messages: messages, vendorOpen: false);
controller.Execute(new ExecuteClientCommandCmd(ClientCommandId.FillComponents, "clear"));
controller.Execute(new ExecuteClientCommandCmd(ClientCommandId.FillComponents, "scarabs 500"));
Assert.Equal(["clearcomps"], calls);
Assert.Equal(["Component list cleared.", "You need an open vendor."], messages);
}
[Fact]
public void UnknownCommandId_FailsAtApplicationBoundary()
{
var controller = NewController();
var command = new ExecuteClientCommandCmd((ClientCommandId)999, string.Empty);
Assert.Throws<ArgumentOutOfRangeException>(() => controller.Execute(command));
}
private static ClientCommandController NewController(
List<string>? calls = null,
List<uint>? errors = null,
uint? playerBitfield = 0x02000028u,
List<string>? messages = null,
bool isAway = false,
bool acceptsLootPermits = true,
FriendsState? friends = null,
SquelchState? squelch = null,
string? lastTeller = null,
bool vendorOpen = false)
{
calls ??= [];
errors ??= [];
messages ??= [];
return new ClientCommandController(new ClientCommandController.Bindings(
() => calls.Add("ls"),
() => calls.Add("mp"),
() => calls.Add("pka"),
() => calls.Add("pla"),
() => calls.Add("house"),
() => calls.Add("mansion"),
() => calls.Add("age"),
() => calls.Add("birth"),
() => calls.Add("fps"),
() => calls.Add("lock"),
messages.Add,
errors.Add,
() => playerBitfield,
() => "1.2.3",
() => new Position(
0xA9B40001u,
new CellFrame(new System.Numerics.Vector3(1f, 2f, 3f),
System.Numerics.Quaternion.Identity)),
() => null,
(message, completed) =>
{
calls.Add($"confirm:{message}");
completed(true);
},
() => calls.Add("suicide"),
all => calls.Add("clear:" + all),
name => calls.Add("saveui:" + name),
name => calls.Add("loadui:" + name),
() => calls.Add("saveautoui"),
() => calls.Add("loadautoui"),
() => isAway,
away => calls.Add("afk:" + away),
message => calls.Add("afkmsg:" + message),
() => acceptsLootPermits,
enabled => calls.Add("consentmode:" + enabled),
() => calls.Add("consentwho"),
() => calls.Add("consentclear"),
name => calls.Add("consentremove:" + name),
message => calls.Add("emote:" + message),
friends ?? new FriendsState(),
name => calls.Add("friendadd:" + name),
id => calls.Add("friendremove:" + id),
() => calls.Add("friendsclear"),
() => calls.Add("friendsold"),
squelch ?? new SquelchState(),
(add, id, name, type) => calls.Add($"charsquelch:{add}:{id}:{name}:{type}"),
(add, name) => calls.Add($"accountsquelch:{add}:{name}"),
(add, type) => calls.Add($"globalsquelch:{add}:{type}"),
() => lastTeller,
() => calls.Add("clearcomps"),
() => vendorOpen,
(category, price) => calls.Add($"fillcomps:{category}:{price}"),
() => calls.Add("pklite"),
() => false,
title => calls.Add("title:" + title),
(optionId, value) => calls.Add($"charoption:{optionId}:{value}"),
name => calls.Add("permitadd:" + name),
name => calls.Add("permitremove:" + name),
houseType => calls.Add("hslist:" + houseType),
() => calls.Add("index"),
channelId => calls.Add("clist:" + channelId),
channelId => calls.Add("on:" + channelId),
channelId => calls.Add("off:" + channelId),
() => calls.Add("alh"),
name => calls.Add("alleginfo:" + name),
() => calls.Add("houseabandon")));
}
}