8.2 存档结构与版本迁移

上一节把存档存进了抽象层,这一节解决更难的一半:玩家设备上躺着的旧存档,格式变了怎么办。用 v1→v2→v3 的迁移链演示三类改动——字段新增默认值、字段重命名、类型收紧,配可跑测试断言旧存档能升到最新且不丢数据、迁移幂等、未知版本明确失败,并用 S 层脚本对照迁移前后的体积与耗时。

站内已有一篇小游戏开发综述 ,那篇是平台能力综述,不会碰到「存档格式怎么升级」这种问题;本章给的是可跑、可断言的存储抽象层与迁移器。另有《游戏服务端编程实践》 那 43 篇讲的是服务端权威逻辑与协议,本章讲的是客户端侧:本地存储降级、存档迁移,以及哪些校验必须在客户端做、哪些必须交给服务端。分工的关键在于——迁移是纯客户端的、可完全单测的事,而校验是否可信则取决于有没有服务端权威。

8.1 把存档抽象成了「键值 + 容量 + 对账」,但它默认了一件事:存进去的格式永远不变。真实项目里这个假设活不过第一个版本。今天存档是 {coins, level},下个版本产品要加 energy、把 coins 改名成 gold、还要把历史遗留的字符串 level 收紧成整数——而几百万台设备上躺着的是旧格式。这些旧存档必须能升到新格式且不丢数据,否则玩家一更新就丢进度。

格式变更有三类,各有各的坑

把「格式变了」拆开,其实只有三类改动,每一类的风险不同:

第一类,字段新增。 加一个 energy 字段,老存档里没有。风险是「读到 undefined」——如果代码直接 save.energy - 1,结果就是 NaN,而 NaN 会顺着数值链路污染整局游戏。正确做法是给默认值,且默认值必须是「一个合理的老玩家状态」,不是 0。

第二类,字段重命名。 coins 改名成 gold。风险是静默丢数据:迁移时如果只读 gold、不读 coins,老玩家的金币就归零了,而且不会报错,只会表现为「我的钱怎么没了」。重命名必须显式地把旧字段的值搬到新字段。

第三类,类型收紧。 历史遗留的 level 可能是字符串(老 UI 绑定的锅),energy 可能是越界的浮点。风险是类型假设崩塌——"12" + 1 得到 "121",250 的能量超过上限却没人拦。收紧必须在迁移时一次性做完:整数化、钳制到合法区间、数组去重排序。

三类改动的共同点是:它们都必须在「读」的时候处理,而不是在「写」的时候。因为写新格式的代码只对未来的存档负责,旧存档已经躺在磁盘上了。所以迁移是读路径的一部分:读到什么版本的存档,就先升级到最新,再交给游戏逻辑。

迁移链:v1 → v2 → v3

下面这段是 P 层内核代码,把上面三类改动落成一条可测的迁移链。每个版本只负责「从上一版升到本版」这一步,migrate 负责把它们串起来。

{
  "version": 1,
  "game": "merge-island",
  "seed": 20261012,
  "note": "存档迁移耗时与体积对照的输入。全整数:时间用微秒整数、体积用字节整数,无浮点。样本里的字符串是历史遗留的数字字段(类型收紧的输入),不是浮点。",
  "costPerStepUs": 40,
  "costPerFieldUs": 8,
  "samples": [
    { "id": "v1_min", "raw": { "version": 1, "coins": 100, "level": 1, "island": "default" } },
    { "id": "v1_typical", "raw": { "version": 1, "coins": 8600, "level": 18, "island": "珊瑚礁" } },
    { "id": "v1_rich", "raw": { "version": 1, "coins": 999999, "level": 59, "island": "极光群岛" } },
    { "id": "v2_mid", "raw": { "version": 2, "gold": 4200, "level": 9, "island": "珊瑚礁", "energy": 77, "unlocked": [1, 2, 3, 5, 8] } },
    { "id": "v2_dirty", "raw": { "version": 2, "gold": "150", "level": "12", "island": "x", "energy": "140", "unlocked": [5, 5, -1, 2, 10, 10] } }
  ]
}
// file: core/src/e08/migrate.ts
// tier: P
// verify: node --test 'core/test/e08_*.test.ts'
//
// 存档版本迁移链 v1 → v2 → v3:字段新增默认值、字段重命名、类型收紧。
// 输入是「不可信的原始 JSON」(磁盘/云端来的东西没有类型保证),所以每一步先解析再规范化;
// 目标只有一个:旧存档能升到最新,且不丢数据。全整数、无浮点、无 Math.random()。

export type JsonValue = null | boolean | number | string | JsonValue[] | { [k: string]: JsonValue };
export type RawSave = { [k: string]: JsonValue };
export const LATEST_VERSION = 3;

