站内已有一篇小游戏开发综述 ,那篇是平台能力综述,不会碰到「存档格式怎么升级」这种问题;本章给的是可跑、可断言的存储抽象层与迁移器。另有《游戏服务端编程实践》 那 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 的六个用例里,「不丢数据」和「幂等」这两条是最该长期保留的——前者防止重命名把值读丢,后者防止迁移链被改成「每读一次就动一次数据」。下一节要处理的则是另一个方向的问题:存档被玩家改了怎么办。
阅读导航:上一节:本地存储与云存档抽象 · 下一节:客户端防作弊与数据校验 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。