veratown-prototype

Command And Plug-in System

The command system maps BC messages to registered command suites. Built-in plug-ins are statically assembled by src/app/start.ts; they are not discovered from the filesystem or loaded as external packages.

src/adapters/bc/command-router-plugin.ts owns the BC message subscription. BcCommandParser validates targeting, sender, native Hidden commands, and Chat/Emote/Whisper bang syntax before submitting a normalized BotCommandInput to the transport-neutral core BotCommandParser. Feature suites register with the core registry and recheck their own permissions. The command context projects only the channel (message.Type), not the raw BC payload; submission handlers that need private message text stay at the BC adapter boundary.

The composition-root room-config-runtime plug-in is a watcher, not a command suite. It refreshes the missing-room creation template when versioned BC room settings change. The supervisor separately applies the active revision after the initial BC room sync, through its journaled room-settings action.

SafetyStopPlugin registers the shared /bot safeword command and delegates stops to handlers registered by active activities. StagedTablePlugin registers its private /bot table suite and a stop handler during start(); it also subscribes to observed BC appearance for pending-release reconciliation. All registrations are disposed during stop(). Staged Table commands reject public chat, and its game replies, cards, consent, offers, and prompt outcomes are sent privately. The activity remains disabled unless its room config is explicitly enabled and adults-only confirmed. Optional /bot table join accept appearance, /bot table solo start appearance, and /bot table appearance stop use an explicitly disclosed, revision-pinned itemset sequence and recoverable release without another socket or journal owner.

The supervisor composes the ABCL feature and registers its command suite with the shared command router. The ABCL suite is an explicit opt-in/opt-out/status allowlist; automatic ABCL triggers are feature-owned and independently require the same consent and observed-region checks as command-triggered work. The separate helper process observes ABCL messages and performs assigned typed tasks, including participant-facing communication. Missing or invalid ABCL configuration leaves the feature inactive without blocking supervisor startup. ABCL configuration and helper-only communication are described in the ABCL feature guide.

Casino entry funding is independent of its game-command gate. The composition root injects server-observed character-entry and ready subscriptions; the casino plug-in grants 20 chips per rolling 24 hours, privately reports credits, and disposes/drains this work on stop before the shared Mongo client closes. Casino rules/fairness disclosures are also private and available while wagering is disabled. Gated game commands accept weighted four-reel slots and the full supplied European roulette layout; roulette accepts multiple bets and refunds the last active bet before close. Game timers follow per-player slot 10/5/5 and shared roulette 45/10/5-second pacing, with terminal cooldowns persisted. Game subcommands are omitted from each sender’s casino help unless the map runtime’s observed point/region transitions match a configured binding for that supported game; bindings and casino rules come from room-scoped prototypeGameConfig, not source-defined location keys. Command dispatch checks the same policy again. The global help entry remains the general casino wallet command and does not reveal game subcommands.

Newly observed roulette access also triggers one private command invitation. The composition root supplies the bot’s member identity so it is excluded. Duplicate entries within the same observed occupancy do not repeat the invitation; exit/re-entry or plugin restart permits a new one. It uses the existing journaled whisper API, never opens a round automatically, and drains alongside entry-grant work before casino game shutdown.

The shop is a separate statically wired plug-in. Its command suite requires observed membership in the operator-created shop region; its service owns recipient consent, economy reservations, appearance reconciliation, favor evidence, and recovery. It shares the existing appearance action and economy service and does not own a socket, transport queue, or second journal. By default, round-open and closing callbacks use the casino plugin’s existing private-announcement path, which selects and rechecks observed roulette-region occupants, excludes the bot, and logs per-recipient failures. The optional wheel-helper mode is composed in start.ts: casino lifecycle callbacks enqueue typed helper events, while the helper uses its own CharacterCommunicationApi and observed-region checks. Only confirmed pre-dispatch unavailability may use the whisper fallback; ambiguous helper work never produces a second announcement. Registration/lifecycle order is unchanged.