/** 迁移终点:v3 存档。字段全部为规范类型(整数、字符串、整数数组)。 */
export interface SaveV3 {
  version: 3;
  schemaRev: number;
  gold: number;
  level: number;
  island: string;
  energy: number;
  unlocked: number[];
}

export type MigrateResult =
  | { ok: true; from: number; steps: string[]; save: SaveV3 }
  | { ok: false; reason: string };

/** 宽松取整:接受整数、浮点与历史遗留的数字字符串;无法解析取默认值。 */
function asInt(v: JsonValue | undefined, fallback: number): number {
  if (typeof v === "number" && Number.isFinite(v)) return Math.floor(v);
  if (typeof v === "string" && v.trim() !== "") {
    const n = Number(v);
    if (Number.isFinite(n)) return Math.floor(n);
  }
  return fallback;
}

function asString(v: JsonValue | undefined, fallback: string): string {
  return typeof v === "string" ? v : fallback;
}

function clampInt(v: number, lo: number, hi: number): number {
  if (v < lo) return lo;
  if (v > hi) return hi;
  return v;
}

/** v1 → v2:重命名 coins → gold;新增 energy(默认 100)与 unlocked(默认空)。 */
export function migrateV1toV2(raw: RawSave): RawSave {
  return {
    version: 2,
    gold: asInt(raw.coins, 0), // 字段重命名
    level: asInt(raw.level, 1),
    island: asString(raw.island, "default"),
    energy: 100, // 字段新增默认值
    unlocked: [], // 字段新增默认值
  };
}

/** v2 → v3:类型收紧——整数化、越界钳制、数组去重升序、补 schemaRev。 */
export function migrateV2toV3(raw: RawSave): SaveV3 {
  const gold = Math.max(0, asInt(raw.gold, 0));
  const level = clampInt(asInt(raw.level, 1), 1, 999);
  const energy = clampInt(asInt(raw.energy, 0), 0, 100);
  const unlockedRaw = Array.isArray(raw.unlocked) ? raw.unlocked : [];
  const seen = new Set<number>();
  const unlocked: number[] = [];
  for (const item of unlockedRaw) {
    const n = asInt(item, -1);
    if (n < 0 || seen.has(n)) continue; // 过滤非法与重复
    seen.add(n);
    unlocked.push(n);
  }
  unlocked.sort((a, b) => a - b);
  return { version: 3, schemaRev: 3, gold, level, island: asString(raw.island, "default"), energy, unlocked };
}

/** 迁移入口:按 version 字段决定走哪条链,未知版本明确失败而非静默降级。 */
export function migrate(raw: RawSave): MigrateResult {
  const from = asInt(raw.version, 0);
  if (from > LATEST_VERSION) return { ok: false, reason: "future_version" };
  if (from < 1) return { ok: false, reason: "unversioned" };
  const steps: string[] = [];
  let cur: RawSave = raw;
  if (from === 1) {
    cur = migrateV1toV2(raw);
    steps.push("v1->v2");
  }
  cur = migrateV2toV3(cur) as unknown as RawSave;
  if (from <= 2) steps.push("v2->v3"); // v3 只做规范化,不产生迁移步骤
  return { ok: true, from, steps, save: migrateV2toV3(cur) };
}

/** 存档摘要:稳定字符串,全整数,用于跨语言比对与回归。 */
export function digest(save: SaveV3): string {
  return [save.gold, save.level, save.island, save.energy, save.unlocked.join(","), save.schemaRev].join("|");
}

设计上有三处刻意的取舍。第一,输入是 RawSave(不可信的原始 JSON),不是 SaveV3。 从磁盘或云端读出来的东西没有类型保证,所以每一步都先解析再规范化——asInt 接受整数、浮点和数字字符串,asString 只在确实是字符串时才用。把「不可信」写进类型,迁移函数就不可能假装输入是干净的。第二,每个版本只写一步。 migrateV1toV2 只管 v1→v2,migrateV2toV3 只管 v2→v3;将来加 v4,只需要写 migrateV3toV4 并在 migrate 里接一段,前面的代码一行不动。第三,未知版本明确失败。 版本号大于 LATEST_VERSION(玩家装了更新的版本又回滚了)返回 future_version,没有版本号返回 unversioned——都不静默降级。静默降级最危险:它会用旧代码去解释新格式,把不认识的字段悄悄丢掉。

P 层测试:旧存档升到最新且不丢数据

下面这段是 P 层测试,六个用例覆盖三类改动加两种边界。

