2.1 play_spec.json 的字段

本章把《灰烬牌库》的玩法收敛成一份 play_spec.json:12 个必填字段位、8 个难度参数、4 张卡的卡池、3 条目标与 4 条不变量。本节逐字段给出类型、取值域与作用,附完整 JSON 与一个只读断言测试——卡池总数必须等于 12,params 必须全为整数,跑通七条断言全绿。

2.1 play_spec.json 的字段

《产品架构与经济系统》 用系统地图与功能架构回答「一个 SLG 由哪些模块拼成」,那是产品/系统口径;本节换另一条路:把一款游戏的玩法收敛成一份与品类无关的可执行规格。上一节 1.2 资源与目标 拆出了机制的构成要素,本节把这些要素落成字段——同一个 config/play_spec.json,卷三会实例化成卡牌构筑、塔防、三消、平台跳跃、自走棋、放置六种规则。

规格为什么必须是 JSON 而不是一段散文?因为一份规格要被四处消费,而四处读的是同一批字段:

消费方读规格的哪一部分产出
S 层(Python)params + cards + strategy轨迹、守恒账、策略熵、SVG 图
P 层(TypeScript)全部字段确定性内核 + 可断言的不变量
W 层(浏览器)全部字段(内联进单文件 HTML)可玩原型 + 无头断言
D 层(文档)字段表本身校验器、评审表、验收清单

四处消费同一份文本,前提是字段是有限的、有类型的、有取值域的。这就是本章要立起来的东西。

D 档:12 个字段位

play_spec.json 顶层 10 个键;其中 rng 是容器,展开成 3 个子字段后,必填字段位恰好 12 个:

#字段类型取值域作用
1spec_version整数≥ 1规格版本号;2.3 的迁移判据
2id字符串非空规格标识(工程名),小写无空格
3name字符串非空展示名(中文可)
4seed整数任意整数洗牌种子;确定性回放的唯一入口
5rng.algo字符串lehmer-minstdPRNG 算法名,跨语言同算法
6rng.mod整数2147483647模数(2³¹−1)
7rng.mul整数48271乘数
8params对象(8 个整数)见下表难度参数,整数地板除
9cards数组(4 张)非空卡池定义,count 之和 = 卡池总数
10goals对象(3 个字符串)非空主目标 / 次目标 / 限时条件
11invariants字符串数组(4 条)非空不变量名清单,S/P 层共用
12strategy字符串数组非空动作序列,p<i> 出牌 / e 结束回合

只读真源全文如下(本节其余部分、后续所有章节都读这一份,绝不另造):

{
  "spec_version": 1,
  "id": "ashdeck",
  "name": "灰烬牌库",
  "seed": 20261014,
  "rng": {
    "algo": "lehmer-minstd",
    "mod": 2147483647,
    "mul": 48271
  },
  "params": {
    "playerHp": 60,
    "enemyHp": 50,
    "enemyAttack": 6,
    "energyMax": 3,
    "handSize": 5,
    "turnLimit": 20,
    "rewardGold": 12,
    "punishHp": 4
  },
  "cards": [
    { "id": "strike", "cost": 1, "damage": 6, "block": 0, "count": 5, "rarity": "common" },
    { "id": "heavy", "cost": 2, "damage": 14, "block": 0, "count": 3, "rarity": "uncommon" },
    { "id": "jab", "cost": 0, "damage": 3, "block": 0, "count": 2, "rarity": "common" },
    { "id": "guard", "cost": 1, "damage": 0, "block": 5, "count": 2, "rarity": "common" }
  ],
  "goals": {
    "primary": "enemyHp<=0",
    "secondary": "playerHp>0",
    "limit": "turn<=turnLimit"
  },
  "invariants": [
    "cardCount==deckSize",
    "damageConserved",
    "energyInRange",
    "blockNonNegative"
  ],
  "strategy": ["p0", "p0", "p0", "p0", "e", "p0", "p0", "p0", "p0", "e", "p0", "p0", "p0", "p0", "e", "p0", "p0", "p0", "p0", "e", "p0", "p0", "p0", "p0", "e"]
}

