15.3 协作与版本管理

叙事策划与程序在规格上分工:策划写结构与条件,程序写内核与门禁,规格是两者唯一的接口。本节给出规格变更的机器可读 diff 与影响面评审(本机可复现),并把真实团队的评审会、冻结窗口与排期逐处标注为需真实业务环境、命令行不可复现,把能算与不能算的分开。

15.3 协作与版本管理

《游戏设计》卷一《玩法机制与可执行规格》 把机制规格当作策划与程序之间的接口:策划调数值,程序读数值,谁都不改对方的文件。本卷的接口换成结构规格 config/story_spec.json——策划写世界、角色、弧光、节拍、分支与条件,程序写穷举、内核与门禁。两套共享「规格是唯一接口、变更要走影响面评审」的纪律,但接口内容不同:那边的接口是数,这边的接口是条件与跳转。

三条线:策划、程序、规格

规格能当接口,前提是三方的写入范围互不重叠。下面这张表把「谁能写什么」写死;这张表是本节的工程主张,而它落地成真实团队的分工、权限与交接方式,需真实业务环境,命令行不可复现。

角色写什么不写什么交付物
叙事策划config/story_spec.json 的 world / characters / beats / dialogue / endings任何脚本、任何内核代码规格的内容变更
程序sim/ / core/ / web/ 的脚本与门禁规格里的台词、条件语义、阈值取值规格的校验能力
共同新增字段的语义(要两人都同意)单方面改字段含义字段表与迁移脚本

第三条是这条分工里最容易出问题的一格。加一个字段看起来是程序的事,但字段的语义是策划的事;反过来,策划想加一个「隐藏好感度」也是加字段,但读它的条件求值器得程序来写。凡是「加字段」,都必须两人同时在场——这不是流程洁癖,而是因为字段一旦落地,规格、内核、门禁三处都要跟着动。

分工失守的典型形态是越界写入:策划为了赶一个分支,直接在 dialogue 里手改了一个条件的语义(把 >= 改成 >),程序为了修一个边界,直接在规格里补了一条 add。两处都「跑得通」,但前者让已落盘的判据失效,后者让「声明域 = 状态域」这条不变量被悄悄破坏。接口的价值不在于它规定了什么,而在于它排除了什么——排除掉「随手改一下」,跨语言对照才有稳定的基准。

规格的评审与冻结

规格既然是接口,就不能随时改。真实团队里的做法是冻结窗口:某个里程碑之后,config/story_spec.json 进入只读状态,改动需要走一次评审。这套流程本机没有,需真实业务环境,命令行不可复现;本节能做的只是把评审需要的证据变成机器可读的,让评审会不必靠人对记忆。

冻结期里可复现的部分只有两件:一是变更必须是纯增量的(删键为零、改值只发生在版本号上),二是每一处变更都附上影响面。前者由脚本判定,后者由脚本统计——两件都不需要开会。

S 层:变更的机器可读 diff

下面的脚本把 config/story_spec.json(v1)与 out/spec_v2.json(v2,由迁移脚本产出)做一次结构 diff,落成 out/spec_diff_arch.json。它回答两个评审问题:这次变更是纯增量的吗?触及的每一段,各有多少个源码文件在读它?非增量变更直接非零退出——这是把「不许改旧字段语义」从口头纪律变成可判定的门禁。

#!/usr/bin/env python3
# file: sim/na_15_3_spec_diff.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/na_15_3_spec_diff.py
"""卷一 15.3 协作与版本管理(S 层):规格变更的机器可读 diff 与影响面。

本机可复现的部分:把 config/story_spec.json(v1)与 out/spec_v2.json(v2)
做一次结构 diff,落成 out/spec_diff_arch.json,并回答两个评审问题——
  1) 这次变更是**纯增量**的吗(删键 0 个、改值只发生在版本号上)?
  2) 触及的每一段,各有多少个文件在读它(变更影响面)?

真实团队的评审会 / 排期 / 冻结流程本机没有,属 X 档,正文逐处标注。
产出 out/spec_diff_arch.json。非增量变更即非零退出。
"""
from __future__ import annotations

import json
import os
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
SCAN_DIRS = ("sim", "core/src/inkwell", "core/test", "web")
SCAN_EXT = (".py", ".ts", ".mjs")
# 允许改值的位置:版本号本身。除此之外任何改值都说明这次变更不是纯增量。
ALLOWED_CHANGED = {"meta.spec_version"}


