10.3 合同关键条款清单

合同不该靠记性检查。本节把代理发行合同拆成一份可校验的条款清单:用 D 层脚本判定每类条款是「齐 / 不齐 / 缺」,算出加权风险分与三档覆盖率;再用 P 层 TypeScript 内核跨语言对拍,证明清单判定逐位一致。只做 H5 小游戏口径的清单化,不重写合同范本。

站内那篇代理发行合同与谈判指南 已经给了完整的合同范本与逐条解读,本节绝不重写它。上一节把「底线分成」算出来了,但底线要落进合同才有意义。《上线变现与长线运营》 讲了怎么把钱收回来,本节补最后一环:怎么确认一份合同把该写的都写了。范本解决的是「条款该怎么措辞」,本节解决的是「条款齐不齐、写全没有」——把它做成一份可校验的清单,而不是靠人肉逐条比对。

为什么用清单,而不是范本

范本是写作工具:告诉你一句话该怎么写。清单是检查工具:告诉你有哪几件事必须写、每件事下面有几个子项、漏了哪个。两者互补,但用途完全不同。合同最危险的错误从来不是「措辞不好」,而是某条根本不存在——你谈好了分成,合同里却漏了「审计与对账权」;你约定了保底,却没写「保底如何回收」。这些漏项在签约时看不出来,在结算时才会咬人。

所以本节的目标很具体:把一份代理发行合同拆成九类关键条款,每类标注是否必备、风险权重、以及必填子项,然后对一份待检合同逐类判定三态——complete(存在且子项写全,通过)/ incomplete(存在但必填子项没写全,回看补齐)/ missing(根本没出现,必须补)。判定不是「有 / 无」的二元:因为「写了但没写全」和「压根没写」是两种不同的风险,处置方式也不同。

条款分类与风险权重

九类条款按必备性分两组,按风险权重分三档。必备条款缺失后果最重,风险权重也最高:

条款必备权重必填子项
授权范围与发行区域是3territory / platforms / exclusivity
分成比例与结算是3sharePermille / settlementCycle / reporting
知识产权归属是3ipOwner / derivative / revert
终止与违约是3terminationForCause / cure / survival
授权期限与续约是2startDate / endDate / renewal
保底与预付否2minGuarantee / recoup
审计与对账权否2auditRight / recordKeep
数据与用户归属否2dataOwnership / privacy
保密否1confidentiality / term

权重不是随便给的:授权范围、分成、知识产权、终止这四类一旦出问题,会直接影响你能不能拿回游戏、能不能拿到钱,所以权重最高;保底、审计、数据是「有更好」的增强项;保密是标配但影响面小。风险分按「权重 × 档位」累加,再对「全条款全缺失」归一成千分比。

D 层:清单齐备性校验脚本

下面这段是 D 层校验脚本 sim/o10_clauses_check.py。它读 config/o10_clauses.json 的条款定义与一份待检合同,逐类判定三态、算加权风险分与三档覆盖率,给出 PASS / REVIEW / BLOCK 判定,产出 JSON + CSV。

#!/usr/bin/env python3
# file: sim/o10_clauses_check.py
# tier: D
# verify: /opt/homebrew/bin/python3 sim/o10_clauses_check.py --config config/ --out out/
"""代理发行合同「关键条款清单」齐备性校验(D 层:清单结构 / lint 校验)。

读 config/o10_clauses.json,对每类条款判定三态:missing(没出现)/ incomplete(出现但
必填子项没写全)/ complete(写全)。风险分按 权重 × 档位(必备缺失 ×3000‰、必备不齐
×1000‰、可选缺失 ×1000‰、可选不齐 ×0‰)求和,再对「全条款全缺失」归一成千分比。
判定:任一必备缺失 → BLOCK;任一必备不齐 → REVIEW;否则 PASS。
全程整数、地板除,无浮点。产出 out/o10_clauses_check.json + .csv。
清单只是「结构与齐备性」的清单化,不是真实合同范本;法律效力需真实业务环境,命令行
不可复现(X)。本文不提供任何法律意见。
"""
from __future__ import annotations

import argparse
import csv
import json
import os


def load(d):
    with open(os.path.join(d, "o10_clauses.json"), encoding="utf-8") as f:
        return json.load(f)


