Skip to content
Kz edited this page Oct 17, 2026 · 6 revisions

Compat 模块

查询“当前服务端属于哪个已验证版本基准、支持哪些能力”,而不是到处判断版本号;另外提供按玩家发送的数据包侧边栏 Sidebars。

模块名 制品 自动带入
compat me.kzheart.klib:klib-compat 无
compat-v1_12 me.kzheart.klib:klib-compat-v1_12 compat
compat-v1_20 me.kzheart.klib:klib-compat-v1_20 compat
compat-v1_21 me.kzheart.klib:klib-compat-v1_21 compat
compat-v26 me.kzheart.klib:klib-compat-v26 compat

快速开始

只用契约和自定义提供者时,选基础模块:

klib {
    modules {
        compat()
    }
}

需要仓库内全部版本实现时,显式选择四个实现:

klib {
    modules {
        compatV1_12()
        compatV1_20()
        compatV1_21()
        compatV26()
    }
}

依赖、直接引用制品和打包方式见 构建与打包。

  • Gradle 插件按模块图自动补入 compat。compat 系列不依赖 core,不会因此引入 core。
  • 发布依赖里没有 Bukkit、NMS 或其他第三方运行时库。
  • 侧边栏运行时通过反射访问服务端内部类;Sidebars 编译期使用 Spigot API 与 klib-core,用它的插件还要选 core()。

不用 Gradle 插件时直接依赖发布物,内嵌与重定位由自己的打包流程处理:

dependencies {
    implementation("me.kzheart.klib:klib-compat:<klib-version>")
    implementation("me.kzheart.klib:klib-compat-v1_12:<klib-version>")
    implementation("me.kzheart.klib:klib-compat-v1_20:<klib-version>")
    implementation("me.kzheart.klib:klib-compat-v1_21:<klib-version>")
    implementation("me.kzheart.klib:klib-compat-v26:<klib-version>")
}

最小示例:解析当前服务端并查询能力。

import me.kzheart.klib.compat.CompatProvider;
import me.kzheart.klib.compat.CompatProviders;
import me.kzheart.klib.compat.CompatResolver;

CompatResolver resolver = CompatProviders.resolver();
CompatProvider provider = resolver.resolve(getServer().getBukkitVersion());
logger().info("兼容实现:" + provider.id() + " / " + provider.version());
boolean hexColors = provider.capability(Capabilities.TEXT)
        .map(TextBridge::supportsHexColors)
        .orElse(false);

boolean legacyMaterialNames = provider.capability(Capabilities.MATERIAL)
        .map(MaterialBridge::usesLegacyNames)
        .orElseThrow(() -> new IllegalStateException("缺少材质能力"));

它是什么,不是什么

klib-compat 是能力声明注册表与版本选择工具:把“哪个版本区间由哪个适配器负责、它声明了哪些能力”整理成可查询、可自检的数据结构。业务代码面向 TextBridge、NbtBridge、MaterialBridge、InventoryBridge 和 SidebarBridge。

  • 解析、选择和能力查询都不依赖 Bukkit 类型。四个实现模块只描述已经验证的基准差异。
  • 除侧边栏外,它不会替你执行 Bukkit/NMS 适配。TextBridge、NbtBridge、MaterialBridge、InventoryBridge 只回答布尔形式的能力问题,不封装物品、文本或容器操作。
  • SidebarBridge 是唯一真正发送数据包的能力,由 数据包侧边栏 中的 Sidebars 使用。
  • Klib 其他模块的运行时版本适配(如 klib-item 的物品标签桥接、klib-lang 的文本渲染差异)在各自模块内部完成,不经过 CompatResolver。打不打包 compat-v* 都不改变它们的行为。
适合 不适合
插件自身要按版本分支,用统一口径查询所属基准和能力 期待引入本模块后 Bukkit API 的版本差异被自动抹平
启动自检或排障时,输出所有已打包适配器的能力矩阵
按同样的模型登记自定义的版本相关能力
按玩家显示、不替换玩家 Bukkit 记分板的侧边栏
新增有真实服务器回归证据的版本适配器

创建解析器

