12.1 文本表与键

文本是规格的一种渲染产物,而键是文本表与规格之间唯一的连接点。本节从 dialogue 段抽出全部文本键——48 条 line、1 条 else、115 条选项文本,共 164 键——给出键命名规则与漏键 / 悬空键检测,并各给一条负例。只做文本的结构一致性,不评价译文质量。

站内的 Godot 本地化与字体实现 解决的是引擎侧怎么切语言、怎么处理字形——一份译文进来,怎么绑到某个 locale、怎么让字体覆盖到那些字形。本章与它划界明确:本章做的是文本表与键的一致性——键有没有漏、变体有没有覆盖、复数形式有没有缺;本章不评价译文质量:一段翻译准不准、顺不顺,既不在本套范围内,也需要真实业务环境与母语审校,命令行不可复现。

12.1 文本表与键

卷一把「文本是规格的渲染产物」这句话讲完了:同一条 dialogue 记录,在不同状态下渲染出不同文本。卷二这一章要处理的是它的工程侧:文本既然要外置、要送审、要过翻译,就必须先有键。键是文本表与规格之间唯一的连接点——键在,文本才在;键漏了,那个位置的文本在运行时无处可取。

文本表因此不是一个新数据结构,它就是一张 键 → 文本 的扁平映射。本节要做的三件事:把键的命名规则定死、把规格里全部文本抽成表、把「漏键」与「悬空键」两类不一致变成可数的量。

键命名规则

规格里能出文本的位置只有三处,键因此只有三种形态。命名用点分命名空间:dlg 段管对话节点,cho 段管选项。

键的形态取值字段出现条件本规格数量
dlg.<node_id>.linedialogue[].line每个节点恒有48
dlg.<node_id>.elsedialogue[].line_else仅当声明了 line_else1
cho.<choice_id>.textdialogue[].choices[].text每个选项恒有115
合计164

第二行是本节最容易漏的一处:line_when 为真时渲染的仍是 line(走 when 分支),不另开一条键;只有 line_else 是独立文本,才需要一条 .else 键。把这点写进规则,是为了让「漏键」成为一个能自动查出来的量,而不是靠人肉对照。

把规则落成一份可校验的文法声明,是本节的 D 层产物:

{
  "namespace": "ne_12_1",
  "key_forms": [
    { "form": "dlg.<node_id>.line", "source": "dialogue[].line", "arity": "恒有" },
    { "form": "dlg.<node_id>.else", "source": "dialogue[].line_else", "arity": "仅当声明 line_else" },
    { "form": "cho.<choice_id>.text", "source": "dialogue[].choices[].text", "arity": "恒有" }
  ],
  "expected_counts": { "dlg_line": 48, "dlg_else": 1, "cho_text": 115, "total": 164 },
  "invariants": [
    "键全局唯一",
    "每个 dlg.<id>.line 的 id 必须出现在 dialogue[].id 中",
    "每个 dlg.<id>.else 的 id 必须有非空 line_else",
    "每个 cho.<id>.text 的 id 必须出现在某个 choices[].id 中"
  ],
  "residuals": { "missing_keys": 0, "dangling_keys": 0 }
}

P 层:抽键与检测

core/src/inkwell/ne_12_1_text_table.ts 只做四件事:按规则抽出文本键、拼成表、算出渲染器的引用集、比对两者得到漏键与悬空键。引用集与表同源(都从规格推出),所以正例里两者恒等——这正是要的:抽键规则一旦写对,残差就该是 0;规则写错,残差立刻冒出来。

// file: core/src/inkwell/ne_12_1_text_table.ts
// tier: P
// verify: node --test 'core/test/ne_12_1_*.test.ts'
// 卷二 12.1 文本表与键:从规格 dialogue 段抽出全部文本键,建成「键 -> 文本」的表。
// 键命名规则(本节唯一口径):
//   dlg.<node_id>.line   每个对话节点的基础台词(base / when / fallback 都读它)
//   dlg.<node_id>.else   仅当节点声明了 line_else 时存在(else 分支)
//   cho.<choice_id>.text 每个选项的文本
// 只做文本的**结构**:抽键、计数、漏键 / 悬空键检测。不评价文本内容。

export type TextKey = { key: string; kind: "dlg.line" | "dlg.else" | "cho.text"; id: string; text: string };
export type TextTable = Record<string, string>;

type Node = { id: string; line: string; line_when?: unknown; line_else?: string; choices?: { id: string; text: string }[] };
type Spec = { dialogue: Node[] };