// file: core/test/e08_migrate.test.ts
// tier: P
// verify: node --test 'core/test/e08_*.test.ts'
//
// 硬断言:旧存档能升到最新且不丢数据;迁移链幂等;未知版本明确失败。

import { test } from "node:test";
import assert from "node:assert/strict";

import { migrate, migrateV1toV2, migrateV2toV3, digest, LATEST_VERSION } from "../src/e08/migrate.ts";

test("v1 → v3:coins 重命名为 gold、level/island 保留、新字段取默认值", () => {
  const r = migrate({ version: 1, coins: 500, level: 7, island: "珊瑚礁" });
  assert.equal(r.ok, true);
  if (!r.ok) return;
  assert.deepEqual(r.steps, ["v1->v2", "v2->v3"]);
  assert.equal(r.save.version, LATEST_VERSION);
  assert.equal(r.save.gold, 500); // coins → gold,值不丢
  assert.equal(r.save.level, 7);
  assert.equal(r.save.island, "珊瑚礁");
  assert.equal(r.save.energy, 100); // 新增字段默认值
  assert.deepEqual(r.save.unlocked, []);
});

test("v2 → v3:类型收紧(取整、越界钳制、数组去重排序)", () => {
  const r = migrate({ version: 2, gold: 123.9, level: 3.9, island: "x", energy: 250, unlocked: [5, 5, -1, 2, 10] });
  assert.equal(r.ok, true);
  if (!r.ok) return;
  assert.deepEqual(r.steps, ["v2->v3"]);
  assert.equal(r.save.gold, 123); // 浮点取整
  assert.equal(r.save.level, 3);
  assert.equal(r.save.energy, 100); // 250 → 钳到上限 100
  assert.deepEqual(r.save.unlocked, [2, 5, 10]); // 去重 + 过滤负数 + 升序
});

test("类型收紧:数字字符串(历史遗留 UI 绑定)被解析为整数", () => {
  const r = migrate({ version: 2, gold: "800", level: "12", island: "a", energy: "40", unlocked: ["3"] });
  assert.equal(r.ok, true);
  if (!r.ok) return;
  assert.equal(r.save.gold, 800);
  assert.equal(r.save.level, 12);
  assert.deepEqual(r.save.unlocked, [3]);
});

test("残缺存档用默认值补齐;v3 幂等(规范化不改动已规范存档)", () => {
  const bare = migrate({ version: 1 });
  assert.equal(bare.ok, true);
  if (!bare.ok) return;
  assert.deepEqual([bare.save.gold, bare.save.level, bare.save.island], [0, 1, "default"]);

  const first = migrate({ version: 2, gold: 10, level: 2, island: "i", energy: 5, unlocked: [1, 2] });
  assert.equal(first.ok, true);
  if (!first.ok) return;
  const again = migrate(first.save as unknown as Record<string, never>);
  assert.equal(again.ok, true);
  if (!again.ok) return;
  assert.deepEqual(again.save, first.save);
  assert.deepEqual(again.steps, []);
});

test("未来版本与无版本:明确失败而非静默降级", () => {
  const future = migrate({ version: 9 });
  assert.equal(future.ok, false);
  if (!future.ok) assert.equal(future.reason, "future_version");
  const none = migrate({ gold: 1 });
  assert.equal(none.ok, false);
  if (!none.ok) assert.equal(none.reason, "unversioned");
});

test("不丢数据:整条链保留 gold / level / island,摘要与直接 v2→v3 一致", () => {
  const chained = migrate({ version: 1, coins: 8600, level: 18, island: "珊瑚礁" });
  assert.equal(chained.ok, true);
  if (!chained.ok) return;
  assert.equal(digest(chained.save), "8600|18|珊瑚礁|100||3");
  const direct = migrateV2toV3({ version: 2, gold: 8600, level: 18, island: "珊瑚礁", energy: 100, unlocked: [] });
  assert.equal(digest(direct), digest(chained.save));
  assert.deepEqual(migrateV1toV2({ version: 1, coins: 42, level: 5, island: "n" }),
    { version: 2, gold: 42, level: 5, island: "n", energy: 100, unlocked: [] });
});

在本机跑它,六个用例全过:

ℹ tests 6
ℹ pass 6
ℹ fail 0

「不丢数据」那条用例是这一节的靶心:它断言「v1 存档走完整条链」和「同样数据直接走 v2→v3」得到同一个摘要。如果哪一步迁移把 coins 读丢了、或者把 island 覆盖成默认值,这条断言就会失败。迁移的正确性不是「能跑通」,而是「跑完还能对上账」。

S 层:迁移耗时与体积对照

