|
VoltMod
C++23 framework for CS2 server plugins
|
Run installs the command and hands nothing back. A command lives as long as the VoltMod::CommandManager that owns it, and VoltMod drops every one before destroying the plugin, so a handler cannot outlive the state it captured. There is no way to unregister one command. The builder is single use: Add starts a new one.
Per invocation the manager resolves the name or alias, authorizes the caller, checks arity, binds every argument, runs the handler, and routes the reply through Policy::Reply (falling back to Messages::Send). Any failure stops before the handler and replies with a localized error.
Each handler parameter after the leading VoltMod::Caller is one argument.
| Parameter | Consumes | Read with |
|---|---|---|
Args::Target | one token via the selector grammar, one online player | t.Value, never null; t->Name() reads through it |
Args::Targets | one token naming several players (@all, @t, ...) | t.Value, a std::vector<Player*>, never empty |
Args::Duration | 30 (minutes), 30s/5m/2h/7d, 0/perm | d.Value, a std::chrono::seconds; 0 means permanent |
Args::SteamId | numeric SteamID64 | id.Value |
Args::PlayerOrSteamId | an online player, or a bare SteamID64 for an offline one | who.Online (may be null) and who.SteamId |
Args::Int | integer | n.Value |
Args::U64 | non-negative 64-bit id (a workshop id) | n.Value |
Args::Word | one verbatim token | w.Value |
Args::Rest | the remainder of the line | r.Value |
Args::Opt<T> | any of the above, optionally | o.Value, a std::optional<T>; o.ValueOr(fallback) for the inner value |
Three rules are compile-time, and breaking one is a static_assert naming the signature: every parameter after Caller is an Args:: type, only trailing arguments may be Args::Opt, and Args::Rest must be last. A generic lambda cannot be a handler - its parameter list is the argument spec, so the types have to be written out.
The handler does not run until every required argument resolves, so Args::Target::Value needs no null check. Args::Opt<T> is the only argument that can be absent, and its default belongs in the handler:
c.Translations.Get(key) with no slot resolves the server language, which is what a reason written to the database or announced to everyone wants; c.Ok, c.Fail and c.Say resolve the caller's.
VoltMod::Caller is the first parameter: c.Player is the player (null when the server ran the command, which c.IsServer() checks), c.Slot their slot (-1 for the server, which is also the server-language slot), and c.Translations the translation table.
| Call | Result |
|---|---|
c.Ok(key, tokens) | succeed, replying with key in the caller's language |
c.Fail(key, tokens) | fail, replying the same way; it is a Result error, not a success |
| return nothing | handled, with nothing to say (a menu, a broadcast) |
c.Say(key, tokens) | send one extra line now |
c.SayRaw(line) | send one already-formatted line now |
c.Text(key, tokens) | the localized line, to use for something other than a reply |
A handler that returns c.Ok or c.Fail needs no return type. One that mixes them with silence returns Reply::Silent() for it and declares -> Result<Reply>, because a lambda cannot deduce a type from both a value and nothing.
Multi-line output is a run of Say/SayRaw followed by Ok or Reply::Silent(), so it goes through the same reply callback as everything else:
Permission(...) gates the command on runtime.Policy.Authorize, which asks the plugin that publishes IPermissions (admin-system). While none is loaded the command is denied, not allowed, and the first denial is logged. Failing open there would hand every player every command.
An empty permission skips the check. See Players, targeting, and actions for the rest of the gate.
By default only players can run a command, from chat.
| Builder call | Players, chat | Players, own console | Server console, rcon, cfg |
|---|---|---|---|
| (nothing) | yes | no | no |
.Anywhere() | yes | yes | yes |
.ServerOnly() | no | no | yes |
Anywhere() and ServerOnly() register a real tier1 ConCommand of the same name, so rcon, cfg files and ExecuteServerCommand reach the same handler:
Server console calls (rcon, cfg files) run the same binder and handler, print their reply to the console, and have no caller: c.Player is null, c.Slot is -1, permissions are skipped (the console is the server), and caller-relative selectors such as @me match nobody. An .Anywhere() command typed in a player's own console runs as that player, exactly as from chat, and replies in chat. A ServerOnly() command ignores players, so put an operator command with no permission there.
To run another plugin's console command as a player, use runtime.Entities.Controller(slot).ExecuteCommand("mm_lvl"). Nothing is echoed to chat.
The framework takes pending menu input first, then sends ! messages through HandleChatMessage; unknown names fall through to normal chat, and every line neither took is raised as runtime.Players.Said. A plugin with chat rules of its own, such as mutes or admin tags, subscribes to that and sets Blocked.
A line naming a command another plugin registered never reaches this plugin's chat handling: it goes on to that plugin, so chat filters such as admin tagging cannot swallow !m before its owner sees it.
The tokenizer treats a "quoted run" as one token and \"</tt> as a literal quote, so
<tt>!ban Bob 30 "wall bang"</tt> is three arguments. Repeated spaces produce no empty arguments; an
explicit <tt>""</tt> does. Console invocations are split by the engine, which quotes the same way.
@section autotoc_md15 Target selectors
<tt>Args::Target</tt> and <tt>Args::Targets</tt> understand:
@icode
@all @* everyone @me yourself @!me everyone else
@t @ct @spec by team @dead @alive @bot @human
@random one random player @randomt @randomct one random per team
#3 slot index 765611... STEAM_... [U:1:...] SteamIDs
name exact match, then prefix, then substring (case-insensitive)
@endicode
Every candidate passes through <tt>runtime.Policy.Authorize</tt>. Rejected matches are removed, and an
all-immune result reports immunity rather than no match. Self-targeting is allowed. A selector
leaving several players binds to <tt>Args::Targets</tt> and fails on <tt>Args::Target</tt>.
@section autotoc_md16 Usage lines and reserved keys
The usage line is built from the argument types and localized, so no English literal lives in
C++. <tt>cmd.usage</tt> is the frame and the <tt>cmd.usage.</tt> keys supply each placeholder:
@icode
cmd.usage "Usage: {usage}"
cmd.usage.target "target" cmd.usage.targets "targets"
cmd.usage.duration "duration" cmd.usage.steamId "steamId"
cmd.usage.playerOrSteamId "target|steamId" cmd.usage.int "number"
cmd.usage.u64 "id" cmd.usage.word "value"
cmd.usage.rest "reason"
@endicode
Required arguments get angle brackets and optional ones square brackets:
@icode{text}
!ban <target> <duration> [reason]
@endicode
The prefix comes from the surface being answered - <tt>!</tt> in chat, nothing in the console. <tt>cmd.usage</tt>
also receives <tt>{prefix}</tt>, <tt>{command}</tt> and <tt>{args}</tt> separately. <tt>UsageKey("cmd.unbanUsage") replaces the whole line with one key of your own.
Argument failures reply from target.noMatch, target.immune, target.ambiguous (gets {count}), target.dead, target.bot, cmd.badDuration, cmd.badSteamId, cmd.badNumber, cmd.noPermission, cmd.tooManyArgs, and cmd.usage for arity errors. The framework ships English defaults for all of them; your own translation file wins.
The router reaches engine state only through ArgBinder, so parsing, binding, permissions, surfaces and replies test with a stub binder. See Testing.