持久化与 NBT 结构
基本信息
| 属性 | 值 |
|---|---|
| 服务端载体 | serverutils.lib.data.ForgeTeam.getDataFile("sharedprospecting") — 路径由 ServerUtilities 决定,本仓库不硬编码 |
| 服务端写盘 | NBTUtils.writeNBTSafe(...),触发时机 ForgeTeamSavedEvent |
| 服务端删盘 | FileUtils.delete(...),触发时机 ForgeTeamDeletedEvent |
| 客户端载体 | <VP 世界缓存目录>/<worldId>/revision.dat |
| 客户端写盘 | NBTUtils.writeNBT(...),只从 reset() 一处触发 |
| 旧格式迁移源 | sharedprospecting/teams/<teamUIDCode>/(本仓库唯一硬编码的相对路径) |
| 存储格式 | gzip 压缩的 NBT(NBTTagCompound) |
没有数据库、没有 SQLite、没有 JSON。 全部数据是 NBT 文件。
服务端:队伍 NBT
// SPTeamData.save()
NBTTagCompound nbt = new NBTTagCompound();
NBTTagCompound dimTag = new NBTTagCompound();
for (Int2ObjectMap.Entry<OrderedDimCache> cache : dimensions.int2ObjectEntrySet()) {
dimTag.setTag(Integer.toString(cache.getIntKey()), cache.getValue().save());
}
nbt.setTag("dimensions", dimTag);
完整结构:
{
dimensions: {
"<dimId>": { // 键是 Integer.toString(dim),如 "0" / "-1" / "42"
oreList: [ TAG_Long, TAG_Long, ... ],
fluidList: [ TAG_Long, TAG_Long, ... ],
depletedVeins: [ TAG_Long, TAG_Long, ... ]
},
...
}
}
字段对照
| NBT 路径 | 类型 | 内容 | 来源 |
|---|---|---|---|
dimensions |
NBTTagCompound |
维度 ID → 该维度缓存 | SPTeamData.save() |
dimensions.<dimId> |
NBTTagCompound |
维度 ID 字符串化后的键 | OrderedDimCache.save() |
dimensions.<dimId>.oreList |
NBTTagList of NBTTagLong |
已探明矿脉区块键,顺序即发现顺序 | saveList(oreVeins) |
dimensions.<dimId>.fluidList |
NBTTagList of NBTTagLong |
已探明地下流体区块键 | saveList(undergroundFluids) |
dimensions.<dimId>.depletedVeins |
NBTTagList of NBTTagLong |
已挖空的矿脉键 | saveList(depletedVeins) |
只存坐标键,不存矿脉类型、Y 值、方块 id、数量。 矿脉实体靠键反查 VP 的 ServerCache;地下流体连反查都不做,发送时重新扫描世界(见 OrderedDimCache)。
读取
// SPTeamData.load()
importOldData(); // 先迁移旧格式
if (nbt == null) return;
NBTTagCompound dimTag = nbt.getCompoundTag("dimensions");
for (String key : dimTag.func_150296_c()) { // func_150296_c = NBTTagCompound.getKeySet()
int dimId = Integer.parseInt(key);
dimensions.computeIfAbsent(dimId, OrderedDimCache::new).load(dimTag.getCompoundTag(key));
}
load 不重置已有列表,readOres / readFluids / readLongList 都是往里 add。因为 dimensions 是 final map 且只在 ForgeTeamLoadedEvent 时从空实例开始填充,正常路径下不会重复加载。
readLongList 用类型化读取保证安全:
NBTTagList tagList = compound.getTagList(tag, Constants.NBT.TAG_LONG);
for (Object obj : tagList.tagList) { // tagList 字段由 AT 打开
list.add(((NBTTagLong) obj).func_150291_c()); // func_150291_c = NBTTagLong.getLong()
}
tagList(NBTTagList.func_74747_a)是 private 字段,源码为此配了 Access Transformer:
# src/main/resources/META-INF/sharedprospecting_at.cfg
public net.minecraft.nbt.NBTTagList field_74747_a # tagList
AT 文件名由 gradle.properties 的 accessTransformersFile = sharedprospecting_at.cfg 指定。整个 mod 只有这一条 AT。
旧格式兼容
readOres / readFluids 各自带一段回退分支:
private void readOres(NBTTagCompound compound) {
NBTTagCompound ores = compound.getCompoundTag("ores");
if (!ores.hasNoTags()) { // 旧格式存在 → 走旧路径并 return
loadFromLegacyTag(oreVeins, ores);
return;
}
readLongList(oreVeins, "oreList", compound);
}
旧格式与新格式的结构差异:
| 旧格式 | 新格式 | |
|---|---|---|
| 容器 | NBTTagCompound |
NBTTagList |
| 矿脉 | 键 "<long的十进制字符串>" → 值 long |
TAG_Long 列表 |
| 流体 | 同上,fluids 键 |
同上,fluidList |
| 解析 | Long.parseLong(key) 遍历 func_150296_c() |
((NBTTagLong) obj).func_150291_c() |
depletedVeins |
无兼容分支 | 只有新格式 |
即旧格式是"键值对 map"风格,新格式是"紧凑列表"风格——新格式省掉了把 long 转成字符串再转回来的开销。
depletedVeins 没有旧格式兼容。 旧存档里不存在这个字段,getTagList 返回空列表,枯竭记录从零开始。
旧数据的文件级迁移
与 NBT 字段兼容是两层独立的事。除了上面的字段回退,还有一个整目录级的一次性迁移,load() 第一行就调 importOldData():
| 项 | 值 |
|---|---|
| 源路径 | new File(SharedProspectingMod.MOD_ID + "/teams/", team.getUIDCode()) → sharedprospecting/teams/<队伍UID>/ |
| 读取方式 | 匿名 WorldCache 子类覆写 getStorageDirectory() 指向源目录 |
| 载入调用 | tempCache.loadVeinCache(WorldIdHandler.getWorldId()) |
| 反射字段 | ReflectionHelper.findField(WorldCache.class, "dimensions") |
| 搬运 | 遍历 DimensionCache 的 getAllOreVeins() / getAllUndergroundFluids() 灌入新结构 |
| 清理 | FileUtils.delete(oldFile) + team.markDirty() |
| 失败处理 | 反射异常只 LOG.error("Failed to get dimension cache map", e) 后 return,不崩服 |
源码注释标注"仅迁移时执行一次,无需缓存字段"。旧目录删掉后,下一次 load() 会在第一步的 exists() 检查短路。
客户端:revision.dat
路径由 Mixin 清单 的 MixinWorldIdNotification 在 VP 加载缓存后计算:
new File(((WorldCacheAccessor) instance).callGetStorageDirectory(), worldId)
也就是 <VP 自己的世界缓存目录>/<worldId>/revision.dat——写在 VP 的目录里,不是 sharedprospecting 自己的目录。
结构:
{
teamId: String, // "teamId"
fullDepletionSent: boolean, // "fullDepletionSent"
revisions: { // "revisions"
"<dimId>": long, // 键 Integer.toString(dim)
}
}
读写都走 loadRevisionData / saveRevisionData,字段名硬编码为字符串字面量。revisions 子标签的键是维度 ID 的字符串形式,逐个 Integer.parseInt(key) 还原。
写入时机极其受限:saveRevisionData() 全仓库只有一个调用点,即 reset();而 reset() 只有两个触发点——FMLNetworkEvent.ClientDisconnectionFromServerEvent 和 loadRevisionData 开头(当 dimRevision 非空时)。所以正常游玩过程中 revision.dat 不会被动过,退出服务器时才会落盘。
落盘频率
| 端 | 触发 | 频率 |
|---|---|---|
| 服务端 | ForgeTeamSavedEvent |
ServerUtilities 决定;每次数据变更都会 team.markDirty() |
| 服务端 | ForgeTeamDeletedEvent |
队伍删除时删整个文件 |
| 客户端 | reset() |
断开连接时 / 切换存档时 |
| 客户端 | loadRevisionData |
revision.dat 不存在则不创建空文件(readNBT 返回 null 后直接 return) |
markDirty() 的调用点在 SPTeamData.updateMembers——只有真正有维度数据变更时才标脏,避免无谓的存档写入。
相关条目
- 队伍数据模型 -
load/save的触发者importOldData - OrderedDimCache - 三个列表的语义与排序规则
- 版本号同步机制 - revision 与
revision.dat的生命周期 - Mixin 清单 - 客户端目录是怎么算出来的