站内卷三《上线变现与长线运营》 的 10-1 写过备案与资质申报的流程框架,那是运营视角的清单;站内海外合规与税务风险清单 覆盖的是出海那一侧的合规口径。两者的分工很清楚:它们回答「有哪些事要办」,本节回答「这些材料之间是什么依赖、最短要几天、卡在哪一环」——把静态清单变成一台能算时间线的机器。先声明:本节只做排期与材料结构的工程建模,不提供任何具体法律意见,也不预测任何审批结果。
11.1 版号申请材料与时间线
做小游戏的人第一次接触版号,最容易犯的错是把材料当成一张待办清单:逐条打勾,打完就以为能提交。真实情况是材料之间有依赖——软著没批下来,带玩法说明的测试包就不能定稿;自审报告没出,出版单位根本不会出具审核意见。按清单打勾,会在某个环节卡住,然后发现整条链都得往后推。正确的心智模型是有向图,不是清单。
材料是一张有向图:把每份材料看成一个节点,dep → m 这条边表示「必须先备齐 dep,才能办 m」;每个节点带一个 leadDays。于是「最早何时能备齐全部材料」就成了一道关键路径问题:start(m) = max finish(dep)、finish(m) = start(m) + leadDays(m)、total = max finish(m),而关键路径就是从 finish 最大的节点回溯「finish 最大的前置」得到的那条链——任何一环延误都会推迟 total。
这张图还必须校验两个坑:悬空依赖(指向不存在的材料)与循环依赖(A 等 B、B 等 A,永远排不出时间线),连同「缺件」「前置倒挂」一起报出来。
S 层仿真:校验 + 时间线
下面这段是 S 层仿真脚本 sim/o11_material.py。它读 config/o11_material.json,先跑四类校验,再按拓扑序算每份材料的 start/finish、总天数与关键路径,输出 JSON + CSV + 甘特图 SVG。
#!/usr/bin/env python3
# file: sim/o11_material.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/o11_material.py --config config/ --out out/
"""版号申报材料清单校验 + 时间线仿真(整数天,无浮点)。
材料是有向图,edge dep -> m 表示「先备齐 dep 才能办 m」。校验 DANGLING / CYCLE /
MISSING / ORDER 四类问题;时间线按拓扑序算 start/finish,关键路径由 finish 最大的
材料回溯「finish 最大的前置」得到。产出 out/o11_material.json + .csv + .svg。
参数属「公开可观察机制」的建模假设;真实受理时长由监管决定,需真实业务环境,
命令行不可复现(X)。不提供法律意见。
"""
from __future__ import annotations
import argparse, csv, json, os
import xml.etree.ElementTree as ET
def cycle_nodes(index: dict) -> list:
color = {k: 0 for k in index}
stack, out = [], set()
def dfs(k):
color[k] = 1
stack.append(k)
for d in index[k]["dependsOn"]:
if d not in index:
continue
if color[d] == 1:
out.update(stack[stack.index(d):])
elif color[d] == 0:
dfs(d)
stack.pop()
color[k] = 2
for k in sorted(index):
if color[k] == 0:
dfs(k)
return sorted(out)
def issues(cfg: dict, index: dict) -> list:
sub, raw = set(cfg["submitted"]), []
for k in sorted(index):
for d in index[k]["dependsOn"]:
if d not in index:
raw.append(f"DANGLING:{k}>{d}")
if index[k]["required"] and k not in sub:
raw.append(f"MISSING:{k}")
elif k in sub:
raw += [f"ORDER:{k}>{d}" for d in sorted(index[k]["dependsOn"]) if d in index and d not in sub]
return sorted(raw + [f"CYCLE:{k}" for k in cycle_nodes(index)])
def schedule(index: dict):
seen, order = set(), []
def visit(k):
if k in seen:
return
seen.add(k)
for d in sorted(index[k]["dependsOn"]):
if d in index:
visit(d)
order.append(k)
for k in sorted(index):
visit(k)
start, finish = {}, {}
for k in order:
s = max([finish[d] for d in index[k]["dependsOn"] if d in finish] or [0])
start[k], finish[k] = s, s + index[k]["leadDays"]
end = ""
for k in sorted(finish):
if end == "" or finish[k] > finish[end]:
end = k
path, cur = [end], end
while True:
nxt = ""
for d in sorted(index[cur]["dependsOn"]):
if d in finish and (nxt == "" or finish[d] > finish[nxt]):
nxt = d
if nxt == "":
break
path.append(nxt)
cur = nxt
path.reverse()
return start, finish, max(finish.values()), path
def draw_svg(out_path: str, index: dict, start: dict, finish: dict, total: int, path: list) -> int:
"""纯 Python SVG:甘特图,关键路径标红。"""
ids, ROW, TOP, LEFTW, span = sorted(index), 28, 54, 210, 470
W, H = LEFTW + span + 60, TOP + ROW * len(ids) + 16
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 = f"《合成小岛》版号材料时间线(共 {total} 天,红色=关键路径)"
scale = span / max(total, 1)
for i, k in enumerate(ids):
y = TOP + i * ROW
ET.SubElement(root, "text", {"x": "16", "y": str(y + 15), "font-size": "11",
"fill": "#374151"}).text = f'{k} {index[k]["name"][:14]}'
x, w = LEFTW + int(start[k] * scale), max(2, int((finish[k] - start[k]) * scale))
ET.SubElement(root, "rect", {"x": str(x), "y": str(y + 3), "width": str(w), "height": "15",
"rx": "3", "fill": "#ef4444" if k in path else "#93c5fd"})
ET.SubElement(root, "text", {"x": str(x + w + 5), "y": str(y + 15), "font-size": "10",
"fill": "#6b7280"}).text = f"{start[k]}–{finish[k]}"
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_material.json"), encoding="utf-8") as f:
cfg = json.load(f)
index = {m["id"]: m for m in cfg["materials"]}
errs = issues(cfg, index)
start, finish, total, path = schedule(index)
sub = set(cfg["submitted"])
rows = [{"id": k, "name": index[k]["name"], "group": index[k]["group"],
"required": index[k]["required"], "leadDays": index[k]["leadDays"],
"startDay": start[k], "finishDay": finish[k], "onCriticalPath": k in path,
"submitted": k in sub} for k in sorted(index)]
digest = f"{total}:{'>'.join(path)}:{'|'.join(errs)}"
os.makedirs(args.out, exist_ok=True)
svg = draw_svg(os.path.join(args.out, "o11_material.svg"), index, start, finish, total, path)
report = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
"materialCount": len(index), "submittedCount": len(sub), "totalDays": total,
"criticalPath": path, "issueCount": len(errs), "issues": errs, "rows": rows,
"digest": digest, "svg": os.path.join(args.out, "o11_material.svg"), "svgBytes": svg}
with open(os.path.join(args.out, "o11_material.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
with open(os.path.join(args.out, "o11_material.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
cols = ("id", "name", "group", "required", "leadDays", "startDay", "finishDay",
"onCriticalPath", "submitted")
w.writerow(cols)
for r in rows:
w.writerow([r[c] for c in cols])
print(json.dumps({"totalDays": total, "criticalPath": path, "issueCount": len(errs),
"issues": errs, "digest": digest}, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
材料数据放在 config/o11_material.json(D 层配置)。leadDays 与依赖关系都是「公开可观察机制」口径的建模假设,不是任何审批机关的实测;真实受理时长、审查口径、退件补正次数属于 X 层。
{
"version": 1,
"game": "merge-island",
"observedAt": "2026-10",
"note": "版号申报材料清单与依赖关系的建模假设,口径是「公开可观察机制」,不是任何审批机关的实测。leadDays=该材料从依赖就绪到备齐所需的准备耗时(自然日);dependsOn=必须先就绪的材料 id;required=是否为申报必备件;submitted=当前已备齐的材料 id。真实受理时长、审查口径、退件补正次数由监管与出版单位决定,需真实业务环境,命令行不可复现(X)。全整数,无浮点。",
"materials": [
{ "id": "m01", "name": "营业执照(主体资格)", "group": "主体资质", "required": true, "leadDays": 0, "dependsOn": [] },
{ "id": "m02", "name": "ICP 备案与域名证书", "group": "主体资质", "required": true, "leadDays": 5, "dependsOn": ["m01"] },
{ "id": "m03", "name": "计算机软件著作权登记证书", "group": "权属材料", "required": true, "leadDays": 40, "dependsOn": ["m01"] },
{ "id": "m05", "name": "可运行测试包与玩法说明书", "group": "内容材料", "required": true, "leadDays": 14, "dependsOn": ["m03"] },
{ "id": "m07", "name": "防沉迷与未成年人保护方案", "group": "内容材料", "required": true, "leadDays": 5, "dependsOn": ["m05"] },
{ "id": "m08", "name": "内容自审报告", "group": "内容材料", "required": true, "leadDays": 5, "dependsOn": ["m07"] },
{ "id": "m09", "name": "出版单位审核意见", "group": "申报材料", "required": true, "leadDays": 20, "dependsOn": ["m08"] },
{ "id": "m10", "name": "版号申报受理回执", "group": "申报材料", "required": true, "leadDays": 60, "dependsOn": ["m09"] },
{ "id": "m11", "name": "多语言版本授权(选填)", "group": "权属材料", "required": false, "leadDays": 15, "dependsOn": ["m03"] }
],
"submitted": ["m01", "m02", "m03", "m05"]
}
在本机跑 /opt/homebrew/bin/python3 sim/o11_material.py --config config/ --out out/,结果如下(同机相对量级,绝对数值不可移植):
| 材料 | 前置 | leadDays | 起 | 止 | 关键路径 | 已备齐 |
|---|---|---|---|---|---|---|
| m01 营业执照 | — | 0 | 0 | 0 | 是 | 是 |
| m03 软著登记 | m01 | 40 | 0 | 40 | 是 | 是 |
| m05 测试包 | m03 | 14 | 40 | 54 | 是 | 是 |
| m07 防沉迷方案 | m05 | 5 | 54 | 59 | 是 | 否 |
| m08 自审报告 | m07 | 5 | 59 | 64 | 是 | 否 |
| m09 出版单位审核 | m08 | 20 | 64 | 84 | 是 | 否 |
| m10 受理回执 | m09 | 60 | 84 | 144 | 是 | 否 |
这张表最该记住的是两件事:其一,全部备齐的最短天数是 144 天,关键路径是 m01 → m03 → m05 → m07 → m08 → m09 → m10——软著那 40 天和受理那 60 天是最硬的两块,任何一环拖一周,total 就涨一周;其二,校验器报了 4 条 MISSING(m07/m08/m09/m10),因为它们还是 required 却没进 submitted。注意 m11 是选填(required=false),没备齐也不报 MISSING——校验器只拦必备件,不拦你暂时不做的可选项。
P 层内核:跨语言对拍,证明校验器没写错
下面这段是 P 层 TypeScript 内核 core/src/o11/material.ts,复算同一套校验与时间线:同样的三色环检测、同样的拓扑序、同样的关键路径回溯。它读同一份 config/o11_material.json,产出和 Python 逐字符一致的摘要。
// file: core/src/o11/material.ts
// tier: P
// verify: node --test 'core/test/o11_material.test.ts'
//
// 版号材料清单校验 + 时间线内核:与 sim/o11_material.py 逐位等价的 TypeScript 对偶实现。
// 读同一份 config/o11_material.json;全整数、无浮点;只用可擦除语法(禁 enum / namespace)。
export interface Material {
readonly id: string; readonly name: string; readonly group: string;
readonly required: boolean; readonly leadDays: number; readonly dependsOn: readonly string[];
}
export interface MaterialConfig {
readonly version: number; readonly game: string;
readonly materials: readonly Material[]; readonly submitted: readonly string[];
}
export interface PlanRow {
readonly id: string; readonly startDay: number; readonly finishDay: number;
readonly onCriticalPath: boolean; readonly submitted: boolean;
}
export interface PlanResult {
readonly rows: PlanRow[]; readonly totalDays: number;
readonly criticalPath: string[]; readonly issues: string[];
}
const index = (ms: readonly Material[]): Map<string, Material> => new Map(ms.map((m) => [m.id, m]));
/** DFS 三色标记,返回参与环的材料 id(升序)。 */
export function findCycle(ix: Map<string, Material>): string[] {
const color = new Map<string, number>(); for (const k of ix.keys()) color.set(k, 0);
const stack: string[] = []; const cycles = new Set<string>();
const dfs = (k: string): void => {
color.set(k, 1); stack.push(k);
for (const dep of ix.get(k)!.dependsOn) {
if (!ix.has(dep)) continue;
if (color.get(dep) === 1) for (const x of stack.slice(stack.indexOf(dep))) cycles.add(x);
else if (color.get(dep) === 0) dfs(dep);
}
stack.pop(); color.set(k, 2);
};
for (const k of [...ix.keys()].sort()) if (color.get(k) === 0) dfs(k);
return [...cycles].sort();
}
/** 前置在前的拓扑序(依赖按 id 升序遍历,保证与 Python 侧一致)。 */
export function topoOrder(ix: Map<string, Material>): string[] {
const seen = new Set<string>(); const order: string[] = [];
const visit = (k: string): void => {
if (seen.has(k)) return;
seen.add(k);
for (const dep of [...ix.get(k)!.dependsOn].sort()) if (ix.has(dep)) visit(dep);
order.push(k);
};
for (const k of [...ix.keys()].sort()) visit(k);
return order;
}
export function buildIssues(cfg: MaterialConfig, ix: Map<string, Material>): string[] {
const sub = new Set(cfg.submitted); const raw: string[] = [];
for (const k of [...ix.keys()].sort()) {
for (const dep of ix.get(k)!.dependsOn) if (!ix.has(dep)) raw.push(`DANGLING:${k}>${dep}`);
if (ix.get(k)!.required && !sub.has(k)) raw.push(`MISSING:${k}`);
else if (sub.has(k)) for (const dep of [...ix.get(k)!.dependsOn].sort()) if (ix.has(dep) && !sub.has(dep)) raw.push(`ORDER:${k}>${dep}`);
}
return raw.concat(findCycle(ix).map((k) => `CYCLE:${k}`)).sort();
}
export function plan(cfg: MaterialConfig): PlanResult {
const ix = index(cfg.materials); const sub = new Set(cfg.submitted);
const errs = buildIssues(cfg, ix);
const start = new Map<string, number>(); const finish = new Map<string, number>();
for (const k of topoOrder(ix)) {
const s = Math.max(0, ...ix.get(k)!.dependsOn.filter((d) => finish.has(d)).map((d) => finish.get(d)!));
start.set(k, s); finish.set(k, s + ix.get(k)!.leadDays);
}
let end = "";
for (const k of [...finish.keys()].sort()) if (end === "" || finish.get(k)! > finish.get(end)!) end = k;
const path = [end]; let cur = end;
for (;;) {
let nxt = ""; let nf = -1;
for (const dep of [...ix.get(cur)!.dependsOn].sort()) if (finish.has(dep) && finish.get(dep)! > nf) { nxt = dep; nf = finish.get(dep)!; }
if (nxt === "") break;
path.push(nxt); cur = nxt;
}
path.reverse();
const rows: PlanRow[] = [...ix.keys()].sort().map((k) => ({
id: k, startDay: start.get(k)!, finishDay: finish.get(k)!,
onCriticalPath: path.includes(k), submitted: sub.has(k),
}));
return { rows, totalDays: Math.max(...finish.values()), criticalPath: path, issues: errs };
}
/** 跨语言对拍摘要:与 sim/o11_material.py 的 digest 逐字符一致。 */
export const digest = (r: PlanResult): string =>
`${r.totalDays}:${r.criticalPath.join(">")}:${r.issues.join("|")}`;
配套测试 core/test/o11_material.test.ts 把 Python 落盘的摘要写成金标准,断言 TS 复算结果逐字符相等,并顺手验环检测、缺件与前置倒挂。在本机跑 node --test 'core/test/o11_material.test.ts',5 个用例全过;两套独立实现给出同一个摘要:
digest = 144:m01>m03>m05>m07>m08>m09>m10:MISSING:m07|MISSING:m08|MISSING:m09|MISSING:m10
只要有一边的整数语义、拓扑序或关键路径回溯写错,摘要立刻分叉。这和卷二「一份逻辑、两种宿主」 是同一个信念:同一份清单,在 Python 校验器与 TS 内核里必须给出同一个数。
X 层:真实审批不可复现
必须把边界划清。上面算的是模型侧:给定 leadDays 与依赖,最短天数是多少。而下面这些属于 X 层,需真实业务环境,命令行不可复现:
- 真实的受理与审查时长(受申报批次、材料完整度、政策窗口共同影响,波动极大);
- 真实的审查口径与红线、退件与补正次数(同一款玩法不同批次、不同出版单位结果可能不同;一次补正往往就是几十天额外等待)。
工程上的正确做法是把模型层与真实数据层分开:模型层回答「如果软著要 60 天会怎样」,真实数据层用来在申报过程中反标定 leadDays。本节不提供任何具体法律意见,也不预测审批结果——排期可以算,审批不能赌。
自检表
| 检查项 | 合格标准 |
|---|---|
| 依赖成图 | 每份材料都有明确的 dependsOn,没有「默认能办」的隐含前提 |
| 四类校验 | 悬空 / 环 / 缺件 / 前置倒挂都能报出,不静默放过 |
| 关键路径 | 总天数由关键路径决定,且能指出卡在哪一环 |
| 跨语言一致 | Python 与 TS 摘要逐字符相同,边界标 X 不写成「实测」 |
把材料图和时间线算清楚,你才知道版号不是「上线前顺手办一下」,而是一条以月为单位的前置链。下一节把这张图放大到整个资质体系:出版物号、文网文、软著之间谁依赖谁、谁必须先拿、谁会把整条链卡死。
阅读导航:上一节:10.3 合同关键条款清单 · 下一节:11.2 出版物号、文网文与软著 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。