11.3 资质倒排与上线前置

正排告诉你「最快多久能拿到版号」,倒排告诉你「最晚哪天必须开工」。本节把资质链反过来算:给定计划上线日,逐个资质倒推最晚开工日与浮时,找出浮时最小的关键链,再用可播种的蒙特卡洛扫一遍缓冲天数对「按时拿到版号」概率的影响,配可跑的 Python 仿真,并把真实发证波动标为不可复现的 X 层。

上一节把资质画成了有向图并算出可达闭包 ,那是正排:从今天出发,最快多久拿到版号。但运营要回答的是另一个方向的问题——「我计划某个日子上线,那么最晚什么时候必须启动每一张证」。这正是卷三《上线变现与长线运营》 里反复强调的「上线日不是拍脑袋定的」,也是站内游戏产业全景 里各品类发行节奏差异的工程底色。本节把资质链反过来算,并用蒙特卡洛回答一个更现实的问题:缓冲留多少天才够。先声明:本节只做排期仿真,不提供任何具体法律意见,也不预测任何发证结果。

11.3 资质倒排与上线前置

正排从第 0 天起,earliestFinish(lic) = max earliestFinish(req) + leadDays(lic),totalDays 是版号的最早出证日。倒排给定上线日 L,从目标往回推:latestFinish(target) = L,latestFinish(lic) = min latestStart(succ)(所有把它当前置的后继里最紧的那个),latestStart(lic) = latestFinish(lic) - leadDays(lic)。两者相减就是浮时 slack(lic) = latestStart(lic) - earliestStart(lic):浮时为 0 的节点一步都不能拖,浮时最小的一组节点构成关键链。倒排的实用价值在于:它把「什么时候必须开始办」变成一个可以写进日历的日期,而不是一句「尽早」。

S 层仿真:倒排 + 缓冲敏感性

下面这段是 S 层仿真脚本 sim/o11_reverse.py,它读 config/o11_reverse.json(6 个资质 + 播种参数 + 缓冲档位),跑三件事:正排算最早出证日、倒排算最晚开工日与浮时、用可播种 LCG 做蒙特卡洛扫各缓冲档位的按时率,并产出 JSON + CSV + 纯 Python SVG。

{
  "version": 1,
  "game": "merge-island",
  "observedAt": "2026-10",
  "seed": 20261013,
  "trials": 2000,
  "jitterDays": 12,
  "bufferDefault": 30,
  "target": "isbn",
  "buffers": [0, 15, 30, 45, 60, 90],
  "note": "资质倒排与上线前置的排期建模假设,口径是「公开可观察机制」,不是任何发证机关实测。leadDays=基准出证耗时(自然日);requires=前置资质;jitterDays=每次模拟对单个资质耗时的对称抖动幅度(±天,由 seed 播种);trials=蒙特卡洛次数;bufferDefault=倒排时预设的缓冲天数;buffers=敏感性扫描的缓冲档位。真实发证时长波动与补正次数由监管决定,需真实业务环境,命令行不可复现(X)。全整数,无浮点。",
  "qualifications": [
    { "id": "company", "name": "公司主体与营业执照", "leadDays": 0, "requires": [] },
    { "id": "icp", "name": "ICP 备案", "leadDays": 5, "requires": ["company"] },
    { "id": "soft-copyright", "name": "计算机软件著作权登记", "leadDays": 40, "requires": ["company"] },
    { "id": "culture-license", "name": "网络文化经营许可证(文网文)", "leadDays": 60, "requires": ["company", "icp"] },
    { "id": "publication-permit", "name": "网络出版服务许可证", "leadDays": 90, "requires": ["company"] },
    { "id": "isbn", "name": "出版物号(版号)", "leadDays": 120, "requires": ["soft-copyright", "culture-license", "publication-permit"] }
  ]
}
#!/usr/bin/env python3
# file: sim/o11_reverse.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/o11_reverse.py --config config/ --out out/
"""资质倒排与上线前置:从上线日往回推「最晚开工日」,并做耗时不定的蒙特卡洛。

读 config/o11_reverse.json。资质是有向图,edge  req -> lic 表示「先有 req 才能办 lic」。

  正排(从第 0 天起):
    earliestStart(lic)  = max_{req∈requires} earliestFinish(req)
    earliestFinish(lic) = earliestStart(lic) + leadDays(lic)
    totalDays           = earliestFinish(target)

  倒排(给定上线日 L = totalDays + bufferDefault):
    latestFinish(target)  = L
    latestFinish(lic)     = min_{succ: lic∈requires(succ)} latestStart(succ)
    latestStart(lic)      = latestFinish(lic) - leadDays(lic)
    slack(lic)            = latestStart(lic) - earliestStart(lic)
    关键链                = slack == min(slack) 的节点(缓冲耗尽最先出问题的那些)

  蒙特卡洛(可播种 LCG,整数):
    每次试验给每个资质的耗时加 ±jitterDays 的对称抖动,重算 target 的 finish;
    onTime‰(buffer) = 在 L = totalDays + buffer 之内拿到版号的试验占比。

全程整数、无浮点。产出 out/o11_reverse.json + .csv + .svg。
leadDays / jitterDays / buffer 属「公开可观察机制」的建模假设;真实发证时长波动
与补正次数由监管决定,需真实业务环境,命令行不可复现(X)。不提供法律意见。
"""
from __future__ import annotations

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

