16.2 配置驱动与数据表

配置表是小游戏里的唯一真源:关卡数值、合成链、掉落权重都写在 JSON 里,错的不只是一个文件,而是所有读它的仿真、内核与原型。本节先给出一份带字段、类型、取值范围与必填约束的 D 层 schema,再用一段 P 层校验器把类型、范围、引用完整性、禁浮点与唯一键一次查全,返回全部错误而非首个。

上一节把代码的「形状」守住了,但小游戏工程里还有一半的真源不在 .ts 里,而在配置表里。这一节解决的是本章另一条小游戏项目特有的发布约束——配置表校验;它的通用背景(工作流、矩阵、缓存)在站内的 GitHub Actions 与 CI/CD 专题 里,本节不重复,只讲配置这一层为什么必须在校验器里兜住。

先回忆本套书的两条核心主张:一份 config,三种消费——同一份 config/economy.json,Python 仿真、TS 内核、浏览器原型读的是同一组数字;一份逻辑,两种宿主——逻辑只依赖 Host 抽象,mock 宿主与真机宿主逐帧一致。这两条主张有一个共同前提:配置是唯一真源。而「唯一真源」这个词是把双刃剑——它意味着配置一旦写错,错的不是一个文件,而是所有读它的仿真、内核与原型同时错,而且往往错得一致,所以测不出来。

举个具体的:《合成小岛》的合成链是「贝壳 → 珍珠 → 珊瑚 → …… → 岛屿」,每一级的 nextId 指向下一级物品的 id。如果有人在改表时把某一行的 nextId 拼成了不存在的 id,仿真照样跑(它只是找不到下一级)、内核照样跑(它把 undefined 当空处理)、原型照样能打开——三个消费者都「没报错」,但游戏里那一条合成链断了。这类错误只有一种办法能提前发现:让配置在校验器里过一遍。

D 层:数据表 schema

下面这段是 D 层(静态层) 的 schema config/e16_schema.json:它声明了合成物品表 merge_items 的字段、类型、取值范围与必填性,还有两条表级规则(禁浮点、唯一键)。它是纯数据,不含逻辑——nextId 的类型是 ref,refTable 与 refField 一起描述「它必须指向本表某一行的 id」。

{
  "version": 1,
  "table": "merge_items",
  "rules": { "noFloats": true, "uniqueKey": "id" },
  "fields": [
    { "name": "id", "type": "string", "required": true, "pattern": "^[a-z][a-z0-9_]*$" },
    { "name": "tier", "type": "int", "required": true, "min": 1, "max": 12 },
    { "name": "value", "type": "int", "required": true, "min": 0, "max": 999999 },
    { "name": "nextId", "type": "ref", "required": false, "nullable": true, "refTable": "merge_items", "refField": "id" },
    { "name": "sprite", "type": "string", "required": true, "pattern": "^[a-z][a-z0-9_]*$" },
    { "name": "unlockable", "type": "bool", "required": false }
  ]
}

下面这段是 D 层数据表实例 config/e16_datatable.json:八级合成链,从 shell 一路指到 island,最后一级 nextId 为 null(合法,因为 schema 里 nextId 标了 nullable)。注意全部数值都是整数——value 走 1、3、8、21、55、144、377、987,这是整数增长,没有一处小数。

{
  "version": 1,
  "table": "merge_items",
  "rows": [
    { "id": "shell", "tier": 1, "value": 1, "nextId": "pearl", "sprite": "item_shell", "unlockable": false },
    { "id": "pearl", "tier": 2, "value": 3, "nextId": "coral", "sprite": "item_pearl", "unlockable": false },
    { "id": "coral", "tier": 3, "value": 8, "nextId": "seaglass", "sprite": "item_coral", "unlockable": false },
    { "id": "seaglass", "tier": 4, "value": 21, "nextId": "driftwood", "sprite": "item_seaglass", "unlockable": false },
    { "id": "driftwood", "tier": 5, "value": 55, "nextId": "anchor", "sprite": "item_driftwood", "unlockable": true },
    { "id": "anchor", "tier": 6, "value": 144, "nextId": "lighthouse", "sprite": "item_anchor", "unlockable": true },
    { "id": "lighthouse", "tier": 7, "value": 377, "nextId": "island", "sprite": "item_lighthouse", "unlockable": true },
    { "id": "island", "tier": 8, "value": 987, "nextId": null, "sprite": "item_island", "unlockable": true }
  ]
}

P 层:配置校验器

schema 是「该怎么写」的声明,数据是「实际怎么写」的事实。两者之间需要一层校验。下面这段是 P 层内核 core/src/e16/validate.ts:它读一份数据表与一份 schema,逐字段检查必填、类型、取值范围、正则、引用完整性与未声明字段,做表级的唯一键与禁浮点检查,一次返回全部错误(不是遇到第一个就停)。为什么强调「全部」?因为一份坏配置的修复成本取决于你一次能看见多少条——一次报一条会让人改十轮,一次报全才是一次修完。

