站内的 Godot 本地化与字体实现 解决的是引擎侧怎么切语言、怎么处理字形——一份译文进来,怎么绑到某个 locale、怎么让字体覆盖到那些字形。本章与它划界明确:本章做的是文本表与键的一致性——键有没有漏、变体有没有覆盖、复数形式有没有缺;本章不评价译文质量:一段翻译准不准、顺不顺,既不在本套范围内,也需要真实业务环境与母语审校,命令行不可复现。
12.3 文本的一致性校验
前两节各立了一层:12.1 立了键,12.2 立了变体。本节把两层并到一起,做四类残差的联合校验——每一类都能算出一个整数,加在一起就是文本层的总残差。
| # | 校验 | 判据 | 不合格怎么处理 | 本规格期望 |
|---|---|---|---|---|
| ① | 漏键 | 规格引用但表里没有 | FAIL | 0 |
| ② | 悬空键 | 表里有但规格没引用 | 报告 | 0 |
| ③ | 变体覆盖 | 带条件节点缺 line_else | 报告(不 FAIL) | 1 |
| ④ | 占位符一致性 | 文本里的占位符名对不上声明变量 | FAIL | 0 |
四类里只有 ③ 的期望值是 1——d_lt_2 声明了 line_when 却没有 line_else。它是规格层面的欠声明,按声明规则回落到 line 是正确行为,所以报告而不 FAIL:欠声明是要报的事实,不是要拦的错。
把四类校验的定义与判据落成一份验收清单,是本节的 D 层产物:
{
"namespace": "ne_12_3",
"checks": [
{ "id": "missing", "name": "漏键", "criterion": "规格引用但表缺 -> FAIL", "expected": 0 },
{ "id": "dangling", "name": "悬空键", "criterion": "表有但规格无引用 -> 报告", "expected": 0 },
{ "id": "variant", "name": "变体覆盖", "criterion": "带条件节点缺 line_else -> 报告(不 FAIL)", "expected": 1 },
{ "id": "placeholder", "name": "占位符一致性", "criterion": "文本占位符名对不上声明变量 -> FAIL", "expected": 0 }
],
"declared_vars": { "aff": 4, "flag": 18, "item": 9, "total": 31 },
"boundary": "只做文本的结构一致性;译文质量 / 字体字形覆盖 / 配音成本需真实业务环境,命令行不可复现。"
}
P 层:四类残差一次算齐
core/src/inkwell/ne_12_3_text_consistency.ts 复用前两节的模块——漏键 / 悬空键来自 12.1,变体覆盖来自 12.2——只新增占位符扫描。这本身就是「同源」的一个落点:一致性校验不重新实现抽键与变体,而是消费它们。
// file: core/src/inkwell/ne_12_3_text_consistency.ts
// tier: P
// verify: node --test 'core/test/ne_12_3_*.test.ts'
// 卷二 12.3 文本的一致性校验:四类残差——漏键 / 悬空键 / 变体覆盖 / 占位符一致性。
// 前两类复用 12.1 的抽键与检测;变体覆盖复用 12.2 的矩阵;占位符只做「名字对得上」。
// 只做文本的**结构一致性**:不评价译文质量,也不做文本层面的摘要。
import { extractTextKeys, buildTable, rendererRefs, checkKeys } from "./ne_12_1_text_table.ts";
import { variantMatrix } from "./ne_12_2_variant_plural.ts";
type Spec = { dialogue: unknown[]; state: { vars: string[]; flags: string[]; items: string[] } };
const PLACEHOLDER = /\{([^{}]*)\}/g;
/** 规格声明的变量名全集(含命名空间写法)。 */
export function declaredVars(spec: Spec): Set<string> {
const s = new Set<string>();
for (const v of spec.state.vars) {
s.add(v);
s.add(`aff.${v}`);
}
for (const f of spec.state.flags) {
s.add(f);
s.add(`flag.${f}`);
}
for (const i of spec.state.items) {
s.add(i);
s.add(`item.${i}`);
}
return s;
}
/** 扫描文本里的行内占位符,返回命中 / 未解析 / 声明未用。 */
export function scanPlaceholders(texts: string[], declared: Set<string>) {
const used = new Set<string>();
const unresolved = new Set<string>();
for (const t of texts) {
for (const m of t.matchAll(PLACEHOLDER)) {
const name = m[1].trim();
used.add(name);
if (!declared.has(name)) unresolved.add(name);
}
}
const unused = [...declared].filter((d) => !used.has(d));
return { used: [...used].sort(), unresolved: [...unresolved].sort(), unused_declared: unused.length };
}
/** 四类残差一次算齐。 */
export function consistencyReport(spec: Spec) {
const table = buildTable(spec as never);
const { missing, dangling } = checkKeys(table, rendererRefs(spec as never));
const variants = variantMatrix(spec as never);
const under = variants.filter((r) => r.status === "under_declared");
const ph = scanPlaceholders(Object.values(table), declaredVars(spec));
return {
missing_keys: missing,
dangling_keys: dangling,
variant_under_declared: under.map((r) => r.id),
placeholders: ph,
residual: {
missing: missing.length,
dangling: dangling.length,
variant: under.length,
placeholder: ph.unresolved.length,
},
};
}
export { extractTextKeys };
// file: core/test/ne_12_3_text_consistency.test.ts
// tier: P
// verify: node --test 'core/test/ne_12_3_*.test.ts'
// 卷二 12.3:四类残差一次算齐,并给出占位符的负例。
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 { consistencyReport, declaredVars, scanPlaceholders } from "../src/inkwell/ne_12_3_text_consistency.ts";
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, "..", "..");
const spec = JSON.parse(readFileSync(join(ROOT, "config/story_spec.json"), "utf8"));
test("四类残差:漏键 0 / 悬空键 0 / 变体欠声明 1 / 占位符 0", () => {
const r = consistencyReport(spec);
assert.deepEqual(r.missing_keys, []);
assert.deepEqual(r.dangling_keys, []);
assert.deepEqual(r.variant_under_declared, ["d_lt_2"]);
assert.deepEqual(r.placeholders.used, []);
assert.deepEqual(r.placeholders.unresolved, []);
assert.equal(r.residual.missing, 0);
assert.equal(r.residual.dangling, 0);
assert.equal(r.residual.variant, 1);
assert.equal(r.residual.placeholder, 0);
console.log(`残差:漏键 ${r.residual.missing} / 悬空键 ${r.residual.dangling} / 变体欠声明 ${r.residual.variant} ${JSON.stringify(r.variant_under_declared)} / 占位符 ${r.residual.placeholder}`);
});
test("声明变量 31 个,文本里一个占位符都没用", () => {
const declared = declaredVars(spec);
assert.equal(declared.size, spec.state.vars.length * 2 + spec.state.flags.length * 2 + spec.state.items.length * 2);
const r = scanPlaceholders(["你持有 3 件线索。"], declared);
assert.deepEqual(r.used, []);
console.log(`声明变量 ${declared.size} 个(含命名空间写法),文本占位符命中 ${r.used.length} 处`);
});
test("负例 · 占位符:声明过的名字解析,没声明的报未解析", () => {
const declared = declaredVars(spec);
const ok = scanPlaceholders(["好感度 {aff.yan} 现在是多少"], declared);
assert.deepEqual(ok.used, ["aff.yan"]);
assert.deepEqual(ok.unresolved, []);
const bad = scanPlaceholders(["你持有 {aff.__nope__} 件线索"], declared);
assert.deepEqual(bad.unresolved, ["aff.__nope__"]);
console.log(`负例·占位符:命中 ${JSON.stringify(ok.used)};未解析 ${JSON.stringify(bad.unresolved)}`);
});
test("落盘:一致性报告写 out/", () => {
const r = consistencyReport(spec);
mkdirSync(join(ROOT, "out"), { recursive: true });
writeFileSync(join(ROOT, "out/ne_12_3_consistency.json"), JSON.stringify(r, null, 2) + "\n");
console.log(`落盘:residual = ${JSON.stringify(r.residual)}`);
});
本机实跑 node --test 'core/test/ne_12_3_*.test.ts':
残差:漏键 0 / 悬空键 0 / 变体欠声明 1 ["d_lt_2"] / 占位符 0
声明变量 62 个(含命名空间写法),文本占位符命中 0 处
负例·占位符:命中 ["aff.yan"];未解析 ["aff.__nope__"]
落盘:residual = {"missing":0,"dangling":0,"variant":1,"placeholder":0}
✔ 四类残差:漏键 0 / 悬空键 0 / 变体欠声明 1 / 占位符 0 (2.981958ms)
✔ 声明变量 31 个,文本里一个占位符都没用 (0.195583ms)
✔ 负例 · 占位符:声明过的名字解析,没声明的报未解析 (0.149875ms)
✔ 落盘:一致性报告写 out/ (3.747792ms)
ℹ tests 4
ℹ suites 0
ℹ pass 4
ℹ fail 0
ℹ duration_ms 523.682584
判读:四个数各有各的含义
「漏键 0 / 悬空键 0」 是 12.1 那两条检测的直接结果:164 条键全部被引用,164 条引用全部有键。这两个 0 说明文本表与规格完全对齐——键不多不少。
「变体欠声明 1」 是唯一的非零残差,指向 d_lt_2。它声明了 line_when 却没有 line_else,渲染时 fallback 回落到 line。按判据这是报告不是 FAIL——欠声明是「声明不完整」,不是「文本错了」。把它算进残差,是为了让「补一条 line_else」成为一个有明确数字可回归的动作:补上后残差从 1 变 0。
「占位符 0」 说的是:164 条文本里没有一个 {...} 占位符。规格声明了 31 个变量(4 好感度 + 18 flag + 9 物品),但没有一条文本引用它们——这与卷一 12.1 的实测一致(行内占位 0 处)。所以占位符一致性这一项当前是空过的:没有占位符,就没有对不上的占位符。它现在报 0 不代表以后永远报 0——一旦有文本写了 {aff.yan},这条校验就开始真正工作。
负例证明检测不是恒空。 声明过的名字 {aff.yan} 解析成功、{aff.__nope__} 报未解析——占位符校验真的会随文本变化而变。
四类残差与「文本层总残差」
| 残差 | 值 | 性质 | 回归口径 |
|---|---|---|---|
| 漏键 | 0 | 硬(FAIL) | 表缺键即涨 |
| 悬空键 | 0 | 软(报告) | 表多键即涨 |
| 变体欠声明 | 1 | 软(报告) | 补 line_else 即归零 |
| 占位符 | 0 | 硬(FAIL) | 出现对不上的占位符即涨 |
四个数相加 = 1,这就是当前文本层的总残差。它可回归:规格一动,四个数各自变化,加起来就是新的残差。注意它只度量结构——残差为 0 不等于「文本没问题」,只等于「键、变体、占位符三件结构事都对得上」。
边界:本章不做什么(X 层)
| 事项 | 能否命令行复现 |
|---|---|
| 键、变体、占位符的结构一致性 | 能(本节全部断言都在这一列) |
| 译文质量:准不准、顺不顺 | 不能,需真实业务环境与母语审校,命令行不可复现 |
| 字体字形覆盖是否完整 | 不能,需真实业务环境,命令行不可复现 |
| 配音成本与排期 | 不能,需真实业务环境,命令行不可复现 |
本章从头到尾只做左边一列。右边三列不是「暂时不做」,是命令行不可复现——它们要真实的人、真实的语料、真实的字体文件与真实的录音棚,给不出整数残差。
自检表
| 检查项 | 合格标准 |
|---|---|
| 四类齐备 | 漏键 / 悬空键 / 变体覆盖 / 占位符各算一个整数 |
| 判据分明 | 漏键与占位符判 FAIL,悬空键与变体覆盖只报告 |
| 复用同源 | 漏键 / 悬空键复用 12.1,变体覆盖复用 12.2,不重写 |
| 残差可回归 | 四个数相加为总残差,规格一动即变 |
| 负例必给 | 占位符给一条「未解析」负例,证明检测非恒空 |
| 不做译文评价 | 只比名字与条数,不判断译文质量 |
第 12 章到此收束:文本从规格里被抽成键(12.1)、按状态与数量分流成变体(12.2)、再被四类残差校验(12.3)。下一章把这条线拉到跨语言上——同一份规格在 Python、TS、浏览器三条链上推进出的状态摘要必须逐位相同,而文本本身不做摘要。
阅读导航:上一节:12.2 变体与复数 · 下一节:13.1 跨语言摘要与 digest 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。