8.2 未成年人广告与防沉迷联动

未成年人广告与防沉迷联动回答的是「受限时段与时长如何折算成广告展示机会」:把可玩时段、每日时长上限与广告节奏参数串成一条整数链,算各年龄段的时长触达率与广告机会触达率,再用 B 基准对节奏与上限参数做 ±20% 扰动检验排序稳健性;配可跑的 Python 仿真与稳健性基准。

站内《上线变现与长线运营》 第 10 章讲过防沉迷规则与内容安全红线,那是规则与合规口径;本节分工不同,它不重写防沉迷规则、不提供具体法律意见,只做「广告与防沉迷联动」里可仿真的那一半:把可玩时段与时长上限,折算成广告展示机会,看限制到底把广告触达压掉了多少。

防沉迷为什么是「广告」的事

前面几章一直把广告位当作「配置对象」。但对未成年人账号,广告位还多一层约束:可玩时间被防沉迷卡死,广告的展示机会就被结构性压缩。这不是「少一点用户」的问题,而是另一套投放规则——一个未满 8 周岁的账号和一个成年人账号,即使同一款游戏、同一个广告位,一天能触达的广告次数差好几倍。它受两条独立约束叠乘:时间约束(只在允许的日期与时段可玩、每日时长有上限)压缩「能玩多久」,内容约束(部分广告类别如开屏对未成年人不投放)压缩「能看哪几类」。做收入预测时如果按「平均用户」算,会系统性高估未成年用户占比高的产品。

S 层:把时长限制折算成广告机会

下面这段是 S 层仿真脚本 sim/o08_minorad.py。它读 config/o08_minorad.json 的档案与会话序列,先按允许日与时间窗结算可用时长,再把时长折算成各广告位的展示机会,以成年人为基准算触达率,产出 JSON + CSV + SVG。

#!/usr/bin/env python3
# file: sim/o08_minorad.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/o08_minorad.py --config config/ --out out/
"""未成年人广告与防沉迷联动仿真:把「可玩时段 / 时长上限」折算成「广告展示机会」。

读 config/o08_minorad.json:档案(时段 / 允许日 / 每日时长上限)+ 会话序列 + 广告节奏。
会话按允许日与半开窗 [start,end) 放行、时长在上限内截断;激励视频机会 = 已发放时长 //
adEveryMin,插屏/横幅/开屏 = 有效会话数 × 每次上限;未成年人屏蔽部分广告类别。
以成年人为基准(1000‰)算时长触达率与广告机会触达率。规则、节奏与屏蔽清单均为示例归纳;
真实规则版本、实名核验与广告内容限制需真实业务环境与专业法务判断,命令行不可复现(X)。
产出 out/o08_minorad.json + .csv + .svg。全整数、无浮点、无随机数。
"""

from __future__ import annotations

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


def load(d: str, name: str) -> dict:
    with open(os.path.join(d, f"{name}.json"), encoding="utf-8") as f:
        return json.load(f)


def settle(p: dict, sessions: list[dict]) -> dict:
    """跑完该档案的会话序列:返回已发放时长与有效会话数(被拒会话不计入)。"""
    used, granted_sessions, decisions = 0, 0, []
    for s in sessions:
        if s["dayKind"] not in p["allowedDays"]:
            reason, granted = "dayNotAllowed", 0
        elif not (p["windowStart"] <= s["clock"] < p["windowEnd"]):
            reason, granted = "outOfWindow", 0
        else:
            rem = p["dailyMinCap"] - used
            granted = min(s["durationMin"], rem) if rem > 0 else 0
            reason = "capReached" if granted == 0 else "granted"
            used += granted
        if granted > 0:
            granted_sessions += 1
        decisions.append({"id": s["id"], "reason": reason, "grantedMin": granted})
    return {"grantedMin": used, "sessionCount": granted_sessions, "decisions": decisions}


