Skip to content

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: - IdentityRpc.Caller.GetCharacter(), never a client-supplied character. - Existence — an unregistered name is a logged no-op (legacy: "This is not a valid command!"). - Statecharacter.IsInitialised. - Accesscharacter.HasPermission(definition.RequiredPermission). - Argument countarguments.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.