13.2 六个品类变体的实例化

把六份规格各自真跑一遍——卡牌构筑(本体)/ 塔防 / 三消 / 平台跳跃 / 自走棋 / 放置——并列成一张表:规格 id、规则段条目数、状态段字段数、参数字段数、不变量条数、确定性 digest。表同时证明结构同构(快照都投影进同一套 12 个契约槽位)与内容不同(四组计数与 digest 两两不同)。

13.2 六个品类变体的实例化

站内 《小游戏开发实战》3.1 品类分类体系 把八个品类摊成一张分类表,读的是产品特征——变现模式、单局时长、内容量级。本节读的是同一批品类的另一面:13.1 立了六段 schema,这一节把它真跑一遍——六份规格各喂给各自的确定性内核,把结果并列成一张表。上一节是「同一份 schema」,这一节要拿运行结果证明它。

六个变体各自跑一遍

变体规格 id规则条目状态字段参数字段不变量digest 里的 dealt / turn / won
卡牌构筑(本体)ashdeck2728450 / 4 / true
塔防gd_proto_06_td26385772 / 6 / true
三消gd_proto_07_m311225166 / 24 / false
平台跳跃gd_proto_08_platformer1522430000 / 120 / true
自走棋gd_proto_09_ab52455149 / 12 / true
放置gd_proto_10_idle22346324619 / 800 / false

最后一列只摘了 digest 里最能看出量级的三个字段(完整 digest 在下面 P 层的实跑输出里逐字给出)。四组计数一眼看去就参差不齐:规则条目最多 52(自走棋,因为它的单位与羁绊都算规则)最少 11(三消,只有匹配与掉落两条),参数字段最多 8(本体与塔防)最少 2(三消与平台跳跃)。这正是「同一份 schema,六组参数」该有的样子。

表要证明的两件事

结构同构:六个快照的键集必须逐个相同,且等于 13.1 声明的那 12 个契约槽位。注意这里的六个品类在真实游戏里毫无关系——塔防没有「手牌」、三消没有「回合」——但它们都必须把状态投影进同一套槽位:

契约槽位在本体里在塔防里在三消里
turn回合数波次进度已用步数
energy剩余能量击杀获得的金币本局得分
block本回合护甲基地剩余血量连锁长度
hand手牌场上的塔当前棋盘片段

「投影」这个词是认真的:塔防的 energy 装的是金币,语义完全不同,但类型与位置相同。这就是 naming 段存在的理由——槽位是契约,名字是品类自己的话。

内容不同:四组计数与六个 digest 都必须两两不同。如果两个品类跑出同一个 digest,说明它们的规则段其实没区别,那这六份规格里就有一份是白写的。

S 层:结构的那一半

Python 侧只实现了本体内核,五个品类内核都写在 TypeScript 侧,所以 S 层只能负责结构那一半——规格 id、四组计数、内容指纹:

# snippet: 从注册表把六个变体并列成一张表(只读,写 out/gd_proto_13_variant_table.csv)
/opt/homebrew/bin/python3 sim/gd_proto_13_2_variant_table.py
genre            specId              rules state params inv  naming  hash
deckbuilder      ashdeck               27     2      8    4      8  2090613211
tower-defense    gd_proto_06_td        26     3      8    5      8  3936912495
match3           gd_proto_07_m3        11     2      2    5      8  3384481313
platformer       gd_proto_08_platformer    15     2      2    4      8  3729999018
autobattler      gd_proto_09_ab        52     4      5    5      8  1902561097
idle             gd_proto_10_idle      22     3      4    6      8  3660024852
skeletonIsomorphic=True namingSlots=8
contractSlotsDeclared=block|cardCount|damageConserved|dealt|enemyHp|energy|hand|over|played|playerHp|turn|won (由 P 层 digest 键集核对)
structuralTuplesDistinct=True (6/6)
csv=6 rows

最后一行 structuralTuplesDistinct=True (6/6) 是把(规则条目 / 状态字段 / 参数字段 / 不变量 / 内容指纹)当成一个五元组去看——六个五元组两两不同。运行结果那一半(digest)S 层给不了,只能由 P 层给,所以下一节才是本节的正戏。

P 层:运行结果那一半

