2.1 story_spec.json 的字段全表

把《雾港夜航》的 story_spec.json 逐段摊开:顶层十个键里九段承载数据、conditions 只声明文法。给出字段名、类型、必填与消费方,再用 S 层脚本审计每个声明项是否真被引用、P 层类型声明是否与 JSON 逐项对齐,两边都不留未对齐字段。

上一章把故事还原成了一份规格,但只交代了骨架。这一节把 config/story_spec.json 摊开:它有哪些字段、哪些必填、谁来读它。需要先划一条界——《游戏设计》卷一:玩法机制与可执行规格 的那套真源是数值与规则(伤害、资源、随机性),消费对象是一局战斗的状态轨迹;本套真源是结构与条件(世界、角色、弧光、分支、阈值),消费对象是故事在玩家选择下的展开路径。两者共享「整数化 + 确定性 + 代理口径」的工程纪律,但字段表没有一处可以互相搬运。

2.1.1 十个顶层键:九段数据 + 一段文法

story_spec.json 的顶层有十个键。其中 conditions 只声明条件文法(算子集合与形式清单),不承载任何故事数据;其余九段承载数据。下表按段列出字段名、类型、是否必填、示例值与消费方。

段字段类型必填示例值消费方
metatitle / code / spec_versionstr / str / int是"雾港夜航"工具链抬头、迁移判据
metastartstr是"d_pier_1"状态机入口(卷二第 1 章)
metaacts / act_namesint / str[]是3 / ["第一夜 抵港", …]三幕结构检验(第 6 章)
metascaleint 映射是{"scenes": 14, …}规模对账(P 层断言)
worldfactions[].stanceint 矩阵是{"guild": 1, …}派系立场(第 3 章)
worldlocations[].unlock_when条件 / null是["flag","met_hui",1]解锁判定(第 7 章)
worldlore[].reveal_flagflag 名是"knows_tide_rule"设定暴露节奏(第 3.3 节)
charactersaffinity{init,range,thresholds,gates}int是20 / [0,100] / [30,60,85]好感度状态机(第 10 章)
charactersarc{stages,beats}str[] / int[]是["失踪的传说", …]弧光可达性(第 5 章)
beatstensionint 1–5是4张力曲线(卷三第 5 章)
scene_graphnodes[].enter_when条件 / null是["item","lantern",1]场景可达性(第 7.2 节)
scene_graphedges[].when条件 / null否null顺序约束(第 7.3 节)
dialogueline / line_when / line_elsestr / 条件 / str是 / 否 / 否"先生,您是来等船的吗?"台词渲染(第 12 章)
dialoguechoices[].effects四算子是{"add":{"hui":5}}状态推进(卷二第 3 章)
dialoguechoices[].goto节点 id是"d_tavern_1"跳转目标(第 11 章)
conditionsops / forms文法声明是[">=","<","==","!="]求值器(卷二第 2 章)
stateflags / vars / itemsstr[]是18 / 4 / 9 项状态域(卷二第 5 章)
endingswhen条件 / null是["flag","bell_rung",1]结局判定(卷二第 6.3 节)
metricstarget_tension_curveint[16]是[1,2,2,3,…]曲线 MAE(卷三第 6 章)
metricscoverage_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 模式校验与语义校验 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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