Location-entry-driven features can use the generic core LocationEntryFeatureHandler<TAction> to own their event subscription, concurrent member/location de-duplication, error reporting, and stop-time drain. It injects event policy, action resolution, and execution rather than depending on feature or adapter code. The location-messages plug-in uses it to validate location data and current BC-observed context before sending journaled private Whispers; see the location entry messages guide. The location-appearance plug-in independently consumes both entry and exit transitions: it delays an itemset application, cancels on exit or stop, and revalidates current geometry and configuration at expiry. See the delayed location appearance guide. The emergency-release plug-in is a native-only self-service command: it applies emergency.release.clothing directly to the requesting member without moving either character or waiting for a location event; see the emergency release guide.

UML Map

classDiagram
    direction LR

    class BotPlugin {
        <<lifecycle contract>>
        +id: string
        +start()
        +stop()
    }
    class BotPluginHost {
        -plugins: BotPlugin[]
        -startedPlugins: BotPlugin[]
        +start() Promise~void~
        +stop() Promise~void~
    }
    class CommandSuite {
        <<registration contract>>
        +register() Dispose
    }
    class BotCommandParser {
        <<registry and dispatcher>>
        +register(name, description, handler, helpDescriptors?) Dispose
        +handleMessage(input: BotCommandInput) Promise~boolean~
    }
    class BcCommandParser {
        <<BC validation adapter>>
        +handleMessage(message: BcChatRoomMessage) Promise~boolean~
    }
    class KeyedDeadlines {
        +schedule(key, delayMs, callback)
        +cancel(key)
        +clear()
        +has(key)
    }
    class KeyedSerialQueue {
        +run(key, operation) Promise
        +drain() Promise
    }
    class TrackedTasks {
        +track(promise) Promise
        +drain() Promise
    }
    class DisposerStack {
        +add(disposers)
        +dispose() Promise
    }
    class CommandAliasResolver {
        <<single-pass fallback>>
        +resolve(parts, commandPaths, descriptors)
        +availableAliases(commandPaths, descriptors)
    }
    class CommandHelpCatalog {
        <<live registration metadata>>
        +register(descriptors) Dispose
        +snapshot() CommandHelpSnapshot
        +validate()
    }
    class ScopedHelpService {
        <<read-only filtered help>>
        +request(sender, scope, channel)
    }
    class HelpContextPort {
        <<observed requester context>>
        +current(sender, channel)
        +isCurrent(context)
    }
    class CommandRouterPlugin {
        <<BC event adapter>>
        +start() void
        +stop() void
    }
    class DoorFeaturePlugin
    class LocationEntryBehaviorPlugin
    class LocationEntryMessagePlugin
    class LocationAppearanceTriggerPlugin
    class EmergencyReleasePlugin
    class LocationEntryFeatureHandler {
        <<generic core event handler>>
        +start() void
        +stop() Promise~void~
    }
    class WorldAdminPlugin
    class RoomConfigRuntimePlugin {
        <<composition-root watcher>>
        +start()
        +stop()
    }
    class CommunicationPlugin
    class PrototypeStatusPlugin
    class AppearancePlugin
    class CasinoFeaturePlugin
    class ShopFeaturePlugin
    class AnonymousWhispererPlugin
    class TruthDarePlugin
    class HotSeatPlugin
    class StagedTablePlugin
    class DilemmaPlugin
    class ShowerFeaturePlugin
    class AbclFeaturePlugin
    class HelperBotsPlugin
    class TrickOrTreatPlugin
    class ShopLocationAccess
    class CasinoLocationAccess {
        <<per-member allowlists>>
        +canAccess(member, gameId)
        +accessibleGames(member)
    }
    class MapLocationEventBus {
        <<server-observed point/region transitions>>
        +locationEntered(member, key, shape)
        +locationLeft(member, key, shape)
    }
    class DoorCommandSuite
    class DoorAdminCommandSuite
    class CommunicationTestCommandSuite
    class PrototypeCommandSuite
    class AppearanceCommandSuite
    class EmergencyReleaseCommandSuite
    class WorldAdminCommandSuite
    class WhispererCommandSuite
    class WhispererSubmissionHandler
    class TruthDareCommandSuite
    class HotSeatCommandSuite
    class StagedTableCommandSuite
    class SafetyStopPlugin
    class SafetyStopCommandSuite
    class SafetyStopRegistry {
        <<shared activity stop boundary>>
        +register(activityId, handler) Dispose
        +stopActive(reason)
    }
    class DilemmaCommandSuite
    class ShopCommandSuite
    class DilemmaService
    class ShowerService
    class ShopService
    class MongoDilemmaRepository
    class MongoShopRepository
    class HotSeatService
    class MongoHotSeatRepository
    class EconomyPort {
        <<shared application contract>>
        +grantOnce(operationId, member, credits)
    }
    class CharacterAppearanceApi {
        <<shared appearance action API>>
        +execute(member, mutation)
    }
    class CharacterAppearanceSequenceApi {
        <<ordered shared appearance action API>>
        +executeSequence(member, mutations, source, onPieceOutcome?)
    }
    class ShowerAdminPort {
        <<transport-neutral room-admin contract>>
        +showSettings()
        +updateSettings(settings)
        +manageSongs()
    }
    class SharedAppearanceObservationBus
    class BcClient
    class ActionCoordinator
    class MongoActionJournal
    class MongoRoomConfigRepository {
        <<world config repository>>
        +loadCurrent(roomKey)
        +appendRevision(...)
        +watch(reload, onError)
    }

    BotPluginHost o-- BotPlugin : owns lifecycle
    RoomConfigRuntimePlugin ..|> BotPlugin
    RoomConfigRuntimePlugin --> MongoRoomConfigRepository : watches revisions
    DoorFeaturePlugin ..|> BotPlugin
    LocationEntryBehaviorPlugin ..|> BotPlugin
    WorldAdminPlugin ..|> BotPlugin
    CommunicationPlugin ..|> BotPlugin
    PrototypeStatusPlugin ..|> BotPlugin
    AppearancePlugin ..|> BotPlugin
    CasinoFeaturePlugin ..|> BotPlugin
    AnonymousWhispererPlugin ..|> BotPlugin
    TruthDarePlugin ..|> BotPlugin
    HotSeatPlugin ..|> BotPlugin
    StagedTablePlugin ..|> BotPlugin
    SafetyStopPlugin ..|> BotPlugin
    DilemmaPlugin ..|> BotPlugin
    ShowerFeaturePlugin ..|> BotPlugin
    ShopFeaturePlugin ..|> BotPlugin
    AbclFeaturePlugin ..|> BotPlugin
    HelperBotsPlugin ..|> BotPlugin
    TrickOrTreatPlugin ..|> BotPlugin
    CommandRouterPlugin ..|> BotPlugin
    DoorCommandSuite ..|> CommandSuite
    DoorAdminCommandSuite ..|> CommandSuite
    CommunicationTestCommandSuite ..|> CommandSuite
    PrototypeCommandSuite ..|> CommandSuite
    AppearanceCommandSuite ..|> CommandSuite
    WorldAdminCommandSuite ..|> CommandSuite
    WhispererCommandSuite ..|> CommandSuite
    TruthDareCommandSuite ..|> CommandSuite
    HotSeatCommandSuite ..|> CommandSuite
    StagedTableCommandSuite ..|> CommandSuite
    SafetyStopCommandSuite ..|> CommandSuite
    DilemmaCommandSuite ..|> CommandSuite
    ShopCommandSuite ..|> CommandSuite
    ShowerFeaturePlugin --> ShowerService
    ShowerService --> CharacterAppearanceSequenceApi
    ShowerService --> MapLocationEventBus
    WorldAdminCommandSuite --> ShowerAdminPort
    DoorFeaturePlugin --> DoorCommandSuite
    DoorFeaturePlugin --> DoorAdminCommandSuite
    WorldAdminPlugin --> WorldAdminCommandSuite
    CommunicationPlugin --> CommunicationTestCommandSuite
    PrototypeStatusPlugin --> PrototypeCommandSuite
    AppearancePlugin --> AppearanceCommandSuite
    TruthDarePlugin --> TruthDareCommandSuite
    TruthDarePlugin --> TruthDareService
    TruthDareCommandSuite --> TruthDareService
    CasinoFeaturePlugin --> CasinoLocationAccess
    CasinoFeaturePlugin --> MapLocationEventBus : observed entry/exit
    DoorFeaturePlugin --> MapLocationEventBus : publishes transitions
    LocationEntryBehaviorPlugin --> MapLocationEventBus : consumes transitions
    LocationEntryMessagePlugin --> MapLocationEventBus : consumes observed entries
    LocationAppearanceTriggerPlugin --> MapLocationEventBus : consumes entry/exit
    LocationAppearanceTriggerPlugin --> AppearanceItemSetService : delayed set application
    EmergencyReleasePlugin --> EmergencyReleaseCommandSuite
    EmergencyReleasePlugin --> MapLocationEventBus : target entry observation
    EmergencyReleasePlugin --> MapTeleportAction : observed bot teleport
    EmergencyReleasePlugin --> AppearanceItemSetService : one-time release set
    EmergencyReleasePlugin --> CharacterCommunicationApi : private notice and result
    EmergencyReleaseCommandSuite --> BotCommandParser : native-only handler
    LocationEntryMessagePlugin --> LocationEntryFeatureHandler
    LocationEntryMessagePlugin --> CharacterCommunicationApi : journaled private Whisper
    DoorCommandSuite --> BotCommandParser
    DoorAdminCommandSuite --> BotCommandParser
    CommunicationTestCommandSuite --> BotCommandParser
    PrototypeCommandSuite --> BotCommandParser
    AppearanceCommandSuite --> BotCommandParser
    WorldAdminCommandSuite --> BotCommandParser
    TruthDareCommandSuite --> BotCommandParser
    TruthDareService --> EconomyPort
    TruthDareService --> TruthDareRepository
    WorldAdminCommandSuite --> LocationAdministration : injected core API
    AnonymousWhispererPlugin --> WhispererCommandSuite
    AnonymousWhispererPlugin --> WhispererSubmissionHandler : explicit private input
    WhispererCommandSuite --> BotCommandParser
    HotSeatPlugin --> HotSeatCommandSuite
    HotSeatPlugin --> HotSeatService
    HotSeatCommandSuite --> BotCommandParser
    StagedTablePlugin --> StagedTableCommandSuite
    StagedTablePlugin --> StagedTableService
    StagedTablePlugin --> SafetyStopRegistry
    StagedTableCommandSuite --> BotCommandParser
    SafetyStopPlugin --> SafetyStopCommandSuite
    SafetyStopPlugin --> SafetyStopRegistry
    SafetyStopCommandSuite --> BotCommandParser
    DilemmaPlugin --> DilemmaCommandSuite
    DilemmaPlugin --> DilemmaService
    DilemmaService --> MongoDilemmaRepository
    DilemmaService --> EconomyPort
    DilemmaCommandSuite --> DilemmaService
    DilemmaCommandSuite --> BotCommandParser
    ShopFeaturePlugin --> ShopCommandSuite
    ShopFeaturePlugin --> ShopLocationAccess
    ShopFeaturePlugin --> MapLocationEventBus : observed entry/exit
    ShopFeaturePlugin --> ShopService
    ShopService --> MongoShopRepository
    ShopService --> EconomyPort
    ShopService --> CharacterAppearanceApi
    ShopCommandSuite --> BotCommandParser
    CommandRouterPlugin --> BcCommandParser : passes BC messages
    BcCommandParser --|> BotCommandParser : normalized commands
    CommandRouterPlugin --> BcClient : owns message subscription
    DilemmaService --> KeyedDeadlines : session expiry
    VeratownMapRuntime --> KeyedDeadlines : door and notification timers
    ShopService --> KeyedDeadlines : favor deadlines
    LocationAppearanceTriggerPlugin --> KeyedDeadlines : entry delay
    CasinoGameService --> KeyedSerialQueue : per-session operations
    TrickOrTreatPlugin --> TrackedTasks : shutdown drain
    CommunicationPlugin --> TrackedTasks : event work
    DoorFeaturePlugin --> DisposerStack : reverse cleanup
    StagedTablePlugin --> DisposerStack : partial-start rollback
    HotSeatPlugin --> DisposerStack : reverse cleanup
    ShopFeaturePlugin --> DisposerStack : startup rollback
    CasinoFeaturePlugin --> DisposerStack : service shutdown
    DilemmaPlugin --> DisposerStack : preserve resolution drain order
    TrickOrTreatPlugin --> DisposerStack : event drain before service
    WorldAdminPlugin --> DisposerStack : watch cleanup
    AbclFeaturePlugin --> TrackedTasks : fence and drain accepted work
    HelperBotsPlugin --> TrackedTasks : fence and drain repository work
    BotCommandParser --> ActionCoordinator : handlers use journaled actions
    BotCommandParser --> ScopedHelpService : global/scoped help
    BotCommandParser --> CommandHelpCatalog : registration lifecycle
    BotCommandParser --> CommandAliasResolver : fallback after real commands
    ScopedHelpService --> CommandHelpCatalog
    ScopedHelpService --> HelpContextPort
    ScopedHelpService --> CharacterCommunicationApi : private whispers only
    ActionCoordinator --> MongoActionJournal

