2.2 模式校验与语义校验
《产品架构与经济系统》
用系统地图回答「模块怎么分、谁依赖谁」,那是架构口径;本节回答一个更窄也更硬的问题:写进 config/play_spec.json 的东西合不合法,谁能给出一条具体理由。上一节 2.1 play_spec.json 的字段
把 12 个字段位钉死了,本节给它们配上判据。
「不合法」其实是两类完全不同的问题,修法也不同:
| 级别 | 查什么 | 典型错误 | 谁该修 |
|---|---|---|---|
| 模式校验 | 必填字段存在、类型正确、数值在域内 | seed 写成字符串、cards 是空数组 | 改字段本身 |
| 语义校验 | 引用完整性、跨字段约束 | strategy 引用了不存在的手牌位 | 改字段之间的关系 |
把两级混在一起写,错误信息就会退化成「规格有错」这种没法行动的话。分开写之后,报错能直接说清是 schema: params.energyMax 必须是整数 还是 semantics: strategy[1] 索引越界 p9(handSize=5)。
D 档:判据清单
模式校验的域约束(类型一栏 int 指 JS number 且 Number.isInteger 为真):
| 字段 | 类型 | 域 |
|---|---|---|
spec_version | int | ≥ 1 |
id / name | string | 非空 |
seed | int | 任意整数 |
rng | object | 含 algo(string) / mod(int) / mul(int) |
params | object | 8 个键齐备且全为 int |
cards | array | 非空;每张含 id/rarity(string) 与 4 个 int |
goals | object | primary / secondary / limit 三个非空 string |
invariants | array | 非空且元素全为 string |
strategy | array | 非空且元素全为 string |
语义校验的四条引用完整性判据:strategy 里每个 p<i> 的 i 必须落在 [0, handSize);卡池 count 之和必须大于 0;turnLimit 必须大于 0;energyMax 必须大于等于 0。另外补两条跨字段约束:handSize > 0,且 handSize 不得超过卡池总数。
P 档:校验器
下面这段是 P 层校验器 core/src/ashdeck/validate.ts。它导出 validateSchema / validateSemantics / validateSpec 三个函数,返回错误字符串数组,空数组即合法——这个形状让错误可以逐条打印、逐条断言。
// file: core/src/ashdeck/validate.ts
// tier: P
// verify: node --test 'core/test/gd_mech_02_validate.test.ts'
//
// 规格的两级校验:
// 模式校验 validateSchema —— 必填字段存在、类型正确、数值在域内(结构);
// 语义校验 validateSemantics —— 引用完整性(strategy 的 p<i> 可达、卡池非空、turnLimit>0、energyMax>=0)。
// 返回错误字符串数组,空数组即合法。判据全整数、无浮点,只允许可擦除 TS 语法(禁 enum / namespace / 装饰器)。
import type { Spec } from "./types.ts";
export const TOP_FIELDS: string[] = [
"spec_version", "id", "name", "seed", "rng", "params", "cards", "goals", "invariants", "strategy",
];
export const RNG_FIELDS: string[] = ["algo", "mod", "mul"];
export const PARAM_FIELDS: string[] = [
"playerHp", "enemyHp", "enemyAttack", "energyMax", "handSize", "turnLimit", "rewardGold", "punishHp",
];
export const CARD_FIELDS: string[] = ["id", "cost", "damage", "block", "count", "rarity"];
export const CARD_INT_FIELDS: string[] = ["cost", "damage", "block", "count"];
/** 整数判据:JS 的 number 只有整数与 IEEE754 双精度两种,这里只认整数。 */
export function isInt(x: unknown): boolean {
return typeof x === "number" && Number.isInteger(x);
}
function isObj(x: unknown): boolean {
return x !== null && typeof x === "object" && !Array.isArray(x);
}
/** 模式校验:必填字段存在、类型正确、数值在域内。返回错误清单。 */
export function validateSchema(spec: Record<string, unknown>): string[] {
const errs: string[] = [];
if (!isObj(spec)) return ["schema: 根节点必须是对象"];
for (const k of TOP_FIELDS) if (!(k in spec)) errs.push(`schema: 缺字段 ${k}`);
if (!isInt(spec.spec_version)) errs.push("schema: spec_version 必须是整数");
if (typeof spec.id !== "string" || spec.id === "") errs.push("schema: id 必须是非空字符串");
if (typeof spec.name !== "string" || spec.name === "") errs.push("schema: name 必须是非空字符串");
if (!isInt(spec.seed)) errs.push("schema: seed 必须是整数");
if (!isObj(spec.rng)) errs.push("schema: rng 必须是对象");
else {
const r = spec.rng as Record<string, unknown>;
for (const k of RNG_FIELDS) if (!(k in r)) errs.push(`schema: 缺 rng.${k}`);
if (typeof r.algo !== "string") errs.push("schema: rng.algo 必须是字符串");
if (!isInt(r.mod)) errs.push("schema: rng.mod 必须是整数");
if (!isInt(r.mul)) errs.push("schema: rng.mul 必须是整数");
}
if (!isObj(spec.params)) errs.push("schema: params 必须是对象");
else {
const p = spec.params as Record<string, unknown>;
for (const k of PARAM_FIELDS) {
if (!(k in p)) errs.push(`schema: 缺 params.${k}`);
else if (!isInt(p[k])) errs.push(`schema: params.${k} 必须是整数`);
}
}
if (!Array.isArray(spec.cards)) errs.push("schema: cards 必须是数组");
else if (spec.cards.length === 0) errs.push("schema: cards 不能为空数组");
else {
for (let i = 0; i < spec.cards.length; i += 1) {
const c = spec.cards[i];
if (!isObj(c)) {
errs.push(`schema: cards[${i}] 必须是对象`);
continue;
}
const card = c as Record<string, unknown>;
for (const k of CARD_FIELDS) if (!(k in card)) errs.push(`schema: 缺 cards[${i}].${k}`);
if (typeof card.id !== "string" || card.id === "") errs.push(`schema: cards[${i}].id 必须是非空字符串`);
if (typeof card.rarity !== "string" || card.rarity === "") errs.push(`schema: cards[${i}].rarity 必须是非空字符串`);
for (const k of CARD_INT_FIELDS) {
if (!isInt(card[k])) errs.push(`schema: cards[${i}].${k} 必须是整数`);
}
}
}
if (!isObj(spec.goals)) errs.push("schema: goals 必须是对象");
else {
const g = spec.goals as Record<string, unknown>;
for (const k of ["primary", "secondary", "limit"]) {
if (typeof g[k] !== "string" || g[k] === "") errs.push(`schema: goals.${k} 必须是非空字符串`);
}
}
if (!Array.isArray(spec.invariants)) errs.push("schema: invariants 必须是数组");
else if (spec.invariants.length === 0) errs.push("schema: invariants 不能为空数组");
else if (!spec.invariants.every((x) => typeof x === "string")) errs.push("schema: invariants 元素必须是字符串");
if (!Array.isArray(spec.strategy)) errs.push("schema: strategy 必须是数组");
else if (spec.strategy.length === 0) errs.push("schema: strategy 不能为空数组");
else if (!spec.strategy.every((x) => typeof x === "string")) errs.push("schema: strategy 元素必须是字符串");
return errs;
}
/** 语义校验:引用完整性。只在结构合法时才有意义(调用方须先过模式校验)。 */
export function validateSemantics(spec: Spec): string[] {
const errs: string[] = [];
const p = spec.params;
const deck = spec.cards.reduce((a, c) => a + c.count, 0);
if (deck <= 0) errs.push("semantics: 卡池为空(count 之和必须 > 0)");
if (p.turnLimit <= 0) errs.push(`semantics: turnLimit 必须 > 0(实为 ${p.turnLimit})`);
if (p.energyMax < 0) errs.push(`semantics: energyMax 不能为负(实为 ${p.energyMax})`);
if (p.handSize <= 0) errs.push(`semantics: handSize 必须 > 0(实为 ${p.handSize})`);
if (p.handSize > deck) errs.push(`semantics: handSize(${p.handSize}) 超过卡池总数(${deck})`);
for (let k = 0; k < spec.strategy.length; k += 1) {
const cmd = spec.strategy[k];
if (cmd === "e") continue;
const m = /^p(\d+)$/.exec(cmd);
if (m === null) {
errs.push(`semantics: strategy[${k}] 非法动作 ${cmd}`);
continue;
}
const idx = Number(m[1]);
if (idx < 0 || idx >= p.handSize) {
errs.push(`semantics: strategy[${k}] 索引越界 p${idx}(handSize=${p.handSize})`);
}
}
return errs;
}
/** 合并校验:先模式后语义;结构不合法时语义无意义,直接返回结构错误。 */
export function validateSpec(spec: Record<string, unknown>): string[] {
const schema = validateSchema(spec);
if (schema.length > 0) return schema;
return validateSemantics(spec as unknown as Spec);
}
配套测试 core/test/gd_mech_02_validate.test.ts 读同一份规格,用一个正例加六个负例钉死判据:
// file: core/test/gd_mech_02_validate.test.ts
// tier: P
// verify: node --test 'core/test/gd_mech_02_validate.test.ts'
//
// 2.2 的两级校验测试:合法规格 0 错;6 个负例(缺字段 / 类型错 / strategy 索引越界 /
// turnLimit 为 0 / energyMax 为负 / cards 空数组)每个都必须被判为不合法。
// 判据全整数,与 core/src/ashdeck/validate.ts 同源。
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 { validateSchema, validateSemantics, validateSpec } from "../src/ashdeck/validate.ts";
import type { Spec } from "../src/ashdeck/types.ts";
const HERE = dirname(fileURLToPath(import.meta.url));
const RAW = readFileSync(join(HERE, "..", "..", "config", "play_spec.json"), "utf8");
const SPEC: Spec = JSON.parse(RAW);
function clone(): Record<string, unknown> {
return JSON.parse(RAW);
}
function paramsOf(spec: Record<string, unknown>): Record<string, number> {
return spec.params as Record<string, number>;
}
test("合法规格:模式校验 0 错、语义校验 0 错、合并 0 错", () => {
assert.deepEqual(validateSchema(SPEC as unknown as Record<string, unknown>), []);
assert.deepEqual(validateSemantics(SPEC), []);
assert.deepEqual(validateSpec(clone()), []);
});
test("负例 1 · 模式 · 缺字段:删掉 goals 必须被判不合法", () => {
const bad = clone();
delete bad.goals;
const errs = validateSpec(bad);
assert.ok(errs.length > 0);
assert.ok(errs.some((e) => e.includes("缺字段 goals")));
});
test("负例 2 · 模式 · 类型错:seed 写成字符串必须被判不合法", () => {
const bad = clone();
bad.seed = "20261014";
const errs = validateSpec(bad);
assert.ok(errs.length > 0);
assert.ok(errs.some((e) => e.includes("seed 必须是整数")));
});
test("负例 3 · 语义 · strategy 索引越界 p9 必须被判不合法", () => {
const bad = clone();
bad.strategy = ["p0", "p9"];
assert.deepEqual(validateSchema(bad), []); // 结构本身没问题
const errs = validateSemantics(bad as unknown as Spec);
assert.ok(errs.some((e) => e.includes("索引越界 p9")));
});
test("负例 4 · 语义 · turnLimit 为 0 必须被判不合法", () => {
const bad = clone();
paramsOf(bad).turnLimit = 0;
assert.deepEqual(validateSchema(bad), []);
const errs = validateSemantics(bad as unknown as Spec);
assert.ok(errs.some((e) => e.includes("turnLimit 必须 > 0")));
});
test("负例 5 · 语义 · energyMax 为负必须被判不合法", () => {
const bad = clone();
paramsOf(bad).energyMax = -1;
assert.ok(validateSpec(bad).some((e) => e.includes("energyMax 不能为负")));
});
test("负例 6 · 模式 · cards 为空数组必须被判不合法", () => {
const bad = clone();
bad.cards = [];
assert.ok(validateSpec(bad).some((e) => e.includes("cards 不能为空数组")));
});
本机跑 node --test 'core/test/gd_mech_02_validate.test.ts',真实输出:
✔ 合法规格:模式校验 0 错、语义校验 0 错、合并 0 错 (1.169959ms)
✔ 负例 1 · 模式 · 缺字段:删掉 goals 必须被判不合法 (0.149792ms)
✔ 负例 2 · 模式 · 类型错:seed 写成字符串必须被判不合法 (0.164625ms)
✔ 负例 3 · 语义 · strategy 索引越界 p9 必须被判不合法 (0.1025ms)
✔ 负例 4 · 语义 · turnLimit 为 0 必须被判不合法 (0.102709ms)
✔ 负例 5 · 语义 · energyMax 为负必须被判不合法 (0.092ms)
✔ 负例 6 · 模式 · cards 为空数组必须被判不合法 (0.075834ms)
ℹ tests 7
ℹ pass 7
ℹ fail 0
六个负例各自的判据
| 负例 | 注入的破坏 | 期望命中的判据 | 级别 |
|---|---|---|---|
| 1 | 删 goals | schema: 缺字段 goals | 模式 |
| 2 | seed 改字符串 | schema: seed 必须是整数 | 模式 |
| 3 | strategy = ["p0","p9"] | semantics: strategy[1] 索引越界 p9 | 语义 |
| 4 | turnLimit = 0 | semantics: turnLimit 必须 > 0 | 语义 |
| 5 | energyMax = -1 | semantics: energyMax 不能为负 | 语义 |
| 6 | cards = [] | schema: cards 不能为空数组 | 模式 |
负例 3 与 4 特意先断言 validateSchema(bad) 为空——证明错误确实来自语义级,而不是被模式级顺手拦下。这条区分很重要:一个把所有错误都归到模式级的校验器,会让「索引越界」这类问题在修字段类型时被反复忽略。
自检表
| 检查项 | 合格标准 |
|---|---|
| 两级分离 | 模式级只判结构与类型;语义级只判引用与跨字段约束 |
| 错误可行动 | 每条错误都带字段路径或下标,不出现「规格有错」 |
| 负例成对 | 语义负例必须先证明模式级放行,才说明判据在语义级 |
| 合法规格零错 | 只读真源必须 0 错,否则校验器本身有 bug |
判据立住了,但规格是会长的:下一节要新增一个 params.critRate 字段,问题就变成「旧规格怎么办」——迁移必须让旧规格既能被新校验器接受,又保持语义等价。
阅读导航:上一节:2.1 play_spec.json 的字段 · 下一节:2.3 规格的版本与迁移 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。