7.2 语言 QA 与母语审校

译文交付之后,怎么判它合不合格?本节把多语言上线前该做的检查整理成一份可机读清单——键完整、占位符对齐、长度达标、术语一致、文化无禁忌、母语签署——再把「语言 × 检查 × 结论」矩阵折算成阻断/复核/放行三档闸门,用 D 层脚本与 P 层 TypeScript 内核跨语言对拍,证明判定逐位一致。

站内那篇独立游戏多语言本地化指南 讲的是怎么找到译员、怎么分工、怎么付钱,本节不重写这些流程,只补上它没给的那一段:译文回来之后,怎么判它合不合格、能不能发。这是「本地化质量」的第二半——第 7.1 节伪本地化与溢出检测 解决的是「译文还没来,排版会不会炸」;本节解决的是「译文已经来了,内容对不对」。两节合起来,才是一条完整的质量闸门。

译文交付之后,问题才刚开始

很多团队把「译员交了稿」当成终点,直接打进包里发出去。真实情况是:交付恰恰是问题暴露的起点。一份德语稿可能少翻了一个键、把 {name} 占位符写成了 {nombre}、把一段带 <b> 的富文本标签落下了闭合、把一个本该 12 个字符的按钮标题翻成了 26 个字符。这些错误不会让游戏崩,但会让玩家看到乱码般的 {0}、截断的按钮、或者一句读起来像机器翻的话。

更麻烦的是,这些错误性质不同:少一个键是硬伤(玩家看到空白),长度超标只是难看(还能读),而术语不统一介于两者之间。所以「合格 / 不合格」这种二元判断不够用——我们需要一份分档的清单,把「必须拦下的」和「建议改的」分开。

一张可机读的 QA 清单

清单要能被程序读。每一项检查都有四个属性:阶段(交付前自动检查 / 审校期 / 上线后)、类别、严重度(1 阻断 / 2 警告 / 3 提示)、以及能否自动判定(automated)。最后一项是清单的灵魂:它把「机器能判的」和「必须由母语审校人签署的」显式分开——母语审校不是可选项,它是清单里一条 automated=false 的硬项。

规则与结论都写进 config/g07_qa.json:checks 是清单,results 是「语言 × 检查 → pass/fail/na」的结论矩阵:

{
  "version": 1,
  "game": "merge-island",
  "observedAt": "2026-10",
  "note": "语言 QA 与母语审校清单模型。口径是「公开可观察机制」的建模假设:把多语言交付前该做的检查整理成可机读清单,不是任何平台审核标准的逐条实测,也不构成法律意见。phase=pre(交付前自动检查)/review(审校期)/live(上线后);severity=1 阻断(该项失败则该语言不可发)、2 警告(可发但需人工复核)、3 提示(建议项);automated=该检查能否被脚本自动判定,false 的项必须由母语审校人签署。results 是每个语言 × 每项检查的结论:pass/fail/na(不适用)。真实母语审校意见、真实截图语境与真实上线反馈需真实业务环境,命令行不可复现(X)。全整数,无浮点。",
  "locales": [
    { "id": "en-US", "name": "英语(美国)", "native": "en" },
    { "id": "de-DE", "name": "德语(德国)", "native": "de" },
    { "id": "ja-JP", "name": "日语(日本)", "native": "ja" },
    { "id": "ar-SA", "name": "阿拉伯语(沙特)", "native": "ar" }
  ],
  "checks": [
    { "id": "qa-key-parity", "phase": "pre", "category": "completeness", "severity": 1, "automated": true, "desc": "键集合与基准语言完全一致,无缺失键" },
    { "id": "qa-placeholder", "phase": "pre", "category": "consistency", "severity": 1, "automated": true, "desc": "占位符({name}/{0} 等)数量与顺序逐键对齐" },
    { "id": "qa-max-length", "phase": "pre", "category": "layout", "severity": 2, "automated": true, "desc": "译文长度未超过该键的 maxLength 上限" },
    { "id": "qa-term-consistency", "phase": "review", "category": "terminology", "severity": 2, "automated": true, "desc": "术语表命中项在全文译法一致" },
    { "id": "qa-cultural-taboo", "phase": "review", "category": "culture", "severity": 1, "automated": true, "desc": "无该地区文化禁忌符号 / 措辞(第 6.1 节)" },
    { "id": "qa-native-review", "phase": "review", "category": "human", "severity": 1, "automated": false, "desc": "母语审校人签署「读起来自然、无歧义」" },
    { "id": "qa-live-feedback", "phase": "live", "category": "ops", "severity": 3, "automated": true, "desc": "上线后玩家反馈中的语言问题已归档处理" }
  ],
  "results": [
    { "locale": "en-US", "check": "qa-key-parity", "status": "pass" },
    { "locale": "en-US", "check": "qa-placeholder", "status": "pass" },
    { "locale": "en-US", "check": "qa-max-length", "status": "pass" },
    { "locale": "en-US", "check": "qa-term-consistency", "status": "pass" },
    { "locale": "en-US", "check": "qa-cultural-taboo", "status": "pass" },
    { "locale": "en-US", "check": "qa-native-review", "status": "pass" },
    { "locale": "en-US", "check": "qa-live-feedback", "status": "na" },
    { "locale": "de-DE", "check": "qa-key-parity", "status": "pass" },
    { "locale": "de-DE", "check": "qa-placeholder", "status": "pass" },
    { "locale": "de-DE", "check": "qa-max-length", "status": "fail" },
    { "locale": "de-DE", "check": "qa-term-consistency", "status": "pass" },
    { "locale": "de-DE", "check": "qa-cultural-taboo", "status": "pass" },
    { "locale": "de-DE", "check": "qa-native-review", "status": "pass" },
    { "locale": "de-DE", "check": "qa-live-feedback", "status": "pass" },
    { "locale": "ja-JP", "check": "qa-key-parity", "status": "pass" },
    { "locale": "ja-JP", "check": "qa-placeholder", "status": "pass" },
    { "locale": "ja-JP", "check": "qa-max-length", "status": "pass" },
    { "locale": "ja-JP", "check": "qa-term-consistency", "status": "fail" },
    { "locale": "ja-JP", "check": "qa-cultural-taboo", "status": "pass" },
    { "locale": "ja-JP", "check": "qa-native-review", "status": "pass" },
    { "locale": "ja-JP", "check": "qa-live-feedback", "status": "fail" },
    { "locale": "ar-SA", "check": "qa-key-parity", "status": "fail" },
    { "locale": "ar-SA", "check": "qa-placeholder", "status": "fail" },
    { "locale": "ar-SA", "check": "qa-max-length", "status": "pass" },
    { "locale": "ar-SA", "check": "qa-term-consistency", "status": "pass" },
    { "locale": "ar-SA", "check": "qa-cultural-taboo", "status": "fail" },
    { "locale": "ar-SA", "check": "qa-native-review", "status": "pass" },
    { "locale": "ar-SA", "check": "qa-live-feedback", "status": "pass" }
  ]
}

D 层:把清单变成放行闸门

下面这段是 D 层(静态清单层)校验脚本 sim/g07_qa_check.py。它读清单与结论矩阵,把每种语言的失败项按 severity 汇总,再折算成三档闸门:任一 severity=1 失败 → block(该语言不可发);否则有 severity=2 失败 → review(可发但要人工复核);否则 ship。注意 na(不适用)不算失败——基准语言的上线反馈项本来就是空的:

#!/usr/bin/env python3
# file: sim/g07_qa_check.py
# tier: D
# verify: /opt/homebrew/bin/python3 sim/g07_qa_check.py --config config/ --out out/
"""语言 QA 与母语审校清单校验(D 层:清单结构 / lint 校验)。

读 config/g07_qa.json,把「语言 × 检查项 × 结论(pass/fail/na)」矩阵折算成每种语言的
放行闸门:

    severity(1/2/3)          检查项自带;1 阻断、2 警告、3 提示
    fail(sev)                该语言在该检查项上 status=fail
    block = #{fail 且 sev==1};warn = #{fail 且 sev==2};info = #{fail 且 sev==3}
    gate  = "block" 若 block>0;否则 "review" 若 warn>0;否则 "ship"
    humanChecks = 清单里 automated=false 的检查项数(必须由母语审校人签署)

口径:清单是「公开可观察机制」的建模假设,不是任何平台审核标准的逐条实测,也不构成
法律意见。真实母语审校意见、真实截图语境与真实上线反馈需真实业务环境,命令行不可复现
(X)。产出 out/g07_qa_check.json + .csv。全整数,无浮点,无随机。
"""
from __future__ import annotations

import argparse
import csv
import json
import os


