12.2 变体与复数

同一条键在不同状态下要渲染不同文本,这是条件变体;同一句话在不同数量下要选不同形式,这是复数。本节给出 2 个带条件变体节点的覆盖矩阵(1 完整、1 欠声明),并给出 zh-CN 与 en-US 的最小复数规则对照——中文退化为单类,复数只在对照语言上有意义。

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

12.2 变体与复数

上一节把键立了起来:一个位置一条键。但一条键未必只对应一条文本——它可能按状态分流(条件变体),也可能按数量分流(复数)。本节把这两类分流都变成可数的覆盖矩阵。

变体维度触发条件本规格实测
条件变体同一节点在不同状态下渲染不同台词2 个节点带 line_when,其中 1 个带 line_else
复数变体同一句话按数量 n 选不同形式中文单类;对照语言 en-US 两类

条件变体复用引擎内核 condOk / initState(同源基准),不另立一套求值。复数则给一份最小规则集,并且必须把中文的适用边界写死。

条件变体:从定义域枚举到覆盖矩阵

一个节点只要声明了 line_when,渲染器就有四条分支:base(无 line_when)、when(条件真)、else(条件假且有 line_else)、fallback(条件假且无 line_else)。把条件里的自由变量在整个定义域上枚举一遍,就能算出这个节点实际会走到几种分支、渲染出几条不同文本。

core/src/inkwell/ne_12_2_variant_plural.ts 做两件事:条件变体的覆盖矩阵,与最小复数规则的选择与渲染。

// file: core/src/inkwell/ne_12_2_variant_plural.ts
// tier: P
// verify: node --test 'core/test/ne_12_2_*.test.ts'
// 卷二 12.2 变体与复数:条件变体(同一节点按状态渲染不同台词)与数量变体(复数)。
// 条件变体复用引擎内核 condOk / initState(同源基准),不另立一套求值。
// 复数只给最小规则集:中文退化为单类 other,对照语言 en-US 分 one / other。
// 只做文本的**结构**:分支覆盖与复数类别计数。不评价译文质量。

import { condOk, initState, type State } from "./engine.ts";

type Node = { id: string; line: string; line_when?: unknown; line_else?: string };
type Spec = Parameters<typeof initState>[0] & { dialogue: Node[] };

export type MatrixRow = {
  id: string;
  branches: string[];
  distinct_texts: number;
  declared: number;
  status: "complete" | "under_declared";
};

/** 条件里的自由变量(kind, name),去重保序。 */
export function freeVars(c: unknown, out: [string, string][] = []): [string, string][] {
  if (!c) return out;
  const a = c as unknown[];
  const k = a[0] as string;
  if (k === "and" || k === "or") {
    freeVars(a[1], out);
    freeVars(a[2], out);
  } else if (k === "not") {
    freeVars(a[1], out);
  } else if (k === "flag" || k === "item" || k === "aff") {
    const pair: [string, string] = [k, a[1] as string];
    if (!out.some((p) => p[0] === pair[0] && p[1] === pair[1])) out.push(pair);
  }
  return out;
}

export function domainOf(kind: string): number[] {
  if (kind === "aff") return Array.from({ length: 101 }, (_, i) => i);
  return [0, 1];
}

/** 四分支渲染:base / when / else / fallback,与引擎同语义。 */
export function renderLine(node: Node, st: State): [string, string] {
  if (node.line_when == null) return [node.line, "base"];
  if (condOk(node.line_when as never, st)) return [node.line, "when"];
  if (node.line_else) return [node.line_else, "else"];
  return [node.line, "fallback"];
}

function cartesian(vars: [string, string][]): number[][] {
  let acc: number[][] = [[]];
  for (const [kind] of vars) {
    const d = domainOf(kind);
    acc = acc.flatMap((row) => d.map((v) => [...row, v]));
  }
  return acc;
}

function withAssign(spec: Spec, vars: [string, string][], values: number[]): State {
  const st = initState(spec);
  vars.forEach(([kind, name], i) => {
    if (kind === "aff") st.aff[name] = values[i];
    else if (kind === "flag") st.flags[name] = values[i];
    else st.items[name] = values[i];
  });
  return st;
}

/** 变体覆盖矩阵:每个带 line_when 的节点,枚举定义域得到分支与不同文本数。 */
export function variantMatrix(spec: Spec): MatrixRow[] {
  const rows: MatrixRow[] = [];
  for (const n of spec.dialogue) {
    if (!n.line_when) continue;
    const vars = freeVars(n.line_when);
    const branches = new Set<string>();
    const texts = new Set<string>();
    for (const values of cartesian(vars)) {
      const [text, branch] = renderLine(n, withAssign(spec, vars, values));
      branches.add(branch);
      texts.add(text);
    }
    const b = [...branches].sort();
    rows.push({
      id: n.id,
      branches: b,
      distinct_texts: texts.size,
      declared: n.line_else ? 2 : 1,
      status: n.line_else && texts.size === b.length ? "complete" : "under_declared",
    });
  }
  return rows;
}

