veratown-prototype

Command alias proposal

Status: Implemented; archived 2026-10-18.

Implementation decisions: startup seeds bet, slots, and balance only when rooms.main["command.aliases"] is absent; aliases may target public or room-admin commands, whose original authorization remains in force; room admins manage aliases through /bot world alias add|remove|list; fixedArgs is supported; player-defined aliases are out of scope; no aliases beyond the starter set are seeded.

Purpose and scope

Let players use short commands for common long ones, for example:

Alias Expands to
!bet <args> !casino roulette bet <args>
!slots <args> !casino slots <args>
!balance !casino balance

Aliases are data, not code. Room admins add and remove them through the world alias commands; changes take effect without a restart through the existing prototypeGameConfig change-stream reload.

Included

Explicitly unchanged

Design constraints

Constraint Consequence
Real commands always win If an alias name matches a registered command path or its first word, the registered command is routed and the alias is ignored. A feature can never be hidden or hijacked by data.
No privilege change Aliases never carry permissions. Admin-only targets stay admin-only.
Single expansion An alias target may not start with another alias. There is no recursion and no loop detection needed.
Fail closed A structurally invalid config disables all aliases (logged once per revision). Unknown targets are rejected at write time; a target that later stops being registered makes only that alias unknown.
Privacy Sensitive commands are never alias targets (code, whisperer, testcomms, door access-code paths), so alias resolution cannot bypass the parser’s existing argument redaction.
Static composition The resolver is constructed in start.ts and given a read function over GameConfigRuntime; no feature imports it, and no plug-in is discovered dynamically.
Dependency direction The resolver and its validation live in core/ with explicit guards (core does not use zod). The composition root connects it to the world config runtime.

Configuration

Stored at prototypeGameConfig.rooms.main["command.aliases"]:

{
    "schemaVersion": 1,
    "enabled": true,
    "aliases": [
        { "name": "bet", "target": "casino roulette bet" },
        { "name": "slots", "target": "casino slots" },
        { "name": "balance", "target": "casino balance" },
        { "name": "spin", "target": "casino slots", "fixedArgs": "10" }
    ]
}
interface CommandAliasConfig {
    readonly schemaVersion: 1;
    readonly enabled: boolean;
    readonly aliases: readonly CommandAlias[]; // max 64
}

interface CommandAlias {
    /** One or two lowercase words, e.g. "bet" or "rb red". Same pattern as command paths. */
    readonly name: string;
    /** A command path that has a registered help descriptor. */
    readonly target: string;
    /** Optional words inserted before the player's arguments, max 64 chars. */
    readonly fixedArgs?: string;
}

Validation rules (on admin write and on every load):

Because plug-ins register commands at runtime, a valid alias can become invalid later, for example when a feature is disabled. The resolver therefore checks again at resolution time and treats an alias whose target is no longer registered as unknown (the normal “Unknown command” help reply).

Why prototypeGameConfig and not a new collection

The key is seeded at startup with bet, slots, and balance only when absent. Removing it disables aliases until the next startup, when the baseline is inserted again.

Resolution flow

sequenceDiagram
    participant BC as BC message
    participant Parser as BotCommandParser
    participant Alias as CommandAliasResolver
    participant Config as GameConfigRuntime
    participant Handler as Feature command handler
    BC->>Parser: "!bet red 10"
    Parser->>Parser: syntax/source/sender checks (unchanged)
    Parser->>Parser: help request? (unchanged)
    Parser->>Parser: longest registered path match
    alt registered command matched
        Parser->>Handler: route as today
    else no registered match
        Parser->>Alias: resolve(["bet","red","10"])
        Alias->>Config: current validated alias set
        alt alias found and target registered
            Alias-->>Parser: "casino roulette bet red 10"
            Parser->>Parser: route rewritten line (no second alias pass)
            Parser->>Handler: casino handler, same context except command line
        else no alias
            Parser-->>BC: private "Unknown command" help (unchanged)
        end
    end

Key points:

Help integration

Administration

/bot world alias list
/bot world alias add spin casino slots fixedArgs=10
/bot world alias add "red bet" casino roulette bet
/bot world alias remove spin

Two-word alias names are quoted. The optional fixedArgs=<words> value is inserted before the invoking player’s arguments. The dedicated commands validate the live help catalog and write the same room-scoped key with a revision check; generic world config set also validates that key.

Components

flowchart LR
    Root["start.ts"] --> Parser["BotCommandParser"]
    Root --> Resolver["CommandAliasResolver (core)"]
    Root --> Validator["command.aliases write validator"]
    Resolver --> Read["read: () => validated alias set"]
    Read --> Runtime["GameConfigRuntime.getForRoom(main, command.aliases)"]
    Parser --> Resolver
    Parser --> Help["CommandHelpCatalog / ScopedHelpService"]
    Validator --> Help
    Runtime --> DB[("prototypeGameConfig")]

The resolver holds no timers or listeners; it reads the current runtime value on each lookup, so there is nothing to dispose in shutdown.

Phases

Phase Content Gate
1 Config schema, seeding, resolver, parser fallback, write validator, and trace event Unit tests; seeded test-room shortcuts
2 Help shortcut lines and !<alias> help Unit tests; manual help check
3 Room-admin alias add/remove/list commands and revision-safe writes Unit tests

Test plan

Resolved decisions