MASK = 0x7FFFFFFF


def lcg(state: int) -> int:
    return (state * 1103515245 + 12345) & MASK


def build_index(quals: list[dict]) -> dict[str, dict]:
    return {q["id"]: q for q in quals}


def topo_order(index: dict[str, dict]) -> list[str]:
    seen: set[str] = set()
    order: list[str] = []

    def visit(k: str) -> None:
        if k in seen:
            return
        seen.add(k)
        for req in sorted(index[k]["requires"]):
            if req in index:
                visit(req)
        order.append(k)

    for k in sorted(index):
        visit(k)
    return order


def forward(index: dict[str, dict], order: list[str], jitter: dict[str, int]) -> tuple[dict[str, int], dict[str, int]]:
    start: dict[str, int] = {}
    finish: dict[str, int] = {}
    for k in order:
        s = 0
        for req in index[k]["requires"]:
            if req in finish:
                s = max(s, finish[req])
        start[k] = s
        finish[k] = s + max(0, index[k]["leadDays"] + jitter.get(k, 0))
    return start, finish


def reverse(index: dict[str, dict], order: list[str], launch_day: int) -> tuple[dict[str, int], dict[str, int]]:
    """从上线日倒排最晚开工日;order 是拓扑序(前置在前)。"""
    latest_finish: dict[str, int] = {}
    latest_start: dict[str, int] = {}
    for k in reversed(order):
        succ_latest = [latest_start[s] for s in index if k in index[s]["requires"] and s in latest_start]
        lf = min(succ_latest) if succ_latest else launch_day
        latest_finish[k] = lf
        latest_start[k] = lf - index[k]["leadDays"]
    return latest_finish, latest_start


def gen_jitters(ids: list[str], seed: int, trials: int, jitter: int) -> list[dict[str, int]]:
    state = seed
    out: list[dict[str, int]] = []
    for _ in range(trials):
        row: dict[str, int] = {}
        for k in ids:
            state = lcg(state)
            row[k] = (state % (2 * jitter + 1)) - jitter
        out.append(row)
    return out


