上一节把资质画成了有向图并算出可达闭包 ,那是正排:从今天出发,最快多久拿到版号。但运营要回答的是另一个方向的问题——「我计划某个日子上线,那么最晚什么时候必须启动每一张证」。这正是卷三《上线变现与长线运营》 里反复强调的「上线日不是拍脑袋定的」,也是站内游戏产业全景 里各品类发行节奏差异的工程底色。本节把资质链反过来算,并用蒙特卡洛回答一个更现实的问题:缓冲留多少天才够。先声明:本节只做排期仿真,不提供任何具体法律意见,也不预测任何发证结果。
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 营业执照 | 0 | 0 | 0 | 30 | 30 | 30 | 是 |
| icp ICP 备案 | 5 | 0 | 5 | 55 | 60 | 55 | 否 |
| soft-copyright 软著登记 | 40 | 0 | 40 | 80 | 120 | 80 | 否 |
| culture-license 文网文 | 60 | 5 | 65 | 60 | 120 | 55 | 否 |
| publication-permit 网络出版服务许可证 | 90 | 0 | 90 | 30 | 120 | 30 | 是 |
| isbn 出版物号(版号) | 120 | 90 | 210 | 120 | 240 | 30 | 是 |
假设上线日取默认缓冲(L = 210 + 30 = 240 天),这张表给出几条能直接写进日历的结论。第一,版号必须在上线前 240 天就开始排,其中版号本身最晚第 120 天启动,它的前置网络出版服务许可证最晚第 30 天启动——只留了 30 天浮时。第二,浮时最小的三个节点是 company → publication-permit → isbn,也就是关键链,minSlackDays = 30;而软著(浮时 80)和文网文(浮时 55)看着耗时最长,其实不在关键链上——因为它们在 120 天里被版号的自身耗时吸收了。这正是倒排的洞察:决定上线日期的往往不是最慢的那张证,而是浮时最小的那条链。
缓冲留多少才够?用可播种 LCG 对每个资质的耗时加 ±12 天的对称抖动,跑 2000 次试验,同一批抖动用于所有档位(共同随机数,档位可比),得到按时拿到版号的千分比:
| 缓冲天数 | 0 | 15 | 30 | 45 | 60 | 90 |
|---|---|---|---|---|---|---|
| 按时率(‰) | 436 | 870 | 996 | 1000 | 1000 | 1000 |
零缓冲时只有 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 平台分成与结算周期 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。