// file: core/test/gd_proto_13_2_variant_table.test.ts
// tier: P
// verify: node --test 'core/test/gd_proto_13_2_variant_table.test.ts'
//
// 卷三 13.2 六个品类变体的实例化(P 层):把 6 个变体**真跑一遍**——各自读自己那份规格、
// 各自跑自己的确定性内核——然后并列成一张表:规格 id / 规则段条数 / 状态段字段数 /
// 参数段字段数 / 不变量条数 / 确定性 digest。表要证明两件事:结构同构(六段、契约 12 槽位
// 逐个相同)、内容不同(计数两两不同、digest 两两不同)。
//
// 六个内核都是既有文件,本测试只读不改。全整数,无浮点、无 Math.random。

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, invariantsHold } from "../src/ashdeck/combat.ts";
import type { Spec } from "../src/ashdeck/types.ts";
import { digestSnap } from "../src/ashdeck/digest.ts";
import { tdRun, tdInvariantsHold, tdDigest } from "../genre/towerdefense.ts";
import type { TdSpec } from "../genre/towerdefense.ts";
import { m3Run, m3InvariantsHold, m3Digest } from "../genre/match3.ts";
import type { M3Spec } from "../genre/match3.ts";
import { pfRun, pfInvariantsHold, pfDigest } from "../genre/platformer.ts";
import type { PfSpec } from "../genre/platformer.ts";
import { abRun, abInvariantsHold, abDigest } from "../genre/autobattler.ts";
import type { AbSpec } from "../genre/autobattler.ts";
import { idleRun, idleInvariantsHold, idleDigest } from "../genre/idle.ts";
import type { IdleSpec } from "../genre/idle.ts";

const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, "..", "..");
const load = (rel: string): any => JSON.parse(readFileSync(join(ROOT, rel), "utf8"));

const REG: any = load("config/gd_proto_13_variants.json");
const SEGMENTS: string[] = REG.segments;
const NAMING_SLOTS: string[] = REG.naming_slots;
const CONTRACT: string[] = REG.contract_slots;
const clone = <T>(o: T): T => JSON.parse(JSON.stringify(o)) as T;

/** 与 13.1 同一口径的六段投影(本测试自包含,不跨文件 import 测试模块)。 */
function resolve(spec: any, view: any): Record<string, any> {
  const out: Record<string, any> = {};
  for (const seg of SEGMENTS) {
    const r = view[seg];
    if (r.kind === "path") out[seg] = spec[r.path];
    else if (r.kind === "identity") out[seg] = Object.fromEntries(NAMING_SLOTS.map((s) => [s, s]));
    else if (r.kind === "derived") out[seg] = Object.fromEntries(r.fields.map((f: string) => [f, spec[r.from][f]]));
    else if (r.kind === "composite") out[seg] = Object.fromEntries(r.from.map((k: string) => [k, spec[k]]));
    else throw new Error("unknown view kind: " + r.kind);
  }
  return out;
}

function leafCount(x: unknown): number {
  if (Array.isArray(x)) return x.reduce((a: number, v) => a + leafCount(v), 0);
  if (x !== null && typeof x === "object") {
    return Object.values(x as Record<string, unknown>).reduce((a: number, v) => a + leafCount(v), 0);
  }
  return 1;
}

/** 每个变体一条「实例化」入口:读自己的规格、跑自己的内核、投影进共享契约。 */
const RUNNERS: Record<string, { spec: string; run: (sp: any) => any; inv: (sp: any, s: any) => boolean; dig: (s: any) => string }> = {
  deckbuilder: { spec: "config/play_spec.json", run: (sp: Spec) => runSpec(sp), inv: invariantsHold, dig: digestSnap },
  "tower-defense": { spec: "config/gd_proto_06_td.json", run: (sp: TdSpec) => tdRun(sp), inv: tdInvariantsHold, dig: tdDigest },
  match3: { spec: "config/gd_proto_07_m3.json", run: (sp: M3Spec) => m3Run(sp), inv: m3InvariantsHold, dig: m3Digest },
  platformer: { spec: "config/gd_proto_08_platformer.json", run: (sp: PfSpec) => pfRun(sp), inv: pfInvariantsHold, dig: pfDigest },
  autobattler: { spec: "config/gd_proto_09_ab.json", run: (sp: AbSpec) => abRun(sp), inv: abInvariantsHold, dig: abDigest },
  idle: { spec: "config/gd_proto_10_idle.json", run: (sp: IdleSpec) => idleRun(sp), inv: idleInvariantsHold, dig: idleDigest },
};

