事故:分级标签改了,资源没换
《潮汐方舟》同一份构建发往六个商店区域。德文版本更新时,运营只改了商店页的分级标签,客户端里的血液特效与断肢模型没有替换;韩国版本则因为区域开关按设备语言判断,部分英文系统的韩国玩家被分到了跨服池。两次问题都不是「翻译错了」,而是把商店区域、内容分级、服务区域与语言当成了同一个变量。
区域化的第一原则是拆轴:商店区域决定在哪个货架上架,服务区域决定连哪组服务器,内容分级决定能展示什么资源与功能,语言决定玩家读到什么文字。 四个轴可以相关,但不能互相推导。把「设备语言是韩文」当成「玩家在韩国商店」,或者把「商店区域是美国」当成「内容分级一定是 ESRB」,都会在边界玩家身上出错。
四个轴:商店、服务、分级、语言
| 轴 | 取值示例 | 决定什么 | 不能从什么推导 |
|---|---|---|---|
| 商店区域 | us、jp、kr、de、sea、cn | 上架、定价、分级机构、支付渠道 | 不能从设备语言或 IP 推导 |
| 服务区域 | na、eu、apac、cn | 延迟、数据驻留、跨服匹配 | 不能从商店区域直接推导 |
| 内容分级 | ESRB、CERO、GRAC、USK、未分级 | 资源变体、披露、聊天与购买限制 | 不能只看商店页标签 |
| 语言 | en、ja、ko、de、ar、zh-Hans | 文本、字体、排版方向 | 不能从商店区域唯一推导 |
商店区域通常由商店回执或服务端下发的区域令牌确定,客户端不能自己声明。服务区域由账号与网络调度决定,和商店区域可以不同:一个美国商店的账号可能在亚太服游戏。内容分级是发行与法务确认后的配置,客户端只消费结论。语言是玩家偏好,应该允许在设置里独立选择,并持久化到账号。
四个轴分开后,配置里就不该出现一个包打天下的 region 字段。至少要有 storeRegion、serviceRegion、ratingProfile 与 locale 四个独立输入,缺哪个就显式回退,而不是拿另一个顶上。
语言与区域解耦
美国商店的玩家可以选择日文,日本商店的玩家也可能用英文。区域配置声明「这个商店允许哪些语言」,语言解析在该集合内做回退:玩家选 ja 但商店只提供 en 与 es 时,落到 en 并记录 locale-not-offered;玩家选 zh-Hant 而区域只提供 zh-Hans 时,沿语言回退链落到 zh-Hans。回退不能静默,否则玩家会以为设置没保存。
不要把系统语言当成唯一输入。首次启动可以用系统语言做推荐,但玩家在游戏内改过语言后,以账号设置为准;换设备、重装、跨区旅行都不应该把选择重置。语言选择与 11.1 的 key、回退链、字体预算直接相连:新增一个可选语言,等于新增一套 key 校验、字体子集与排版回归。
阿拉伯文还带来方向问题。语言是 RTL 时,界面方向、列表起点与弹窗按钮顺序要镜像,但数字、价格、品牌标识不镜像;这部分在 11.1 已给出模型,本节只把它作为区域配置的一个输出字段。
内容分级:标签只是结果
分级机构给出的是结论,客户端要把它翻译成可执行的资源与功能约束。
| 分级输入 | 工程输出 | 失败后果 |
|---|---|---|
| 资源变体 | 血液颜色、断肢模型、恐怖音效替换 | 商店合规风险与下架 |
| 概率披露 | 付费随机箱的概率入口与展示 | 玩家投诉与监管问询 |
| 聊天过滤 | 标准或严格词库、举报入口 | 社区内容风险 |
| 购买限制 | 未成年账号限额、监护人确认 | 退款与合规风险 |
| 年龄门槛 | 注册与活动准入 | 分级标签与实物不一致 |
模型把「付费随机箱」做成组合门:区域开关打开还不够,必须同时满足概率披露与购买限制,否则 featureDecision 返回 disclosure-missing 或 purchase-limit-missing。这样运营无法只改一个开关就把功能放出去。资源变体则作为分级配置的一部分,和基础包版本锁定;只改商店页标签而不换资源,正是开头事故的成因。
这里只讨论工程接口,不提供法律意见。具体分级结论、概率披露形式、未成年保护与退款规则,必须以当地机构要求和法务意见为准;客户端能做的是让配置无法绕过这些结论。内购与商店合规的工程侧会在 12.x 继续展开。
区域资源包:基础、语言与覆盖层
区域化资源分成三层:基础包、语言包、区域覆盖包。基础包放所有区域共用的模型、场景与通用 UI;语言包放 11.1 的文本与字体子集;区域覆盖包只替换分级相关的贴图、音效、图标与少量配置。三层分开,才能让同一份基础包发往多个商店,而不必为每个区域打一份全量包。
| 层级 | 内容 | 更新频率 | 应用要求 |
|---|---|---|---|
| 基础包 | 通用模型、场景、UI | 跟随版本 | 首包或首启下载 |
| 语言包 | 文本目录、字体子集 | 跟随翻译与活动 | 首帧前完成关键路径 |
| 区域覆盖包 | 分级资源、区域图标、披露配置 | 跟随发行与合规变更 | 原子应用,不可半套 |
区域覆盖包必须原子应用:要么整套生效,要么保持上一套,不能出现「血是灰色但断肢还在」的半套状态。下载完成后校验版本与哈希,再切换资源集;切换失败回滚到上一套并上报。覆盖包与基础包版本锁定,基础包升级时覆盖包必须一起升级,否则旧覆盖可能引用不存在的资源。
方舟案例设定首包 350MB、总资源 1.8GB,区域覆盖包相对很小,但它的失败代价最高,因为它直接对应合规结论。消消案例设定首包 120MB、总资源 480MB,区域差异更多体现在活动与广告素材,覆盖层更薄,但同样需要原子应用。
区域开关与配置版本
区域开关应该是服务端下发的配置,不是客户端里的 if (region === "kr")。客户端硬编码区域分支,新增区域要发版,紧急关闭功能要等审核,且无法审计「谁在什么时候改了开关」。
配置至少包含:功能名、开关值、适用区域、生效版本、原因与过期时间。客户端只执行,不推断。开关关闭时返回明确原因(disabled、not-configured、disclosure-missing),而不是统一的 false;原因会进入日志与客服工具,减少「为什么我不能用」的排查成本。配置表的结构与版本化可以参考校验、引用完整性与灰度
。
区域配置还要有 kill switch:合规资源异常、支付通道故障、聊天事故时,能只关闭受影响区域的功能,而不影响其他区域。kill switch 的优先级高于普通开关,且必须有审计记录。
隐私与数据出境:只给接口边界
客户端不决定数据能否出境,也不解释法律。它只实现三个接口:同意管理、导出请求、删除请求,以及一个保守的传输判定。
- 同意管理:区分服务必需与营销、个性化等可选目的;可选目的默认关闭,拒绝不影响基础服务。
- 导出与删除:提供账号级入口,返回请求编号与处理状态,不在客户端拼接数据。
- 传输判定:同区域返回
allow,跨区域默认返回review,由服务端策略与法务结论决定,不由客户端放行。
模型里的 createPrivacyBoundary 就是这层接口:它不包含任何法律判断,只保证客户端不会把「未确认」当成「允许」。GDPR/CCPA 的工程侧接口可以参考GDPR 与 CCPA 工程化
,具体适用与文本仍以法务为准。
可运行模型:region.ts
下面的模型把四个轴的解析、语言回退、分级门禁、三层资源包组合、配置校验与隐私边界放在同一个文件里。区域配置由调用方注入,模型不依赖真实商店回执。
// file: src/i18n/region.ts
// tier: L1
// verify: node --test test/i18n/region.test.ts
import type { Direction } from "./catalog.ts";
import { LOCALES, fallbackChain } from "./catalog.ts";
export type StoreRegion = "us" | "jp" | "kr" | "de" | "sea" | "cn";
export type ServiceRegion = "na" | "eu" | "apac" | "cn";
export type RatingBoard = "ESRB" | "CERO" | "GRAC" | "USK" | "none";
export type AssetVariant = "default" | "reduced-violence" | "no-blood";
export interface RatingProfile {
readonly board: RatingBoard; readonly minAge: number; readonly assetVariant: AssetVariant;
readonly probabilityDisclosure: boolean; readonly chatFilter: "standard" | "strict";
readonly purchaseLimit: "none" | "minor-account";
}
export interface RegionConfig {
readonly storeRegion: StoreRegion; readonly serviceRegion: ServiceRegion;
readonly rating: RatingProfile; readonly locales: readonly string[];
readonly defaultLocale: string; readonly features: Readonly<Record<string, boolean>>;
readonly privacyProfile: string;
}
export const REGION_CONFIGS: Readonly<Record<StoreRegion, RegionConfig>> = {
us: {
storeRegion: "us", serviceRegion: "na",
rating: { board: "ESRB", minAge: 13, assetVariant: "default", probabilityDisclosure: true, chatFilter: "standard", purchaseLimit: "minor-account" },
locales: ["en", "es"], defaultLocale: "en",
features: { "paid-random-box": true, chat: true, crossPlay: true }, privacyProfile: "us-default",
},
jp: {
storeRegion: "jp", serviceRegion: "apac",
rating: { board: "CERO", minAge: 15, assetVariant: "reduced-violence", probabilityDisclosure: true, chatFilter: "standard", purchaseLimit: "minor-account" },
locales: ["ja", "en"], defaultLocale: "ja",
features: { "paid-random-box": true, chat: true, crossPlay: true }, privacyProfile: "jp-default",
},
kr: {
storeRegion: "kr", serviceRegion: "apac",
rating: { board: "GRAC", minAge: 15, assetVariant: "reduced-violence", probabilityDisclosure: true, chatFilter: "strict", purchaseLimit: "minor-account" },
locales: ["ko", "en"], defaultLocale: "ko",
features: { "paid-random-box": true, chat: true, crossPlay: false }, privacyProfile: "kr-default",
},
de: {
storeRegion: "de", serviceRegion: "eu",
rating: { board: "USK", minAge: 16, assetVariant: "no-blood", probabilityDisclosure: true, chatFilter: "standard", purchaseLimit: "minor-account" },
locales: ["de", "en"], defaultLocale: "de",
features: { "paid-random-box": true, chat: true, crossPlay: true }, privacyProfile: "eu-default",
},
sea: {
storeRegion: "sea", serviceRegion: "apac",
rating: { board: "none", minAge: 13, assetVariant: "default", probabilityDisclosure: false, chatFilter: "standard", purchaseLimit: "none" },
locales: ["en", "zh-Hans"], defaultLocale: "en",
features: { "paid-random-box": false, chat: true, crossPlay: true }, privacyProfile: "sea-default",
},
cn: {
storeRegion: "cn", serviceRegion: "cn",
rating: { board: "none", minAge: 12, assetVariant: "reduced-violence", probabilityDisclosure: true, chatFilter: "strict", purchaseLimit: "minor-account" },
locales: ["zh-Hans"], defaultLocale: "zh-Hans",
features: { "paid-random-box": true, chat: true, crossPlay: false }, privacyProfile: "cn-default",
},
};
export type RegionIssueKind =
| "unknown-region" | "locale-not-offered" | "unknown-locale"
| "rating-board-mismatch" | "disclosure-missing" | "purchase-limit-missing"
| "privacy-profile-missing" | "default-locale-missing";
export interface RegionIssue { readonly kind: RegionIssueKind; readonly detail: string }
const EXPECTED_BOARD: Readonly<Partial<Record<StoreRegion, RatingBoard>>> = {
us: "ESRB", jp: "CERO", kr: "GRAC", de: "USK",
};
export interface RegionResolution {
readonly config: RegionConfig; readonly locale: string; readonly direction: Direction;
readonly requestedLocale: string; readonly languageFallback: boolean;
readonly issues: readonly RegionIssue[];
}
export function resolveRegion(
configs: Readonly<Record<StoreRegion, RegionConfig>>, storeRegion: string,
requestedLocale: string, fallbackRegion: StoreRegion = "us",
): RegionResolution {
const issues: RegionIssue[] = [];
const config = configs[storeRegion as StoreRegion] ?? configs[fallbackRegion];
if (config === undefined) throw new Error(`no region config for ${storeRegion} and fallback ${fallbackRegion}`);
if (configs[storeRegion as StoreRegion] === undefined) {
issues.push({ kind: "unknown-region", detail: `${storeRegion} -> ${fallbackRegion}` });
}
if (LOCALES[requestedLocale] === undefined) issues.push({ kind: "unknown-locale", detail: requestedLocale });
let locale = requestedLocale;
let languageFallback = false;
if (!config.locales.includes(requestedLocale)) {
locale = fallbackChain(requestedLocale).find((item) => config.locales.includes(item)) ?? config.defaultLocale;
languageFallback = true;
issues.push({ kind: "locale-not-offered", detail: `${requestedLocale} -> ${locale}` });
}
return {
config, locale, direction: LOCALES[locale]?.direction ?? "ltr",
requestedLocale, languageFallback, issues,
};
}
export interface FeatureDecision {
readonly enabled: boolean;
readonly reason: "enabled" | "not-configured" | "disabled" | "disclosure-missing" | "purchase-limit-missing";
}
export function featureDecision(config: RegionConfig, feature: string): FeatureDecision {
const value = config.features[feature];
if (value === undefined) return { enabled: false, reason: "not-configured" };
if (!value) return { enabled: false, reason: "disabled" };
if (feature === "paid-random-box" && !config.rating.probabilityDisclosure) {
return { enabled: false, reason: "disclosure-missing" };
}
if (feature === "paid-random-box" && config.rating.purchaseLimit === "none") {
return { enabled: false, reason: "purchase-limit-missing" };
}
return { enabled: true, reason: "enabled" };
}
export interface BundleAsset {
readonly id: string; readonly tier: "base" | "language" | "region";
readonly bytes: number; readonly locale?: string; readonly storeRegion?: StoreRegion;
}
export interface BundlePlan {
readonly ids: readonly string[]; readonly totalBytes: number;
readonly byTier: Readonly<Record<"base" | "language" | "region", readonly string[]>>;
readonly missing: readonly string[];
}
export function planBundles(config: RegionConfig, locale: string, assets: readonly BundleAsset[]): BundlePlan {
const base = assets.filter((asset) => asset.tier === "base");
const language = assets.find((asset) => asset.tier === "language" && asset.locale === locale);
const region = assets.find((asset) => asset.tier === "region" && asset.storeRegion === config.storeRegion);
const missing: string[] = [];
if (language === undefined) missing.push(`language:${locale}`);
if (region === undefined) missing.push(`region:${config.storeRegion}`);
const selected = [...base, ...(language ? [language] : []), ...(region ? [region] : [])];
const seen = new Set<string>();
const ids: string[] = [];
let totalBytes = 0;
for (const asset of selected) {
if (seen.has(asset.id)) continue;
seen.add(asset.id);
ids.push(asset.id);
totalBytes += asset.bytes;
}
return {
ids, totalBytes, missing,
byTier: {
base: base.map((asset) => asset.id),
language: language ? [language.id] : [],
region: region ? [region.id] : [],
},
};
}
export function validateRegionConfig(config: RegionConfig): readonly RegionIssue[] {
const issues: RegionIssue[] = [];
if (!config.locales.includes(config.defaultLocale)) {
issues.push({ kind: "default-locale-missing", detail: config.defaultLocale });
}
for (const locale of config.locales) {
if (LOCALES[locale] === undefined) issues.push({ kind: "unknown-locale", detail: locale });
}
const expected = EXPECTED_BOARD[config.storeRegion];
if (expected !== undefined && expected !== config.rating.board) {
issues.push({ kind: "rating-board-mismatch", detail: `${config.storeRegion}: ${config.rating.board} != ${expected}` });
}
if (config.features["paid-random-box"] && !config.rating.probabilityDisclosure) {
issues.push({ kind: "disclosure-missing", detail: "paid-random-box requires probability disclosure" });
}
if (config.features["paid-random-box"] && config.rating.purchaseLimit === "none") {
issues.push({ kind: "purchase-limit-missing", detail: "paid-random-box requires a purchase limit profile" });
}
if (config.privacyProfile.trim().length === 0) {
issues.push({ kind: "privacy-profile-missing", detail: config.storeRegion });
}
return issues;
}
export type DataPurpose = "service" | "analytics" | "personalization" | "marketing";
export type TransferDecision = "allow" | "deny" | "review";
export interface PrivacyBoundary {
readonly profileId: string;
consentRequired(purpose: DataPurpose): boolean;
exportPath(accountId: string): string;
deletionPath(accountId: string): string;
transferDecision(purpose: DataPurpose, from: ServiceRegion, to: ServiceRegion): TransferDecision;
}
export function createPrivacyBoundary(profileId: string): PrivacyBoundary {
return {
profileId,
consentRequired: (purpose) => purpose !== "service",
exportPath: (accountId) => `/privacy/export/${accountId}`,
deletionPath: (accountId) => `/privacy/delete/${accountId}`,
transferDecision: (_purpose, from, to) => (from === to ? "allow" : "review"),
};
}
配套测试 test/i18n/region.test.ts 覆盖 10 条路径:语言与商店解耦、未知区域回退、未知语言回退、RTL、分级组合门、三层资源包、重复资源去重、六区域配置校验、分级机构不匹配与隐私边界。运行命令是 node --test test/i18n/region.test.ts。
模型不校验商店回执、不判断法律适用,也不测量资源包下载耗时。真实区域令牌由服务端签发,分级结论由发行与法务确认;这里是配置模型,不是合规审计。
双案例:方舟的六区域与消消的轻区域
方舟六个商店区域、分级差异直接对应资源变体与付费随机箱,覆盖包虽小但必须原子应用,任何半套资源都可能触发下架风险。
消消区域差异更多在活动素材与广告,分级压力较低,但区域开关同样不能硬编码,否则一次紧急关闭就要等商店审核。
与相邻章节的关系
11.1 解决文本与字体,11.2 解决时间口径,本节把语言、区域、分级与资源包串起来。配置版本化在 5.x 已讨论,资源句柄与内存水位在 6.x,内购与商店合规在 12.x 会继续展开;本节只保留区域化需要的接口边界。
小结
- 商店区域、服务区域、内容分级与语言是四个轴,不能互相推导。
- 语言在区域允许的集合内回退,玩家选择要持久化,不能跟随设备语言漂移。
- 分级标签只是结果,资源变体、概率披露、聊天过滤与购买限制才是可执行约束。
- 资源分基础包、语言包、区域覆盖包,覆盖包必须原子应用并与基础包版本锁定。
- 区域开关服务端下发、带原因与审计;隐私只实现接口边界,不做法律判断。
阅读导航:上一节:11.2 时区、服务器时间与本地时间 · 下一节:12.1 崩溃、ANR 与卡顿的采集边界 。
常见错误与边界
- 用一个
region字段决定所有事。商店、服务、分级、语言会被迫绑定,边界玩家必然出错;拆成四个独立输入。 - 用设备语言推断商店区域。英文系统的韩国玩家会被分错服;商店区域只认商店回执或服务端令牌。
- 只改商店页分级标签,不换客户端资源。标签与实物不一致会带来下架风险;分级配置必须驱动资源变体。
- 概率披露与购买限制只做一个。付费随机箱需要组合门,缺一项就不应放行;模型返回明确原因。
- 区域覆盖包边下边用。半套资源比没有覆盖更危险;下载、校验、切换、回滚必须原子。
- 客户端硬编码区域开关。新增区域要发版,紧急关闭要等审核;开关应由服务端下发并带审计。
- 把隐私传输判定放在客户端。客户端只能返回
review并上报;是否允许由服务端策略与法务决定。 - 边界:本节不提供法律意见。分级结论、数据出境与未成年保护以当地机构要求和法务意见为准;本节只定义工程接口。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。