三条设计纪律就写在这份 JSON 里。整数:params 的 8 个值、cards 的 cost/damage/block/count 全是整数,config/ 下禁浮点,跨语言才能逐位一致。自实现 PRNG:rng 段写明算法与两个常数,Python 与 TS 各实现一遍 Lehmer MINSTD,禁用语言自带随机数。一处定义:strategy 是默认策略序列,S 层、P 层、W 层都从它出发。

参数与卡池速查

params 的 8 个字段与它们在战斗里的角色:

参数值含义
playerHp60玩家初始血量
enemyHp50敌人初始血量(也是伤害守恒的分母)
enemyAttack6敌人每回合攻击力
energyMax3每回合能量上限
handSize5手牌上限(也是抽牌数)
turnLimit20回合上限,超过即判负
rewardGold12胜利奖励金币(本卷不参与结算)
punishHp4失败惩罚血量(本卷不参与结算)

卡池 4 张,count 之和 = 5 + 3 + 2 + 2 = 12,与字段位数同为 12 是巧合,但都必须在断言里钉死:

卡costdamageblockcountrarity
strike1605common
heavy21403uncommon
jab0302common
guard1052common

P 档:只读断言

规格是只读真源,因此第一个 P 层文件不写产物、只做断言:读 config/play_spec.json,证明 12 个字段位齐备、卡池总数等于 12、params 全为整数。

// file: core/test/gd_mech_02_fields.test.ts
// tier: P
// verify: node --test 'core/test/gd_mech_02_fields.test.ts'
//
// 2.1 的字段级断言:读只读真源 config/play_spec.json,断言结构完整——
// 12 个必填字段位齐备(9 顶层 + rng 展开的 3 子字段)、卡池总数 = 12、params 8 个全为整数。
// 只读不改:本文件不写任何产物,是 2.2 / 2.3 两个校验器的共同前提。

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 { buildDeck } from "../src/ashdeck/cards.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"),
);

// 12 个必填字段位:9 个顶层(rng 作为容器另算)+ rng 展开的 3 个子字段。
const TOP_FIELDS = ["spec_version", "id", "name", "seed", "params", "cards", "goals", "invariants", "strategy"];
const RNG_FIELDS = ["algo", "mod", "mul"];
const PARAM_FIELDS = ["playerHp", "enemyHp", "enemyAttack", "energyMax", "handSize", "turnLimit", "rewardGold", "punishHp"];
const CARD_FIELDS = ["id", "cost", "damage", "block", "count", "rarity"];

function at(obj: unknown, ...path: string[]): unknown {
  let cur: unknown = obj;
  for (const k of path) {
    if (cur === null || typeof cur !== "object" || !(k in (cur as Record<string, unknown>))) return undefined;
    cur = (cur as Record<string, unknown>)[k];
  }
  return cur;
}

test("12 个必填字段位齐备(9 顶层 + rng 展开 3)", () => {
  const paths = [...TOP_FIELDS.map((k) => [k]), ...RNG_FIELDS.map((k) => ["rng", k])];
  assert.equal(paths.length, 12);
  const missing = paths.filter((p) => at(SPEC, ...p) === undefined).map((p) => p.join("."));
  assert.deepEqual(missing, []);
});

test("卡池总数 = 12(5+3+2+2)", () => {
  assert.equal(buildDeck(SPEC.cards).length, 12);
  assert.equal(SPEC.cards.reduce((a, c) => a + c.count, 0), 12);
});

test("params 8 个字段全为整数", () => {
  assert.equal(PARAM_FIELDS.length, 8);
  for (const k of PARAM_FIELDS) {
    assert.equal(Number.isInteger(at(SPEC, "params", k)), true, `params.${k} 应为整数`);
  }
});

