跳到正文

来源、查询与事件 API

SourceSnapshot 契约

java
SourceSnapshot source = new SourceSnapshot(
    new SourceKey("quest", "reward/chapter_1"),
    12L,
    List.of(
        new SourceContribution(
            AttributeId.of("sealattributes", "max_health"),
            ContributionOperation.ADD,
            100.0
        )
    ),
    SourcePersistence.PERMANENT,
    Optional.empty()
);

规则:

  • provider 必须等于绑定命名空间;source path 和 ID 必须规范化;
  • revision 必须 >0,相同键只接受更新修订;
  • contributions 不可变,数值有限,(attributeId, operation) 不重复;
  • RUNTIMEPERMANENT 不带 expiry;EXPIRES_AT 必须带未来到期时间;
  • 新快照整体替换旧快照,空变化应使用 withdraw,而不是发布含空/补丁语义的数据。

当前 SourceSnapshot 没有独立 64 项上限;旧 MAX_CONTRIBUTIONS_PER_SOURCE=64 仅为弃用 ABI 字段。真正快照属性上限是 512,单主体来源上限 256。

运行时发布

java
PublishResult result = api.sources().publish(subject, source);
if (!result.successful()) {
    logger.warning("publish rejected: " + result.getStatus());
}

sources() 是同步内存操作,可从任意线程准备不可变输入,但主体/平台相关动作仍要遵守返回状态。卸载:

java
api.sources().withdraw(subject, key, nextRevision);

需要同时换职业与被动时用原子批次:

java
BatchPublishResult result = api.sources().mutate(subject, List.of(
    SourceMutation.withdraw(oldClassKey, oldRevision + 1),
    SourceMutation.replace(newClassSnapshot),
    SourceMutation.replace(newPassiveSnapshot)
));

批次只允许本 provider 的 RUNTIME 来源,同键不重复;任一项 stale、越权、无效或超限则整批不落地,只在成功时生成一次快照变更事件。

持久发布

PERMANENT/EXPIRES_AT 走异步存储:

java
api.persistentSources().publish(subject, persistentSnapshot)
    .thenAccept(result -> {
        switch (result.getStatus()) {
            case APPLIED, PERSISTED_SESSION_DEFERRED -> {
                // 业务成功;DEFERRED 不得重试。
            }
            default -> logger.warning("persistent publish failed: " + result.getStatus());
        }
    });

重点状态:STALE_SUBJECTSTALE_OWNERSTALE_REVISIONSTORAGE_FAILURELIMIT_EXCEEDEDPERSISTED_SESSION_DEFERRED 表示数据库提交成功,但请求时的玩家会话在运行态应用前失效;后续当前会话会从持久事实物化,重复写会制造错误业务语义。

持久写失败不会覆盖最后一次已提交来源。不要先改自己的数据库再假设 SealAttributes 一定成功;业务要定义跨系统补偿或幂等键。

查询

java
QueryResult<SubjectKey> current = api.queries().currentSubject(playerUuid);
if (!current.found()) return;

SubjectKey subject = current.value.orElseThrow();
QueryResult<AttributeSnapshot> snapshot = api.queries().snapshot(subject);
if (snapshot.found()) {
    double health = snapshot.value.orElseThrow().valueOr(
        AttributeId.of("sealattributes", "max_health"), 0.0
    );
}

查询是 O(1) 的已提交不可变快照读取,不访问 Bukkit 实体、磁盘或数据库,可在任意线程调用。永远使用当前 SubjectKey,不要只缓存 UUID;重登会产生新 subject epoch,旧 key 返回 STALE_SUBJECT。

snapshot() 的 max_health 是 RPG 目标值;effectiveHealth() 返回 Paper 已证明的当前/有效最大生命,遇到其他 modifier 时两者可能不同。

装备发布闸门

SealItems 一类外部装备 owner 每次发布前都必须 fail closed:

java
boolean mayPublish = false;
try {
    QueryResult<Boolean> state = api.builtInEquipmentReaderEnabled();
    mayPublish = state.found() && !state.value.orElse(true);
} catch (RuntimeException ignored) {
    // 保持 false
}
if (!mayPublish) return;

只有明确 FOUND false 才能写;missing、stale、异常一律按内置 reader 仍开启。不要长期缓存结果。

提交后事件

java
EventSubscription subscription = api.events().onAttributeSnapshotChanged(event -> {
    logger.fine("revision=" + event.getCurrent().getState().getSnapshotRevision()
        + ", reason=" + event.getReason());
});

// 插件自己的 onDisable 中可显式关闭;owner epoch 失效也会自动关闭。
subscription.close();

四类订阅:onAttributeSnapshotChangedonCombatCompletedonHealingCompletedonResourceChanged。它们均是提交后、只读、不可取消;监听异常被隔离。

快照监听器中再次改来源会排到下一轮,避免重入破坏当前通知;combat/healing 完成回调中递归提交相同类操作会 fail closed。事件运行在拥有主体的执行上下文,不要做数据库、网络、文件或长计算;复制少量不可变数据后异步处理。

异常处理策略

  • 构造器的非法参数属于开发错误,允许立即抛出并在测试中发现;
  • 运行态拒绝通过 status 表达,不解析人类日志;
  • Gate 抛异常视为拒绝;公式/扩展异常被隔离且产生诊断;
  • stale 不代表“再试一定成功”,先重新获取 current subject/owner;
  • capacity/limit 是安全边界,应缩减模型而不是无限重试。

Minecraft 服务端插件使用与开发文档