12.2 W 层路径批跑与断言

把规格与内核内联进单文件 HTML,用 Playwright 无头注入 24 条选择序列真玩:逐条断言终局节点、结局与状态摘要与 Python 侧一致,两遍确定性 24/24,覆盖率 982‰,截图与批跑 JSON 落盘。真人玩家测试标 X。

12.2 W 层路径批跑与断言

《游戏设计》卷三《原型可玩性与品类》 的可玩性闸门是通用的——怎么设计一次可玩性测试、怎么取样本、怎么读结果。本章收窄口径:这里批跑的是叙事路径,断言的是覆盖率、结局分布与跨宿主一致。真人玩家测试需真实业务环境,命令行不可复现,标 X。

卷二第 9、10 章 已经把可玩原型挂到了无头浏览器上;本章把同一套做法换到叙事度量上:12.1 的四条假设要在浏览器里真跑才算数——把规格与内核内联进单文件 HTML,把 24 条选择序列逐条注入点击,逐条读回终局与 Python 侧比对。

选择器契约

页面不靠类名或文案被驱动,只靠固定的 data-* 契约。契约一旦固定,注入脚本就与页面实现解耦:

选择器挂在什么上值用途
[data-node-id]当前节点卡片节点 id读回「走到哪个节点」
[data-choice-id]每个选项按钮选项 id点击注入的目标
[data-choice-available]每个选项按钮1 / 0该选项当前是否可用
[data-aff]好感度卡片好感度 JSON读回好感度读数
[data-ending]终局卡片结局名读回终局结局
[data-digest]终局卡片状态摘要读回终局摘要

S 层:先把期望值算出来

W 层要断言,就得先有一张「跑完应该长什么样」的对照表。脚本 sim/nm_12_2_batch_paths.py 用种子 20261016 采 24 条路径,逐条记下选择序列、终局节点、结局与 FNV-1a 状态摘要,再汇总这一批的覆盖率、结局分布与分布熵。

#!/usr/bin/env python3
# file: sim/nm_12_2_batch_paths.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/nm_12_2_batch_paths.py
"""卷三 12.2 W 层路径批跑与断言(S 层):采一批路径,做成批跑的**期望值**。

W 层要在浏览器里真玩、真断言,就得先有一张「跑完之后应该长什么样」的对照表。本脚本用
固定种子 20261016 采 N=24 条路径(Lehmer MINSTD,与 core/src/inkwell/engine.ts 的
samplePaths 同算法同种子),每条记下:选择序列、终局节点、结局、FNV-1a 状态摘要。再汇总
这一批的**去重对话边覆盖率**、**结局分布**与**分布熵**(毫比特)——这三个量正是 12.1 那
四条可证伪假设的被测对象。

产物 out/nm_12_2_batch_paths.json 同时供:W 层 web/verify_nm12.mjs 注入对照;12.3 的
sim/nm_12_3_decision.py 判定假设通过还是被证伪。公共底座见 sim/nm_12_common.py。
只读 config/story_spec.json。全部整数;熵公式用 math.log2(不进入状态)。
"""
from __future__ import annotations

import json
import os
import sys

import nm_12_common as cm

SEED, N = 20261016, 24


def main() -> int:
    spec = cm.load_spec()
    E = cm.dedup_edges(spec)
    paths = cm.sample_paths(spec, N, SEED)

    # 逐条把选择序列回放一遍,收集走过的去重边(覆盖率分子)
    covered = set()
    for p in paths:
        s = cm.init_state(spec)
        for cid in p["seq"]:
            ch = next(c for c in cm.node_of(spec, s["node"])["choices"] if c["id"] == cid)
            covered.add((s["node"], ch["goto"]))
            s = cm.step(s, ch)

    endings: dict[str, int] = {}
    for p in paths:
        endings[p["ending"]] = endings.get(p["ending"], 0) + 1
    cov_permille = len(covered) * 1000 // len(E)
    ent = cm.entropy_milli_bits(list(endings.values()), N)

    print(f"采样路径 {len(paths)} 条(seed={SEED})")
    print(f"步数区间 {min(len(p['seq']) for p in paths)}–{max(len(p['seq']) for p in paths)}")
    print(f"覆盖去重边 {len(covered)}/{len(E)} = {cov_permille}‰")
    print(f"结局分布 {json.dumps(endings, ensure_ascii=False, sort_keys=True)}({len(endings)} 种)")
    print(f"结局分布熵 {ent} 毫比特")
    print(f"摘要区间 {min(p['digest'] for p in paths)}–{max(p['digest'] for p in paths)}")

    doc = {"seed": SEED, "n": N, "step_cap": cm.STEP_CAP, "edges_total": len(E),
           "coverage": {"edges": len(covered), "permille": cov_permille},
           "endings": endings, "endings_reached": len(endings),
           "ending_entropy_milli_bits": ent, "paths": paths}
    os.makedirs(cm.ROOT + "/out", exist_ok=True)
    with open(cm.ROOT + "/out/nm_12_2_batch_paths.json", "w", encoding="utf-8") as fh:
        json.dump(doc, fh, ensure_ascii=False, indent=2)
        fh.write("\n")

    assert len({p["digest"] for p in paths}) == N, "24 条摘要应互不相同,采样不得退化"
    assert 0 <= cov_permille <= 1000, "覆盖率须落在 [0,1000]‰"
    assert ent >= 0, "熵非负"
    print("wrote out/nm_12_2_batch_paths.json")
    print(f"OK nm_12_2_batch_paths:{N} 条路径采样完成,覆盖 {cov_permille}‰,"
          f"{len(endings)} 种结局,熵 {ent} 毫比特")
    return 0


