持久化与 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——只有真正有维度数据变更时才标脏,避免无谓的存档写入。

相关条目