Repository navigation
Core
所有 Klib 插件的生命周期底座:KPlugin、可组合的 Scope、绑定作用域的 Bukkit 事件和任务,以及统一日志。
| 模块名 | 制品 |
|---|---|
core |
me.kzheart.klib:klib-core |
其他大多数模块都会间接引入它。只选其他模块时无需重复声明 core,Gradle 插件会从模块依赖图中自动补齐。
适合直接使用 Core 的场景:
- 插件启用、重载或关闭时,自动释放监听器、任务和其他资源;
- 把某个功能的生命周期隔离在独立子作用域中;
- 安全地执行同步延迟任务、循环任务,或「异步计算后回主线程」;
- 用简洁的 DSL 注册 Bukkit 事件;
- 实现一个可被其他模块发现的作用域能力。
klib { modules { core() } }依赖、直接引用制品和打包方式见 构建与打包。仍需按目标服务端添加 spigot-api 或 paper-api 的 compileOnly 依赖。
插件主类继承 KPlugin,覆盖无参 setup()。不要再覆盖 onEnable 或 onDisable,这两个入口由 Klib 固定管理。
package com.example.myplugin;
import me.kzheart.klib.KPlugin;
import me.kzheart.klib.scheduler.Ticks;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerJoinEvent;
public final class MyPlugin extends KPlugin {
@Override
protected void setup() {
events().register(new JoinListener());
tasks().every(Ticks.seconds(30), () ->
logger().debug("heartbeat", "plugin is alive"));
}
private final class JoinListener implements Listener {
@EventHandler
public void onJoin(PlayerJoinEvent event) {
logger().info(event.getPlayer().getName() + " joined");
}
}
}-
root指根作用域:在无参setup()中用context().scope()取得,或改为覆盖setup(Scope root)。两种入口不要同时覆盖。 - 插件关闭时根作用域自动关闭。
- 初始化中途失败时,已安装的资源会被清理,然后插件被禁用。
- 示例里的
repository、applyProfile、ArenaRuntime等是业务代码,不是 Klib API。插件完整入口见 快速开始。
KPlugin 的服务入口都绑定插件生命周期,注册的资源属于根作用域:
| 入口 | 用途 |
|---|---|
events().register(Listener) |
复用 Bukkit @EventHandler
|
tasks().register(instance) |
读取 @Every 声明的固定任务 |
tasks().every(...) 等 |
直接创建任务 |
commands() |
命令,见 Command |
configs() |
配置,见 Config |
components().install(instance) |
安装组件,返回可关闭的 ComponentHandle
|
-
KComponent提供相同的服务入口。 - 普通对象可声明 public、无参、返回 void 的
@OnStart、@OnStop。 - 初始化失败会回滚;关闭时先清理注册的资源,再执行
@OnStop。 - 没有自动扫描、注入或隐式线程切换。
完整签名、线程和释放规则见 组件与注解。
任何实现 Disposable 的资源都可以交给作用域持有:
root.install(() -> closeDatabaseConnection());资源按安装顺序的逆序释放。先安装被依赖的底层资源,再安装依赖它的上层资源。
子作用域表示一项可整体重建或关闭的业务功能:
root.scope("arena", arena -> {
ArenaRuntime runtime = arena.install(new ArenaRuntime());
arena.on(PlayerJoinEvent.class, event ->
runtime.handleJoin(event.getPlayer()));
arena.every(Ticks.seconds(1), runtime::tick);
});- 子作用域继承父作用域注册的能力,但资源独立归属。
- 关闭父作用域会连同子作用域一起释放。
Scope.rebuild() 先逆序释放当前作用域的资源,再重新执行创建它时的初始化逻辑。
配置重载通常只更新配置值和业务状态。确实要重建监听器、任务等资源时,把它们放进不含命令声明的子作用域:
Scope gameplay = root.scope("gameplay", scope -> {
ArenaRuntime runtime = scope.install(new ArenaRuntime());
scope.on(PlayerJoinEvent.class, event -> runtime.handleJoin(event.getPlayer()));
scope.every(Ticks.seconds(1), runtime::tick);
});
config.onChange(gameplay::rebuild);命令规则:
- 命令在插件启动时一次声明,处理器通过稳定的服务入口读取最新配置和业务状态。
-
不要把命令放进上面的可重建子作用域,也不要用
config.onChange(root::rebuild)重建含命令的根资源图。 - 命令作用域关闭只保证立即停用执行、补全与权限检查,不保证物理节点立即消失,也不支持再次注册。详见 Command 模块。
不含命令的资源图:
- 可以用
root.rebuild(),也可以从插件实例调用rebuild()。 - 插件的
rebuild()返回是否成功。失败时残留资源会被清理、插件被禁用,不能继续使用旧资源图。 - 需要跨重建保留的状态,放在被重建的作用域之外,或从持久化数据重新构造。
- 不要把已被旧作用域释放的对象继续缓存在静态字段中。
展开:整棵作用域树共享一把锁带来的行为
整棵作用域树共享同一把生命周期锁,子作用域创建时直接复用父作用域的锁。
-
install、remove、scope(...)、registerCapability、findCapability、rebuild()和close()在整棵树范围内串行执行。 - 重建或关闭进行期间,其他线程对树上任意作用域的这些调用都会等待。
由此产生的行为:
-
rebuild()在持锁状态下执行setup。初始化里的阻塞等待(例如等另一个线程回调再继续)会一直占用这把锁,可能造成死锁。初始化应保持非阻塞,耗时工作放到async或初始化完成之后。 -
Scope.dispose()与Scope.close()等价。dispose()只是让作用域能作为父作用域的一项资源被释放。 - 已经关闭的作用域再次
close()无副作用。 - 关闭或重建尚未完成时再次调用
close()(例如在自身的释放回调里关闭同一个作用域)会抛出IllegalStateException。释放回调只负责释放自己的资源。 - 关闭中部分资源抛异常时,其余资源仍继续释放,最后以聚合异常报告失败;作用域被标记为已关闭,不会退回可用状态。
Core 的生命周期、作用域、事件与调度由 Kotlin 实现,Kotlin API、标准库和协程是同一制品依赖图的内置能力,不需要单独的扩展模块。Java 仍直接使用 KPlugin、静态工厂、SAM 回调与 CompletionStage,不需要处理 Continuation。
setup() 仍是普通同步方法,下面的初始化块也同步完成。
import me.kzheart.klib.KLogger
import me.kzheart.klib.KPlugin
import me.kzheart.klib.event.on
import me.kzheart.klib.scope.childScope
import me.kzheart.klib.scope.requireCapability
import org.bukkit.event.player.PlayerJoinEvent
class MyPlugin : KPlugin() {
override fun setup() {
context().scope().childScope("gameplay") {
val log = requireCapability<KLogger>()
on<PlayerJoinEvent> { event ->
log.info("${event.player.name} joined")
}
}
}
}-
scope(name) { ... }创建可重建根作用域,childScope(name) { ... }使用接收者块创建子作用域。 -
requireCapability<T>()返回类型化能力,findCapability<T>()未找到时返回null,registerCapability<T>(value)注册能力。 -
Scope.on<E>(priority = ..., ignoreCancelled = ...) { event -> ... }和events().on<E> { ... }支持 reified 类型与命名参数。 - 事件块是普通函数,不是挂起函数;事件优先级、取消和 Bukkit 事件线程语义保持不变。
| 调用方使用 | 模块内部负责 |
|---|---|
Scope.install、childScope、rebuild
|
资源归属、逆序释放、失败聚合与新旧构建代隔离 |
tasks()、submitSync、scope.coroutines
|
任务附着/取消竞态、关闭门禁与结果终态 |
events()、components()
|
声明检查、监听注册与组件启动失败回滚 |
Java 的 Scope、调度 SPI、注解和 KPlugin 入口保留原契约;保留它们是为了外部实现、继承和 Java 平台类型,不要求调用方切换语言,也没有第二套 Java 执行引擎。
使用 Klib Gradle 插件时,Kotlin/JVM 与私有运行时随选定模块自动接入。手动打包不能只复制 Klib 类而漏掉标准库或协程;依赖选择、重定位和隔离加载限制统一见构建与打包。
root.on(BlockBreakEvent.class, event -> handleBreak(event));需要控制 Bukkit 事件参数时用完整重载:
root.on(
BlockBreakEvent.class,
EventPriority.HIGH,
true,
event -> handleBreak(event));- 第三个参数是 Bukkit 的
ignoreCancelled。 - 返回的
Disposable可以提前释放。通常无需保存,作用域关闭时自动注销订阅。 - 最后一个同类路由订阅被释放时,底层 Bukkit 监听器也会注销。
- 处理器中的异常会被记录,不影响同一路由的其他订阅继续接收事件。
| 方法 | 运行线程 | 返回 | 说明 |
|---|---|---|---|
after(ticks, task) |
Bukkit 同步调度器 | TaskHandle |
可用 0 tick;完成后自动从作用域移除 |
every(ticks, task) |
Bukkit 同步调度器 | TaskHandle |
周期至少 1 tick |
sync(task) |
主线程下一 tick,可从任意线程调用 | TaskHandle |
「回主线程」的官方入口 |
async(supplier) |
Bukkit 异步任务 | 链式 | 接 thenSync / onError
|
submitSync(supplier) |
主线程 | CompletableFuture<T> |
有结果的主线程任务,关闭或重建时取消 |
submitSync(stage, action) |
主线程 | CompletableFuture<R> |
等待源阶段并转换结果,等待和排队均绑定当前构建代 |
syncExecutor() |
主线程 | Executor |
低层投递;不能保证被取消的 JDK 派生阶段终结 |
TaskHandle 可通过 cancel() 提前取消。一次性任务完成后自动移除,长生命周期作用域里不会累积句柄。
root.after(Ticks.of(1), () -> logger().info("next tick"));
root.every(Ticks.seconds(5), () -> updateOnlinePlayers());root.async(() -> repository.loadPlayer(playerId))
.thenSync(profile -> applyProfile(player, profile))
.onError(error -> logger().error("读取玩家数据失败", error));- 供应器在 Bukkit 异步任务中执行;
thenSync与onError通过同步调度器派发。 - 失败时只调用错误回调;取消时两类回调都不调用。
- 任务已完成后再注册回调,仍会被派发。
- 作用域关闭会取消任务,已排队未执行的同步回调也会跳过。
- 异步取消不会强制中断正在运行的供应器。供应器本身仍应设置 I/O 超时并尽快结束。
root.sync(() -> player.sendMessage("数据已加载"));- 可提前取消;作用域关闭后已排队的任务不再执行。
- 等价的旧写法是
root.after(Ticks.of(0), ...),新代码请用sync。
klib-data、klib-script 和 klib-remote 返回 JDK CompletionStage。它的 thenApply、thenAccept 等非 Async 回调可能由完成阶段的线程执行,也可能在注册回调时直接执行,不保证在 Bukkit 主线程运行,在其中直接调用 Bukkit API 是典型的崩溃来源。
需要结果时使用 Scope.submitSync。它拥有返回的 Future,等待外部阶段、排队和执行都绑定调用时的作用域构建代:
CompletableFuture<Integer> applied = root.submitSync(
storage.get("profile", playerId),
value -> {
int level = decodeLevel(value);
player.setLevel(level);
return level;
});
CompletableFuture<String> currentName = root.submitSync(player::getName);| 情况 | 返回结果 | 业务回调 |
|---|---|---|
| 正常完成 | 正常完成 | 在同步调度器执行一次 |
| 源阶段或业务回调失败 | 异常完成 | 源失败时不执行转换回调 |
| 同步调度器拒绝提交 | 异常完成 | 不执行 |
| 提交时已关闭,或等待/排队中关闭、重建 | 取消 | 不再执行 |
| 调用方取消返回 Future | 取消 | 未开始的回调不执行 |
| 主线程回调已经开始 | 生命周期切换等待当前回调退出 | 不强制中断 |
- 取消返回结果不会取消源阶段,更不代表数据库写入已回滚。外部 I/O 仍需自己的超时、事务和幂等规则。
- 正常或异常结束后自动释放作用域资源;重建不会使旧请求进入新一代。
-
tasks().submitSync(...)提供相同入口。该功能要求ScopeImpl支持的作用域,KPlugin和其子作用域都满足。 - 业务回调与作用域生命周期切换串行,保持简短、非阻塞;不要在回调中等待需要同一作用域锁的工作。
AsyncTasks 保留原结果并在主线程调用成功或错误处理器;异常会解包后传给错误处理器。等待、拒绝、关闭和重建复用上述终态规则:
import me.kzheart.klib.scheduler.AsyncTasks;
CompletionStage<Optional<byte[]>> applied = AsyncTasks.thenSync(
storage.get("profile", playerId),
root,
value -> player.sendMessage("等级 " + decodeLevel(value)),
error -> logger().error("读取玩家数据失败", error));-
thenSync成功回调结束后保留原值;源阶段失败时返回阶段仍失败,错误回调不把失败转换成成功。 -
AsyncTasks.onSync(stage, scope)只转交成功结果,不注册业务回调。 - 取消、源失败和提交拒绝的终结通知可能发生在任意线程。返回 Future 上自行追加的
whenComplete等非Async观察器也遵循 JDK 线程规则,不能在其中无条件访问 Bukkit。
完整回路(异步读库 → 回主线程 → 更新玩家):
public void loadAndApply(final Player player) {
CompletionStage<Optional<byte[]>> loading =
sessionStage.thenCompose(session -> session.get("profile", player.getUniqueId().toString()));
AsyncTasks.thenSync(loading, root,
stored -> {
// 主线程:可以安全访问玩家与世界状态
player.setLevel(stored.isPresent() ? decodeLevel(stored.get()) : 0);
},
error -> logger().error("读取玩家数据失败", error));
}多步组合中,每个访问 Bukkit 状态的回调都通过 submitSync 或 AsyncTasks.thenSync。即使某个阶段正常在主线程完成,之后从其他线程追加的非 Async 回调也不保证在主线程运行。
Scope.syncExecutor() 仍保留:内置后端对关闭后的新提交抛出 RejectedExecutionException,已排队的命令随作用域取消。
但 Executor 收到的只是一个不透明的 Runnable,无法在跳过业务回调的同时替它完成任意 JDK 派生 Future。
因此 stage.thenApplyAsync(..., scope.syncExecutor()) 在先入队、再关闭时仍可能永不完成;需要可靠结果的流水线使用 submitSync 或 AsyncTasks。
Bukkit 后端保持原有的同步调度器排队顺序,不额外增加一轮延迟。
CompletionStage<T>.observeOperation() 挂起观察外部已受理操作;观察者取消不取消源操作,调用协程仍会及时结束。
源操作取消会抛 SourceOperationCancelledException,表示结果未知;它不是支付拒绝,也不取消仍活跃的调用协程。
import me.kzheart.klib.scope.observeOperation
val result = acceptedPayment.observeOperation()scope.coroutines 返回当前构建代的协程入口;同一代重复读取返回同一实例,重建后使用全新的实例和 SupervisorJob。
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import me.kzheart.klib.scope.coroutines
val runtime = context().scope().coroutines
runtime.launch {
val profile = withContext(Dispatchers.IO) {
repository.loadPlayer(playerId)
}
// 默认恢复到本代主线程调度器;只在这里访问 Bukkit 状态
applyProfile(player, profile)
}| 入口 | 行为 |
|---|---|
runtime.launch { ... } |
返回 Job,默认排队到主线程 |
runtime.async { ... } |
返回类型化 Deferred<T>,默认仍在主线程;耗时 I/O 显式切到 Dispatchers.IO
|
runtime.mainDispatcher |
需要时用 withContext(runtime.mainDispatcher) 回到本代主线程 |
runtime.scope |
固定当前代的 Scope 视图,失效后拒绝注册和能力查询 |
runtime.job |
本代 SupervisorJob;单个协程失败不会取消兄弟协程 |
- 子作用域的 Job 属于父作用域的 Job;关闭、重建或初始化失败时,先使整个子树失效并取消 Job,再逆序释放普通资源。
- 默认
launch/async支持DEFAULT和LAZY启动;拒绝替换生命周期Job或使用绕过初始取消检查的启动方式。 - 调度被拒绝、已排队的恢复任务被关闭取消时,continuation 仍会完成取消,不会因静默丢弃而永远挂起。
-
close()/rebuild()不等待协程完成,不在主线程使用runBlocking或join。取消收尾可能在Dispatchers.Default上执行,finally中不得访问 Bukkit,也不能依赖已释放的作用域资源。 - 取消是协作式的。阻塞 I/O 必须设置超时;
NonCancellable代码、捕获的原始Scope或其他资源引用不会被强制撤销。跨挂起点使用runtime.scope,不要通过原始可重建作用域查询新代能力。 - 旧
runtime在重建后保持取消,不能用来启动新任务;从新代Scope重新获取入口。此集成要求 Klib 创建的ScopeImpl和已注册的SchedulerFactory。 - 协程与 Java
submitSync复用同一代际终态原语;协程另外负责把被取消 continuation 的清理派发到Dispatchers.Default。 - 不要把
syncExecutor()直接传给asCoroutineDispatcher():排队后的命令仍可能被取消,不能负责 continuation 的取消收尾。
Cooldowns<K> 按任意键记录冷却截止时间,线程安全。它实现 Disposable,安装进作用域后随作用域关闭清空。
import me.kzheart.klib.cooldown.Cooldowns;
// 以玩家 UUID 为键:自动安装进作用域,玩家退出时移除其冷却
Cooldowns<UUID> skillCd = Cooldowns.perPlayer(root);
Cooldowns.Attempt attempt = skillCd.tryAcquire(player.getUniqueId(), Duration.ofSeconds(8));
if (!attempt.acquired()) {
player.sendMessage("冷却中,还剩 " + attempt.remaining().getSeconds() + " 秒");
return;
}键可以是任意类型。按武器、按技能等场景用复合键,不需要拼字符串:
Cooldowns<WeaponKey> weaponCd = root.install(Cooldowns.create());
weaponCd.set(key, Duration.ofSeconds(5)); // 无条件设置
weaponCd.reduce(key, Duration.ofSeconds(2)); // 缩短,缩到零即结束
weaponCd.extend(key, Duration.ofSeconds(1)); // 延长,未冷却时从现在开始
weaponCd.clearIf(k -> k.owner().equals(playerId));| 规则 | 行为 |
|---|---|
tryAcquire |
原子操作。冷却已结束:开始新冷却并返回成功;仍在冷却:不修改,返回剩余时间 |
| 时长为零 | 不开始冷却 |
| 负时长 | 抛 IllegalArgumentException
|
| 超大时长 | 饱和到 Long.MAX_VALUE 毫秒 |
| 过期清理 | 读取时移除,每 256 次写入批量清理;立即清理调用 purgeExpired()
|
| 持久化 | 只在内存中,不跨重启。需要时由调用方用 klib-data 保存截止时间 |
| 测试 |
create(Clock) 与 perPlayer(scope, Clock) 接受自定义时钟 |
me.kzheart.klib.random 包统一了抽奖、掉落和数值浮动中常见的随机逻辑。所有随机方法都有接受 java.util.Random 的重载,便于复现与测试。
不可变的加权随机池,构建时就拒绝非法配置,而不是在抽取时各自猜测:
import me.kzheart.klib.random.WeightedPool;
WeightedPool<Drop> pool = WeightedPool.of(drops, Drop::weight);
Drop one = pool.pick(); // 或 pick(random)
List<Drop> three = pool.pickDistinct(3); // 不放回抽取
double chance = pool.chance(0); // 第 0 项被抽中的概率| 情况 | 行为 |
|---|---|
| 负数、NaN、无穷权重 | 抛 IllegalArgumentException
|
| 池非空但总权重为零 | 同样失败 |
| 权重为零的条目 | 保留,但永远不会被抽中 |
pickDistinct 可抽条目不足 |
返回全部正权重条目 |
| 空池 | 可以创建;pick 抛 IllegalStateException,pickDistinct 返回空列表 |
闭区间。parse 接受的写法:
| 输入 | 含义 |
|---|---|
5 |
单值 |
1-5、1~5、2 to 8
|
区间 |
-5-3 |
-5 到 3 |
-5~-1 |
-5 到 -1 |
上界小于下界时抛出异常。它们已注册为 Config 的内置类型,配置字段可直接声明:
public final class DropConfig {
IntRange amount = IntRange.of(1, 1);
DoubleRange bonus = DoubleRange.of(0, 0);
}
int n = config.amount.random(); // 闭区间整数
double b = config.bonus.random(); // [min, max) 内均匀取值-
Chance.percent(12.5)按百分比判定,Chance.ratio(0.125)按比例判定。 - 小于等于 0 永不命中,大于等于上限必定命中,NaN 或无穷抛出异常。
KPlugin.logger() 返回 KLogger:
logger().info("开始加载竞技场");
logger().success("竞技场加载完成");
logger().warn("未找到可选依赖");
logger().error("保存失败", exception);
logger().setDebug("arena", true);
logger().debug("arena", "players=" + players.size());info、success、warn、error 都有携带模块名的重载,模块名只影响最近日志缓冲中的来源标记:
logger().warn("arena", "竞技场配置缺少出生点");- 调试日志默认关闭,可按模块或用
*总开关启用。 - 调试输出以
INFO级别发出,由模块开关过滤而非日志级别。服务器不必改日志配置就能看到,代价是无法用日志级别单独屏蔽。 -
recentLines()返回最近日志的只读快照,格式为时间戳 级别 [模块] 消息,未带模块名的记为core。适合诊断上报,不要当持久日志存储。 - 行首符号默认
❯✔⚠✖。GBK 等无法显示这些字符的 Windows 控制台,在启动参数加-Dklib.logger.ascii=true,改用 ASCII 前缀>、+、!、x。该属性在类初始化时读取一次,运行期修改无效。
展开:DiagnosticSource 与 Remote Incident 接线
DiagnosticSource 是 Core 提供的只读内存快照边界。
-
ExecutorScheduler和BukkitSchedulerAdapter报告调度后端、作用域状态和执行器是否关闭。 - 采集过程不枚举服务器任务,不访问文件、网络或数据库。
- 选择
remote模块后,可用KlibDiagnosticContributor把这些快照加入 Incident。 - 自定义实现同样只能读取已缓存的轻量状态,禁止在
diagnosticSnapshot()中现场执行阻塞 I/O。
完整接线见 Remote 的排障上下文。
-
KPlugin.setup、Bukkit 命令注册以及绝大多数 Bukkit API 操作在服务器主线程完成。命令在插件启动时声明,配置重载不重复注册。 -
after、every、sync、submitSync、thenSync、onError的业务回调运行在同步调度器;async的供应器不在主线程。 - 不要在
async供应器中读写世界、实体、背包等要求主线程的 Bukkit 状态。先完成纯 I/O 或计算,再在thenSync中应用结果。 - JDK
CompletionStage的非Async回调不保证在 Bukkit 主线程执行。触碰 Bukkit 状态前必须经Scope.sync、Scope.submitSync或AsyncTasks切回主线程。 - Bukkit 事件处理器的线程由事件本身决定,异步事件的处理器不会自动切回主线程。
-
Scope的生命周期操作会串行化,但不要并发触发同一作用域的rebuild、close或资源注册。 - 作用域关闭后,再注册资源、加载能力或访问已关闭的模块运行时会失败。
- 一个作用域内同一种 capability 类型只能注册一次;子作用域可以覆盖父作用域能力。
- 子作用域名称在同一父作用域中必须唯一。
- 不要手工释放一个资源后仍让它留在作用域中。自定义一次性资源完成时可调用
scope.remove(this)。 -
KPlugin.instance()和rootScope()只在插件处于活动状态时可用。 - 清理中部分资源抛异常时,Klib 仍继续释放其余资源,最后以聚合异常报告失败。