15.1 规格、仿真、内核、原型的目录分工

《雾港夜航》的工具链分成五层:config/ 是唯一真源,sim/ 用 Python 穷举与校验,core/ 是 TypeScript 确定性内核并被 sim 的产物交叉验证,web/ 把内核内联进单文件 HTML,out/ 收三层产物。本节给出实测目录清单、每层的读写边界,以及「改一个文件会影响谁」的影响面反查。

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、规格迁移Pythoncore/test/ 读它落下的产物做交叉验证
core/src/inkwell/确定性内核:条件求值 / 状态推进 / 摘要TypeScriptcore/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 从规格到校验的流水线 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

  1. 15.3 规模告警
  2. 15.2 求值与遍历的开销
  3. 15.1 规格规模的增长