16.3 交付物与卷二衔接

卷一交出的不是一本排好版的 PDF,而是一份可点数的清单:哪些是给人读的规范文本,哪些是机器校验的静态结构,哪些是能真跑的产物。本节用一份真跑的 S 层脚本与 P 层 TS 内核校验这份清单本身是否完整——字段齐全、kind 与 status 合法、id 唯一,且交给卷二的目标必须真实存在并已完成,缺一项就 fail。

16.3.1 卷一到底交出了什么

卷一到这一节就算收口了,但它交出去的不是一本排好版的 PDF,而是一份可点数的清单:哪些是给人读的规范文本,哪些是机器能校验的静态结构,哪些是能真跑的产物。把这三类分清,卷二接手时才知道「哪些东西可以直接拿来当输入跑起来」,而不必把前十五章再读一遍人话。资产管线在引擎侧怎么落地属于实现,外链 《glTF 资产管线》 ;本节只做清单 ↔ 接手这一层的对账。

16.1 证明了模板的章节齐了,16.2 证明了同一份结构能在六个主题下落地。本节补上最后一环:这份清单本身完整吗——每一项都有 id / kind / producer / status;交给卷二的那批目标必须真实存在于清单里,而且状态必须是 done。这两条听起来像废话,但一个没人校验的交付物清单,正是「文档写完了、东西没做完」的温床。

16.3.2 D 层:交付物清单与接手目标

config/sp16_deliverables.json 是这份清单的静态形态。deliverables 逐项声明 id、kind、producer(谁产出的)、status;handoffToVolume2 列出卷二要接手的那批 id。kind 分三类——spec-text(给人读的规范文本)、schema(机器校验的静态结构)、runnable-artifact(能真跑的产物)——这个区分直接决定卷二能不能把它们当管线输入。

{
  "project": "stardust-inn",
  "volume": "美术制作与视觉规范",
  "nextVolume": "技术美术与管线工程",
  "note": "卷一交付物清单。kind 三类:spec-text=给人读的规范文本,schema=机器校验的静态结构,runnable-artifact=可跑产物。handoffToVolume2 列出卷二接手的那批——只有 status=done 的交付物才允许被接手,且接手目标必须真实存在于清单里。",
  "requiredFields": ["id", "kind", "producer", "status"],
  "allowedKinds": ["spec-text", "schema", "runnable-artifact"],
  "allowedStatus": ["done", "in-progress", "planned"],
  "deliverables": [
    { "id": "spec.visual", "kind": "schema", "producer": "sim/consume.py", "status": "done" },
    { "id": "tokens.palette", "kind": "spec-text", "producer": "config/visual_spec.json", "status": "done" },
    { "id": "bible.template", "kind": "spec-text", "producer": "config/sp16_art_bible.json", "status": "done" },
    { "id": "manifest.schema", "kind": "schema", "producer": "config/sp06_manifest.schema.json", "status": "done" },
    { "id": "budget.geometry", "kind": "schema", "producer": "config/sp07_budget.json", "status": "done" },
    { "id": "budget.texture", "kind": "schema", "producer": "config/sp08_textures.json", "status": "done" },
    { "id": "atlas.spec", "kind": "schema", "producer": "config/sp10_atlas.json", "status": "done" },
    { "id": "lint.naming", "kind": "runnable-artifact", "producer": "core/src/sp05/naming.ts", "status": "done" },
    { "id": "check.dir-consistency", "kind": "runnable-artifact", "producer": "sim/sp06_dir.py", "status": "done" },
    { "id": "assembly.six-themes", "kind": "runnable-artifact", "producer": "sim/sp16_six.py", "status": "done" }
  ],
  "handoffToVolume2": [
    "spec.visual", "manifest.schema", "budget.geometry", "budget.texture",
    "atlas.spec", "lint.naming", "check.dir-consistency"
  ]
}

16.3.3 S 层:清单完整性校验

下面这段 S 层脚本读清单,逐项查字段齐全、kind/status 合法、id 唯一,再对每个接手目标查两件事:目标在不在清单里、状态是不是 done。违例字符串排序去重后落 out/sp16_handoff.json:

#!/usr/bin/env python3
# file: sim/sp16_handoff.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/sp16_handoff.py
"""16.3 交付物清单完整性 + 卷二接手的 S 层校验器。

读 config/sp16_deliverables.json,对卷一交付物清单做完整性校验,并校验交给卷二的
那批目标:字段齐全、kind/status 合法、id 唯一、handoff 目标存在且已 done。
违例字符串排序后落 out/sp16_handoff.json 供 P 层跨语言对照。
"""
import hashlib
import json
import os
import sys

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))


