10.1 讲主体资格,10.2 讲内容过审,本节讲第三道硬门槛:未成年人保护与防沉迷。它与前两节的共同点是规则先于玩法,差异在于防沉迷要在运行时逐次判定:每次登录、每条消费请求都要问「现在能不能玩、今天还能玩多久、这笔钱能不能花」。本节把这三个问题做成可配置、可复现的判定逻辑。真实规则版本、真实实名核验与真实消费拦截结果需真实业务环境与专业法务判断,命令行不可复现(X)。
先说分工。站内那篇小游戏开发综述 里有审核合规口径的综述段,其中提到未成年人保护;本节不重复它的框架,而是把「时段 / 时长 / 消费」三条规则写成数据 + 脚本 + 内核,让「这个未成年玩家今天还能玩几分钟」成为一个能算、能断言、能单测的值。清单与资质在 10.1,内容红线在 10.2,本节只管防沉迷,不越界。
三条硬规则:时段、时长、消费
时段:未成年人只能在允许的日期类型(如周末与节假日)的固定时间窗内游玩,窗外一律拒绝;规则表用 allowedDays 与半开区间 [windowStart, windowEnd) 表示,半开意味着 21:00 整点已不在窗内。时长:每日有总上限,请求超出剩余额度就截断发放(不是整次拒绝),用满判 capReached。消费:分单次与月度累计两道闸,任一超出即拒绝;低龄段(如未满 8 周岁)单次上限直接为 0,等于禁止充值。三个判定互相独立——一次尝试可能「时长放行 15 分钟」却「消费被单次上限拦下」,分开记账才能让「卡在哪一条」可复现。
年龄档案与尝试序列(D 层)
下面这段是 D 层静态配置:四个年龄段档案(含成年人作对照)与十次登录/消费尝试。年龄段、时段、上限都是公开可观察机制的示例归纳;真实规则版本与核验结果需专业法务判断,命令行不可复现(X)。上限单位是分钟与分,-1 表示不设上限,全程整数无浮点。
{
"version": 1,
"game": "merge-island",
"observedAt": "2026-10",
"note": "未成年人保护与防沉迷规则表(D 层)。年龄段、可玩时段、时长上限、单次与月度消费上限均为公开可观察机制的示例归纳;真实规则版本、真实实名核验与真实消费拦截结果需真实业务环境与专业法务判断,命令行不可复现(X)。时段用 HH:MM 零填充字符串,比较按字典序;上限单位:分钟 / 分。-1 表示不设上限。",
"dayKinds": ["school", "fri", "sat", "sun"],
"profiles": [
{"id": "p_u8", "band": "未满 8 周岁", "allowedDays": ["fri", "sat", "sun"], "windowStart": "20:00", "windowEnd": "21:00", "dailyMinCap": 40, "singleSpendCapFen": 0, "monthlySpendCapFen": 0},
{"id": "p_8_16", "band": "8-16 周岁", "allowedDays": ["fri", "sat", "sun"], "windowStart": "20:00", "windowEnd": "21:00", "dailyMinCap": 60, "singleSpendCapFen": 5000, "monthlySpendCapFen": 20000},
{"id": "p_16_18", "band": "16-18 周岁", "allowedDays": ["fri", "sat", "sun"], "windowStart": "20:00", "windowEnd": "21:00", "dailyMinCap": 60, "singleSpendCapFen": 10000, "monthlySpendCapFen": 40000},
{"id": "p_adult", "band": "成年人", "allowedDays": ["school", "fri", "sat", "sun"], "windowStart": "00:00", "windowEnd": "24:00", "dailyMinCap": 1440, "singleSpendCapFen": -1, "monthlySpendCapFen": -1}
],
"attempts": [
{"id": "a1", "profileId": "p_u8", "clock": "20:10", "dayKind": "sat", "durationMin": 25, "spendFen": 0},
{"id": "a2", "profileId": "p_u8", "clock": "20:50", "dayKind": "sat", "durationMin": 25, "spendFen": 0},
{"id": "a3", "profileId": "p_u8", "clock": "21:05", "dayKind": "sat", "durationMin": 10, "spendFen": 0},
{"id": "a4", "profileId": "p_u8", "clock": "20:30", "dayKind": "school", "durationMin": 10, "spendFen": 0},
{"id": "a5", "profileId": "p_u8", "clock": "20:30", "dayKind": "sat", "durationMin": 5, "spendFen": 600},
{"id": "a6", "profileId": "p_8_16", "clock": "20:20", "dayKind": "fri", "durationMin": 60, "spendFen": 3000},
{"id": "a7", "profileId": "p_8_16", "clock": "20:20", "dayKind": "fri", "durationMin": 10, "spendFen": 3000},
{"id": "a8", "profileId": "p_8_16", "clock": "20:20", "dayKind": "sun", "durationMin": 5, "spendFen": 16000},
{"id": "a9", "profileId": "p_16_18", "clock": "20:40", "dayKind": "sun", "durationMin": 30, "spendFen": 9000},
{"id": "a10", "profileId": "p_adult", "clock": "23:30", "dayKind": "school", "durationMin": 120, "spendFen": 0}
]
}
用 S 层脚本逐次结算一天
下面这段是 S 层仿真脚本,读上面那份配置,按尝试序列逐次判定:先判日期类型与时间窗,再在剩余额度内截断发放时长,最后独立判单次与月度消费上限。每个档案从零状态起算,产出可用时长、放行次数、会话拒绝数、消费拒绝数与已消费额,并生成 JSON + CSV + SVG。它把「这一天各年龄段能玩多久、被拦几次」变成一张可对照的表。
#!/usr/bin/env python3
# file: sim/l10_antiediction.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/l10_antiediction.py --config config/ --out out/
"""防沉迷结算:按时段 / 时长 / 消费三条规则逐次判定(整数、可复算)。
读 config/l10_antiediction.json:档案 + 尝试序列。时段判日期类型与半开时间窗,时长在上限内
截断发放,消费按单次与月度双闸拦截(-1 不设限)。规则为示例归纳;真实规则版本、实名核验与
拦截结果需专业法务判断,命令行不可复现(X)。产出 out/l10_antiediction.{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(cfg_dir: str, name: str) -> dict:
with open(os.path.join(cfg_dir, f"{name}.json"), encoding="utf-8") as f:
return json.load(f)
def in_window(clock: str, start: str, end: str) -> bool:
return start <= clock < end
def grant_minutes(p: dict, used: int, want: int) -> int:
remaining = p["dailyMinCap"] - used
if remaining <= 0:
return 0
return min(want, remaining)
def single_ok(p: dict, fen: int) -> bool:
return p["singleSpendCapFen"] < 0 or fen <= p["singleSpendCapFen"]
def monthly_ok(p: dict, spent: int, fen: int) -> bool:
return p["monthlySpendCapFen"] < 0 or spent + fen <= p["monthlySpendCapFen"]
def evaluate(p: dict, state: dict, a: dict) -> dict:
used = state["minutesUsed"]
spent = state["monthSpentFen"]
if a["dayKind"] not in p["allowedDays"]:
session, granted = "dayNotAllowed", 0
elif not in_window(a["clock"], p["windowStart"], p["windowEnd"]):
session, granted = "outOfWindow", 0
else:
granted = grant_minutes(p, used, a["durationMin"])
session = "capReached" if granted == 0 else "granted"
used += granted
if a["spendFen"] <= 0:
spend, spent_delta = "noSpend", 0
elif not single_ok(p, a["spendFen"]):
spend, spent_delta = "singleCap", 0
elif not monthly_ok(p, spent, a["spendFen"]):
spend, spent_delta = "monthlyCap", 0
else:
spend, spent_delta = "ok", a["spendFen"]
spent += spent_delta
return {"sessionReason": session, "grantedMin": granted, "spendReason": spend,
"spentFen": spent_delta, "state": {"minutesUsed": used, "monthSpentFen": spent}}
def txt(parent: ET.Element, x: float, y: float, s: str, size: int = 12,
fill: str = "#374151", anchor: str | None = None) -> None:
a = {"x": str(x), "y": str(y), "font-size": str(size), "fill": fill}
if anchor:
a["text-anchor"] = anchor
ET.SubElement(parent, "text", a).text = s
def svg(path: str, rows: list[dict]) -> int:
"""纯 Python SVG:每档案一根横条,长度 = 已发放时长 / 每日上限。"""
W, PAD, ROW = 720, 200, 42
H = 70 + 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"})
txt(r, 16, 26, "《合成小岛》各年龄段可用时长(分钟 / 每日上限,示例规则)", 15, "#111827")
bmax = W - PAD - 120
for i, row in enumerate(rows):
y = 70 + i * ROW
txt(r, 16, y + 20, row["band"])
frac = row["minutesUsed"] * 1000 // row["dailyMinCap"]
w = bmax * frac // 1000
ET.SubElement(r, "rect", {"x": str(PAD), "y": str(y + 6), "width": str(bmax),
"height": "20", "rx": "4", "fill": "#e5e7eb"})
ET.SubElement(r, "rect", {"x": str(PAD), "y": str(y + 6), "width": str(w),
"height": "20", "rx": "4", "fill": "#2563eb"})
txt(r, PAD + bmax + 8, y + 21, f'{row["minutesUsed"]}/{row["dailyMinCap"]}', 12, "#6b7280")
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, "l10_antiediction")
by_id = {p["id"]: p for p in cfg["profiles"]}
rows = []
for pid, p in by_id.items():
state = {"minutesUsed": 0, "monthSpentFen": 0}
decisions = []
for at in cfg["attempts"]:
if at["profileId"] != pid:
continue
d = evaluate(p, state, at)
state = d["state"]
decisions.append({"id": at["id"], "clock": at["clock"], "dayKind": at["dayKind"],
"durationMin": at["durationMin"], "spendFen": at["spendFen"],
"sessionReason": d["sessionReason"], "grantedMin": d["grantedMin"],
"spendReason": d["spendReason"], "spentFen": d["spentFen"]})
rows.append({"profileId": pid, "band": p["band"], "dailyMinCap": p["dailyMinCap"],
"minutesUsed": state["minutesUsed"], "monthSpentFen": state["monthSpentFen"],
"attempts": len(decisions),
"granted": sum(1 for d in decisions if d["sessionReason"] == "granted"),
"sessionRejected": sum(1 for d in decisions if d["sessionReason"] != "granted"),
"spendRejected": sum(1 for d in decisions if d["spendReason"] in ("singleCap", "monthlyCap")),
"decisions": decisions})
os.makedirs(a.out, exist_ok=True)
sb = svg(os.path.join(a.out, "l10_antiediction.svg"), rows)
report = {"source": "sim/l10_antiediction.py", "tier": "S", "game": cfg["game"],
"observedAt": cfg["observedAt"], "note": cfg["note"],
"profileCount": len(rows), "rows": rows, "svgBytes": sb}
with open(os.path.join(a.out, "l10_antiediction.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
with open(os.path.join(a.out, "l10_antiediction.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["profileId", "band", "dailyMinCap", "minutesUsed", "monthSpentFen",
"granted", "sessionRejected", "spendRejected"])
for r in rows:
w.writerow([r["profileId"], r["band"], r["dailyMinCap"], r["minutesUsed"],
r["monthSpentFen"], r["granted"], r["sessionRejected"], r["spendRejected"]])
for r in rows:
print(f'{r["band"]:<8} 时长 {r["minutesUsed"]}/{r["dailyMinCap"]} 分钟 '
f'放行{r["granted"]} 会话拒{r["sessionRejected"]} 消费拒{r["spendRejected"]} '
f'已消费{r["monthSpentFen"]}分')
return 0
if __name__ == "__main__":
sys.exit(main())
本机跑出来的输出(同机相对量级,绝对数值不可移植):
未满 8 周岁 时长 40/40 分钟 放行2 会话拒3 消费拒1 已消费0分
8-16 周岁 时长 60/60 分钟 放行1 会话拒2 消费拒1 已消费6000分
16-18 周岁 时长 30/60 分钟 放行1 会话拒0 消费拒0 已消费9000分
成年人 时长 120/1440 分钟 放行1 会话拒0 消费拒0 已消费0分
几处值得对照。未满 8 周岁被拦得最狠:五次尝试放行 2 次、会话拒绝 3 次(超时窗、非允许日、时长到顶各一次),且因单次上限为 0,a5 的 600 分消费被拦,已消费 0。8-16 周岁两次时长尝试只放行第一次的 60 分钟,消费累计 6000 分未越月度线。成年人在 23:30 上学日仍可玩,印证时段规则只对未成年人收紧——同一套内核,只换档案。
P 层:运行时同款的闸门内核
上面脚本服务离线对账;真机上要在每次登录/消费请求当场判定,需要能嵌进运行时的内核。下面这段是 P 层闸门内核,与 S 层脚本同一套语义(时段半开区间、时长截断、消费双闸),只做纯函数:给定档案、当日状态与一次尝试,返回会话原因、发放分钟、消费原因与新状态。-1 上限走「不设限」分支,全程整数,无 Math.random,可确定性回放。
// file: core/src/l10/antiediction.ts
// tier: P
// verify: node --test 'core/test/l10_*.test.ts'
//
// 防沉迷闸门内核:给定档案(年龄段)与一次登录/消费尝试,判定「本时段能不能玩、还能玩多久、
// 这笔消费放不放行」。规则可配置、全整数、无 Math.random,可离线单测。
// 约束:只用可擦除语法(禁 enum / namespace / 参数属性 / 装饰器)。
export interface MinorProfile {
readonly id: string;
readonly band: string;
readonly allowedDays: readonly string[];
readonly windowStart: string; // "HH:MM"
readonly windowEnd: string; // "HH:MM"(不含)
readonly dailyMinCap: number;
readonly singleSpendCapFen: number; // -1 = 无上限
readonly monthlySpendCapFen: number; // -1 = 无上限
}
export interface DayState {
readonly minutesUsed: number;
readonly monthSpentFen: number;
}
export interface Attempt {
readonly clock: string; // "HH:MM"
readonly dayKind: string;
readonly durationMin: number;
readonly spendFen: number;
}
export type SessionReason = "granted" | "outOfWindow" | "dayNotAllowed" | "capReached";
export type SpendReason = "ok" | "noSpend" | "singleCap" | "monthlyCap";
export interface Decision {
readonly sessionReason: SessionReason;
readonly grantedMin: number;
readonly spendReason: SpendReason;
readonly spentFen: number;
readonly state: DayState;
}
/** HH:MM 字典序比较即可(零填充)。半开区间 [start, end)。 */
export function inWindow(clock: string, start: string, end: string): boolean {
return clock >= start && clock < end;
}
/** 本时段是否可玩:日期类型在允许集内,且时间落在窗口内。 */
export function windowOpen(p: MinorProfile, clock: string, dayKind: string): boolean {
return p.allowedDays.includes(dayKind) && inWindow(clock, p.windowStart, p.windowEnd);
}
/** 在每日时长上限内能实际发放多少分钟(不足则截断,用满则 0)。 */
export function grantMinutes(p: MinorProfile, used: number, want: number): number {
const remaining = p.dailyMinCap - used;
if (remaining <= 0) return 0;
return want < remaining ? want : remaining;
}
/** 单次消费是否放行(-1 表示不设上限)。 */
export function singleSpendAllowed(p: MinorProfile, fen: number): boolean {
return p.singleSpendCapFen < 0 || fen <= p.singleSpendCapFen;
}
/** 月度累计消费是否放行(-1 表示不设上限)。 */
export function monthlySpendAllowed(p: MinorProfile, spent: number, fen: number): boolean {
return p.monthlySpendCapFen < 0 || spent + fen <= p.monthlySpendCapFen;
}
/** 判定一次尝试:时段、时长与消费三条规则独立结算,返回新状态。 */
export function evaluate(p: MinorProfile, state: DayState, a: Attempt): Decision {
let minutesUsed = state.minutesUsed;
let monthSpentFen = state.monthSpentFen;
let sessionReason: SessionReason = "granted";
let grantedMin = 0;
if (!p.allowedDays.includes(a.dayKind)) {
sessionReason = "dayNotAllowed";
} else if (!inWindow(a.clock, p.windowStart, p.windowEnd)) {
sessionReason = "outOfWindow";
} else {
grantedMin = grantMinutes(p, minutesUsed, a.durationMin);
if (grantedMin === 0) sessionReason = "capReached";
minutesUsed += grantedMin;
}
let spendReason: SpendReason = a.spendFen > 0 ? "ok" : "noSpend";
let spentFen = 0;
if (a.spendFen > 0) {
if (!singleSpendAllowed(p, a.spendFen)) {
spendReason = "singleCap";
} else if (!monthlySpendAllowed(p, monthSpentFen, a.spendFen)) {
spendReason = "monthlyCap";
} else {
spentFen = a.spendFen;
monthSpentFen += a.spendFen;
}
}
return { sessionReason, grantedMin, spendReason, spentFen,
state: { minutesUsed, monthSpentFen } };
}
/** 按时间顺序跑一串尝试,返回终态与逐次判定。 */
export function runDay(p: MinorProfile, attempts: readonly Attempt[]):
{ decisions: Decision[]; state: DayState } {
let state: DayState = { minutesUsed: 0, monthSpentFen: 0 };
const decisions: Decision[] = [];
for (const a of attempts) {
const d = evaluate(p, state, a);
decisions.push(d);
state = d.state;
}
return { decisions, state };
}
配套的 P 层测试覆盖六条边界——时段半开区间(21:00 整点不在窗内)、时长不足截断与用满归零、单次与月度上限分别拦截且 -1 不设限、拒绝原因区分、以及跑完整天后的终态与配置里十次尝试逐位一致。node --test 'core/test/l10_*.test.ts' 六条全部通过,运行时闸门与离线脚本共用一套口径。最后照例声明边界:上面的时段、时长与消费上限都是公开可观察机制的归纳,观察时点 2026-10;真实平台上当前生效的规则版本、实名核验的具体实现、消费拦截的最终结果都不可复现,命令行跑不出来,标 X。本节给的是判定骨架——真实规则参数填进 profiles,内核负责把三条规则算准、算稳、算可复现;任何具体合规口径都必须以监管要求与专业法务判断为准,脚本只保证逻辑自洽、边界可断言。
阅读导航:上一节:10.2 内容安全与审核红线 · 下一节:11.1 指标树与北极星指标 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。