8.2 分语言活动与素材

活动文案不能回退、素材缺版本可以回退,这两条不对称的规则决定了分语言运营的排期优先级。本节用一份可机读的活动与素材配置,把「活动 × 语言」叉乘成分配矩阵,并用 TypeScript 内核跨语言对拍,证明缺什么、回退到哪逐位一致,配本机实跑输出。

站内 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/43
英语(美国)5/5—4/40
日语2/5万圣夜、新春、夏日加速2/42
德语3/5春季祭典、新春2/42
泰语2/5春季祭典、万圣夜、新春1/43

摘要行给出全局规模: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 小语种取舍 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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