// file: core/src/e16/validate.ts
// tier: P
// verify: node --test 'core/test/e16_*.test.ts'
//
// 配置表校验器:把「配置是唯一真源,所以配置必须被校验」落成一条断言——
// 必填 / 类型 / 取值范围 / 正则 / 引用完整性 / 唯一键 / 未声明字段 / 禁浮点,
// 一次返回全部错误(不是遇到第一个就停),让一份坏配置的修复成本可见。
// 全整数、无 Math.random()、只用可擦除语法。

export type FieldType = "string" | "int" | "ref" | "bool";

export interface FieldSpec {
  readonly name: string;
  readonly type: FieldType;
  readonly required?: boolean;
  readonly nullable?: boolean;
  readonly min?: number;
  readonly max?: number;
  readonly pattern?: string;
  readonly refTable?: string;
  readonly refField?: string;
}

export interface TableSchema {
  readonly version: number;
  readonly table: string;
  readonly rules?: { readonly noFloats?: boolean; readonly uniqueKey?: string };
  readonly fields: readonly FieldSpec[];
}

export interface TableData {
  readonly version: number;
  readonly table: string;
  readonly rows: readonly Record<string, unknown>[];
}

export interface ValidationError {
  readonly row: number;      // 1 基行号;0 表示表级 / 文本级错误
  readonly field: string;
  readonly code: string;
  readonly message: string;
}

export interface ValidationResult {
  readonly ok: boolean;
  readonly table: string;
  readonly rows: number;
  readonly errors: ValidationError[];
}

/** 扫描原始 JSON 文本里的浮点字面量(先剥掉字符串内容,避免把文本误判成数字)。 */
export function scanFloatLiterals(raw: string): { line: number; text: string }[] {
  const out: { line: number; text: string }[] = [];
  const lines = raw.split("\n");
  for (let i = 0; i < lines.length; i += 1) {
    const stripped = lines[i].replace(/"(?:[^"\\]|\\.)*"/g, '""').replace(/'(?:[^'\\]|\\.)*'/g, "''");
    const m = stripped.match(/-?\d+\.\d+/);
    if (m) out.push({ line: i + 1, text: m[0] });
  }
  return out;
}

/** 全量校验:返回表名、行数与**全部**错误。rawText 可选,给定时额外做禁浮点扫描。 */
export function validateTable(
  data: TableData, schema: TableSchema, rawText?: string,
): ValidationResult {
  const errors: ValidationError[] = [];
  const err = (row: number, field: string, code: string, message: string): void => {
    errors.push({ row, field, code, message });
  };

  if (data.table !== schema.table) {
    err(0, "", "table-mismatch", `表名不符:${data.table} != ${schema.table}`);
  }

  // 自引用表先建索引:refField 的值集合。
  const refIndex = new Map<string, Set<unknown>>();
  for (const f of schema.fields) {
    if (f.type === "ref" && f.refTable === schema.table && f.refField) {
      const set = new Set<unknown>();
      for (const r of data.rows) set.add(r[f.refField]);
      refIndex.set(f.refField, set);
    }
  }

  // 唯一键:重复的后续行各自报一条,指向首次出现的行。
  const uk = schema.rules?.uniqueKey;
  if (uk) {
    const first = new Map<unknown, number>();
    data.rows.forEach((r, i) => {
      const v = r[uk];
      if (first.has(v)) err(i + 1, uk, "duplicate", `唯一键 ${uk}=${String(v)} 与第 ${first.get(v)} 行重复`);
      else first.set(v, i + 1);
    });
  }

  data.rows.forEach((row, i) => {
    const line = i + 1;
    for (const f of schema.fields) {
      const present = Object.prototype.hasOwnProperty.call(row, f.name);
      const v = row[f.name];
      if (!present || v === undefined) {
        if (f.required) err(line, f.name, "missing", `必填字段缺失:${f.name}`);
        continue;
      }
      if (v === null) {
        if (!f.nullable) err(line, f.name, "null", `字段 ${f.name} 不允许为 null`);
        continue;
      }
      if (f.type === "int") {
        if (typeof v !== "number" || !Number.isInteger(v)) {
          err(line, f.name, "type", `字段 ${f.name} 应为整数,实为 ${JSON.stringify(v)}`);
          continue;
        }
        if (f.min !== undefined && v < f.min) err(line, f.name, "min", `${f.name}=${v} 小于下界 ${f.min}`);
        if (f.max !== undefined && v > f.max) err(line, f.name, "max", `${f.name}=${v} 大于上界 ${f.max}`);
      } else if (f.type === "string") {
        if (typeof v !== "string") {
          err(line, f.name, "type", `字段 ${f.name} 应为字符串,实为 ${JSON.stringify(v)}`);
          continue;
        }
        if (f.pattern && !new RegExp(f.pattern).test(v)) {
          err(line, f.name, "pattern", `${f.name}=${v} 不匹配 ${f.pattern}`);
        }
      } else if (f.type === "bool") {
        if (typeof v !== "boolean") err(line, f.name, "type", `字段 ${f.name} 应为布尔,实为 ${JSON.stringify(v)}`);
      } else if (f.type === "ref") {
        if (typeof v !== "string") {
          err(line, f.name, "type", `字段 ${f.name} 应为引用(字符串),实为 ${JSON.stringify(v)}`);
          continue;
        }
        const set = f.refField ? refIndex.get(f.refField) : undefined;
        if (set && !set.has(v)) {
          err(line, f.name, "ref", `${f.name}=${v} 在 ${f.refTable}.${f.refField} 中不存在`);
        }
      }
    }
    for (const k of Object.keys(row)) {
      if (!schema.fields.some((f) => f.name === k)) err(line, k, "unknown-field", `未声明字段:${k}`);
    }
  });

  if (rawText !== undefined && (schema.rules?.noFloats ?? true)) {
    for (const hit of scanFloatLiterals(rawText)) {
      err(0, "", "float", `第 ${hit.line} 行出现浮点字面量:${hit.text}`);
    }
  }

  return { ok: errors.length === 0, table: schema.table, rows: data.rows.length, errors };
}

