FastSync 开发者对接文档
FastSync 开发者对接文档
面向第三方插件开发者:如何让你的插件把自定义数据接入 FastSync 的跨服同步、如何在同步的关键时刻刷新自己的状态、以及读写玩家背包时该注意什么。
适用版本:FastSync 1.x(仅支持 Minecraft 1.20.1+,Paper / Folia)。本文所有类路径均以
com.xbaimiao.fastsync为根包。
1. 概述
FastSync 是一个「preload-always」的跨服数据同步插件:玩家数据(背包、末影箱、血量、经验、药水效果、成就、统计、PDC 以及第三方扩展数据)永远在玩家出生之前就同步就位。它对外提供两类扩展能力,用途完全不同,不要混用:
| 能力 | 机制 | 典型用途 | 线程 |
|---|---|---|---|
| Addon(扩展数据同步) | 实现 FastSyncAddon 接口 + 注册,FastSync 通过直接回调读写你的字节,不走 Bukkit 事件 |
让「一个插件保存在玩家身上的自定义数值/状态」跟着玩家跨服(经济、飞行充能、称号、法力值……) | 玩家所在线程 |
| 通知事件(Bukkit Event) | 监听 FastSync 抛出的 Bukkit 事件 | 同步完成后刷新自身已有的状态(时装重贴、伪装重挂)、进服门禁、损坏物品告警统计 | 见各事件说明(主线程 / 异步) |
一句话区分:
- 你要让一段数据跟着玩家走 → 写 addon。
- 你只想在同步的某个时刻做点事(而数据本身由你自己的插件持久化)→ 监听事件。
2. Maven / Gradle 依赖
对接只需要编译期依赖 FastSync 的 API 包,运行期由服务器上安装的 FastSync 主插件提供实现。
- 仓库:
https://maven.xbaimiao.com/repository/maven-public/ - 坐标:
groupId = com.xbaimiao,artifactId = fast-invsync-api(暂定,请以 FastSync 作者发布的实际坐标为准),version = <与目标服 FastSync 匹配的版本> - 依赖范围:
compileOnly(provided)。不要把 FastSync 打进你的 jar。
API 包对接面涉及的类型(按包):
com.xbaimiao.fastsync.addon:FastSyncAddon、FastSyncAddonManager、PluginDatacom.xbaimiao.fastsync.data:SaveReason、PlayerData(列名常量)、QuarantineRecord(容器常量)com.xbaimiao.fastsync.event:FastSyncDoneEvent、PreSyncEvent、ItemSanitizeEvent、ItemQuarantineEventcom.xbaimiao.fastsync.serialize:ItemCodec、InventoryBlobCodec
提示:如果发布的 api 包未包含
serialize下的编解码工具,你也可以只依赖它做编译,运行期由主插件提供;或直接用 Bukkit 在线 API 读写背包(见第 5 节,多数情况这才是首选)。
Gradle(Kotlin DSL)
repositories {
maven("https://maven.xbaimiao.com/repository/maven-public/")
}
dependencies {
// 版本号请替换为目标服 FastSync 对应的版本
compileOnly("com.xbaimiao:fast-invsync-api:1.0.0")
}
Maven
<repositories>
<repository>
<id>xbaimiao-public</id>
<url>https://maven.xbaimiao.com/repository/maven-public/</url>
</repository>
</repositories>
<dependencies>
<dependency>
<groupId>com.xbaimiao</groupId>
<artifactId>fast-invsync-api</artifactId>
<version>1.0.0</version>
<scope>provided</scope>
</dependency>
</dependencies>
plugin.yml
因为 FastSync 是运行期可选前置,用 softdepend 声明加载顺序(保证你的 onEnable 在 FastSync 之后执行,注册 addon 时它已就绪):
name: YourPlugin
main: com.example.YourPlugin
version: 1.0.0
api-version: '1.20'
softdepend: [FastSync]
如果你的插件必须有 FastSync 才能工作(例如核心功能就是同步某数据),用 depend: [FastSync];否则一律用 softdepend,这样 FastSync 没装时你的插件仍能加载。
3. 开发 Addon(扩展数据同步)
Addon 是让「你插件保存在玩家身上的一段状态」跟着玩家跨服的标准姿势。每个 addon 用唯一 id 在玩家数据表的 plugin_data 列里占一段字节。同步不经过任何 Bukkit 事件,FastSync 直接回调你的接口方法。
3.1 接口成员逐一说明
interface FastSyncAddon {
/** 唯一标识,同时作为存储 key(大小写不敏感) */
val id: String
/** 玩家线程:读玩家当前状态并返回要保存的字节,null 表示无数据 / 删除本 addon 数据 */
fun onSave(player: Player, reason: SaveReason): ByteArray?
/** 玩家线程:把保存的字节写回玩家,bytes 为 null 表示该玩家没有本 addon 的数据 */
fun onApply(player: Player, bytes: ByteArray?)
/** 进服门禁:返回 false 则跳过该玩家「整次」扩展数据的应用(默认放行) */
fun shouldApply(player: Player): Boolean = true
}
id: String
- 全局唯一,不区分大小写(内部统一转小写)。建议用你插件名,避免和别的 addon 撞车。
- 它就是
plugin_dataKV 容器里的 key;改了 id 等于换了一段全新数据,老数据会读不到。
onSave(player, reason): ByteArray?
- 在玩家所在线程调用(Paper 主线程;Folia 是玩家所在区域线程)。可以安全读玩家状态和调用大多数 Bukkit / 第三方在线 API。
- 返回你要保存的字节。字节格式完全自定,推荐用
DataOutputStream写基本类型,简单可控。 - 返回
null表示「本玩家本 addon 无数据」——会从plugin_data里删掉这个 key(不是保存空字节)。 reason是保存原因(见 3.3),大多数场景你不需要区分,但必须能容忍SaveReason.INIT(见 3.4)。- 触发时机:玩家退出、切服前预保存(pre-quit)、自动保存、关服、
/fastsync save等。
onApply(player, bytes)
- 同样在玩家所在线程调用,进服同步时把上面保存的字节写回玩家。
bytes为null表示该玩家没有本 addon 的数据(例如从没保存过、或上次onSave返回了 null)。务必处理 null 分支——通常是「什么都不做」或「缓存一个『无数据』标记」。- 反序列化用与
onSave对称的DataInputStream。
shouldApply(player): Boolean
- 进服门禁。任一 addon 返回
false,FastSync 就跳过该玩家整次扩展数据的应用(不仅是你这个 addon,而是所有 addon + 末影箱/效果/成就/PDC 等 API 应用字段一起跳过)。默认true放行。 - 用途:存档切换类插件——玩家没切档就不覆盖它当前 profile 的数据。参考内置的
EpicProfileSwitchGate。 - 注意:preload 架构下背包/血量等 vanilla 字段已在玩家出生前离线写进
.dat,shouldApply只能拦住 Join 时 API 应用的扩展字段(末影箱/效果/成就/PDC/addon)。想连背包一起门禁,用PreSyncEvent(见 4.2)也拦不住背包,需在 preload 层处理——这属于 FastSync 内部机制,不在对接范围。
3.2 注册(在你的插件 onEnable)
override fun onEnable() {
if (server.pluginManager.isPluginEnabled("FastSync")) {
FastSyncAddonManager.register(MyManaAddon)
}
}
- 注册入口只有一个:
FastSyncAddonManager.register(addon)。 - 时机:你的插件
onEnable。配合softdepend: [FastSync],此时 FastSync 已启用。 - 注册后永久生效,无需注销(addon 内部自己判断依赖是否可用即可,见范例的
hasXxx)。
3.3 SaveReason 取值
enum class SaveReason {
DISCONNECT, // 玩家退出网络
PRE_QUIT, // 代理端切服前的提前保存
AUTO_SAVE, // 周期自动保存
SHUTDOWN, // 插件卸载 / 服务器关闭
API, // 指令 / API 触发(如 /fastsync save)
INIT, // addon 初始化探针(防 ClassNotFound),不产生实际保存
}
3.4 防 ClassNotFound:为什么必须容忍 SaveReason.INIT
FastSync 有一个「初始化探针」机制,你的 addon 必须配合:
- 问题背景:你的
onSave/onApply方法体里会引用第三方插件的类(例如经济插件的EconomyManager、CMI 的CMI)。JVM 是懒加载类的——只有方法第一次真正执行到时才去加载这些类。如果直到关服都没执行过,等到关服时依赖插件可能先于你被卸载(jar 被关闭),此时 JVM 再去加载就会ClassNotFoundException/zip file closed,污染关服流程。 - FastSync 的解法:玩家首次同步完成后,FastSync 会对「还从没被
onSave过」的 addon 用SaveReason.INIT补调一次onSave。此刻依赖插件必然在线、jar 还开着,你方法体里引用的第三方类会被正常加载进 metaspace。之后即便关服时依赖插件先卸载,类已在内存,不会再 ClassNotFound。 - 开发者注意事项:
onSave收到INIT时,正常执行读取逻辑即可(此调用的返回值不会被真正落库,纯粹为触发类加载)。不要因为是 INIT 就直接return null跳过——那样触发不了类加载,探针就白费了。当然,如果你的读取逻辑本身遇到「无数据」而返回 null 是没问题的。onSave里凡是catch (t: Throwable)包住第三方调用是好习惯,但不要把整个方法体包在一个「依赖没装就 return」的短路里挡在类引用之前——用一个hasXxx布尔判断来短路时,务必让「真正引用第三方类的那行」在依赖装了时能被执行到。- 别在
onSave/onApply里做重 IO 或阻塞——它们在玩家线程上。
3.5 完整范例:同步某插件的一个 double 值
下面用一个虚构的 ExampleManaPlugin(假设它有 ExampleManaAPI.getMana(player) / setMana(player, value))演示一个把「玩家法力值」跟着跨服同步的 addon。结构对齐内置的 CmiFlyChargeAddon,包含软依赖判断和 hadData init 语义。
package com.example.sync
import com.example.mana.ExampleManaAPI // 你的虚构第三方 API
import com.xbaimiao.fastsync.addon.FastSyncAddon
import com.xbaimiao.fastsync.data.SaveReason
import org.bukkit.Bukkit
import org.bukkit.entity.Player
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.DataInputStream
import java.io.DataOutputStream
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
object ExampleManaAddon : FastSyncAddon {
override val id: String = "ExampleMana"
// 软依赖判断:目标插件没装/没启用时,onSave 返回 null、onApply 直接跳过
private val available: Boolean
get() = Bukkit.getPluginManager().isPluginEnabled("ExampleManaPlugin")
// init 语义:记录「该玩家进服时 DB 里有没有本 addon 数据」,
// 用于避免用本服默认值去初始化一个跨服过来、DB 里本来就没这段数据的玩家。
private val hadData = ConcurrentHashMap<UUID, Boolean>()
// 是否允许用本服默认值初始化「DB 无数据」的玩家(按需做成配置项)
private const val INIT_WHEN_ABSENT = false
override fun onSave(player: Player, reason: SaveReason): ByteArray? {
if (!available) return null
// 该玩家 DB 本来没数据、且不允许初始化 → 不写,避免污染
if (!INIT_WHEN_ABSENT && hadData[player.uniqueId] != true) return null
// 注意:这一行真正引用了第三方类 ExampleManaAPI,
// SaveReason.INIT 探针调用会执行到这里,从而完成类加载(防 ClassNotFound)。
val mana = ExampleManaAPI.getMana(player)
val out = ByteArrayOutputStream()
DataOutputStream(out).use { it.writeDouble(mana) }
return out.toByteArray()
}
override fun onApply(player: Player, bytes: ByteArray?) {
if (!available) return
// 记住这个玩家有没有数据,供退出时的 onSave 判断
hadData[player.uniqueId] = bytes != null
if (bytes == null) return // 该玩家无本 addon 数据,什么都不做
val value = DataInputStream(ByteArrayInputStream(bytes)).use { it.readDouble() }
ExampleManaAPI.setMana(player, value)
}
// 本 addon 不需要门禁,用默认 shouldApply(=true) 即可,无需重写
}
注册:
override fun onEnable() {
if (server.pluginManager.isPluginEnabled("FastSync")) {
FastSyncAddonManager.register(ExampleManaAddon)
}
}
Java 等价(接口实现骨架):
public class ExampleManaAddon implements FastSyncAddon {
public static final ExampleManaAddon INSTANCE = new ExampleManaAddon();
@Override public String getId() { return "ExampleMana"; }
@Override public byte[] onSave(Player player, SaveReason reason) {
if (!Bukkit.getPluginManager().isPluginEnabled("ExampleManaPlugin")) return null;
double mana = ExampleManaAPI.getMana(player);
ByteArrayOutputStream out = new ByteArrayOutputStream();
try (DataOutputStream dos = new DataOutputStream(out)) {
dos.writeDouble(mana);
} catch (IOException e) { return null; }
return out.toByteArray();
}
@Override public void onApply(Player player, byte[] bytes) {
if (bytes == null) return;
try (DataInputStream dis = new DataInputStream(new ByteArrayInputStream(bytes))) {
ExampleManaAPI.setMana(player, dis.readDouble());
} catch (IOException ignored) {}
}
// shouldApply 有默认实现(Kotlin 默认方法),Java 侧如需门禁再 @Override
}
序列化格式建议:用
DataOutputStream写固定顺序的基本类型,读时严格对称。若字段可能演进,自己在第一个字节写一个「格式版本号」,向后兼容更省心。FastSync 只把你的字节当不透明 blob,不关心内容。
4. 监听通知事件
事件用于「在同步的某个时刻做点事」,数据本身由你自己的插件持久化。用标准 Bukkit 方式注册监听器即可(不依赖 EasyLib)。
class FastSyncListener : Listener {
// @EventHandler 方法见下
}
// onEnable 里注册
server.pluginManager.registerEvents(FastSyncListener(), this)
4.1 FastSyncDoneEvent —— 同步完成(主线程,不可取消)
玩家所有扩展数据(末影箱/效果/成就/PDC/addon 等)全部应用完成后在主线程触发。这是你「刷新自身状态」的正确时机:时装重贴、伪装重挂、称号重设、Scoreboard 重画等。
@EventHandler
fun onSyncDone(event: FastSyncDoneEvent) {
val player = event.player
// 此时玩家背包/末影箱/PDC 等都已就位,可以安全刷新依赖这些数据的显示层
// 建议:需要读背包/PDC 结果的刷新,延迟 1~2 tick 更稳妥(等世界状态彻底落地)
Bukkit.getScheduler().runTaskLater(myPlugin, Runnable {
if (player.isOnline) MyCosmeticApi.reapply(player)
}, 2L)
}
参考内置 hook:DragonArmourersHook(时装同步完成后延迟重贴皮肤)、LibsDisguisesHook(按已保存的伪装重挂、没有则清掉当前伪装防止跨服串装)。
Folia 注意:刷新操作要调度到玩家所在区域线程(Folia 用
player.getScheduler().runDelayed(...);Paper 用全局/主线程 scheduler 即可)。
4.2 PreSyncEvent —— 进服门禁(主线程,可取消)
玩家进服、扩展数据应用之前在主线程触发,可取消。取消后 FastSync 跳过该玩家整次扩展数据的应用。
@EventHandler
fun onPreSync(event: PreSyncEvent) {
val player = event.player
// 示例:某条件下跳过该玩家的扩展数据应用(例如玩家处于某种「独立存档」状态)
if (MyProfileApi.isInIsolatedProfile(player.uniqueId)) {
event.isCancelled = true
}
}
要点:
- preload 架构下背包/血量/经验等 vanilla 字段已在玩家出生前离线写进
.dat,本事件(以及 addon 的shouldApply)只能拦住 Join 时 API 应用的扩展字段(末影箱/效果/成就/PDC/addon)。取消它不会回滚已经离线写好的背包。 - 与 addon 的
shouldApply效果等价,二者任一否决都会跳过。写 addon 的用shouldApply,纯监听场景用本事件。
4.3 ItemSanitizeEvent / ItemQuarantineEvent —— 物品清洗 / 隔离(异步事件)
当某个物品因缺少注册项(多为附魔插件被移除)无法直接反序列化时,FastSync 会:先尝试净化(剥离未知附魔后恢复)→ 触发 ItemSanitizeEvent;净化也救不回来则隔离(原始字节进隔离表、槽位放占位屏障)→ 触发 ItemQuarantineEvent。
这两个都是异步事件,原因:解码发生在异步线程,且 preload 场景下玩家未必在线,所以事件只带 uuid,不带 Player。
@EventHandler
fun onSanitize(event: ItemSanitizeEvent) {
// 异步线程!不要在这里碰 Bukkit 主线程状态(不要 getPlayer 后改世界)
logger.warning(
"[Sanitize] ${event.uuid} ${event.container} slot=${event.slot} " +
"剥离未知附魔: ${event.removed.joinToString()}"
)
}
@EventHandler
fun onQuarantine(event: ItemQuarantineEvent) {
// 同为异步线程,多用于日志/统计/告警(如推送到运维群)
logger.severe(
"[Quarantine] ${event.uuid} ${event.container} slot=${event.slot} " +
"记录id=${event.recordId} 原因: ${event.error}"
)
}
字段:
ItemSanitizeEvent(uuid, container, slot, removed: List<String>)——removed是被剥离的附魔 key 明细。ItemQuarantineEvent(uuid, container, slot, recordId: Long, error: String)——recordId是隔离记录 id(0表示写库失败),error是反序列化失败原因。container取值为QuarantineRecord.CONTAINER_INVENTORY("inventory")或CONTAINER_ENDER_CHEST("ender_chest")。
红线:监听器里绝不能操作主线程状态(改玩家、改世界、开 GUI)。只做纯计算 / 记日志 / 计数 / 异步告警。若确需回到主线程做事,自行 runTask 切回并做好玩家可能不在线的判断。
5. 读取玩家数据 / 背包物品
5.1 在线玩家(首选,最简单)
只要玩家在线且同步已完成,直接用 Bukkit API 读即可——FastSync 已经把数据应用到了玩家身上:
val inv = player.inventory // 背包
val ender = player.enderChest // 末影箱
val held = inv.itemInMainHand
不要为了读在线玩家数据去碰 FastSync 的数据库或字节。在线玩家身上的就是最新的。
5.2 从 FastSync 的字节反序列化容器(少数进阶场景)
如果你拿到的是 FastSync 存的容器 blob(例如你在做数据分析工具、读备份/隔离原始字节),可以用序列化工具解开:
流程:容器 blob –InventoryBlobCodec.decode–> 槽位 -> 单品字节 –ItemCodec.deserialize–> ItemStack。
import com.xbaimiao.fastsync.serialize.InventoryBlobCodec
import com.xbaimiao.fastsync.serialize.ItemCodec
fun decodeContainer(blob: ByteArray): Map<Int, ItemStack> {
val result = LinkedHashMap<Int, ItemStack>()
// 帧头版本不认识时会抛 IllegalStateException(数据由更新版本 FastSync 写入)
val frames: Map<Int, ByteArray> = InventoryBlobCodec.decode(blob)
for ((slot, bytes) in frames) {
when (val decoded = ItemCodec.deserialize(bytes)) {
is ItemCodec.Decoded.Ok -> result[slot] = decoded.item
is ItemCodec.Decoded.Sanitized -> {
// 净化后可用,decoded.removed 是被剥离的附魔明细
result[slot] = decoded.item
}
is ItemCodec.Decoded.Broken -> {
// 净化也救不回来,decoded.error 是失败原因;这里按需跳过或记录
}
}
}
return result
}
关键点:
InventoryBlobCodec帧格式:[u8 版本=1][int 数量]{ [int 槽位][int 字节长度][单品字节] }...。按槽位独立成帧,一个物品坏了不影响同容器其他物品。ItemCodec.serialize走 Paper 官方ItemStack#serializeAsBytes()(GZIP NBT,内嵌 DataVersion,跨版本自动升级)。ItemCodec.deserialize返回三态,必须都处理:Decoded.Ok(item):正常。Decoded.Sanitized(item, removed):剥离未知附魔后恢复,item可用。Decoded.Broken(error):无法恢复。
5.3 PlayerData 字段与列名常量
FastSync 主表 fastsync_player(PlayerData.TABLE_NAME)每列都有列名常量(在 PlayerData 伴生对象里),别硬编码字符串:
| 常量 | 列名 | 含义 / 编码 |
|---|---|---|
COL_UUID |
uuid |
玩家 UUID(主键,VARCHAR(36)) |
COL_DATA_VERSION |
data_version |
单调递增版本号,乐观写凭据 |
COL_LOCK_OWNER |
lock_owner |
租约锁持有者(服务器名),NULL=空闲 |
COL_LOCK_EXPIRES_AT |
lock_expires_at |
租约过期时间戳(ms) |
COL_INVENTORY |
inventory |
背包 blob(InventoryBlobCodec 帧格式) |
COL_ENDER_CHEST |
ender_chest |
末影箱 blob |
COL_EFFECTS |
effects |
药水效果(Gson JSON) |
COL_HEALTH / COL_MAX_HEALTH |
health / max_health |
血量 / 最大血量(double) |
COL_FOOD / COL_SATURATION |
food / saturation |
饱食度 / 饱和度 |
COL_EXP / COL_LEVEL |
exp / level |
经验条 / 等级 |
COL_HELD_SLOT |
held_slot |
当前手持槽位 |
COL_GAME_MODE |
game_mode |
游戏模式名 |
COL_STATISTICS |
statistics |
统计(Gson JSON + GZIP) |
COL_ADVANCEMENTS |
advancements |
成就进度(Gson JSON + GZIP) |
COL_PDC |
pdc |
玩家 PersistentDataContainer(Paper serializeToBytes) |
COL_PLUGIN_DATA |
plugin_data |
addon 扩展数据(PluginData GZIP KV:id→字节) |
COL_FIELD_HASHES |
field_hashes |
各字段指纹 JSON(诊断用) |
COL_UPDATED_AT |
updated_at |
最后更新时间戳 |
plugin_data列就是你 addon 数据的落脚点。它是一个 GZIP 压缩的 KV 容器(PluginData:[int 数量]{ [UTF key][int len][bytes] },key 存小写)。你几乎不需要自己拆它——用 addon 的onSave/onApply回调即可,FastSync 会替你 put/read 对应 id 的那段字节。
5.4 离线读:目前没有公开的离线读 API
如实说明:FastSync 当前没有对外开放「读取离线玩家数据」的稳定 API。内部读离线数据走 MysqlStorage.load(uuid) + 租约锁:抢锁 → 读行 → (离线写 .dat)。这条路径和「在线服正持有该玩家租约」是互斥设计的——旧数据永远盖不掉新数据(data_version 乐观版本 + 租约锁双保险)。
因此,不建议第三方插件绕过 FastSync 直接去查/改 fastsync_player:
- 你可能读到一个正被某个在线服持有的玩家的旧快照。
- 你若直接写库,会与 FastSync 的乐观版本写和租约锁冲突,极易造成回档 / 数据错乱。
推荐做法:一切离线数据操作,尽量转化为对在线玩家的操作(等玩家上线、监听 FastSyncDoneEvent 后再动),或使用 FastSync 提供的管理指令(备份、隔离恢复等)。确有离线批处理需求,请与 FastSync 作者确认是否有受控的入口,不要自己撬库。
6. 修改玩家背包的正确姿势
6.1 在线玩家:直接用 Bukkit Inventory API
player.inventory.addItem(ItemStack(Material.DIAMOND))
player.inventory.setItem(0, someItem)
player.enderChest.setItem(0, someItem)
改完不需要你手动落库——FastSync 会在玩家退出 / pre-quit / 周期自动保存 / 关服时自动快照并写库。如果你需要立即落库(例如发奖后想马上跨服可见),可提示管理员执行:
/fastsync save <玩家名>
(/fastsync save 需要 fastsync.admin 权限,且玩家在线、数据已加载完成。目前它是管理指令,不是编程 API。)
6.2 重要警告:不要在同步窗口内改背包
FastSync 在两个窗口会冻结玩家(拦截背包点击/拖拽/丢弃/拾取/交互/开容器等,见 FreezeListener):
- 进服冻结窗口:Join 到扩展字段应用完成(
FastSyncDoneEvent触发前)这段时间。 - pre-quit 冻结窗口:代理切服前提前保存后,到玩家真正离开前(≤5 秒)。
在这两个窗口内改背包会有两类后果:
- 你的背包操作可能被冻结监听器直接取消(
InventoryClickEvent等被setCancelled(true))。 - 即使部分改动生效,也可能与 FastSync 的快照/应用竞态,造成刷物品或丢物品。
正确时机:等到 FastSyncDoneEvent 之后再操作在线玩家背包。若你的逻辑由玩家进服触发,把它挂在 FastSyncDoneEvent 监听里(必要时再延迟 1~2 tick),而不是 PlayerJoinEvent。
7. 损坏物品 / 隔离机制对开发者的影响
当某物品无法反序列化又净化不回来时,FastSync 会在该槽位放一个占位物品顶替,把原始字节安全存进隔离表,等相关插件装回来重新登录时自动复活。占位物品特征:
- 材质:
Material.BARRIER(屏障)。 - PDC:带一个 LONG 类型的键,命名空间键为
fastsync:quarantine_id,值是隔离记录 id。- FastSync 内部通过
QuarantineService.placeholderKey(即NamespacedKey(fastSyncPlugin, "quarantine_id"))读写它。 - 你的插件若不想依赖
QuarantineService,可直接构造同一个键来识别:
- FastSync 内部通过
import org.bukkit.NamespacedKey
import org.bukkit.persistence.PersistentDataType
// 命名空间来自 FastSync 插件名(小写)"fastsync",键名 "quarantine_id"
private val QUARANTINE_KEY = NamespacedKey("fastsync", "quarantine_id")
fun isQuarantinePlaceholder(item: ItemStack?): Boolean {
if (item == null || item.type != Material.BARRIER) return false
val meta = item.itemMeta ?: return false
return meta.persistentDataContainer.has(QUARANTINE_KEY, PersistentDataType.LONG)
}
开发者必须注意:
- 不要误删、不要误判这些占位屏障为「玩家自己的普通屏障方块」。删了它 = 玩家那件损坏物品永久丢失(虽然原始字节还在隔离表,但玩家背包里的取货凭证没了)。
- 任何遍历玩家背包做批量处理的插件(清理插件、反作弊、物品扫描、经济回收、格式化背包等)——遇到带
fastsync:quarantine_idPDC 的物品一律跳过,别动它、别序列化它、别当垃圾清掉。 - 别试图「修复」或「替换」占位物品。它会在相关插件回归后由 FastSync 自动复活成真实物品。
8. 附录:线程模型、同步时序与跨服数据流
8.1 线程模型
| 阶段 | 线程 | 说明 |
|---|---|---|
| 快照(capture) | 玩家所在线程 | Paper 主线程 / Folia 玩家区域线程;读玩家状态必须在这里 |
| 序列化 / 反序列化 | 异步线程 | 只做字节 ↔ 对象转换,不碰世界状态;物品解码、隔离编排都在这 |
| 应用(apply) | 玩家所在线程 | 把反序列化结果 setItem 回玩家;addon 的 onApply 在这 |
addon onSave / onApply |
玩家所在线程 | 可安全调用在线 Bukkit / 第三方 API |
FastSyncDoneEvent / PreSyncEvent |
主线程 | 同步完成 / 进服门禁 |
ItemSanitizeEvent / ItemQuarantineEvent |
异步线程 | 只带 uuid,禁止碰主线程状态 |
8.2 preload-always 同步时序(简述)
进服路径(同步永远发生在玩家出生之前):
- 代理预载(preload):代理提前通知目标服,后台异步抢租约 + 读库 + 把 vanilla 字段离线写进玩家
.dat。 AsyncPlayerPreLoginEvent(异步,可阻塞):命中 preload 缓存直接放行;未命中则现场抢租约 + 读库 + 离线写兜底。都失败按配置拒绝登录(默认拒绝,绝不静默给空背包)。- NMS 读
.dat:玩家出生即拥有正确的背包 / 血量 / 经验(这些字段离线写已覆盖)。 PlayerJoinEvent:进服冻结开始;异步反序列化「离线写覆盖不到的扩展字段」(末影箱/效果/成就/PDC/addon),再切回玩家线程应用。- 门禁检查(
shouldApply+PreSyncEvent)→ 应用扩展字段 → 解冻 → 抛FastSyncDoneEvent→ addon INIT 探针 warmup。
退出路径:pre-quit 已保存则跳过重复保存;否则玩家线程快照 → 异步保存 + 释放租约。周期自动保存、关服兜底同理。
8.3 跨服数据流
源服玩家改动
│ (退出 / pre-quit / 自动保存 / 关服)
▼
玩家线程快照 ──► 异步序列化 ──► MySQL 乐观写(data_version+1) + 释放租约
│
代理编排 preload
▼
目标服 AsyncPreLogin 抢租约 + 读库 + 离线写 .dat ──► 出生即就位 ──► Join 应用扩展字段 ──► FastSyncDoneEvent
- 无 Redis:租约锁内嵌在 MySQL 主表,抢锁 + 读数据一个事务完成。
- 旧数据永远盖不掉新数据:
data_version乐观版本是独立于租约锁的第二道防线。
8.4 常见注意事项清单
- addon 数据字节格式自定,但要自洽且向后兼容(建议自带版本号字节)。
onSave/onApply是玩家线程、别阻塞;onSave要容忍SaveReason.INIT并让第三方类引用能被执行到(防 ClassNotFound)。onApply的bytes可能为null,务必处理。- 需要读同步结果再刷新自己 → 用
FastSyncDoneEvent,别用PlayerJoinEvent。 - 异步事件(Sanitize/Quarantine)里绝不碰主线程状态。
- 改在线玩家背包用 Bukkit API,且避开进服/pre-quit 冻结窗口(等
FastSyncDoneEvent之后)。 - 遍历背包时跳过
fastsync:quarantine_id占位屏障。 - 离线数据别自己撬库——用在线玩家路径或 FastSync 指令。
softdepend: [FastSync]+compileOnly依赖 +onEnable里判断isPluginEnabled("FastSync")后再注册 addon。