if __name__ == "__main__":
    sys.exit(main())

本机实跑:

采样路径 24 条(seed=20261016)
步数区间 22–28
覆盖去重边 55/56 = 982‰
结局分布 {"沉没": 5, "灯守": 1, "离港": 16, "缄默": 2}(4 种)
结局分布熵 1351 毫比特
摘要区间 81529623–4276896151
wrote out/nm_12_2_batch_paths.json
OK nm_12_2_batch_paths:24 条路径采样完成,覆盖 982‰,4 种结局,熵 1351 毫比特

W 层:把规格与内核内联进单文件 HTML

web/build_nm12.mjs 复用 web/pack.mjs 的 bundle / page / writePage / inlineConfig,把 core/src/inkwell/engine.ts 与 config/story_spec.json 内联进页面;页面还多画一张 canvas(走过的节点序列),供「已绘制」断言。

// file: web/build_nm12.mjs
// tier: W
// verify: node web/build_nm12.mjs
// 卷三 12.2:把 core/src/inkwell/engine.ts + config/story_spec.json 打成单文件 HTML。
// 页面用 data-node-id / data-choice-id / data-aff / data-ending 四个选择器契约暴露可注入
// 的接口,并把走过的节点序列画到 canvas 上(供「已绘制」断言);供 web/verify_nm12.mjs
// 注入 24 条选择序列真玩、断言终局状态与结局、两遍确定性、批跑对照 Python 侧。

import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { bundle, page, writePage, inlineConfig } from "./pack.mjs";

const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, "..");
const spec = JSON.parse(readFileSync(join(ROOT, "config/story_spec.json"), "utf8"));
const core = bundle(ROOT, ["core/src/inkwell/engine.ts"]);

const pageScript = `
const SPEC = ${inlineConfig(spec)};
let state = initState(SPEC);

function drawPath() {
  const cv = document.getElementById("c");
  const g = cv.getContext("2d");
  const W = cv.width, H = cv.height;
  g.fillStyle = "#111827"; g.fillRect(0, 0, W, H);
  const n = state.path.length;
  const step = Math.max(1, Math.floor((W - 40) / Math.max(1, n - 1)));
  const y = (i) => H - 30 - (i % 3) * 34;
  g.strokeStyle = "#38bdf8"; g.lineWidth = 2; g.beginPath();
  for (let i = 0; i < n; i += 1) {
    const x = 20 + i * step;
    if (i === 0) g.moveTo(x, y(i)); else g.lineTo(x, y(i));
  }
  g.stroke();
  for (let i = 0; i < n; i += 1) {
    g.fillStyle = "#7dd3fc";
    g.beginPath(); g.arc(20 + i * step, y(i), 3, 0, Math.PI * 2); g.fill();
  }
}

function render() {
  const n = nodeById(SPEC, state.node);
  const host = document.querySelector("[data-node-id]");
  host.dataset.nodeId = n.id; host.textContent = n.id;
  document.querySelector("[data-speaker]").textContent = n.speaker;
  document.querySelector("[data-line]").textContent = n.line;
  const box = document.querySelector("[data-choices]");
  box.textContent = "";
  for (const c of n.choices || []) {
    const b = document.createElement("button");
    b.className = "choice";
    b.dataset.choiceId = c.id;
    const ok = condOk(c.when, state);
    b.dataset.choiceAvailable = ok ? "1" : "0";
    b.disabled = !ok;
    b.textContent = c.text;
    b.addEventListener("click", () => { state = applyChoice(SPEC, state, c.id); render(); });
    box.appendChild(b);
  }
  const aff = document.querySelector("[data-aff]");
  aff.dataset.aff = JSON.stringify(state.aff);
  aff.textContent = Object.keys(state.aff).sort().map((k) => k + " " + state.aff[k]).join("   ");
  document.querySelector("[data-ending]").textContent = n.ending || "(未终局)";
  document.querySelector("[data-digest]").textContent = String(digest(state));
  drawPath();
}

window.__nm12 = {
  get node() { return state.node; },
  get aff() { return state.aff; },
  get path() { return state.path.slice(); },
  get digest() { return digest(state); },
  get canonical() { return canonical(state); },
  get ending() { return nodeById(SPEC, state.node).ending || ""; },
  get available() {
    return Array.from(document.querySelectorAll("[data-choice-id]"))
      .filter((b) => b.dataset.choiceAvailable === "1").map((b) => b.dataset.choiceId);
  },
  drawn() {
    const cv = document.getElementById("c");
    const d = cv.getContext("2d").getImageData(0, 0, cv.width, cv.height).data;
    let c = 0;
    for (let i = 0; i < d.length; i += 4) if (d[i] !== 17 || d[i + 1] !== 24 || d[i + 2] !== 39) c += 1;
    return c;
  },
  reset() { state = initState(SPEC); render(); },
};
render();
`;

