12.2 制裁与地区可用性

上一节问「素材能不能发」,这一节问「这个地区本身能不能进」。把各地区的发行档位与功能开关整理成一份可机读登记表,用 D 层脚本扫成 region × feature 开关矩阵,算出封锁区、收款区与功能覆盖度,再用 P 层 TypeScript 内核跨语言对拍,并标明制裁清单只作公开可观察引用。

先划边界。站内那篇出海合规与税务风险清单 给的是法务与税务判断:哪些地区有法律风险、哪些条款要留意。本节不重写它,只做工程落点:把「哪些地区能进、进去之后哪些功能要关」变成一张可维护的登记表与一组可复算的开关。本节不提供法律意见,制裁与地区限制只作公开可观察机制引用(观察时点 2026-10),不可复现处标 X。

上一节处理的是内容侧的可用性:一份素材在哪些地区能发。这一节处理地区侧的可用性:这个地区本身能不能进、进去了哪些功能要关。两者是正交的——一个地区可能对内容没限制,但整区因制裁不可发;也可能整区可发,却必须关掉支付或社交。

地区可用性分三层:清单、档位、开关

地区可用性不是一个「能 / 不能」的开关,而是一套三层结构。第一层是清单:哪些地区在发行组合里。第二层是档位:每个地区分三档——tier=1 常规(全功能)、tier=2 受限(部分功能关闭,需人工复核)、tier=3 封锁(整区不可发)。第三层是功能开关:每档给一组默认开关(支付、广告、排行榜、社交、聊天、云存档、实名),再允许按地区覆盖个别功能。

这套结构的价值在于:档位决定默认,覆盖处理例外。中国大陆是常规档但单独关掉聊天;中东是受限档但单独打开聊天。把这些写进配置,地区策略就从「散落在代码里的 if」变成「一份可 review 的表」。

档位与覆盖:tierPolicy + featureOverrides

登记表是纯数据。tierPolicy 给出各档默认功能开关,featureOverrides 按地区覆盖:

{
  "version": 1,
  "game": "merge-island",
  "observedAt": "2026-10",
  "note": "制裁与地区可用性登记表。口径是「公开可观察机制」的建模假设(观察时点 2026-10,非实测):把各地区的发行可用性与功能开关整理成可机读的登记表,不是任何监管机构或平台的逐条法律结论,也不构成法律意见。tier=1 常规(全功能)、2 受限(部分功能关闭,需人工复核)、3 封锁(整区不可发)。tierPolicy 给出各档的默认功能开关;featureOverrides 按地区覆盖默认值。available = tier != 3;feature(region, f) = tierPolicy[tier][f] 再被 featureOverrides 覆盖。真实制裁清单逐季变动、真实地区封禁结果需真实业务环境,命令行不可复现(X)。全整数,无浮点。",
  "features": ["payments", "ads", "leaderboard", "social", "chat", "cloudSave", "realName"],
  "tierPolicy": {
    "1": { "payments": 1, "ads": 1, "leaderboard": 1, "social": 1, "chat": 1, "cloudSave": 1, "realName": 1 },
    "2": { "payments": 1, "ads": 1, "leaderboard": 1, "social": 0, "chat": 0, "cloudSave": 1, "realName": 1 },
    "3": { "payments": 0, "ads": 0, "leaderboard": 0, "social": 0, "chat": 0, "cloudSave": 0, "realName": 0 }
  },
  "regions": [
    { "id": "us", "name": "美国", "tier": 1, "featureOverrides": {} },
    { "id": "eu", "name": "欧盟", "tier": 1, "featureOverrides": {} },
    { "id": "jp", "name": "日本", "tier": 1, "featureOverrides": {} },
    { "id": "kr", "name": "韩国", "tier": 1, "featureOverrides": {} },
    { "id": "br", "name": "巴西", "tier": 1, "featureOverrides": {} },
    { "id": "cn", "name": "中国大陆", "tier": 1, "featureOverrides": { "chat": 0 } },
    { "id": "in", "name": "印度", "tier": 2, "featureOverrides": {} },
    { "id": "mena", "name": "中东", "tier": 2, "featureOverrides": { "chat": 1 } },
    { "id": "ru", "name": "俄罗斯", "tier": 3, "featureOverrides": {} },
    { "id": "ir", "name": "伊朗", "tier": 3, "featureOverrides": {} },
    { "id": "kp", "name": "朝鲜", "tier": 3, "featureOverrides": {} },
    { "id": "sy", "name": "叙利亚", "tier": 3, "featureOverrides": {} }
  ]
}

