规格一旦落地就会被多个消费者读:Python 校验器、TS 引擎、可视化脚本。改一个字段,等于同时改这几方的契约。照例划界:《游戏设计》卷一:玩法机制与可执行规格 的版本管理关心数值与规则的兼容(改一个伤害公式会不会让旧存档算错),消费对象是一局战斗的状态轨迹;本节关心结构与条件的兼容(加一个字段会不会让旧读取器读不出故事),消费对象是故事在玩家选择下的展开路径。
2.3.1 什么样的改动算兼容
meta.spec_version 是一个整数,它是「这份文档按哪版契约解析」的唯一判据。读取器先看它,再决定用哪套字段表——而不是靠猜字段在不在。
| 变更类型 | 版本 | 规则 |
|---|---|---|
| 新增可选字段 + 默认值 | 次版本 +1 | 允许。v1 读取器忽略新键即得回 v1 视图 |
| 给已有字段补枚举值 | 次版本 +1 | 允许。旧值语义不变,新值只在 v2 出现 |
| 字段重命名 | 主版本 +1 | 不允许在同一版本内做;必须配迁移脚本 |
| 改字段语义(同名字段含义变化) | 主版本 +1 | 禁止静默改;旧文档必须显式迁移 |
| 删除字段 | 主版本 +1 | 删除前先停用两个次版本 |
这份规则的核心是向前兼容:v1 的读取器读一份 v2 文档时,只要忽略它不认识的键,得到的投影就应当与读 v1 原件一致。这条性质可以用一个整数来验证——把文档投影回 v1 视图,取 FNV-1a 32 位摘要,两个版本必须给出同一个数。
2.3.2 迁移脚本:读 v1、补默认值、产出 v2
迁移脚本做三件事:补齐 v2 新增键的默认值、把 spec_version 提到 2、断言旧字段语义不变。第三件最重要——它把「兼容」从口头承诺变成了断言。
$ cd $HOME/.inkwellbook/inkwell && /opt/homebrew/bin/python3 sim/na_02_migrate.py
#!/usr/bin/env python3
# file: sim/na_02_migrate.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/na_02_migrate.py
"""规格迁移 v1 → v2:读 v1、补默认值、产出 v2,并断言旧字段语义不变。
v2 相对 v1 只做「加字段 + 给默认值」,不做重命名、不改语义——
这是向前兼容的前提:v1 的读取器读 v2 文档时,忽略新键即得到与读 v1 相同的投影。
"""
from __future__ import annotations
import copy
import json
import os
import sys
V2_DEFAULTS = {"meta.locale": "zh-CN", "metrics.path_len_max": 200}
V2_LIST_DEFAULTS = {"dialogue.tags": [], "beats.pov": ""}
def load_spec() -> dict:
here = os.path.dirname(os.path.abspath(__file__))
with open(os.path.join(here, "../config/story_spec.json"), encoding="utf-8") as fh:
return json.load(fh)
def migrate_v1_to_v2(v1: dict) -> dict:
v2 = copy.deepcopy(v1)
v2["meta"]["spec_version"] = 2
v2["meta"]["locale"] = V2_DEFAULTS["meta.locale"]
for n in v2["dialogue"]:
n["tags"] = list(V2_LIST_DEFAULTS["dialogue.tags"])
for b in v2["beats"]:
b["pov"] = V2_LIST_DEFAULTS["beats.pov"]
v2["metrics"]["path_len_max"] = V2_DEFAULTS["metrics.path_len_max"]
return v2
def strip_v2_additions(v2: dict) -> dict:
"""把 v2 文档投影回 v1 视图:删掉 v2 新增键,spec_version 复位。"""
v1 = copy.deepcopy(v2)
v1["meta"].pop("locale", None)
v1["meta"]["spec_version"] = 1
for n in v1["dialogue"]:
n.pop("tags", None)
for b in v1["beats"]:
b.pop("pov", None)
v1["metrics"].pop("path_len_max", None)
return v1
def project_v1_view(doc: dict) -> dict:
"""v1 读取器的行为:只取 v1 认识的键,忽略其余。"""
segs = ("meta", "world", "characters", "beats", "scene_graph",
"dialogue", "conditions", "state", "endings", "metrics")
return {k: copy.deepcopy(doc[k]) for k in segs}
def v1_projection_digest(doc: dict) -> int:
"""对 v1 投影做 FNV-1a 32 位摘要(只对结构,不对文本)。"""
v1 = strip_v2_additions(doc)
s = json.dumps(project_v1_view(v1), ensure_ascii=False, sort_keys=True, separators=(",", ":"))
h = 2166136261
for ch in s:
h = (h ^ ord(ch)) & 0xFFFFFFFF
h = (h * 16777619) & 0xFFFFFFFF
return h
def main() -> int:
v1 = load_spec()
print(f"读入 v1:spec_version={v1['meta']['spec_version']} "
f"dialogue={len(v1['dialogue'])} beats={len(v1['beats'])}")
assert v1["meta"]["spec_version"] == 1, "输入必须是 v1 规格"
v2 = migrate_v1_to_v2(v1)
print("新增字段与默认值:")
for k, dv in sorted(V2_DEFAULTS.items()):
print(f" {k:<22} = {json.dumps(dv, ensure_ascii=False)}")
for k, dv in sorted(V2_LIST_DEFAULTS.items()):
print(f" {k:<22} = {json.dumps(dv, ensure_ascii=False)}(逐项补齐)")
print(f"迁移后 spec_version={v2['meta']['spec_version']}")
back = strip_v2_additions(v2)
same = back == v1
segs = [k for k in v1 if back.get(k) == v1[k]]
print(f"旧字段语义比对:{len(segs)}/{len(v1)} 段逐段相同:{same}")
assert same, "迁移改动了旧字段语义"
d1 = v1_projection_digest(v1)
d2 = v1_projection_digest(v2)
print(f"v1 投影摘要:v1={d1} v2={d2} 一致:{d1 == d2}")
assert d1 == d2, "v1 读取器读 v2 得到不同投影"
# v2 的新键确实生效
assert v2["meta"]["locale"] == "zh-CN"
assert all(n["tags"] == [] for n in v2["dialogue"])
assert all(b["pov"] == "" for b in v2["beats"])
assert v2["metrics"]["path_len_max"] == 200
print(f"v2 新键生效:dialogue[].tags={len(v2['dialogue'])} 项、beats[].pov={len(v2['beats'])} 项")
out = os.path.join(os.path.dirname(os.path.abspath(__file__)), "../out/spec_v2.json")
os.makedirs(os.path.dirname(out), exist_ok=True)
with open(out, "w", encoding="utf-8") as fh:
json.dump(v2, fh, ensure_ascii=False, indent=2)
fh.write("\n")
print(f"写出 out/spec_v2.json {os.path.getsize(out)} bytes")
print("OK na_02_migrate:v1 → v2 迁移完成,旧字段语义不变,v1 投影摘要一致")
return 0
if __name__ == "__main__":
sys.exit(main())
读入 v1:spec_version=1 dialogue=48 beats=16
新增字段与默认值:
meta.locale = "zh-CN"
metrics.path_len_max = 200
beats.pov = ""(逐项补齐)
dialogue.tags = [](逐项补齐)
迁移后 spec_version=2
旧字段语义比对:10/10 段逐段相同:True
v1 投影摘要:v1=811075797 v2=811075797 一致:True
v2 新键生效:dialogue[].tags=48 项、beats[].pov=16 项
写出 out/spec_v2.json 63312 bytes
OK na_02_migrate:v1 → v2 迁移完成,旧字段语义不变,v1 投影摘要一致
「10/10 段逐段相同」是深度相等的结果,「摘要一致」是投影层面的结果。前者说明迁移没有碰到旧键,后者说明即使新旧文档整体不同,v1 读取器拿到的视图也逐位一致。两条断言互为冗余:一条管字段、一条管读取器行为。
2.3.3 跨语言同摘要
摘要必须跨语言可比,否则「v1 读取器读 v2 与读 v1 一致」只是 Python 侧的自证。TS 侧用同一套序列化口径与同一个 FNV-1a 常数重算一遍。
$ cd $HOME/.inkwellbook/inkwell && node --test 'core/test/na_02_migrate.test.ts'
// file: core/test/na_02_migrate.test.ts
// tier: P
// verify: node --test 'core/test/na_02_migrate.test.ts'
// v1 → v2 迁移的 TS 侧:与 sim/na_02_migrate.py 同规则,产物逐项对齐。
// 「旧字段语义不变」用 v1 投影摘要表达:摘要是整数,可以逐位比对。
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync, existsSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import type { SpecDoc } from "./na_02_spec_types.test.ts";
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, "../..");
type Doc = Record<string, unknown>;
const V2_SCALAR_DEFAULTS: Record<string, unknown> = { "meta.locale": "zh-CN", "metrics.path_len_max": 200 };
const V2_ITEM_DEFAULTS: [string, string, unknown][] = [["dialogue", "tags", []], ["beats", "pov", ""]];
const readJson = (p: string): Doc => JSON.parse(readFileSync(p, "utf8")) as Doc;
const clone = (v: Doc): Doc => JSON.parse(JSON.stringify(v)) as Doc;
export function migrateV1toV2(v1: Doc): Doc {
const v2 = clone(v1);
(v2.meta as Doc).spec_version = 2;
(v2.meta as Doc).locale = V2_SCALAR_DEFAULTS["meta.locale"];
for (const [seg, key, dv] of V2_ITEM_DEFAULTS) {
for (const item of v2[seg] as Doc[]) item[key] = Array.isArray(dv) ? [...dv] : dv;
}
(v2.metrics as Doc).path_len_max = V2_SCALAR_DEFAULTS["metrics.path_len_max"];
return v2;
}
export function stripV2Additions(v2: Doc): Doc {
const v1 = clone(v2);
delete (v1.meta as Doc).locale;
(v1.meta as Doc).spec_version = 1;
for (const [seg, key] of V2_ITEM_DEFAULTS) for (const item of v1[seg] as Doc[]) delete item[key];
delete (v1.metrics as Doc).path_len_max;
return v1;
}
/** 键名排序 + 无空格分隔的稳定序列化:与 Python json.dumps(sort_keys=True, separators=(",",":")) 同口径。 */
export function stable(v: unknown): string {
if (v === null || typeof v !== "object") return JSON.stringify(v);
if (Array.isArray(v)) return `[${v.map(stable).join(",")}]`;
const o = v as Record<string, unknown>;
return `{${Object.keys(o).sort().map((k) => `${JSON.stringify(k)}:${stable(o[k])}`).join(",")}}`;
}
export function projectionDigest(doc: Doc): number {
const s = stable(stripV2Additions(doc));
let h = 2166136261;
for (let i = 0; i < s.length; i += 1) {
h = (h ^ s.charCodeAt(i)) >>> 0;
h = Math.imul(h, 16777619) >>> 0;
}
return h >>> 0;
}
const v1 = readJson(join(ROOT, "config/story_spec.json"));
test("迁移补默认值并把 spec_version 提到 2", () => {
assert.equal((v1.meta as Doc).spec_version, 1);
const v2 = migrateV1toV2(v1);
assert.equal((v2.meta as Doc).spec_version, 2);
assert.equal((v2.meta as Doc).locale, "zh-CN");
assert.equal((v2.metrics as Doc).path_len_max, 200);
assert.ok((v2.dialogue as Doc[]).every((d) => Array.isArray(d.tags) && (d.tags as unknown[]).length === 0));
assert.ok((v2.beats as Doc[]).every((b) => b.pov === ""));
});
test("旧字段语义不变:投影回 v1 后与原件深度相等", () => {
const v2 = migrateV1toV2(v1);
assert.deepEqual(stripV2Additions(v2), v1);
const segs = Object.keys(v1).filter((k) => stable(stripV2Additions(v2)[k]) === stable(v1[k]));
assert.equal(segs.length, Object.keys(v1).length);
});
test("v1 读取器读 v1 与读 v2 得到同一投影摘要", () => {
const v2 = migrateV1toV2(v1);
assert.equal(projectionDigest(v1), projectionDigest(v2));
assert.equal(projectionDigest(v1), 811075797);
console.log(`TS v1 投影摘要 = ${projectionDigest(v1)}`);
});
test("与 Python 产物 out/spec_v2.json 逐项对齐", () => {
const py = join(ROOT, "out/spec_v2.json");
assert.ok(existsSync(py), "先跑 /opt/homebrew/bin/python3 sim/na_02_migrate.py");
const pyDoc = readJson(py);
assert.deepEqual(pyDoc, migrateV1toV2(v1));
assert.equal(projectionDigest(pyDoc), projectionDigest(v1));
});
TS v1 投影摘要 = 811075797
✔ 迁移补默认值并把 spec_version 提到 2 (2.763459ms)
✔ 旧字段语义不变:投影回 v1 后与原件深度相等 (8.342ms)
✔ v1 读取器读 v1 与读 v2 得到同一投影摘要 (4.741625ms)
✔ 与 Python 产物 out/spec_v2.json 逐项对齐 (5.028208ms)
ℹ tests 4
ℹ suites 0
ℹ pass 4
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 373.688417
811075797 同时出现在 Python 与 TS 的输出里,这不是巧合,而是两侧约定同一套序列化口径的必然结果:键名排序、无空格分隔、非 ASCII 不转义。摘要只对结构计算、不对文本计算,所以改一句台词不会让它变化,改一个 goto 目标会。最后一个测试还拿 Python 落盘的 out/spec_v2.json 与 TS 自己迁移的结果做深度相等——两侧的迁移规则必须逐字段相同,否则这个断言会先失败。
阅读导航:上一节:2.2 模式校验与语义校验 · 下一节:3.1 派系、地点与规则 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。