def ad_opportunities(p: dict, cfg: dict, settled: dict) -> dict:
    """按可用类别折算各广告位的展示机会(未成年人屏蔽部分类别)。"""
    blocked = set(cfg["minorBlockedCategories"]) if p["isMinor"] else set()
    eligible = [c for c in cfg["adCategories"] if c not in blocked]
    per = {"interstitial": cfg["interstitialPerSession"], "banner": cfg["bannerPerSession"],
           "splash": cfg["splashPerSession"]}
    opp = {}
    for c in eligible:
        opp[c] = (settled["grantedMin"] // cfg["adEveryMin"] if c == "rewarded_video"
                  else settled["sessionCount"] * per[c])
    return {"eligible": eligible, "opp": opp, "totalOpp": sum(opp.values())}


def svg(path: str, rows: list[dict]) -> int:
    """纯 Python SVG:每档案两根横条——时长触达率与广告机会触达率(成年人=1000‰)。"""
    W, PAD, ROW = 760, 210, 62
    H = 74 + ROW * len(rows)
    r = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W),
                           "height": str(H), "viewBox": f"0 0 {W} {H}"})
    ET.SubElement(r, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
    ET.SubElement(r, "text", {"x": "16", "y": "28", "font-size": "15",
                              "fill": "#111827"}).text = "《合成小岛》未成年人广告触达(‰,成年人=1000)"
    bmax = W - PAD - 170
    for i, row in enumerate(rows):
        y = 74 + i * ROW
        ET.SubElement(r, "text", {"x": "16", "y": str(y + 20), "font-size": "12",
                                  "fill": "#374151"}).text = row["band"]
        for j, (key, color, label) in enumerate([("timeReachPermille", "#93c5fd", "时长"),
                                                 ("adReachPermille", "#2563eb", "广告机会")]):
            w = bmax * min(row[key], 1200) // 1200
            ET.SubElement(r, "rect", {"x": str(PAD), "y": str(y + 6 + j * 22), "width": str(w),
                                      "height": "18", "rx": "3", "fill": color})
            ET.SubElement(r, "text", {"x": str(PAD + w + 8), "y": str(y + 20 + j * 22),
                                      "font-size": "11", "fill": "#6b7280"}).text = \
                f'{label} {row[key]}‰ · 机会 {row["totalOpp"]}'
    os.makedirs(os.path.dirname(path), exist_ok=True)
    ET.ElementTree(r).write(path, encoding="utf-8", xml_declaration=True)
    return os.path.getsize(path)


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    a = ap.parse_args()
    cfg = load(a.config, "o08_minorad")
    rows = []
    for p in cfg["profiles"]:
        sessions = [s for s in cfg["sessions"] if s["profileId"] == p["id"]]
        settled = settle(p, sessions)
        ads = ad_opportunities(p, cfg, settled)
        rows.append({"id": p["id"], "band": p["band"], "isMinor": p["isMinor"],
                     "dailyMinCap": p["dailyMinCap"], "grantedMin": settled["grantedMin"],
                     "sessionCount": settled["sessionCount"], "eligible": ads["eligible"],
                     "eligibleCatCount": len(ads["eligible"]), "opp": ads["opp"],
                     "totalOpp": ads["totalOpp"], "decisions": settled["decisions"]})
    adult = next(r for r in rows if not r["isMinor"])
    for r in rows:
        r["timeReachPermille"] = r["grantedMin"] * 1000 // adult["grantedMin"] if adult["grantedMin"] else 0
        r["adReachPermille"] = r["totalOpp"] * 1000 // adult["totalOpp"] if adult["totalOpp"] else 0
    digest = ",".join(f'{r["id"]}:{r["grantedMin"]}:{r["sessionCount"]}:{r["totalOpp"]}:{r["eligibleCatCount"]}'
                      for r in rows)
    os.makedirs(a.out, exist_ok=True)
    sb = svg(os.path.join(a.out, "o08_minorad.svg"), rows)
    report = {"source": "sim/o08_minorad.py", "tier": "S", "game": cfg["game"],
              "observedAt": cfg["observedAt"], "note": cfg["note"], "adEveryMin": cfg["adEveryMin"],
              "profileCount": len(rows), "adultBaseline": {"grantedMin": adult["grantedMin"],
              "totalOpp": adult["totalOpp"]}, "rows": rows, "digest": digest, "svgBytes": sb}
    with open(os.path.join(a.out, "o08_minorad.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    with open(os.path.join(a.out, "o08_minorad.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["id", "band", "isMinor", "dailyMinCap", "grantedMin", "sessionCount",
                    "eligibleCatCount", "rewardedOpp", "interstitialOpp", "bannerOpp", "splashOpp",
                    "totalOpp", "timeReachPermille", "adReachPermille"])
        for r in rows:
            o = r["opp"]
            w.writerow([r["id"], r["band"], r["isMinor"], r["dailyMinCap"], r["grantedMin"],
                        r["sessionCount"], r["eligibleCatCount"], o.get("rewarded_video", 0),
                        o.get("interstitial", 0), o.get("banner", 0), o.get("splash", 0),
                        r["totalOpp"], r["timeReachPermille"], r["adReachPermille"]])
    for r in rows:
        print(f'{r["band"]:<8} 可用{r["grantedMin"]:>3}分 会话{r["sessionCount"]} '
              f'类别{r["eligibleCatCount"]} 广告机会{r["totalOpp"]:>2} '
              f'时长触达{r["timeReachPermille"]}‰ 广告触达{r["adReachPermille"]}‰')
    print(f"digest={digest}")
    return 0


if __name__ == "__main__":
    sys.exit(main())

参数放在 config/o08_minorad.json。时段、上限、节奏与屏蔽清单都是「公开可观察机制」口径的示例归纳,不是任何平台后台的实测:

{
  "version": 1,
  "game": "merge-island",
  "observedAt": "2026-10",
  "note": "未成年人广告与防沉迷联动参数(D 层)。年龄段、可玩时段、时长上限、广告节奏与内容屏蔽清单均为公开可观察机制的示例归纳;真实规则版本、真实实名核验与真实广告内容限制需真实业务环境与专业法务判断,命令行不可复现(X)。时段用 HH:MM 零填充字符串,比较按字典序。adEveryMin=每 N 分钟可用时长折算 1 次激励视频;interstitialPerSession/bannerPerSession/splashPerSession=每个有效会话各类广告位的展示机会上限。全整数,无浮点。",
  "adEveryMin": 5,
  "interstitialPerSession": 2,
  "bannerPerSession": 1,
  "splashPerSession": 1,
  "adCategories": ["rewarded_video", "interstitial", "banner", "splash"],
  "minorBlockedCategories": ["splash"],
  "profiles": [
    {"id": "p_u8", "band": "未满 8 周岁", "isMinor": true, "allowedDays": ["fri", "sat", "sun"], "windowStart": "20:00", "windowEnd": "21:00", "dailyMinCap": 40},
    {"id": "p_8_16", "band": "8-16 周岁", "isMinor": true, "allowedDays": ["fri", "sat", "sun"], "windowStart": "20:00", "windowEnd": "21:00", "dailyMinCap": 60},
    {"id": "p_16_18", "band": "16-18 周岁", "isMinor": true, "allowedDays": ["fri", "sat", "sun"], "windowStart": "20:00", "windowEnd": "21:00", "dailyMinCap": 60},
    {"id": "p_adult", "band": "成年人", "isMinor": false, "allowedDays": ["school", "fri", "sat", "sun"], "windowStart": "00:00", "windowEnd": "24:00", "dailyMinCap": 1440}
  ],
  "sessions": [
    {"id": "s1", "profileId": "p_u8", "clock": "20:10", "dayKind": "sat", "durationMin": 25},
    {"id": "s2", "profileId": "p_u8", "clock": "20:50", "dayKind": "sat", "durationMin": 25},
    {"id": "s3", "profileId": "p_u8", "clock": "21:05", "dayKind": "sat", "durationMin": 10},
    {"id": "s4", "profileId": "p_8_16", "clock": "20:20", "dayKind": "fri", "durationMin": 60},
    {"id": "s5", "profileId": "p_8_16", "clock": "20:20", "dayKind": "sun", "durationMin": 30},
    {"id": "s6", "profileId": "p_16_18", "clock": "20:40", "dayKind": "sun", "durationMin": 30},
    {"id": "s7", "profileId": "p_adult", "clock": "23:30", "dayKind": "school", "durationMin": 120}
  ]
}

在本机跑 /opt/homebrew/bin/python3 sim/o08_minorad.py --config config/ --out out/,结果是(同机相对量级,绝对数值不可移植):

档案可用时长有效会话可用类别广告机会时长触达‰广告触达‰
未满 8 周岁40 分2314333500
8-16 周岁60 分1315500535
16-18 周岁30 分139250321
成年人120 分142810001000

三个未成年人档案的广告触达都在成年人的一半上下(321–535‰),且都不是简单正比于时长:8-16 周岁时长触达 500‰ 但广告触达 535‰,因为它把 60 分钟集中在一个会话里,插屏/横幅的「每次上限」吃满;未满 8 周岁时长只有 333‰,却因拆成两个会话、插屏机会翻倍,广告触达反而升到 500‰。会话结构(不是总时长)决定了插屏与横幅的机会——这正是把「时间限制」和「广告机会」分开建模的价值。

B 基准:触达对节奏与上限参数有多敏感

触达率依赖 adEveryMin、interstitialPerSession、dailyMinCap——这些是产品与监管决策,不是客观常数。下面这段是 B 层基准脚本 sim/o08_minorad_bench.py,逐档扰动这些参数重跑,比较各档案触达率与名次,软告警不硬失败。

#!/usr/bin/env python3
# file: sim/o08_minorad_bench.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/o08_minorad_bench.py --config config/ --out out/
"""B 基准:未成年人广告触达对「节奏 / 防沉迷参数」的稳健性。

触达率依赖 adEveryMin(激励视频节奏)、interstitialPerSession(插屏每次上限)与
dailyMinCap(每日时长上限)——这些是**产品与监管决策**,不是客观常数。逐档 ±20%(上限类)
或 ±1(会话类)重跑,比较各档案触达率与名次。软告警:任一名次翻转即打 WARN;退出码恒为 0。
全整数、无浮点。
"""

from __future__ import annotations

import argparse
import json
import os

from o08_minorad import ad_opportunities, load, settle

PCT = 20


def reach(cfg: dict) -> dict:
    """给定 cfg 下每个档案的广告机会触达率(成年人=1000)。"""
    out = {}
    for p in cfg["profiles"]:
        settled = settle(p, [s for s in cfg["sessions"] if s["profileId"] == p["id"]])
        out[p["id"]] = ad_opportunities(p, cfg, settled)["totalOpp"]
    adult = out["p_adult"] or 1
    return {pid: out[pid] * 1000 // adult for pid in out}


def rank_map(m: dict) -> dict:
    order = sorted(m, key=lambda pid: (-m[pid], pid))
    return {pid: i for i, pid in enumerate(order)}


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    a = ap.parse_args()
    cfg = load(a.config, "o08_minorad")
    base, base_rank = reach(cfg), None
    base_rank = rank_map(base)

    scenarios = [("adEveryMin", PCT), ("adEveryMin", -PCT),
                 ("interstitialPerSession", 1), ("interstitialPerSession", -1),
                 ("dailyMinCap", PCT), ("dailyMinCap", -PCT)]
    cases, flips = [], []
    for key, delta in scenarios:
        cfg2 = dict(cfg)
        if key == "dailyMinCap":
            cfg2["profiles"] = [dict(p, dailyMinCap=p["dailyMinCap"] * (100 + delta) // 100)
                                for p in cfg["profiles"]]
        elif key == "adEveryMin":
            cfg2[key] = cfg[key] * (100 + delta) // 100
        else:
            cfg2[key] = cfg[key] + delta
        r2 = reach(cfg2)
        r2_rank = rank_map(r2)
        flip = {pid: [base_rank[pid], r2_rank[pid]] for pid in base_rank if base_rank[pid] != r2_rank[pid]}
        cases.append({"key": key, "delta": delta, "adReachPermille": r2,
                      "changed": {pid: [base[pid], r2[pid]] for pid in base if base[pid] != r2[pid]},
                      "rankFlip": flip})
        for pid, pair in flip.items():
            flips.append({"key": key, "delta": delta, "profile": pid, "fromRank": pair[0], "toRank": pair[1]})

    swing = {pid: {"min": min([base[pid]] + [c["adReachPermille"][pid] for c in cases]),
                   "max": max([base[pid]] + [c["adReachPermille"][pid] for c in cases])} for pid in base}
    for pid in swing:
        swing[pid]["spanPermille"] = swing[pid]["max"] - swing[pid]["min"]

    os.makedirs(a.out, exist_ok=True)
    rep = {"version": cfg["version"], "source": "sim/o08_minorad_bench.py", "base": base,
           "baseRank": base_rank, "swing": swing, "cases": cases, "flipCount": len(flips),
           "flips": flips, "verdict": "PASS(soft)" if not flips else f"WARN(soft) {len(flips)} 条名次翻转"}
    with open(os.path.join(a.out, "o08_minorad_bench.json"), "w", encoding="utf-8") as f:
        json.dump(rep, f, ensure_ascii=False, indent=2)
    print(json.dumps({"base": base, "swing": swing, "flipCount": len(flips), "flips": flips,
                      "verdict": rep["verdict"]}, ensure_ascii=False, indent=2))
    return 0


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

在本机跑它,6 个场景无一名次翻转(PASS(soft),退出码 0),各档案触达摆动幅度(‰):

档案最小最大摆动
未满 8 周岁428551123
8-16 周岁428714286
16-18 周岁29434450
成年人100010000

读这张表要读出两件事。第一,排序稳健:无论怎么扰动节奏与上限,「成年人 > 8-16 周岁 ≥ 未满 8 周岁 > 16-18 周岁」这个次序都不变——所以「未成年人广告触达显著低于成年人」这个结论是稳的。第二,8-16 周岁最敏感(摆动 286‰):它的 60 分钟卡在单会话上限附近,dailyMinCap 一上调,触达就往上跳一大截;而 16-18 周岁只有 30 分钟、离上限远,摆动最小(50‰)。这提醒我们:谈未成年人触达必须连参数一起交代,单说「触达是成年人一半」而不说「在什么上限与节奏下」,是把判断当成了事实。

X 层:真实规则与实名核验不可复现

上面算的是模型侧:给定时段、上限与节奏,广告机会是多少。下面这些属于 X 层,需真实业务环境,命令行不可复现:

  • 真实的防沉迷规则版本与实名核验结果(由平台侧与监管要求定义,且会更新);
  • 真实的广告内容限制清单(哪些类别对未成年人不投放,各渠道口径不同);
  • 真实的会话行为(未成年人真实的在线时长分布,与「刚好用满上限」的建模假设未必一致)。

自检表

检查项合格标准
双约束齐全时间约束(时段/时长)与内容约束(类别屏蔽)都建模
折算可复算时长 → 广告机会的每一步都是整数地板除
触达有基准以成年人为 1000‰ 基准,各档案可横向比
稳健性有据对节奏与上限做 ±20% / ±1 扰动,量化摆动与名次翻转
会话结构可见区分「总时长」与「会话数」,不混为一谈
边界清晰真实规则与实名核验标 X,不写成「实测」

未成年人广告最危险的错觉,是把「平均用户触达」当成「每个用户触达」。防沉迷把不同年龄段的可用时间切成了完全不同的形状,用均值算收入预测会系统性失真。上一节处理的是广告内容本身能不能上,这一节处理的是谁能看到广告;下一节我们把镜头转向广告背后的数据——采集与上报的同意与最小化。

阅读导航:上一节:8.1 广告内容合规与审核 · 下一节:8.3 广告数据与隐私 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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