def load(rel: str) -> dict:
    with open(os.path.join(ROOT, rel), encoding="utf-8") as fh:
        return json.load(fh)


def diff(a, b, path: str = "") -> tuple[list[str], list[str], list[dict]]:
    """返回 (added, removed, changed)。列表按索引逐项递归,路径形如 beats[0].pov。"""
    added: list[str] = []
    removed: list[str] = []
    changed: list[dict] = []
    if isinstance(a, dict) and isinstance(b, dict):
        for k in sorted(set(a) | set(b)):
            p = f"{path}.{k}" if path else k
            if k not in a:
                added.append(p)
            elif k not in b:
                removed.append(p)
            else:
                da, dr, dc = diff(a[k], b[k], p)
                added += da
                removed += dr
                changed += dc
    elif isinstance(a, list) and isinstance(b, list):
        for i in range(max(len(a), len(b))):
            p = f"{path}[{i}]"
            if i >= len(a):
                added.append(p)
            elif i >= len(b):
                removed.append(p)
            else:
                da, dr, dc = diff(a[i], b[i], p)
                added += da
                removed += dr
                changed += dc
    elif a != b:
        changed.append({"path": path, "from": a, "to": b})
    return added, removed, changed


def consumers(section: str) -> dict:
    """谁在读这一段:扫源码里出现的 `["<section>"]` 取值形式。"""
    hits: list[str] = []
    for rel in SCAN_DIRS:
        d = os.path.join(ROOT, rel)
        if not os.path.isdir(d):
            continue
        for f in sorted(os.listdir(d)):
            if not f.endswith(SCAN_EXT) or not os.path.isfile(os.path.join(d, f)):
                continue
            with open(os.path.join(d, f), encoding="utf-8") as fh:
                if f'["{section}"]' in fh.read():
                    hits.append(f"{rel}/{f}")
    return {"referrers": len(hits), "examples": hits[:3]}


def section_of(path: str) -> str:
    return path.split(".")[0].split("[")[0]


def main() -> int:
    problems: list[str] = []
    v1 = load("config/story_spec.json")
    if not os.path.isfile(os.path.join(ROOT, "out/spec_v2.json")):
        print("FAIL 缺 out/spec_v2.json,先跑 /opt/homebrew/bin/python3 sim/na_02_migrate.py")
        return 1
    v2 = load("out/spec_v2.json")

    added, removed, changed = diff(v1, v2)

    if removed:
        problems.append(f"出现删除键 {len(removed)} 个:{removed[:3]}")
    off = [c["path"] for c in changed if c["path"] not in ALLOWED_CHANGED]
    if off:
        problems.append(f"改值超出允许集 {sorted(ALLOWED_CHANGED)}:{off[:3]}")

    touched: dict[str, int] = {}
    for p in added + removed + [c["path"] for c in changed]:
        s = section_of(p)
        touched[s] = touched.get(s, 0) + 1
    impact = {s: consumers(s) for s in sorted(touched)}

    report = {
        "ok": not problems,
        "problems": problems,
        "from": "config/story_spec.json",
        "to": "out/spec_v2.json",
        "added": len(added),
        "removed": removed,
        "changed": changed,
        "touched_sections": touched,
        "impact": impact,
    }
    out = os.path.join(ROOT, "out/spec_diff_arch.json")
    os.makedirs(os.path.dirname(out), exist_ok=True)
    with open(out, "w", encoding="utf-8") as fh:
        json.dump(report, fh, ensure_ascii=False, indent=2)
        fh.write("\n")

    print("v1 → v2 结构 diff(v2 由 sim/na_02_migrate.py 产出)")
    print(f"  新增键 {len(added)} 个,删除键 {len(removed)} 个,改值 {len(changed)} 处")
    for c in changed:
        print(f"    改值 {c['path']}:{c['from']} → {c['to']}")
    print(f"  触及段 {len(touched)} 个:" + "、".join(f"{s}×{n}" for s, n in sorted(touched.items())))
    print("变更影响面(读该段的源码文件数):")
    for s, info in impact.items():
        ex = "、".join(info["examples"]) if info["examples"] else "(无)"
        print(f"  {s:<10} 被 {info['referrers']:>2} 个文件读  例:{ex}")
    for p in problems:
        print(f"FAIL {p}")
    if problems:
        return 1
    print("OK na_15_3_spec_diff:变更是纯增量的,触及段全部给出影响面")
    return 0


