Skip to content

SealAttributes 第三方插件接入指南

本文面向物品、职业、技能、怪物、任务、保护区和 UI 插件开发者。第三方插件只能编译依赖 cn.seal:sealattributes-api,不能依赖 sealattributes-coresealattributes-paper 或复制运行时内部类。

1. 接入边界

  • 普通第三方插件在 plugin.yml 中声明 depend: [SealAttributes];只有功能可选时才使用 softdepend: [SealAttributes] 并在每次启用时检查服务是否存在。内部属性扩展包使用第 3 节的专用入口, 不属于 Bukkit 插件。
  • API JAR 是编译依赖,不是服务端插件。当前 1.0.12 候选的 API baseline 为 1.3;服主只安装 与 API 版本配套的主插件 JAR。
  • API 没有全局单例。每个插件必须用自己的主类绑定一个唯一小写命名空间,例如 sealitems,之后只能写入 sealitems:* 的定义、来源和扩展。
  • 定义、公式、战斗计划、Gate 和扩展只能在 SealAttributes 启动注册窗口内注册。来源发布、查询、 战斗和治疗只在运行窗口使用。
  • Bukkit /reload 不属于支持的生命周期。插件被禁用时,SealAttributes 会让该 owner epoch 下的 facade、发布器和事件订阅全部失效。

2. Gradle 依赖

本地开发包发布到 Maven Local 后,可这样声明:

kotlin
repositories {
    mavenLocal()
}

dependencies {
    compileOnly("cn.seal:sealattributes-api:1.0.12")
}

当前工作区构建产物为 sealattributes-api/build/libs/sealattributes-api-1.0.12.jar;发布到 Maven Local 后使用同一坐标。SealItems 应优先使用上面的 compileOnly 坐标;不要把 API JAR 放进服务端 plugins/,也不要 shade 进玩法插件。

若团队用私有 Maven 仓库,只需替换仓库地址;不要把 API JAR shade 进自己的插件。

3. 内部属性扩展包

只负责定义属性、公式和属性行为的可信代码,可以打成内部扩展 JAR,放入:

text
plugins/SealAttributes/extensions/server-attributes.jar

入口实现 SealAttributeExtensionproviderId() 同时是该扩展拥有的属性命名空间;扩展只能注册同一 命名空间下的 ID:

java
package com.example.attributes;

import cn.sealplugins.attributes.api.SealAttributesApiMetadata;
import cn.sealplugins.attributes.api.definition.AttributeDefinition;
import cn.sealplugins.attributes.api.definition.AttributeId;
import cn.sealplugins.attributes.api.definition.AttributeType;
import cn.sealplugins.attributes.api.definition.DefinitionRegistry;
import cn.sealplugins.attributes.api.definition.RegistrationResult;
import cn.sealplugins.attributes.api.extension.SealAttributeExtension;

public final class ServerAttributeExtension implements SealAttributeExtension {
    @Override
    public String providerId() {
        return "server_attributes";
    }

    @Override
    public String apiBaseline() {
        return SealAttributesApiMetadata.BASELINE;
    }

    @Override
    public void register(DefinitionRegistry registry) {
        RegistrationResult result = registry.register(
            AttributeDefinition.dataOnly(
                AttributeId.of(providerId(), "spiritual_power"),
                "精神力",
                AttributeType.RATING,
                0.0
            )
        );
        if (!result.accepted()) {
            throw new IllegalStateException("精神力注册失败:" + result.getStatus());
        }
    }
}

JAR 内必须增加标准 JVM 服务文件:

text
src/main/resources/
└── META-INF/services/
    └── cn.sealplugins.attributes.api.extension.SealAttributeExtension

文件内容只有入口类全名:

text
com.example.attributes.ServerAttributeExtension

每个 JAR 必须恰好提供一个入口,但一个入口可以注册多个属性、FormulaProviderCombatExtensionPlatformExtension。专用入口只提供启动期 DefinitionRegistry,不提供玩家来源发布入口或 Bukkit 插件 生命周期、命令或监听器。需要装备、职业、技能等运行期能力时,应继续制作普通 Paper 插件并使用下节绑定。

扩展按文件名排序加载,最多 64 个;不支持扩展 JAR 之间互相依赖,也不支持热加载。任一 JAR 缺少入口、 包含多个入口、API 基线不匹配、命名空间冲突或注册抛错时,SealAttributes 整体拒绝启动。扩展是可信代码, 不是安全沙箱;不得启动线程、执行阻塞 I/O、访问网络或长期保存 Bukkit 活对象。

