外观
SealAttributes 第三方插件接入指南
本文面向物品、职业、技能、怪物、任务、保护区和 UI 插件开发者。第三方插件只能编译依赖 cn.seal:sealattributes-api,不能依赖 sealattributes-core、sealattributes-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入口实现 SealAttributeExtension。providerId() 同时是该扩展拥有的属性命名空间;扩展只能注册同一 命名空间下的 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 必须恰好提供一个入口,但一个入口可以注册多个属性、FormulaProvider、CombatExtension 和 PlatformExtension。专用入口只提供启动期 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,不会延迟到运行期偷偷生效。
需要行为的属性应同时注册受约束的 FormulaProvider、CombatExtension 或 PlatformExtension,并在 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_hand、equipment/off_hand、equipment/head、equipment/chest、 equipment/legs、equipment/feet 和 equipment/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 适合在线装备和临时状态;PERMANENT 与 EXPIRES_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_REVISION、STORAGE_FAILURE 与 LIMIT_EXCEEDED。PERSISTED_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。公开入口只有 query、tryConsume、restore、restorePercent 和 fill, 不提供绕过减免的任意 set、负数 add、转账或隐式回滚。
资源操作是同步内存事务,不访问数据库;只有 READY 的精确 SubjectKey 可用。当前值由 SealAttributes 异步 保存,玩法插件不得另存第二份权威余额。死亡时消费返回 DEAD;owner/Subject 过期、资源未知和非法数值均有 独立 ResourceStatus。ResourceChanged 是提交后只读事件,监听器若再次操作资源会排到本次通知之后。
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);提交必须发生在平台认可的目标实体执行上下文,第三方只能使用 SKILL 或 API 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,用于防重。调用方必须处理 COOLDOWN、EXTERNAL_BLOCKED、 STALE_SUBJECT、STALE_RUNTIME、COMMIT_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 中未公开的类。