10.3 未成年人保护与防沉迷

把防沉迷做成三条可配置的硬规则:时段(可玩日与时间窗)、时长(每日上限内截断发放)、消费(单次与月度上限分别拦截)。一条带年龄段的 D 层档案表、一段逐次判定十次登录与消费尝试的 S 层结算脚本,以及一份运行时同款的 P 层闸门内核与六条断言。

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 指标树与北极星指标 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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