11.1 版号申请材料与时间线

版号申报的材料不是一张清单,而是一张有向图:软著没下来,测试包就没法提交;自审报告没出,出版单位就不会受理。本节把材料依赖画成图,用整数公式算出最短备齐天数与关键路径,配可跑的 Python 仿真与 TypeScript 校验内核跨语言对拍,并标明真实受理时长属不可复现的 X 层。

站内卷三《上线变现与长线运营》 的 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 营业执照—000是是
m03 软著登记m0140040是是
m05 测试包m03144054是是
m07 防沉迷方案m0555459是否
m08 自审报告m0755964是否
m09 出版单位审核m08206484是否
m10 受理回执m096084144是否

这张表最该记住的是两件事:其一,全部备齐的最短天数是 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 出版物号、文网文与软著 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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