8.3 小语种取舍

做十个语种的钱只够做三个,哪些小语种该做、哪些该砍?本节用可跑的 Python 仿真把每个候选语言的增量 DAU、月净收益、一次性成本与 ROI 算清楚,在预算约束下贪心选出组合,再用 B 层基准扰动收益假设,暴露取舍结论的脆弱性,附本机实跑输出。

站内那篇《游戏商业核心指标详解》 把 ARPDAU、LTV、ROI 这些指标定义得很清楚,本节不重定义它们,而是把它们套到「一个语种」这个决策单元上。上一节8.2 分语言活动与素材 默认所有语言都值得做,现实却相反:预算有限,语种必须取舍。这一节把「做不做某个小语种」变成一个可仿真的投入产出问题。收入情景建模的口径见卷一13.3 收入预测与情景分析 ,单语言回本周期见卷三3.2 LTV 与回本周期 ——这里做的是把两者合起来、加一个预算约束。

取舍要回答什么

「要不要做泰语」不是一个是非题,而是三个量相减:做它带来的增量收益,减去做它的一次性成本与持续维护成本,看净值为不为止。难点在于这三个量都不确定:增量 DAU 是估的、该语言 ARPDAU 是估的、翻译报价是估的。所以正确做法不是「算一个数」,而是算一张表 + 一个稳健性检查——表回答「性价比排序」,检查回答「这个排序经不经得起扰动」。

三个量与一条 ROI

对每个候选语言 L,把决策写成整数公式(金额统一用「分」):

增量 DAU          = totalDau × dauSharePermille ÷ 1000
月净收益          = 增量 DAU × 净ARPDAU × 30 ÷ 10        # 净ARPDAU 是 0.1 分单位
月净利            = 月净收益 − 月维护成本
窗口净增益        = 月净利 × 窗口月数 − 一次性成本
回本月数          = ⌈一次性成本 ÷ 月净利⌉
ROI‰              = 窗口净增益 × 1000 ÷ 一次性成本

预算分配按 ROI 降序贪心:预算从高到低花,谁性价比高谁先进。这里要区分两类落选——「性价比高但被预算挤出」(下次有钱就该补)与**「性价比本身就低」**(长期也不该做),它们的处置完全不同。

S 层:投入产出仿真

下面这段是 S 层仿真脚本 sim/g08_minor_lang.py。它读 config/g08_minor_lang.json,把每个候选语言的三个量算成 ROI,按预算贪心选出组合,产出 JSON + CSV + SVG。

#!/usr/bin/env python3
# file: sim/g08_minor_lang.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/g08_minor_lang.py --config config/ --out out/
"""小语种投入产出仿真:给定预算,选出性价比最高的候选语言。

对 config/g08_minor_lang.json 的每个候选语言 L:

    incrementalDau(L)      = totalDau * dauSharePermille // 1000
    monthlyNetFen(L)       = incrementalDau * netArpdauDeciFen * daysPerMonth // 10   # 0.1 分单位
    monthlyMarginFen(L)    = monthlyNetFen - monthlyMaintenanceFen
    netGainHorizonFen(L)   = monthlyMarginFen * horizonMonths - oneTimeCostFen
    paybackMonths(L)       = ceil(oneTimeCostFen / monthlyMarginFen)   # 上取整
    roiPermille(L)         = netGainHorizonFen * 1000 // oneTimeCostFen

预算分配:按 roiPermille 降序贪心选取,累计 oneTimeCostFen 不超 budgetFen 即入选。
「性价比高但被预算挤出」与「性价比低但便宜」是两类不同的落选,必须分开看。

全程整数、地板除(上取整用 (a+b-1)//b),无浮点。产出 out/g08_minor_lang.json + .csv + .svg。
平台参数是「公开可观察机制」口径的建模假设;真实报价与真实 ARPDAU 需真实业务环境,
命令行不可复现(X)。
"""

from __future__ import annotations

import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET


def ceil_div(a: int, b: int) -> int:
    return 0 if b == 0 else (a + b - 1) // b


