12.1 规则引擎:命名/尺寸/引用/预算
前面几章各修了一段管线:第 3 章管导入、第 4 章管引用图、第 5 章管图集与字节账。它们各自能跑,但各自跑意味着没有任何一次检查能同时拦住「命名错了 + 引用悬空 + 包超预算」。CI 门禁要的不是三段脚本,而是一台引擎:把散落的判据收进一张规则表,用一份资产样本跑一遍,给出一个布尔结论。产能与包体的整体账属于生产优化,本节外链 《游戏性能优化与生产》 ,只做可判定规则。
本节有一条硬约束:不另起一套。命名文法与保留字沿用第 3 章与卷一,引用完整性直接 import 第 4 章的建图器与环 / 悬空检测,字节账直接 import 第 5 章的 mip 公式。引擎自己只做一件事——把它们的输出汇成一份带规则码、可跨语言对拍的清单。
12.1.1 一台引擎,四类规则
四类规则的判据都不在本节新造,而是各章已有口径的消费方:
| 类别 | 判据来源 | 规则码 | 硬失败 |
|---|---|---|---|
| naming | 第 3 章 namePattern + 卷一保留字 | E_NAME / E_RESERVED / E_PREFIX | E_NAME、E_RESERVED |
| size | 第 3 章 maxDimension / potRequired | E_SIZE_MAX / E_SIZE_POT | 否 |
| ref | 第 4 章建图 + 环 / 悬空 | E_REF_CYCLE / E_REF_DANGLING | 是 |
| budget | 第 5 章 mip 链字节 | E_BUDGET_OVER | 是 |
硬失败的判据是后果的不可逆性,不是数值大小:命名错了引用会断(见 5.3 保留字与反模式 )、保留字会撞工具链、悬空引用打包时找不到源、成环则无法拓扑排序、超预算则整包发不出去。这五类一旦放过,下游一定崩。
12.1.2 规则表:级别与是否硬失败都写死
规则表把每个规则码映射到类别、级别与 hardFail。severity 沿用第 3 章的 block / warn / info 三级(见 3.2 导入期校验
);hardFail 是布尔,任一条命中即门禁失败:
{
"ruleSet": "ci-gate",
"specVersion": 1,
"levels": ["block", "warn", "info"],
"note": "卷二 12.1 CI 规则引擎的规则表。每条规则声明 category(naming/size/ref/budget)、severity(block/warn/info)与 hardFail。hardFail=true 的五条即命名违例 / 保留字 / 引用悬空 / 依赖成环 / 预算超限,任一条命中即门禁失败。命名文法、尺寸上限、保留字、依赖图与字节账全部沿用第 3/4/5 章与卷一既有口径,不另起一套。",
"rules": [
{ "code": "E_NAME", "category": "naming", "severity": "block", "hardFail": true, "detail": "文件名不匹配命名文法" },
{ "code": "E_RESERVED", "category": "naming", "severity": "block", "hardFail": true, "detail": "文件名含保留字 / 临时态词" },
{ "code": "E_PREFIX", "category": "naming", "severity": "warn", "hardFail": false, "detail": "前缀与 kind 不对齐" },
{ "code": "E_SIZE_MAX", "category": "size", "severity": "warn", "hardFail": false, "detail": "边长超过导入上限" },
{ "code": "E_SIZE_POT", "category": "size", "severity": "warn", "hardFail": false, "detail": "边长不是 2 的幂" },
{ "code": "E_REF_DANGLING", "category": "ref", "severity": "block", "hardFail": true, "detail": "引用指向不存在的 id" },
{ "code": "E_REF_CYCLE", "category": "ref", "severity": "block", "hardFail": true, "detail": "依赖成环,无法拓扑排序" },
{ "code": "E_BUDGET_OVER", "category": "budget", "severity": "block", "hardFail": true, "detail": "整包纹理全链字节超预算" }
]
}
12.1.3 资产样本:结构对齐第 4 章的图
样本与第 4 章的 config/ta04_graph.json 同构(assets / dirOwners / roots),因此可直接喂给第 4 章的建图器。15 个资产里埋了 5 条硬失败与 2 条警告:
{
"project": "stardust-inn",
"chapter": 12,
"note": "卷二 12.1 CI 规则引擎的资产样本。结构与 config/ta04_graph.json 同构(assets/dirOwners/roots),可直接喂给第 4 章的建图器;尺寸与命名文法沿用第 3 章的导入规则;纹理全链字节按第 5 章的 mip 公式算。样本里埋了 5 条硬失败(E_NAME / E_RESERVED / E_REF_DANGLING / E_REF_CYCLE / E_BUDGET_OVER)与 2 条警告。",
"roots": ["scene_inn_lobby", "scene_inn_room"],
"dirOwners": { "ui/icons/": "atlas_ui" },
"packageBudgetBytes": { "ui": 1500000, "world": 12000000 },
"assets": [
{ "id": "scene_inn_lobby", "kind": "scene", "name": "scene_inn-lobby_v01", "dir": "scenes/", "dependsOn": ["ui_hud", "furn_lamp_01"] },
{ "id": "scene_inn_room", "kind": "scene", "name": "scene_inn-room_v01", "dir": "scenes/", "dependsOn": ["ui_hud"] },
{ "id": "ui_hud", "kind": "ui", "name": "ui_hud_v01", "dir": "ui/", "dependsOn": ["atlas_ui"] },
{ "id": "atlas_ui", "kind": "ui", "name": "ui_atlas_v01", "dir": "ui/" },
{ "id": "icon_coin", "kind": "icon", "name": "icon_coin-gold_v01", "dir": "ui/icons/" },
{ "id": "icon_coin_copy", "kind": "icon", "name": "icon_coin_copy_v01", "dir": "ui/icons/" },
{ "id": "furn_lamp_01", "kind": "furniture", "name": "furn_lamp-01_v01", "dir": "furniture/", "dependsOn": ["furn_lamp_01_v2", "mat_lamp_brass"] },
{ "id": "furn_lamp_01_v2", "kind": "furniture", "name": "furn_lamp-01_v02", "dir": "furniture/" },
{ "id": "mat_lamp_brass", "kind": "material", "name": "mat_lamp-brass_v01", "dir": "materials/", "slots": { "baseColor": "tex_lamp_brass" } },
{ "id": "mat_counter_wood", "kind": "material", "name": "mat_counter-wood_v01", "dir": "materials/", "slots": { "baseColor": "tex_missing_wood" } },
{ "id": "tex_lamp_brass", "kind": "texture", "name": "tex_lamp-brass_v01", "dir": "textures/", "w": 512, "h": 512, "format": "bc1", "package": "ui" },
{ "id": "tex_ui_atlas", "kind": "texture", "name": "tex_ui-atlas_v01", "dir": "textures/", "w": 1024, "h": 1024, "format": "bc1", "package": "ui" },
{ "id": "tex_banner_hd", "kind": "texture", "name": "tex_banner-hd_v01", "dir": "textures/", "w": 1024, "h": 1536, "format": "bc1", "package": "ui" },
{ "id": "tex_legacy_upper", "kind": "texture", "name": "Furn_Lamp_Bad_v01", "dir": "textures/", "w": 256, "h": 256, "format": "bc1", "package": "ui" },
{ "id": "tex_huge_bg", "kind": "texture", "name": "tex_huge-bg_v01", "dir": "textures/", "w": 4096, "h": 4096, "format": "bc1", "package": "world" }
]
}
12.1.4 S 层:四类规则共用一份样本
下面这段是 S 层脚本,读规则表与样本,ref 与 budget 两类直接 import 第 4 章与第 5 章的模块,跑完按 code:id:detail 排序取 sha256 前 16 位作 digest(字符串类,跨语言逐位一致):
#!/usr/bin/env python3
# file: sim/ta12_engine.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ta12_engine.py
"""卷二 12.1 CI 规则引擎(S 层)。一份资产样本 + 一张规则表,跑四类规则:
* naming —— 命名文法(沿用第 3 章 namePattern)、保留字(沿用卷一 sp05_reserved 的 tempMarkers)、
前缀与 kind 对齐;
* size —— 边长上限与 2 的幂(沿用第 3 章的 maxDimension / potRequired);
* ref —— **直接复用第 4 章**的建图器与环 / 悬空检测(sim/ta04_graph.py + sim/ta04_cycle.py);
* budget —— **直接复用第 5 章**的 mip 链字节公式(sim/ta05_mip.py),按包累加纹理全链字节。
规则码的 severity / hardFail 由 config/ta12_rules.json 声明;硬失败即命名违例 / 保留字 /
引用悬空 / 依赖成环 / 预算超限五类。finding 只含字符串,落 out/ta12_engine.json 供 P 层对照。
"""
import hashlib
import json
import os
import re
import sys
from ta04_graph import build_graph
from ta04_cycle import dangling, find_cycles
from ta05_mip import chain_block
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def load(rel):
return json.load(open(os.path.join(ROOT, rel), encoding="utf-8"))
def run_rules(assets, naming, reserved, rules, formats):
"""返回已定级、已排序的 findings 列表。四类规则共用同一份样本。"""
fmt = {f["id"]: f for f in formats["formats"]}
name_re = re.compile(naming["namePattern"])
kind_prefix, temp = naming["kindPrefix"], set(reserved["tempMarkers"])
max_dim, pot_required = naming["maxDimension"], naming["potRequired"]
out = []
add = lambda code, rid, detail: out.append({"code": code, "id": rid, "detail": detail})
for a in assets["assets"]: # naming / size:逐资产
name = a.get("name", a["id"])
if not name_re.match(name):
add("E_NAME", a["id"], name)
hit = sorted(set(name.lower().split("_")) & temp)
if hit:
add("E_RESERVED", a["id"], hit[0])
kp = kind_prefix.get(a.get("kind"))
if kp and name.split("_")[0] != kp:
add("E_PREFIX", a["id"], f"prefix={name.split('_')[0]} expected={kp}")
if "w" in a and "h" in a:
w, h = a["w"], a["h"]
if max(w, h) > max_dim:
add("E_SIZE_MAX", a["id"], f"{w}x{h}")
if pot_required and ((w & (w - 1)) or (h & (h - 1))):
add("E_SIZE_POT", a["id"], f"{w}x{h}")
ids, edges = build_graph(assets) # ref:直接复用第 4 章
for c in find_cycles(ids, edges):
add("E_REF_CYCLE", c[6:].split("->")[0], c)
for d in dangling(ids, edges):
add("E_REF_DANGLING", d[9:].split("->")[0], d)
budgets = assets.get("packageBudgetBytes", {}) # budget:直接复用第 5 章
totals = {}
for a in assets["assets"]:
if a.get("kind") == "texture" and a.get("format") in fmt:
f = fmt[a["format"]]
totals[a["package"]] = totals.get(a["package"], 0) + chain_block(
a["w"], a["h"], f["block"], f["bytes"])
for pkg in sorted(totals):
if pkg in budgets and totals[pkg] > budgets[pkg]:
add("E_BUDGET_OVER", pkg, f"bytes={totals[pkg]} budget={budgets[pkg]}")
sev = {r["code"]: r["severity"] for r in rules["rules"]}
hard = {r["code"]: r["hardFail"] for r in rules["rules"]}
for f in out:
if f["code"] not in sev:
raise SystemExit(f"FAIL 规则码 {f['code']} 未在规则表中声明")
f["severity"], f["hardFail"] = sev[f["code"]], bool(hard[f["code"]])
out.sort(key=lambda f: (f["code"], f["id"], f["detail"]))
return out
def main():
rules, assets = load("config/ta12_rules.json"), load("config/ta12_assets.json")
naming, reserved = load("config/ta03_import_rules.json"), load("config/sp05_reserved.json")
formats = load("config/ta05_formats.json")
findings = run_rules(assets, naming, reserved, rules, formats)
levels = {lv: 0 for lv in rules["levels"]}
for f in findings:
levels[f["severity"]] += 1
hard_fail = [f for f in findings if f["hardFail"]]
lines = [f"{f['code']}:{f['id']}:{f['detail']}" for f in findings]
digest = hashlib.sha256("\n".join(lines).encode("utf-8")).hexdigest()[:16]
rep = {"project": assets["project"], "ruleSet": rules["ruleSet"],
"assetCount": len(assets["assets"]), "ruleCount": len(rules["rules"]),
"findingCount": len(findings), "bySeverity": levels,
"hardFailCount": len(hard_fail), "gate": "block" if hard_fail else "pass",
"findings": findings, "digest": digest}
with open(os.path.join(ROOT, "out/ta12_engine.json"), "w", encoding="utf-8") as f:
json.dump(rep, f, ensure_ascii=False, indent=2)
f.write("\n")
print(f"assets={rep['assetCount']} rules={rep['ruleCount']} findings={rep['findingCount']} "
f"block={levels['block']} warn={levels['warn']} info={levels['info']}")
print(f"hardFail={rep['hardFailCount']} gate={rep['gate']} digest={digest}")
for f in findings:
print(f" {f['severity']:5s} {f['code']:16s} {f['id']:18s} {f['detail']}")
print("wrote out/ta12_engine.json")
return 0
if __name__ == "__main__":
sys.exit(main())
实跑 /opt/homebrew/bin/python3 sim/ta12_engine.py:
assets=15 rules=8 findings=7 block=5 warn=2 info=0
hardFail=5 gate=block digest=a81e513ef1f6583d
block E_BUDGET_OVER ui bytes=1966144 budget=1500000
block E_NAME tex_legacy_upper Furn_Lamp_Bad_v01
block E_REF_CYCLE furn_lamp_01 CYCLE:furn_lamp_01->furn_lamp_01_v2
block E_REF_DANGLING mat_counter_wood DANGLING:mat_counter_wood->tex_missing_wood
block E_RESERVED icon_coin_copy copy
warn E_SIZE_MAX tex_huge_bg 4096x4096
warn E_SIZE_POT tex_banner_hd 1024x1536
wrote out/ta12_engine.json
7 条 finding 里 5 条是硬失败。三条来自命名与引用(Furn_Lamp_Bad_v01 大写违例、icon_coin_copy_v01 含临时态词 copy、mat_counter_wood 的槽位指向不存在的 tex_missing_wood),一条是第 4 章埋的那个混合环(furn_lamp_01 → furn_lamp_01_v2),一条是 ui 包纹理全链 1966144 字节超了 1500000 预算。这五条正是「命名 / 保留字 / 引用悬空 / 依赖成环 / 预算超限」的完整清单。
12.1.5 P 层:TS 内核与跨语言 digest
下面这段是 P 层 TS 内核,buildGraph / findCycles / danglingRefs 来自第 4 章,chainBlock 来自第 5 章——规则逻辑与 Python 逐条对应,因此 finding 清单与 digest 必须逐位一致:
// file: core/src/ta12/engine.ts
// tier: P
// verify: node --test 'core/test/ta12_*.test.ts'
// 卷二 12.1 CI 规则引擎的 TS 侧实现。与 sim/ta12_engine.py 同一套口径:命名文法与保留字沿用
// 第 3 章 / 卷一配置,引用规则**直接复用第 4 章**的建图与环 / 悬空检测,预算**直接复用第 5 章**
// 的 mip 链字节公式。finding 只含字符串 → 两侧逐位一致。只允许可擦除 TS 语法。
import { buildGraph } from "../ta04/graph.ts";
import { danglingRefs, findCycles } from "../ta04/cycle.ts";
import { chainBlock } from "../ta05/mip.ts";
export type Rule = { code: string; category: string; severity: string; hardFail: boolean; detail: string };
export type Finding = { code: string; id: string; detail: string; severity: string; hardFail: boolean };
const cmp = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
export function runRules(assets: any, naming: any, reserved: any, rules: Rule[], formats: any): Finding[] {
const fmt: Record<string, any> = {};
for (const f of formats.formats) fmt[f.id] = f;
const nameRe = new RegExp(naming.namePattern);
const kindPrefix: Record<string, string> = naming.kindPrefix;
const temp = new Set<string>(reserved.tempMarkers);
const maxDim: number = naming.maxDimension;
const potRequired: boolean = naming.potRequired;
const out: { code: string; id: string; detail: string }[] = [];
const add = (code: string, id: string, detail: string): void => { out.push({ code, id, detail }); };
for (const a of assets.assets) { // naming / size:逐资产
const name: string = a.name ?? a.id;
if (!nameRe.test(name)) add("E_NAME", a.id, name);
const hit = [...new Set(name.toLowerCase().split("_"))].filter((s) => temp.has(s)).sort();
if (hit.length) add("E_RESERVED", a.id, hit[0]);
const kp = kindPrefix[a.kind];
if (kp !== undefined && name.split("_")[0] !== kp) add("E_PREFIX", a.id, `prefix=${name.split("_")[0]} expected=${kp}`);
if (a.w !== undefined && a.h !== undefined) {
const w: number = a.w, h: number = a.h;
if (Math.max(w, h) > maxDim) add("E_SIZE_MAX", a.id, `${w}x${h}`);
if (potRequired && ((w & (w - 1)) !== 0 || (h & (h - 1)) !== 0)) add("E_SIZE_POT", a.id, `${w}x${h}`);
}
}
const { ids, edges } = buildGraph(assets); // ref:直接复用第 4 章
for (const c of findCycles(ids, edges)) add("E_REF_CYCLE", c.slice(6).split("->")[0], c);
for (const d of danglingRefs(ids, edges)) add("E_REF_DANGLING", d.slice(9).split("->")[0], d);
const budgets: Record<string, number> = assets.packageBudgetBytes ?? {}; // budget:复用第 5 章
const totals: Record<string, number> = {};
for (const a of assets.assets) {
if (a.kind === "texture" && a.format in fmt) {
const f = fmt[a.format];
totals[a.package] = (totals[a.package] ?? 0) + chainBlock(a.w, a.h, f.block, f.bytes);
}
}
for (const pkg of Object.keys(totals).sort()) {
if (pkg in budgets && totals[pkg] > budgets[pkg]) add("E_BUDGET_OVER", pkg, `bytes=${totals[pkg]} budget=${budgets[pkg]}`);
}
const sev: Record<string, string> = {}, hard: Record<string, boolean> = {};
for (const r of rules) { sev[r.code] = r.severity; hard[r.code] = r.hardFail; }
const findings: Finding[] = out.map((f) => {
if (!(f.code in sev)) throw new Error(`规则码 ${f.code} 未在规则表中声明`);
return { ...f, severity: sev[f.code], hardFail: hard[f.code] };
});
findings.sort((x, y) => cmp(x.code, y.code) || cmp(x.id, y.id) || cmp(x.detail, y.detail));
return findings;
}
export function summarize(findings: Finding[], levels: string[]) {
const bySeverity: Record<string, number> = {};
for (const lv of levels) bySeverity[lv] = 0;
for (const f of findings) bySeverity[f.severity] = (bySeverity[f.severity] ?? 0) + 1;
const hardFailCount = findings.filter((f) => f.hardFail).length;
return { bySeverity, hardFailCount, gate: hardFailCount > 0 ? "block" : "pass" };
}
实跑 node --test 'core/test/ta12_*.test.ts'(本节 4 条,本章三节合计 12 条):
engine digest = a81e513ef1f6583d
✔ 跨语言:TS finding 清单 == Python finding 清单(逐位一致)
✔ 样本:7 条 finding = block 5 + warn 2,硬失败恰好是那五类,门禁判阻断
✔ 负例:干净样本零 finding,门禁必须 pass
✔ 负例:五类硬失败各自可被单独触发(检测器是活的,不是空转)
ℹ pass 12
ℹ fail 0
两侧 digest 都是 a81e513ef1f6583d——字符串类,逐位一致。测试里两条负例是本节的核心:一条喂一个干净样本(零 finding、门禁 pass),一条把五类硬失败各自单独触发一遍(改名 → 只报 E_NAME;加 _copy_ → 只报 E_RESERVED;槽位指向幽灵 → 只报 E_REF_DANGLING;加一条反向边 → 只报 E_REF_CYCLE;砍预算 → 只报 E_BUDGET_OVER)。负例证明的不是「测试通过」,而是断言是活的——检测器坏了、恒返回空,负例立刻打红。
12.1.6 五条硬失败为什么必须是硬的
E_SIZE_MAX / E_SIZE_POT 在本节只是 warn,而在第 3 章的导入分级表里 E_SIZE_MAX 是 block——同一个规则码,两处级别不同,这不是矛盾。导入期面对的是单个文件的导入动作,尺寸超限会让导入器当场失败;CI 面对的是整批已入库资产,一张超尺寸的图只要没进包,就不该拦住整条流水线。级别的判据永远是**「后果有多不可逆」**,而不是「这个数超了多少」。
反过来,命名与引用类问题在任何阶段都不可逆:命名错了,引用它的清单、图集收编规则、材质槽会一起断,且断得静默(第 4 章的三类隐式边正是这种静默断裂的来源);依赖成环则让拓扑排序无解。所以这五类在任何门禁里都只能是硬失败。下一节要回答的是另一个问题:硬失败之外的那些,门禁到底拦不拦、拿什么当阻断条件。
阅读导航:上一节:11.3 版本一致性与缓存失效 · 下一节:12.2 门禁的分级与阻断策略 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。