站内那篇《游戏商业核心指标详解》 把 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‰ | 入选 |
|---|---|---|---|---|---|---|
| 日语 | 800000 | 18000 | 17250000 | 206200000 | 257750 | ✅ |
| 葡语(巴西) | 450000 | 16000 | 8622000 | 103014000 | 228920 | ✅ |
| 德语 | 600000 | 12000 | 9335000 | 111420000 | 185700 | ✅ |
| 越南语 | 300000 | 9000 | 4038000 | 48156000 | 160520 | ❌ |
| 泰语 | 350000 | 5000 | 2985000 | 35470000 | 101342 | ❌ |
| 阿拉伯语 | 900000 | 3000 | 2120000 | 24540000 | 27266 | ❌ |
预算 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 的工程落点 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。