test("每张卡的 6 个字段齐备且类型正确", () => {
  for (const c of SPEC.cards) {
    for (const k of CARD_FIELDS) assert.ok(k in c, `card ${c.id} 缺 ${k}`);
    assert.equal(typeof c.id, "string");
    assert.equal(typeof c.rarity, "string");
    for (const k of ["cost", "damage", "block", "count"]) {
      assert.equal(Number.isInteger(at(c, k)), true, `card ${c.id}.${k} 应为整数`);
    }
  }
});

test("rng 常数与全站一致:lehmer-minstd / 2147483647 / 48271", () => {
  assert.equal(SPEC.rng.algo, "lehmer-minstd");
  assert.equal(SPEC.rng.mod, 2147483647);
  assert.equal(SPEC.rng.mul, 48271);
});

test("goals 三段与 invariants 四条都在,strategy 非空", () => {
  assert.equal(typeof SPEC.goals.primary, "string");
  assert.equal(typeof SPEC.goals.secondary, "string");
  assert.equal(typeof SPEC.goals.limit, "string");
  assert.equal(SPEC.invariants.length, 4);
  assert.ok(SPEC.strategy.length > 0);
});

test("负例:删字段后缺失检测必须能报出来", () => {
  const noParams = JSON.parse(JSON.stringify(SPEC));
  delete noParams.params;
  assert.deepEqual(TOP_FIELDS.filter((k) => at(noParams, k) === undefined), ["params"]);
  const noMul = JSON.parse(JSON.stringify(SPEC));
  delete noMul.rng.mul;
  assert.deepEqual(RNG_FIELDS.filter((k) => at(noMul, "rng", k) === undefined), ["mul"]);
});

在本机跑 node --test 'core/test/gd_mech_02_fields.test.ts',真实输出:

✔ 12 个必填字段位齐备(9 顶层 + rng 展开 3) (0.866208ms)
✔ 卡池总数 = 12(5+3+2+2) (0.09475ms)
✔ params 8 个字段全为整数 (0.074459ms)
✔ 每张卡的 6 个字段齐备且类型正确 (0.819583ms)
✔ rng 常数与全站一致:lehmer-minstd / 2147483647 / 48271 (0.088ms)
✔ goals 三段与 invariants 四条都在,strategy 非空 (0.061333ms)
✔ 负例:删字段后缺失检测必须能报出来 (0.207083ms)
ℹ tests 7
ℹ pass 7
ℹ fail 0

七条全绿。注意最后一条负例:它删掉 params 与 rng.mul 后,缺失检测必须分别报出 ["params"] 与 ["mul"]——一条只会说「合法」的断言没有价值,能说出「哪里不合法」的断言才是校验器(2.2 就把它长成完整的校验器)。

四个易踩的字段坑

  • seed 不是「随便一个数」:它决定洗牌结果,换一个种子就换掉整局轨迹;seed 与 rng 常数必须成组理解才有意义。
  • strategy 是动作序列,不是「意图」:p0 指手牌的第 0 位,而手牌每回合都在变;所以同一条 strategy 在 S 层与 W 层必须逐字符一致,否则两处消费的轨迹会分叉。
  • invariants 是名字清单,不是断言本身:这里只登记「要检查哪四条」,真正的判定函数在第四章的 P 层。
  • goals 用字符串写条件:enemyHp<=0 是给人读的约定,不是可执行代码;可执行的胜负判定在 combat.ts 里,两者必须语义一致。

自检表

检查项合格标准
字段位固定顶层 10 键 + rng 展开 3 子字段 = 12 个必填字段位
全整数params 8 个、cards 的 4 个数值字段全为整数,config/ 无浮点
卡池对账count 之和必须等于断言里写死的 12
有负例删字段后必须报出具体字段名,而不是笼统失败

字段定下之后,下一个问题是:谁来保证写进这份 JSON 的东西是合法的?下一节把「缺字段」「类型错」「索引越界」三类错误分给两级校验器,并用六个负例钉死它们的判据。

阅读导航:上一节:1.3 反馈:机制与玩家的接口 · 下一节:2.2 模式校验与语义校验 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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