每个扩展 JAR 必须自包含除 JDK 和 sealattributes-api 之外的全部运行时依赖。扩展使用独立类加载器,不能 看到其他 Paper 插件通过 libraries 声明的依赖;因此默认推荐使用上面的 Java 入口。若使用 Kotlin,必须把 匹配版本的 Kotlin runtime 一并打入扩展 JAR,但仍不得把 sealattributes-api 打入其中。

4. 获取绑定后的 API

Paper 会把 SealAttributesApiProvider 注册到服务管理器。下面代码应在第三方插件启用早期执行, YourPlugin.class 必须是调用方插件自己的主类或同一插件类加载器中的类:

java
RegisteredServiceProvider<SealAttributesApiProvider> registration =
    Bukkit.getServicesManager().getRegistration(SealAttributesApiProvider.class);
if (registration == null) {
    throw new IllegalStateException("SealAttributes API service is unavailable");
}

ProviderBindingResult binding = registration.getProvider().bind("sealitems", YourPlugin.class);
if (!binding.bound()) {
    throw new IllegalStateException("SealAttributes bind failed: " + binding.getStatus());
}
SealAttributesApi api = binding.getApi().orElseThrow();

不要缓存跨重载的旧 facade。ProviderBindingStatus 可区分命名空间无效、owner 无法识别、命名冲突、 平台尚未就绪和平台已关闭。

5. 注册自定义属性

数据型属性只负责聚合和查询,是第一版最常用、风险最低的扩展方式:

java
AttributeId spiritualPower = AttributeId.of("sealitems", "spiritual_power");
RegistrationResult result = api.definitions().register(
    AttributeDefinition.dataOnly(spiritualPower, "精神力", AttributeType.RATING, 0.0)
);
if (!result.accepted()) {
    throw new IllegalStateException("Attribute registration failed: " + result.getStatus());
}

AMOUNT 是绝对数值,PERCENT 使用小数(0.25 代表 25%),RATING 保存评级点数。 注册必须检查 RegistrationStatus,不得通过解析日志判断成功。注册窗口冻结后会返回 WINDOW_CLOSED,不会延迟到运行期偷偷生效。

需要行为的属性应同时注册受约束的 FormulaProviderCombatExtensionPlatformExtension,并在 AttributeDefinition 中明确 behaviorId。回调不得做 I/O、访问可变全局 状态或重入 SealAttributes 写入口。

6. 发布结构化属性来源

SealAttributes 不要求第三方插件拼 Lore。装备、职业、套装、称号或技能插件应把自身的权威数据编译成 完整、不可变的 SourceSnapshot

