15.2 本地化与合规落地

平台接进去了,不等于能送审。本节是出海案例的第二段:把本地化与合规拆成一张「清单项 × 平台」的落地矩阵,用可跑的门禁内核判定每个平台能否送审。五个平台里只有 Poki 全部必做项就绪,而「儿童向 COPPA 限制」是唯一同时卡住两家的关键阻塞项,先修它性价比最高。

站内有一篇 H5 游戏产业全景 ,那是 H5 产业的行业综述,含隐私清单与联运分成;卷三《上线变现与长线运营》 讲的是国内上线与长线运营的完整方法。本章不重做行业综述、也不重做国内运营方法论,只做一件事:把前 14 章的方法装配成《合成小岛》的出海案例。本地化与合规的工程视角,前 14 章已经分别讲过:i18n 资源管线见第 5 章 、文化禁忌见第 6 章 、伪本地化与语言 QA 见第 7 章 、数据合规见第 9 章 。本节不重讲这些方法,只把它们交叉成一张可判定的落地清单。

落地清单:从「接进去」到「能送审」

上一节算出了接入时间线,五平台会先后接好。但接好不等于能送审——平台会先检查一堆前置项:文案键齐不齐、语言包能不能加载回退、伪本地化有没有溢出、文化禁忌扫没扫、GDPR/CCPA 同意管理做没做、儿童向的 COPPA 限制符不符合、税务表单(W-8BEN 等)填没填。这些项里有的缺一项就不能送审(必做项),有的只是加分(可选项)。

把「清单项 × 平台」交叉起来,就是一张落地矩阵:每一格是「这个平台的这一项做完了没有」。门禁规则只有一条——必做项全部完成,平台才能送审。这条规则简单,但它把「本地化与合规」从一堆散活变成了一个可判定的就绪度。

D 层:清单与门禁配置

下面这段是 D 层配置 config/g15_landing.json。它把 9 个清单项(7 个必做、2 个可选)与 5 个平台各自的完成情况写成唯一真源。口径是公开可观察机制的建模假设,不是任何平台后台实测,也不构成法律意见。

{
  "version": 1,
  "game": "merge-island",
  "observedAt": "2026-10",
  "note": "出海本地化与合规落地清单。口径是「公开可观察机制」的建模假设,不是任何平台后台实测,也不构成法律意见。items[].category=i18n/art/compliance/payout 四类;required=是否为上线前置必做项;manDays=该项落地人天。platforms[].done=该平台已完成(有证据)的清单项。真实合规结论、税务与平台条款以各家官方要求为准,需真实业务环境,命令行不可复现(X)。全整数,无浮点。",
  "items": [
    { "id": "i18n_keys", "name": "文案键齐全", "category": "i18n", "required": true, "manDays": 3 },
    { "id": "langpack_load", "name": "语言包加载与回退", "category": "i18n", "required": true, "manDays": 4 },
    { "id": "pseudo_pass", "name": "伪本地化无溢出", "category": "i18n", "required": true, "manDays": 2 },
    { "id": "taboo_scan", "name": "文化禁忌扫描", "category": "art", "required": true, "manDays": 3 },
    { "id": "art_variant", "name": "美术本地化变体", "category": "art", "required": false, "manDays": 5 },
    { "id": "consent_gdpr", "name": "GDPR/CCPA 同意管理", "category": "compliance", "required": true, "manDays": 6 },
    { "id": "kids_coppa", "name": "儿童向 COPPA 限制", "category": "compliance", "required": true, "manDays": 4 },
    { "id": "tax_form", "name": "税务表单 W-8BEN", "category": "payout", "required": true, "manDays": 2 },
    { "id": "payout_method", "name": "收款方式与门槛", "category": "payout", "required": false, "manDays": 3 }
  ],
  "platforms": [
    { "id": "gamedistribution", "name": "GameDistribution", "done": ["i18n_keys", "langpack_load", "pseudo_pass", "taboo_scan", "consent_gdpr", "tax_form", "art_variant"] },
    { "id": "poki", "name": "Poki", "done": ["i18n_keys", "langpack_load", "pseudo_pass", "taboo_scan", "consent_gdpr", "kids_coppa", "tax_form", "art_variant", "payout_method"] },
    { "id": "crazygames", "name": "CrazyGames", "done": ["i18n_keys", "langpack_load", "pseudo_pass", "taboo_scan", "consent_gdpr", "kids_coppa"] },
    { "id": "fb_instant", "name": "Facebook Instant Games", "done": ["i18n_keys", "langpack_load", "pseudo_pass", "consent_gdpr", "kids_coppa", "tax_form"] },
    { "id": "yt_playables", "name": "YouTube Playables", "done": ["i18n_keys", "langpack_load", "pseudo_pass", "taboo_scan", "consent_gdpr", "tax_form"] }
  ]
}

