2.3 规格的版本与迁移
《产品架构与经济系统》
把 SLG 的模块与依赖画成一张长期演进的系统地图;本节处理这份跨品类可执行规格自己的演进问题:字段加了,旧规格怎么办。上一节 2.2 模式校验与语义校验
立起的校验器是「当下的判据」,而规格是长生命周期资产——今天能跑的 play_spec.json,三个月后必须还能跑。
设计里加字段几乎不可避免:想给战斗加一个暴击千分比 critRate,它是 params 的第 9 个字段。若直接改字段表,所有存档、所有 W 层页面里的旧规格会立刻被判「缺字段」。正确做法是版本号 + 迁移函数:
| 项 | v1 | v2 | 迁移动作 |
|---|---|---|---|
spec_version | 1 | 2 | 抬到 2 |
params.critRate | 不存在 | 必填整数,0–1000 | 补默认值 0 |
| 其余 12 个字段位 | 原样 | 原样 | 不动 |
三条纪律决定迁移能不能用:纯函数(不改传入对象、不读全局状态)、幂等(迁两次等于迁一次)、语义等价(迁移前后 runSpec 的 digest 必须逐字相同)。第三条最硬——它把「这次改动不影响玩法」从一句口头承诺,变成一个可断言的事实。
P 档:迁移函数与 v2 校验器
下面这段是 core/src/ashdeck/migrate.ts。migrate 把 v1 规格补上 critRate: 0 并抬版本号,validateSpecV2 复用 2.2 的 v1 判据再追加一条 critRate 域检查——v2 校验器因此能同时接受 v1 与 v2 的结构(多出的键不影响 v1 判据)。
// file: core/src/ashdeck/migrate.ts
// tier: P
// verify: node --test 'core/test/gd_mech_02_migrate.test.ts'
//
// 规格的版本与迁移。v1 → v2 只做一件事:新增 params.critRate(暴击千分比),默认 0。
// 迁移是**纯函数**:不动任何既有字段,故 runSpec 的 digest 迁移前后逐字相同——
// 「语义等价」在这里是一个可断言的事实,不是形容词。只允许可擦除 TS 语法。
import type { Spec } from "./types.ts";
import { validateSpec } from "./validate.ts";
export const SPEC_VERSION_V1 = 1;
export const SPEC_VERSION_V2 = 2;
export const CRIT_RATE_DEFAULT = 0;
export type ParamsV2 = Spec["params"] & { critRate: number };
export type SpecV2 = Omit<Spec, "params"> & { params: ParamsV2 };
/** v1 → v2:补 params.critRate(默认 0)并把 spec_version 抬到 2。已是 v2 则原样返回。 */
export function migrate(spec: Spec): SpecV2 {
const v = spec.spec_version;
if (v === SPEC_VERSION_V2) return spec as unknown as SpecV2;
if (v !== SPEC_VERSION_V1) throw new Error(`未知 spec_version: ${v}`);
const params: ParamsV2 = { ...spec.params, critRate: CRIT_RATE_DEFAULT };
return { ...spec, spec_version: SPEC_VERSION_V2, params };
}
/** v2 校验器:v1 的判据 + critRate 必填且落在 0–1000 千分比。 */
export function validateSpecV2(spec: SpecV2): string[] {
const errs = validateSpec(spec as unknown as Record<string, unknown>);
if (errs.length > 0) return errs;
const c = spec.params.critRate;
if (!Number.isInteger(c)) errs.push("v2: params.critRate 必须是整数");
else if (c < 0 || c > 1000) errs.push(`v2: params.critRate 必须在 0–1000(实为 ${c})`);
return errs;
}
配套测试 core/test/gd_mech_02_migrate.test.ts 钉住三条纪律,并把 v1 规格的基线 digest 写死在测试里:
// file: core/test/gd_mech_02_migrate.test.ts
// tier: P
// verify: node --test 'core/test/gd_mech_02_migrate.test.ts'
//
// 2.3 的迁移测试:v1 规格迁到 v2 后①能被 v2 校验器接受,②与「v1 被 v2 校验器拒绝」形成对照,
// ③**语义等价**——迁移前后 runSpec 的 digest 逐字相同(critRate 默认 0,不参与本局结算)。
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { runSpec } from "../src/ashdeck/combat.ts";
import { digestSnap } from "../src/ashdeck/digest.ts";
import { migrate, validateSpecV2, SPEC_VERSION_V2, CRIT_RATE_DEFAULT } from "../src/ashdeck/migrate.ts";
import type { Spec } from "../src/ashdeck/types.ts";
const HERE = dirname(fileURLToPath(import.meta.url));
const SPEC: Spec = JSON.parse(
readFileSync(join(HERE, "..", "..", "config", "play_spec.json"), "utf8"),
);
// 迁移前 v1 规格的基线 digest(与 gd_mech_base.test.ts 同一条)。
const V1_DIGEST =
'{"block":0,"cardCount":12,"damageConserved":true,"dealt":50,"enemyHp":0,' +
'"energy":1,"hand":["strike","jab","heavy"],"over":true,"played":9,' +
'"playerHp":48,"turn":4,"won":true}';
test("迁移:v1 → v2,spec_version=2 且 critRate 落到默认 0", () => {
const v2 = migrate(SPEC);
assert.equal(v2.spec_version, SPEC_VERSION_V2);
assert.equal(v2.params.critRate, CRIT_RATE_DEFAULT);
assert.equal(v2.params.critRate, 0);
});
test("迁移后能被 v2 校验器接受(0 错)", () => {
assert.deepEqual(validateSpecV2(migrate(SPEC)), []);
});
test("对照:未迁移的 v1 规格被 v2 校验器拒绝(缺 critRate)", () => {
const errs = validateSpecV2(SPEC as never);
assert.ok(errs.some((e) => e.includes("critRate")));
});
test("语义等价:迁移前后 runSpec 的 digest 逐字相同", () => {
const before = digestSnap(runSpec(SPEC));
const after = digestSnap(runSpec(migrate(SPEC) as unknown as Spec));
assert.equal(before, after);
assert.equal(before, V1_DIGEST);
});
test("迁移是幂等的:二次迁移与一次迁移逐字段相同", () => {
const once = migrate(SPEC);
const twice = migrate(once as unknown as Spec);
assert.deepEqual(twice, once);
});
test("负例:未知 spec_version(99)必须抛错,不许静默放过", () => {
const bad = { ...SPEC, spec_version: 99 } as Spec;
assert.throws(() => migrate(bad), /未知 spec_version: 99/);
});
本机跑 node --test 'core/test/gd_mech_02_migrate.test.ts',真实输出:
✔ 迁移:v1 → v2,spec_version=2 且 critRate 落到默认 0 (0.514709ms)
✔ 迁移后能被 v2 校验器接受(0 错) (0.677666ms)
✔ 对照:未迁移的 v1 规格被 v2 校验器拒绝(缺 critRate) (0.142167ms)
✔ 语义等价:迁移前后 runSpec 的 digest 逐字相同 (0.286958ms)
✔ 迁移是幂等的:二次迁移与一次迁移逐字段相同 (0.064334ms)
✔ 负例:未知 spec_version(99)必须抛错,不许静默放过 (0.224125ms)
ℹ tests 6
ℹ pass 6
ℹ fail 0
语义等价的证据
单独跑一次两遍回放,digest 是完全相同的一行(整数类,跨语言也可逐位比对):
v1 digest {"block":0,"cardCount":12,"damageConserved":true,"dealt":50,"enemyHp":0,"energy":1,"hand":["strike","jab","heavy"],"over":true,"played":9,"playerHp":48,"turn":4,"won":true}
v2 digest {"block":0,"cardCount":12,"damageConserved":true,"dealt":50,"enemyHp":0,"energy":1,"hand":["strike","jab","heavy"],"over":true,"played":9,"playerHp":48,"turn":4,"won":true}
v2 params {"playerHp":60,"enemyHp":50,"enemyAttack":6,"energyMax":3,"handSize":5,"turnLimit":20,"rewardGold":12,"punishHp":4,"critRate":0}
spec_version 2
critRate 确实进了 params(第 9 个键,值 0),但两遍 digest 一模一样——因为默认值 0 在本局的结算里不触发任何暴击,runSpec 的输出与 v1 完全一致。这就是「语义等价」的判定方式:不是看字段少了没有,而是看可观测输出变了没有。
迁移产物长这样(v2 的 params,critRate 作为第 9 个键落在末尾):
{
"playerHp": 60,
"enemyHp": 50,
"enemyAttack": 6,
"energyMax": 3,
"handSize": 5,
"turnLimit": 20,
"rewardGold": 12,
"punishHp": 4,
"critRate": 0
}
键顺序也是契约的一部分:migrate 用 { ...spec.params, critRate } 追加,既有 8 个键顺序不变、新键在末尾。digest 按字母序输出不受影响,但人读的规格文件保持插入序更利于逐行 diff——一次迁移在 git diff 里应该只多出一行。
六条断言钉住的纪律
| 断言 | 钉住的纪律 |
|---|---|
迁移后 spec_version=2 且 critRate=0 | 版本号与新字段都真的写上了 |
| v2 校验器接受迁移结果(0 错) | 迁移产物合法,不是「能读但错」 |
| v1 被 v2 校验器拒绝 | 对照:迁移确有必要,不是装饰 |
| 前后 digest 逐字相同 | 语义等价(整数类,可逐位比对) |
| 二次迁移 = 一次迁移 | 幂等 |
| 未知版本抛错 | 不许静默放行 |
第二行与第三行是一对:只有「未迁移的 v1 会被 v2 校验器拒绝」,才证明迁移真的做了事;只有「迁移后 0 错」,才证明它做对了。缺任何一半,迁移都可能是一个自我感觉良好的空操作。
默认值为什么必须是 0
迁移的等价性完全由默认值决定。同一处改动,换一个默认值就立刻不等价:
| 默认值 | v2 参数变化 | 本局是否受影响 | 迁移是否等价 |
|---|---|---|---|
critRate = 0 | 加一个不触发的参数 | 否(0‰ 暴击率 = 永不暴击) | 是 |
critRate = 50 | 每张牌 50‰ 概率触发暴击 | 是(伤害变大) | 否 |
critRate = 1000 | 必定暴击 | 是(伤害翻倍) | 否 |
判据一句话:新字段的默认值必须落在「本局不触发」的那一档。这不是数学定理,而是设计决定——它要求新字段有明确的「不生效」取值。若某个新字段天生没有中性值(例如「本回合必须选一个阶段」这种枚举),那就不能靠默认值迁移,必须抬 major 版本并重算所有既有数值。
迁移不许做的三件事
迁移是最容易埋雷的一步,因为它同时改动「数据」与「语义」。三条禁令:
- 不许在迁移里重算既有数值。
migrate只补默认值、抬版本号;一旦它开始「顺手修正」enemyHp,语义等价立刻失守,而且错误藏在数据里不可见。 - 不许就地改传入对象。
migrate(spec)必须返回新对象,否则 S 层脚本读到的还是迁移前的引用,两处消费会分叉。 - 不许对未知版本静默放行。遇到
spec_version = 99必须抛错——静默放行会让一份来自未来版本的规格被当成 v1 处理,而它的字段含义可能早已变了。
跨语言上还有一条省事的做法:迁移只在 TS 侧实现一次,Python 侧的 S 层脚本读的是迁移后落盘的新 JSON,不重复实现迁移逻辑。迁移的产物就是新的真源,重复实现只会引入第二份可能分叉的判据。
版本策略
什么时候该抬版本号、抬哪一位,判据要提前写死,否则迁移会退化成随手的补丁:
| 变更类型 | 例子 | 动作 |
|---|---|---|
| 新增可选字段(有默认值) | params.critRate 默认 0 | 抬 minor:spec_version +1,迁移补默认值 |
| 新增必填字段(无默认值) | 新的 phases 数组 | 需设计方给出默认语义,否则不许加 |
| 改字段含义或取值域 | damage 从「点」改「千分比」 | 抬 major:必须重算所有既有数值 |
| 删字段 | 删掉 rewardGold | 迁移留空键 + 记录,禁止静默删 |
本节的 v1→v2 是新增可选字段:默认值选 0 而不是「无」,因为 0 在本局结算里等价于「不触发」,才能保住语义等价。若默认值选成 50,迁移后 digest 立刻变化,迁移就不是等价的了——这条约束会在第十三章 13.1 机制增量与兼容
里放大成「机制层演化」的通用规则。
自检表
| 检查项 | 合格标准 |
|---|---|
| 纯函数 | migrate 不改传入对象,返回新对象 |
| 幂等 | migrate(migrate(s)) 与 migrate(s) 逐字段相同 |
| 语义等价 | 迁移前后 runSpec 的 digest 逐字相同(整数类) |
| 默认值有理由 | 默认值必须让本局结算不变,否则迁移不等价 |
| 未知版本报错 | 遇到没见过的 spec_version 必须抛错,不许静默放过 |
规格的定义、校验、迁移都齐了。下一章起,这份规格开始被真正消费:先从 S 层出发,把静态的字段跑成一条可读的轨迹。
阅读导航:上一节:2.2 模式校验与语义校验 · 下一节:3.1 从规格到轨迹 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。