World administration owns guided sessions, game-config watching, and the map/room creation-template watchers; shutdown disposes them before closing MongoDB. All guided controls recheck BC room-admin permissions. Map save/edit/restore append revisions, while only synchronized BC map observations can be saved as live snapshots. See world administration for commands and stored-versus-live semantics.

Plug-in Lifecycle

stateDiagram-v2
    [*] --> Constructed
    Constructed --> Starting: BotPluginHost.start()
    Starting --> Running: all plug-ins started in registration order
    Starting --> RollingBack: plug-in start throws
    RollingBack --> Stopped: stop started/partially-started plug-ins in reverse order
    Running --> Stopping: shutdown signal
    Stopping --> Stopped: stop plug-ins in reverse order
    Stopped --> [*]

The current static order is doors/map, location-entry messages, delayed location appearance, emergency release, location-entry behavior, world administration, communication/social, prototype status, appearance, Anonymous Whisperer, casino, shop, Truth/Dare, Dilemma, Hot Seat, Trick-or-Treat, helper bots, shower, then command routing. Thus command handlers and event listeners are installed before the BC client starts. Emergency release registers only native /bot emergency-release and applies the configured set to the requesting member. Anonymous Whisperer uses a separate explicit private-submission handler and remains database-disabled by default. Casino starts its approved slots/roulette engine and recovery; shop starts order reconciliation and region-gated commands, while release/favor use remains disabled until adult-policy confirmation. Truth/Dare recovery is enabled following operator-reported qualification and approval on 2026-10-15, while room configuration remains disabled by default. Dilemma registers commands and timer recovery but starts no round without enabled room config. Hot Seat starts recovery and private commands, but disabled room configuration prevents play. Trick-or-Treat listens to the shared map event bus, and only post-start position_update entries can offer a checkpoint; commands and invitations reply privately. Shower is opt-in and accepts matching local BC room updates without additional helper corroboration; its placement after helper bots preserves reverse-order shutdown with live room updates and optional helper observations available during restoration. Shutdown stops plugins in reverse order, draining game work and removing listeners, command registrations, watchers, and timers before closing the BC connection. On startup failure, the host attempts cleanup for the failing plugin and each earlier plugin.

