FastSync 开发者对接文档

18

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.xbaimiaoartifactId = fast-invsync-api暂定,请以 FastSync 作者发布的实际坐标为准),version = <与目标服 FastSync 匹配的版本>
  • 依赖范围compileOnly(provided)。不要把 FastSync 打进你的 jar。

API 包对接面涉及的类型(按包):

  • com.xbaimiao.fastsync.addonFastSyncAddonFastSyncAddonManagerPluginData
  • com.xbaimiao.fastsync.dataSaveReasonPlayerData(列名常量)、QuarantineRecord(容器常量)
  • com.xbaimiao.fastsync.eventFastSyncDoneEventPreSyncEventItemSanitizeEventItemQuarantineEvent
  • com.xbaimiao.fastsync.serializeItemCodecInventoryBlobCodec

提示:如果发布的 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_data KV 容器里的 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)

  • 同样在玩家所在线程调用,进服同步时把上面保存的字节写回玩家。
  • bytesnull 表示该玩家没有本 addon 的数据(例如从没保存过、或上次 onSave 返回了 null)。务必处理 null 分支——通常是「什么都不做」或「缓存一个『无数据』标记」。
  • 反序列化用与 onSave 对称的 DataInputStream

shouldApply(player): Boolean

  • 进服门禁。任一 addon 返回 false,FastSync 就跳过该玩家整次扩展数据的应用(不仅是你这个 addon,而是所有 addon + 末影箱/效果/成就/PDC 等 API 应用字段一起跳过)。默认 true 放行。
  • 用途:存档切换类插件——玩家没切档就不覆盖它当前 profile 的数据。参考内置的 EpicProfileSwitchGate
  • 注意:preload 架构下背包/血量等 vanilla 字段已在玩家出生前离线写进 .datshouldApply 只能拦住 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。
  • 开发者注意事项
    1. onSave 收到 INIT 时,正常执行读取逻辑即可(此调用的返回值不会被真正落库,纯粹为触发类加载)。不要因为是 INIT 就直接 return null 跳过——那样触发不了类加载,探针就白费了。当然,如果你的读取逻辑本身遇到「无数据」而返回 null 是没问题的。
    2. onSave 里凡是 catch (t: Throwable) 包住第三方调用是好习惯,但不要把整个方法体包在一个「依赖没装就 return」的短路里挡在类引用之前——用一个 hasXxx 布尔判断来短路时,务必让「真正引用第三方类的那行」在依赖装了时能被执行到
    3. 别在 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(例如你在做数据分析工具、读备份/隔离原始字节),可以用序列化工具解开:

流程:容器 blobInventoryBlobCodec.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_playerPlayerData.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):

  1. 进服冻结窗口:Join 到扩展字段应用完成(FastSyncDoneEvent 触发前)这段时间。
  2. 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,可直接构造同一个键来识别:
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_id PDC 的物品一律跳过,别动它、别序列化它、别当垃圾清掉。
  • 别试图「修复」或「替换」占位物品。它会在相关插件回归后由 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 同步时序(简述)

进服路径(同步永远发生在玩家出生之前):

  1. 代理预载(preload):代理提前通知目标服,后台异步抢租约 + 读库 + 把 vanilla 字段离线写进玩家 .dat
  2. AsyncPlayerPreLoginEvent(异步,可阻塞):命中 preload 缓存直接放行;未命中则现场抢租约 + 读库 + 离线写兜底。都失败按配置拒绝登录(默认拒绝,绝不静默给空背包)。
  3. NMS 读 .dat:玩家出生即拥有正确的背包 / 血量 / 经验(这些字段离线写已覆盖)。
  4. PlayerJoinEvent:进服冻结开始;异步反序列化「离线写覆盖不到的扩展字段」(末影箱/效果/成就/PDC/addon),再切回玩家线程应用。
  5. 门禁检查(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)。
  • onApplybytes 可能为 null,务必处理。
  • 需要读同步结果再刷新自己 → 用 FastSyncDoneEvent,别用 PlayerJoinEvent
  • 异步事件(Sanitize/Quarantine)里绝不碰主线程状态
  • 改在线玩家背包用 Bukkit API,且避开进服/pre-quit 冻结窗口(等 FastSyncDoneEvent 之后)。
  • 遍历背包时跳过 fastsync:quarantine_id 占位屏障。
  • 离线数据别自己撬库——用在线玩家路径或 FastSync 指令。
  • softdepend: [FastSync] + compileOnly 依赖 + onEnable 里判断 isPluginEnabled("FastSync") 后再注册 addon。