/** 从规格抽出全部文本键(唯一权威口径)。 */
export function extractTextKeys(spec: Spec): TextKey[] {
  const rows: TextKey[] = [];
  for (const n of spec.dialogue) {
    rows.push({ key: `dlg.${n.id}.line`, kind: "dlg.line", id: n.id, text: n.line });
    if (typeof n.line_else === "string" && n.line_else.length > 0) {
      rows.push({ key: `dlg.${n.id}.else`, kind: "dlg.else", id: n.id, text: n.line_else });
    }
    for (const c of n.choices ?? []) {
      rows.push({ key: `cho.${c.id}.text`, kind: "cho.text", id: c.id, text: c.text });
    }
  }
  return rows;
}

/** 键 -> 文本。 */
export function buildTable(spec: Spec): TextTable {
  const t: TextTable = {};
  for (const r of extractTextKeys(spec)) t[r.key] = r.text;
  return t;
}

/** 渲染器可能查找的键集合(引用集):line 恒被读,else 仅当声明了 line_else,选项文本恒被读。 */
export function rendererRefs(spec: Spec): string[] {
  const refs: string[] = [];
  for (const n of spec.dialogue) {
    refs.push(`dlg.${n.id}.line`);
    if (typeof n.line_else === "string" && n.line_else.length > 0) refs.push(`dlg.${n.id}.else`);
    for (const c of n.choices ?? []) refs.push(`cho.${c.id}.text`);
  }
  return refs;
}

/** 漏键 = 被引用但表里没有;悬空键 = 表里有但无人引用。 */
export function checkKeys(table: TextTable, refs: string[]): { missing: string[]; dangling: string[] } {
  const keySet = new Set(Object.keys(table));
  const refSet = new Set(refs);
  const missing = [...refSet].filter((k) => !keySet.has(k)).sort();
  const dangling = [...keySet].filter((k) => !refSet.has(k)).sort();
  return { missing, dangling };
}

core/test/ne_12_1_text_table.test.ts 在正例之外各给一条负例——删掉一条键看漏键报不报、加一条没人引用的键看悬空键报不报。负例是「检测器真的会随表变化而变」的证据。

// file: core/test/ne_12_1_text_table.test.ts
// tier: P
// verify: node --test 'core/test/ne_12_1_*.test.ts'
// 卷二 12.1:文本键的抽取与计数,漏键 / 悬空键的正例与负例。
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { extractTextKeys, buildTable, rendererRefs, checkKeys } from "../src/inkwell/ne_12_1_text_table.ts";

const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, "..", "..");
const spec = JSON.parse(readFileSync(join(ROOT, "config/story_spec.json"), "utf8"));

function counts(rows: ReturnType<typeof extractTextKeys>) {
  return {
    line: rows.filter((r) => r.kind === "dlg.line").length,
    else_: rows.filter((r) => r.kind === "dlg.else").length,
    text: rows.filter((r) => r.kind === "cho.text").length,
  };
}

test("文本键总数 = 48 line + 1 else + 115 text = 164", () => {
  const rows = extractTextKeys(spec);
  const c = counts(rows);
  assert.deepEqual(c, { line: 48, else_: 1, text: 115 });
  assert.equal(rows.length, 164);
  assert.equal(new Set(rows.map((r) => r.key)).size, 164, "键必须唯一");
});

test("正例:表与引用集同源,漏键 0 / 悬空键 0", () => {
  const table = buildTable(spec);
  const { missing, dangling } = checkKeys(table, rendererRefs(spec));
  assert.deepEqual(missing, []);
  assert.deepEqual(dangling, []);
  assert.equal(Object.keys(table).length, 164);
  console.log(`正例:表 ${Object.keys(table).length} 键,漏键 ${missing.length} / 悬空键 ${dangling.length}`);
});

test("负例 · 漏键:删掉 else 键,漏键报出 dlg.d_tc_2.else", () => {
  const table = buildTable(spec);
  delete table["dlg.d_tc_2.else"];
  const { missing, dangling } = checkKeys(table, rendererRefs(spec));
  assert.deepEqual(missing, ["dlg.d_tc_2.else"]);
  assert.deepEqual(dangling, []);
  console.log(`负例·漏键:漏键 ${missing.length} ${JSON.stringify(missing)}`);
});

test("负例 · 悬空键:加一个无人引用的键,悬空键报出", () => {
  const table = buildTable(spec);
  table["dlg.__ghost__.line"] = "无人引用";
  const { missing, dangling } = checkKeys(table, rendererRefs(spec));
  assert.deepEqual(missing, []);
  assert.deepEqual(dangling, ["dlg.__ghost__.line"]);
  console.log(`负例·悬空键:悬空键 ${dangling.length} ${JSON.stringify(dangling)}`);
});

