10.3 多路径批跑

用固定种子采样 20 条路径,逐条把选择序列注入页面,断言终局节点、结局与状态摘要与 Python 侧一致,批跑通过 20/20、覆盖 4 种结局。失败时原样打印选择序列便于重放;S 层落成 JSON,P 层 core/test 回放对照。

Godot 的选择历史日志 记录的是一个玩家的一次选择史——一局、一条轨迹、可回看。本节要的是一批:用固定种子采样 20 条不同的选择序列,逐条注入页面,逐条断言终局与 Python 侧一致。一条路径通了只证明这一条;一批路径一起通,才证明这台引擎在整片状态空间上可复现。

10.3 多路径批跑

单条路径的终局断言(10.2)回答「这条路对不对」;批跑回答「很多条路是不是都对」。采样用 Lehmer MINSTD 可播种 PRNG——与内核 engine.ts 的 samplePaths 同算法同种子,所以 Python 侧采出的 20 条序列与 TS 侧采出的 20 条逐条相同。

S 层:采样 20 条路径

sim/ne_10_3_batch_paths.py 用固定种子 20261016 采样 20 条路径,每条记下选择序列、终局节点、结局与状态摘要,落成 out/ne_10_3_batch_paths.json。这份 JSON 是批跑的期望值:W 层拿它注入对照,P 层拿它回放对照。

#!/usr/bin/env python3
# file: sim/ne_10_3_batch_paths.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ne_10_3_batch_paths.py
"""卷二 10.3 多路径批跑(S 层):用 Lehmer MINSTD 采样 20 条路径,落成对照表。

采样算法与 core/src/inkwell/engine.ts 的 samplePaths 逐行同构(同算法同种子):
每步在当前节点的可用选项里按 PRNG 取一条,走出 20 条不同的选择序列,各记下
终局节点、结局与 FNV-1a 状态摘要。产物 out/ne_10_3_batch_paths.json 同时供
W 层 web/verify_ne10.mjs 注入对照与 P 层 core/test/ne_10_3_batch.test.ts 回放对照。
"""
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/ne_10_3_batch_paths.json")

N, SEED, MAX_STEPS = 20, 20261016, 200


def cond_ok(c, s: dict) -> bool:
    if c is None:
        return True
    k = c[0]
    if k == "flag":
        return s["flags"].get(c[1], 0) == c[2]
    if k == "item":
        return s["items"].get(c[1], 0) == c[2]
    if k == "aff":
        v, op, n = s["aff"].get(c[1], 0), c[2], c[3]
        return {">=": v >= n, "<": v < n, "==": v == n, "!=": v != n}[op]
    if k == "and":
        return cond_ok(c[1], s) and cond_ok(c[2], s)
    if k == "or":
        return cond_ok(c[1], s) or cond_ok(c[2], s)
    return not cond_ok(c[1], s)


def node(spec: dict, nid: str) -> dict:
    return next(n for n in spec["dialogue"] if n["id"] == nid)


def init_state(spec: dict) -> dict:
    aff = {v: next(c["affinity"]["init"] for c in spec["characters"] if c["id"] == v)
           for v in sorted(spec["state"]["vars"])}
    return {"node": spec["meta"]["start"], "aff": aff,
            "flags": {f: 0 for f in sorted(spec["state"]["flags"])},
            "items": {i: 0 for i in sorted(spec["state"]["items"])},
            "path": [spec["meta"]["start"]]}


def step(s: dict, c: dict) -> dict:
    eff = c.get("effects") or {}
    aff, flags, items = dict(s["aff"]), dict(s["flags"]), dict(s["items"])
    for k, v in (eff.get("set") or {}).items():
        flags[k] = v
    for k, v in (eff.get("add") or {}).items():
        aff[k] = min(100, max(0, aff.get(k, 0) + v))
    for k, v in (eff.get("grant") or {}).items():
        items[k] = v
    for k in (eff.get("revoke") or {}):
        items[k] = 0
    return {"node": c["goto"], "aff": aff, "flags": flags, "items": items,
            "path": s["path"] + [c["goto"]]}


def canonical(s: dict) -> str:
    aff = ",".join(f"{k}={s['aff'][k]}" for k in sorted(s["aff"]))
    fl = ",".join(k for k in sorted(s["flags"]) if s["flags"][k])
    it = ",".join(k for k in sorted(s["items"]) if s["items"][k])
    return f"{s['node']}|{aff}|{fl}|{it}|{'>'.join(s['path'])}"


def digest(s: dict) -> int:
    h = 2166136261
    for ch in canonical(s):
        h = ((h ^ ord(ch)) * 16777619) & 0xFFFFFFFF
    return h


