上一章把故事还原成了一份规格,但只交代了骨架。这一节把 config/story_spec.json 摊开:它有哪些字段、哪些必填、谁来读它。需要先划一条界——《游戏设计》卷一:玩法机制与可执行规格
的那套真源是数值与规则(伤害、资源、随机性),消费对象是一局战斗的状态轨迹;本套真源是结构与条件(世界、角色、弧光、分支、阈值),消费对象是故事在玩家选择下的展开路径。两者共享「整数化 + 确定性 + 代理口径」的工程纪律,但字段表没有一处可以互相搬运。
2.1.1 十个顶层键:九段数据 + 一段文法
story_spec.json 的顶层有十个键。其中 conditions 只声明条件文法(算子集合与形式清单),不承载任何故事数据;其余九段承载数据。下表按段列出字段名、类型、是否必填、示例值与消费方。
| 段 | 字段 | 类型 | 必填 | 示例值 | 消费方 |
|---|---|---|---|---|---|
meta | title / code / spec_version | str / str / int | 是 | "雾港夜航" | 工具链抬头、迁移判据 |
meta | start | str | 是 | "d_pier_1" | 状态机入口(卷二第 1 章) |
meta | acts / act_names | int / str[] | 是 | 3 / ["第一夜 抵港", …] | 三幕结构检验(第 6 章) |
meta | scale | int 映射 | 是 | {"scenes": 14, …} | 规模对账(P 层断言) |
world | factions[].stance | int 矩阵 | 是 | {"guild": 1, …} | 派系立场(第 3 章) |
world | locations[].unlock_when | 条件 / null | 是 | ["flag","met_hui",1] | 解锁判定(第 7 章) |
world | lore[].reveal_flag | flag 名 | 是 | "knows_tide_rule" | 设定暴露节奏(第 3.3 节) |
characters | affinity{init,range,thresholds,gates} | int | 是 | 20 / [0,100] / [30,60,85] | 好感度状态机(第 10 章) |
characters | arc{stages,beats} | str[] / int[] | 是 | ["失踪的传说", …] | 弧光可达性(第 5 章) |
beats | tension | int 1–5 | 是 | 4 | 张力曲线(卷三第 5 章) |
scene_graph | nodes[].enter_when | 条件 / null | 是 | ["item","lantern",1] | 场景可达性(第 7.2 节) |
scene_graph | edges[].when | 条件 / null | 否 | null | 顺序约束(第 7.3 节) |
dialogue | line / line_when / line_else | str / 条件 / str | 是 / 否 / 否 | "先生,您是来等船的吗?" | 台词渲染(第 12 章) |
dialogue | choices[].effects | 四算子 | 是 | {"add":{"hui":5}} | 状态推进(卷二第 3 章) |
dialogue | choices[].goto | 节点 id | 是 | "d_tavern_1" | 跳转目标(第 11 章) |
conditions | ops / forms | 文法声明 | 是 | [">=","<","==","!="] | 求值器(卷二第 2 章) |
state | flags / vars / items | str[] | 是 | 18 / 4 / 9 项 | 状态域(卷二第 5 章) |
endings | when | 条件 / null | 是 | ["flag","bell_rung",1] | 结局判定(卷二第 6.3 节) |
metrics | target_tension_curve | int[16] | 是 | [1,2,2,3,…] | 曲线 MAE(卷三第 6 章) |
metrics | coverage_target_permille 等 | int | 是 | 850 | 覆盖与容差口径(卷三第 15 章) |
「必填」在本套的判据是:缺失即解析失败,而不是「缺了就报个警告」。conditions 段只有四个键,但它决定 when / unlock_when / enter_when 里允许出现哪些形式;把它单列出来,是因为改文法等于改所有条件的合法集合。
2.1.2 声明一处、引用零处:字段审计
字段表最容易出的错不是写错,而是写了没人读。state.flags 里声明了一个 flag,可全文没有任何一条条件读它、没有一个效果写它——它就是一个永远为 0 的死变量。这类字段在人工评审里几乎不会被发现,因为它在表里「看起来是对的」。
S 层脚本把每个声明项和它的引用点对起来,未引用项计入 residual,并要求恒为 0。
$ cd $HOME/.inkwellbook/inkwell && /opt/homebrew/bin/python3 sim/na_02_field_usage.py
#!/usr/bin/env python3
# file: sim/na_02_field_usage.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/na_02_field_usage.py
"""字段审计:每个声明的 flag / item / 好感度变量 / 角色 / 地点都必须被至少一处引用。
声明一处、引用零处是叙事规格里最隐蔽的失效:表里写着这个 flag,
却没有一条条件读它、没有一个效果写它。这类字段计入 residual,要求恒为 0。
"""
from __future__ import annotations
import json
import os
import sys
DATA = ("meta", "world", "characters", "beats", "scene_graph", "dialogue", "state", "endings", "metrics")
def load():
here = os.path.dirname(os.path.abspath(__file__))
with open(os.path.join(here, "../config/story_spec.json"), encoding="utf-8") as fh:
return json.load(fh)
def leaves(c):
"""展开嵌套条件,产出全部叶子(flag / item / aff)。"""
if c is None:
return
if c[0] in ("and", "or"):
yield from leaves(c[1]); yield from leaves(c[2])
elif c[0] == "not":
yield from leaves(c[1])
else:
yield c
def audit(spec):
"""返回 声明集合 -> 被引用集合。"""
decl = {"flags": set(spec["state"]["flags"]), "items": set(spec["state"]["items"]), "vars": set(spec["state"]["vars"]),
"characters": {c["id"] for c in spec["characters"]}, "locations": {l["id"] for l in spec["world"]["locations"]},
"dialogue_nodes": {n["id"] for n in spec["dialogue"]}}
used = {k: set() for k in decl}
used["dialogue_nodes"].add(spec["meta"]["start"])
def feed(c):
for leaf in leaves(c):
used[{"flag": "flags", "item": "items", "aff": "vars"}[leaf[0]]].add(leaf[1])
for n in spec["dialogue"]:
used["characters"].add(n["speaker"])
feed(n.get("line_when"))
for c in n.get("choices") or []:
feed(c.get("when"))
e = c.get("effects") or {}
used["flags"] |= set(e.get("set") or {})
used["vars"] |= set(e.get("add") or {})
used["items"] |= set(e.get("grant") or {}) | set(e.get("revoke") or {})
used["dialogue_nodes"].add(c["goto"])
for e in spec["endings"]:
feed(e.get("when"))
for n in spec["scene_graph"]["nodes"]:
used["locations"].add(n["location"])
feed(n.get("enter_when"))
for e in spec["scene_graph"]["edges"]:
feed(e.get("when"))
for l in spec["world"]["locations"]:
feed(l.get("unlock_when"))
for l in spec["world"]["lore"]:
used["flags"].add(l["reveal_flag"])
return decl, used
def main() -> int:
spec = load()
print(f"顶层键 {len(spec)} 个:{sorted(spec)}(其中 conditions 只声明文法,其余 {len(DATA)} 段承载数据)")
decl, used = audit(spec)
print(f"{'声明项':<15} {'声明':>4} {'被引用':>6} {'未引用':>6} 未引用清单")
rep, residual = {}, 0
for k in decl:
un = sorted(decl[k] - used[k])
residual += len(un)
rep[k] = {"declared": len(decl[k]), "used": len(decl[k] & used[k]), "unused": un}
print(f"{k:<15} {len(decl[k]):>4} {len(decl[k] & used[k]):>6} {len(un):>6} {','.join(un) or '-'}")
curve = spec["metrics"]["target_tension_curve"]
print(f"residual = {residual}")
print(f"target_tension_curve 长度 {len(curve)} == beats 数 {len(spec['beats'])}:{len(curve) == len(spec['beats'])}")
assert residual == 0, rep
assert len(curve) == len(spec["beats"]) and all(1 <= v <= 5 for v in curve)
out = os.path.join(os.path.dirname(os.path.abspath(__file__)), "../out/field_usage.json")
with open(out, "w", encoding="utf-8") as fh:
json.dump(rep, fh, ensure_ascii=False, indent=1)
fh.write("\n")
print("OK na_02_field_usage:所有声明项都被引用,写出 out/field_usage.json")
return 0
if __name__ == "__main__":
sys.exit(main())
顶层键 10 个:['beats', 'characters', 'conditions', 'dialogue', 'endings', 'meta', 'metrics', 'scene_graph', 'state', 'world'](其中 conditions 只声明文法,其余 9 段承载数据)
声明项 声明 被引用 未引用 未引用清单
flags 18 18 0 -
items 9 9 0 -
vars 4 4 0 -
characters 6 6 0 -
locations 14 14 0 -
dialogue_nodes 48 48 0 -
residual = 0
target_tension_curve 长度 16 == beats 数 16:True
OK na_02_field_usage:所有声明项都被引用,写出 out/field_usage.json
dialogue_nodes 一行值得单独看:48 个节点里,47 个是某个选项的 goto 目标,剩下 1 个是 meta.start。声明集合与引用集合在这里恰好相等,说明没有「写了却没接进图里」的孤立节点。脚本对 flags / items / vars / characters / locations 用同一把尺子量,任何一类出现未引用项都会让 residual 大于 0 并断言失败。
2.1.3 类型声明与 JSON 对齐
Python 侧审计「有没有人用」,TypeScript 侧审计「类型声明与 JSON 是否说的是同一件事」。这里的对齐必须落在运行时:Node 26 对 .ts 只做类型擦除、不做类型检查,写一个 satisfies SpecDoc 不会在运行时报任何错。
$ cd $HOME/.inkwellbook/inkwell && node --test 'core/test/na_02_spec_types.test.ts'
// file: core/test/na_02_spec_types.test.ts
// tier: P
// verify: node --test 'core/test/na_02_spec_types.test.ts'
// 字段全表与 JSON 对齐。Node 26 是 strip-only:类型声明只被擦除、不被检查,
// 所以「对齐」必须落成运行时断言,类型只当文档用。
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "../..");
export type Op = ">=" | "<" | "==" | "!=";
export type Cond =
| ["flag" | "item", string, number]
| ["aff", string, Op, number]
| ["and" | "or", Cond, Cond]
| ["not", Cond];
export type Effects = { set?: Record<string, number>; add?: Record<string, number>; grant?: Record<string, number>; revoke?: Record<string, number> };
export type SpecDoc = {
meta: { title: string; code: string; spec_version: number; start: string; acts: number; act_names: string[]; scale: Record<string, number> };
world: {
factions: { id: string; name: string; stance: Record<string, number> }[];
locations: { id: string; name: string; act: number; unlock_when: Cond | null }[];
lore: { id: string; reveal_flag: string; text: string }[];
rules: { id: string; text: string }[];
};
characters: {
id: string; name: string; role: string; faction: string; goals: string[]; fears: string[]; secret: string;
affinity: { init: number; range: [number, number]; thresholds: number[]; gates: Record<string, number> };
arc: { stages: string[]; beats: number[] };
}[];
beats: { id: string; act: number; scene: string; tension: number; summary: string }[];
scene_graph: {
nodes: { id: string; act: number; location: string; enter_when: Cond | null }[];
edges: { from: string; to: string; when: Cond | null }[];
};
dialogue: {
id: string; scene: string; speaker: string; line: string; line_when: Cond | null; line_else?: string; ending?: string;
choices: { id: string; text: string; when: Cond | null; effects: Effects; goto: string }[];
}[];
conditions: { grammar: string; ops: string[]; truthy: number; forms: Record<string, unknown> };
state: { flags: string[]; vars: string[]; items: string[] };
endings: { id: string; name: string; when: Cond | null }[];
metrics: { target_tension_curve: number[]; coverage_target_permille: number; ending_entropy_min_milli_bits: number; affinity_mae_max: number; tension_mae_max: number };
};
const spec = JSON.parse(readFileSync(join(ROOT, "config/story_spec.json"), "utf8")) as SpecDoc;
// 字段全表:段 → 该段(或该段数组元素)的字段名清单
const SEGMENT_FIELDS: Record<string, string[]> = {
meta: ["title", "code", "spec_version", "start", "acts", "act_names", "scale"],
world: ["factions", "locations", "lore", "rules"],
characters: ["id", "name", "role", "faction", "goals", "fears", "secret", "affinity", "arc"],
beats: ["id", "act", "scene", "tension", "summary"],
scene_graph: ["nodes", "edges"],
dialogue: ["id", "scene", "speaker", "line", "line_when", "choices"],
conditions: ["grammar", "ops", "truthy", "forms"],
state: ["flags", "vars", "items"],
endings: ["id", "name", "when"],
metrics: ["target_tension_curve", "coverage_target_permille", "ending_entropy_min_milli_bits", "affinity_mae_max", "tension_mae_max"],
};
const sample = (seg: string): Record<string, unknown> => {
const v = (spec as unknown as Record<string, unknown>)[seg];
return (Array.isArray(v) ? v[0] : v) as Record<string, unknown>;
};
test("顶层 10 段:九段数据 + conditions 文法声明", () => {
assert.deepEqual(Object.keys(spec).sort(), ["beats", "characters", "conditions", "dialogue", "endings", "meta", "metrics", "scene_graph", "state", "world"]);
assert.equal(Object.keys(SEGMENT_FIELDS).length, 10);
});
test("每段的字段名与类型声明逐项对齐", () => {
for (const [seg, fields] of Object.entries(SEGMENT_FIELDS)) {
const actual = Object.keys(sample(seg));
assert.deepEqual(actual.filter((k) => !fields.includes(k)), [], `${seg} 有未声明字段`);
assert.deepEqual(fields.filter((k) => !actual.includes(k)), [], `${seg} 缺字段`);
}
});
test("meta.scale 与实测计数逐项对账", () => {
const actual = {
scenes: spec.scene_graph.nodes.length,
characters: spec.characters.length,
dialogue_nodes: spec.dialogue.length,
choices: spec.dialogue.reduce((n, d) => n + d.choices.length, 0),
flags: spec.state.flags.length,
affinity_vars: spec.state.vars.length,
items: spec.state.items.length,
factions: spec.world.factions.length,
endings: spec.endings.length,
};
assert.deepEqual(spec.meta.scale, actual);
});
test("每个对话节点要么是 start,要么是某选项的 goto 目标", () => {
const ids = new Set(spec.dialogue.map((d) => d.id));
const targets = new Set<string>([spec.meta.start]);
for (const d of spec.dialogue) for (const c of d.choices) targets.add(c.goto);
assert.deepEqual([...ids].filter((x) => !targets.has(x)).sort(), []);
});
✔ 顶层 10 段:九段数据 + conditions 文法声明 (0.878167ms)
✔ 每段的字段名与类型声明逐项对齐 (0.637792ms)
✔ meta.scale 与实测计数逐项对账 (0.129875ms)
✔ 每个对话节点要么是 start,要么是某选项的 goto 目标 (0.140542ms)
ℹ tests 4
ℹ suites 0
ℹ pass 4
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 220.802042
SEGMENT_FIELDS 是这份类型声明的运行时镜像:类型告诉编辑器字段长什么样,这张表告诉测试字段应该有哪些。两边都由人工维护,所以断言用双向差集——actual 里多出来的键和 fields 里缺失的键都要为空。meta.scale 那一项则是把「声明规模」和「实测规模」摆在一起对账:规模是全书常量,任何一处对不上都意味着规格被改动过。
下一节处理的是另一类失效:字段都在、引用也都存在,但条件本身写不通——["aff","hui","~=",10] 这种形状在解析阶段就该被拦住。
阅读导航:上一节:1.3 文本是渲染:规格与文本的分工 · 下一节:2.2 模式校验与语义校验 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。