2.3 规格的版本与迁移

meta.spec_version 是迁移的判据。本节给出字段增删的兼容性规则,并用一个迁移脚本演示 v1 到 v2 的向前兼容:读 v1 规格、补默认值、产出 v2,再用 v1 投影摘要断言旧字段语义不变,且 Python 与 TS 两侧摘要一致。

规格一旦落地就会被多个消费者读: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 派系、地点与规则 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

  1. 15.3 规模告警
  2. 15.2 求值与遍历的开销
  3. 15.1 规格规模的增长