12.3 文本表与单页预览

把全部 48 条台词与 115 个选项渲染成一张 163 行的文本表,每行一个键,内联进单文件 HTML,再用 Playwright 无头打开,断言台词条数、选项条数、空文本行数与键指纹,截图到 out/shots/,并验两遍渲染确定性。末尾重申本章的做 / 不做边界:本套只做文本的结构,不评价文本质量。

《孢子三部曲创作手记》 做的是文本创作本身——小说怎么写、叙事怎么编、文笔与修辞怎么打磨。本节与它划界明确:本套不评价文本写得好不好,只做文本的结构。这一节把前两节的模板与变体一起落成一张文本表,并用无头浏览器证明这张表真的渲染出来了。

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 结构校验:孤儿节点与断链 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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