def evaluate(cfg: dict) -> dict:
    rows = []
    for c in cfg["candidates"]:
        inc_dau = cfg["totalDau"] * c["dauSharePermille"] // 1000
        monthly_net = inc_dau * c["netArpdauDeciFen"] * cfg["daysPerMonth"] // 10
        monthly_margin = monthly_net - c["monthlyMaintenanceFen"]
        net_gain = monthly_margin * cfg["horizonMonths"] - c["oneTimeCostFen"]
        payback = ceil_div(c["oneTimeCostFen"], monthly_margin) if monthly_margin > 0 else 0
        roi = net_gain * 1000 // c["oneTimeCostFen"] if c["oneTimeCostFen"] else 0
        rows.append({
            "id": c["id"], "name": c["name"], "oneTimeCostFen": c["oneTimeCostFen"],
            "monthlyMaintenanceFen": c["monthlyMaintenanceFen"],
            "incrementalDau": inc_dau, "monthlyNetFen": monthly_net,
            "monthlyMarginFen": monthly_margin, "netGainHorizonFen": net_gain,
            "paybackMonths": payback, "roiPermille": roi,
        })
    ranked = sorted(rows, key=lambda r: (-r["roiPermille"], r["id"]))
    selected, spent, left = [], 0, cfg["budgetFen"]
    for r in ranked:
        if r["oneTimeCostFen"] <= left:
            selected.append(r["id"])
            left -= r["oneTimeCostFen"]
            spent += r["oneTimeCostFen"]
    for r in rows:
        r["selected"] = 1 if r["id"] in selected else 0
    return {"rows": rows, "roiRank": [r["id"] for r in ranked], "selected": selected,
            "spentFen": spent, "leftFen": left}