迁移本身很快,但它有一个工程上必须算的量:迁移前后存档会变大多少。每加一个字段、每补一次 schemaRev,存档都会变胖;玩家存档体积增长会直接影响 8.1 里那条容量淘汰线。下面这段是 S 层脚本的核心片段(完整脚本在 sim/e08_migrate.py),它对每个样本跑一遍等价迁移,算出前后字节数、步数、写入字段数与估算耗时,并断言三项不变量。

# snippet: 迁移入口与不变量断言(完整脚本见 sim/e08_migrate.py)
def migrate(raw):
    """按 version 走链;未知版本明确失败。返回 (ok, from, steps, save)。"""
    frm = as_int(raw.get("version"), 0)
    if frm > LATEST_VERSION or frm < 1:
        return False, frm, [], None
    steps, cur = [], raw
    if frm == 1:
        cur = migrate_v1_to_v2(raw)
        steps.append("v1->v2")
    v3 = migrate_v2_to_v3(cur)
    if frm <= 2:
        steps.append("v2->v3")
    return True, frm, steps, v3

# 每个样本:迁移 → 算字节 → 断言「不丢数据 + 幂等 + 摘要与 TS 金标准一致」
src_gold = as_int(raw.get("coins", raw.get("gold")), 0)
kept = (save["gold"] == max(0, src_gold)
        and save["level"] == clamp(as_int(raw.get("level"), 1), 1, 999)
        and save["island"] == as_str(raw.get("island"), "default"))
_, _, again_steps, again = migrate(save)
idem = digest(again) == digest(save) and len(again_steps) == 0
match = digest(save) == EXPECTED_DIGEST[s["id"]]

跑它是这样(同机相对量级,绝对数值不可移植):

{"allOk": true, "sampleCount": 5, "totalEstUs": 744, "totalBeforeBytes": 361, "totalAfterBytes": 495, "avgDeltaBytes": 26}

样本明细 out/e08_migrate_samples.csv 里,每行是一个存档的迁移账:

id,fromVersion,steps,fields,beforeBytes,afterBytes,deltaBytes,estUs,matchExpected
v1_min,1,2,13,54,94,40,184,True
v1_typical,1,2,13,58,98,40,184,True
v1_rich,1,2,13,63,103,40,184,True
v2_mid,2,1,7,91,105,14,96,True
v2_dirty,2,1,7,95,95,0,96,True

数字读出三件事。第一,v1 存档平均要胖 40 字节。 三个 v1 样本的 deltaBytes 都是 40——这不是巧合:v1→v2→v3 加了 energy、unlocked、schemaRev 三个字段,字段名与默认值的字节数是固定的,与 coins 的数值大小无关。第二,v2 存档几乎不胖。 v2_mid 只涨 14 字节(补了 schemaRev 与排序后的 unlocked),而 v2_dirty 的 deltaBytes 是 0——它本来就有那些字段,类型收紧只改值不改结构,所以字节数不变。第三,v2_dirty 是唯一被「收紧」的样本:它的 gold/level/energy 都是字符串、unlocked 有重复和负数,迁移后变成 150|12|x|100|2,5,10|3(见它的摘要列),去掉了重复的 5 与 10、丢掉了负数 -1、energy 从 140 钳到 100。这行数据正好演示了「类型收紧」在体积上可能是零成本,但在数据语义上做了大量修正。

需要说明的是,estUs(估算耗时)是一个整数成本模型,不是实测耗时:每步固定 40 微秒、每字段 8 微秒,v1 样本两步 13 字段得 184 微秒。它的用途不是「预测真实迁移要多久」(那取决于设备,属于 X 层),而是给不同迁移路径一个可比较的量级:v1→v3 的成本约为 v2→v3 的两倍,因为多走一步。

迁移不是一次性的,是读路径的常态

最后把这一节收回到工程习惯上。迁移链一旦建好,它就不该是一次性的脚本,而应该是读路径的常态:每次读存档,先 migrate 到最新,再交给游戏逻辑;写存档时永远写最新格式并带上 schemaRev。这样「加字段」就退化成一个纯客户端的、可单测的动作,而不是「发版本前手动跑一遍脚本」。

一个具体的纪律:每个版本号都要有一个真实的、能失败的测试。8.2 的六个用例里,「不丢数据」和「幂等」这两条是最该长期保留的——前者防止重命名把值读丢,后者防止迁移链被改成「每读一次就动一次数据」。下一节要处理的则是另一个方向的问题:存档被玩家改了怎么办。

阅读导航:上一节:本地存储与云存档抽象 · 下一节:客户端防作弊与数据校验 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「minigame」更多文章

  1. 附录 F:全书公式、参数与数据集索引
  2. 9.3 同意管理与数据出境
  3. 9.2 COPPA 与儿童向游戏的限制