站内《上线变现与长线运营》 第 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 分 | 2 | 3 | 14 | 333 | 500 |
| 8-16 周岁 | 60 分 | 1 | 3 | 15 | 500 | 535 |
| 16-18 周岁 | 30 分 | 1 | 3 | 9 | 250 | 321 |
| 成年人 | 120 分 | 1 | 4 | 28 | 1000 | 1000 |
三个未成年人档案的广告触达都在成年人的一半上下(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 周岁 | 428 | 551 | 123 |
| 8-16 周岁 | 428 | 714 | 286 |
| 16-18 周岁 | 294 | 344 | 50 |
| 成年人 | 1000 | 1000 | 0 |
读这张表要读出两件事。第一,排序稳健:无论怎么扰动节奏与上限,「成年人 > 8-16 周岁 ≥ 未满 8 周岁 > 16-18 周岁」这个次序都不变——所以「未成年人广告触达显著低于成年人」这个结论是稳的。第二,8-16 周岁最敏感(摆动 286‰):它的 60 分钟卡在单会话上限附近,dailyMinCap 一上调,触达就往上跳一大截;而 16-18 周岁只有 30 分钟、离上限远,摆动最小(50‰)。这提醒我们:谈未成年人触达必须连参数一起交代,单说「触达是成年人一半」而不说「在什么上限与节奏下」,是把判断当成了事实。
X 层:真实规则与实名核验不可复现
上面算的是模型侧:给定时段、上限与节奏,广告机会是多少。下面这些属于 X 层,需真实业务环境,命令行不可复现:
- 真实的防沉迷规则版本与实名核验结果(由平台侧与监管要求定义,且会更新);
- 真实的广告内容限制清单(哪些类别对未成年人不投放,各渠道口径不同);
- 真实的会话行为(未成年人真实的在线时长分布,与「刚好用满上限」的建模假设未必一致)。
自检表
| 检查项 | 合格标准 |
|---|---|
| 双约束齐全 | 时间约束(时段/时长)与内容约束(类别屏蔽)都建模 |
| 折算可复算 | 时长 → 广告机会的每一步都是整数地板除 |
| 触达有基准 | 以成年人为 1000‰ 基准,各档案可横向比 |
| 稳健性有据 | 对节奏与上限做 ±20% / ±1 扰动,量化摆动与名次翻转 |
| 会话结构可见 | 区分「总时长」与「会话数」,不混为一谈 |
| 边界清晰 | 真实规则与实名核验标 X,不写成「实测」 |
未成年人广告最危险的错觉,是把「平均用户触达」当成「每个用户触达」。防沉迷把不同年龄段的可用时间切成了完全不同的形状,用均值算收入预测会系统性失真。上一节处理的是广告内容本身能不能上,这一节处理的是谁能看到广告;下一节我们把镜头转向广告背后的数据——采集与上报的同意与最小化。
阅读导航:上一节:8.1 广告内容合规与审核 · 下一节:8.3 广告数据与隐私 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。