D 层:把地区清单扫成功能开关表

下面这段是 D 层校验脚本 sim/g12_sanction_gate.py。它读登记表,把「地区」与「功能开关」叉乘成一张 gate 表,算出封锁区、收款地区集合、功能覆盖度,以及开启 / 关闭的格子总数:

#!/usr/bin/env python3
# file: sim/g12_sanction_gate.py
# tier: D
# verify: /opt/homebrew/bin/python3 sim/g12_sanction_gate.py --config config/ --out out/
"""制裁与地区可用性开关校验(D 层:清单结构 / lint 校验)。

读 config/g12_sanction_regions.json,把「地区」与「功能开关」叉乘成一张 gate 表:

    available(region)        = 1 if tier != 3 else 0
    feature(region, f)       = tierPolicy[tier][f],再被 region.featureOverrides 覆盖
    enabledCount(region)     = 该地区开启的功能数
    enabledCells             = Σ enabledCount(所有地区)
    disabledCells            = regions × features − enabledCells
    paymentRegions           = 可用且开启 payments 的地区(能收款的地区)

口径:登记表是「公开可观察机制」的建模假设,不是监管机构或平台的逐条法律结论,
也不构成法律意见。真实制裁清单逐季变动、真实地区封禁结果需真实业务环境,
命令行不可复现(X)。产出 out/g12_sanction_gate.json + .csv + .svg。全整数,无浮点。
"""
from __future__ import annotations

import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET


def gate(cfg: dict, region: dict) -> dict:
    tier = str(region["tier"])
    policy = cfg["tierPolicy"][tier]
    overrides = region.get("featureOverrides", {})
    feats = {f: int(overrides.get(f, policy[f])) for f in cfg["features"]}
    return {"id": region["id"], "name": region["name"], "tier": region["tier"],
            "available": 0 if region["tier"] == 3 else 1, "features": feats,
            "enabledCount": sum(feats.values())}