def gate_of(cfg: dict, locale_id: str) -> dict:
    """单语言放行闸门:按失败项的 severity 汇总,再折算成 block / review / ship。"""
    sev_of = {c["id"]: c["severity"] for c in cfg["checks"]}
    block = warn = info = 0
    failed = []
    for r in cfg["results"]:
        if r["locale"] != locale_id or r["status"] != "fail":
            continue
        sev = sev_of.get(r["check"], 0)
        failed.append(r["check"])
        if sev == 1:
            block += 1
        elif sev == 2:
            warn += 1
        else:
            info += 1
    gate = "block" if block > 0 else ("review" if warn > 0 else "ship")
    return {"id": locale_id, "block": block, "warn": warn, "info": info,
            "gate": gate, "failed": failed}


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    a = ap.parse_args()
    with open(os.path.join(a.config, "g07_qa.json"), encoding="utf-8") as f:
        cfg = json.load(f)

    rows = [gate_of(cfg, loc["id"]) for loc in cfg["locales"]]
    human = sum(1 for c in cfg["checks"] if not c["automated"])
    digest = ";".join(f'{r["id"]}:{r["gate"]}:{r["block"]}:{r["warn"]}:{r["info"]}' for r in rows)
    by_gate = {g: sum(1 for r in rows if r["gate"] == g) for g in ("ship", "review", "block")}

    os.makedirs(a.out, exist_ok=True)
    with open(os.path.join(a.out, "g07_qa_check.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["locale", "gate", "block", "warn", "info", "failedChecks"])
        for r in rows:
            w.writerow([r["id"], r["gate"], r["block"], r["warn"], r["info"], "+".join(r["failed"])])
    rep = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
           "localeCount": len(cfg["locales"]), "checkCount": len(cfg["checks"]),
           "humanChecks": human, "byGate": by_gate, "gates": rows, "digest": digest,
           "csv": os.path.join(a.out, "g07_qa_check.csv")}
    with open(os.path.join(a.out, "g07_qa_check.json"), "w", encoding="utf-8") as f:
        json.dump(rep, f, ensure_ascii=False, indent=2)
    print(json.dumps({"digest": digest, "localeCount": rep["localeCount"],
                      "checkCount": rep["checkCount"], "humanChecks": human, "byGate": by_gate,
                      "gates": [{"id": r["id"], "gate": r["gate"], "block": r["block"],
                                 "warn": r["warn"], "info": r["info"], "failed": r["failed"]}
                                for r in rows]},
                     ensure_ascii=False, indent=2))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

在本机跑 /opt/homebrew/bin/python3 sim/g07_qa_check.py --config config/ --out out/,4 种语言 × 7 项检查的结果是(同机相对量级,绝对数值不可移植):

digest = en-US:ship:0:0:0;de-DE:review:0:1:0;ja-JP:review:0:1:1;ar-SA:block:3:0:0
byGate = { ship: 1, review: 2, block: 1 }

四种语言里,1 种放行、2 种要复核、1 种被阻断。逐语言看:en-US 零失败直接放行;de-DE 只有长度超标(警告),复核即可;ja-JP 一项术语警告 + 一项上线反馈提示;ar-SA 三项 severity=1 失败(缺键、占位符不对齐、文化禁忌),闸门直接落到 block——这正是分档的价值:如果只看「有没有失败」,de-DE 和 ar-SA 会被当成同一类,但它们一个能发、一个绝对不能发。

P 层:跨语言对拍,证明判定没写错

一张表看不出「是判定对」还是「碰巧看着对」。本套用一份 P 层 TypeScript 内核 core/src/g07/qa.ts 复算同一套折算:同样的 severity 分档、同样的「取最小失败档」、同样的 na 不算失败。它读同一份 config/g07_qa.json,产出与 Python 逐字符一致的摘要:

// file: core/src/g07/qa.ts
// tier: P
// verify: node --test 'core/test/g07_qa.test.ts'
//
// 语言 QA 与母语审校内核:与 sim/g07_qa_check.py 逐位等价的 TypeScript 对偶实现。
// 读同一份 config/g07_qa.json:把「语言 × 检查项 × 结论」矩阵折算成每种语言的放行闸门
// ——severity=1 任一失败即 block(不可发)、否则有 severity=2 失败即 review(人工复核)、
// 否则 ship。全整数、无浮点、无随机、禁 Math.random(只用可擦除语法,禁 enum / namespace)。

export interface QaLocale { readonly id: string; readonly name: string; readonly native: string }
export interface QaCheck {
  readonly id: string; readonly phase: string; readonly category: string;
  readonly severity: number; readonly automated: boolean; readonly desc: string;
}
export interface QaResult { readonly locale: string; readonly check: string; readonly status: string }
export interface QaConfig {
  readonly version: number; readonly game: string;
  readonly locales: readonly QaLocale[]; readonly checks: readonly QaCheck[];
  readonly results: readonly QaResult[];
}
export interface LocaleGate {
  readonly id: string;
  readonly block: number; readonly warn: number; readonly info: number;
  readonly gate: string;
  readonly failed: readonly string[];
}

/** 每种语言的放行闸门:按失败项的 severity 汇总,再折算成 block / review / ship。 */
export function gateOf(cfg: QaConfig, localeId: string): LocaleGate {
  const sevOf = new Map(cfg.checks.map((c) => [c.id, c.severity]));
  let block = 0; let warn = 0; let info = 0;
  const failed: string[] = [];
  for (const r of cfg.results) {
    if (r.locale !== localeId || r.status !== "fail") continue;
    const sev = sevOf.get(r.check) ?? 0;
    failed.push(r.check);
    if (sev === 1) block += 1; else if (sev === 2) warn += 1; else info += 1;
  }
  const gate = block > 0 ? "block" : warn > 0 ? "review" : "ship";
  return { id: localeId, block, warn, info, gate, failed };
}

/** 全部语言的闸门(按 config 顺序)。 */
export function gates(cfg: QaConfig): LocaleGate[] {
  return cfg.locales.map((l) => gateOf(cfg, l.id));
}

/** 需要母语审校人签署的检查项数(automated=false)。 */
export function humanChecks(cfg: QaConfig): number {
  return cfg.checks.filter((c) => !c.automated).length;
}

/** 跨语言对拍摘要:与 sim/g07_qa_check.py 的 digest 逐字符一致。 */
export function digest(cfg: QaConfig): string {
  return gates(cfg).map((g) => `${g.id}:${g.gate}:${g.block}:${g.warn}:${g.info}`).join(";");
}

配套测试 core/test/g07_qa.test.ts 把 Python 落盘的摘要写成金标准,断言 TS 内核复算结果逐字符相等。在本机跑 node --test 'core/test/g07_qa.test.ts',本节 6 个用例全过:

✔ 跨语言一致:TS 闸门摘要 == Python 金标准
✔ 三档:基准语言放行、德语复核、阿语阻断
✔ 阻断优先:阿语 3 项 severity=1 失败,闸门为 block 而非 review
✔ severity 分级:日语一项警告 + 一项提示,合计不等于阻断
✔ na 不算失败:基准语言 live-feedback 为 na,闸门仍为 ship
✔ 人工签署项:清单里恰好一项 automated=false
ℹ tests 6 / pass 6 / fail 0

只要有一边的 severity 分档、na 的处理、或「阻断优先于复核」的口径写错,摘要立刻分叉。这与第 7.1 节是同一个信念:同一份清单,在 Python 校验器与 TS 内核里必须判得一模一样——否则你根本不知道闸门是按哪套规则开的。

三档闸门怎么读

把三种语言并排看,三档闸门的意义就清楚了:

语言失败项闸门动作
en-US无(live-feedback 为 na)ship直接发
de-DE长度超标(警告)review人工看一遍,多数可直接发
ja-JP术语不一致(警告)+ 上线反馈(提示)review先统一术语再发
ar-SA缺键、占位符、文化禁忌(阻断 ×3)block不可发,退回补译

关键在最后一列:闸门不是「合格 / 不合格」,而是**「谁来处理、处理到什么程度」**。block 退回译员,review 交给审校人,ship 直接进构建。清单里那条 automated=false 的 qa-native-review 是所有语言都必须过的硬项——它提醒我们:机器能判的只是下限,读起来自然不自然,永远得由母语的人说了算。

X 层:真实审校意见不可复现

边界必须划清。上面算的是清单侧:给定一份清单与一份结论矩阵,闸门是什么。而下面这些属于 X 层,需真实业务环境,命令行不可复现:

  • 真实的母语审校意见(「这句话读起来别扭」没有可复现的判据,只能由人签);
  • 真实的截图语境(同一个键在弹窗里和在按钮里,可读性完全不同);
  • 真实的上线反馈(玩家报的语言问题,往往指向清单没覆盖的维度)。

工程上正确的做法是:把可复现的清单层与不可复现的人审层分开——清单层保证「明显会漏的项一个不漏」,人审层靠母语审校人签署兜底。本节把「译文来了怎么判」做成了可跑的闸门;下一节我们把闸门换成钱——这些检查、这些语言,一共要花多少、要排多久。

阅读导航:上一节:7.1 伪本地化与溢出检测 · 下一节:7.3 本地化成本与排期 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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