《移动游戏产品的架构·客户端与移动运行时》11.3 区域化配置与内容分级

《潮汐方舟》多区域发行的事故复盘:商店区域、服务区域、内容分级与语言必须拆成四个轴,分级结论要驱动资源变体、概率披露与购买限制,区域覆盖包必须原子应用,语言选择不能靠设备推断。隐私与数据出境只给接口边界,不做法律判断,并给出可运行配置模型与工程边界。

事故:分级标签改了,资源没换

《潮汐方舟》同一份构建发往六个商店区域。德文版本更新时,运营只改了商店页的分级标签,客户端里的血液特效与断肢模型没有替换;韩国版本则因为区域开关按设备语言判断,部分英文系统的韩国玩家被分到了跨服池。两次问题都不是「翻译错了」,而是把商店区域、内容分级、服务区域与语言当成了同一个变量。

区域化的第一原则是拆轴:商店区域决定在哪个货架上架,服务区域决定连哪组服务器,内容分级决定能展示什么资源与功能,语言决定玩家读到什么文字。 四个轴可以相关,但不能互相推导。把「设备语言是韩文」当成「玩家在韩国商店」,或者把「商店区域是美国」当成「内容分级一定是 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 并上报;是否允许由服务端策略与法务决定。
  • 边界:本节不提供法律意见。分级结论、数据出境与未成年保护以当地机构要求和法务意见为准;本节只定义工程接口。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《移动游戏产品的架构·商业化与发行工程》附录 C 商业化与发行评审清单
  2. 《移动游戏产品的架构·商业化与发行工程》附录 B 双案例商业化事实表
  3. 《移动游戏产品的架构·商业化与发行工程》附录 A 商业化与发行术语速查