def main() -> int:
    with open(SPEC, encoding="utf-8") as fh:
        spec = json.load(fh)

    rng = SEED

    def rnd(mod: int) -> int:
        nonlocal rng
        rng = (rng * 48271) % 2147483647
        return rng % mod

    paths: list[dict] = []
    for i in range(N):
        s, seq = init_state(spec), []
        for _ in range(MAX_STEPS):
            cur = node(spec, s["node"])
            if cur.get("ending"):
                break
            opts = [c for c in (cur.get("choices") or []) if cond_ok(c.get("when"), s)]
            if not opts:
                break
            c = opts[rnd(len(opts))]
            seq.append(c["id"])
            s = step(s, c)
        paths.append({"name": f"p{i + 1:02d}", "seq": seq, "node": s["node"],
                      "ending": node(spec, s["node"]).get("ending", ""),
                      "digest": digest(s), "canonical": canonical(s)})

    endings: dict[str, int] = {}
    for p in paths:
        endings[p["ending"]] = endings.get(p["ending"], 0) + 1

    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"结局分布 {json.dumps(endings, ensure_ascii=False, sort_keys=True)}")
    for p in paths:
        print(f"  {p['name']} 步 {len(p['seq']):>2} 终局 {p['node']:<7} 结局 {p['ending']:<4} 摘要 {p['digest']}")

    doc = {"seed": SEED, "n": N, "max_steps": MAX_STEPS,
           "endings": endings, "paths": paths}
    os.makedirs(os.path.dirname(OUT), exist_ok=True)
    with open(OUT, "w", encoding="utf-8") as fh:
        json.dump(doc, fh, ensure_ascii=False, indent=2)
        fh.write("\n")

    if len({p["digest"] for p in paths}) == 1:
        print("FAIL 20 条路径摘要全同,采样退化")
        return 1
    print("OK ne_10_3_batch_paths:20 条路径采样完成,摘要去重后非退化")
    return 0


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

本机实跑:

采样路径 20 条(seed=20261016)
步数区间 22–28
结局分布 {"沉没": 4, "灯守": 1, "离港": 13, "缄默": 2}
  p01 步 22 终局 e_dark  结局 离港   摘要 3976226298
  p02 步 25 终局 e_dark  结局 离港   摘要 3848604931
  p03 步 28 终局 e_dark  结局 离港   摘要 1198029437
  p04 步 25 终局 e_loss  结局 沉没   摘要 1613114666
  p05 步 28 终局 e_loss  结局 沉没   摘要 2934909485
  p06 步 22 终局 e_loss  结局 沉没   摘要 1979625530
  p07 步 25 终局 e_loss  结局 沉没   摘要 844847140
  p08 步 25 终局 e_silence 结局 缄默   摘要 4230877648
  p09 步 25 终局 e_dark  结局 离港   摘要 3757012698
  p10 步 28 终局 e_lit   结局 灯守   摘要 81529623
  p11 步 25 终局 e_dark  结局 离港   摘要 3400741328
  p12 步 22 终局 e_dark  结局 离港   摘要 2500940853
  p13 步 25 终局 e_dark  结局 离港   摘要 1943352585
  p14 步 25 终局 e_dark  结局 离港   摘要 3950420436
  p15 步 22 终局 e_dark  结局 离港   摘要 3469897896
  p16 步 25 终局 e_dark  结局 离港   摘要 3025828969
  p17 步 25 终局 e_dark  结局 离港   摘要 1373024092
  p18 步 22 终局 e_silence 结局 缄默   摘要 1799670809
  p19 步 25 终局 e_dark  结局 离港   摘要 4276896151
  p20 步 28 终局 e_dark  结局 离港   摘要 827903905
OK ne_10_3_batch_paths:20 条路径采样完成,摘要去重后非退化

P 层:回放这 20 条序列

core/test/ne_10_3_batch.test.ts 读同一份 JSON,用内核把每条序列回放一遍,断言终局节点、结局、摘要三项全等。它证明的是「S 层采出的序列,在 TS 内核里推进出的终局与 S 层自己算的一样」——跨语言同源的第一道门。

// file: core/test/ne_10_3_batch.test.ts
// tier: P
// verify: node --test 'core/test/ne_10_3_batch.test.ts'
// 卷二 10.3(P 层门禁):回放 S 层采样的 20 条路径,断言终局节点、结局与状态摘要一致。
// 同一份 out/ne_10_3_batch_paths.json 也供 W 层 web/verify_ne10.mjs 注入对照。

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";

import type { Spec, State } from "../src/inkwell/engine.ts";
import { initState, applyChoice, condOk, nodeById, digest } from "../src/inkwell/engine.ts";

const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, "../..");
const spec = JSON.parse(readFileSync(join(ROOT, "config/story_spec.json"), "utf8")) as Spec;
const batch = JSON.parse(readFileSync(join(ROOT, "out/ne_10_3_batch_paths.json"), "utf8")) as {
  seed: number;
  n: number;
  endings: Record<string, number>;
  paths: { name: string; seq: string[]; node: string; ending: string; digest: number }[];
};