入口 行为
CompatProviders.resolver() 装配已打包的内置实现,返回 CompatResolver(推荐)
CompatProviders.resolve(String) 只解析一次时一步到位
CompatProviders.detectServerVersion() 反射读 org.bukkit.Bukkit.getBukkitVersion();不在 Bukkit 运行时返回 Optional.empty()
CompatProviders.resolveCurrent() 探测 + 解析一步完成;探测不到时抛 IllegalStateException
CompatProviders.discover() 已打包的内置实现列表;只打包基础模块时为空
new CompatResolver(List) 手动指定参与解析的集合
CompatProvider provider = CompatProviders.resolve(getServer().getBukkitVersion());
// 反射读取 org.bukkit.Bukkit.getBukkitVersion();不在 Bukkit 运行时返回 Optional.empty()
Optional<String> detected = CompatProviders.detectServerVersion();

// 探测 + 解析一步完成;探测不到时抛 IllegalStateException
CompatProvider current = CompatProviders.resolveCurrent();

本模块不依赖 Bukkit,所以版本字符串默认由调用方传入;运行在 Bukkit 上时才可以用上面的自动探测。

CompatProviders 的发现方式:

  • 按固定的实现类名列表用 Class.forName 探测,没打包的实现直接跳过。
  • 不用 ServiceLoader,也不需要 META-INF/services 资源。
  • 类名字符串常量会被 Klib Gradle 插件的类文件重定位一并改写,shade + relocate 产物里同样有效。
  • 发现结果不缓存。建议启用时装配一次,自己持有 CompatResolver。
  • 一个内置实现都没打包时,resolver() / resolve(String) 抛 IllegalStateException,消息里列出期望的实现类名。

需要混入自己的提供者,或刻意排除某个实现时,手动构造:

CompatResolver resolver = new CompatResolver(Arrays.asList(
        new V1_12CompatImplementation(),
        new V1_20CompatImplementation(),
        new V1_21CompatImplementation(),
        new V26CompatImplementation()));

版本解析与选择规则

  • ServerVersion.parse 在字符串中查找第一个 主版本.次版本[.补丁],可以直接解析常见 Bukkit 版本文本;省略补丁时按 0 处理。
  • 无法解析或没有适配器覆盖时抛 IllegalArgumentException。不要静默假定最新版能力。
  • 无匹配时的异常消息列出每个已注册提供者的基准版本和落选原因(基准高于服务端 / 未声明支持该版本),据此区分是漏打包实现模块,还是版本落在覆盖空隙。
  • 解析器选择“基准版本不高于当前服务器、且声明支持当前服务器”的最新实现。

构造期校验以下约束,任一不满足都抛 IllegalArgumentException:

  • 提供者集合非空;
  • 每个提供者的 ID 唯一;
  • 每个提供者的基准版本唯一;
  • 每个提供者必须 supports 自己的基准版本(否则该实现永远无法被选中)。

解析区间

下表是当前四个实现通过 CompatResolver 的实际解析结果。区间同时受 supports 和“基准版本不得高于当前版本”约束。

实现 适配器基准版本(SERVER_VERSION) 实测联机版本 supports 声明范围 实际可解析范围
compat-v1_12 1.12.2 1.12.2 [1.12.2, 1.20.0) [1.12.2, 1.20.0)
compat-v1_20 1.20.4 1.20.4 [1.20.0, 1.21.0) [1.20.4, 1.21.0)
compat-v1_21 1.21(即 1.21.0) 1.21.4 [1.21.0, 26.2.0) [1.21.0, 26.2.0)
compat-v26 26.2(即 26.2.0) 26.2 同为 26.x 且不低于 26.2 [26.2.0, 27.0.0)
  • 适配器基准版本:实现类中 SERVER_VERSION 常量声明的支持起点,决定区间下界。ServerVersion 省略补丁号时按 0 补齐,"1.21" 与 1.21.0 等价。
  • 实测联机版本:根 README.md 里该版本线真实跑过回归的服务端版本(如 1.21 线是 1.21.4)。基准版本可以低于实测版本,解析行为以表中的代码常量为准。
  • 适配器只按版本字符串选择实现。表中是解析器的选择范围,不等于区间内每个服务器版本都经过实际运行验证。

覆盖空隙与回退:

  • 当前没有实现覆盖低于 1.12.2、1.20.0–1.20.3、27.x 及更高版本。
  • 26.0/26.1 会落到 compat-v1_21,这是源码中明确保留的最近实现回退。
  • 新增覆盖范围前应先有真实服务器回归证据,不能仅靠版本号猜测行为。

内部能力矩阵

四个实现都公开完整的五项能力,但返回值或实现方式不同:

