CT-B4 was filed as "the plain-text session chat log, path and rotation UNKNOWN, needs a live check." Both unknowns dissolve once you read the handler: there is no automatic session log. Retail's @log is a COMMAND. DoSetOutput @0x0057E4F0 takes a filename, StartCopyOutputToFile @0x0057C8A0 does the fopen(name, "a+"), and running it again with no argument closes it. Nothing rotates because it appends forever, and nothing has a fixed path because the player names the file. The path question that DOES exist — where a bare name lands — was answered all along by retail's own help text, which CH4 extracted verbatim into our help table a fortnight ago and nobody read: "a log file named Aclog.txt in your Asheron's Call directory." A blocked question sat on top of a committed answer. We cannot use the install directory: the launcher replaces it atomically on update, so a log written there is wiped by the next update or blocks it. The client's own log directory is the equivalent that survives. Rooted paths are honoured verbatim, as retail's fopen would. Register CT-5. The verb was registered in the help table but NOT in the command catalog, so /log printed help and did nothing — and the CH4 conformance registry recorded it as a "server passthrough" precisely because that shape is indistinguishable from an unimplemented client command. It never went on the wire at all. Both are corrected, with the totals moved in the same commit rather than left to drift. Moving it into the catalog also moves which help table answers for it, so retail's real text moved to the catalog-verb table in the same change. Without that, /help log would have silently started printing acdream's own invented one-line summary — caught by the coverage test, and now pinned by a test that names the text. All five replies are byte-decoded from the PDB-paired binary rather than read off Binary Ninja's previews, which truncate at ~33 characters and would have lost the second half of every one of them (including the two spaces retail puts after "Copying chat to %s."). The writer attaches on OPEN, not at startup — retail's help is explicit that only what appears after the command is copied — and detaches from the transcript it actually attached to, so a session teardown cannot leave a live handler writing into a file the player believes is closed. What gets written is the composed display line with the shared timestamp, because retail's fprintf sits inside AddTextToScroll: downstream of composition, upstream of glyph layout. Logging the raw entry text would have produced a file of bare fragments with no speakers. acdream's logs carry no inline tag markup where retail's do, since tags live beside the text as spans here rather than inside it. Registered as CT-6 rather than reconstructed purely to write it to a file. Register: CT-5, CT-6. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
309 lines
14 KiB
C#
309 lines
14 KiB
C#
using AcDream.UI.Abstractions;
|
|
using AcDream.UI.Abstractions.Panels.Chat;
|
|
|
|
namespace AcDream.UI.Abstractions.Tests.Panels.Chat;
|
|
|
|
/// <summary>
|
|
/// Campaign CH slice CH4 (2026-08-09): pins acdream's complete chat-command
|
|
/// surface against the retail registry enumeration in
|
|
/// <c>docs/research/2026-08-09-chat-retail-command-registry.md</c> §2/§4 —
|
|
/// 130 verbs registered by <c>InitializeCommands</c> +
|
|
/// <c>StartupTurbineChatSystem</c>, plus the 22 unregistered
|
|
/// <c>ChannelSystem::GetChannelID</c> fallback tags (152 total).
|
|
///
|
|
/// <para>
|
|
/// Every verb has an explicit <see cref="Status"/>:
|
|
/// <list type="bullet">
|
|
/// <item><see cref="Status.Implemented"/> — executes locally (typed client
|
|
/// command, chat alias, channel send, or local presentation).</item>
|
|
/// <item><see cref="Status.HelpOnly"/> — retail registers it with a NULL
|
|
/// function pointer (verified byte-level in the registry doc); it can
|
|
/// never execute in retail OR acdream. Typing it bare reaches the server
|
|
/// as literal text.</item>
|
|
/// <item><see cref="Status.ServerPassthrough"/> — a real retail verb
|
|
/// acdream has not yet ported (TS-68/TS-69/TS-70, or the deliberate
|
|
/// @loadfile product decision); falls through to ACE as literal text.</item>
|
|
/// </list>
|
|
/// The FIRST test enforces that every verb resolves to the RIGHT status.
|
|
/// The SECOND enforces the ownership rule in reverse: nothing in
|
|
/// <see cref="RetailClientCommandCatalog"/> or <see cref="ChatInputParser"/>
|
|
/// exists that ISN'T in this registry (no invented verbs slip back in).
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class RetailCommandRegistryConformanceTests
|
|
{
|
|
private enum Status { Implemented, HelpOnly, ServerPassthrough }
|
|
|
|
private sealed record Entry(Status Status, params string[] Verbs);
|
|
|
|
/// <summary>
|
|
/// The complete 152-verb registry, one entry per retail handler (or
|
|
/// per fallback-tag family), transcribed directly from the
|
|
/// command-registry doc's §2 tables. Verb-string counts per section
|
|
/// were cross-checked against the doc's own §4 totals (130 + 22 = 152;
|
|
/// the Turbine §2.4 net-new count here is exactly 14, matching the
|
|
/// doc's "15 added, 14 net-new (a replaced)" note) before this test
|
|
/// was written.
|
|
/// </summary>
|
|
private static readonly Entry[] Registry =
|
|
[
|
|
// §2.1 Help / group nodes (9)
|
|
new(Status.Implemented, "help", "?"),
|
|
new(Status.HelpOnly, "commands"),
|
|
new(Status.HelpOnly, "allegiances"),
|
|
new(Status.HelpOnly, "channels"),
|
|
new(Status.HelpOnly, "chatting"),
|
|
new(Status.HelpOnly, "death"),
|
|
new(Status.HelpOnly, "status"),
|
|
new(Status.HelpOnly, "text"),
|
|
|
|
// §2.2 Local chat routing (31)
|
|
new(Status.Implemented, "say", "s"),
|
|
new(Status.Implemented, "tell", "t", "send", "whisper", "w"),
|
|
new(Status.Implemented, "reply", "r", "rp"),
|
|
new(Status.HelpOnly, "mr"),
|
|
new(Status.HelpOnly, "pr"),
|
|
new(Status.Implemented, "retell", "rt"),
|
|
new(Status.Implemented, "chat"),
|
|
new(Status.Implemented, "notell"),
|
|
new(Status.Implemented, "join"),
|
|
new(Status.Implemented, "leave"),
|
|
new(Status.Implemented, "index"),
|
|
new(Status.Implemented, "clist"),
|
|
new(Status.Implemented, "on"),
|
|
new(Status.Implemented, "off"),
|
|
new(Status.Implemented, "title"),
|
|
// CT-B4 (2026-08-21): "log" was never a server passthrough. Retail
|
|
// handles it entirely client-side — DoSetOutput @0x0057E4F0 opens a
|
|
// file, and nothing goes on the wire. It was classified here as a
|
|
// passthrough because it had a help entry and no catalog entry, which
|
|
// is the shape an unimplemented client command has too.
|
|
new(Status.Implemented, "log"),
|
|
new(Status.Implemented, "clear"),
|
|
new(Status.Implemented, "filter"),
|
|
new(Status.Implemented, "unfilter"),
|
|
new(Status.Implemented, "messagetypes", "message_types", "msgtypes", "msg_types"),
|
|
new(Status.ServerPassthrough, "loadfile"), // deliberate — doc §3 "explicitly do NOT implement"
|
|
|
|
// §2.3 Chat channels (20)
|
|
new(Status.Implemented, "a", "ab"),
|
|
new(Status.Implemented, "co-vassals", "covassals", "covassal", "c"),
|
|
new(Status.Implemented, "monarch", "m"),
|
|
new(Status.Implemented, "patron", "p"),
|
|
new(Status.Implemented, "vassals", "vassal", "v"),
|
|
new(Status.Implemented, "fellowship", "fellows", "fellow", "f", "group", "g", "party"),
|
|
|
|
// §2.3 fallback: 22 GetChannelID tags with NO registered verb (22)
|
|
new(Status.Implemented, "av", "av1", "advocate", "advocate1"),
|
|
new(Status.Implemented, "av2", "advocate2"),
|
|
new(Status.Implemented, "av3", "advocate3"),
|
|
new(Status.Implemented, "abuse"),
|
|
new(Status.Implemented, "ad", "admin"),
|
|
new(Status.Implemented, "au", "audit"),
|
|
new(Status.Implemented, "sent", "sentinel"),
|
|
new(Status.Implemented, "celestialhand", "celhan"),
|
|
new(Status.Implemented, "eldrytchweb", "eldweb"),
|
|
new(Status.Implemented, "radiantblood", "radblo"),
|
|
new(Status.Implemented, "ol"),
|
|
|
|
// §2.4 Turbine chat, 14 net-new (a already counted in §2.3) (14)
|
|
new(Status.Implemented, "guild", "gu"),
|
|
new(Status.Implemented, "general", "cg"),
|
|
new(Status.Implemented, "trade", "ct"),
|
|
new(Status.Implemented, "lfg", "clfg"),
|
|
new(Status.Implemented, "roleplay", "crp"),
|
|
new(Status.Implemented, "society", "soc"),
|
|
new(Status.Implemented, "olthoi", "o"),
|
|
|
|
// §2.5 Allegiance management (6; "ab" already counted in §2.3)
|
|
new(Status.Implemented, "allegiance", "all"),
|
|
new(Status.Implemented, "alh", "ah"),
|
|
new(Status.ServerPassthrough, "motd"), // TS-68
|
|
new(Status.Implemented, "speaker"),
|
|
|
|
// §2.5b Housing (7)
|
|
new(Status.Implemented, "house", "hou"),
|
|
new(Status.Implemented, "hor", "hr"),
|
|
new(Status.Implemented, "hom", "hoa"),
|
|
new(Status.Implemented, "hslist"),
|
|
|
|
// §2.6 Death / recall / PK (17)
|
|
new(Status.Implemented, "lifestone", "lif", "ls"),
|
|
new(Status.Implemented, "marketplace", "mar", "mp"),
|
|
new(Status.Implemented, "pkarena", "pka"),
|
|
new(Status.Implemented, "pklarena", "pla"),
|
|
new(Status.Implemented, "pklite", "pkl"),
|
|
new(Status.Implemented, "die"),
|
|
new(Status.Implemented, "corpse", "cor"),
|
|
new(Status.Implemented, "consent"),
|
|
new(Status.Implemented, "permit"),
|
|
|
|
// §2.7 Status / display (8)
|
|
new(Status.Implemented, "age"),
|
|
new(Status.Implemented, "birth"),
|
|
new(Status.ServerPassthrough, "day"), // TS-69 — no sky/time-of-day override hook
|
|
new(Status.Implemented, "endurance"),
|
|
new(Status.Implemented, "framerate"),
|
|
new(Status.Implemented, "loc"),
|
|
new(Status.Implemented, "version"),
|
|
new(Status.ServerPassthrough, "render"), // TS-69 — no SmartBox equivalent
|
|
|
|
// §2.8 Interface layout, emotes, social, components (18)
|
|
new(Status.Implemented, "saveui"),
|
|
new(Status.Implemented, "loadui"),
|
|
new(Status.Implemented, "saveautoui"),
|
|
new(Status.Implemented, "loadautoui"),
|
|
new(Status.Implemented, "lockui"),
|
|
new(Status.Implemented, "emote", "e", "em", "me"),
|
|
new(Status.Implemented, "emotes"),
|
|
new(Status.Implemented, "afk"),
|
|
new(Status.Implemented, "friends"),
|
|
new(Status.Implemented, "friends_add"),
|
|
new(Status.Implemented, "friends_remove"),
|
|
new(Status.Implemented, "squelch"),
|
|
new(Status.Implemented, "unsquelch"),
|
|
new(Status.Implemented, "fillcomps"),
|
|
];
|
|
|
|
private static readonly HashSet<string> CatalogVerbs =
|
|
new(RetailClientCommandCatalog.KnownVerbs, StringComparer.OrdinalIgnoreCase);
|
|
|
|
private static bool IsExecutable(string verb)
|
|
{
|
|
if (verb is "help" or "?")
|
|
return true; // local presentation — not a catalog/parser entry.
|
|
if (CatalogVerbs.Contains(verb))
|
|
return true;
|
|
if (ChatInputParser.IsKnownVerb("/" + verb))
|
|
return true;
|
|
if (RetailChannelTagTable.IsUnregisteredFallbackTag(verb))
|
|
return true;
|
|
return false;
|
|
}
|
|
|
|
public static IEnumerable<object[]> AllVerbsWithStatus() =>
|
|
Registry.SelectMany(entry => entry.Verbs.Select(verb => new object[] { verb, entry.Status }));
|
|
|
|
[Theory]
|
|
[MemberData(nameof(AllVerbsWithStatus))]
|
|
public void EveryRegistryVerb_HasTheCorrectOwnershipStatus(string verb, object statusObj)
|
|
{
|
|
var status = (Status)statusObj;
|
|
bool executable = IsExecutable(verb);
|
|
if (status == Status.Implemented)
|
|
{
|
|
Assert.True(executable,
|
|
$"'{verb}' is marked Implemented in the registry but no production surface " +
|
|
"(RetailClientCommandCatalog, ChatInputParser, or RetailChannelTagTable) recognizes it.");
|
|
}
|
|
else
|
|
{
|
|
Assert.False(executable,
|
|
$"'{verb}' is marked {status} (must fall through to server passthrough) but a " +
|
|
"production surface claims to execute it locally — that's a real behavior change " +
|
|
"the registry doesn't know about yet.");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Registry_EnumeratesExactly152Verbs()
|
|
{
|
|
int total = Registry.Sum(entry => entry.Verbs.Length);
|
|
Assert.Equal(152, total);
|
|
}
|
|
|
|
[Fact]
|
|
public void Registry_StatusCountsMatchTheAuditedTotals()
|
|
{
|
|
Assert.Equal(9, Registry.Where(e => e.Status == Status.HelpOnly).Sum(e => e.Verbs.Length));
|
|
// CT-B4 moved "log" from ServerPassthrough to Implemented, so these
|
|
// two totals shift by one against the CH4 audit. The 152 verb total
|
|
// is unchanged, which is what NoDuplicateVerbsAcrossEntries and the
|
|
// section counts protect.
|
|
Assert.Equal(4, Registry.Where(e => e.Status == Status.ServerPassthrough).Sum(e => e.Verbs.Length));
|
|
Assert.Equal(139, Registry.Where(e => e.Status == Status.Implemented).Sum(e => e.Verbs.Length));
|
|
}
|
|
|
|
[Fact]
|
|
public void NoDuplicateVerbsAcrossEntries()
|
|
{
|
|
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
foreach (Entry entry in Registry)
|
|
{
|
|
foreach (string verb in entry.Verbs)
|
|
{
|
|
Assert.True(seen.Add(verb), $"'{verb}' appears more than once in the registry.");
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Ownership-rule enforcement, reverse direction ──────────────────
|
|
//
|
|
// A verb RetailClientCommandCatalog or ChatInputParser claims to
|
|
// execute that ISN'T in the registry above is exactly the "acdream
|
|
// invented a verb retail doesn't have" class of bug the doc's §4
|
|
// "candidates for removal" list called out (gen, cv, lookingforgroup,
|
|
// tr, role, h) — deleted at CH4. This test fails the build the next
|
|
// time one slips back in.
|
|
|
|
[Fact]
|
|
public void RetailClientCommandCatalog_HasNoVerbsOutsideTheRegistry()
|
|
{
|
|
var registryVerbs = new HashSet<string>(
|
|
Registry.SelectMany(e => e.Verbs), StringComparer.OrdinalIgnoreCase);
|
|
|
|
foreach (string verb in RetailClientCommandCatalog.KnownVerbs)
|
|
{
|
|
Assert.True(registryVerbs.Contains(verb),
|
|
$"RetailClientCommandCatalog recognizes '{verb}', which is not in the retail " +
|
|
"command-registry doc — either it's a genuine retail verb missing from this " +
|
|
"test's registry, or it's an invented alias that must be deleted.");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void ChatInputParser_HasNoVerbsOutsideTheRegistry()
|
|
{
|
|
var registryVerbs = new HashSet<string>(
|
|
Registry.SelectMany(e => e.Verbs), StringComparer.OrdinalIgnoreCase);
|
|
|
|
foreach (string verbWithSlash in ChatInputParser.KnownVerbs)
|
|
{
|
|
string verb = verbWithSlash.TrimStart('/');
|
|
Assert.True(registryVerbs.Contains(verb),
|
|
$"ChatInputParser recognizes '{verbWithSlash}', which is not in the retail " +
|
|
"command-registry doc — either it's a genuine retail verb missing from this " +
|
|
"test's registry, or it's an invented alias that must be deleted.");
|
|
}
|
|
}
|
|
|
|
// ── CH4 REJECT-review nit 13 (2026-08-09) ───────────────────────────
|
|
//
|
|
// The tests above only prove a verb STRING is recognized somewhere —
|
|
// not which channel it actually resolves to. A rebind regression (e.g.
|
|
// "/g" quietly reverting to General, the exact Tier-1 #1 bug this
|
|
// campaign fixed) would still pass every case above. These two pin the
|
|
// real dispatch outcome so a rebind regression fails THIS suite, not
|
|
// just a narrower parser-only test.
|
|
|
|
[Fact]
|
|
public void GVerb_BindsToFellowship_NotGeneral()
|
|
{
|
|
ChatInputParser.ParsedInput? parsed = ChatInputParser.Parse(
|
|
"/g hi gang", ChatChannelKind.Say, lastTellSender: null);
|
|
|
|
Assert.NotNull(parsed);
|
|
Assert.Equal(ChatChannelKind.Fellowship, parsed!.Value.Channel);
|
|
}
|
|
|
|
[Fact]
|
|
public void RpVerb_BindsToReply_NotRoleplay()
|
|
{
|
|
ChatInputParser.ParsedInput? parsed = ChatInputParser.Parse(
|
|
"/rp hello back", ChatChannelKind.Say, lastTellSender: "Aunt Agatha");
|
|
|
|
Assert.NotNull(parsed);
|
|
Assert.Equal(ChatChannelKind.Tell, parsed!.Value.Channel);
|
|
Assert.Equal("Aunt Agatha", parsed!.Value.TargetName);
|
|
}
|
|
}
|