const body = `
<h1>雾港夜航 · 路径批跑原型</h1>
<div class="sub">core/src/inkwell/engine.ts 内联 · 同一份 story_spec.json · 24 条路径注入真玩</div>
<div class="card"><div class="k">当前节点</div><div class="big" data-node-id></div></div>
<div class="line"><span class="who" data-speaker></span> <span data-line></span></div>
<canvas id="c" width="640" height="160"></canvas>
<div data-choices></div>
<div class="card"><div class="k">好感度</div><div data-aff></div></div>
<div class="card"><div class="k">终局</div><div data-ending></div><div class="k">digest</div><div data-digest></div></div>
`;

const html = page({ title: "雾港夜航 · 路径批跑原型", body, script: core + "\n\n" + pageScript });
console.log("wrote web/dist/nm12.html", writePage(join(HERE, "dist/nm12.html"), html), "bytes");

本机实跑 node web/build_nm12.mjs:

wrote web/dist/nm12.html 42333 bytes

W 层:注入 24 条路径、逐条断言、两遍确定性

web/verify_nm12.mjs 先开一个预热页(冷启动首个 page 读 canvas 像素恒为空白),再打开页面,把 24 条序列逐条注入。每条读回终局节点 / 结局 / 摘要与 Python 侧比对;再把整批跑两遍,逐条摘要必须相同。核心注入循环:

// snippet: web/verify_nm12.mjs(12.2 注入循环节选)
async function runBatch() {
  const rows = [];
  for (const p of batch.paths) {
    await page.goto(url);
    await page.evaluate(() => window.__nm12.reset());
    for (const c of p.seq) await page.click(`[data-choice-id="${c}"]`);
    const got = await page.evaluate(() => ({
      node: window.__nm12.node, ending: window.__nm12.ending,
      digest: window.__nm12.digest, path: window.__nm12.path,
    }));
    rows.push({ name: p.name, steps: p.seq.length, ...got });
  }
  return rows;
}

本机实跑 node web/build_nm12.mjs && NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_nm12.mjs:

== 12.2 第一遍批跑(浏览器注入 24 条路径)
OK   p01 步 22 终局 e_dark 结局 离港 摘要 3976226298
OK   p02 步 25 终局 e_dark 结局 离港 摘要 3848604931
OK   p03 步 28 终局 e_dark 结局 离港 摘要 1198029437
OK   p04 步 25 终局 e_loss 结局 沉没 摘要 1613114666
OK   p05 步 28 终局 e_loss 结局 沉没 摘要 2934909485
OK   p06 步 22 终局 e_loss 结局 沉没 摘要 1979625530
OK   p07 步 25 终局 e_loss 结局 沉没 摘要 844847140
OK   p08 步 25 终局 e_silence 结局 缄默 摘要 4230877648
OK   p09 步 25 终局 e_dark 结局 离港 摘要 3757012698
OK   p10 步 28 终局 e_lit 结局 灯守 摘要 81529623
OK   p11 步 25 终局 e_dark 结局 离港 摘要 3400741328
OK   p12 步 22 终局 e_dark 结局 离港 摘要 2500940853
OK   p13 步 25 终局 e_dark 结局 离港 摘要 1943352585
OK   p14 步 25 终局 e_dark 结局 离港 摘要 3950420436
OK   p15 步 22 终局 e_dark 结局 离港 摘要 3469897896
OK   p16 步 25 终局 e_dark 结局 离港 摘要 3025828969
OK   p17 步 25 终局 e_dark 结局 离港 摘要 1373024092
OK   p18 步 22 终局 e_silence 结局 缄默 摘要 1799670809
OK   p19 步 25 终局 e_dark 结局 离港 摘要 4276896151
OK   p20 步 28 终局 e_dark 结局 离港 摘要 827903905
OK   p21 步 28 终局 e_dark 结局 离港 摘要 1594298665
OK   p22 步 25 终局 e_dark 结局 离港 摘要 4199467955
OK   p23 步 28 终局 e_loss 结局 沉没 摘要 294643575
OK   p24 步 25 终局 e_dark 结局 离港 摘要 1572577640
OK   浏览器与 Python 逐条一致: 24
OK   浏览器覆盖去重边数: 55
OK   浏览器覆盖率(‰): 982
OK   浏览器结局分布与 Python 一致: ["沉没=5","灯守=1","离港=16","缄默=2"]
OK   已绘制像素 3927 px(断言 >= 1)
OK   截图 out/shots/nm12_batch_final.png 68310 bytes
== 12.2 第二遍批跑(两遍确定性)
OK   两遍确定性(逐条摘要相同): 24
wrote out/nm_12_2_w_batch.json
== verify_nm12: 失败 0

判读:批跑证明了什么

「浏览器与 Python 逐条一致 24/24」 说明三处消费同源:同一条选择序列在 Python 与浏览器里推进出的终局摘要逐位相同;24 条摘要去重后仍是 24(S 层断言),采样没有退化成「都走同一条」。覆盖率 982‰、结局分布 {沉没=5, 灯守=1, 离港=16, 缄默=2} 由浏览器读回的节点序列独立还原,与 Python 侧完全一致——不是抄来的数;这一条覆盖了 H1、H2。

两遍确定性 24/24 说明注入不是「一次性的运气」:同一批序列跑第二遍,逐条摘要不变。已绘制 3927 px 说明 canvas 真的画了东西(断言放宽为「≥ 1 px」,绝不写反向的「= 0」)。唯一没被这批数据满足的是 H4:结局分布熵 1351 毫比特 < 1500,它是不是规格缺陷,下一节用样本量扫描来判定。

三个易踩的工程坑

  • 冷启动首个 page 读 canvas 像素恒为空白。规避办法是先开一个预热页(画一次 8×8 再丢弃),之后正常页的绘制才可信;「已绘制」断言只写前景像素 ≥ 1。
  • NODE_PATH 对 ESM import 无效。Playwright 全局装在 /opt/homebrew/lib/node_modules,必须在 .mjs 里 createRequire(import.meta.url) 拿到 CJS 的 require,再 require("playwright")。
  • 别把截图 Buffer 直接 console.log。page.screenshot() 同时返回 Buffer 并写文件;直接打印会把二进制灌进输出,正确做法是只打 .length。

自检表

检查项合格标准
契约完整六个 data-* 选择器都挂到页面上
逐条一致24 条浏览器终局与 Python 侧全等
覆盖独立还原覆盖率由浏览器读回的节点序列算出,非抄写
两遍确定性同批序列跑两遍,逐条摘要相同
已绘制预热页规避冷启动;断言写「≥ 1 px」
产物落盘截图 + out/nm_12_2_w_batch.json

小结

  • 期望值先算:S 层采 24 条路径,覆盖 982‰、4 种结局、熵 1351 毫比特,落成对照表;W 层把规格与内核内联进 nm12.html,24 条序列逐条注入,逐条断言 24/24。
  • 两遍确定性:同一批序列跑两遍,逐条摘要相同;覆盖率由 DOM 读数独立还原。一条被证伪:H4(熵 1351 < 1500)留给下一节判定是改规格还是改假设。

批跑通过只说明「结构断言在模拟路径下成立」——覆盖率、结局数、一致条数、分布熵全是结构量代理指标(proxy),只做相对比较,它不声称这份叙事更好玩;那是体验,真人测试标 X。

阅读导航:上一节:12.1 目标与假设 · 下一节:12.3 从测试到决策 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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