def check(cfg):
    w = cfg["riskWeightPermille"]
    present = {c["typeId"]: set(c["fields"]) for c in cfg["contract"]}
    rows, risk, max_risk = [], 0, 0
    counts = {"complete": 0, "incomplete": 0, "missing": 0}
    ft = fp = mft = mfp = mm = mi = 0
    for t in cfg["clauseTypes"]:
        req, have = t["fields"], present.get(t["id"], set())
        if t["id"] not in present:
            status = "missing"
        elif set(req) <= have:
            status = "complete"
        else:
            status = "incomplete"
        counts[status] += 1
        miss = [f for f in req if f not in have]
        if status == "missing":
            risk += t["weight"] * (w["mandatoryMissing"] if t["mandatory"] else w["optionalMissing"]) // 1000
        elif status == "incomplete":
            risk += t["weight"] * (w["mandatoryIncomplete"] if t["mandatory"] else w["optionalIncomplete"]) // 1000
        max_risk += t["weight"] * w["mandatoryMissing"] // 1000
        ft += len(req)
        fp += sum(1 for f in req if f in have)
        if t["mandatory"]:
            mft += len(req)
            mfp += sum(1 for f in req if f in have)
            mm += status == "missing"
            mi += status == "incomplete"
        rows.append({"typeId": t["id"], "name": t["name"], "mandatory": t["mandatory"],
                     "weight": t["weight"], "status": status, "missingFields": miss,
                     "presentFields": [f for f in req if f in have]})
    clause_cov = len(present) * 1000 // len(cfg["clauseTypes"])
    field_cov = fp * 1000 // ft if ft else 0
    mand_field_cov = mfp * 1000 // mft if mft else 0
    risk_permille = risk * 1000 // max_risk if max_risk else 0
    verdict = "BLOCK" if mm else ("REVIEW" if mi else "PASS")
    return {"rows": rows, "counts": counts, "riskPoints": risk, "maxRiskPoints": max_risk,
            "riskPermille": risk_permille, "clauseCoveragePermille": clause_cov,
            "fieldCoveragePermille": field_cov, "mandatoryFieldCoveragePermille": mand_field_cov,
            "mandatoryMissing": mm, "mandatoryIncomplete": mi, "verdict": verdict}


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    a = ap.parse_args()
    cfg = load(a.config)
    rep = check(cfg)
    c = rep["counts"]
    digest = (f'present={len(cfg["contract"])},complete={c["complete"]},incomplete={c["incomplete"]},'
              f'missing={c["missing"]},risk={rep["riskPoints"]},riskPermille={rep["riskPermille"]},'
              f'clause={rep["clauseCoveragePermille"]},field={rep["fieldCoveragePermille"]},'
              f'mandField={rep["mandatoryFieldCoveragePermille"]},verdict={rep["verdict"]}')
    os.makedirs(a.out, exist_ok=True)
    with open(os.path.join(a.out, "o10_clauses_check.csv"), "w", encoding="utf-8", newline="") as f:
        wr = csv.writer(f)
        wr.writerow(("typeId", "name", "mandatory", "weight", "status", "presentFields", "missingFields"))
        for r in rep["rows"]:
            wr.writerow([r["typeId"], r["name"], r["mandatory"], r["weight"], r["status"],
                         "|".join(r["presentFields"]), "|".join(r["missingFields"])])
    report = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
              "reviewThresholdPermille": cfg["reviewThresholdPermille"], **rep, "digest": digest,
              "csv": os.path.join(a.out, "o10_clauses_check.csv")}
    with open(os.path.join(a.out, "o10_clauses_check.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    print(json.dumps({"counts": c, "riskPoints": rep["riskPoints"], "riskPermille": rep["riskPermille"],
                      "clauseCoveragePermille": rep["clauseCoveragePermille"],
                      "fieldCoveragePermille": rep["fieldCoveragePermille"],
                      "mandatoryFieldCoveragePermille": rep["mandatoryFieldCoveragePermille"],
                      "mandatoryMissing": rep["mandatoryMissing"], "verdict": rep["verdict"],
                      "digest": digest}, ensure_ascii=False, indent=2))
    return 0


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

