上一节把版号材料画成了一张有向图 ,那还只是「申报材料」这一层。再往上看一层,真正的门槛是资质:软著、网络文化经营许可证(文网文)、网络出版服务许可证、ICP 备案……它们不是并列的四张纸,而是一条谁先谁后的依赖链。站内游戏产业全景 与卷三《上线变现与长线运营》 讲的是运营与变现的节奏,站内海外合规与税务风险清单 讲的是出海那一侧的口径,独立游戏开发笔记 里也记过不少「先备案再上线」的踩坑;本节只回答一个工程问题——从今天的资质状态出发,哪些证办得下来、哪些被卡死、拿到版号要走多长的关键链。先声明:本节只做依赖图与排期的建模,不提供任何具体法律意见,也不预测任何发证结果。
11.2 出版物号、文网文与软著
把每个资质看成一个节点,dep → lic 表示「先拿到 dep 才能申请 lic」。给定公司当前已持有的资质集合 obtained,要回答三个问题:其一,可达闭包——反复把「全部前置都已在已持有集合里」的资质加进来,直到不再增长,得到当前能办下来的全部资质;其二,被卡住——闭包之外的那些,说明它们的前置永远凑不齐(或者指向了一个根本不存在的资质);其三,目标链——从版号出发,逐级回溯「出证日最大的前置」,得到拿到版号必须依次办下来的关键链。这三个问题都是纯图论,整数天,无浮点。
S 层仿真:可达闭包与出证时间线
下面这段是 S 层仿真脚本 sim/o11_license_graph.py,它读下面这份 D 层配置表(9 个资质,含 requires 与 leadDays),跑四件事:校验悬空/循环依赖、算可达闭包、按拓扑算层级与最早出证日、回溯版号关键链,并产出 JSON + CSV + 纯 Python SVG。
{ "version": 1, "game": "merge-island", "observedAt": "2026-10", "target": "isbn",
"note": "出版物号 / 文网文 / 软著等资质的依赖关系建模假设,口径是「公开可观察机制」,不是任何发证机关的实测。leadDays=该证从依赖就绪到出证的耗时(自然日);requires=必须先持有的前置资质;obtained=公司当前已持有的资质。真实发证时长、审查口径与是否需要特定前置由监管决定,需真实业务环境,命令行不可复现(X)。全整数,无浮点。",
"licenses": [
{ "id": "company", "name": "公司主体与营业执照", "leadDays": 0, "requires": [] },
{ "id": "icp", "name": "ICP 备案", "leadDays": 5, "requires": ["company"] },
{ "id": "trademark", "name": "游戏名称商标注册", "leadDays": 7, "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": "content-review", "name": "游戏内容自审报告", "leadDays": 20, "requires": ["soft-copyright"] },
{ "id": "isbn", "name": "出版物号(版号)", "leadDays": 120, "requires": ["soft-copyright", "culture-license", "publication-permit", "content-review"] },
{ "id": "overseas-release", "name": "海外发行资质备案(选做)", "leadDays": 30, "requires": ["isbn", "sanction-check"] }
], "obtained": ["company"]
}
#!/usr/bin/env python3
# file: sim/o11_license_graph.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/o11_license_graph.py --config config/ --out out/
"""出版物号 / 文网文 / 软著等资质的依赖图校验与出证时间线。
读 config/o11_license_graph.json:资质是有向图,edge dep -> lic 表示「先拿到 dep 才能
申请 lic」。obtainable = 从 obtained 出发的最小闭包;level = 0 或 1+max level(req);
finish = max finish(req) + leadDays;blocked = 不在 obtainable 者。校验 DANGLING 与
CYCLE;目标链从 config.target 回溯「finish 最大的前置」。全程整数无浮点,产出
.json+.csv+.svg。leadDays 与依赖属「公开可观察机制」建模假设,真实发证时长由监管决定,
需真实业务环境,命令行不可复现(X)。不提供法律意见。
"""
from __future__ import annotations
import argparse, csv, json, os
import xml.etree.ElementTree as ET
def find_cycle(index: dict) -> list:
color, stack, out = {k: 0 for k in index}, [], set()
def dfs(k):
color[k] = 1
stack.append(k)
for req in index[k]["requires"]:
if req not in index:
continue
if color[req] == 1:
out.update(stack[stack.index(req):])
elif color[req] == 0:
dfs(req)
stack.pop()
color[k] = 2
for k in sorted(index):
if color[k] == 0:
dfs(k)
return sorted(out)
def build_issues(index: dict) -> list:
raw = [f"DANGLING:{k}>{r}" for k in sorted(index)
for r in index[k]["requires"] if r not in index]
return sorted(raw + [f"CYCLE:{k}" for k in find_cycle(index)])
def closure(index: dict, obtained: list) -> list:
"""从 obtained 出发,反复纳入「前置全部就绪」的资质,返回升序可达集。"""
have, changed = set(obtained), True
while changed:
changed = False
for k in sorted(index):
if k not in have and all(r in have for r in index[k]["requires"]):
have.add(k)
changed = True
return sorted(have)
def levels_and_finish(index: dict, have: set):
"""在可达集内算层级与最早出证日(前置按 id 升序遍历,保证确定性)。"""
level, finish = {}, {}
def solve(k):
if k in finish:
return
lv, fin = 0, 0
for r in sorted(index[k]["requires"]):
if r in have and r in index:
solve(r)
lv, fin = max(lv, level[r] + 1), max(fin, finish[r])
level[k], finish[k] = lv, fin + index[k]["leadDays"]
for k in sorted(have):
solve(k)
return level, finish
def chain_to(target: str, index: dict, finish: dict) -> list:
if target not in finish:
return []
chain, cur = [target], target
while True:
nxt, nf = "", -1
for r in sorted(index[cur]["requires"]):
if r in finish and finish[r] > nf:
nxt, nf = r, finish[r]
if nxt == "":
break
chain.append(nxt)
cur = nxt
chain.reverse()
return chain
def draw_svg(out_path, index, finish, chain, blocked) -> int:
"""纯 Python SVG:按出证日排的横条,关键链红色、被卡住灰色。"""
ids = sorted(index)
ROW, TOP, LEFTW, span = 30, 58, 220, 450
W, H = LEFTW + span + 70, TOP + ROW * len(ids) + 20
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 = "《合成小岛》资质出证时间线(红色=版号关键链,灰色=被卡住)"
scale = span / max([finish.get(k, 0) for k in ids] + [1])
for i, k in enumerate(ids):
y = TOP + i * ROW
color, note = ("#d1d5db", "blocked") if k in blocked else (
("#ef4444", "critical") if k in chain else ("#93c5fd", ""))
ET.SubElement(root, "text", {"x": "16", "y": str(y + 16), "font-size": "11",
"fill": "#374151"}).text = f'{k} {index[k]["name"][:14]}'
w = max(2, int(finish.get(k, 0) * scale))
ET.SubElement(root, "rect", {"x": str(LEFTW), "y": str(y + 4), "width": str(w),
"height": "16", "rx": "3", "fill": color})
ET.SubElement(root, "text", {"x": str(LEFTW + w + 5), "y": str(y + 16), "font-size": "10",
"fill": "#6b7280"}).text = f'D{finish.get(k, 0)} {note}'
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_license_graph.json"), encoding="utf-8") as f:
cfg = json.load(f)
index = {x["id"]: x for x in cfg["licenses"]}
errs = build_issues(index)
obtainable = closure(index, cfg["obtained"])
have = set(obtainable)
blocked = sorted(k for k in index if k not in have)
level, finish = levels_and_finish(index, have)
target = cfg["target"]
chain = chain_to(target, index, finish)
total = finish.get(target, 0)
rows = [{"id": k, "name": index[k]["name"], "leadDays": index[k]["leadDays"],
"level": level.get(k, -1), "finishDay": finish.get(k, 0),
"obtainable": k in have, "onCriticalChain": k in chain} for k in sorted(index)]
digest = (f"{','.join(obtainable)}:{total}:{'>'.join(chain)}:"
f"{','.join(blocked)}:{'|'.join(errs)}")
os.makedirs(args.out, exist_ok=True)
svg = draw_svg(os.path.join(args.out, "o11_license_graph.svg"), index, finish, chain, set(blocked))
report = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
"target": target, "licenseCount": len(index), "obtainableCount": len(obtainable),
"blockedCount": len(blocked), "obtainable": obtainable, "blocked": blocked,
"totalDays": total, "criticalChain": chain, "issues": errs, "rows": rows,
"digest": digest, "svg": os.path.join(args.out, "o11_license_graph.svg"), "svgBytes": svg}
with open(os.path.join(args.out, "o11_license_graph.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
with open(os.path.join(args.out, "o11_license_graph.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
cols = ("id", "name", "leadDays", "level", "finishDay", "obtainable", "onCriticalChain")
w.writerow(cols)
for r in rows:
w.writerow([r[c] for c in cols])
print(json.dumps({"target": target, "obtainable": obtainable, "blocked": blocked,
"totalDays": total, "criticalChain": chain, "issues": errs,
"digest": digest}, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
在本机跑 /opt/homebrew/bin/python3 sim/o11_license_graph.py --config config/ --out out/,结果如下(同机相对量级,绝对数值不可移植):
| 资质 | 前置 | leadDays | 层级 | 出证日 | 关键链 | 可达 |
|---|---|---|---|---|---|---|
| company 营业执照 | — | 0 | 0 | 0 | 是 | 是 |
| soft-copyright 软著登记 | company | 40 | 1 | 40 | 否 | 是 |
| culture-license 文网文 | company, icp | 60 | 2 | 65 | 否 | 是 |
| publication-permit 网络出版服务许可证 | company | 90 | 1 | 90 | 是 | 是 |
| isbn 出版物号(版号) | soft-copyright, culture-license, publication-permit, content-review | 120 | 3 | 210 | 是 | 是 |
| overseas-release 海外发行备案(选做) | isbn, sanction-check | 30 | — | — | 否 | 否 |
这张表要读三件事。第一,可达闭包是 8 个:只持有营业执照时,除海外发行外的 8 个资质都在闭包里——版号本身可达,只是排在最后。第二,被卡住的是 overseas-release:它的 requires 里写了 sanction-check,而这张图里根本没有这个节点,于是校验器报一条 DANGLING:overseas-release>sanction-check——悬空前置 = 永远凑不齐 = 被卡死,这正是「图比清单强」的地方:清单上写着「办海外发行」,图会告诉你它还缺一个谁都没定义的前置。第三,版号关键链是 company → publication-permit → isbn,出证日 210 天:软著(40 天)、文网文(65 天)、自审(60 天)都在 210 以内被吸收了,真正卡住版号的是网络出版服务许可证那 90 天,加上版号自身的 120 天。层级一栏也印证了这点:版号 level=3 是最深的一层。
P 层内核:跨语言对拍,证明依赖图没算错
下面这段是 P 层 TypeScript 内核 core/src/o11/license.ts,复算同一张图的闭包、层级、出证日与关键链。它读同一份 config/o11_license_graph.json,产出和 Python 逐字符一致的摘要——闭包、总天数、关键链、被卡住集合、校验问题,全部按同一顺序拼接。
// file: core/src/o11/license.ts
// tier: P
// verify: node --test 'core/test/o11_license.test.ts'
//
// 资质依赖图内核:与 sim/o11_license_graph.py 逐位等价的 TypeScript 对偶实现。
// 读同一份 config/o11_license_graph.json;全整数、无浮点;只用可擦除语法(禁 enum / namespace)。
export interface License {
readonly id: string; readonly name: string; readonly leadDays: number; readonly requires: readonly string[]; }
export interface LicenseConfig {
readonly version: number; readonly game: string; readonly target: string;
readonly licenses: readonly License[]; readonly obtained: readonly string[]; }
export interface LicenseRow {
readonly id: string; readonly level: number; readonly finishDay: number;
readonly obtainable: boolean; readonly onCriticalChain: boolean; }
export interface LicenseResult {
readonly rows: LicenseRow[]; readonly obtainable: string[]; readonly blocked: string[];
readonly totalDays: number; readonly criticalChain: string[]; readonly issues: string[]; }
const buildIndex = (ls: readonly License[]): Map<string, License> => new Map(ls.map((x) => [x.id, x]));
export function findCycle(index: Map<string, License>): string[] {
const color = new Map<string, number>(); for (const k of index.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 req of index.get(k)!.requires) {
if (!index.has(req)) continue;
if (color.get(req) === 1) for (const x of stack.slice(stack.indexOf(req))) cycles.add(x);
else if (color.get(req) === 0) dfs(req);
}
stack.pop(); color.set(k, 2);
};
for (const k of [...index.keys()].sort()) if (color.get(k) === 0) dfs(k);
return [...cycles].sort();
}
export function buildIssues(index: Map<string, License>): string[] {
const raw: string[] = [];
for (const k of [...index.keys()].sort())
for (const req of index.get(k)!.requires) if (!index.has(req)) raw.push(`DANGLING:${k}>${req}`);
return raw.concat(findCycle(index).map((k) => `CYCLE:${k}`)).sort();
}
/** 从 obtained 出发的最小闭包:反复纳入「前置全部就绪」的资质。 */
export function closure(index: Map<string, License>, obtained: readonly string[]): string[] {
const have = new Set<string>(obtained); let changed = true;
while (changed) { changed = false;
for (const k of [...index.keys()].sort())
if (!have.has(k) && index.get(k)!.requires.every((r) => have.has(r))) { have.add(k); changed = true; } }
return [...have].sort();
}
export function solve(index: Map<string, License>, have: Set<string>): { level: Map<string, number>; finish: Map<string, number> } {
const level = new Map<string, number>(), finish = new Map<string, number>();
const go = (k: string): void => {
if (finish.has(k)) return;
let lv = 0, fin = 0;
for (const r of [...index.get(k)!.requires].sort())
if (have.has(r) && index.has(r)) { go(r); lv = Math.max(lv, level.get(r)! + 1); fin = Math.max(fin, finish.get(r)!); }
level.set(k, lv); finish.set(k, fin + index.get(k)!.leadDays);
};
for (const k of [...have].sort()) go(k);
return { level, finish };
}
export function chainTo(target: string, index: Map<string, License>, finish: Map<string, number>): string[] {
if (!finish.has(target)) return [];
const chain = [target]; let cur = target;
for (;;) {
let best = ""; let bestF = -1;
for (const r of [...index.get(cur)!.requires].sort()) if (finish.has(r) && finish.get(r)! > bestF) { best = r; bestF = finish.get(r)!; }
if (best === "") break;
chain.push(best); cur = best;
}
chain.reverse();
return chain;
}
export function resolve(cfg: LicenseConfig): LicenseResult {
const index = buildIndex(cfg.licenses);
const obtainable = closure(index, cfg.obtained);
const have = new Set(obtainable);
const blocked = [...index.keys()].filter((k) => !have.has(k)).sort();
const { level, finish } = solve(index, have);
const chain = chainTo(cfg.target, index, finish);
const rows: LicenseRow[] = [...index.keys()].sort().map((k) => ({
id: k, level: level.get(k) ?? -1, finishDay: finish.get(k) ?? 0,
obtainable: have.has(k), onCriticalChain: chain.includes(k) }));
return { rows, obtainable, blocked, totalDays: finish.get(cfg.target) ?? 0, criticalChain: chain, issues: buildIssues(index) };
}
/** 跨语言对拍摘要:与 sim/o11_license_graph.py 的 digest 逐字符一致。 */
export const digest = (r: LicenseResult): string =>
`${r.obtainable.join(",")}:${r.totalDays}:${r.criticalChain.join(">")}:`
+ `${r.blocked.join(",")}:${r.issues.join("|")}`;
配套测试 core/test/o11_license.test.ts 把 Python 落盘的摘要写成金标准,断言 TS 复算结果逐字符相等,并顺手验可达闭包、层级、关键链与悬空前置。在本机跑 node --test 'core/test/o11_license.test.ts',5 个用例全过;两套独立实现给出同一个摘要:
digest = company,content-review,culture-license,icp,isbn,publication-permit,soft-copyright,trademark:210:company>publication-permit>isbn:overseas-release:DANGLING:overseas-release>sanction-check
X 层:真实发证不可复现
必须把边界划清。上面算的是模型侧:给定 leadDays 与 requires,哪些证办得下来、版号要走多长。而下面这些属于 X 层,需真实业务环境,命令行不可复现:
- 真实的发证时长与受理口径、真实的前置要求(受批次、材料完整度、政策窗口与地区差异共同影响,波动极大)。
工程上的正确做法是把模型层与真实数据层分开:模型层回答「如果网络出版服务许可证要 180 天会怎样」,真实数据层用来在办证过程中反标定 leadDays。本节不提供任何具体法律意见,也不预测发证结果——依赖可以算,发证不能赌。
自检表
| 检查项 | 合格标准 |
|---|---|
| 依赖成图 | 每个资质都有明确的 requires,没有「默认能办」的隐含前提 |
| 悬空/环校验 | 指向不存在资质的 DANGLING 与循环依赖的 CYCLE 都能报出 |
| 可达闭包与关键链 | 最小闭包可复算、被卡住集合可解释,总天数由最长路径决定;边界标 X 不写成「实测」 |
把资质图算清楚,你才会明白版号不是「上线前顺手办一下」,而是一条以月为单位、且会被某个前置许可证单独卡住的依赖链。下一节把这张图反过来用:倒排——从计划上线日往回推,每张证最晚什么时候必须启动,缓冲留多少才不至于翻车。
阅读导航:上一节:11.1 版号申请材料与时间线 · 下一节:11.3 资质倒排与上线前置 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。