For the complete wiring and data-authority model, see Bot Runtime Architecture.

Command Routing

stateDiagram-v2
    [*] --> IncomingMessage
    IncomingMessage --> NativeBotPacket: Hidden + ChatRoomBot prefix
    IncomingMessage --> BangMessage: Chat/Emote/Whisper + !prefix
    IncomingMessage --> Ignored: other message
    NativeBotPacket --> TargetCheck: explicit target must be this bot
    TargetCheck --> Ignored: another explicit target / invalid sender
    TargetCheck --> RegisteredLookup: target omitted or matches bot
    BangMessage --> NormalizeWhisper: strip BC wrapper when Whisper
    NormalizeWhisper --> TargetCheck: targeted Whisper validation
    RegisteredLookup --> LongestPrefixMatch: parse command words
    LongestPrefixMatch --> Handler: registered command wins
    LongestPrefixMatch --> AliasFallback: no registered command
    AliasFallback --> Handler: valid alias target routed once
    AliasFallback --> PrivateUnknown: no valid alias
    LongestPrefixMatch --> PrivateHelp: help requested
    LongestPrefixMatch --> PrivateUnknown: no handler found
    Handler --> ActionLayer: feature invokes dedicated action/service
    ActionLayer --> PrivateReply: command provides feedback
    Handler --> [*]
    PrivateHelp --> [*]
    PrivateUnknown --> [*]
    Ignored --> [*]