条款定义与待检合同放在 config/o10_clauses.json(D 层配置)。它只是「结构与齐备性」的清单化,不是真实合同范本,也不构成法律意见:

{
  "version": 1,
  "game": "merge-island",
  "observedAt": "2026-10",
  "note": "代理发行合同关键条款清单与齐备性校验的建模数据。口径是「行业惯例」的公开可观察机制,只是「条款是否被覆盖 / 是否写全必填子项」的清单化结构,不是任何真实合同范本,也不构成法律意见。clauseTypes[].mandatory=是否必备条款;weight=风险权重;fields=该条款必填子项。contract[]=一份待检合同里已出现的条款及其已写子项。真实合同范本与法律效力需真实业务环境,命令行不可复现(X)。全整数,无浮点。",
  "riskWeightPermille": { "mandatoryMissing": 3000, "mandatoryIncomplete": 1000, "optionalMissing": 1000, "optionalIncomplete": 0 },
  "reviewThresholdPermille": 300,
  "clauseTypes": [
    { "id": "scope", "name": "授权范围与发行区域", "weight": 3, "mandatory": true, "fields": ["territory", "platforms", "exclusivity"] },
    { "id": "term", "name": "授权期限与续约", "weight": 2, "mandatory": true, "fields": ["startDate", "endDate", "renewal"] },
    { "id": "revshare", "name": "分成比例与结算", "weight": 3, "mandatory": true, "fields": ["sharePermille", "settlementCycle", "reporting"] },
    { "id": "ip", "name": "知识产权归属", "weight": 3, "mandatory": true, "fields": ["ipOwner", "derivative", "revert"] },
    { "id": "termination", "name": "终止与违约", "weight": 3, "mandatory": true, "fields": ["terminationForCause", "cure", "survival"] },
    { "id": "mg", "name": "保底与预付", "weight": 2, "mandatory": false, "fields": ["minGuarantee", "recoup"] },
    { "id": "audit", "name": "审计与对账权", "weight": 2, "mandatory": false, "fields": ["auditRight", "recordKeep"] },
    { "id": "confidential", "name": "保密", "weight": 1, "mandatory": false, "fields": ["confidentiality", "term"] },
    { "id": "data", "name": "数据与用户归属", "weight": 2, "mandatory": false, "fields": ["dataOwnership", "privacy"] }
  ],
  "contract": [
    { "typeId": "scope", "fields": ["territory", "platforms", "exclusivity"] },
    { "typeId": "term", "fields": ["startDate", "endDate"] },
    { "typeId": "revshare", "fields": ["sharePermille", "settlementCycle"] },
    { "typeId": "ip", "fields": ["ipOwner", "derivative", "revert"] },
    { "typeId": "termination", "fields": ["terminationForCause", "cure"] },
    { "typeId": "confidential", "fields": ["confidentiality"] }
  ]
}

在本机跑 /opt/homebrew/bin/python3 sim/o10_clauses_check.py --config config/ --out out/,这份待检合同的结果是(同机相对量级,绝对数值不可移植):

条款必备权重状态缺的子项
授权范围与发行区域是3complete—
分成比例与结算是3incompletereporting
知识产权归属是3complete—
终止与违约是3incompletesurvival
授权期限与续约是2incompleterenewal
保密否1incompleteterm
保底与预付否2missing整条
审计与对账权否2missing整条
数据与用户归属否2missing整条

统计下来是 complete 2 / incomplete 4 / missing 3,加权风险分 14 分(满分 63 分),风险千分比 222‰;三档覆盖率是条款 666‰、子项 565‰、必备子项 800‰。判定 REVIEW——没有必备条款整条缺失(否则 BLOCK),但有三条必备条款「写了没写全」(reporting / survival / renewal),必须回看补齐。这正是三态的价值:如果只看「必备条款在不在」,这份合同是 100% 覆盖的假象;三态才暴露出三个隐蔽的漏洞。

P 层内核:跨语言对拍,证明判定没写错

一张表看不出「是判定对」还是「碰巧看着对」。本套用一份 P 层 TypeScript 内核 core/src/o10/clauses.ts 复算同一套判定:同样的三态、同样的权重、同样的归一化。它读同一份 config/o10_clauses.json,产出与 Python 逐字符一致的摘要。