test("落盘:config/ 与 out/ 各写一份键 -> 文本", () => {
  const table = buildTable(spec);
  const rows = extractTextKeys(spec);
  const c = counts(rows);
  const doc = {
    counts: { ...c, total: rows.length },
    keys_unique: new Set(rows.map((r) => r.key)).size,
    table,
  };
  const body = JSON.stringify(doc, null, 2) + "\n";
  mkdirSync(join(ROOT, "out"), { recursive: true });
  writeFileSync(join(ROOT, "out/ne_12_1_text_table.json"), body);
  writeFileSync(join(ROOT, "config/ne_12_1_text_table.json"), body);
  console.log(`落盘:${rows.length} 键(line ${c.line} / else ${c.else_} / text ${c.text})`);
});

本机实跑 node --test 'core/test/ne_12_1_*.test.ts':

正例:表 164 键,漏键 0 / 悬空键 0
负例·漏键:漏键 1 ["dlg.d_tc_2.else"]
负例·悬空键:悬空键 1 ["dlg.__ghost__.line"]
落盘:164 键(line 48 / else 1 / text 115)
✔ 文本键总数 = 48 line + 1 else + 115 text = 164 (1.899542ms)
✔ 正例:表与引用集同源,漏键 0 / 悬空键 0 (0.704708ms)
✔ 负例 · 漏键:删掉 else 键,漏键报出 dlg.d_tc_2.else (0.205917ms)
✔ 负例 · 悬空键:加一个无人引用的键,悬空键报出 (0.183958ms)
✔ 落盘:config/ 与 out/ 各写一份键 -> 文本 (11.145459ms)
ℹ tests 5
ℹ suites 0
ℹ pass 5
ℹ fail 0
ℹ duration_ms 873.256208

判读:164 是三个计数的和

「164 键」 拆开看是 48 + 1 + 115。48 条 line 每个对话节点一条;1 条 else 来自 d_tc_2(规格里唯一声明了 line_else 的节点);115 条选项文本。这三个数都不是本节编的,是规格的 meta.scale 里 dialogue / choices 两项的实测值——文本表的规模必须与规格规模对齐,这是第一层一致性。

「正例漏键 0 / 悬空键 0」 是抽键规则正确的直接后果。注意这个 0 是有前提的:引用集与表由同一套规则推出。如果哪天渲染器新增一种查找(比如给旁白加 dlg.<id>.narr),引用集就会多出一条,而表没有——漏键立刻从 0 变 1。这正是要的:把「规则变了但表没跟上」变成一次可回归的失败。

两条负例各挡一面。 删掉 dlg.d_tc_2.else 后漏键报出恰好这一条——证明漏键检测不是恒返回空;加一条 dlg.__ghost__.line 后悬空键报出它——证明悬空键检测也真的在工作。只报不删是悬空键的口径:表里多出来的键未必是错的(可能是预留),所以它只报告,不判 FAIL。

表里长什么样

config/ne_12_1_text_table.json 由 P 层测试落盘,结构是「计数 + 键唯一性 + 键 → 文本」。下面截取头部几行看格式,完整文件 164 键:

{
  "counts": {
    "line": 48,
    "else_": 1,
    "text": 115,
    "total": 164
  },
  "keys_unique": 164,
  "table": {
    "dlg.d_pier_1.line": "先生,您是来等船的吗?这三天没船了。",
    "cho.c_pier_1a.text": "问他灯塔为什么没亮",

注意 dlg.d_tc_2.else 与 dlg.d_tc_2.line 并存:同一个节点有两条键,分别装 else 分支与基础台词。这不是重复,是两条不同的渲染路径各取各的文本。

本章的做与不做

本套做(文本的结构一致性)本套不做(需真实业务环境,命令行不可复现)
键有没有漏、有没有悬空译文准不准、顺不顺
变体有没有覆盖、复数形式有没有缺字体字形覆盖是否完整
占位符名字与声明变量对不对得上配音是否到位、成本多少

右边一列在整章里不会出现在任何断言中。文本表与键的一致性可以在命令行上复现;译文质量与字体覆盖不能——后者要真实业务环境与人工审校。本章只做左边。

自检表

检查项合格标准
命名规则唯一键只有 dlg.<id>.line / dlg.<id>.else / cho.<id>.text 三族
与规格对齐164 键 = 48 line + 1 else + 115 text,与 meta.scale 一致
漏键可查被引用但表里没有的键被列出,正例为 0
悬空键只报告表里有但无人引用的键只报告,不判 FAIL
不做译文评价只数键、只比名字,不比较文本内容

下一节把键放到条件与数量上:同一条键在不同状态下可能要多份文本,复数形式则是数量维度上的变体——而规格里已经有一处「声明了条件却没声明另一分支」的欠声明,正好用变体矩阵照出来。

阅读导航:上一节:11.3 快照的向前兼容 · 下一节:12.2 变体与复数 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

  1. 15.3 规模告警
  2. 15.2 求值与遍历的开销
  3. 15.1 规格规模的增长