export type Rule = "other_only" | "one_other";

/** 复数类别选择:other_only 恒 other;one_other 分 n==1 与否。 */
export function pluralCategory(rule: Rule, n: number): string {
  if (rule === "other_only") return "other";
  return n === 1 ? "one" : "other";
}

export function renderMessage(msg: Record<string, string>, rule: Rule, n: number): string {
  const cat = pluralCategory(rule, n);
  return (msg[cat] ?? msg.other).replace("{n}", String(n));
}

复数:最小规则集与中文边界

中文没有复数——「1 件线索」与「3 件线索」用的是同一个「件」。这不是「复数规则可以省」,而是「中文的复数规则退化成只有 other 一个类别」。要把它写成一份可对照的规则集,就必须至少有一个会分类的对照语言:

{
  "namespace": "ne_12_2",
  "langs": {
    "zh-CN": { "rule": "other_only", "categories": ["other"] },
    "en-US": { "rule": "one_other", "categories": ["one", "other"] }
  },
  "messages": {
    "you_hold_clues": {
      "zh-CN": { "other": "你持有 {n} 件线索。" },
      "en-US": { "one": "You hold {n} clue.", "other": "You hold {n} clues." }
    },
    "you_met_chars": {
      "zh-CN": { "other": "你已经见过 {n} 个人。" },
      "en-US": { "one": "You have met {n} person.", "other": "You have met {n} people." }
    }
  },
  "boundary": "zh-CN 只有 other 一个复数类,复数规则在中文下退化为单类;数量变体只在对照语言 en-US 上可区分。"
}

规则集里 rule 只有两个值:other_only(恒单类)与 one_other(n==1 分一类)。这足以覆盖「中文 + 一个会分类的对照语言」这个最小场景,也足以说明一件事:复数缺不缺,只有在会分类的语言上才看得出来。中文文本里「件」用对了,不代表 en-US 的 clue / clues 就齐了——后者要另算。

P 层:正例与负例

// file: core/test/ne_12_2_variant_plural.test.ts
// tier: P
// verify: node --test 'core/test/ne_12_2_*.test.ts'
// 卷二 12.2:条件变体的覆盖矩阵与最小复数规则集。
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 { variantMatrix, pluralCategory, renderMessage, type Rule } from "../src/inkwell/ne_12_2_variant_plural.ts";

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

test("条件变体:2 个节点,1 完整 + 1 欠声明", () => {
  const rows = variantMatrix(spec);
  assert.equal(rows.length, 2);
  const by = Object.fromEntries(rows.map((r) => [r.id, r]));
  assert.deepEqual(by["d_tc_2"].branches, ["else", "when"]);
  assert.equal(by["d_tc_2"].distinct_texts, 2);
  assert.equal(by["d_tc_2"].status, "complete");
  assert.deepEqual(by["d_lt_2"].branches, ["fallback", "when"]);
  assert.equal(by["d_lt_2"].distinct_texts, 1);
  assert.equal(by["d_lt_2"].status, "under_declared");
  console.log(`条件变体矩阵:${rows.map((r) => `${r.id}(${r.branches.join("/")}) ${r.distinct_texts}变体 ${r.status}`).join(";")}`);
});

test("复数:zh-CN 恒 other,en-US 分 one / other", () => {
  const zh: Rule = plural.langs["zh-CN"].rule;
  const en: Rule = plural.langs["en-US"].rule;
  assert.deepEqual([0, 1, 2, 5].map((n) => pluralCategory(zh, n)), ["other", "other", "other", "other"]);
  assert.deepEqual([0, 1, 2, 5].map((n) => pluralCategory(en, n)), ["other", "one", "other", "other"]);
  console.log(`复数类别:zh-CN ${[0, 1, 2, 5].map((n) => pluralCategory(zh, n)).join(",")};en-US ${[0, 1, 2, 5].map((n) => pluralCategory(en, n)).join(",")}`);
});

