命令描述语句,也称命令规范字符串 spec,用于声明一条命令的固定路径、参数顺序、参数类型和结构约束。
rs item give <target:minecraft:player> <item:minecraft:item> [amount:int(1..64)=1]
描述语句只定义命令结构。业务处理器由 register(...) 指定,权限、执行位置和命令说明由 CommandOptions 指定。
一条完整命令描述语句由固定字面量和参数组成:
根命名空间 固定字面量... <必填参数> [可选参数]...
例如:
rs control rename <target:minecraft:player> [silent:bool=false]
可以拆成:
rs:根命名空间,对应 Bukkit 中声明的根命令。control rename:固定字面量路径。<target:minecraft:player>:名为 target 的必填参数。[silent:bool=false]:名为 silent、默认值为 false 的可选参数。逐条注册时,spec 必须包含根命名空间:
commands.register(
"rs control rename <target:minecraft:player>",
executor,
permission,
target,
i18n
);
在命令组中注册时,命令组已经保存了根和父级路径,因此叶子规范使用相对路径:
renameGroup.register(
"item <slot:int>",
executor,
permission,
target,
i18n
);
命令组会先把相对规范展开成完整路径,再交给同一个规范解析器。逐条注册和命令组注册不会产生两种不同的命令语法。
不属于参数括号的普通 token 会被解析为固定字面量:
rs control rename item
字面量用于建立明确的命令路径,匹配时不区分大小写。所有字面量必须位于第一个参数之前:
rs find player <name:string>
下面的结构不被允许:
rs find <name:string> detail
参数开始后再出现字面量会使候选分支难以静态确定,也会使帮助用法和 Tab 补全产生歧义,因此规范解析器会在注册时拒绝它。
当字面量本身以 < 或 [ 开始时,可以使用反引号把它标记为字面量:
rs compare `<` <value:int>
反引号只作用于 spec 的结构解析。它不会为玩家输入增加引号功能,也不能让带空格文本变成一个 Bukkit 命令 token。
必填参数使用尖括号:
<name:string>
可选参数使用方括号:
[page:int]
参数内部的一般结构为:
参数名:类型表达式=默认值 @内联显示名
只有参数名是必需部分。没有声明类型时,默认使用 string:
<name>
等价于:
<name:string>
参数名是命令结构中的稳定标识。执行器通过它取得解析结果,语言标签也通过它关联显示名称:
Player target = ctx.get("target");
CommandOptions.options()
.label("target", lang.command.target);
修改语言标签只会改变帮助页中的显示文字,不会改变 ctx.get("target") 使用的键。
可选参数可以不提供输入,也可以声明默认值:
[page:int=1]
默认值在规范中仍然是一段文本。命令执行时,它会交给当前参数的 TypeResolver 解析,因此执行器取得的是解析后的对象,而不是未经处理的字符串。
没有默认值且没有输入的可选参数不会写入上下文。业务代码可以使用 ctx.getOr(...) 提供业务默认值:
int page = ctx.getOr("page", 1);
参数布局需要满足以下结构规则:
text 类型必须位于参数列表末尾。注册时会检查这些规则。描述语句不合法时,注册失败并抛出 IllegalArgumentException。
参数可以使用 @ 声明帮助页中的内联显示名:
<page:int @页码>
内联显示名属于显示层,不参与参数解析。它会出现在帮助用法中;当参数的 TypeResolver 没有实际候选时,也会作为空参数位置的 Tab 提示。面向多语言插件时,更推荐使用稳定参数名配合 CommandOptions.label(...):
CommandOptions.options()
.label("page", lang.command.page);
旧规范中的 @i18n 标记仍由解析器兼容,但新的语言引用接口不需要该标记。新代码应直接在注册参数中传入 LangText 引用,使语言文件重载后仍能得到最新显示值。
冒号后的内容是类型表达式:
<参数名:类型ID>
类型 ID 只是描述语句交给解析系统的标识。例如 int 属于 Core,minecraft:player 属于 Bukkit Runtime。规范解析器不会根据名称自行猜测 Java 类型,也不会在注册时查询玩家或世界。
类型可以携带元数据:
<amount:int(1..64)>
<amount:int[1..64]>
<name:string(regex=^[a-z]+$)>
<mode:enum{add,remove,clear}>
这三种括号写法会被解析为以下元数据,供类型解析器读取:
(key=value,...):生成命名元数据。(min..max) 或 [min..max]:生成 min 与 max 元数据。{...}:把内部内容作为 body 原样传递。配置是否有意义由对应 TypeResolver 决定。描述语句能够正确读出 min=1,并不代表任意类型都会使用 min。各类型支持的配置和输入要求请见:命令参数类型 ⇱。
多个类型可以使用最外层的 | 组成联合类型:
<target:minecraft:player|uuid|string>
执行时,命令服务按照声明顺序尝试各个 TypeResolver,第一个成功解析的结果会写入上下文。因此,调整类型顺序可能改变解析结果。接受范围最宽的回退类型通常应放在最后。
只有类型表达式最外层的 | 会拆分联合。配置内部的符号保持原义:
<action:string(regex=^(add|remove)$)|enum(clear)>
上例包含两个候选类型,正则表达式内部的 | 不会产生额外分支。
联合类型改变的是同一个参数允许产生的值,不会建立多个可重载的命令叶子。执行器应根据实际返回对象处理不同结果:
Object target = ctx.get("target");
if (target instanceof Player player) {
inspect(player);
} else if (target instanceof UUID uniqueId) {
inspect(uniqueId);
}
TypeResolver 是参数解析器接口。描述语句声明参数类型后,由对应解析器检查输入,并将其转换为 Java 对象。
接口包含三个方法:
interface TypeResolver {
boolean supports(String typeId);
Object parse(ParseCtx ctx, String token) throws Exception;
List<String> complete(ParseCtx ctx, String prefix);
}
supports(...) 判断解析器是否负责某个类型 ID。命令执行时,parse(...) 接收当前 token 并返回最终对象;输入不满足要求时抛出异常。Tab 补全时,complete(...) 根据当前输入前缀返回候选。解析器返回空列表且参数尚未输入时,命令服务会回退到参数说明或参数名,因此自定义解析器不需要自行重复这套显示逻辑。
ParseCtx 向解析器提供四类上下文:
vars():同一命令中此前已经成功解析的参数。meta():描述语句从类型配置产生的元数据。platform():当前平台上下文,在 Bukkit Runtime 中通常是插件对象。sender():当前命令发送者。因此参数解析器不必局限于字符串转换。minecraft:player 可以根据发送者输入查询在线玩家,位置解析器可以取得发送者当前世界,交互解析器还可以挂起命令并等待 Bukkit 事件。
Core 安装基础类型解析器,Bukkit Runtime 再安装平台对象和交互解析器。平台能力通过相同接口加入命令系统,Core 不需要直接依赖 Bukkit 类型。这也是类型 ID 使用 minecraft: 命名空间的原因,它用来区分基础类型和 Minecraft 平台类型。
虽然 TypeResolver 是公开接口,当前 LinCommand API 尚未提供插件侧注册入口。开发者不应强制转换到 LinCommandImpl 或依赖 Runtime 内部方法来添加类型。在公开扩展入口完成之前,应先用已有类型接收输入,再在业务代码中转换为项目所需的对象。
命令描述语句只在注册时建立结构,不会保存业务对象,也不会提前执行 TypeResolver:
flowchart LR
Spec["命令描述语句"] --> ParseSpec["SpecParser 解析结构"]
ParseSpec --> Node["生成命令节点"]
Node --> Registry["写入统一注册表"]
Input["发送者输入命令"] --> Match["匹配字面量和参数位置"]
Registry --> Match
Match --> Guard["检查权限与执行位置"]
Guard --> Resolve["TypeResolver 解析参数"]
Resolve --> Context["建立命令上下文"]
Context --> Execute["执行业务处理器"]
这种分层保证无权限发送者不会触发玩家查询、文件读取或交互等待,也让 Tab 补全可以复用相同类型解析器,而不需要复制参数规则。
注册表使用固定字面量路径以及必填、可选参数的形状识别命令签名。参数名和类型 ID 不用于区分两个同形叶子:
rs find <id:int>
rs find <name:string>
第二条命令不会成为 Java 方法重载式的另一个分支,而会被视为重复签名。否则同一输入究竟进入哪个执行器将依赖解析器尝试顺序,使权限、帮助和失败回调都难以保持确定。
需要不同分支时,应增加明确字面量:
rs find id <id:int>
rs find name <name:string>
同一位置本来就接受多种值时,应使用联合类型:
rs find <target:int|string>
使用不同字面量区分命令用途,使用联合类型表示同一参数的多种输入,命令的执行路径会更容易判断。
讨论