def check(handoff: dict) -> list:
    req = handoff["requiredFields"]
    kinds, statuses = set(handoff["allowedKinds"]), set(handoff["allowedStatus"])
    items = handoff["deliverables"]
    ids = [d["id"] for d in items]
    known = set(ids)
    viol = []
    for d in items:
        for f in req:
            if not d.get(f):
                viol.append(f"MISSING_FIELD:{d.get('id', '?')}:{f}")
        if d.get("kind") not in kinds:
            viol.append(f"BAD_KIND:{d.get('id', '?')}:{d.get('kind')}")
        if d.get("status") not in statuses:
            viol.append(f"BAD_STATUS:{d.get('id', '?')}:{d.get('status')}")
        if ids.count(d.get("id")) > 1:
            viol.append(f"DUPLICATE_ID:{d.get('id')}")
    for target in handoff["handoffToVolume2"]:
        if target not in known:
            viol.append(f"DANGLING_HANDOFF:{target}")
            continue
        st = next(d["status"] for d in items if d["id"] == target)
        if st != "done":
            viol.append(f"HANDOFF_NOT_DONE:{target}:{st}")
    return sorted(set(viol))


def digest(handoff: dict, viol: list) -> str:
    parts = [f"{d['id']}:{d['kind']}:{d['status']}" for d in handoff["deliverables"]]
    parts += list(handoff["handoffToVolume2"]) + viol
    return hashlib.sha256(";".join(parts).encode("utf-8")).hexdigest()[:16]