实现 十六进制颜色 PersistentDataContainer 旧材质名 运行时更新容器标题 侧边栏
compat-v1_12 否 否 是 否 队伍前缀,显示红色分数
compat-v1_20 是 是 否 是 分数显示文本,隐藏分数
compat-v1_21 是 是 否 是 分数显示文本,隐藏分数
compat-v26 是 是 否 是 分数显示文本,隐藏分数
  • 通过类型化能力键访问(见快速开始),不要强制转换到版本实现。
  • 能力缺失用 Optional.empty() 表达。是否允许降级由业务决定,不要无条件调用 get()。

启动时审计所有实现,可读取能力快照:

CompatCapabilityMatrix matrix = resolver.capabilityMatrix();
for (CompatCapabilityMatrix.Row row : matrix.rows()) {
    logger().info(row.providerId() + " text=" + row.has(Capabilities.TEXT));
}

数据包侧边栏

me.kzheart.klib.compat.sidebar.Sidebars 按玩家维护侧边栏:

  • 标题和行只以数据包形式发给目标玩家的客户端。
  • 不读取、不替换、不修改玩家的 Bukkit Scoreboard,原有队伍、计分项和其他插件的记分板保持不变。
  • 更新时只发送与上一次不同的标题和行。

需要 core(),以及覆盖目标服务端版本的 compat-v*(Gradle 插件自动补入 compat):

klib {
    modules {
        core()
        compatV1_21()
    }
}
import me.kzheart.klib.KPlugin;
import me.kzheart.klib.compat.sidebar.Sidebars;
import me.kzheart.klib.scheduler.Ticks;
import org.bukkit.entity.Player;

public final class QuestPlugin extends KPlugin {
    @Override
    protected void setup() {
        Sidebars sidebars = Sidebars.install(context().scope());
        tasks().every(Ticks.seconds(1), () -> {
            for (Player player : getServer().getOnlinePlayers()) {
                sidebars.show(player, "§6任务追踪",
                        "§f击杀僵尸 §a" + kills(player) + "/10",
                        "§f收集木头 §a5/5");
            }
        });
    }

    private int kills(Player player) {
        return 3;
    }
}
方法 行为
Sidebars.install(Scope) 用 CompatProviders.resolveCurrent() 选出当前版本的实现并要求其公开 Capabilities.SIDEBAR;没打包对应实现或版本不受支持时抛 IllegalStateException / IllegalArgumentException
Sidebars.install(Scope, SidebarBridge) 使用指定实现,例如已经自行解析的 provider.capability(Capabilities.SIDEBAR)
show(Player, String title, String... lines) / show(Player, String, List<String>) 首次调用创建侧边栏,之后只发送差异;lines 自上而下排列,可以为空;离线玩家被忽略
hide(Player) 移除侧边栏;之前未显示时返回 false
isShown(Player) 当前登录会话是否显示着本实例的侧边栏
maxLines() 实现可完整显示的最大行数,当前为 15;超过时 show 抛 IllegalArgumentException 且不发送数据包

在不支持的服务端上需要降级时,先查询能力再安装:

CompatProviders.resolveCurrent().capability(Capabilities.SIDEBAR)
        .map(bridge -> Sidebars.install(context().scope(), bridge))
        .orElse(null);

侧边栏文本与限制

规则 说明
颜色 只用 § 颜色代码,§x 十六进制颜色从 1.16 起生效
不解析 &、MiniMessage、JSON 文本都不解析,需要时先自行转换
换行 行文本不应包含换行
1.12.2 至 1.19.x(compat-v1_12) 每行用一个队伍前缀显示文本,右侧显示客户端默认的红色分数
1.12 长度 行文本超过 16 个字符时按颜色延续拆到后缀,前缀加后缀最多 32 个字符;标题最多 32 个字符;超出部分截断。1.13 起没有这些限制
1.20.4 起 每行是带自定义显示文本的分数,计分项使用空白数字格式,不显示右侧分数
  • 玩家自己的 Bukkit 记分板之后把计分项放到侧边栏槽位时,会覆盖本侧边栏。
  • hide 只清空客户端的侧边栏槽位,不会替其他记分板恢复显示。
  • 每个 Sidebars 实例使用随机的计分项名,多个插件或实例互不覆盖内部状态。但客户端同一时间只显示一个侧边栏,后发送显示请求的一方生效。

侧边栏实服验证

展开:各实现已在哪些服务端版本上实服验证

