《孢子三部曲创作手记》 做的是文本创作本身——小说怎么写、叙事怎么编、文笔与修辞怎么打磨。本节与它划界明确:本套不评价文本写得好不好,只做文本的结构。这一节把前两节的模板与变体一起落成一张文本表,并用无头浏览器证明这张表真的渲染出来了。
12.3 文本表与单页预览
文本表是文本层的交付形态:把散落在 48 条记录里的台词与 115 个选项收进一张表,每行一个键。键是文本表与规格之间唯一的连接点——键在,文本才在;键重复,说明两处文本抢了同一个位置。
| 行的种类 | 键的构成 | 本规格行数 |
|---|---|---|
| 台词 | line.<节点 id>.<分支> | 48 |
| 选项 | choice.<选项 id> | 115 |
| 合计 | 163 |
台词键里带分支,是因为同一节点在不同状态下会渲染出不同文本(12.2 的结论):分支进键,两种变体就不会撞在同一个键上。
S 层:把文本渲染成表
sim/na_12_3_text_table.py 在初始状态下渲染 48 条 line(条件节点取实际走到的分支)与 115 条选项文本,产出 out/na_12_3_text_table.json。指纹只由键算出——只吃 ASCII 标识符,不吃文本。
#!/usr/bin/env python3
# file: sim/na_12_3_text_table.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/na_12_3_text_table.py
"""卷一 12.3 文本表与单页预览(S 层):把全部台词与选项渲染成一张文本表。
文本表的每一行有一个**键**:`line.<节点 id>.<分支>` 或 `choice.<选项 id>`。
键是文本表与规格之间唯一的连接点——键在,文本才在。
在**初始状态**下渲染 48 条 line(条件节点取实际走到的分支)与 115 条选项文本,
产出 out/na_12_3_text_table.json 供 W 层 web/build_na12.mjs 内联进单文件 HTML。
指纹只由**键**算出(整数),不对文本做摘要。
"""
from __future__ import annotations
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
SPEC = os.path.join(ROOT, "config/story_spec.json")
OUT = os.path.join(ROOT, "out/na_12_3_text_table.json")
N_LINES, N_CHOICES, MOD = 48, 115, 2147483647
def load_spec() -> dict:
with open(SPEC, encoding="utf-8") as fh:
return json.load(fh)
def eval_cond(c, st: dict) -> bool:
if c is None:
return True
k = c[0]
if k == "flag":
return st["flags"].get(c[1], 0) == c[2]
if k == "item":
return st["items"].get(c[1], 0) == c[2]
if k == "aff":
v, op, n = st["aff"].get(c[1], 0), c[2], c[3]
return {">=": v >= n, "<": v < n, "==": v == n, "!=": v != n}[op]
if k == "and":
return eval_cond(c[1], st) and eval_cond(c[2], st)
if k == "or":
return eval_cond(c[1], st) or eval_cond(c[2], st)
if k == "not":
return not eval_cond(c[1], st)
raise ValueError(f"unknown cond op: {k}")
def render_line(node: dict, st: dict) -> tuple[str, str]:
"""四分支渲染规则,与 na_01_line_render.py 同一口径。"""
if node.get("line_when") is None:
return node["line"], "base"
if eval_cond(node["line_when"], st):
return node["line"], "when"
if node.get("line_else"):
return node["line_else"], "else"
return node["line"], "fallback"
def init_state(spec: dict) -> dict:
return {
"aff": {v: next(c["affinity"]["init"] for c in spec["characters"] if c["id"] == v)
for v in sorted(spec["state"]["vars"])},
"flags": {f: 0 for f in sorted(spec["state"]["flags"])},
"items": {i: 0 for i in sorted(spec["state"]["items"])},
}
def fingerprint(keys: list) -> int:
"""键的整数指纹:只吃键(ASCII 标识符),不吃文本。"""
fp = 0
for i, k in enumerate(keys):
fp += (i + 1) * (len(k) + 1)
return fp % MOD
def main() -> int:
spec = load_spec()
base = init_state(spec)
rows: list[dict] = []
for node in spec["dialogue"]:
text, branch = render_line(node, base)
rows.append({"key": f"line.{node['id']}.{branch}", "kind": "line", "id": node["id"], "text": text})
for ch in (node.get("choices") or []):
rows.append({"key": f"choice.{ch['id']}", "kind": "choice", "id": ch["id"],
"parent": node["id"], "text": ch["text"]})
keys = [r["key"] for r in rows]
n_line = sum(1 for r in rows if r["kind"] == "line")
n_ch = len(rows) - n_line
fp = fingerprint(keys)
print(f"文本表:{n_line} 条 line + {n_ch} 条 choice = {len(rows)} 行")
print(f"键唯一性:{len(set(keys))}/{len(keys)}(重复 {len(keys) - len(set(keys))} 个)")
print(f"键指纹(整数,只吃键):{fp}")
cond = [r["key"] for r in rows if r["kind"] == "line" and not r["key"].endswith(".base")]
print(f"条件节点在初始状态下走到的分支:{cond}")
table = {"counts": {"lines": n_line, "choices": n_ch, "rows": len(rows),
"keys_unique": len(set(keys))}, "fingerprint": fp, "rows": rows}
os.makedirs(os.path.dirname(OUT), exist_ok=True)
with open(OUT, "w", encoding="utf-8") as fh:
json.dump(table, fh, ensure_ascii=False, indent=2)
fh.write("\n")
if n_line != N_LINES or n_ch != N_CHOICES:
print(f"FAIL 行数 {n_line}/{n_ch} != {N_LINES}/{N_CHOICES}")
return 1
if len(set(keys)) != len(keys):
print("FAIL 文本表键重复")
return 1
if any(not r["text"] for r in rows):
print("FAIL 文本表出现空文本")
return 1
print("OK na_12_3_text_table:163 行文本表,键全唯一,指纹由键算出")
return 0
if __name__ == "__main__":
sys.exit(main())
文本表:48 条 line + 115 条 choice = 163 行
键唯一性:163/163(重复 0 个)
键指纹(整数,只吃键):212376
条件节点在初始状态下走到的分支:['line.d_tc_2.else', 'line.d_lt_2.fallback']
OK na_12_3_text_table:163 行文本表,键全唯一,指纹由键算出
W 层:内联进单文件 HTML
web/build_na12.mjs 复用 web/pack.mjs 的页面外壳与内联器,把整张表塞进一个 HTML;本页不涉及 TS 擦除,故不调用 bundle()。下面是它的页面脚本核心,完整文件在工作区:
// snippet: web/build_na12.mjs(节选)
import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { page, writePage, inlineConfig } from "./pack.mjs";
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, "..");
const table = JSON.parse(readFileSync(join(ROOT, "out/na_12_3_text_table.json"), "utf8"));
const pageScript = `
const TABLE = ${inlineConfig(table)};
const host = document.getElementById("tbl");
const frag = document.createDocumentFragment();
for (const r of TABLE.rows) {
const row = document.createElement("div");
row.className = "row " + r.kind;
row.setAttribute("data-key", r.key);
row.setAttribute("data-kind", r.kind);
const k = document.createElement("span");
k.className = "key";
k.textContent = r.key;
const t = document.createElement("span");
t.className = "txt";
t.textContent = r.text;
row.append(k, t);
frag.appendChild(row);
}
host.appendChild(frag);
`;
const html = page({ title: "雾港夜航 · 文本表预览", body, script: pageScript, css });
console.log("wrote web/dist/arch12.html", writePage(join(HERE, "dist/arch12.html"), html), "bytes");
wrote web/dist/arch12.html 22517 bytes
table rows 163 · fingerprint 212376
W 层:无头浏览器断言
web/verify_na12.mjs 用 Playwright 无头打开这个 HTML,断言台词条数、选项条数、空文本行数与键指纹,截图到 out/shots/,并在重载后重算一次指纹验两遍确定性。冷启动先开一个预热页,避免首个 page 读像素恒为空白。
// file: web/verify_na12.mjs
// tier: W
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_na12.mjs
// 卷一 12.3:Playwright 无头打开单文件文本表,断言台词条数、选项条数、键指纹与
// S 层一致,截图到 out/shots/,并验两遍渲染确定性。
// NODE_PATH 对 ESM import 无效 → 必须 createRequire。
import { createRequire } from "node:module";
import { readFileSync, mkdirSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const require = createRequire(import.meta.url);
const { chromium } = require("playwright");
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, "..");
const SHOTS = join(ROOT, "out/shots");
mkdirSync(SHOTS, { recursive: true });
const table = JSON.parse(readFileSync(join(ROOT, "out/na_12_3_text_table.json"), "utf8"));
const url = pathToFileURL(join(HERE, "dist/arch12.html")).href;
const browser = await chromium.launch();
const ctx = await browser.newContext({ viewport: { width: 900, height: 1200 } });
const page = await ctx.newPage();
// 冷启动预热页:首个 page 读像素恒为空白,先画一次 8x8 再丢弃
const warm = await ctx.newPage();
await warm.setContent('<canvas id="w" width="8" height="8"></canvas>');
await warm.evaluate(() => {
const g = document.getElementById("w").getContext("2d");
g.fillStyle = "#38bdf8";
g.fillRect(0, 0, 8, 8);
});
await warm.close();
let fail = 0;
function check(name, got, want) {
const ok = got === want;
if (!ok) fail += 1;
console.log(`${ok ? "OK " : "FAIL"} ${name}: ${got}${ok ? "" : " != " + want}`);
}
await page.goto(url);
const got = await page.evaluate(() => ({
lines: window.__na12.lines,
choices: window.__na12.choices,
rows: window.__na12.rows,
emptyText: window.__na12.emptyText,
fingerprint: window.__na12.fingerprint,
}));
check("台词条数", got.lines, table.counts.lines);
check("选项条数", got.choices, table.counts.choices);
check("文本表总行数", got.rows, table.counts.rows);
check("空文本行数", got.emptyText, 0);
check("键指纹(浏览器 vs S 层)", got.fingerprint, table.fingerprint);
const shot = await page.screenshot({ path: join(SHOTS, "arch12_text_table.png"), fullPage: true });
console.log(`OK 截图 out/shots/arch12_text_table.png ${shot.length} bytes`);
if (shot.length <= 0) {
fail += 1;
console.log("FAIL 截图为空");
}
await page.reload();
check("两遍渲染确定性", await page.evaluate(() => window.__na12.fingerprint), table.fingerprint);
await browser.close();
console.log(`== verify_na12: 失败 ${fail}`);
process.exit(fail ? 1 : 0);
OK 台词条数: 48
OK 选项条数: 115
OK 文本表总行数: 163
OK 空文本行数: 0
OK 键指纹(浏览器 vs S 层): 212376
OK 截图 out/shots/arch12_text_table.png 1036515 bytes
OK 两遍渲染确定性: 212376
== verify_na12: 失败 0
判读:断言的是结构,不是文本
「48 / 115 / 163」 是文本表的规模自证:S 层产出的行数、浏览器 DOM 里的行数、规格里的节点与选项数三者对齐。断言的是条数,不是「这段台词写得对不对」。
「键指纹(浏览器 vs S 层): 212376」 是跨层一致性。指纹由键的位置与长度算出,S 层用 Python 算、浏览器用 JS 算,两边都是整数,因此可以直接比对——这是「跨语言只对结构做摘要」纪律的一个具体落点。
「两遍渲染确定性: 212376」 是重载后重算的结果,两遍一致说明渲染是确定的。「截图 1036515 bytes」 是「已绘制」的证据:已绘制断言只写字节数大于 0,不写「等于 0」的反向断言——空白与失败是两回事。
边界重申:本章做文本的什么,不做什么
| 本套做(文本的结构) | 本套不做(文本的创作) |
|---|---|
| 模板与变量:占位怎么被规格里的值替换 | 用词是否漂亮、句式是否有节奏 |
| 条件文本与变体:一条记录按状态分流成几条文本 | 台词是否符合人物语言风格 |
| 文本表与键:文本外置成可校验、可预览的表 | 台词的文学质量评价 |
| 变体的一致性:键缺失、键重复、变体退化 | 配音、口型与演出(需真实业务环境,标 X) |
这张表在本章三节里是同一张,右边一列永远不会出现在任何断言里。文本创作划界到 《孢子三部曲创作手记》 ——那一套做的是小说文字本身的工艺,本章一个字都不评价。
自检表
| 检查项 | 合格标准 |
|---|---|
| 键唯一 | 163 个键互不重复(脚本内断言) |
| 文本非空 | 每一行都有文本(脚本内断言,浏览器端再断一次) |
| 层间一致 | 浏览器算出的键指纹等于 S 层算出的指纹 |
| 确定性 | 重载后重算指纹不变 |
| 已绘制 | 截图字节数大于 0,不写「等于 0」的反向断言 |
| 边界写死 | 只做文本结构,不评价文本质量;不写文本层面的摘要 |
下一章开始校验:先做结构校验——孤儿节点与断链,把「有节点进不去、有链接指不到」这两类问题从规格里揪出来。
阅读导航:上一节:12.2 条件文本与变体 · 下一节:13.1 结构校验:孤儿节点与断链 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。