版本号同步机制
基本信息
| 属性 | 值 |
|---|---|
| 打包工具 | 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 一批切分,见 网络封包协议 |
相关条目
- 队伍数据模型 - 版本号由谁发出、
markDirty在哪 - OrderedDimCache -
getRevision()与区间截取的具体实现 - 持久化与 NBT 结构 -
revision.dat与服务端 NBT 的字段对照 - 网络封包协议 - 6 条封包的字段与方向
- Mixin 清单 - 触发
onClientCacheLoad的挂钩