function replay(seq: string[]): State {
  let s = initState(spec);
  for (const c of seq) s = applyChoice(spec, s, c);
  return s;
}

test("批跑规模与种子:20 条路径、seed=20261016", () => {
  assert.equal(batch.seed, 20261016);
  assert.equal(batch.n, 20);
  assert.equal(batch.paths.length, 20);
});

test("每条路径回放:终局节点、结局、摘要与 S 层一致", () => {
  for (const p of batch.paths) {
    const s = replay(p.seq);
    assert.equal(s.node, p.node, `${p.name} 终局节点不一致`);
    assert.equal(nodeById(spec, s.node).ending ?? "", p.ending, `${p.name} 结局不一致`);
    assert.equal(digest(s), p.digest, `${p.name} 摘要不一致`);
  }
});

test("批跑非退化:20 条摘要互不相同,结局分布覆盖 4 种", () => {
  const digs = new Set(batch.paths.map((p) => p.digest));
  assert.equal(digs.size, 20, "20 条摘要应互不相同");
  assert.equal(Object.keys(batch.endings).length, 4, "应覆盖 4 种结局");
  const sum = Object.values(batch.endings).reduce((a, b) => a + b, 0);
  assert.equal(sum, 20, "结局分布计数之和应为 20");
});

test("确定性策略路径:25 步走到 e_lit / 灯守 / 4162796975", () => {
  let s = initState(spec);
  const seq: string[] = [];
  for (let i = 0; i < 64; i += 1) {
    const n = nodeById(spec, s.node);
    if (n.ending) break;
    const opts = (n.choices ?? []).filter((c) => condOk(c.when, s));
    if (!opts.length) break;
    seq.push(opts[0].id);
    s = applyChoice(spec, s, opts[0].id);
  }
  assert.equal(seq.length, 25);
  assert.equal(s.node, "e_lit");
  assert.equal(nodeById(spec, s.node).ending, "灯守");
  assert.equal(digest(s), 4162796975);
});

本机实跑(node --test 'core/test/ne_10_3_batch.test.ts'):

✔ 批跑规模与种子:20 条路径、seed=20261016 (0.458333ms)
✔ 每条路径回放:终局节点、结局、摘要与 S 层一致 (1.695917ms)
✔ 批跑非退化:20 条摘要互不相同,结局分布覆盖 4 种 (0.128583ms)
✔ 确定性策略路径:25 步走到 e_lit / 灯守 / 4162796975 (0.211167ms)
ℹ tests 4
ℹ pass 4
ℹ fail 0

W 层:批跑汇总表

web/verify_ne10.mjs(全文见 10.2)的第三段读同一份 JSON,逐条把序列注入页面,断言 DOM 读回的终局与 JSON 一致。失败时它把失败的选择序列原样打印出来,方便直接重放定位。

// snippet: web/verify_ne10.mjs(10.3 批跑循环节选)
for (const p of batch.paths) {
  await page.goto(url);
  await page.evaluate(() => window.__ne10.reset());
  for (const c of p.seq) await page.click(`[data-choice-id="${c}"]`);
  const got = await page.evaluate(() => ({ node: window.__ne10.node, ending: window.__ne10.ending, digest: window.__ne10.digest }));
  const ok = got.node === p.node && got.ending === p.ending && got.digest === p.digest;
  if (ok) pass += 1;
  else { fail += 1; console.log(`FAIL ${p.name} 重放:${JSON.stringify(p.seq)}`); }
}

本机实跑(NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_ne10.mjs 的 10.3 段):

== 10.3 多路径批跑汇总
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
批跑通过 20/20
== verify_ne10: 失败 0

判读:批跑证明了什么

「批跑通过 20/20」 与 P 层的「pass 4 / fail 0」指向同一件事:20 条由固定种子采出的序列,在 Python 侧算出的终局(节点 / 结局 / 摘要),在浏览器里注入点击后逐条重现。三处(S 采样、P 回放、W 注入)用的是同算法同种子,所以对照才有意义。步数区间 22–28、20 条摘要去重后仍是 20,说明采样没有退化成「都走同一条」——P 层的非退化断言正是在守这一条。

结局分布 {"沉没": 4, "灯守": 1, "离港": 13, "缄默": 2} 是采样口径下的分布,只做相对比较、不做绝对判断——均匀采样 20 条得到这样的分布,不等于「游戏设计偏向离港」,那要看穷举口径下的分布(卷三)。这里它只用来确认「采样覆盖了多个结局」,不声称任何体验结论。

失败时的可复现命令:批跑失败会把整条选择序列 JSON.stringify 出来,直接粘回 p.seq 就能重放同一局——失败信息本身是可执行的,这是批跑比「只报一个红叉」更有用的地方。

阅读导航:上一节:10.2 断言终局状态与结局 · 下一节:11.1 存档的序列化 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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