验证环境为 Paper 服务端 + 真实客户端,覆盖创建、差异更新、增删行、15 行上限、移除与重新登录。

实现 已验证服务端 说明
compat-v1_12 1.12.2、1.16.5、1.18.2 1.16.5 只验证了数据包往返和客户端日志,未截图核对显示
compat-v1_20 1.20.4 1.20.5、1.20.6 按与 1.21 相同的数据包结构实现,未实服验证
compat-v1_21 1.21.1、1.21.4、1.21.11、26.1.2
compat-v26 26.2
  • 1.13 至 1.15、1.17、1.19、1.20.5 至 1.20.6,以及非 Paper 的 Spigot 服务端没有实服验证。
  • 1.20.0 至 1.20.3 没有任何实现覆盖(见 解析区间),resolveCurrent() 会直接失败。

自定义提供者

实现 CompatProvider,或继承 AbstractCompatProvider 并传入不可变能力映射。

  • 能力键由 ID 和 Java 类型共同确定。
  • 自定义键应保存为共享常量,避免不同模块创建外观相同但实际不相等的键。
public final class CustomCompat extends AbstractCompatProvider {
    public CustomCompat() {
        super("custom-1_20", "1.20.6", capabilities());
    }

    @Override
    public boolean supports(ServerVersion version) {
        return version.compareTo(ServerVersion.of(1, 20, 6)) >= 0
                && version.compareTo(ServerVersion.of(1, 21, 0)) < 0;
    }

    private static Map<Capability<?>, Object> capabilities() {
        Map<Capability<?>, Object> values = new HashMap<Capability<?>, Object>();
        values.put(Capabilities.TEXT, (TextBridge) () -> true);
        return values;
    }
}

现代数据包实现共享

解析、能力发现与侧边栏实现使用 Kotlin,Java 仍通过 CompatProviders、Capabilities 与 Sidebars 静态入口调用。业务查询能力并提交标题/行,不需要维护 NMS 名称、包映射或数据包构造器。

现代侧边栏公共逻辑集中在 Compat 内部实现,版本模块只保留版本选择入口。相同 packet shape 不复制整套反射、差异更新或文本逻辑;旧版文本拆分仍由旧版适配器处理。

CompatProvider、SidebarBridge 保留 Java 平台类型契约;仅返回 primitive boolean 的四个能力 SPI 使用等价 Kotlin fun interface,继续支持 Java/Kotlin lambda。NbtBridge 只描述 PDC 能力,实际原生标签读写使用 Item 的 NativeItemTags,不把版本解析当作 NBT 布局探测。

版本解析成功只代表选择到适配器,不等于每个服务端补丁版本均经过实服验证。验证范围以 验证记录 为准。

线程与生命周期

  • 解析器、版本对象、提供者和能力矩阵是普通 JVM 对象,不创建线程,也不需要释放。
  • 除 SidebarBridge 外,桥接能力只是能力描述,不替调用方执行 Bukkit/NMS 操作;业务模块仍需遵守相关 API 的线程约束。
  • Sidebars 由作用域管理释放:
    • install 把实例安装到作用域,并通过作用域事件能力监听 PlayerQuitEvent。玩家退出时直接丢弃其状态,不发送数据包;同一 UUID 重新登录后的首次 show 会重新创建侧边栏。
    • 作用域关闭时向仍在线的玩家移除侧边栏,之后再调用 show 抛 IllegalStateException。
    • 方法可从任意线程调用,同一玩家的更新按调用顺序串行发送。

常见坑

  • compat 基础模块不会自动带入任何版本实现。只打包基础模块时 CompatProviders.discover() 返回空列表,CompatProviders.resolver() 抛 IllegalStateException,直接构造实现类则会在运行时缺类。
  • 能力缺失时不要无条件调用 Optional.get()。
  • 版本匹配不替代目标服务端上的功能验证。版本范围是经过测试的支持边界,不是对未来版本的兼容承诺。
  • 侧边栏反射在首次发送时解析。遇到无法识别的服务端结构会抛出带服务端版本的 IllegalStateException,不会静默降级。
  • 1.20.0–1.20.3 没有实现覆盖,resolveCurrent() 和 Sidebars.install(Scope) 都会失败。

相关页面

  • 构建与打包:模块选择、直接依赖与重定位
  • Core:作用域与调度(Sidebars 需要)
  • Lang:文本渲染的版本差异在 Lang 内部处理

Clone this wiki locally