版本号同步机制

基本信息

属性 值
打包工具 com.rune580.sharedprospecting.util.RevisionUtil
客户端状态 com.rune580.sharedprospecting.database.ClientRevision(@EventBusSubscriber(side = Side.CLIENT))
revision 本体 CoordinatePacker.pack(oreCount, 0, fluidCount) — 是数量对,不是计数器
客户端落盘 <VP 世界缓存目录>/<worldId>/revision.dat
同步粒度 队伍 × 维度
增量数据通道 Visual Prospecting 自己的 ProspectingNotification(走 VP.network)

一句话概括

服务端按维度维护一条只增不减、有序去重的矿脉/流体坐标列表;把"这个列表有多长"打包成一个 long 当作版本号推给客户端;客户端存下自己已知的版本号,并把它回传给服务端;服务端据此只补发 [已知长度, 当前长度) 这一段区间。

revision 的打包

RevisionUtil 全部 5 个方法:

方法 实现
packRevision(oreSize, fluidSize) CoordinatePacker.pack(oreSize, 0, fluidSize)
getOreSize(revision) CoordinatePacker.unpackX(revision)
getFluidSize(revision) CoordinatePacker.unpackZ(revision)
getOreVeinKey(chunkX, chunkZ) Utils.chunkCoordsToKey(Utils.mapToCenterOreChunkCoord(x), Utils.mapToCenterOreChunkCoord(z))
getUndergroundFluidKey(chunkX, chunkZ) Utils.chunkCoordsToKey(Utils.mapToCornerUndergroundFluidChunkCoord(x), Utils.mapToCornerUndergroundFluidChunkCoord(z))

坐标打包一律走 GTNHLib 的 CoordinatePacker,Y 槽恒为 0(矿脉和地下流体都是二维区块坐标,没有 Y 信息)。两个键算法与 OrderedDimCache 共用。

客户端状态

ClientRevision 的 5 个静态字段:

字段 类型 作用
clientCacheDims Map<Integer, DimensionCache> 反射自 VP WorldCache.dimensions,直接指向 ClientCache.instance
dimRevision Int2LongMap(Int2LongOpenHashMap) 维度 ID → 客户端已知的 revision
revisionFile File 上次使用的 revision.dat
teamId String,初值 "" 当前客户端认为归属的队伍 UID
pendingRevision ObjectLongPair<String> 缓存未就绪时暂存的一条待处理版本通知
fullDepletionSent boolean 本次缓存加载是否已全量上报过枯竭矿脉

clientCacheDims 在静态初始化块里用反射取得,失败直接抛 RuntimeException("Failed to get client dimensions field", e):

clientCacheDims = (Map<Integer, DimensionCache>) ReflectionHelper
    .findField(WorldCache.class, "dimensions").get(ClientCache.instance);

用裸反射而不是 Mixin 清单 里的 WorldCacheAccessor 是因为 dimensions 是 private 字段而非方法,Accessor 生成不了。

完整同步时序

1. 服务端推送版本号

三个时机,见 队伍数据模型:

  • 玩家入队(ForgeTeamPlayerJoinedEvent)
  • 玩家登录(PlayerEvent.PlayerLoggedInEvent,LOWEST 优先级)
  • 玩家换维度(PlayerEvent.PlayerChangedDimensionEvent)
  • 队伍数据变更且该玩家当前就在受影响维度(updateMembers)

发的是 MessageUpdateRevision(team.getUIDCode(), player.dimension, getDimRevision(dim))。

2. 客户端 updateRevision(team, dim, revision)

WorldCacheAccessor cache = (WorldCacheAccessor) ClientCache.instance;
if (!cache.getIsLoaded()) { pendingRevision = ObjectLongPair.of(team, revision); return; }
if (!teamId.equals(team)) { teamId = team; dimRevision.clear(); }
if (team.isEmpty()) return;
long oldRevision = dimRevision.put(dim, revision);
if (oldRevision == 0) { /* 首次见到该维度 → 上传本地已有数据 sendCache() */ }
new MessageRequestUpdate(oldRevision).sendToServer();

逐步说明:

步骤 条件 行为
缓存未就绪 !getIsLoaded() 存入 pendingRevision(只保留最后一条)并直接返回
换队伍 !teamId.equals(team) 更新 teamId 并清空整个 dimRevision 表
无队伍 team.isEmpty() 返回,不写 dimRevision
记录版本 总是 dimRevision.put(dim, revision),取回旧值 oldRevision
首次见到该维度 oldRevision == 0 若本地该维度已有矿脉或流体,sendCache(dimension) 上传
回问服务端 总是 MessageRequestUpdate(oldRevision)

oldRevision == 0 同时是"首次"的哨兵值和"回问 0 条"的请求值,所以换队伍清表后,下一次收到的推送会同时触发"上传本地数据"和"请求全量增量",等于自动完成一次重建。

3. 客户端缓存就绪后补发

onClientCacheLoad(File dir) 由 Mixin 清单 里的 MixinWorldIdNotification 在 VP 的 ClientCache.loadVeinCache 返回后调用:

loadRevisionData(dir);
if (pendingRevision != null) {
    int dim = Minecraft.getMinecraft().thePlayer.dimension;
    updateRevision(pendingRevision.left(), dim, pendingRevision.rightLong());
    pendingRevision = null;
}
if (!fullDepletionSent) sendFullDepletion();

注意 pendingRevision 只存了队伍和 revision,没有存维度。 补发时用的是玩家当前所在维度。所以在缓存加载完成前收到的版本号,会被套用到玩家当下的维度上。

4. 服务端 sendUpdate(player, playerRevision)

OrderedDimCache cache = dimensions.computeIfAbsent(player.dimension, OrderedDimCache::new);
long revision = cache.getRevision();
if (revision == playerRevision) return;
List<OreVeinPosition> oreVeins = cache.getOresForRevision(playerRevision);
List<UndergroundFluidPosition> fluids = cache.getFluidsForRevision(playerRevision);
VP.network.sendTo(new ProspectingNotification(oreVeins, fluids), player);

版本相同直接不发;不同则走 VP 自己的封包(不是 sharedprospecting 通道)下发两个增量列表。区间截取逻辑见 OrderedDimCache。

5. 新矿脉入库并回推

VP 探到新矿脉 → Mixin 清单 捕获 → SPTeamData.addOreVeins(...) → 去重后 updateMembers(modifiedDims) → team.markDirty() + 向该维度在线成员发新的 MessageUpdateRevision。闭环回到第 1 步。

客户端 revision 的落盘

revision.dat(gzip NBT,经 serverutils.lib.util.NBTUtils):

{
  teamId: String,          // "teamId"
  fullDepletionSent: bool, // "fullDepletionSent"
  revisions: {             // "revisions"
    "<dimId>": long,       // 键是 Integer.toString(dim)
  }
}
  • 读取:loadRevisionData(File dir) — 若 dimRevision 非空则先 reset();NBTUtils.readNBT 返回 null(文件不存在)就直接返回,此时 teamId 保持 ""、fullDepletionSent 保持 false。
  • 写入:只有 saveRevisionData() 一处调用,且只从 reset() 调用。
  • reset() = saveRevisionData() + dimRevision.clear() + fullDepletionSent = false,由 FMLNetworkEvent.ClientDisconnectionFromServerEvent 触发。

reset() 在 loadRevisionData 开头也会被调(当 dimRevision 非空时),这是换存档保护:离开服务器时先把旧服务器的版本表存回它自己的文件,再载入新存档的。

revision.dat 不会因为换队伍而删除,换服后靠 teamId 字符串比较触发 dimRevision.clear() 来重置。

枯竭(depleted)状态是独立的一套同步

矿脉"是否已挖空"走完全不同的通道,不受 revision 机制管辖:

方向 封包 触发
C→S 全量 MessageSendDepleted(dim, map) ClientRevision.sendFullDepletion(),每次缓存加载只发一次(fullDepletionSent 守门)
C→S 单条 MessageSendDepleted(map)(维度哨兵 = Integer.MIN_VALUE) Mixin 清单 的 MixinOreVeinPosition 挂在 toggleDepleted 的 TAIL
S→C 批量 MessageUpdateDepletions(dim, list) 玩家换维度时补发该维度的全部枯竭矿脉
S→C 单条 MessageUpdateSingleDepleted(dim, pos, bool) 服务端 updateDepletedVeins 且 depletedVeins.size() == 1 时广播

sendFullDepletion() 遍历客户端所有维度,只收集 vein.isDepleted() 为真的矿脉,组成 Long2BooleanOpenHashMap 后逐维度发送。客户端 updateDepletions 用"在列表里 = 已枯竭"的全量覆盖语义(depletedVeins.contains(key)),所以换维度时收到的是该维度的完整快照而非增量。

回环防护:MixinOreVeinPosition 只在客户端侧加载(Side.CLIENT),服务端调用 toggleDepleted 不会反向再发一条包给服务端。

失配与边界情况

情况 行为
客户端报告的 revision 大于服务端当前长度 Math.min 把它夹到 oreVeins.size(),随后 oreSize == oreVeins.size() 成立 → 返回空列表,不发数据也不报错。下次玩家重连/换维度时 MessageUpdateRevision 会用服务端真实值覆盖客户端表,自愈
客户端 dimRevision 缺失该维度(oldRevision == 0) 触发本地数据上传 + 请求从 0 开始的全部增量
缓存未加载时收到多条版本通知 pendingRevision 被后者覆盖,只处理最后一条
玩家退出队伍 服务端 clearClientRevision 发 MessageUpdateRevision("", 0, 0);客户端先因 teamId 不匹配而 teamId = "" 并 dimRevision.clear(),再因 team.isEmpty() 提前返回,不写 dimRevision
队伍数据被删除 ForgeTeamDeletedEvent → FileUtils.delete(team.getDataFile("sharedprospecting"));客户端侧下次进服会因 oldRevision == 0 重新上传本地数据
封包过大 sendCache 按 1500 一批切分,见 网络封包协议

相关条目