2.2 模式校验与语义校验

规格合法不合法,要能给出具体理由。本节把校验拆成两级:模式校验查必填字段、类型与取值域,语义校验查引用完整性(strategy 的 p<i> 索引必须可达、卡池非空、turnLimit>0、energyMax>=0)。附可跑的 validate.ts 与六个负例,每个都必须被判为不合法,七条断言全绿。

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_versionint≥ 1
id / namestring非空
seedint任意整数
rngobject含 algo(string) / mod(int) / mul(int)
paramsobject8 个键齐备且全为 int
cardsarray非空;每张含 id/rarity(string) 与 4 个 int
goalsobjectprimary / secondary / limit 三个非空 string
invariantsarray非空且元素全为 string
strategyarray非空且元素全为 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删 goalsschema: 缺字段 goals模式
2seed 改字符串schema: seed 必须是整数模式
3strategy = ["p0","p9"]semantics: strategy[1] 索引越界 p9语义
4turnLimit = 0semantics: turnLimit 必须 > 0语义
5energyMax = -1semantics: energyMax 不能为负语义
6cards = []schema: cards 不能为空数组模式

负例 3 与 4 特意先断言 validateSchema(bad) 为空——证明错误确实来自语义级,而不是被模式级顺手拦下。这条区分很重要:一个把所有错误都归到模式级的校验器,会让「索引越界」这类问题在修字段类型时被反复忽略。

自检表

检查项合格标准
两级分离模式级只判结构与类型;语义级只判引用与跨字段约束
错误可行动每条错误都带字段路径或下标,不出现「规格有错」
负例成对语义负例必须先证明模式级放行,才说明判据在语义级
合法规格零错只读真源必须 0 错,否则校验器本身有 bug

判据立住了,但规格是会长的:下一节要新增一个 params.critRate 字段,问题就变成「旧规格怎么办」——迁移必须让旧规格既能被新校验器接受,又保持语义等价。

阅读导航:上一节:2.1 play_spec.json 的字段 · 下一节:2.3 规格的版本与迁移 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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