2.3 规格的版本与迁移

规格会随设计长出新字段,旧规格必须还能被新校验器接受。本节设计 v1→v2 的迁移:v2 只新增 params.critRate(默认 0),迁移写成纯函数,幂等且不动既有字段。迁移前后 runSpec 的 digest 逐字相同,语义等价因此是可断言的事实,六条断言全绿。

2.3 规格的版本与迁移

《产品架构与经济系统》 把 SLG 的模块与依赖画成一张长期演进的系统地图;本节处理这份跨品类可执行规格自己的演进问题:字段加了,旧规格怎么办。上一节 2.2 模式校验与语义校验 立起的校验器是「当下的判据」,而规格是长生命周期资产——今天能跑的 play_spec.json,三个月后必须还能跑。

设计里加字段几乎不可避免:想给战斗加一个暴击千分比 critRate,它是 params 的第 9 个字段。若直接改字段表,所有存档、所有 W 层页面里的旧规格会立刻被判「缺字段」。正确做法是版本号 + 迁移函数:

项v1v2迁移动作
spec_version12抬到 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 版本并重算所有既有数值。

迁移不许做的三件事

迁移是最容易埋雷的一步,因为它同时改动「数据」与「语义」。三条禁令:

  1. 不许在迁移里重算既有数值。migrate 只补默认值、抬版本号;一旦它开始「顺手修正」enemyHp,语义等价立刻失守,而且错误藏在数据里不可见。
  2. 不许就地改传入对象。migrate(spec) 必须返回新对象,否则 S 层脚本读到的还是迁移前的引用,两处消费会分叉。
  3. 不许对未知版本静默放行。遇到 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 从规格到轨迹 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

  1. 附录 A 品类参数表、机制距离矩阵与数据集索引
  2. 9.3 自走棋的涌现
  3. 9.2 自走棋的经济与平衡