15.1.1 版本号不是「最后一次保存」
第 15 章主题从「怎么画」转向「怎么协作」。行业侧讲美术方向的那篇短文 《游戏美术方向与风格圣经 2026》
讲的是道理;本节不重复那些道理,只做一件更窄更硬的事:把「一份资产第几次改」变成可解析的版本号 + 可判定的冲突规则。5.1 已定了命名的 _v<NN> 文法,本节只关心其中版本那一段的语义。常见误解是把版本号当时间戳——「谁最后保存谁大」。时间戳不可排序、不可对账:时钟不同、合并后顺序丢失、覆盖时谁也说不清谁新。版本号必须是整数序:v001 < v002 < v003,任何语言解析出来都一样。
15.1.2 引用文法:主序列与变体后缀
v<NNN> 主序列,三位十进制整数,从 v001 起连续
v<NNN>_<变体字母> 同一版本号的一个变体,如 v002_a / v002_b
- 主序列用于「一次正式修改」,必须连续:
v001/v002/v003,不能跳号。跳号说明中间有一版丢了或被人改号,必须报出来。 - 变体后缀用于「同一版的两个并行方案」(如同一张椅子的暖色版与冷色版)。变体共享版本号但身份不同,所以不算重号。
这条区分很关键:重号是错,变体不是。门禁要把两者分开判,否则会把正常的 A/B 方案误伤成冲突。
15.1.3 D 层:版本样本
config/sp15_versions.json 是这份账的静态输入:每个资产带一串 (ref, branch, author),外加主干名与引用文法正则。样本里故意埋了一条跳号与一条同号并行冲突:
{
"project": "stardust-inn",
"trunk": "main",
"refPattern": "^v(\\d{3})(?:_([a-z]))?$",
"note": "《星屑旅店》资产版本样本:每个资产在若干分支上有一串版本引用。引用文法 v<NNN>(可选 _<变体字母>)。样本含一条同号并行分支冲突与一条跳号,用于版本治理门禁。",
"assets": [
{ "id": "furn_chair_01", "refs": [
{ "ref": "v001", "branch": "main", "author": "lin" },
{ "ref": "v002", "branch": "main", "author": "lin" },
{ "ref": "v003", "branch": "feature/cozy", "author": "zhao" } ] },
{ "id": "portrait_host_01", "refs": [
{ "ref": "v001", "branch": "main", "author": "lin" },
{ "ref": "v002", "branch": "main", "author": "lin" },
{ "ref": "v002", "branch": "feature/face", "author": "zhao" } ] },
{ "id": "ui_frame_01", "refs": [
{ "ref": "v001", "branch": "main", "author": "chen" },
{ "ref": "v003", "branch": "main", "author": "chen" } ] },
{ "id": "tile_floor_01", "refs": [
{ "ref": "v001", "branch": "main", "author": "lin" },
{ "ref": "v002_a", "branch": "main", "author": "lin" },
{ "ref": "v002_b", "branch": "main", "author": "chen" } ] }
]
}
furn_chair_01 是干净样本(主干 v001/v002 连续,功能分支接 v003);portrait_host_01 在主干与 feature/face 上各有一条 v002,是同号冲突;ui_frame_01 从 v001 直接到 v003,是跳号;tile_floor_01 是 v002 的两个变体,合法。
15.1.4 S 层:解析、排序与冲突检测
下面这段是 S 层脚本。它按 refPattern 解析每个引用,产出五类违例:BADREF(不符文法)、OUTOFORDER(同分支内非升序)、GAP(跳号)、DUPLICATE(同分支重号)、CONFLICT(跨分支同号):
#!/usr/bin/env python3
# file: sim/sp15_versions.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/sp15_versions.py
"""15.1 资产版本治理 S 层:按 v<NNN>[_<变体>] 解析每个资产的版本引用,产出
BADREF / OUTOFORDER / GAP / DUPLICATE / CONFLICT 五类违例,排序后落
out/sp15_versions.json 供 P 层跨语言对照。全整数 → 逐位一致。"""
import json
import os
import re
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def parse_ref(ref, pattern):
m = re.match(pattern, ref)
return None if not m else (int(m.group(1)), m.group(2) or "")
def label(num, variant):
return f"v{num:03d}" + (f"_{variant}" if variant else "")
def check_asset(asset, pattern):
aid, viol, parsed = asset["id"], [], []
for r in asset["refs"]:
p = parse_ref(r["ref"], pattern)
if p is None:
viol.append(f"{aid}:BADREF:{r['ref']}")
continue
parsed.append((p[0], p[1], r["branch"]))
# 按分支分组:顺序违例、跳号、重号
by_branch = {}
for num, var, br in parsed:
by_branch.setdefault(br, []).append((num, var))
for br in sorted(by_branch):
seq = by_branch[br]
nums = [n for n, _ in seq]
for i in range(1, len(nums)):
if nums[i] < nums[i - 1]:
viol.append(f"{aid}:OUTOFORDER:{br}:v{nums[i-1]:03d}->v{nums[i]:03d}")
distinct = sorted(set(nums))
for i in range(1, len(distinct)):
if distinct[i] != distinct[i - 1] + 1:
viol.append(f"{aid}:GAP:{br}:v{distinct[i-1]:03d}->v{distinct[i]:03d}")
seen = set()
for num, var in seq:
if (num, var) in seen:
viol.append(f"{aid}:DUPLICATE:{br}:{label(num, var)}")
seen.add((num, var))
# 同号冲突:同一 (num,var) 出现在两条不同分支
owner = {}
for num, var, br in parsed:
if (num, var) in owner and owner[(num, var)] != br:
a, b = sorted([owner[(num, var)], br])
viol.append(f"{aid}:CONFLICT:{label(num, var)}:{a}|{b}")
owner.setdefault((num, var), br)
return viol
def main():
cfg = json.load(open(os.path.join(ROOT, "config/sp15_versions.json"), encoding="utf-8"))
pattern, trunk = cfg["refPattern"], cfg["trunk"]
violations, assets = [], []
for a in cfg["assets"]:
violations.extend(check_asset(a, pattern))
branches = sorted({r["branch"] for r in a["refs"]})
nums = sorted(int(re.match(pattern, r["ref"]).group(1)) for r in a["refs"]
if r["branch"] == trunk and re.match(pattern, r["ref"]))
assets.append({"id": a["id"], "refCount": len(a["refs"]), "branches": branches,
"trunkTip": f"v{nums[-1]:03d}" if nums else None})
violations.sort()
report = {"project": cfg["project"], "trunk": trunk, "assetCount": len(assets),
"refCount": sum(a["refCount"] for a in assets), "assets": assets,
"violations": violations, "count": len(violations)}
with open(os.path.join(ROOT, "out/sp15_versions.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
f.write("\n")
print(f"assets={report['assetCount']} refs={report['refCount']} violations={report['count']}")
for a in assets:
print(f" {a['id']:<16} refs={a['refCount']} branches={len(a['branches'])} tip={a['trunkTip']}")
for v in violations:
print(f" VIOLATION {v}")
print("wrote out/sp15_versions.json")
return 0
if __name__ == "__main__":
sys.exit(main())
实跑 /opt/homebrew/bin/python3 sim/sp15_versions.py:
assets=4 refs=11 violations=2
furn_chair_01 refs=3 branches=2 tip=v002
portrait_host_01 refs=3 branches=2 tip=v002
ui_frame_01 refs=2 branches=1 tip=v003
tile_floor_01 refs=3 branches=1 tip=v002
VIOLATION portrait_host_01:CONFLICT:v002:feature/face|main
VIOLATION ui_frame_01:GAP:main:v001->v003
wrote out/sp15_versions.json
四条资产、11 条引用,恰好两条违例:ui_frame_01 跳号、portrait_host_01 同号冲突。注意 tile_floor_01 的 v002_a/v002_b 没被误报,这正是变体与重号分开判的效果。
15.1.5 P 层:同一套解析器的 TS 内核
下面这段是 P 层 TS 内核 core/src/sp15/versions.ts,与 Python 逐条对应——同一套正则、同一套分组、同一套五类违例与字符串格式:
// file: core/src/sp15/versions.ts
// tier: P
// verify: node --test 'core/test/sp15_*.test.ts'
// 15.1 资产版本治理的 P 层内核。与 sim/sp15_versions.py 同一套规则:按 v<NNN>[_<变体>]
// 解析引用,产出 BADREF / OUTOFORDER / GAP / DUPLICATE / CONFLICT 五类违例。
// 版本号与序号全是整数 → 逐位一致。只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。
const REF_RE = /^v(\d{3})(?:_([a-z]))?$/;
function parseRef(ref: string): { num: number; variant: string } | null {
const m = REF_RE.exec(ref);
if (!m) return null;
return { num: Number(m[1]), variant: m[2] ?? "" };
}
function label(num: number, variant: string): string {
const base = "v" + String(num).padStart(3, "0");
return variant ? base + "_" + variant : base;
}
export function checkAsset(asset: any): string[] {
const aid: string = asset.id;
const viol: string[] = [];
const parsed: { num: number; variant: string; branch: string }[] = [];
for (const r of asset.refs) {
const p = parseRef(r.ref);
if (p === null) {
viol.push(`${aid}:BADREF:${r.ref}`);
continue;
}
parsed.push({ num: p.num, variant: p.variant, branch: r.branch });
}
const byBranch: Record<string, { num: number; variant: string }[]> = {};
for (const p of parsed) {
if (!byBranch[p.branch]) byBranch[p.branch] = [];
byBranch[p.branch].push({ num: p.num, variant: p.variant });
}
for (const br of Object.keys(byBranch).sort()) {
const seq = byBranch[br];
const nums = seq.map((s) => s.num);
for (let i = 1; i < nums.length; i++) {
if (nums[i] < nums[i - 1]) {
viol.push(`${aid}:OUTOFORDER:${br}:v${String(nums[i - 1]).padStart(3, "0")}->v${String(nums[i]).padStart(3, "0")}`);
}
}
const distinct = [...new Set(nums)].sort((a, b) => a - b);
for (let i = 1; i < distinct.length; i++) {
if (distinct[i] !== distinct[i - 1] + 1) {
viol.push(`${aid}:GAP:${br}:v${String(distinct[i - 1]).padStart(3, "0")}->v${String(distinct[i]).padStart(3, "0")}`);
}
}
const seen = new Set<string>();
for (const s of seq) {
const key = s.num + "/" + s.variant;
if (seen.has(key)) viol.push(`${aid}:DUPLICATE:${br}:${label(s.num, s.variant)}`);
seen.add(key);
}
}
const owner: Record<string, string> = {};
for (const p of parsed) {
const key = p.num + "/" + p.variant;
if (owner[key] !== undefined && owner[key] !== p.branch) {
const pair = [owner[key], p.branch].sort();
viol.push(`${aid}:CONFLICT:${label(p.num, p.variant)}:${pair[0]}|${pair[1]}`);
}
if (owner[key] === undefined) owner[key] = p.branch;
}
return viol;
}
export function evaluateVersions(cfg: any): any {
const violations: string[] = [];
const assets: any[] = [];
for (const a of cfg.assets) {
violations.push(...checkAsset(a));
const branches = [...new Set(a.refs.map((r: any) => r.branch))].sort();
const trunkNums = a.refs
.filter((r: any) => r.branch === cfg.trunk && REF_RE.test(r.ref))
.map((r: any) => Number(REF_RE.exec(r.ref)![1]))
.sort((x: number, y: number) => x - y);
const tip = trunkNums.length ? "v" + String(trunkNums[trunkNums.length - 1]).padStart(3, "0") : null;
assets.push({ id: a.id, refCount: a.refs.length, branches, trunkTip: tip });
}
violations.sort();
return { assets, violations, count: violations.length };
}
15.1.6 负例:乱序、跳号、重号、同号并行
core/test/sp15_versions.test.ts 一边读 Python 产物做跨语言对照,一边用内存对象构造四条负例——每一种都必须 fail:
// file: core/test/sp15_versions.test.ts
// tier: P
// verify: node --test 'core/test/sp15_*.test.ts'
// 跨语言对照:读 sim/sp15_versions.py 落盘的 out/sp15_versions.json,断言 TS 侧复算的
// 违例集合与资产摘要逐位一致。负例覆盖乱序、跳号、重号、同号并行冲突四种,都必须 fail。
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { evaluateVersions, checkAsset } from "../src/sp15/versions.ts";
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(HERE, "../..");
const cfg = JSON.parse(readFileSync(resolve(ROOT, "config/sp15_versions.json"), "utf8"));
const py = JSON.parse(readFileSync(resolve(ROOT, "out/sp15_versions.json"), "utf8"));
test("版本治理:TS 复算与 Python 产物逐位一致", () => {
const mine = evaluateVersions(cfg);
assert.deepEqual(mine.assets, py.assets);
assert.deepEqual(mine.violations, py.violations);
assert.equal(mine.count, py.count);
// 正例:样本恰好报一条跳号与一条同号冲突
assert.ok(mine.violations.includes("ui_frame_01:GAP:main:v001->v003"));
assert.ok(mine.violations.includes("portrait_host_01:CONFLICT:v002:feature/face|main"));
});
test("负例:乱序 / 跳号 / 重号 / 同号并行四种都必须 fail", () => {
const cases: any[][] = [
[{ ref: "v002", branch: "main" }, { ref: "v001", branch: "main" }], // 乱序
[{ ref: "v001", branch: "main" }, { ref: "v004", branch: "main" }], // 跳号
[{ ref: "v001", branch: "main" }, { ref: "v001", branch: "main" }], // 重号
[{ ref: "v001", branch: "main" }, { ref: "v001", branch: "feature/x" }], // 同号并行
];
const codes = ["OUTOFORDER", "GAP", "DUPLICATE", "CONFLICT"];
cases.forEach((refs, i) => {
assert.ok(checkAsset({ id: "a", refs }).some((v) => v.includes(codes[i])), `${codes[i]} 未报`);
});
});
实跑 node --test 'core/test/sp15_*.test.ts'(此处只列本节 2 条;三节合计 12 条全绿):
✔ 版本治理:TS 复算与 Python 产物逐位一致 (1.311709ms)
✔ 负例:乱序 / 跳号 / 重号 / 同号并行四种都必须 fail (0.167375ms)
ℹ tests 12
ℹ pass 12
ℹ fail 0
15.1.7 谁可以覆盖谁
| 情形 | 判定 | 动作 |
|---|---|---|
| 同号出现在两条分支 | CONFLICT | 禁止合并,回主干重编新号 |
主干已有 vNNN,功能分支想复用 | CONFLICT | 功能分支从主干当前 tip +1 起编 |
| 功能分支存在主干没有的更高号 | 合法 | 合并时按号序接在主干 tip 之后 |
一句话:主干是唯一权威序列,功能分支只能往后接,不能插号。furn_chair_01 就是合规样子——主干 v001/v002,feature/cozy 接 v003,合并后自然成为新 tip。
- 真实版本控制服务器 / 真分支合并需要真实环境(Git / Perforce / 资产服务器),命令行不可复现,标 X;本节只对引用清单做静态解析与冲突判定,不触网、不执行任何分支操作。
- 版本号、序号、条数全是整数,逐位一致;违例是字符串数组,同样逐位一致。本节不涉及色彩派生量,故无浮点容差问题。
- 变体后缀只取单个小写字母(
a–z);若一版需要三个以上并行方案,说明它还没到「定版」,应回评审而非继续加后缀。
阅读导航:上一节:14.3 走查记录与决策留痕 · 下一节:15.2 大文件与二进制资产的协作 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。