test("复数渲染:中文单类、英文两类,同一 {n} 占位", () => {
  const msg = plural.messages.you_hold_clues;
  assert.equal(renderMessage(msg["zh-CN"], plural.langs["zh-CN"].rule, 1), "你持有 1 件线索。");
  assert.equal(renderMessage(msg["zh-CN"], plural.langs["zh-CN"].rule, 3), "你持有 3 件线索。");
  assert.equal(renderMessage(msg["en-US"], plural.langs["en-US"].rule, 1), "You hold 1 clue.");
  assert.equal(renderMessage(msg["en-US"], plural.langs["en-US"].rule, 3), "You hold 3 clues.");
  console.log(`渲染:zh(1)=${renderMessage(msg["zh-CN"], plural.langs["zh-CN"].rule, 1)} en(1)=${renderMessage(msg["en-US"], plural.langs["en-US"].rule, 1)} en(3)=${renderMessage(msg["en-US"], plural.langs["en-US"].rule, 3)}`);
});

test("落盘:变体覆盖矩阵写 config/ 与 out/", () => {
  const rows = variantMatrix(spec);
  const doc = {
    nodes: rows,
    summary: {
      conditional_nodes: rows.length,
      complete: rows.filter((r) => r.status === "complete").length,
      under_declared: rows.filter((r) => r.status === "under_declared").length,
      variant_residual: rows.filter((r) => r.status === "under_declared").length,
    },
  };
  const body = JSON.stringify(doc, null, 2) + "\n";
  mkdirSync(join(ROOT, "out"), { recursive: true });
  writeFileSync(join(ROOT, "out/ne_12_2_variant_matrix.json"), body);
  writeFileSync(join(ROOT, "config/ne_12_2_variant_matrix.json"), body);
  console.log(`落盘:条件节点 ${rows.length},欠声明 ${doc.summary.under_declared}`);
});

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

条件变体矩阵:d_tc_2(else/when) 2变体 complete;d_lt_2(fallback/when) 1变体 under_declared
复数类别:zh-CN other,other,other,other;en-US other,one,other,other
渲染:zh(1)=你持有 1 件线索。 en(1)=You hold 1 clue. en(3)=You hold 3 clues.
落盘:条件节点 2,欠声明 1
✔ 条件变体:2 个节点,1 完整 + 1 欠声明 (2.204792ms)
✔ 复数:zh-CN 恒 other,en-US 分 one / other (0.155334ms)
✔ 复数渲染:中文单类、英文两类,同一 {n} 占位 (0.121167ms)
✔ 落盘:变体覆盖矩阵写 config/ 与 out/ (4.378666ms)
ℹ tests 4
ℹ suites 0
ℹ pass 4
ℹ fail 0
ℹ duration_ms 743.543208

判读:覆盖矩阵里的一处欠声明

d_tc_2 是完整样本。 它的定义域只有 2 个状态(cave_explored 取 0 与 1),走到 2 种分支(else / when),渲染出 2 条不同文本——声明了几条就给几条,status = complete。

d_lt_2 是欠声明样本。 它的定义域有 101 个状态(yan 取 0–100),分支是 when(71 个状态)与 fallback(30 个状态),但两种分支渲染出的是同一条文本——因为规格没写 line_else,fallback 按声明规则回落到 line。于是 distinct_texts = 1 而分支有 2 种,status = under_declared。

这不是缺陷数据,是规格层面的欠声明。 修法是给 d_lt_2 补一条 line_else,不是改台词。本节把它标成 under_declared 而不判 FAIL——欠声明是要报的事实,不是要拦的错。

覆盖矩阵落在 config/ne_12_2_variant_matrix.json,两行各自的 branches / distinct_texts / declared / status 都在里面。下一节把它并进四类残差一次算齐。

复数的边界(X 层)

事项能否命令行复现说明
复数类别的选择与计数能other_only / one_other 是纯函数,可断言
译文里复数形式是否真的对不能需真实业务环境与母语审校,命令行不可复现
字体字形是否覆盖到某个复数形式不能需真实业务环境,命令行不可复现
配音成本随语种的变化不能需真实业务环境,命令行不可复现

本节能断言的是「中文只有一个复数类、英文有两个」这个结构事实;至于译文里 clue 是不是真的该写成 clues、字体有没有那个字形——那要真实业务环境,命令行给不出。

自检表

检查项合格标准
条件变体同源求值复用引擎内核 condOk / initState,不在本节另立一套
枚举有界定义域来自自由变量(布尔取 0/1,好感度取 0–100)
覆盖矩阵可读每个条件节点给出分支、不同文本数、声明数与状态
欠声明只报告d_lt_2 标 under_declared,不判 FAIL
中文边界写死明确中文退化为单类,复数只在对照语言上可区分
不做译文评价只算类别与条数,不判断译文里复数形式对不对

下一节把键、变体、占位符三件事并到一起,做四类一致性校验,把残差一次算齐。

阅读导航:上一节:12.1 文本表与键 · 下一节:12.3 文本的一致性校验 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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