// file: core/src/o10/clauses.ts
// tier: P
// verify: node --test 'core/test/o10_*.test.ts'
//
// 合同关键条款清单齐备性内核:与 sim/o10_clauses_check.py 逐位等价的 TypeScript 对偶实现。
// 读同一份 config/o10_clauses.json;全整数、地板除、无浮点;只用可擦除语法(禁 enum / namespace)。

export interface ClauseType {
  readonly id: string; readonly name: string; readonly weight: number;
  readonly mandatory: boolean; readonly fields: readonly string[];
}
export interface ClauseConfig {
  readonly version: number; readonly game: string;
  readonly riskWeightPermille: {
    readonly mandatoryMissing: number; readonly mandatoryIncomplete: number;
    readonly optionalMissing: number; readonly optionalIncomplete: number;
  };
  readonly reviewThresholdPermille: number;
  readonly clauseTypes: readonly ClauseType[];
  readonly contract: readonly { readonly typeId: string; readonly fields: readonly string[] }[];
}
export interface ClauseRow {
  readonly typeId: string; readonly name: string; readonly mandatory: boolean;
  readonly weight: number; readonly status: string; readonly missingFields: readonly string[];
}
export interface ClauseResult {
  readonly rows: ClauseRow[]; readonly complete: number; readonly incomplete: number;
  readonly missing: number; readonly riskPoints: number; readonly maxRiskPoints: number;
  readonly riskPermille: number; readonly clauseCoveragePermille: number;
  readonly fieldCoveragePermille: number; readonly mandatoryFieldCoveragePermille: number;
  readonly mandatoryMissing: number; readonly mandatoryIncomplete: number; readonly verdict: string;
}

/** 齐备性校验:三态判定 + 加权风险分 + 覆盖率 + 判定。 */
export function check(cfg: ClauseConfig): ClauseResult {
  const w = cfg.riskWeightPermille;
  const present = new Map<string, Set<string>>();
  for (const c of cfg.contract) present.set(c.typeId, new Set(c.fields));
  const rows: ClauseRow[] = [];
  let risk = 0; let maxRisk = 0;
  let complete = 0; let incomplete = 0; let missing = 0;
  let fieldTotal = 0; let fieldPresent = 0; let mandFieldTotal = 0; let mandFieldPresent = 0;
  let mandMissing = 0; let mandIncomplete = 0;
  for (const t of cfg.clauseTypes) {
    const have = present.get(t.id) ?? new Set<string>();
    let status: string;
    if (!present.has(t.id)) status = "missing";
    else if (t.fields.every((f) => have.has(f))) status = "complete";
    else status = "incomplete";
    if (status === "complete") complete += 1;
    else if (status === "incomplete") incomplete += 1;
    else missing += 1;
    const missingFields = t.fields.filter((f) => !have.has(f));
    if (status === "missing") {
      risk += Math.floor((t.weight * (t.mandatory ? w.mandatoryMissing : w.optionalMissing)) / 1000);
    } else if (status === "incomplete") {
      risk += Math.floor((t.weight * (t.mandatory ? w.mandatoryIncomplete : w.optionalIncomplete)) / 1000);
    }
    maxRisk += Math.floor((t.weight * w.mandatoryMissing) / 1000);
    fieldTotal += t.fields.length;
    fieldPresent += t.fields.filter((f) => have.has(f)).length;
    if (t.mandatory) {
      mandFieldTotal += t.fields.length;
      mandFieldPresent += t.fields.filter((f) => have.has(f)).length;
      if (status === "missing") mandMissing += 1;
      else if (status === "incomplete") mandIncomplete += 1;
    }
    rows.push({ typeId: t.id, name: t.name, mandatory: t.mandatory, weight: t.weight, status, missingFields });
  }
  const clauseCov = Math.floor((cfg.contract.length * 1000) / cfg.clauseTypes.length);
  const fieldCov = fieldTotal ? Math.floor((fieldPresent * 1000) / fieldTotal) : 0;
  const mandFieldCov = mandFieldTotal ? Math.floor((mandFieldPresent * 1000) / mandFieldTotal) : 0;
  const riskPermille = maxRisk ? Math.floor((risk * 1000) / maxRisk) : 0;
  const verdict = mandMissing ? "BLOCK" : (mandIncomplete ? "REVIEW" : "PASS");
  return { rows, complete, incomplete, missing, riskPoints: risk, maxRiskPoints: maxRisk,
    riskPermille, clauseCoveragePermille: clauseCov, fieldCoveragePermille: fieldCov,
    mandatoryFieldCoveragePermille: mandFieldCov, mandatoryMissing: mandMissing,
    mandatoryIncomplete: mandIncomplete, verdict };
}

