任务存档

storage/TaskNHWorldData.java,继承 WorldSavedData。任务不放在方块实体或物品里,而是以整个世界的 WorldSavedData 形式落在主世界(维度 0)的 mapStorage 中。字段与 NBT 键见 任务数据模型。

存档名与位置

项 值 来源
当前存档名 TaskNHTasks private static final String DATA_NAME = "TaskNHTasks"
旧存档名(一次性迁移源) ForemanTasks LEGACY_DATA_NAME;源码注释:模组原名 “Foreman”
存储位置 主世界(server.worldServerForDimension(0))的 MapStorage TaskNHWorldData.get()

get() 每次调用都走 MapStorage.loadData(),客户端调用会抛 IllegalStateException(源码:TaskNHWorldData.get() called on client side)。

首次加载(loadData 返回 null)时会创建新实例,并检查旧键 ForemanTasks:若旧数据非空(任务或 playerLastSeen 任一非空),则整体搬入、置 markDirty(),并打出 info 日志「Migrated tasks from legacy storage ‘ForemanTasks’ to ‘TaskNHTasks’ (N team(s))」。只做一次,因为搬入后 TaskNHTasks 已存在,下次走正常加载分支。

顶层 NBT 结构

键 类型 说明
worldIdMost / worldIdLeast long 世界 UUID,首次调用 getWorldId() 时随机生成并置脏。用于客户端按世界分别保存图钉与折叠状态
perTeamTasks TAG_List of compound 每项见下
playerLastSeen TAG_List of compound 每项为 {most, least, ts},记录玩家退出时的 System.currentTimeMillis()

perTeamTasks 的每个元素:

  • teamIdMost / teamIdLeast(long)——队伍 UUID,来自 GTNHLib 的 Team
  • tasks(TAG_List of compound)——该队伍的全部 Task.toNBT()

内层索引是 Map<UUID, LinkedHashMap<UUID, Task>>:外层 key = 队伍 ID,内层 key = 任务 ID,用 LinkedHashMap 保住插入顺序。

按队伍隔离

任务从属于队伍,不从属于玩家。每个子命令和每条封包都先用 TeamManager.getTeamByPlayer(playerUUID) 查队伍,查不到直接 return null(无队伍的玩家不参与任何任务操作)。对外的 getTeamTasks(UUID teamId) 返回 Collections.unmodifiableCollection(...),没有「跨队伍查询」的方法。

服务端改动的统一后续动作是 TaskNHNetwork.sendToTeamMembers(team.getMembers(), new SyncAllTasksPacket(...)),只推给在线队员(sendToTeamMembers 遍历 playerEntityList,离线的队员被静默跳过)。

排序与移动

  • addTask(teamId, task) —— 总是先调 moveToEnd,即新建任务一律落到同组末尾,无论是谁创建的
  • moveToEnd(teamId, task) —— 置 task.order = Task.endOrder(getTeamTasks(teamId), task)
  • updateTask(teamId, task) —— 覆盖写入并置脏
  • deleteTask(teamId, taskId) —— 删除任务,并连带删除其子任务(removeIf(t -> taskId.equals(t.parentId)))。源码注释:嵌套只有一层,所以一层 removeIf 就够
  • mergeTasks(consumedTeamId, survivingTeamId) —— 队伍合并时把被吞并队伍的全部任务搬进幸存队伍

endOrder() 跳过两类任务:other.id.equals(task.id)(自己)以及 other.status != task.status(不同状态的任务各自独立编号),再要求 Objects.equals(other.parentId, task.parentId)(同组)。因此切换状态会改变所在分组,UpdateTaskPacket 与 /tasknh done 都会在状态变化时先 moveToEnd 再 updateTask。源码注释:这一步必须在重排之前,因为重排读的是任务最终落定的状态。

旧扁平格式迁移

readFromNBT 分两层兼容:

  1. 有 perTeamTasks 键 —— 当前格式,正常读取。
  2. 否则有 tasks 键 —— 旧的扁平格式(所有任务平铺,不分队伍)。这些任务被塞进一个哨兵队伍 UUID:00000000-0000-0000-0000-000000000000,并打出 info 日志「Migrated N legacy tasks to sentinel team …」。

两处读取循环都用 try { ... } catch (Exception e) 包裹单个任务,失败只打 warn(「Skipping corrupt task at team …, index …」)并跳过该条,一条坏数据不会让整份存档加载失败。

markDirty() 与 client 调用

addTask / updateTask / deleteTask / mergeTasks / setPlayerLastSeen / getWorldId()(首次生成时)都调 markDirty()。getWorldId() 在 worldId == null 时生成随机 UUID 并置脏——源码注释说明这是为了让客户端区分不同存档,从而按存档分别保存图钉与折叠状态。

客户端侧只通过 WorldIdPacket 拿到这个 UUID,从不直接读 WorldSavedData。