站内 game/industry 那批运营与社区内容
讲的是组织与流程口径——客服怎么排班、社区怎么运营、复盘怎么写。本节不碰这些,它做的是更下游的一件事:给定一批活动与素材,每个语言到底能上线什么。上一节8.1 分语言指标口径
让我们看清了各语言的表现,但看清之后必须按语言交付内容——而这正是出海运营最容易乱掉的地方:活动改了文案、素材缺了版本,落到某个小语种就是一片空白。
活动与素材的两条不对称规则
分语言运营的核心,是两条不对称的规则:
- 活动文案不可回退。一个叫「新春」的活动,如果日语玩家看到的是英文文案,体验是断裂的。所以活动要么有该语言的完整文案,要么该语言就不上线——缺即缺失,没有中间态。
- 素材可以回退。一张主视觉如果没有泰语版,展示英文版通常可接受——玩家看的是一张图,不是一段文字。所以素材缺版本时回退到基准语言,算「降级」而非「缺失」。
这两条规则决定了排期优先级:先补活动文案的缺口(硬缺失),再补素材版本(软降级)。把两者混在一起,就会得出「素材缺了 10 个版本很严重」这种错判——其实那 10 个只是回退,玩家照样能玩。
D 层:把分配做成可跑的矩阵
下面这段是 D 层校验脚本 sim/g08_event_creative.py。它读 config/g08_event_creative.json,把「活动 / 素材」× 「语言」叉乘成一张分配矩阵:算活动可上线数(缺即缺失)、素材本地化数与回退数,并逐语言给出覆盖率与缺失清单,产出 JSON + CSV。
#!/usr/bin/env python3
# file: sim/g08_event_creative.py
# tier: D
# verify: /opt/homebrew/bin/python3 sim/g08_event_creative.py --config config/ --out out/
"""分语言活动与素材分配校验(D 层:清单结构 / lint 校验)。
读 config/g08_event_creative.json,把「活动 / 素材」× 「语言」叉乘成一张分配矩阵:
活动:语言 L 可上线 activity 当且仅当 L ∈ activity.langs(活动文案不可回退,缺即缺失)
eventSlots = events × languages
eventCovered = Σ_event |event.langs ∩ languages|
eventMissing = eventSlots - eventCovered
素材:语言 L 展示 localized 变体当且仅当 L ∈ creative.variants,否则回退 baseLanguage
creativeSlots = creatives × languages
creativeLocalized = Σ_creative |creative.variants ∩ languages|
creativeFallback = creativeSlots - creativeLocalized
逐语言再算覆盖率(千分比,整数地板除)与缺失清单。口径是「公开可观察机制」的建模假设,
真实译文与素材由供应商交付,命令行不可复现(X)。产出 out/g08_event_creative.json + .csv。
全整数,无浮点,无随机。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
def permille(num: int, den: int) -> int:
return 0 if den == 0 else num * 1000 // den
def scan(cfg: dict) -> dict:
langs = [L["id"] for L in cfg["languages"]]
events = cfg["events"]
creatives = cfg["creatives"]
n, e, c = len(langs), len(events), len(creatives)
event_slots = e * n
event_covered = sum(len(set(ev["langs"]) & set(langs)) for ev in events)
creative_slots = c * n
creative_localized = sum(len(set(cr["variants"]) & set(langs)) for cr in creatives)
per_lang = []
for lid in langs:
ev_ok = [ev["id"] for ev in events if lid in ev["langs"]]
ev_missing = [ev["id"] for ev in events if lid not in ev["langs"]]
cr_localized = [cr["id"] for cr in creatives if lid in cr["variants"]]
cr_fallback = [cr["id"] for cr in creatives if lid not in cr["variants"]]
per_lang.append({
"id": lid, "eventsAvailable": len(ev_ok), "eventsMissing": len(ev_missing),
"eventCoveragePermille": permille(len(ev_ok), e),
"missingEvents": ev_missing,
"creativesLocalized": len(cr_localized), "creativesFallback": len(cr_fallback),
"creativeCoveragePermille": permille(len(cr_localized), c),
"fallbackCreatives": cr_fallback,
})
return {"perLanguage": per_lang, "eventSlots": event_slots, "eventCovered": event_covered,
"eventMissing": event_slots - event_covered, "creativeSlots": creative_slots,
"creativeLocalized": creative_localized,
"creativeFallback": creative_slots - creative_localized}
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, "g08_event_creative.json"), encoding="utf-8") as f:
cfg = json.load(f)
res = scan(cfg)
digest = (f'langs={len(cfg["languages"])},events={len(cfg["events"])},'
f'eventSlots={res["eventSlots"]},eventCovered={res["eventCovered"]},'
f'eventMissing={res["eventMissing"]},creatives={len(cfg["creatives"])},'
f'creativeSlots={res["creativeSlots"]},creativeLocalized={res["creativeLocalized"]},'
f'creativeFallback={res["creativeFallback"]}')
os.makedirs(a.out, exist_ok=True)
with open(os.path.join(a.out, "g08_event_creative.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["id", "eventsAvailable", "eventsMissing", "eventCoveragePermille",
"creativesLocalized", "creativesFallback", "creativeCoveragePermille"])
for r in res["perLanguage"]:
w.writerow([r["id"], r["eventsAvailable"], r["eventsMissing"],
r["eventCoveragePermille"], r["creativesLocalized"],
r["creativesFallback"], r["creativeCoveragePermille"]])
rep = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
"baseLanguage": cfg["baseLanguage"], "languageCount": len(cfg["languages"]),
"eventCount": len(cfg["events"]), "creativeCount": len(cfg["creatives"]),
"eventSlots": res["eventSlots"], "eventCovered": res["eventCovered"],
"eventMissing": res["eventMissing"], "creativeSlots": res["creativeSlots"],
"creativeLocalized": res["creativeLocalized"], "creativeFallback": res["creativeFallback"],
"perLanguage": res["perLanguage"], "digest": digest,
"csv": os.path.join(a.out, "g08_event_creative.csv")}
with open(os.path.join(a.out, "g08_event_creative.json"), "w", encoding="utf-8") as f:
json.dump(rep, f, ensure_ascii=False, indent=2)
print(json.dumps({"digest": digest, "eventSlots": res["eventSlots"],
"eventCovered": res["eventCovered"], "eventMissing": res["eventMissing"],
"creativeSlots": res["creativeSlots"],
"creativeLocalized": res["creativeLocalized"],
"creativeFallback": res["creativeFallback"],
"perLanguage": [{k: r[k] for k in ("id", "eventsAvailable", "eventsMissing",
"creativesLocalized", "creativesFallback")}
for r in res["perLanguage"]]},
ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
活动与素材都放进配置。活动模板与奖励预算的口径见卷三12.1 活动模板与奖励预算 ,这里只关心「有没有本地化版本」:
{
"version": 1,
"game": "merge-island",
"observedAt": "2026-10",
"note": "分语言活动与素材配置。口径是「公开可观察机制」的建模假设,不是任何平台后台的实测。baseLanguage=基准语言(素材缺失时的回退目标);events[].langs=该活动已有本地化文案的语言集合(活动文案不可回退,缺即不可上线);events[].rewardBudgetCoins=该活动单用户奖励预算(金币);creatives[].variants=该素材已有本地化版本的语言集合;creatives[].fallback=缺版本时展示的基准语言。真实译文与素材由供应商交付,命令行不可复现(X)。全整数,无浮点。",
"baseLanguage": "en-US",
"languages": [
{ "id": "zh-CN", "name": "简体中文" },
{ "id": "en-US", "name": "英语(美国)" },
{ "id": "ja-JP", "name": "日语" },
{ "id": "de-DE", "name": "德语" },
{ "id": "th-TH", "name": "泰语" }
],
"events": [
{ "id": "spring_festival", "name": "春季祭典", "langs": ["zh-CN", "en-US", "ja-JP"], "rewardBudgetCoins": 5000 },
{ "id": "summer_boost", "name": "夏日加速", "langs": ["en-US", "de-DE", "th-TH"], "rewardBudgetCoins": 4000 },
{ "id": "halloween", "name": "万圣夜", "langs": ["en-US", "de-DE"], "rewardBudgetCoins": 3500 },
{ "id": "anniversary", "name": "周年庆", "langs": ["zh-CN", "en-US", "ja-JP", "de-DE", "th-TH"], "rewardBudgetCoins": 8000 },
{ "id": "lunar_new_year", "name": "新春", "langs": ["zh-CN", "en-US"], "rewardBudgetCoins": 6000 }
],
"creatives": [
{ "id": "cr_hero", "name": "主视觉", "variants": ["en-US", "ja-JP", "de-DE", "th-TH"], "fallback": "en-US" },
{ "id": "cr_spring", "name": "春季素材", "variants": ["zh-CN", "en-US", "ja-JP"], "fallback": "en-US" },
{ "id": "cr_summer", "name": "夏日素材", "variants": ["en-US", "de-DE"], "fallback": "en-US" },
{ "id": "cr_banner", "name": "通用横幅", "variants": ["en-US"], "fallback": "en-US" }
]
}
在本机跑 /opt/homebrew/bin/python3 sim/g08_event_creative.py --config config/ --out out/,5 个活动 × 5 个语言、4 个素材 × 5 个语言的分配结果是(同机相对量级):
| 语言 | 活动可上线/总 | 活动缺失 | 素材本地化/总 | 素材回退 |
|---|---|---|---|---|
| 简体中文 | 3/5 | 夏日加速、万圣夜 | 1/4 | 3 |
| 英语(美国) | 5/5 | — | 4/4 | 0 |
| 日语 | 2/5 | 万圣夜、新春、夏日加速 | 2/4 | 2 |
| 德语 | 3/5 | 春季祭典、新春 | 2/4 | 2 |
| 泰语 | 2/5 | 春季祭典、万圣夜、新春 | 1/4 | 3 |
摘要行给出全局规模:25 个活动槽位里覆盖 15、缺 10;20 个素材槽位里本地化 10、回退 10。
digest = langs=5,events=5,eventSlots=25,eventCovered=15,eventMissing=10,creatives=4,creativeSlots=20,creativeLocalized=10,creativeFallback=10
这张表把「硬缺失」与「软降级」分得清清楚楚:英语满覆盖是基准,日语缺 3 个活动(最该补),泰语缺 3 个活动但只有 1 个素材版本(素材几乎全靠回退)。排期上,先补日语的「万圣夜」「新春」「夏日加速」文案,再回头做泰语素材——因为活动缺失是玩家看不到内容,素材回退只是看到英文图。
P 层内核:跨语言对拍
一张表看不出判定对不对。本套用一份 P 层 TypeScript 内核 core/src/g08/event_creative.ts 复算同一套叉乘:同样的「活动缺即缺失」「素材缺则回退」、同样的覆盖率地板除。它读同一份 config/g08_event_creative.json,产出与 Python 逐字符一致的摘要。这与卷二「一份逻辑、两种宿主」
是同一个信念:同一份配置,在 Python 校验器与 TS 内核里必须判得一模一样。
// file: core/src/g08/event_creative.ts
// tier: P
// verify: node --test 'core/test/g08_*.test.ts'
//
// 分语言活动与素材分配内核:与 sim/g08_event_creative.py 逐位等价的 TypeScript 对偶实现。
// 读同一份 config/g08_event_creative.json;全整数、无浮点、无随机、禁 Math.random。
// 把「活动 / 素材」× 「语言」叉乘成一张分配矩阵(活动缺即缺失,素材缺则回退基准语言)。
export interface Lang { readonly id: string; readonly name: string }
export interface EventDef {
readonly id: string; readonly name: string;
readonly langs: readonly string[]; readonly rewardBudgetCoins: number;
}
export interface CreativeDef {
readonly id: string; readonly name: string;
readonly variants: readonly string[]; readonly fallback: string;
}
export interface EcConfig {
readonly version: number; readonly game: string; readonly baseLanguage: string;
readonly languages: readonly Lang[];
readonly events: readonly EventDef[];
readonly creatives: readonly CreativeDef[];
}
export interface LangRow {
readonly id: string; readonly eventsAvailable: number; readonly eventsMissing: number;
readonly eventCoveragePermille: number; readonly missingEvents: readonly string[];
readonly creativesLocalized: number; readonly creativesFallback: number;
readonly creativeCoveragePermille: number; readonly fallbackCreatives: readonly string[];
}
export interface ScanResult {
readonly perLanguage: readonly LangRow[];
readonly eventSlots: number; readonly eventCovered: number; readonly eventMissing: number;
readonly creativeSlots: number; readonly creativeLocalized: number; readonly creativeFallback: number;
}
export function permille(num: number, den: number): number {
return den === 0 ? 0 : Math.floor((num * 1000) / den);
}
function intersectCount(a: readonly string[], b: readonly string[]): number {
const bs = new Set(b);
let n = 0;
for (const x of new Set(a)) if (bs.has(x)) n += 1;
return n;
}
/** 扫描整张分配矩阵:逐语言求活动可上线数、素材本地化数与回退数。 */
export function scan(cfg: EcConfig): ScanResult {
const langs = cfg.languages.map((l) => l.id);
const n = langs.length;
const e = cfg.events.length;
const c = cfg.creatives.length;
const eventSlots = e * n;
let eventCovered = 0;
for (const ev of cfg.events) eventCovered += intersectCount(ev.langs, langs);
const creativeSlots = c * n;
let creativeLocalized = 0;
for (const cr of cfg.creatives) creativeLocalized += intersectCount(cr.variants, langs);
const perLanguage: LangRow[] = langs.map((lid) => {
const evOk = cfg.events.filter((ev) => ev.langs.includes(lid));
const evMissing = cfg.events.filter((ev) => !ev.langs.includes(lid)).map((ev) => ev.id);
const crLocal = cfg.creatives.filter((cr) => cr.variants.includes(lid));
const crFallback = cfg.creatives.filter((cr) => !cr.variants.includes(lid)).map((cr) => cr.id);
return {
id: lid, eventsAvailable: evOk.length, eventsMissing: evMissing.length,
eventCoveragePermille: permille(evOk.length, e), missingEvents: evMissing,
creativesLocalized: crLocal.length, creativesFallback: crFallback.length,
creativeCoveragePermille: permille(crLocal.length, c), fallbackCreatives: crFallback,
};
});
return { perLanguage, eventSlots, eventCovered, eventMissing: eventSlots - eventCovered,
creativeSlots, creativeLocalized, creativeFallback: creativeSlots - creativeLocalized };
}
/** 跨语言对拍摘要:与 sim/g08_event_creative.py 的 digest 逐字符一致。 */
export function digest(cfg: EcConfig): string {
const r = scan(cfg);
return `langs=${cfg.languages.length},events=${cfg.events.length},`
+ `eventSlots=${r.eventSlots},eventCovered=${r.eventCovered},eventMissing=${r.eventMissing},`
+ `creatives=${cfg.creatives.length},creativeSlots=${r.creativeSlots},`
+ `creativeLocalized=${r.creativeLocalized},creativeFallback=${r.creativeFallback}`;
}
/** 某语言缺哪些活动(活动文案不可回退)。 */
export function missingEvents(cfg: EcConfig, langId: string): string[] {
return cfg.events.filter((ev) => !ev.langs.includes(langId)).map((ev) => ev.id).sort();
}
配套测试 core/test/g08_event_creative.test.ts 把 Python 落盘的摘要写成金标准,断言 TS 内核复算结果逐字符相等,并验「活动不可回退、素材可回退」。在本机跑 node --test 'core/test/g08_event_creative.test.ts',六条全绿:
✔ 跨语言一致:TS 分配矩阵摘要 == Python 金标准
✔ 活动:文案不可回退,缺即缺失
✔ 素材:缺版本回退基准语言,不缺失
✔ 逐语言覆盖:活动与素材覆盖率逐位可复算
✔ 基准语言满覆盖:活动与素材都不缺
✔ 地板除:覆盖率用千分比整数地板除,无浮点
ℹ tests 6 / pass 6 / fail 0
只要有一边的「活动是否回退」「素材是否回退」「交集是否去重」写错,摘要立刻分叉。同一份配置,两条流水线判得一模一样,这就是分语言交付能自动化的前提。
X 层:真实译文与素材不可复现
上面算的是结构侧:给定活动与素材的语言集合,每个语言能上线什么。下面这些属于 X 层,需真实业务环境,命令行不可复现:
- 各语言的真实译文质量(由译者与母语审校决定,第 7 章展开);
- 各语言素材的真实交付节奏(供应商批次、美术产能各不相同);
- 平台对回退素材的真实容忍度(少数平台要求全语言本地化素材,否则不予推荐位)。
工程上正确的做法是:把可复现的分配层与不可复现的交付层分开——分配层保证「缺什么、回退到哪」一目了然;交付层靠供应商的真实排期反标定(补一批译文就把 langs/variants 填回 config,矩阵立刻更新)。
自检表
| 检查项 | 合格标准 |
|---|---|
| 两规则分离 | 活动不可回退、素材可回退,缺失与降级分开统计 |
| 优先级明确 | 先补活动硬缺失,再补素材软降级 |
| 跨语言一致 | Python 校验器与 TS 内核摘要逐字符相同 |
| 全整数 | 覆盖率用千分比地板除,无浮点 |
| 边界清晰 | 真实译文 / 素材 / 平台容忍度标 X,不写成「实测」 |
分配矩阵把「每个语言能上什么」讲清楚了,但它默认所有语言都值得做。现实是预算有限——做十个语种的钱只够做三个。哪些小语种该做、哪些该砍,不能拍脑袋。下一节我们把「投入产出」算成一张可仿真的表,并用软告警基准暴露它的脆弱性。
阅读导航:上一节:8.1 分语言指标口径 · 下一节:8.3 小语种取舍 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。