D 层:落地门禁扫描

下面这段是 D 层扫描脚本 sim/g15_landing.py。它读上面那份配置,算每个平台的就绪度、逐平台阻塞项,以及缺失平台数最多的关键阻塞项。

#!/usr/bin/env python3
# file: sim/g15_landing.py
# tier: D
# verify: /opt/homebrew/bin/python3 sim/g15_landing.py --config config/ --out out/
"""本地化与合规落地清单扫描(D 层:清单 / lint 校验)。

读 config/g15_landing.json,把「清单项 × 平台」交叉成一张落地矩阵:

  requiredTotal  = 必做项数
  doneRequired(p) = 平台 p 已完成的必做项数
  readinessPermille(p) = doneRequired(p) * 1000 // requiredTotal
  blockers(p)    = 平台 p 尚未完成的必做项(非空即不可提审)
  criticalBlocker = 缺失平台数最多的必做项(并列时取人天少的,再按 id 升序)
  readinessPermille = Σ doneRequired * 1000 // (requiredTotal * 平台数)

口径:清单完成情况与合规结论是「公开可观察机制」口径的建模假设,观察时点 2026-10;
真实合规结论、税务与平台条款以各家官方要求为准,需真实业务环境,命令行不可复现(X)。
产出 JSON + CSV,全整数,无浮点。
"""
from __future__ import annotations

import argparse
import csv
import json
import os


