15.1 资产版本治理:命名与分支

版本号不是「最后一次保存」的时间戳,而是可解析、可排序、可对账的整数序。本节给《星屑旅店》的资产版本定一套 v<NNN>[_<变体>] 文法,用一份真跑的 S 层脚本和 P 层 TS 内核给出逐位一致的违例集合,并用乱序、跳号、重号、同号并行分支四条负例证明门禁真的会 fail。

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 大文件与二进制资产的协作 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

  1. 附录 A:风格参数表、量化规则与数据集索引
  2. 9.3 等距资产的制作约束
  3. 9.2 等距 tile 与遮挡规则