const ROWS = REG.variants.map((v: any) => {
  const raw = load(v.spec);
  const view = resolve(raw, v.view);
  const r = RUNNERS[v.genre];
  const snap = r.run(clone(raw));
  // 本体把不变量写在 invariants(复数)里,品类变体写在 invariant(单数)里——投影只换名不增减。
  const invSource = raw[v.view.invariant.path as string] as string[];
  return {
    genre: v.genre, label: v.label, specId: view.id, rulesItems: leafCount(view.rules),
    stateFields: Object.keys(view.state).length, paramsFields: Object.keys(view.params).length,
    invariantCount: view.invariant.length, specInvariants: invSource.length,
    invOk: r.inv(clone(raw), snap), digest: r.dig(snap), snap,
  };
});

test("结构同构:6 个变体的快照都投影进同一套 12 个契约槽位", () => {
  assert.equal(CONTRACT.length, 12);
  for (const row of ROWS) {
    const keys = Object.keys(JSON.parse(row.digest)).sort();
    assert.deepEqual(keys, CONTRACT.slice().sort(), row.genre + " 的契约槽位");
    assert.equal(Object.keys(row.snap).length > 0, true);
  }
});

test("内容不同:四组计数与 digest 都两两不同", () => {
  assert.deepEqual(ROWS.map((r) => r.rulesItems), [27, 26, 11, 15, 52, 22]);
  assert.deepEqual(ROWS.map((r) => r.stateFields), [2, 3, 2, 2, 4, 3]);
  assert.deepEqual(ROWS.map((r) => r.paramsFields), [8, 8, 2, 2, 5, 4]);
  assert.deepEqual(ROWS.map((r) => r.invariantCount), [4, 5, 5, 4, 5, 6]);
  assert.equal(new Set(ROWS.map((r) => r.digest)).size, 6);
  assert.equal(new Set(ROWS.map((r) => r.specId)).size, 6);
});

test("规格里声明的 invariant 条数 == 六段视图里的 invariant 条数(投影不增不减)", () => {
  for (const row of ROWS) assert.equal(row.invariantCount, row.specInvariants, row.genre);
});

test("6 个变体各自的不变量在本机实跑下全部成立", () => {
  for (const row of ROWS) assert.equal(row.invOk, true, row.genre + " 的不变量");
});