四个设计点值得说明:

  • 引用完整性用自引用索引。nextId 是 ref 类型,校验前先把本表所有 id 收成一个集合,逐行查 nextId 是否落在这个集合里(null 因 nullable 放行)。这一步抓的就是开头那个「合成链悄悄断掉」的缺陷。
  • 禁浮点扫的是原始文本,不是解析后的值。JSON 里 1.0 与 1 在 JavaScript 里是同一个数,光看解析结果分不出来;所以 scanFloatLiterals 扫的是原文,并且先剥掉字符串内容——否则 "3.14" 这种描述文本会被误判。这条规则是本套书「跨语言逐位一致」的底线:配置里一旦出现浮点,Python 与 TS 两边的运算就可能分叉。
  • 一次返回全部错误。errors 是一个数组,逐行逐字段累加,绝不在第一条错误处 return。测试里专门有一例「一行多病各报一条」,验证四个问题同时被报出。
  • 未声明字段也报。表里多写一个 rarity,schema 里没有,就被判 unknown-field——防止「字段悄悄加上、没人更新 schema」的漂移。

配套的 P 层测试 core/test/e16_validate.test.ts 覆盖了上面每一类错误(真实表 0 错误、类型、范围、必填、正则、引用、唯一键、未声明字段、禁浮点、多错并报)。本机实跑 node --test 'core/test/e16_validate.test.ts' 的输出如下:

✔ 真实配置表:8 行、0 错误
✔ 类型:tier 为字符串被拒
✔ 取值:tier 越上界、value 越下界各报一条
✔ 必填与 null:sprite 缺失报 missing,nextId 显式 null 放行
✔ 正则:id 含大写被判 pattern
✔ 引用完整性:nextId 指向不存在的 id 被判 ref
✔ 唯一键:重复 id 报在后出现的那行
✔ 未声明字段被单独报出
✔ 禁浮点:原始文本里的 1.5 被扫出,且扫描不误伤字符串
✔ 一次返回全部错误:一行多病各报一条
✔ 同一输入两遍结果一致(无隐藏随机)
ℹ tests 11
ℹ pass 11
ℹ fail 0

真实配置表 8 行 0 错误,说明它本身是干净的;每一类坏数据都被单独抓住,说明校验器真的在工作。这套校验和上一节的边界校验合在一起,就是本章「发布前必须绿」的第一组检查项——下一节把它接进发布流水线。

三条纪律

  1. 配置是唯一真源,所以配置必须先被校验。仿真、内核、原型读同一份配置,配置错了三家一起错、还错得一致,只有校验器能在它们之前发现。
  2. schema 与数据分家。字段、类型、范围、引用关系写进 schema(可复用、可演进),数据只填值。校验器读两者,不把规则硬编码进代码。
  3. 报错报全,不报头一条。一次返回全部错误,修复成本才是一次性的;只报第一条,等于让人用「改一条、跑一次」的方式猜配置。

配置能校验了,下一节把这些校验(连同边界检查、体积门禁、命名规范)串成一条发布前流水线——并说清楚为什么真实 CI 平台的执行不可复现、本节给的是什么。

阅读导航:上一节:代码组织与类型安全 · 下一节:持续集成与发布流水线 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

  1. 附录 F:全书公式、参数与数据集索引
  2. 9.3 同意管理与数据出境
  3. 9.2 COPPA 与儿童向游戏的限制