Commands Framework¶
One registration point for every chat-typed player action, replacing the original's single 58 KB file of hand-rolled handlers sharing one dispatcher. See Legacy/09_CHAT_AND_COMMANDS.md for the full legacy trace — its own design note names the shape worth keeping: "one authenticated, logged, access-gated entry point with a global veto hook." This document is that shape, rebuilt on this codebase's own primitives instead of new ones.
1. What a command is¶
A command is a named, access-gated action a character requests by name, with a minimum argument count, logged on every attempt.
namespace Applejack.Commands;
/// <summary>One registered command. See Code/Commands/README.md.</summary>
public sealed class CommandDefinition
{
public required string Name { get; init; } // invoked as "/name"
public string? RequiredPermission { get; init; } // null = everyone
public int MinimumArguments { get; init; }
public string Usage { get; init; } = "";
public string Category { get; init; } = "";
public string HelpText { get; init; } = "";
public required Func<CommandContext, Task<Result>> Execute { get; init; }
}
public sealed record CommandContext(Character Caller, IReadOnlyList<string> Arguments);
Execute returns Applejack.Core.Result directly
(13_ERROR_HANDLING.md) — a bespoke CommandResult would carry nothing
Result doesn't already have, the same reasoning InteractionStartResult's own doc comment
gives for when a bespoke type is worth the duplication (it isn't, here).
Commands are code-registered, not authored content. Unlike NeedDefinition/
ItemDefinition, a command is behaviour a module owns, not data an operator edits — this
follows IHudRegistry.RegisterHudElement<TPanel>'s runtime-registration shape
(Ui/IHudRegistry.cs), not NeedsConfiguration's
GameResource shape.
2. The registry¶
ICommandRegistry mirrors IItemRegistry/IHudRegistry: register once at module boot, read
many times.
public interface ICommandRegistry
{
void Register(CommandDefinition command);
CommandDefinition? Find(string name);
IReadOnlyList<CommandDefinition> All { get; }
}
Lookup is case-insensitive — a player typing /Help and /help should reach the same
command; nothing in the legacy source or this codebase's own conventions calls for
case-sensitive command names. Registering a name that already exists is a thrown exception at
boot -- a programmer error, same class as a duplicate NeedDefinition.Id
(14_NEEDS_FRAMEWORK.md) -- never silently
overwritten.
3. Access¶
Character.HasPermission(string), backed by PermissionSet
(Character/PermissionSet.cs, Milestone 4) — flat,
exact-match, data-driven. Legacy/09_CHAT_AND_COMMANDS.md §8 lists the legacy's
single-character access flags (b/m/a/s) under "does not survive"; this reuses the
already-built replacement rather than inventing a second access model alongside it.
CommandDefinition.RequiredPermission of null means everyone — most roleplay-affordance
commands (/sleep, /trip) need no permission at all.
4. Dispatch¶
One [Rpc.Host] method per character, following CharacterInventoryComponent.RequestUseItem's
exploit-checklist shape exactly
(Inventory/CharacterInventoryComponent.cs):
[Rpc.Host]
public void RequestExecuteCommand(string name, string[] arguments)
Exploit checklist:
- Identity — Rpc.Caller.GetCharacter(), never a client-supplied character.
- Existence — an unregistered name is a logged no-op (legacy: "This is not a valid
command!").
- State — character.IsInitialised.
- Access — character.HasPermission(definition.RequiredPermission).
- Argument count — arguments.Length >= definition.MinimumArguments.
- Veto — see §5.
- Every attempt — refused or not — is logged with its full argument text
(Legacy/09_CHAT_AND_COMMANDS.md §5: "Every command is logged with its full argument
text. Server operators need this; it is not optional."), LogCategory.Commands.
Reply-to-caller. Legacy's ply:Notify (§6, four distinct notification sounds) has a real
equivalent now: CommandDispatchComponent.ReceiveCommandReply(bool succeeded, string message),
an [Rpc.Broadcast] method the host calls scoped to just the caller's own connection —
using (Rpc.FilterInclude(connection)) { ReceiveCommandReply(...); } — via
Character.FindConnection()
and the single-Connection Rpc.FilterInclude overload (a genuinely different case from
Chat's variable-size recipient list — see 07_NETWORKING.md §5). A private
ReplyToCaller(Character, bool, string) helper is the one call site for this, invoked from
every outcome point in CommandDispatchComponent: the four early-return refusals in
RequestExecuteCommand (unknown command, insufficient permission, too few arguments, vetoed)
and the end of RunAsync (the command's own Result). The one outcome this cannot cover is
the very first bounds check (name/arguments length limits) — it returns before the caller's
identity is even resolved for a reply, so the Chat panel pre-validates those same bounds
client-side instead and shows a local message, rather than relying on a reply that structurally
can't be sent. ChatPanel.razor (Code/Chat/Ui/) is the text-input surface that turns a typed
/command line into the (name, arguments) this RPC expects — see
19_CHAT_FRAMEWORK.md §5.
No typed-parameter binding this pass. The legacy design note wants "typed parameters"
replacing stringly-typed variadic arguments — a real feature (reflection- or
source-gen-based argument binding) on the scale of its own design pass, not a corner of this
one. CommandContext.Arguments stays IReadOnlyList<string>; each command's Execute
parses what it needs, same as Edible.NeedId/InteractionRequest.VerbId are plain strings
today.
5. The global veto hook is a CancellableEvent¶
Legacy's PlayerCanUseCommand — "a single global gate for every command" — is exactly what
CancellableEvent (Core/Events/CancellableEvent.cs,
D-07's fix) already exists for: cancellation is a named, explicit method call, never a truthy
return value from a handler. CommandDispatchComponent publishes CommandExecuting :
CancellableEvent { Character Caller, string CommandName, IReadOnlyList<string> Arguments }
after the access/argument checks pass and before running Execute; any subscriber may veto
(e.g. a future "is muted" check) without Commands depending on whatever owns that rule.
6. Help¶
/help is registered by CommandsModule itself, not a separate example module — it needs
nothing beyond ICommandRegistry.All, unlike Bulletin, which needed a whole module to prove
Guides/01_WRITING_A_MODULE.md. No argument: lists every
registered command's Name/Usage/Category. One argument (a command name): that command's
HelpText. Legacy §1: "Registering a command automatically registers its help text... help
cannot drift from the command list" — true here by construction, since /help reads the
same CommandDefinition the dispatcher itself validates against, not a parallel help table.
Does not filter by the caller's own permissions this pass — showing an admin-only command's
existence to a non-admin is a minor information leak, not a security one
(RequiredPermission still gates running it). Flagged as a future improvement in
Code/Commands/README.md, not built speculatively.
7. What this framework deliberately does not build¶
Legacy/09_CHAT_AND_COMMANDS.md §5's 66 commands, beyond /help. Nearly every one acts on a
system that doesn't exist yet in this codebase (Jobs, Crime, Property beyond doors, Chat
channels, admin player-targeting) — building their bodies now would be fake work against
systems this framework can't actually reach. The registry/dispatch/access/veto/help machinery
is what's real; the commands themselves land alongside whatever milestone item builds each
one's dependency.
8. Testing¶
CommandRegistry is a plain dictionary-backed type with no engine dependency — tested
directly, following ICommandRegistry's own IItemRegistry/IHudRegistry precedent for
what is and isn't unit-testable per
Standards/03_TESTING_STANDARDS.md §1.
CommandDispatchComponent is not — same class of Scene/Rpc.Caller-dependent Component
logic as DoorComponent/CharacterInventoryComponent.RequestUseItem/
BulletinNetworkComponent, none of which have a test either.