The registry supports multiword command paths and chooses the longest registered prefix. For example, door group ... is routed to the admin suite before the parent door handler. Only when no registered path matches does CommandAliasResolver rewrite the leading alias once; real commands always win. Aliases and their targets are validated against the current command-help catalog. Their target handler retains its own permission and availability checks, and scoped help lists an alias only beside a target visible to that requester. Native /bot door group ... becomes a Hidden ChatRoomBot door group ... packet in BC; bang commands remain available through Chat, Emote, and Whisper.

Anonymous Whisperer command and submission input are separate adapters. The command suite handles opt-in, round start, and accusations. A dedicated WhispererSubmissionHandler accepts only an explicit direct Whisper with the game prefix and publish-consent phrase; it never echoes or logs the response. The generic command trace suppresses option-name fragments for this command, and the resulting room send uses the redacted communication action.

Adding A Feature

  1. Put domain and administrative use cases in a transport-neutral feature service that depends on repository/action ports, not channel APIs.
  2. Implement a thin adapter per channel: a CommandSuite for BC, an operator entrypoint for CLI, or a future Discord adapter. Each adapter owns its own authorization and input/output mapping; never copy policy rules into adapters.
  3. Create a BotPlugin under src/features/<feature>/ for feature lifecycle and BC event subscriptions, or a distinct command-adapter plugin where that lifecycle is clearer.
  4. Register commands in start() and retain the disposer returned by BotCommandParser.register(). Inject shared services; do not construct a second Socket.IO client.
  5. Use the existing action/adapter/contract path for BC mutations so outcomes and journal entries remain consistent.
  6. Add the plug-in to the static list in src/app/start.ts, with service tests using fake ports and adapter tests for authorization, routing, and cleanup.