/** 跨语言对拍摘要:与 sim/o10_clauses_check.py 的 digest 逐字符一致。 */
export function digest(cfg: ClauseConfig, r: ClauseResult): string {
  return `present=${cfg.contract.length},complete=${r.complete},incomplete=${r.incomplete},`
    + `missing=${r.missing},risk=${r.riskPoints},riskPermille=${r.riskPermille},`
    + `clause=${r.clauseCoveragePermille},field=${r.fieldCoveragePermille},`
    + `mandField=${r.mandatoryFieldCoveragePermille},verdict=${r.verdict}`;
}

配套测试 core/test/o10_clauses.test.ts 把 Python 落盘的摘要写成金标准,断言 TS 内核复算结果逐字符相等,并顺手验三态判定、风险分加权与判定阈值。在本机跑 node --test 'core/test/o10_*.test.ts',6 个用例全过:

✔ 跨语言一致:TS 内核摘要 == Python 金标准
✔ 三态判定:齐 / 不齐 / 缺各归其位
✔ 判定:无必备缺失、但有必备不齐 → REVIEW(非 PASS、非 BLOCK)
✔ 覆盖率:条款 666‰、子项 565‰、必备子项 800‰
✔ 缺项可定位:term 缺 renewal、revshare 缺 reporting
ℹ tests 6 / pass 6 / fail 0

两套独立实现给出同一个摘要:

digest = present=6,complete=2,incomplete=4,missing=3,risk=14,riskPermille=222,clause=666,field=565,mandField=800,verdict=REVIEW

只要有一边的三态判据、权重方向或归一化口径写错,摘要立刻分叉。这与卷二「一份逻辑、两种宿主」 是同一个信念:同一份清单,在 Python 校验器与 TS 内核里必须判得一模一样。

X 层:真实合同与法律效力不可复现

必须把边界划清。上面算的是清单侧:给定条款定义与一份合同,齐备性判定是什么。下面这些属于 X 层,需真实业务环境,命令行不可复现:

  • 真实的合同范本与条款措辞(各发行商、各法域差异极大,本节只做「有没有写」的清单化,不评价措辞是否有利);
  • 条款的法律效力与可执行性(一条写了的条款是否真的能约束对方,取决于适用法律与司法实践,本节不提供任何法律意见);
  • 真实的谈判与签署过程(哪些条款能谈、哪些是发行商的铁律,是人与关系的事)。

工程上正确的做法是:把可复现的清单校验与不可复现的法律判断分开。清单保证「该写的一个不漏」,但清单说「齐了」不等于「安全了」——条款齐备只是及格线,条款内容是否对你有利、是否可执行,必须交给专业人士逐条审阅。本节能帮你的是:在找律师之前,先把明显缺失和写漏的地方补上。

自检表

检查项合格标准
三态而非二元区分「缺」与「不齐」,不把两者混为一谈
权重有依据必备条款权重高,可选增强项权重低
覆盖率三算条款 / 子项 / 必备子项分别算,不只报一个
判定分级必备缺失 BLOCK、必备不齐 REVIEW、否则 PASS
跨语言一致Python 校验器与 TS 内核摘要逐字符相同
边界清晰真实范本与法律效力标 X;不写成「实测」,不提供法律意见

到这里,第 10 章把「要不要代理 → 底线在哪 → 合同怎么检」串成了一条闭环:10.1 决定要不要找发行商,10.2 算出谈判的红线,10.3 保证红线被写进合同。合同签了只是开始——真正要落地发行,还缺一块硬骨头:资质与版号。下一章我们处理它。

阅读导航:上一节:10.2 分成谈判的筹码与底线 · 下一节:11.1 版号申请材料与时间线 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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