如果 SealItems 负责全部原版与自定义槽位,服主应在 sources.yml 设置 built-in-equipment-reader.enabled: false。此时 SealAttributes 不会再发布 sealattributes:equipment/*,SealItems 应使用自己的 provider 命名空间提交完整装备来源,并在整套装备 变化时使用一次原子 mutate,避免玩家看到半套旧装备和半套新装备。

SealItems 冲突保护(API baseline 1.3)

SealItems 应编译依赖 baseline 1.3 API,并在运行时保留故障关闭处理,以兼容服主误装旧版 SealAttributes 的情况。按 baseline 1.0 编译的内部属性扩展包需要使用新 API JAR 重新构建。

SealItems 不应自行读取 SealAttributes 的 YAML。取得绑定后先保持 READ_ONLY;SealAttributes 的运行时 可能晚于第三方插件 onEnable 激活,因此第一次查询若不是 FOUND,应在后续调度、玩家接入或完整刷新时 重查。每次准备发布装备来源前也要查询当前实时状态。只有明确查到 false 才能写入;其余结果统一保持 READ_ONLY

kotlin
val mayPublishEquipment = runCatching {
    val readerState = api.builtInEquipmentReaderEnabled()
    readerState.found() && !readerState.value.orElse(true)
}.getOrDefault(false)

if (!mayPublishEquipment) {
    enterReadOnly("SealAttributes 内置六槽读取器仍在工作或状态不可用")
    return
}

publishCompleteEquipmentSnapshot()

该查询只读取内存,不访问实体、磁盘或数据库。sealitems provider 已绑定时,任何试图通过 /sa reload 改变读取器有效值的操作都会整体拒绝并保留旧配置;应正常停服、修改 sources.yml,再 完整启动服务器。调用方不得长期缓存查询结果;绑定不存在、状态不是 FOUND 或查询抛出异常时,也必须 按“读取器开启”处理。

SealItems 对外只应维护七个稳定来源: equipment/main_handequipment/off_handequipment/headequipment/chestequipment/legsequipment/feetequipment/resonance。每个槽来源是该槽全部安全组件按 (attributeId, operation) 聚合后的完整快照;resonance 仅承载跨槽派生事实。SourceSnapshot 不再有独立贡献条数限制,不要截断、按玩法拆源或为规避历史 64 条阈值而分片。

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

SourceKey sourceKey = new SourceKey("sealitems", "equipment/main_hand");
SourceSnapshot source = new SourceSnapshot(
    sourceKey,
    itemRevision,
    List.of(
        new SourceContribution(spiritualPower, ContributionOperation.ADD, 120.0),
        new SourceContribution(AttributeId.of("sealattributes", "max_health"),
            ContributionOperation.ADD, 500.0)
    ),
    SourcePersistence.RUNTIME,
    Optional.empty()
);
PublishResult published = api.sources().publish(current.getValue().orElseThrow(), source);

同一 SourceKey 的高 revision 快照会原子替换旧快照,绝不能只发送“本次增加了多少”。装备卸下时:

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

职业切换、整套装备替换或一个状态同时影响多个来源时,不要连续调用多次 publish/withdraw。使用一次 mutate,让玩家只看到旧完整状态或新完整状态:

java
BatchPublishResult result = api.sources().mutate(subject, List.of(
    SourceMutation.withdraw(new SourceKey("sealclass", "class/warrior"), oldRevision + 1),
    SourceMutation.replace(newClassSource),
    SourceMutation.replace(newPassiveSource)
));

同一批次只能操作当前 facade 命名空间下的 RUNTIME 来源,且 SourceKey 不能重复。任一项出现 STALE_REVISION、命名空间错误、容量超限或无效贡献时,整批不落地;成功批次只递增一次主体快照并只发出 一次提交后属性事件。批量持久化不在此入口范围内,持久来源仍使用下述异步接口。

RUNTIME 适合在线装备和临时状态;PERMANENTEXPIRES_AT 必须通过 api.persistentSources() 异步落库。持久发布返回 CompletionStage<PublishResult>,调用线程不得 join() 阻塞服务端:

java
api.persistentSources().publish(subject, source)
    .thenAccept(result -> {
        if (!result.successful()) getLogger().warning("Publish failed: " + result.getStatus());
    });

发布失败时检查 PublishStatus。尤其要分别处理 STALE_SUBJECT(玩家会话已变化)、 STALE_OWNER(调用插件绑定已失效)、STALE_REVISIONSTORAGE_FAILURELIMIT_EXCEEDEDPERSISTED_SESSION_DEFERRED 不是失败:它表示数据库已经提交,但原玩家会话在 运行态发布前失效;调用方必须把业务操作视为成功且不能重试,同一持久事实会在后续当前会话物化。 真正的存储失败不会覆盖最后一次已提交来源。

7. 查询属性

java
QueryResult<AttributeSnapshot> query = api.queries().snapshot(subject);
if (query.found()) {
    double value = query.getValue().orElseThrow().valueOr(spiritualPower, 0.0);
}

查询是 O(1) 的已提交不可变快照读取,不会访问 Bukkit 实体或数据库,可从任意线程调用。 snapshot() 中的 sealattributes:max_health 是 RPG 目标最大生命;effectiveHealth() 是 Paper 已证明的 当前生命与最终有效最大生命。第三方修饰器共存时两者可能不同。

8. 查询与操作当前资源

当前魂力、法力等“当前值”不属于可相加属性来源。扩展负责注册 ResourceDefinition,玩法插件只通过绑定后的 ResourceService 查询、消费或恢复:

java
ResourceId soulPower = ResourceId.of("douluo", "soul_power");
ResourceResult consumed = api.resources().tryConsume(subject, soulPower, 50.0);
if (consumed.getStatus() == ResourceStatus.APPLIED) {
    double actualCost = consumed.getConsumedAmount();
    startSkill(actualCost);
} else if (consumed.getStatus() == ResourceStatus.INSUFFICIENT) {
    showInsufficientSoulPower();
}

tryConsume 会在 Core 内应用资源定义绑定的消耗减免并原子判断余额;不足时不部分扣款。应先完成冷却、目标、 权限等前置检查,扣除成功即代表施法开始,后续落空或被格挡不会自动退款。确需补偿时用返回的 consumedAmount 显式 restore。公开入口只有 querytryConsumerestorerestorePercentfill, 不提供绕过减免的任意 set、负数 add、转账或隐式回滚。

资源操作是同步内存事务,不访问数据库;只有 READY 的精确 SubjectKey 可用。当前值由 SealAttributes 异步 保存,玩法插件不得另存第二份权威余额。死亡时消费返回 DEAD;owner/Subject 过期、资源未知和非法数值均有 独立 ResourceStatusResourceChanged 是提交后只读事件,监听器若再次操作资源会排到本次通知之后。

9. 注册技能战斗计划并提交伤害

计划必须在启动注册窗口注册。能力位是正向授权:未声明就不会闪避、暴击、减伤、吸血或反伤。

java
CombatPlan plan = new CombatPlan(
    CombatPlanId.of("sealitems", "soul_strike"),
    0.0, 1.25, 1.0, // 固定物伤、攻击倍率、物防参考下限
    40.0, 0.80, 1.0, // 固定法伤、法攻倍率、法防参考下限
    0.0,             // 真实伤害倍率
    Set.of(CombatCapability.EVADABLE, CombatCapability.CRITTABLE,
        CombatCapability.AFFECTED_BY_DEFENSE, CombatCapability.TRIGGERS_LIFESTEAL),
    new CombatFeedback(true, true, 0.2)
);
RegistrationResult registered = api.combat().registerPlan(plan);

提交必须发生在平台认可的目标实体执行上下文,第三方只能使用 SKILLAPI origin,并且只能选 内置计划或自己命名空间的计划:

java
CombatOrigin origin = new CombatOrigin(
    Optional.of(projectile.getUniqueId()), actor, Optional.empty(),
    CombatOriginType.SKILL, plan.getCapabilityBits()
);
CombatResult result = api.combat().submit(
    new CombatRequest(UUID.randomUUID(), plan.getId(), origin, target, 0)
);
if (result.getStatus() == CombatStatus.APPLIED) {
    double lost = result.getActualLifeLost();
}

每次请求必须有唯一 request UUID,用于防重。调用方必须处理 COOLDOWNEXTERNAL_BLOCKEDSTALE_SUBJECTSTALE_RUNTIMECOMMIT_FAILED 等结果;只有 APPLIED 代表生命提交成功。

10. 保护插件 Gate

保护插件应注册显式 Gate,不要监听后再试图恢复生命:

java
api.combat().registerGate(new CombatGate() {
    public String id() { return "sealregion:pvp"; }
    public int priority() { return 0; }
    public GateDecision evaluate(CombatGateContext context) {
        return regionAllows(context)
            ? GateDecision.pass()
            : GateDecision.deny("sealregion:pvp_denied");
    }
});

Gate 可在合法实体上下文同步只读保护插件 API,但不能做阻塞 I/O、修改实体或事件、重入属性写入口。 Gate 抛错会 fail-closed,战斗返回 EXTERNAL_BLOCKED 并附带 GATE_EXCEPTION 诊断。

服主可在 combat.yml 为暴击/闪避分别启用 PRD;这不改变 CombatPlan 或公共 Combat API。PRD 暴击状态 由攻击者拥有,闪避状态由防守者拥有,同一 capability 的不同计划共享对应通道。扩展不应自行保存第二套 连败计数,也不应依赖该状态跨配置重载、会话释放或重启持久化。

11. 事件与释放

java
EventSubscription subscription = api.events().onCombatCompleted(event -> {
    CombatResult result = event.getResult();
    // 只读消费已提交结果
});

// 插件停止前可主动释放;owner 失效时也会自动失效。
subscription.close();

事件只在提交后触发,不可取消或改写结果。监听器异常会隔离;在战斗/治疗完成回调内递归提交同类操作 会 fail-closed。

12. 完整编译样例与兼容要求

  • Java:sealattributes-api/fixtures/java/cn/sealplugins/attributes/api/JavaConsumerFixture.java
  • Kotlin:sealattributes-api/fixtures/kotlin/cn/sealplugins/attributes/api/KotlinConsumerFixture.kt
  • 内部扩展:sealattributes-api/fixtures/kotlin/cn/sealplugins/attributes/api/AttributeExtensionConsumerFixture.kt
  • 公共 ABI 基线:sealattributes-api/api/sealattributes-api.api

项目质量门会只用 API artifact 编译 Java/Kotlin fixture,并扫描 API JAR 是否意外引用 Paper、Bukkit、 TabooLib、SQL 或内部实现包。第三方插件升级前应先在测试服核对 apiVersion(),重新获取 facade,并按 类型化结果处理失败;不要依赖日志文本、内部包名或运行 JAR 中未公开的类。