def main() -> int:
    h = json.load(open(os.path.join(ROOT, "config/sp16_deliverables.json"), encoding="utf-8"))
    viol = check(h)
    by_kind = {}
    for d in h["deliverables"]:
        by_kind[d["kind"]] = by_kind.get(d["kind"], 0) + 1
    report = {"project": h["project"], "volume": h["volume"], "nextVolume": h["nextVolume"],
              "deliverableCount": len(h["deliverables"]), "byKind": by_kind,
              "handoffCount": len(h["handoffToVolume2"]), "handoff": h["handoffToVolume2"],
              "violations": viol, "count": len(viol)}
    report["digest"] = digest(h, viol)
    with open(os.path.join(ROOT, "out/sp16_handoff.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")

    print(f"deliverables={report['deliverableCount']} handoff={report['handoffCount']} "
          f"violations={report['count']}")
    for k in sorted(by_kind):
        print(f"  {k:<18} {by_kind[k]}")
    for t in h["handoffToVolume2"]:
        print(f"  HANDOFF -> vol2 {t}")
    for v in viol:
        print(f"  VIOLATION {v}")
    print(f"digest={report['digest']}")
    print("wrote out/sp16_handoff.json")
    return 0


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

实跑 /opt/homebrew/bin/python3 sim/sp16_handoff.py:

deliverables=10 handoff=7 violations=0
  runnable-artifact  3
  schema             5
  spec-text          2
  HANDOFF -> vol2 spec.visual
  HANDOFF -> vol2 manifest.schema
  HANDOFF -> vol2 budget.geometry
  HANDOFF -> vol2 budget.texture
  HANDOFF -> vol2 atlas.spec
  HANDOFF -> vol2 lint.naming
  HANDOFF -> vol2 check.dir-consistency
digest=c3440a00133c232d
wrote out/sp16_handoff.json

16.3.4 交付物一览与卷二接手清单

10 项交付物按 kind 分成三类(3 个可跑产物、5 个静态结构、2 份规范文本),其中 7 项进入卷二接手清单:

idkindproducer卷二接手
spec.visualschemasim/consume.py✓
tokens.palettespec-textconfig/visual_spec.json—
bible.templatespec-textconfig/sp16_art_bible.json—
manifest.schemaschemaconfig/sp06_manifest.schema.json✓
budget.geometryschemaconfig/sp07_budget.json✓
budget.textureschemaconfig/sp08_textures.json✓
atlas.specschemaconfig/sp10_atlas.json✓
lint.namingrunnable-artifactcore/src/sp05/naming.ts✓
check.dir-consistencyrunnable-artifactsim/sp06_dir.py✓
assembly.six-themesrunnable-artifactsim/sp16_six.py—

没被接手的 3 项各有用处:tokens.palette 与 bible.template 是卷一自己的成文规范,卷二按需引用但不作为管线输入;assembly.six-themes 是 16.2 的回放产物,属一次性验证,不参与卷二管线。

16.3.5 P 层:同一套判据的 TS 内核

下面这段 P 层 TS 内核 core/src/sp16/handoff.ts 与 Python 同一套规则(字段齐全、kind/status 合法、id 唯一、接手目标存在且已 done):

// file: core/src/sp16/handoff.ts
// tier: P
// verify: node --test 'core/test/sp16_*.test.ts'
// 16.3 交付物清单完整性的 P 层内核。与 sim/sp16_handoff.py 同一套规则:字段齐全、kind/
// status 合法、id 唯一、handoff 目标存在且已 done。只允许可擦除 TS 语法。

export function checkDeliverables(h: any): string[] {
  const req: string[] = h.requiredFields;
  const kinds = new Set<string>(h.allowedKinds);
  const statuses = new Set<string>(h.allowedStatus);
  const items = h.deliverables;
  const ids = items.map((d: any) => d.id);
  const known = new Set<string>(ids);
  const viol: string[] = [];

  for (const d of items) {
    for (const f of req) {
      if (!d[f]) viol.push(`MISSING_FIELD:${d.id ?? "?"}:${f}`);
    }
    if (!kinds.has(d.kind)) viol.push(`BAD_KIND:${d.id ?? "?"}:${d.kind}`);
    if (!statuses.has(d.status)) viol.push(`BAD_STATUS:${d.id ?? "?"}:${d.status}`);
    if (ids.filter((x: string) => x === d.id).length > 1) viol.push(`DUPLICATE_ID:${d.id}`);
  }
  for (const target of h.handoffToVolume2) {
    if (!known.has(target)) {
      viol.push(`DANGLING_HANDOFF:${target}`);
      continue;
    }
    const st = items.find((d: any) => d.id === target).status;
    if (st !== "done") viol.push(`HANDOFF_NOT_DONE:${target}:${st}`);
  }
  return [...new Set(viol)].sort();
}

core/test/sp16_handoff.test.ts 读 Python 产物做跨语言对照,并构造三条负例——悬空接手目标、接手未完成的交付物、缺字段与非法 kind:

// file: core/test/sp16_handoff.test.ts
// tier: P
// verify: node --test 'core/test/sp16_*.test.ts'
// 跨语言对照:读 sim/sp16_handoff.py 落盘的 out/sp16_handoff.json,断言 TS 侧的清单
// 完整性校验与 Python 逐位一致,并比对 sha256 指纹。负例:悬空接手目标、接手未完成
// 交付物、缺字段,都必须真的 fail。
import test from "node:test";
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { checkDeliverables } from "../src/sp16/handoff.ts";

const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(HERE, "../..");
const h = JSON.parse(readFileSync(resolve(ROOT, "config/sp16_deliverables.json"), "utf8"));
const py = JSON.parse(readFileSync(resolve(ROOT, "out/sp16_handoff.json"), "utf8"));

const clone = (): any => JSON.parse(JSON.stringify(h));

test("交付物清单完整性:TS 与 Python 违例集合逐位一致", () => {
  assert.deepEqual(checkDeliverables(h), py.violations);
  assert.deepEqual(py.violations, []);
  assert.equal(py.deliverableCount, h.deliverables.length);
  assert.equal(py.handoffCount, h.handoffToVolume2.length);
});

test("sha256 指纹跨语言一致", () => {
  const parts = h.deliverables.map((d: any) => `${d.id}:${d.kind}:${d.status}`);
  const all = [...parts, ...h.handoffToVolume2, ...checkDeliverables(h)];
  const digest = createHash("sha256").update(all.join(";"), "utf8").digest("hex").slice(0, 16);
  assert.equal(digest, py.digest);
});

test("负例:接手一个不存在的交付物 → DANGLING_HANDOFF", () => {
  const b = clone();
  b.handoffToVolume2.push("spec.ghost");
  assert.ok(checkDeliverables(b).includes("DANGLING_HANDOFF:spec.ghost"));
});

test("负例:接手一个还没 done 的交付物 → HANDOFF_NOT_DONE", () => {
  const b = clone();
  b.deliverables.find((d: any) => d.id === "atlas.spec").status = "in-progress";
  assert.ok(checkDeliverables(b).includes("HANDOFF_NOT_DONE:atlas.spec:in-progress"));
});

test("负例:缺字段与非法 kind 都必须 fail", () => {
  const b = clone();
  delete b.deliverables[0].producer;
  b.deliverables[1].kind = "video";
  const v = checkDeliverables(b);
  assert.ok(v.includes("MISSING_FIELD:spec.visual:producer"), v.join(","));
  assert.ok(v.includes("BAD_KIND:tokens.palette:video"), v.join(","));
});

实跑 node --test 'core/test/sp16_*.test.ts'(此处只摘本节的 5 条;三节合计 14 条):

✔ 交付物清单完整性:TS 与 Python 违例集合逐位一致 (0.997334ms)
✔ sha256 指纹跨语言一致 (0.423209ms)
✔ 负例:接手一个不存在的交付物 → DANGLING_HANDOFF (0.163625ms)
✔ 负例:接手一个还没 done 的交付物 → HANDOFF_NOT_DONE (0.104583ms)
✔ 负例:缺字段与非法 kind 都必须 fail (0.10125ms)
ℹ tests 14
ℹ pass 14
ℹ fail 0

16.3.6 边界与取舍

  • 真实资产的交接(把美术源文件、贴图、模型真正移交给卷二)需要真实的工程与权限环境,命令行不可复现,标 X;本节只对清单本身做完整性账。
  • 清单校验是静态一致性:它证明「谁交给谁、状态对不对」,不证明「那份产物的内容对不对」——内容对不对由各章自己的 S/P 门禁负责。
  • handoffToVolume2 只允许指向 done 的交付物,是为了挡住「把没做完的东西挂上交接牌」;这条规则本身与项目无关,可移植。

阅读导航:上一节:16.2 双案例回放:《星屑旅店》6 主题的规范装配 · 下一节:回到目录 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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