def draw_svg(path: str, res: dict) -> int:
    """纯 Python SVG:每候选语言一根 ROI 条,入选蓝、落选灰。"""
    rows = sorted(res["rows"], key=lambda r: -r["roiPermille"])
    W, ROW, LEFT, SPAN = 820, 36, 150, 560
    H = 78 + ROW * len(rows)
    root = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W),
                              "height": str(H), "viewBox": f"0 0 {W} {H}"})
    ET.SubElement(root, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
    ET.SubElement(root, "text", {"x": "16", "y": "26", "font-size": "15", "fill": "#111827"}).text = (
        f'《合成小岛》小语种取舍:入选 {len(res["selected"])} 个 · 花费 {res["spentFen"]} 分 · '
        f'余额 {res["leftFen"]} 分')
    ET.SubElement(root, "text", {"x": "16", "y": "46", "font-size": "12", "fill": "#6b7280"}).text = (
        f'ROI 排序 {res["roiRank"]}')
    hi = max([r["roiPermille"] for r in rows] + [1])
    for i, r in enumerate(rows):
        y = 66 + i * ROW
        ET.SubElement(root, "text", {"x": "16", "y": str(y + 18), "font-size": "12",
                                     "fill": "#374151"}).text = r["name"]
        w = SPAN * max(r["roiPermille"], 0) // hi
        color = "#2563eb" if r["selected"] else "#cbd5e1"
        ET.SubElement(root, "rect", {"x": str(LEFT), "y": str(y + 4), "width": str(w),
                                     "height": "16", "rx": "3", "fill": color})
        ET.SubElement(root, "text", {"x": str(LEFT + w + 6), "y": str(y + 17), "font-size": "11",
                                     "fill": "#6b7280"}).text = (
            f'{r["roiPermille"]}‰ · 成本 {r["oneTimeCostFen"]}'
            + ("" if r["selected"] else " · 落选"))
    os.makedirs(os.path.dirname(path), exist_ok=True)
    ET.ElementTree(root).write(path, encoding="utf-8", xml_declaration=True)
    return len(ET.tostring(root))


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    args = ap.parse_args()

    with open(os.path.join(args.config, "g08_minor_lang.json"), encoding="utf-8") as f:
        cfg = json.load(f)
    res = evaluate(cfg)

    digest = (f'candidates={len(res["rows"])}|roi={",".join(res["roiRank"])}|'
              f'selected={",".join(res["selected"])}|spent={res["spentFen"]}|left={res["leftFen"]}')

    os.makedirs(args.out, exist_ok=True)
    svg_bytes = draw_svg(os.path.join(args.out, "g08_minor_lang.svg"), res)
    report = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
              "totalDau": cfg["totalDau"], "horizonMonths": cfg["horizonMonths"],
              "budgetFen": cfg["budgetFen"], "candidates": res["rows"],
              "roiRank": res["roiRank"], "selected": res["selected"],
              "spentFen": res["spentFen"], "leftFen": res["leftFen"], "digest": digest,
              "svg": os.path.join(args.out, "g08_minor_lang.svg"), "svgBytes": svg_bytes}
    with open(os.path.join(args.out, "g08_minor_lang.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    with open(os.path.join(args.out, "g08_minor_lang.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        cols = ("id", "name", "oneTimeCostFen", "incrementalDau", "monthlyNetFen",
                "monthlyMarginFen", "netGainHorizonFen", "paybackMonths", "roiPermille", "selected")
        w.writerow(cols)
        for r in res["rows"]:
            w.writerow([r[k] for k in cols])

    print(json.dumps({"digest": digest, "roiRank": res["roiRank"], "selected": res["selected"],
                      "spentFen": res["spentFen"], "leftFen": res["leftFen"],
                      "candidates": [{k: r[k] for k in ("id", "oneTimeCostFen", "incrementalDau",
                                                        "monthlyNetFen", "monthlyMarginFen",
                                                        "netGainHorizonFen", "roiPermille", "selected")}
                                     for r in sorted(res["rows"], key=lambda r: -r["roiPermille"])]},
                     ensure_ascii=False, indent=2))
    return 0


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

参数放在 config/g08_minor_lang.json。这些数字是「公开可观察机制」口径的建模假设,不是任何供应商的真实报价:真实翻译报价、真实 ARPDAU 属于 X 层——需真实业务环境,命令行不可复现。

{
  "version": 1,
  "game": "merge-island",
  "observedAt": "2026-10",
  "note": "小语种投入产出参数。口径是「公开可观察机制」的建模假设,不是任何本地化供应商的实测报价。candidates[].oneTimeCostFen=一次性翻译+审校成本(分);monthlyMaintenanceFen=每月新增文案维护成本(分);dauSharePermille=该语言本地化后带来的新增 DAU 占组合总 DAU 的千分比;netArpdauDeciFen=该语言用户净 ARPDAU(0.1 分/DAU)。totalDau=组合总日活;budgetFen=一次性投入预算上限。真实报价与真实 ARPDAU 需真实业务环境,命令行不可复现(X)。全整数,无浮点。",
  "totalDau": 200000,
  "daysPerMonth": 30,
  "horizonMonths": 12,
  "budgetFen": 2000000,
  "candidates": [
    { "id": "ja-JP", "name": "日语", "oneTimeCostFen": 800000, "monthlyMaintenanceFen": 30000, "dauSharePermille": 90, "netArpdauDeciFen": 320 },
    { "id": "de-DE", "name": "德语", "oneTimeCostFen": 600000, "monthlyMaintenanceFen": 25000, "dauSharePermille": 60, "netArpdauDeciFen": 260 },
    { "id": "pt-BR", "name": "葡萄牙语(巴西)", "oneTimeCostFen": 450000, "monthlyMaintenanceFen": 18000, "dauSharePermille": 80, "netArpdauDeciFen": 180 },
    { "id": "th-TH", "name": "泰语", "oneTimeCostFen": 350000, "monthlyMaintenanceFen": 15000, "dauSharePermille": 25, "netArpdauDeciFen": 200 },
    { "id": "ar-SA", "name": "阿拉伯语(RTL)", "oneTimeCostFen": 900000, "monthlyMaintenanceFen": 40000, "dauSharePermille": 15, "netArpdauDeciFen": 240 },
    { "id": "vi-VN", "name": "越南语", "oneTimeCostFen": 300000, "monthlyMaintenanceFen": 12000, "dauSharePermille": 45, "netArpdauDeciFen": 150 }
  ]
}

在本机跑 /opt/homebrew/bin/python3 sim/g08_minor_lang.py --config config/ --out out/,6 个候选语言的投入产出(同机相对量级,绝对数值不可移植):

语言一次性成本增量 DAU月净利窗口净增益ROI‰入选
日语8000001800017250000206200000257750✅
葡语(巴西)450000160008622000103014000228920✅
德语600000120009335000111420000185700✅
越南语3000009000403800048156000160520❌
泰语3500005000298500035470000101342❌
阿拉伯语900000300021200002454000027266❌

预算 2000000 分按 ROI 降序贪心,选日语 + 葡语 + 德语(花 1850000,剩 150000)。这张表最有价值的两个落选:越南语 ROI 160520 排在第四、成本才 300000,却因为预算只剩 150000 被挤出——它是「下次有钱就该补」;阿拉伯语 ROI 只有 27266、成本却最高(900000)——它是「性价比本身就低,RTL 改造抬高了成本」。「被预算挤出」与「性价比低」必须分开处置,混为一谈就会砍错语种。摘要行:

digest = candidates=6|roi=ja-JP,pt-BR,de-DE,vi-VN,th-TH,ar-SA|selected=ja-JP,pt-BR,de-DE|spent=1850000|left=150000

B 层基准:扰动收益假设,看取舍稳不稳

上面那张表依赖一组建模假设(每个语言的 DAU 份额与 ARPDAU)。假设是估的,所以必须问:如果某个语言的收益估错了 20%,入选组合还成立吗? 下面这段是 B 层基准脚本 sim/g08_minor_lang_bench.py,它复用 S 层的 evaluate(),把每个候选语言的 dauSharePermille 与 netArpdauDeciFen 单独 ±20%,重算入选集合。

#!/usr/bin/env python3
# file: sim/g08_minor_lang_bench.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/g08_minor_lang_bench.py --config config/ --out out/
"""小语种取舍稳健性基准(B):扰动单个候选语言的 DAU 份额与 ARPDAU,看入选集合是否翻转。

复用 sim/g08_minor_lang.py 的 evaluate(),对每个候选语言单独把 dauSharePermille 与
netArpdauDeciFen 各 ±20%(800 / 1200‰),重算预算约束下的入选集合。软告警不硬失败:
若入选集合随扰动变化,说明「投哪些小语种」的结论对单个语言的收益假设敏感,应留缓冲、
并以多档位交叉验证。产出 out/g08_minor_lang_bench.json。
"""

from __future__ import annotations

import argparse
import importlib.util
import json
import os


def load_sim(path: str):
    spec = importlib.util.spec_from_file_location("g08_minor_lang", path)
    mod = importlib.util.module_from_spec(spec)
    assert spec.loader is not None
    spec.loader.exec_module(mod)
    return mod


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    args = ap.parse_args()
    with open(os.path.join(args.config, "g08_minor_lang.json"), encoding="utf-8") as f:
        base = json.load(f)
    sim = load_sim(os.path.join(os.path.dirname(os.path.abspath(__file__)), "g08_minor_lang.py"))

    base_res = sim.evaluate(base)
    rows = []
    for i, c in enumerate(base["candidates"]):
        for field in ("dauSharePermille", "netArpdauDeciFen"):
            for sc in (800, 1200):
                cfg = json.loads(json.dumps(base))
                cfg["candidates"][i][field] = c[field] * sc // 1000
                res = sim.evaluate(cfg)
                rows.append({"perturbed": c["id"], "field": field, "scalePermille": sc,
                             "selected": res["selected"], "spentFen": res["spentFen"]})

    sets = sorted({tuple(sorted(r["selected"])) for r in rows})
    base_set = sorted(base_res["selected"])
    stable = len(sets) == 1
    report = dict(version=base["version"], game=base["game"],
                  baseSelected=base_res["selected"], baseRoiRank=base_res["roiRank"],
                  rows=rows, distinctSelected=[list(s) for s in sets], stable=stable,
                  note="软告警:入选集合跨全部单参数 ±20% 扰动均不变,取舍结论才稳健。")
    os.makedirs(args.out, exist_ok=True)
    with open(os.path.join(args.out, "g08_minor_lang_bench.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    if not stable:
        print(f"WARN 入选集合随扰动漂移:{len(sets)} 种不同结果(软告警,不硬失败)")
    print(json.dumps({"baseSelected": base_set, "stable": stable,
                      "distinctSelected": [list(s) for s in sets],
                      "changedRows": [{"perturbed": r["perturbed"], "field": r["field"],
                                       "scalePermille": r["scalePermille"],
                                       "selected": sorted(r["selected"])}
                                      for r in rows if sorted(r["selected"]) != base_set]},
                     ensure_ascii=False, indent=2))
    return 0


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

在本机跑 /opt/homebrew/bin/python3 sim/g08_minor_lang_bench.py --config config/ --out out/,结果是软告警(B 层不硬失败):

WARN 入选集合随扰动漂移:2 种不同结果(软告警,不硬失败)

两种结果分别是基准的 {ja-JP, pt-BR, de-DE} 与 {ja-JP, pt-BR, th-TH, vi-VN}。翻转发生在两处:

  • 德语被下调 20%:它的 ROI 从 185700 掉到第三名之外,空出的预算让越南语与泰语一起挤进来——说明德语的入选并不牢靠;
  • 越南语被上调 20%:它的 ROI 冲到足够高,顶掉德语入选——说明「越南语被挤出」这个结论也可能反转。

两个方向的扰动都指向同一个脆弱点:德语与「越南语 + 泰语」这一组在预算线上咬得很近。所以正确的读法不是「基准选了日语+葡语+德语就照做」,而是:日语与葡语是稳的(任何扰动都在),第三席在德语与越南语之间摇摆,应该按实时报价再拍一次。B 层的意义正是把这个摇摆显式暴露,而不是假装组合是确定的。

X 层:真实报价与真实 ARPDAU 不可复现

上面算的是模型侧。下面这些属于 X 层,需真实业务环境,命令行不可复现:

  • 各语言的真实翻译与审校报价(按字数、语种稀缺度、供应商档位浮动);
  • 各语言的真实 ARPDAU 与新增 DAU 份额(受当地买量成本、竞品、平台推荐共同影响);
  • 持续维护的真实成本(每加一个活动就要多翻一批文案,长期成本常被低估)。

工程上正确的做法是:把可复现的决策层与不可复现的报价层分开——决策层固定「ROI + 预算贪心 + 稳健性检查」这套算法;报价层在拿到真实报价后反标定(把供应商报价与后台实测 ARPDAU 填回 config,排序立刻反映现实)。

自检表

检查项合格标准
三量齐全增量收益 / 一次性成本 / 维护成本都要算,不能只看收益
预算显式有明确预算上限,贪心选择可复现
落选分类「被预算挤出」与「性价比低」分开,不混为一谈
稳健性显式用 B 层扰动收益假设,暴露入选组合的脆弱性
全整数ROI 用千分比地板除,上取整用整数公式,无浮点
边界清晰真实报价 / ARPDAU / 维护成本标 X,不写成「实测」

小语种取舍最反直觉的一点是:它看起来是一个「算 ROI」的纯数学问题,实际上是一个「在不确定假设下做排序」的问题。答案不是一个数,而是「一个排序 + 一条稳健边界」。到这里,第 8 章的多语言运营就闭环了:先用分语言口径看清表现,再用分配矩阵排清缺口,最后用投入产出决定做哪几个语种。接下来的一章,我们把视角从「运营」转向「合规」——出海绕不开的数据合规工程落地。

阅读导航:上一节:8.2 分语言活动与素材 · 下一节:9.1 GDPR/CCPA 的工程落点 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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