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 个:
| # | 字段 | 类型 | 取值域 | 作用 |
|---|---|---|---|---|
| 1 | spec_version | 整数 | ≥ 1 | 规格版本号;2.3 的迁移判据 |
| 2 | id | 字符串 | 非空 | 规格标识(工程名),小写无空格 |
| 3 | name | 字符串 | 非空 | 展示名(中文可) |
| 4 | seed | 整数 | 任意整数 | 洗牌种子;确定性回放的唯一入口 |
| 5 | rng.algo | 字符串 | lehmer-minstd | PRNG 算法名,跨语言同算法 |
| 6 | rng.mod | 整数 | 2147483647 | 模数(2³¹−1) |
| 7 | rng.mul | 整数 | 48271 | 乘数 |
| 8 | params | 对象(8 个整数) | 见下表 | 难度参数,整数地板除 |
| 9 | cards | 数组(4 张) | 非空 | 卡池定义,count 之和 = 卡池总数 |
| 10 | goals | 对象(3 个字符串) | 非空 | 主目标 / 次目标 / 限时条件 |
| 11 | invariants | 字符串数组(4 条) | 非空 | 不变量名清单,S/P 层共用 |
| 12 | strategy | 字符串数组 | 非空 | 动作序列,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 个字段与它们在战斗里的角色:
| 参数 | 值 | 含义 |
|---|---|---|
playerHp | 60 | 玩家初始血量 |
enemyHp | 50 | 敌人初始血量(也是伤害守恒的分母) |
enemyAttack | 6 | 敌人每回合攻击力 |
energyMax | 3 | 每回合能量上限 |
handSize | 5 | 手牌上限(也是抽牌数) |
turnLimit | 20 | 回合上限,超过即判负 |
rewardGold | 12 | 胜利奖励金币(本卷不参与结算) |
punishHp | 4 | 失败惩罚血量(本卷不参与结算) |
卡池 4 张,count 之和 = 5 + 3 + 2 + 2 = 12,与字段位数同为 12 是巧合,但都必须在断言里钉死:
| 卡 | cost | damage | block | count | rarity |
|---|---|---|---|---|---|
strike | 1 | 6 | 0 | 5 | common |
heavy | 2 | 14 | 0 | 3 | uncommon |
jab | 0 | 3 | 0 | 2 | common |
guard | 1 | 0 | 5 | 2 | common |
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 模式校验与语义校验 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。