test("实例化表(本机实跑输出)", () => {
  console.log("  genre            specId                  rules  state  params  inv  digest");
  for (const r of ROWS) {
    console.log(`  ${r.genre.padEnd(16)} ${r.specId.padEnd(22)} ${String(r.rulesItems).padStart(5)} ${String(r.stateFields).padStart(6)} ${String(r.paramsFields).padStart(7)} ${String(r.invariantCount).padStart(4)}  ${r.digest}`);
  }
  assert.equal(ROWS.length, 6);
});
  genre            specId                  rules  state  params  inv  digest
  deckbuilder      ashdeck                   27      2       8    4  {"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}
  tower-defense    gd_proto_06_td            26      3       8    5  {"block":4,"cardCount":4,"damageConserved":true,"dealt":772,"enemyHp":0,"energy":70,"hand":["0:cannon:1","1:arrow:2","2:arrow:1","3:cannon:1"],"over":true,"played":124,"playerHp":20,"turn":6,"won":true}
  match3           gd_proto_07_m3            11      2       2    5  {"block":6,"cardCount":49,"damageConserved":true,"dealt":166,"enemyHp":1011,"energy":0,"hand":["1","1","2","1","1","3","1","1","1","2","1","1","1","2","2","1","1","1","4","2","1","6","1","3"],"over":true,"played":24,"playerHp":1989,"turn":24,"won":false}
  platformer       gd_proto_08_platformer    15      2       2    4  {"block":4750,"cardCount":3,"damageConserved":true,"dealt":30000,"enemyHp":0,"energy":250,"hand":["P1","P2"],"over":true,"played":2,"playerHp":0,"turn":120,"won":true}
  autobattler      gd_proto_09_ab            52      4       5    5  {"block":3,"cardCount":48,"damageConserved":true,"dealt":149,"enemyHp":147,"energy":47,"hand":["arcane_adept:2","ward_sentinel:2","blade_grunt:2","blade_champion:2","blade_champion:1","blade_champion:1"],"over":true,"played":19,"playerHp":28,"turn":12,"won":true}
  idle             gd_proto_10_idle          22      3       4    6  {"block":3,"cardCount":7,"damageConserved":true,"dealt":324619,"enemyHp":4,"energy":64807,"hand":["m45","m75","m115"],"over":true,"played":279,"playerHp":3,"turn":800,"won":false}
✔ 结构同构:6 个变体的快照都投影进同一套 12 个契约槽位 (0.957167ms)
✔ 内容不同:四组计数与 digest 都两两不同 (0.150125ms)
✔ 规格里声明的 invariant 条数 == 六段视图里的 invariant 条数(投影不增不减) (0.072459ms)
✔ 6 个变体各自的不变量在本机实跑下全部成立 (0.070708ms)
✔ 实例化表(本机实跑输出) (0.482833ms)
ℹ tests 5
ℹ suites 0
ℹ pass 5
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 144.206666

六行 digest 全在同一套槽位上,值却差得很远:本体的 dealt 是 50,平台跳跃是 30000(一局 120 帧的累计位移),放置是 324619(800 tick 的累计产出)。同一套槽位,六个数量级——这就是「投影」而不是「相等」的意思。

顺带记一个真实的坑:本体把不变量写在 invariants(复数)里,品类变体写在 invariant(单数)里。第一版测试直接读 raw.invariant.length,在本体上抛 TypeError: Cannot read properties of undefined。修法是不要绕过 13.1 的 view 声明——按 v.view.invariant.path 去取,投影层保证「只换名不增减」,于是这条断言本身也成了对投影的检验。

W 层:六个变体在同一个页面里

W 层把六个内核装进 out/proto13.html(构建细节见 13.1),页面是一个品类选择器:点一个品类就跑对应内核,把该变体的四组结构计数画进同一张图。构建期顺手在 Node 侧把六个变体各跑一遍,把 digest 冻进 out/gd_proto_13_build.json:

NODE_DIGEST[deckbuilder]={"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}
NODE_DIGEST[tower-defense]={"block":4,"cardCount":4,"damageConserved":true,"dealt":772,"enemyHp":0,"energy":70,"hand":["0:cannon:1","1:arrow:2","2:arrow:1","3:cannon:1"],"over":true,"played":124,"playerHp":20,"turn":6,"won":true}
NODE_DIGEST[match3]={"block":6,"cardCount":49,"damageConserved":true,"dealt":166,"enemyHp":1011,"energy":0,"hand":["1","1","2","1","1","3","1","1","1","2","1","1","1","2","2","1","1","1","4","2","1","6","1","3"],"over":true,"played":24,"playerHp":1989,"turn":24,"won":false}
NODE_DIGEST[platformer]={"block":4750,"cardCount":3,"damageConserved":true,"dealt":30000,"enemyHp":0,"energy":250,"hand":["P1","P2"],"over":true,"played":2,"playerHp":0,"turn":120,"won":true}
NODE_DIGEST[autobattler]={"block":3,"cardCount":48,"damageConserved":true,"dealt":149,"enemyHp":147,"energy":47,"hand":["arcane_adept:2","ward_sentinel:2","blade_grunt:2","blade_champion:2","blade_champion:1","blade_champion:1"],"over":true,"played":19,"playerHp":28,"turn":12,"won":true}
NODE_DIGEST[idle]={"block":3,"cardCount":7,"damageConserved":true,"dealt":324619,"enemyHp":4,"energy":64807,"hand":["m45","m75","m115"],"over":true,"played":279,"playerHp":3,"turn":800,"won":false}

这六条与上表 P 层的 digest 逐字相同——同一份规格、同一个内核、两条不同的运行路径(Node 测试 / 浏览器页面)给出同一个结果。这就是「确定性」在工程上唯一有意义的检验方式。

实跑清单

层命令结果
S/opt/homebrew/bin/python3 sim/gd_proto_13_2_variant_table.py六行结构表;五元组 6/6 两两不同
Pnode --test 'core/test/gd_proto_13_2_variant_table.test.ts'5 测试全绿;六个 digest 两两不同
Wnode web/build_proto13.mjs六个 NODE_DIGEST 与 P 层逐字一致

小结

13.2 把 13.1 的 schema 兑现成了运行结果:六个变体各自跑一遍,六行 digest 全部落在同一套 12 个契约槽位上(结构同构),值却相差六个数量级(内容不同)。到这里「同一份规格」还是只被证明了一次——六份规格各跑一遍而已。下一节要问一个更狠的问题:这六个变体,各自真的「能玩」吗? 一套判据能不能同时判它们。

阅读导航:上一节:13.1 规格的品类参数化 · 下一节:13.3 变体的可玩性闸门 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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