def scan(cfg: dict) -> tuple[list, dict]:
    required = [it for it in cfg["items"] if it["required"]]
    req_ids = [it["id"] for it in required]
    req_total = len(req_ids)
    man_days = {it["id"]: it["manDays"] for it in required}
    rows, miss = [], {rid: 0 for rid in req_ids}
    for p in cfg["platforms"]:
        done = set(p["done"])
        blockers = [rid for rid in req_ids if rid not in done]
        for rid in blockers:
            miss[rid] += 1
        rows.append({"id": p["id"], "name": p["name"],
                     "doneRequired": req_total - len(blockers), "requiredTotal": req_total,
                     "blockers": blockers, "readinessPermille": (req_total - len(blockers)) * 1000 // req_total,
                     "ready": 1 if not blockers else 0})
    crit = sorted(miss.items(), key=lambda kv: (-kv[1], man_days[kv[0]], kv[0]))[0]
    done_sum = sum(r["doneRequired"] for r in rows)
    slots = req_total * len(rows)
    summary = {"requiredTotal": req_total, "slots": slots, "doneRequired": done_sum,
               "readinessPermille": done_sum * 1000 // slots, "criticalBlocker": crit[0],
               "criticalBlockerCount": crit[1],
               "readyPlatforms": [r["id"] for r in rows if r["ready"]],
               "blockedPlatforms": [r["id"] for r in rows if not r["ready"]]}
    return rows, summary


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, "g15_landing.json"), encoding="utf-8") as f:
        cfg = json.load(f)
    rows, s = scan(cfg)
    digest = (f'items={len(cfg["items"])}|required={s["requiredTotal"]}|slots={s["slots"]}'
              f'|done={s["doneRequired"]}|readiness={s["readinessPermille"]}'
              f'|blocker={s["criticalBlocker"]}|ready={"+".join(s["readyPlatforms"])}')
    os.makedirs(a.out, exist_ok=True)
    with open(os.path.join(a.out, "g15_landing.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["id", "name", "doneRequired", "requiredTotal", "readinessPermille", "ready", "blockers"])
        for r in rows:
            w.writerow([r["id"], r["name"], r["doneRequired"], r["requiredTotal"],
                        r["readinessPermille"], r["ready"], "|".join(r["blockers"])])
    rep = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
           "itemCount": len(cfg["items"]), "requiredTotal": s["requiredTotal"],
           "criticalBlocker": s["criticalBlocker"], "criticalBlockerCount": s["criticalBlockerCount"],
           "readinessPermille": s["readinessPermille"], "readyPlatforms": s["readyPlatforms"],
           "blockedPlatforms": s["blockedPlatforms"], "platforms": rows, "digest": digest,
           "csv": os.path.join(a.out, "g15_landing.csv")}
    with open(os.path.join(a.out, "g15_landing.json"), "w", encoding="utf-8") as f:
        json.dump(rep, f, ensure_ascii=False, indent=2)
    print(json.dumps({"digest": digest, "readinessPermille": s["readinessPermille"],
                      "criticalBlocker": s["criticalBlocker"], "readyPlatforms": s["readyPlatforms"],
                      "platforms": [{"id": r["id"], "doneRequired": r["doneRequired"],
                                     "readinessPermille": r["readinessPermille"],
                                     "blockers": r["blockers"]} for r in rows]},
                     ensure_ascii=False, indent=2))
    return 0


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

在本机跑 /opt/homebrew/bin/python3 sim/g15_landing.py --config config/ --out out/,结果(同机相对量级,绝对数值不可移植):

平台必做项完成就绪度‰阻塞项可送审
GameDistribution6/7857kids_coppa否
Poki7/71000—✅
CrazyGames6/7857tax_form否
Facebook Instant Games6/7857taboo_scan否
YouTube Playables6/7857kids_coppa否

五家里只有 Poki 通过门禁;其余四家各缺一项必做项,整体就绪度 885‰。注意两家缺的是同一个必做项 kids_coppa——这就是「关键阻塞项」的由来。

P 层内核:门禁的跨语言对拍

下面这段是 P 层 TypeScript 内核 core/src/g15/landing.ts:它读同一份 config,复算同一套就绪度与阻塞项,产出与 Python 逐字符一致的摘要。

// file: core/src/g15/landing.ts
// tier: P
// verify: node --test 'core/test/g15_*.test.ts'
//
// 本地化与合规落地门禁内核:与 sim/g15_landing.py 逐位等价的 TypeScript 对偶实现。
// 读同一份 config/g15_landing.json,把「清单项 × 平台」交叉成落地矩阵并给出提审门禁。
// 全整数、无浮点、无随机(禁 Math.random;只用可擦除语法,禁 enum / namespace)。

export interface ChecklistItem {
  readonly id: string; readonly name: string; readonly category: string;
  readonly required: boolean; readonly manDays: number;
}
export interface PlatformLanding { readonly id: string; readonly name: string; readonly done: readonly string[] }
export interface LandingConfig {
  readonly version: number; readonly game: string;
  readonly items: readonly ChecklistItem[]; readonly platforms: readonly PlatformLanding[];
}
export interface PlatformRow {
  readonly id: string; readonly name: string; readonly doneRequired: number;
  readonly requiredTotal: number; readonly blockers: readonly string[];
  readonly readinessPermille: number; readonly ready: boolean;
}
export interface LandingResult {
  readonly platforms: readonly PlatformRow[]; readonly requiredTotal: number; readonly slots: number;
  readonly doneRequired: number; readonly readinessPermille: number;
  readonly criticalBlocker: string; readonly criticalBlockerCount: number;
  readonly readyPlatforms: readonly string[]; readonly blockedPlatforms: readonly string[];
}

/** 必做项缺失最多的清单项即「关键阻塞项」;并列时取人天少的,再按 id 升序。 */
export function scan(cfg: LandingConfig): LandingResult {
  const required = cfg.items.filter((it) => it.required);
  const reqIds = required.map((it) => it.id);
  const manDays = new Map(required.map((it) => [it.id, it.manDays]));
  const miss = new Map(reqIds.map((rid) => [rid, 0]));
  const platforms: PlatformRow[] = cfg.platforms.map((p) => {
    const done = new Set(p.done);
    const blockers = reqIds.filter((rid) => !done.has(rid));
    for (const rid of blockers) miss.set(rid, miss.get(rid)! + 1);
    return {
      id: p.id, name: p.name, doneRequired: reqIds.length - blockers.length,
      requiredTotal: reqIds.length, blockers,
      readinessPermille: Math.floor(((reqIds.length - blockers.length) * 1000) / reqIds.length),
      ready: blockers.length === 0,
    };
  });
  const crit = [...miss.entries()].sort((x, y) =>
    (y[1] - x[1]) || (manDays.get(x[0])! - manDays.get(y[0])!) || (x[0] < y[0] ? -1 : 1))[0];
  const slots = reqIds.length * platforms.length;
  const doneSum = platforms.reduce((s, r) => s + r.doneRequired, 0);
  return {
    platforms, requiredTotal: reqIds.length, slots, doneRequired: doneSum,
    readinessPermille: Math.floor((doneSum * 1000) / slots),
    criticalBlocker: crit[0], criticalBlockerCount: crit[1],
    readyPlatforms: platforms.filter((r) => r.ready).map((r) => r.id),
    blockedPlatforms: platforms.filter((r) => !r.ready).map((r) => r.id),
  };
}

/** 跨语言对拍摘要:与 sim/g15_landing.py 的 digest 逐字符一致。 */
export function digest(cfg: LandingConfig, r: LandingResult): string {
  return `items=${cfg.items.length}|required=${r.requiredTotal}|slots=${r.slots}`
    + `|done=${r.doneRequired}|readiness=${r.readinessPermille}`
    + `|blocker=${r.criticalBlocker}|ready=${r.readyPlatforms.join("+")}`;
}

配套测试 core/test/g15_landing.test.ts 把 Python 落盘的摘要写成金标准。在本机跑 node --test 'core/test/g15_landing.test.ts',6 个用例全过;两套独立实现给出同一个摘要:

digest = items=9|required=7|slots=35|done=31|readiness=885|blocker=kids_coppa|ready=poki

只要有一边的必做项过滤、就绪度地板除或关键阻塞项排序写错,摘要立刻分叉。

先修哪一项:关键阻塞项

门禁给出的最有用的一个数字,是关键阻塞项——缺失平台数最多的必做项。当前是 kids_coppa(儿童向 COPPA 限制),它同时卡住 GameDistribution 与 YouTube Playables。修它一项,就解锁两个平台:

必做项阻塞平台数落地人天修完解锁
kids_coppa24GameDistribution、YouTube Playables
tax_form12CrazyGames
taboo_scan13Facebook Instant Games

这是「按阻塞面排序」而不是「按人天排序」的典型场景:tax_form 只要 2 人天,但它只解锁 1 家;kids_coppa 要 4 人天,却解锁 2 家。先修阻塞面最大的项,总解锁效率最高。修完这三项后,五平台全部可送审,整条出海流水线才真正跑起来。

X 层:真实合规结论不可复现

边界必须划清。上面算的是门禁模型:给定清单完成情况,每个平台能不能送审。下面这些属于 X 层,需真实业务环境,命令行不可复现:

  • 各平台真实的必做项清单(不同平台、不同地区、不同品类要求不同,且随条款更新);
  • 真实的合规结论(是否满足 GDPR/CCPA、是否触发 COPPA、税务表单是否正确受理),以各家官方要求与专业意见为准;
  • 真实的审核结果与驳回往返(清单全绿也可能被平台以条款外理由驳回)。

工程上正确的做法是:把可复现的门禁模型与不可复现的合规结论分开——门禁模型回答「还差哪几项」,合规结论由法务与平台官方判定。本节不提供任何法律意见。接好、送审之后,案例进入最后一段:五平台上线后的数据表现,以及据此决定往哪个平台加注。

自检表

检查项合格标准
清单可枚举本地化与合规项逐条列出,标注必做/可选
门禁唯一判定规则只有一条:必做项全绿才可送审
就绪度可算每平台给出就绪度千分比,可复算
阻塞可排序给出关键阻塞项与逐项阻塞平台数
跨语言一致Python 与 TS 两套实现的摘要逐字符相等
边界清晰真实合规结论标 X,不写成「实测」,不给法律意见

落地清单最反直觉的一点是:它不是一张勾选框,而是一张排序表。真正决定你多快能送审的,不是「还剩几项没做」,而是「先做哪一项能一次解锁最多平台」。下一节我们看五平台上线后的数据:谁赚钱、谁的样本够、该往哪加注。

阅读导航:上一节:15.1 平台组合与接入时间线 · 下一节:15.3 多平台数据表现与迭代 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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