The operator console is a typed, socket-free dispatcher over the server facade
Status: Accepted (2026-07-07)
Context
The operator console had grown into one large async function: every command parsed its own arguments, sent its own packets on the socket and logged its own outcome. Parsing, the domain action and socket I/O were mixed, so no command could be tested without a socket, and a second way into the console (a TCP or web admin adapter) would have meant rewriting every command.
The console needs enough structure that future adapters and resource-owned commands fit without changing the contract, but no code for consumers that do not exist yet. It builds on the server facade (ADR 0009).
Decision
The dispatcher is synchronous and adapter-agnostic.
operator::dispatch(line, &mut ServerApi)returnsOption<OperatorOutcome>: a typed reply (statusandtext) plus theSends the command produced;Noneis a blank line. It does no socket I/O. The adapter (stdin today, the only one) delivers the sends and logs the reply. The facade borrow ends before any.await, as everywhere else in the server.Commands are a table, not an enum. A built-in command is a
BuiltinCommand { name, handler }in a static table; the dispatcher looks the name up and calls the handler, which parses its own arguments and owns its usage string. Each command is testable on its own, and a second lookup stage (resource-owned commands) adds to the table lookup instead of rewriting it.A handler sees only
ServerApi. No handler touches the wire plane, the roster, the transport or protocol types, so the console is not a second mutation path around the facade.The module lives in the server binary (
src/operator/), not the library. The console is a concern of the binary; the facade is the library's.Outcomes are typed.
Success,Usage(bad arguments, nothing changed),Failed(the facade refused, nothing changed) andUnknown(no such command). A remote adapter can map them to request results; the stdin adapter mapsSuccessto an info log line and the rest to warnings.Ownership of console commands. The core keeps only what is the server's own business; gameplay, world and admin actions belong to resources:
resource list | start | stop | reloadmanages the resource host (ADR 0025) and is handled inmain.rs, because it mutates the host rather than the facade;help,status,uptime,version(sver) andshutdown [reason]need server-loop context the facade deliberately lacks (ports, uptime, the resource host, the stop signal) and live insrc/console.rs.shutdown(like Ctrl-C or SIGTERM) stops every resource, each after the ones that depend on it, then exits;- a resource adds console commands with
dlmp.add_console_command(name, fn)(one resource per name, removed with the resource's stop); the shippeddemoresource providesban,unbanandbansthis way; - the operator table holds
players(a read-only listing, including connections still waiting for login) andpassword set <text> | clear(the server password, as in the config).
The console tries these planes in that order. A
passwordline never reaches resource commands, because it carries the password.
Consequences
- Each command is a small function tested without a socket.
- A command's reply is text with a typed status rather than structured log fields; argument names and order are kept stable.
- Remote adapters, a channel feeding the dispatcher, access control and permission metadata on commands are not built: they have no consumer. A remote adapter would sit on the same
dispatch -> (reply, sends)seam; a remote-console protocol, if ever needed, would be an alias over this plane, not a second path into the server state. - The client and the wire protocol are not involved; the console is server-side only.
Alternatives considered
- An
OperatorCommandenum parsed first and executed in a central match. Rejected: two matches per command, and it cannot take dynamic, resource-owned commands without moving to boxed handlers later. - Putting the dispatcher in the library. Rejected: it would pull console-only data and concerns into the public domain crate and blur the console/domain boundary.
- A boolean success flag or raw log levels as the outcome. Rejected: future adapters need a shared, typed meaning of the result.
- Keeping gameplay commands (weapons, wanted level, kick, teleport, time, weather) as core built-ins. Rejected: they are gamemode policy and belong to resources, which can expose them through
dlmp.add_console_command.
