15.1 规格、仿真、内核、原型的目录分工
《游戏设计》卷一《玩法机制与可执行规格》
也把一份规格交给多处消费:config/play_spec.json 是数值规格,同一份数值被仿真、内核、原型、度量四处读,消费对象是一局战斗的状态轨迹。本卷的目录形状与它相同——config/ → sim/ → core/ → web/——但真源换成了结构规格 config/story_spec.json,消费对象从轨迹换成故事在玩家选择下的展开路径。两套共享「一份真源、多层消费」的工程纪律:真源只有一处、派生层不许反向写回、跨层对照必须整数化。但文件与判据不通用:那边判的是数值守恒,这边判的是可达、覆盖与一致性残差。
五层各放什么
目录分工要回答的第一个问题是「什么东西允许放在哪里」。放错层的东西会破坏「一份真源」,而破坏一次之后,后面所有交叉验证都会同时失效。
| 层 | 放什么 | 语言 | 谁读它 |
|---|---|---|---|
config/ | 唯一真源:story_spec.json 十段字段;各章声明的锚点 / 关系表 | JSON(全整数) | 全部 sim/ 脚本、core/test/ 的门禁、web/ 的构建 |
sim/ | 穷举、校验、曲线、SVG、规格迁移 | Python | core/test/ 读它落下的产物做交叉验证 |
core/src/inkwell/ | 确定性内核:条件求值 / 状态推进 / 摘要 | TypeScript | core/test/、web/pack.mjs 的打包器 |
core/test/ | 内核的属性测试与各章门禁 | TypeScript | 命令行、CI |
web/ | 把内核内联进单文件 HTML 的打包器与页面 | JavaScript | 无头浏览器 |
out/ | 三层产物:JSON / CSV / SVG / 日志 / 截图 | 产物 | 人、下游门禁脚本 |
一条纪律贯穿这五层:派生层不许写回真源。sim/ 跑出的是产物,core/ 跑出的是状态摘要,web/ 跑出的是页面——三者都能从 config/ 重建,config/ 不能从三者重建。这也是为什么规格文件里禁浮点:只要真源全是整数,三层的数值就不会因为序列化差异而分叉。
实测目录清单
本机实测的五层结构(写作时刻快照;sim/ 与 out/ 随各章推进持续增长):
$HOME/.inkwellbook/inkwell/
├── config/ 5 个 JSON
│ story_spec.json(唯一真源)· mini_spec.json · mini_paths.json
│ na_04_1_anchors.json · na_04_2_relations.json
├── sim/ 56 个 Python
│ mini.py · na_00_spec_check.py … na_16_3_handoff.py · na_appendix_*.py
├── core/src/inkwell/ 2 个 TS
│ engine.ts(确定性内核)· mini.ts(冒烟用的最小内核)
├── core/test/ 14 个 TS
│ na_02_digest / na_02_migrate / na_02_schema / na_02_spec_types
│ na_09_1_cond_expr / na_10_1_affinity_bounds / na_10_2_threshold_gate
│ na_11_1_dialogue_goto / na_13_1_structure / na_13_2_dead_condition
│ na_15_1_layout_contract / na_15_2_pipeline_gate / na_16_2_rehearsal · smoke_mini
├── web/ 5 个 mjs
│ pack.mjs(共享打包器)· build_/verify_ 成对脚本 · dist/(生成页)
└── out/ 59 个产物 + _log/ + shots/
*.json(报告与摘要)· *.csv(逐条读数)· *.svg(纯 xml.etree 出图)
谁读谁:读写边界
把上面那张「谁读它」展开成一条条允许的读边,就得到目录分工的全部约束:
| 读边 | 允许 | 理由 |
|---|---|---|
config/ ← 任何层 | 允许 | 真源可以被任意多次读,读不改状态 |
任何层 → config/ | 禁止 | 派生结果不许写回真源,否则真源不再是唯一真源 |
sim/ → core/ | 禁止 | Python 侧不许直接调内核,两侧只能通过产物对照 |
core/ → sim/ | 禁止 | 内核不许读 Python 脚本,只读 config/ 与 out/ |
out/ ← sim/ core/ web/ | 允许 | 三层各自落产物,产物是三层之间唯一的通道 |
web/ → core/src | 允许(打包期) | pack.mjs 把内核源码拼成一段 JS 内联进页面 |
「两侧只能通过产物对照」这条最容易违反,也最容易掩盖错误。如果 sim/ 直接 import 内核,跨语言对照就退化成同语言自证:两边共用同一份实现,分歧当然为零。唯一有意义的对照是「两边各自独立推进、只在摘要上比大小」——这正是 15.2 那条流水线最后一环在做的事。
S 层:影响面反查
改一个文件之前,得先知道谁在读它。下面的脚本做一次静态反查:把工作区里所有可被引用的文本文件读一遍,对每个关键文件数出「内容里出现了它的文件名」的文件个数。不跑任何路径,纯静态。
#!/usr/bin/env python3
# file: sim/na_15_1_directory_impact.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/na_15_1_directory_impact.py
"""卷一 15.1 目录分工(S 层):五层清点 + 「改一个文件会影响谁」的影响面反查。
五层:config/(真源)→ sim/(Python 穷举校验)→ core/(TS 内核与门禁)
→ web/(单文件原型)→ out/(三层产物)。
做法:把工作区里所有可被引用的文本文件读一遍,对每个关键文件数出
「内容里出现了它的文件名」的文件个数,并给出代表引用者。
这是静态反查,不跑任何路径;产出 out/directory_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)
LAYERS = [
("config", "唯一真源"),
("sim", "Python 穷举与校验"),
("core/src/inkwell", "TypeScript 内核"),
("core/test", "内核门禁"),
("web", "单文件原型"),
("out", "三层产物"),
]
SCAN_EXT = (".py", ".ts", ".mjs", ".json", ".md", ".html")
TARGETS = ["story_spec.json", "engine.ts", "pack.mjs", "na_00_spec_check.py", "na_02_digest.py", "digest_py.json"]
def files_in(rel: str) -> list[str]:
d = os.path.join(ROOT, rel)
if not os.path.isdir(d):
return []
return sorted(f for f in os.listdir(d) if os.path.isfile(os.path.join(d, f)) and not f.startswith("."))
def read(rel: str) -> str:
with open(os.path.join(ROOT, rel), encoding="utf-8") as fh:
return fh.read()
def main() -> int:
layers = {}
for rel, role in LAYERS:
fs = files_in(rel)
exts: dict[str, int] = {}
for f in fs:
e = os.path.splitext(f)[1] or "(无)"
exts[e] = exts.get(e, 0) + 1
layers[rel] = {"role": role, "files": len(fs), "by_ext": dict(sorted(exts.items()))}
sources = [f"{rel}/{f}" for rel, _ in LAYERS for f in files_in(rel) if os.path.splitext(f)[1] in SCAN_EXT]
contents = {p: read(p) for p in sources}
# 排除本脚本与其产物:它们含 TARGETS 字面量,会把计数算虚。
skip = {"sim/na_15_1_directory_impact.py", "out/directory_arch.json"}
impact = {}
for tgt in TARGETS:
refs = [p for p, txt in contents.items() if tgt in txt and not p.endswith("/" + tgt) and p not in skip]
impact[tgt] = {"referrers": len(refs), "examples": refs[:3]}
report = {"layers": layers, "targets": impact, "scanned_sources": len(sources)}
out = os.path.join(ROOT, "out/directory_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("层 角色 文件数 扩展名")
for rel, role in LAYERS:
info = layers[rel]
exts = " ".join(f"{k}×{v}" for k, v in info["by_ext"].items())
print(f" {rel:<18}{role:<20}{info['files']:>4} {exts}")
print(f"扫描可引用文本文件 {len(sources)} 个")
print("影响面(谁引用了它):")
for tgt, info in impact.items():
ex = "、".join(info["examples"]) if info["examples"] else "(无)"
print(f" {tgt:<24} 被 {info['referrers']:>2} 个文件引用 例:{ex}")
print("OK na_15_1_directory_impact:五层清点完成,影响面反查完成")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/na_15_1_directory_impact.py:
层 角色 文件数 扩展名
config 唯一真源 5 .json×5
sim Python 穷举与校验 56 .py×56
core/src/inkwell TypeScript 内核 2 .ts×2
core/test 内核门禁 14 .ts×14
web 单文件原型 5 .mjs×5
out 三层产物 59 .csv×17 .json×32 .svg×10
扫描可引用文本文件 114 个
影响面(谁引用了它):
story_spec.json 被 59 个文件引用 例:sim/na_00_spec_check.py、sim/na_00_toc_scale.py、sim/na_01_line_render.py
engine.ts 被 10 个文件引用 例:sim/na_02_digest.py、sim/na_16_3_handoff.py、core/test/na_02_digest.test.ts
pack.mjs 被 3 个文件引用 例:core/test/na_15_1_layout_contract.test.ts、web/build_na12.mjs、web/build_smoke.mjs
na_00_spec_check.py 被 13 个文件引用 例:sim/na_00_toc_scale.py、sim/na_01_line_render.py、sim/na_08_2_width_depth.py
na_02_digest.py 被 4 个文件引用 例:sim/na_15_2_pipeline.py、core/src/inkwell/engine.ts、core/test/na_02_digest.test.ts
digest_py.json 被 6 个文件引用 例:sim/na_02_digest.py、sim/na_15_2_pipeline.py、sim/na_16_3_handoff.py
OK na_15_1_directory_impact:五层清点完成,影响面反查完成
P 层:目录契约门禁
影响面是「读的规模」,契约是「读的规则」。下面这条 node --test 门禁从内核一侧断言四层目录的契约:目录存在、真源可解析且含全部十段、规格无浮点、内核无随机源、sim/ 脚本一律带卷前缀、web/pack.mjs 导出五个共享入口。任何一条破了,构建前的这一环就先红。
// file: core/test/na_15_1_layout_contract.test.ts
// tier: P
// verify: node --test 'core/test/na_15_1_layout_contract.test.ts'
// 目录分工的 P 层门禁:从内核一侧断言四层目录的契约成立。
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, "../..");
const LAYERS = ["config", "sim", "core/src/inkwell", "core/test", "web", "out"];
const SPEC_SECTIONS = [
"meta", "world", "characters", "beats", "scene_graph",
"dialogue", "conditions", "state", "endings", "metrics",
];
const listFiles = (rel: string): string[] =>
readdirSync(join(ROOT, rel)).filter((f) => statSync(join(ROOT, rel, f)).isFile() && !f.startsWith("."));
const read = (rel: string): string => readFileSync(join(ROOT, rel), "utf8");
// 拼出禁用符号,避免本文件自己成为「出现该符号」的样本。
const RANDOM_CALL = ["Math", "random("].join(".");
/** 规格里出现任何浮点即违规:跨语言对照的前提是整数。 */
function findFloats(node: unknown, path = ""): string[] {
if (typeof node === "number") return Number.isInteger(node) ? [] : [path];
if (Array.isArray(node)) return node.flatMap((v, i) => findFloats(v, `${path}[${i}]`));
if (node && typeof node === "object") {
return Object.keys(node as Record<string, unknown>)
.sort()
.flatMap((k) => findFloats((node as Record<string, unknown>)[k], `${path}.${k}`));
}
return [];
}
test("四层目录 + 产物目录都存在且非空", () => {
for (const rel of LAYERS) {
assert.ok(statSync(join(ROOT, rel)).isDirectory(), `${rel} 不是目录`);
assert.ok(listFiles(rel).length > 0, `${rel} 是空目录`);
}
});
test("真源规格解析成功且含全部 10 段", () => {
const spec = JSON.parse(read("config/story_spec.json")) as Record<string, unknown>;
for (const seg of SPEC_SECTIONS) assert.ok(seg in spec, `规格缺段 ${seg}`);
assert.equal(Object.keys(spec).length, SPEC_SECTIONS.length);
});
test("规格无浮点:全部数值都是整数", () => {
const spec = JSON.parse(read("config/story_spec.json"));
assert.deepEqual(findFloats(spec), []);
});
test("内核与门禁不含随机源", () => {
for (const rel of ["core/src/inkwell", "core/test"]) {
for (const f of listFiles(rel).filter((x) => x.endsWith(".ts"))) {
assert.ok(!read(`${rel}/${f}`).includes(RANDOM_CALL), `${rel}/${f} 出现 ${RANDOM_CALL}`);
}
}
});
test("sim 脚本一律带卷前缀或为共享 mini", () => {
const py = listFiles("sim").filter((f) => f.endsWith(".py"));
// 三卷共用同一项目根目录,卷前缀必须来自已声明的 na / ne / nm。
const NAMED = /^(?:(?:na|ne|nm)_(?:\d{2}(?:_\d)?_[a-z0-9_]+|appendix_[a-z0-9_]+)|mini)\.py$/;
for (const f of py) assert.match(f, NAMED, `${f} 不符合三卷章号前缀命名`);
for (const f of ["na_01_spec.py", "ne_00_toc_engine.py", "nm_16_common.py", "mini.py"]) {
assert.match(f, NAMED);
}
for (const f of ["other.py", "xx_01_spec.py", "na_1_spec.py", "na_01-Spec.py"]) {
assert.doesNotMatch(f, NAMED, `${f} 应被命名契约拒绝`);
}
});
test("web/pack.mjs 导出五个共享入口", () => {
const src = read("web/pack.mjs");
for (const fn of ["bundle", "page", "writePage", "inlineConfig", "BASE_CSS"]) {
assert.ok(src.includes(fn), `pack.mjs 缺共享入口 ${fn}`);
}
});
本机实跑 node --test 'core/test/na_15_1_layout_contract.test.ts':
✔ 四层目录 + 产物目录都存在且非空 (102.931708ms)
✔ 真源规格解析成功且含全部 10 段 (7.9685ms)
✔ 规格无浮点:全部数值都是整数 (2.763833ms)
✔ 内核与门禁不含随机源 (20.061875ms)
✔ sim 脚本一律带卷前缀或为共享 mini (3.313459ms)
✔ web/pack.mjs 导出五个共享入口 (17.490917ms)
ℹ tests 6
ℹ suites 0
ℹ pass 6
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 862.261
判读:三条读数
第一,真源的引用面远大于任何派生件。 story_spec.json 被 59 个文件引用,engine.ts 10 个,web/pack.mjs 3 个。这不是说真源「更重要」,而是说改真源的影响面最宽——它同时是 S 层的输入、P 层的夹具与 W 层的配置。所以改规格必须走 15.2 那条完整流水线;改一个 sim/ 脚本只影响它自己落的那份产物。
第二,out/ 是三层之间唯一的通道。 影响面表里 digest_py.json 被 6 个文件引用,其中两个跨了语言:sim/na_02_digest.py 写它,core/test/na_02_digest.test.ts 读它。这条读写边就是「两侧只能通过产物对照」的物理形态——如果把这条边换成 import,跨语言对照立刻失去意义。
第三,目录清单是快照,不是常量。 上面的清点是写作时刻的读数,sim/ 与 out/ 会随各章推进继续增长。可以稳定的是结构(五层、各自的语言、读写边),不是计数。把计数当成验收指标会立刻过时;把结构当成验收指标,它就一直成立。
自检表
| 检查项 | 合格标准 |
|---|---|
| 真源唯一 | 只有 config/ 存规格,派生层不写回 |
| 语言边界清晰 | Python 只进 sim/,TS 只进 core/,JS 只进 web/ |
| 跨层只走产物 | sim/ 与 core/ 之间不出现直接 import |
| 计数不硬编码 | 目录清点由脚本给出,不写死在正文里当常量 |
| 契约可门禁 | 目录契约有一条 node --test 能一键验 |
目录分清了「东西放哪」,但还没回答「按什么顺序动它们」。下一节把五层串成一条可复现的流水线,从改规格一直跑到产物核对。
阅读导航:上一节:14.3 一致性的残差与告警 · 下一节:15.2 从规格到校验的流水线 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。