if __name__ == "__main__":
    sys.exit(main())

本机实跑 /opt/homebrew/bin/python3 sim/na_15_3_spec_diff.py:

v1 → v2 结构 diff(v2 由 sim/na_02_migrate.py 产出)
  新增键 66 个,删除键 0 个,改值 1 处
    改值 meta.spec_version:1 → 2
  触及段 4 个:beats×16、dialogue×48、meta×2、metrics×1
变更影响面(读该段的源码文件数):
  beats      被 19 个文件读  例:sim/na_00_spec_check.py、sim/na_00_toc_scale.py、sim/na_01_skeleton.py
  dialogue   被 43 个文件读  例:sim/na_00_spec_check.py、sim/na_00_toc_scale.py、sim/na_01_line_render.py
  meta       被 26 个文件读  例:sim/na_00_spec_check.py、sim/na_00_toc_scale.py、sim/na_01_skeleton.py
  metrics    被  8 个文件读  例:sim/na_01_skeleton.py、sim/na_01_spec_parse.py、sim/na_02_field_usage.py
OK na_15_3_spec_diff:变更是纯增量的,触及段全部给出影响面

可复现与不可复现的分界

协作这件事一半可算、一半不可算,分界必须写清楚,否则容易把「脚本跑绿了」当成「流程走完了」:

事项档说明
变更是纯增量的吗S脚本判定,删键或改旧字段语义即非零退出
每处变更影响哪些文件S脚本统计,按段给出读它的文件数
字段语义是否变更D靠字段表比对,人读
谁有权批准这次变更X需真实业务环境,命令行不可复现
评审会怎么开、几人参加X需真实业务环境,命令行不可复现
冻结窗口多长、什么时候解冻X需真实业务环境,命令行不可复现
变更什么时候排期、谁来做X需真实业务环境,命令行不可复现

上表里四行 X 是本节不能替你决定的部分。它们共同的特点是:答案取决于人,不取决于文件。脚本能告诉你「这次变更触及 4 段、dialogue 被 43 个文件读」,但告诉不了你「这 43 个文件的作者要不要一起过一遍」——那是组织问题。

判读:三条读数

第一,纯增量是向前兼容的前提。 这次 diff 新增 66 个键、删除 0 个、改值 1 处(版本号 1 → 2)。删键为零意味着 v1 的读取器读 v2 文档时,忽略新键就能得到与读 v1 相同的投影——兼容不是靠承诺,是靠 diff 判据。一旦出现删键或旧字段改值,脚本立刻非零退出,评审会还没开就已经知道这次变更越界了。

第二,影响面是评审会的议程表。 dialogue 被 43 个文件读,metrics 只有 8 个。评审时优先看 dialogue,不是因为台词重要,而是因为改它的爆炸半径最大。这张表把「先看哪一块」从习惯变成读数。

第三,可复现的部分不该被流程拖累。 上面四行 X 决定的是人和时间,三行 S/D 决定的是事实。把事实部分自动化,评审会才能只谈取舍;如果连「改了几个键」都要在会上数,评审就被杂事占满了。

第四,diff 的粒度决定了评审的粒度。 这份 diff 是按键路径报的(meta.locale、dialogue[3].tags、beats[0].pov),不是按文件行号报的。键路径的好处是它对应规格的语义单元:评审「dialogue 段新增 48 个 tags」比评审「文件多了 48 行」有意义得多。粒度选错,评审就会退化成读 diff——而读 diff 恰恰是脚本已经做完的事。

自检表

检查项合格标准
写入范围互斥策划只写规格内容,程序只写脚本与门禁
加字段两人在场字段语义的变更不由单方面决定
变更可判定纯增量与否由脚本判定,不靠口头承诺
影响面可读每处变更都附上「谁在读它」
X 处如实标注本机跑不了的流程明确写「命令行不可复现」,不假装能跑

目录、流水线、协作三件事凑齐,工具链就算立住了。下一章进入装配:把这一卷产出的规格、脚本与门禁打包成一份可交付的检查表。

阅读导航:上一节:15.2 从规格到校验的流水线 · 下一节:16.1 叙事规格的装配检查表 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

  1. 15.3 规模告警
  2. 15.2 求值与遍历的开销
  3. 15.1 规格规模的增长