正在载入

等待时间过长时请刷新页面

编写于

最近更新

命令描述语句

命令描述语句,也称命令规范字符串 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]

可以拆成:

  1. rs:根命名空间,对应 Bukkit 中声明的根命令。
  2. control rename:固定字面量路径。
  3. <target:minecraft:player>:名为 target 的必填参数。
  4. [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);

参数布局需要满足以下结构规则:

  1. 同一条命令中的参数名不能重复,名称比较不区分大小写。
  2. 可选参数之后不能再声明必填参数。
  3. 默认值只能用于可选参数。
  4. 读取剩余全部输入的 text 类型必须位于参数列表末尾。
  5. 参数开始后不能再声明固定字面量。

注册时会检查这些规则。描述语句不合法时,注册失败并抛出 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}>

这三种括号写法会被解析为以下元数据,供类型解析器读取:

  1. (key=value,...):生成命名元数据。
  2. (min..max)[min..max]:生成 minmax 元数据。
  3. {...}:把内部内容作为 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

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 向解析器提供四类上下文:

  1. vars():同一命令中此前已经成功解析的参数。
  2. meta():描述语句从类型配置产生的元数据。
  3. platform():当前平台上下文,在 Bukkit Runtime 中通常是插件对象。
  4. 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>

使用不同字面量区分命令用途,使用联合类型表示同一参数的多种输入,命令的执行路径会更容易判断。

讨论

请登录账号