Command permissions belong in the feature command suite/service, not in the generic parser. The BC adapter handles BC syntax and source normalization; the core registry routes normalized commands and sends private unknown-command replies. Each suite registers reviewed CommandHelpDescriptor metadata with its command disposer. ScopedHelpService resolves the current room-member context, applies explicit public/admin and availability policies fail-closed, and renders only visible capabilities, including operator aliases for visible targets. A feature-wide availability policy hides every descriptor for that feature, including administrative setup help, while disabled; per-capability policies can additionally hide commands such as games unavailable at the requester’s location. /bot help, !help, scoped <feature> help, and marked bare-feature fallbacks share that service; !<alias> help opens help for the canonical target. Unknown and inaccessible scopes use the same neutral private response. Oversized help is paginated on descriptor boundaries with !<scope> help page <n> continuation links; every page recomputes visibility and is rate-limited. Output is suppressed if room, permission, location/configuration revision, or catalog state changes before enqueue. A help listing never replaces command-time authorization.

Reusable Service And Channel-Adapter Pattern

Administrative and future non-BC interfaces share feature use cases, not transport behavior or permissions:

flowchart LR
    BC[BC command suite] -->|room-admin check| UseCases[Feature use-case service]
    CLI[Operator CLI] -->|operator invocation guard| UseCases
    Discord[Future Discord adapter] -->|guild and role check| UseCases
    UseCases --> Repositories[Feature repository ports]
    UseCases --> Actions[Feature action ports]

Location entry behavior is edited through the room-scoped world-config service at main.location-entry-behavior.rules; legacy main.bunny.rules remains a read-only compatibility fallback unless explicitly edited. Referenced appearance itemsets are managed through the standard appearance itemset editor. Timer locks and their expiry properties are part of those generic itemset definitions and are passed to BC unchanged. Generic itemset applications can select conditional variants from the target’s latest observed appearance; the composition root injects the BC prerequisite evaluator, and unknown evidence does not match. Consumers with fixed-set policies reject variants.