def draw_svg(path: str, rows: list[dict], features: list[str]) -> int:
    """纯 Python SVG:region × feature 开关格(绿=开、灰=关、封锁区整行红底)。"""
    CW, CH, LEFT, TOP = 78, 30, 150, 74
    W = LEFT + CW * len(features) + 20
    H = TOP + CH * len(rows) + 20
    svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W),
                             "height": str(H), "viewBox": f"0 0 {W} {H}"})
    ET.SubElement(svg, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
    ET.SubElement(svg, "text", {"x": "16", "y": "28", "font-size": "15",
                                "fill": "#111827"}).text = "地区 × 功能开关(绿=开 / 灰=关 / 红=整区封锁)"
    for k, f in enumerate(features):
        ET.SubElement(svg, "text", {"x": str(LEFT + k * CW + 4), "y": str(TOP - 10),
                                    "font-size": "10", "fill": "#6b7280"}).text = f
    for i, r in enumerate(rows):
        y = TOP + i * CH
        ET.SubElement(svg, "text", {"x": "16", "y": str(y + 19), "font-size": "11",
                                    "fill": "#374151"}).text = f'{r["name"]}(tier{r["tier"]})'
        for k, f in enumerate(features):
            on = r["features"][f]
            fill = "#16a34a" if on else ("#ef4444" if r["available"] == 0 else "#e5e7eb")
            ET.SubElement(svg, "rect", {"x": str(LEFT + k * CW + 2), "y": str(y + 4),
                                        "width": str(CW - 4), "height": str(CH - 8), "rx": "3",
                                        "fill": fill})
    os.makedirs(os.path.dirname(path), exist_ok=True)
    ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
    return len(ET.tostring(svg))


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, "g12_sanction_regions.json"), encoding="utf-8") as f:
        cfg = json.load(f)

    features = cfg["features"]
    rows = [gate(cfg, r) for r in cfg["regions"]]
    available = [r["id"] for r in rows if r["available"] == 1]
    blocked = [r["id"] for r in rows if r["available"] == 0]
    enabled_cells = sum(r["enabledCount"] for r in rows)
    disabled_cells = len(rows) * len(features) - enabled_cells
    payment_regions = [r["id"] for r in rows if r["available"] == 1 and r["features"]["payments"] == 1]
    per_feature = [{"feature": f, "enabledRegions": sum(1 for r in rows if r["features"][f] == 1)}
                   for f in features]

    digest = (f"regions={len(rows)},available={len(available)},blocked={len(blocked)},"
              f"enabledCells={enabled_cells},disabledCells={disabled_cells}")

    os.makedirs(a.out, exist_ok=True)
    svg = draw_svg(os.path.join(a.out, "g12_sanction_gate.svg"), rows, features)
    with open(os.path.join(a.out, "g12_sanction_gate.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["region", "name", "tier", "available", "enabledCount", *features])
        for r in rows:
            w.writerow([r["id"], r["name"], r["tier"], r["available"], r["enabledCount"],
                        *[r["features"][ft] for ft in features]])
    rep = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
           "features": features, "regions": rows, "available": available, "blocked": blocked,
           "enabledCells": enabled_cells, "disabledCells": disabled_cells,
           "paymentRegions": payment_regions, "perFeature": per_feature,
           "digest": digest, "svg": os.path.join(a.out, "g12_sanction_gate.svg"), "svgBytes": svg}
    with open(os.path.join(a.out, "g12_sanction_gate.json"), "w", encoding="utf-8") as f:
        json.dump(rep, f, ensure_ascii=False, indent=2)
    print(json.dumps({"digest": digest, "available": available, "blocked": blocked,
                      "enabledCells": enabled_cells, "disabledCells": disabled_cells,
                      "paymentRegions": payment_regions, "perFeature": per_feature},
                     ensure_ascii=False, indent=2))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

在本机跑 /opt/homebrew/bin/python3 sim/g12_sanction_gate.py --config config/ --out out/,12 个地区 × 7 个功能 = 84 格的结果是(同机相对量级,绝对数值不可移植):

digest = regions=12,available=8,blocked=4,enabledCells=52,disabledCells=32

逐地区看(enabledCount 是该地区开启的功能数):

地区档位可用开启功能读法
美国 / 欧盟 / 日本 / 韩国 / 巴西1是7全功能
中国大陆1是6常规档,单独关掉聊天
印度2是5受限档,社交与聊天默认关
中东2是6受限档,但单独打开聊天
俄罗斯 / 伊朗 / 朝鲜 / 叙利亚3否0封锁,整行关闭

这张表最该记住的是两条分界线。第一条是可用 / 封锁:4 个地区(ru / ir / kp / sy)整区不可发,功能全关——它们不出现在 paymentRegions 里,所以连支付通道都不必接。第二条是覆盖优先级:中国大陆是常规档却关掉了聊天,中东是受限档却打开了聊天——档位给默认,覆盖处理例外。功能覆盖度也一目了然:social 与 chat 只覆盖 6 个地区,其余功能覆盖 8 个。

P 层内核:跨语言对拍,证明开关没配错

一张表看不出「是判定对」还是「碰巧看着对」。本套用一份 P 层 TypeScript 内核 core/src/g12/region_gate.ts 复算同一套开关:同样的档位默认、同样的覆盖优先级、同样的封锁判定。它读同一份 config/g12_sanction_regions.json,产出与 Python 逐字符一致的摘要。

// file: core/src/g12/region_gate.ts
// tier: P
// verify: node --test 'core/test/g12_*.test.ts'
//
// 制裁与地区可用性内核:与 sim/g12_sanction_gate.py 逐位等价的 TypeScript 对偶实现。
// 读同一份 config/g12_sanction_regions.json;全整数、无浮点、无随机、禁 Math.random。
// available = tier != 3;feature(region, f) = tierPolicy[tier][f] 再被 featureOverrides 覆盖。

export interface RawRegion {
  readonly id: string; readonly name: string; readonly tier: number;
  readonly featureOverrides: Readonly<Record<string, number>>;
}
export interface GateConfig {
  readonly version: number; readonly game: string; readonly observedAt: string;
  readonly features: readonly string[];
  readonly tierPolicy: Readonly<Record<string, Readonly<Record<string, number>>>>;
  readonly regions: readonly RawRegion[];
}
export interface GateRow {
  readonly id: string; readonly name: string; readonly tier: number; readonly available: number;
  readonly features: Readonly<Record<string, number>>; readonly enabledCount: number;
}
export interface GateResult { readonly rows: readonly GateRow[]; readonly blocked: readonly string[];
  readonly paymentRegions: readonly string[] }

/** 单地区 gate:先取 tier 默认,再用 featureOverrides 覆盖。 */
export function gate(cfg: GateConfig, region: RawRegion): GateRow {
  const policy = cfg.tierPolicy[String(region.tier)];
  const features: Record<string, number> = {};
  for (const f of cfg.features) {
    const override = region.featureOverrides[f];
    features[f] = override === undefined ? policy[f] : override;
  }
  let enabled = 0;
  for (const f of cfg.features) enabled += features[f];
  return { id: region.id, name: region.name, tier: region.tier,
    available: region.tier === 3 ? 0 : 1, features, enabledCount: enabled };
}

/** 全量 gate:逐地区求开关、可用性与功能计数。 */
export function gateAll(cfg: GateConfig): GateResult {
  const rows = cfg.regions.map((r) => gate(cfg, r));
  return {
    rows,
    blocked: rows.filter((r) => r.available === 0).map((r) => r.id),
    paymentRegions: rows.filter((r) => r.available === 1 && r.features["payments"] === 1).map((r) => r.id),
  };
}

/** 跨语言对拍摘要:与 sim/g12_sanction_gate.py 的 digest 逐字符一致。 */
export function digest(cfg: GateConfig): string {
  const { rows } = gateAll(cfg);
  const enabled = rows.reduce((s, r) => s + r.enabledCount, 0);
  const cells = rows.length * cfg.features.length;
  const available = rows.filter((r) => r.available === 1).length;
  return `regions=${rows.length},available=${available},blocked=${rows.length - available},`
    + `enabledCells=${enabled},disabledCells=${cells - enabled}`;
}

/** 某功能在多少个地区开启(跨地区功能覆盖度)。 */
export function enabledRegionCount(cfg: GateConfig, feature: string): number {
  return gateAll(cfg).rows.filter((r) => r.features[feature] === 1).length;
}

配套测试 core/test/g12_region_gate.test.ts 把 Python 落盘的摘要写成金标准,断言 TS 内核复算结果逐字符相等,并验封锁区整行关闭、覆盖优先级与收款地区集合。在本机跑 node --test 'core/test/g12_*.test.ts',本节 6 个用例全过:

✔ 跨语言一致:TS 地区 gate 摘要 == Python 金标准
✔ 封锁区整行关闭:4 个 tier3 地区全部功能为 0 且不可用
✔ 覆盖优先:中国大陆默认全开但单独关掉 chat;中东受限但单独打开 chat
✔ 受限区默认关闭社交与聊天:印度 enabledCount=5
✔ 收款地区集合:8 个可用地区全部开启 payments
✔ 功能覆盖度:社交与聊天只覆盖 6 个地区,其余功能 8 个
ℹ tests 6 / pass 6 / fail 0

只要有一边的档位默认、覆盖优先级或封锁判定写错,摘要立刻分叉。这与「GDPR / CCPA 的工程落点」 是同一种思路:把随地区变化的策略关进配置,让代码只负责「按表执行」。

X 层:真实制裁清单与封禁结果不可复现

必须把边界划清。上面算的是策略侧:给定一张地区表,开关矩阵是什么。而下面这些属于 X 层,需真实业务环境,命令行不可复现:

  • 各地区的真实制裁清单(清单逐季变动、口径各异,本节只作「公开可观察机制」引用并标注观察时点 2026-10,不构成法律意见);
  • 各平台的真实地区封禁结果(同一地区在不同平台可能可发 / 不可发,且平台侧不透明);
  • 各支付 / 广告网络的真实准入地区(与平台可用性不一致,需逐个对接)。

工程上正确的做法是:把策略层与事实层分开——策略层(tierPolicy / featureOverrides)是数据,可 review、可版本化;事实层(真实清单与准入结果)靠法务与商务反标定登记表(某地区被列入清单就改一行 tier,矩阵立刻更新)。这也是本节与「平台分成与结算条款」 的接口:地区可用性决定结算对象——封锁区的收入为零,收款地区才进分成账。

自检表

检查项合格标准
三层结构清单、档位、开关分开,档位给默认、覆盖处理例外
封锁可查tier=3 的地区整行关闭,不出现在收款集合
覆盖可复算覆盖优先级唯一确定开关,Python / TS 结果一致
边界清晰真实清单 / 封禁标 X,标观察时点,不写成「实测」
与法务分界只做工程落点,不提供法律意见

到这里,地区侧的可控性就立起来了:先有地区表与开关,才谈得上多地区运营。下一节换一个完全不同的维度——不再是「地区能不能进」,而是「进来的流量是不是真人」:给出一套可播种的刷量检测内核,用正常 / 刷量两类流量算出检测率与误伤率。

阅读导航:上一节:12.1 内容与版权的地区差异 · 下一节:12.3 反作弊与刷量 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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