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>
354 lines
12 KiB
C#
354 lines
12 KiB
C#
using AcDream.Core.Chat;
|
|
using AcDream.UI.Abstractions.Panels.Chat;
|
|
|
|
namespace AcDream.UI.Abstractions.Tests.Panels.Chat;
|
|
|
|
/// <summary>
|
|
/// Phase I.4: when the user submits text via the chat input field, the
|
|
/// panel must publish the appropriate typed intent to the command bus.
|
|
/// We exercise the full Render path with the <see cref="FakePanelRenderer"/>
|
|
/// pre-loading a "submitted" string and a recording bus capturing the
|
|
/// resulting command.
|
|
/// </summary>
|
|
public sealed class ChatPanelInputTests
|
|
{
|
|
private sealed class RecordingBus : ICommandBus
|
|
{
|
|
public List<object> Published { get; } = new();
|
|
public void Publish<T>(T command) where T : notnull => Published.Add(command);
|
|
}
|
|
|
|
[Fact]
|
|
public void Submit_HelpCommand_RendersLocalHelpAndDoesNotPublish()
|
|
{
|
|
// Phase J follow-up: client-side commands (/help, /?, /h) are
|
|
// intercepted before the parser. They render a local cheat-sheet
|
|
// via ChatLog.OnSystemMessage and do NOT round-trip the server
|
|
// — that's what prevented the "Unknown command: help" duplicate
|
|
// ACE was firing back.
|
|
var log = new ChatLog();
|
|
var vm = new ChatVM(log);
|
|
var panel = new ChatPanel(vm);
|
|
var bus = new RecordingBus();
|
|
var renderer = new FakePanelRenderer
|
|
{
|
|
InputTextSubmitNextSubmitted = "/help",
|
|
InputTextSubmitNextBufferAfter = "",
|
|
};
|
|
|
|
panel.Render(new PanelContext(0.016f, bus), renderer);
|
|
|
|
Assert.Empty(bus.Published);
|
|
var entry = Assert.Single(log.Snapshot());
|
|
Assert.Equal(ChatKind.System, entry.Kind);
|
|
// Help text mentions / and @ equivalence and points at @acehelp
|
|
// for the server's full command list.
|
|
Assert.Contains("/tell", entry.Text);
|
|
Assert.Contains("@acehelp", entry.Text);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("/?")]
|
|
// "/h" is DELETED (Campaign CH slice CH4, 2026-08-09) — it is not a
|
|
// retail-registered verb (registry doc §4's removal list).
|
|
[InlineData("/HELP")]
|
|
public void Submit_HelpAliases_AlsoRenderLocalHelp(string raw)
|
|
{
|
|
var log = new ChatLog();
|
|
var vm = new ChatVM(log);
|
|
var panel = new ChatPanel(vm);
|
|
var bus = new RecordingBus();
|
|
var renderer = new FakePanelRenderer
|
|
{
|
|
InputTextSubmitNextSubmitted = raw,
|
|
InputTextSubmitNextBufferAfter = "",
|
|
};
|
|
|
|
panel.Render(new PanelContext(0.016f, bus), renderer);
|
|
|
|
Assert.Empty(bus.Published);
|
|
Assert.Single(log.Snapshot());
|
|
}
|
|
|
|
[Fact]
|
|
public void Submit_FramerateCommand_PublishesTypedClientCommand()
|
|
{
|
|
var log = new ChatLog();
|
|
var vm = new ChatVM(log) { FpsProvider = () => 60f };
|
|
var panel = new ChatPanel(vm);
|
|
var bus = new RecordingBus();
|
|
var renderer = new FakePanelRenderer
|
|
{
|
|
InputTextSubmitNextSubmitted = "/framerate",
|
|
InputTextSubmitNextBufferAfter = "",
|
|
};
|
|
|
|
panel.Render(new PanelContext(0.016f, bus), renderer);
|
|
|
|
var command = Assert.IsType<ExecuteClientCommandCmd>(Assert.Single(bus.Published));
|
|
Assert.Equal(ClientCommandId.ToggleFrameRate, command.Command);
|
|
Assert.Empty(log.Snapshot());
|
|
}
|
|
|
|
[Fact]
|
|
public void Submit_LocCommand_PublishesTypedClientCommand()
|
|
{
|
|
var log = new ChatLog();
|
|
var vm = new ChatVM(log)
|
|
{
|
|
PositionProvider = () => new System.Numerics.Vector3(10f, 20f, 30f),
|
|
};
|
|
var panel = new ChatPanel(vm);
|
|
var bus = new RecordingBus();
|
|
var renderer = new FakePanelRenderer
|
|
{
|
|
InputTextSubmitNextSubmitted = "@loc",
|
|
InputTextSubmitNextBufferAfter = "",
|
|
};
|
|
|
|
panel.Render(new PanelContext(0.016f, bus), renderer);
|
|
|
|
var command = Assert.IsType<ExecuteClientCommandCmd>(Assert.Single(bus.Published));
|
|
Assert.Equal(ClientCommandId.ShowLocation, command.Command);
|
|
Assert.Empty(log.Snapshot());
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("/foo", "@foo")]
|
|
[InlineData("/genio public", "@genio public")]
|
|
public void Submit_UnknownSlashCommand_RoutesToExplicitServerCommand(string raw, string expectedText)
|
|
{
|
|
// Phase J Tier 4 held: /-prefixed text is still NEVER broadcast
|
|
// as plain speech. Retail treats / and @ as equivalent command
|
|
// prefixes, so unknown verbs now go to the SERVER as @commands
|
|
// (ACE's GameActionTalk intercepts @ on the Say action and
|
|
// answers "Unknown command: x" itself) instead of a local guess.
|
|
var log = new ChatLog();
|
|
var vm = new ChatVM(log);
|
|
var panel = new ChatPanel(vm);
|
|
var bus = new RecordingBus();
|
|
var renderer = new FakePanelRenderer
|
|
{
|
|
InputTextSubmitNextSubmitted = raw,
|
|
InputTextSubmitNextBufferAfter = "",
|
|
};
|
|
|
|
panel.Render(new PanelContext(0.016f, bus), renderer);
|
|
|
|
var sendCmd = Assert.IsType<SendServerCommandCmd>(Assert.Single(bus.Published));
|
|
Assert.Equal(expectedText, sendCmd.Text);
|
|
Assert.Empty(log.Snapshot()); // no local "Unknown command" guess
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("/lifestone")]
|
|
[InlineData("/lif")]
|
|
[InlineData("/ls")]
|
|
[InlineData("@LS")]
|
|
public void Submit_LifestoneAlias_PublishesTypedClientCommand(string raw)
|
|
{
|
|
var vm = new ChatVM(new ChatLog());
|
|
var panel = new ChatPanel(vm);
|
|
var bus = new RecordingBus();
|
|
var renderer = new FakePanelRenderer
|
|
{
|
|
InputTextSubmitNextSubmitted = raw,
|
|
InputTextSubmitNextBufferAfter = "",
|
|
};
|
|
|
|
panel.Render(new PanelContext(0.016f, bus), renderer);
|
|
|
|
var command = Assert.IsType<ExecuteClientCommandCmd>(Assert.Single(bus.Published));
|
|
Assert.Equal(ClientCommandId.LifestoneRecall, command.Command);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("/")]
|
|
[InlineData("//shrug")]
|
|
public void Submit_CommandShapedWithoutVerb_ShowsUnknownAndDoesNotPublish(string raw)
|
|
{
|
|
// Command-shaped but no letter verb: refused locally — this is
|
|
// the remaining Tier-4 guard (never broadcast /-text as speech,
|
|
// and don't put junk @-rewrites on the wire either).
|
|
var log = new ChatLog();
|
|
var vm = new ChatVM(log);
|
|
var panel = new ChatPanel(vm);
|
|
var bus = new RecordingBus();
|
|
var renderer = new FakePanelRenderer
|
|
{
|
|
InputTextSubmitNextSubmitted = raw,
|
|
InputTextSubmitNextBufferAfter = "",
|
|
};
|
|
|
|
panel.Render(new PanelContext(0.016f, bus), renderer);
|
|
|
|
Assert.Empty(bus.Published);
|
|
var entry = Assert.Single(log.Snapshot());
|
|
Assert.Equal(ChatKind.System, entry.Kind);
|
|
Assert.Contains("Unknown command", entry.Text);
|
|
Assert.Contains("/help", entry.Text);
|
|
}
|
|
|
|
[Fact]
|
|
public void Submit_AtAcehelp_PublishesExplicitServerCommand()
|
|
{
|
|
// Unknown @-verb falls through to the default channel with the
|
|
// literal "@acehelp" text intact so ACE's CommandManager
|
|
// intercepts it server-side. The explicit server-command record keeps
|
|
// it distinct from ordinary Say text.
|
|
var log = new ChatLog();
|
|
var vm = new ChatVM(log);
|
|
var panel = new ChatPanel(vm);
|
|
var bus = new RecordingBus();
|
|
var renderer = new FakePanelRenderer
|
|
{
|
|
InputTextSubmitNextSubmitted = "@acehelp",
|
|
InputTextSubmitNextBufferAfter = "",
|
|
};
|
|
|
|
panel.Render(new PanelContext(0.016f, bus), renderer);
|
|
|
|
var sendCmd = Assert.IsType<SendServerCommandCmd>(Assert.Single(bus.Published));
|
|
Assert.Equal("@acehelp", sendCmd.Text);
|
|
}
|
|
|
|
[Fact]
|
|
public void Submit_ClearCommand_PublishesTypedClientCommand()
|
|
{
|
|
var log = new ChatLog();
|
|
log.OnSystemMessage("seed line", chatType: 0);
|
|
var vm = new ChatVM(log);
|
|
var panel = new ChatPanel(vm);
|
|
var bus = new RecordingBus();
|
|
var renderer = new FakePanelRenderer
|
|
{
|
|
InputTextSubmitNextSubmitted = "/clear",
|
|
InputTextSubmitNextBufferAfter = "",
|
|
};
|
|
|
|
panel.Render(new PanelContext(0.016f, bus), renderer);
|
|
|
|
var command = Assert.IsType<ExecuteClientCommandCmd>(Assert.Single(bus.Published));
|
|
Assert.Equal(ClientCommandId.ClearChat, command.Command);
|
|
Assert.Single(log.Snapshot());
|
|
}
|
|
|
|
[Fact]
|
|
public void Submit_PlainText_PublishesSayCommand()
|
|
{
|
|
var log = new ChatLog();
|
|
var vm = new ChatVM(log);
|
|
var panel = new ChatPanel(vm);
|
|
var bus = new RecordingBus();
|
|
var renderer = new FakePanelRenderer
|
|
{
|
|
InputTextSubmitNextSubmitted = "hello world",
|
|
InputTextSubmitNextBufferAfter = "",
|
|
};
|
|
|
|
panel.Render(new PanelContext(0.016f, bus), renderer);
|
|
|
|
var cmd = Assert.Single(bus.Published);
|
|
var sendCmd = Assert.IsType<SendChatCmd>(cmd);
|
|
Assert.Equal(ChatChannelKind.Say, sendCmd.Channel);
|
|
Assert.Null(sendCmd.TargetName);
|
|
Assert.Equal("hello world", sendCmd.Text);
|
|
}
|
|
|
|
[Fact]
|
|
public void Submit_TellSlashCommand_PublishesTellCommand()
|
|
{
|
|
var log = new ChatLog();
|
|
var vm = new ChatVM(log);
|
|
var panel = new ChatPanel(vm);
|
|
var bus = new RecordingBus();
|
|
var renderer = new FakePanelRenderer
|
|
{
|
|
InputTextSubmitNextSubmitted = "/t Bestie ping",
|
|
InputTextSubmitNextBufferAfter = "",
|
|
};
|
|
|
|
panel.Render(new PanelContext(0.016f, bus), renderer);
|
|
|
|
var sendCmd = Assert.IsType<SendChatCmd>(Assert.Single(bus.Published));
|
|
Assert.Equal(ChatChannelKind.Tell, sendCmd.Channel);
|
|
Assert.Equal("Bestie", sendCmd.TargetName);
|
|
Assert.Equal("ping", sendCmd.Text);
|
|
}
|
|
|
|
[Fact]
|
|
public void Submit_ReplySlashCommand_UsesLastIncomingTellSender()
|
|
{
|
|
var log = new ChatLog();
|
|
var vm = new ChatVM(log);
|
|
log.OnTellReceived("Bestie", "ping", senderGuid: 0x5000_00AAu, logTextType: 0x03u);
|
|
|
|
var panel = new ChatPanel(vm);
|
|
var bus = new RecordingBus();
|
|
var renderer = new FakePanelRenderer
|
|
{
|
|
InputTextSubmitNextSubmitted = "/r back at you",
|
|
InputTextSubmitNextBufferAfter = "",
|
|
};
|
|
|
|
panel.Render(new PanelContext(0.016f, bus), renderer);
|
|
|
|
var sendCmd = Assert.IsType<SendChatCmd>(Assert.Single(bus.Published));
|
|
Assert.Equal(ChatChannelKind.Tell, sendCmd.Channel);
|
|
Assert.Equal("Bestie", sendCmd.TargetName);
|
|
Assert.Equal("back at you", sendCmd.Text);
|
|
}
|
|
|
|
[Fact]
|
|
public void Submit_EmptyOrWhitespace_PublishesNothing()
|
|
{
|
|
var log = new ChatLog();
|
|
var vm = new ChatVM(log);
|
|
var panel = new ChatPanel(vm);
|
|
var bus = new RecordingBus();
|
|
var renderer = new FakePanelRenderer
|
|
{
|
|
InputTextSubmitNextSubmitted = " ",
|
|
InputTextSubmitNextBufferAfter = "",
|
|
};
|
|
|
|
panel.Render(new PanelContext(0.016f, bus), renderer);
|
|
|
|
Assert.Empty(bus.Published);
|
|
}
|
|
|
|
[Fact]
|
|
public void NoSubmit_PublishesNothing()
|
|
{
|
|
// Most frames: user is typing or idle; submitted == null.
|
|
var log = new ChatLog();
|
|
var vm = new ChatVM(log);
|
|
var panel = new ChatPanel(vm);
|
|
var bus = new RecordingBus();
|
|
var renderer = new FakePanelRenderer
|
|
{
|
|
InputTextSubmitNextSubmitted = null,
|
|
};
|
|
|
|
panel.Render(new PanelContext(0.016f, bus), renderer);
|
|
|
|
Assert.Empty(bus.Published);
|
|
}
|
|
|
|
[Fact]
|
|
public void Render_AlwaysCallsInputTextSubmit_ToShowTheField()
|
|
{
|
|
var log = new ChatLog();
|
|
var vm = new ChatVM(log);
|
|
var panel = new ChatPanel(vm);
|
|
var bus = new RecordingBus();
|
|
var renderer = new FakePanelRenderer
|
|
{
|
|
InputTextSubmitNextSubmitted = null,
|
|
};
|
|
|
|
panel.Render(new PanelContext(0.016f, bus), renderer);
|
|
|
|
Assert.Contains(renderer.Calls, c => c.Method == "InputTextSubmit");
|
|
}
|
|
}
|