def draw_svg(out_path: str, buffers: list[int], on_time: list[int]) -> int:
    """纯 Python SVG:缓冲天数 vs 按时拿到版号的概率折线(千分比)。"""
    W, H, L, R, T, B = 760, 300, 70, 30, 50, 50
    root = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W),
                              "height": str(H), "viewBox": f"0 0 {W} {H}"})
    ET.SubElement(root, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
    ET.SubElement(root, "text", {"x": "16", "y": "26", "font-size": "15", "fill": "#111827"}
                  ).text = "《合成小岛》缓冲天数 vs 按时拿到版号概率(千分比,1000=必达)"
    pw, ph = W - L - R, H - T - B
    hi = max(buffers) or 1
    pts = []
    for b, p in zip(buffers, on_time):
        x = L + pw * b // hi
        y = T + ph - ph * p // 1000
        pts.append(f"{x},{y}")
    ET.SubElement(root, "line", {"x1": str(L), "y1": str(T + ph), "x2": str(L + pw), "y2": str(T + ph),
                                 "stroke": "#cbd5e1"})
    ET.SubElement(root, "line", {"x1": str(L), "y1": str(T), "x2": str(L), "y2": str(T + ph),
                                 "stroke": "#cbd5e1"})
    ET.SubElement(root, "polyline", {"points": " ".join(pts), "fill": "none",
                                     "stroke": "#2563eb", "stroke-width": "2"})
    for b, p, pt in zip(buffers, on_time, pts):
        cx, cy = pt.split(",")
        ET.SubElement(root, "circle", {"cx": cx, "cy": cy, "r": "4", "fill": "#2563eb"})
        ET.SubElement(root, "text", {"x": cx, "y": str(int(cy) - 8), "font-size": "10",
                                     "fill": "#374151"}).text = str(p)
        ET.SubElement(root, "text", {"x": str(int(cx) - 10), "y": str(T + ph + 16), "font-size": "10",
                                     "fill": "#6b7280"}).text = f"+{b}d"
    os.makedirs(os.path.dirname(out_path), exist_ok=True)
    ET.ElementTree(root).write(out_path, encoding="utf-8", xml_declaration=True)
    return len(ET.tostring(root))


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    args = ap.parse_args()

    with open(os.path.join(args.config, "o11_reverse.json"), encoding="utf-8") as f:
        cfg = json.load(f)
    index = build_index(cfg["qualifications"])
    ids = sorted(index)
    order = topo_order(index)

    # 正排(无抖动)
    start, finish = forward(index, order, {})
    target = cfg["target"]
    total_days = finish[target]

    # 倒排(默认缓冲)
    launch_default = total_days + cfg["bufferDefault"]
    latest_finish, latest_start = reverse(index, order, launch_default)
    slack = {k: latest_start[k] - start[k] for k in ids}
    min_slack = min(slack.values())
    critical = [k for k in order if slack[k] == min_slack]

    # 蒙特卡洛:同一批抖动用于所有缓冲档位(共同随机数,档位可比)
    jitters = gen_jitters(ids, cfg["seed"], cfg["trials"], cfg["jitterDays"])
    on_time: list[int] = []
    for buf in cfg["buffers"]:
        limit = total_days + buf
        hit = 0
        for row in jitters:
            _, fin = forward(index, order, row)
            if fin[target] <= limit:
                hit += 1
        on_time.append(hit * 1000 // cfg["trials"])

    rows = [{"id": k, "name": index[k]["name"], "leadDays": index[k]["leadDays"],
             "earliestStart": start[k], "earliestFinish": finish[k],
             "latestStart": latest_start[k], "latestFinish": latest_finish[k],
             "slackDays": slack[k], "onCriticalChain": k in critical} for k in ids]
    digest = (f"{total_days}:{'>'.join(critical)}:{min_slack}:"
              + ",".join(str(x) for x in on_time))

    os.makedirs(args.out, exist_ok=True)
    svg_bytes = draw_svg(os.path.join(args.out, "o11_reverse.svg"), cfg["buffers"], on_time)
    report = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
              "target": target, "totalDays": total_days, "bufferDefault": cfg["bufferDefault"],
              "launchDefault": launch_default, "minSlackDays": min_slack, "criticalChain": critical,
              "buffers": cfg["buffers"], "onTimePermille": on_time, "seed": cfg["seed"],
              "trials": cfg["trials"], "jitterDays": cfg["jitterDays"], "rows": rows,
              "digest": digest, "svg": os.path.join(args.out, "o11_reverse.svg"), "svgBytes": svg_bytes}
    with open(os.path.join(args.out, "o11_reverse.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    with open(os.path.join(args.out, "o11_reverse.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        cols = ("id", "name", "leadDays", "earliestStart", "earliestFinish",
                "latestStart", "latestFinish", "slackDays", "onCriticalChain")
        w.writerow(cols)
        for r in rows:
            w.writerow([r[k] for k in cols])

    print(json.dumps({"totalDays": total_days, "criticalChain": critical, "minSlackDays": min_slack,
                      "latestStart": {r["id"]: r["latestStart"] for r in rows},
                      "buffers": cfg["buffers"], "onTimePermille": on_time,
                      "digest": digest}, ensure_ascii=False, indent=2))
    return 0


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

在本机跑 /opt/homebrew/bin/python3 sim/o11_reverse.py --config config/ --out out/,正排 + 倒排结果如下(同机相对量级,绝对数值不可移植):

资质leadDays最早开工最早出证最晚开工最晚出证浮时关键链
company 营业执照000303030是
icp ICP 备案505556055否
soft-copyright 软著登记400408012080否
culture-license 文网文605656012055否
publication-permit 网络出版服务许可证900903012030是
isbn 出版物号(版号)1209021012024030是

假设上线日取默认缓冲(L = 210 + 30 = 240 天),这张表给出几条能直接写进日历的结论。第一,版号必须在上线前 240 天就开始排,其中版号本身最晚第 120 天启动,它的前置网络出版服务许可证最晚第 30 天启动——只留了 30 天浮时。第二,浮时最小的三个节点是 company → publication-permit → isbn,也就是关键链,minSlackDays = 30;而软著(浮时 80)和文网文(浮时 55)看着耗时最长,其实不在关键链上——因为它们在 120 天里被版号的自身耗时吸收了。这正是倒排的洞察:决定上线日期的往往不是最慢的那张证,而是浮时最小的那条链。

缓冲留多少才够?用可播种 LCG 对每个资质的耗时加 ±12 天的对称抖动,跑 2000 次试验,同一批抖动用于所有档位(共同随机数,档位可比),得到按时拿到版号的千分比:

缓冲天数01530456090
按时率(‰)436870996100010001000

零缓冲时只有 436‰(不到一半);留 15 天到 870‰,留 30 天到 996‰,45 天以后基本必达。这不是「留 30 天就够了」的通用结论——它完全取决于 jitterDays 的假设;把 jitterDays 调大,曲线会整体右移。工程上的用法是:把 jitterDays 当成从真实补正数据反标定出来的参数,而不是拍一个数。

X 层:真实发证波动不可复现

必须把边界划清。上面的按时率是模型侧:给定 leadDays 与 jitterDays 的对称抖动假设算出来的。而下面这些属于 X 层,需真实业务环境,命令行不可复现:

  • 真实的发证时长波动(不是对称的 ±12 天,而是有长尾、有批次效应的分布);
  • 真实的补正次数与补正后的重排(一次补正往往让整条关键链重算)。

所以 jitterDays 不是一个可以「实测」的数,而是一个需要真实业务数据反标定的假设。本节不提供任何具体法律意见,也不预测发证结果——倒排可以算,波动不能猜。

自检表

检查项合格标准
正排可复算最早开工/出证由拓扑序决定,totalDays 等于目标资质最早出证日
倒排可复算最晚开工 = 最晚出证 − leadDays,且后继取最紧的那个
浮时可解释slack = latestStart − earliestStart,关键链是浮时最小的一组
随机可播种抖动由 seed + 整数 LCG 生成,禁 Math.random,重跑结果一致
边界清晰真实波动标 X,jitterDays 写成待反标定参数,不写成「实测」

倒排的意义不在于算出一个精确日期,而在于把「资质办理」从一句「尽早办」变成一张有浮时、有缓冲、能反标定的时间表。下一节进入卷四的运营主线:上线之后,钱是怎么在平台和你之间分的——12.1 平台分成与结算周期 。

阅读导航:上一节:11.2 出版物号、文网文与软著 · 下一节:12.1 平台分成与结算周期 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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