1.3 路径的回放与复现

同一条选择序列推进两次,走出的状态必须逐位一致。本节用三条证据钉死这件事:确定性序列回放两次摘要相同、每个前缀从零重放等于全程记录、500 条采样序列各自回放两次逐条一致,并说明「回放无隐藏状态」到底在保证什么、为什么它是本卷所有度量的前提。

站内那篇独立游戏的叙事设计与分支对话 (约 1076 行)谈的是玩家选择体验:怎么让一次选择被记住、怎么让后果可信,配的是散文与清单,代码不可跑。本章把「记住」这件事变成一个可断言的性质——一次通关不是被记在日志里,而是被编码成一串 choice_id,只要把这串 id 再喂给引擎一遍,就能重建出逐位相同的状态。这是本卷所有度量的前提:如果同一条路径两次走出的状态不一样,那么覆盖率、结局分布、张力曲线全部失去意义。

上一节定死了编码,这一节定死复现。

回放是什么

回放(replay)是把一串选择序列从头喂给引擎,让它重新推进出终局状态。它成立的前提只有一个:引擎的转移是纯函数——同样的输入必然得到同样的输出,没有随机、没有时钟、没有 I/O。

只要这个前提成立,回放就有三个直接后果:

  • 可复现:同一个玩家两次做出同样的选择,到达同一个状态。
  • 可跨语言:Python 侧算出的摘要,TS 侧用同一串序列能算回同一个整数。
  • 可调试:线上出了一条异常路径,把它编码成 choice_id 序列,本地就能重建现场。

本节要证的,就是这三条里最硬的那条:回放无隐藏状态。

三条证据

「回放无隐藏状态」不是一句口号,它拆成三个可测的断言:

断言测法为什么必要
同序列两次回放相同同一串 id 推进两次,比终局摘要排除「推进本身带随机」
前缀从零重放一致每个前缀独立重放,比全程记录的该步摘要排除「中途状态被外部记忆污染」
采样序列批量一致500 条序列各回放两次,逐条比排除「只在个别路径上成立」

第一条排除随机,第二条排除隐藏状态(比如某个全局变量在推进中被悄悄改写),第三条排除样本偏差。三条都过,才敢说「状态完全由选择序列决定」。

S 层:Python 侧的回放复现

sim/nm_01_3_replay.py 走确定性路径,回放两次比终局摘要;再对每一个前缀从零重放,与全程记录的逐步摘要比对;最后对 500 条采样序列各回放两次。它还会读卷二产物 out/ne_04_3_py.json(同种子同采样的 500 条摘要)做跨卷对照。推进与摘要复用卷一 sim/na_02_digest.py(只读 import)。

#!/usr/bin/env python3
# file: sim/nm_01_3_replay.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/nm_01_3_replay.py
"""卷三 1.3 路径的回放与复现(S 层,Python 侧):证明「同一选择序列两次推进,
走出的状态逐位一致」。

四条证据:
  1) 确定性序列回放两次,终局 digest 相同(且与卷二 ne_01_1 的 4162796975 一致)
  2) 前缀重放:每个前缀从零重放出的 digest == 全程记录的该步 digest(无隐藏状态)
  3) 500 条采样序列各自回放两次,逐条逐位一致
  4) 与卷二产物 out/ne_04_3_py.json 对照(存在时):同种子同摘要
落 out/nm_01_3_replay.json。推进与摘要复用卷一 sim/na_02_digest.py(只读 import)。
摘要只对状态做,不对文本做;全部整数。
"""
from __future__ import annotations

import json
import os
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
from na_02_digest import cond_ok, init_state, node_by_id, apply_choice, digest, sample_paths  # noqa: E402

ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")
JSON_PATH = os.path.join(OUT, "nm_01_3_replay.json")
AFF_LO, AFF_HI = 0, 100
SEED, N = 20261016, 500


def load_spec() -> dict:
    with open(os.path.join(ROOT, "config/story_spec.json"), encoding="utf-8") as fh:
        return json.load(fh)


def replay(spec: dict, choices: list[str]) -> dict:
    s = init_state(spec)
    for cid in choices:
        s = apply_choice(spec, s, cid)
    return s


def walk(spec: dict) -> list[str]:
    s, trail = init_state(spec), []
    for _ in range(64):
        cur = node_by_id(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
        trail.append(opts[0]["id"])
        s = apply_choice(spec, s, opts[0]["id"])
    return trail


def main() -> int:
    spec = load_spec()
    det = walk(spec)

    # 1) 确定性序列两次回放
    d1, d2 = digest(replay(spec, det)), digest(replay(spec, det))

    # 2) 前缀重放:全程记录每步 digest,再对每个前缀从零重放
    s, full = init_state(spec), [digest(init_state(spec))]
    for cid in det:
        s = apply_choice(spec, s, cid)
        full.append(digest(s))
    prefix_ok = all(digest(replay(spec, det[:k])) == full[k] for k in range(len(det) + 1))

    # 3) 500 条采样序列各自回放两次
    seqs = sample_paths(spec, N, SEED)
    dg = [digest(replay(spec, p)) for p in seqs]
    dg2 = [digest(replay(spec, p)) for p in seqs]
    same_n = sum(1 for a, b in zip(dg, dg2) if a == b)

    # 4) 与卷二产物对照(存在时)
    peer = os.path.join(OUT, "ne_04_3_py.json")
    peer_match, peer_note = 0, "缺 out/ne_04_3_py.json,跳过"
    if os.path.isfile(peer):
        with open(peer, encoding="utf-8") as fh:
            old = json.load(fh)
        peer_match = sum(1 for a, b in zip(dg, [r["digest"] for r in old]) if a == b)
        peer_note = f"卷二 ne_04_3_py.json {len(old)} 条"

    finals = [replay(spec, p) for p in seqs]
    bad = sum(1 for st in finals for v in st["aff"].values() if not (AFF_LO <= v <= AFF_HI))

    print(f"spec={spec['meta']['title']}  策略=首选项  seed={SEED}")
    print(f"1) 确定性序列({len(det)} 步)回放两次:digest {d1} == {d2} -> {d1 == d2}")
    print(f"   终局节点 {replay(spec, det)['node']}  与卷二 ne_01_1 的 4162796975 一致:{d1 == 4162796975}")
    print(f"2) 前缀重放:{len(det) + 1} 个前缀从零重放全部等于全程记录:{prefix_ok}")
    print(f"   步 0..{len(det)} 的 digest 前 4 / 末 1:{full[:4]} .. {full[-1:]}")
    print(f"3) 采样 {N} 条序列各自回放两次,逐位一致 {same_n} / {N}")
    print(f"4) 与卷二对照:{peer_match} / {N} 条摘要相同({peer_note})")
    print(f"整数不变量:好感度越界 {bad}({N} 条终局状态全部落在 [{AFF_LO},{AFF_HI}])")

    if not (d1 == d2 == 4162796975) or not prefix_ok or same_n != N or bad:
        print("FAIL 回放未复现")
        return 1

    os.makedirs(OUT, exist_ok=True)
    with open(JSON_PATH, "w", encoding="utf-8") as fh:
        json.dump({
            "seed": SEED, "n": N,
            "deterministic_choices": det, "deterministic_digest": d1,
            "prefix_digests": full, "prefix_ok": prefix_ok,
            "sampled_choices": seqs, "sampled_digests": dg, "sampled_same": same_n,
            "peer_match": peer_match, "peer_note": peer_note,
        }, fh, ensure_ascii=False, indent=1)
        fh.write("\n")
    print(f"wrote out/nm_01_3_replay.json  deterministic_digest={d1}")
    print(f"OK nm_01_3_replay:同序列两次回放逐位一致,终局摘要 {d1}")
    return 0


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

本机实跑 /opt/homebrew/bin/python3 sim/nm_01_3_replay.py:

spec=雾港夜航  策略=首选项  seed=20261016
1) 确定性序列(25 步)回放两次:digest 4162796975 == 4162796975 -> True
   终局节点 e_lit  与卷二 ne_01_1 的 4162796975 一致:True
2) 前缀重放:26 个前缀从零重放全部等于全程记录:True
   步 0..25 的 digest 前 4 / 末 1:[4127714464, 3085224634, 3641975660, 2314516345] .. [4162796975]
3) 采样 500 条序列各自回放两次,逐位一致 500 / 500
4) 与卷二对照:500 / 500 条摘要相同(卷二 ne_04_3_py.json 500 条)
整数不变量:好感度越界 0(500 条终局状态全部落在 [0,100])
wrote out/nm_01_3_replay.json  deterministic_digest=4162796975
OK nm_01_3_replay:同序列两次回放逐位一致,终局摘要 4162796975

P 层:在 TS 内核上回放同一串序列

同一批选择序列喂给 TypeScript 内核 core/src/inkwell/engine.ts(只 import,不改动它),终局摘要必须与 Python 侧逐条相同。门禁测试 core/test/nm_01_3_replay.test.ts 读 Python 落盘的产物逐条断言:

// file: core/test/nm_01_3_replay.test.ts
// tier: P
// verify: node --test 'core/test/nm_01_3_replay.test.ts'
// 卷三 1.3(P 层门禁):同一选择序列在 TS 内核上回放,逐位复现 Python 侧的摘要。

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 { replay, digest } from "../src/inkwell/engine.ts";
import type { Spec } 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 data = JSON.parse(readFileSync(join(ROOT, "out/nm_01_3_replay.json"), "utf8")) as {
  seed: number;
  n: number;
  deterministic_choices: string[];
  deterministic_digest: number;
  prefix_digests: number[];
  prefix_ok: boolean;
  sampled_choices: string[][];
  sampled_digests: number[];
  sampled_same: number;
  peer_match: number;
};

test("确定性序列回放两次,终局摘要逐位一致且与 Python 侧相同", () => {
  const a = digest(replay(spec, data.deterministic_choices));
  const b = digest(replay(spec, data.deterministic_choices));
  assert.equal(a, b, "两次回放摘要应一致");
  assert.equal(a, data.deterministic_digest, "与 Python 侧 deterministic_digest 应一致");
  assert.equal(a, 4162796975, "应与卷二 ne_01_1 的终局摘要一致");
});

test("每个前缀从零重放出的摘要等于全程记录的该步摘要", () => {
  const det = data.deterministic_choices;
  assert.equal(data.prefix_digests.length, det.length + 1);
  for (let k = 0; k <= det.length; k += 1) {
    assert.equal(digest(replay(spec, det.slice(0, k))), data.prefix_digests[k], `前缀 ${k} 摘要不一致`);
  }
  assert.equal(data.prefix_ok, true);
});

test("500 条采样序列在 TS 内核上回放,摘要与 Python 侧逐条一致", () => {
  assert.equal(data.n, 500);
  assert.equal(data.sampled_choices.length, 500);
  assert.equal(data.sampled_digests.length, 500);
  for (let i = 0; i < data.sampled_choices.length; i += 1) {
    assert.equal(digest(replay(spec, data.sampled_choices[i])), data.sampled_digests[i], `#${i} 摘要不一致`);
  }
});

本机实跑 node --test 'core/test/nm_01_3_replay.test.ts':

✔ 确定性序列回放两次,终局摘要逐位一致且与 Python 侧相同 (1.044458ms)
✔ 每个前缀从零重放出的摘要等于全程记录的该步摘要 (0.906583ms)
✔ 500 条采样序列在 TS 内核上回放,摘要与 Python 侧逐条一致 (15.360083ms)
ℹ tests 3
ℹ suites 0
ℹ pass 3
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 139.835625

判读:逐位一致到底保证了什么

终局摘要 4162796975 三处相同。 Python 回放两次得到它,TS 内核回放得到它,卷二 1.1 的逐步骤轨迹末行也是它。同一个策略、同一份规格、同一个 FNV-1a 算法,终局状态就被钉死在一个 32 位整数上——只要它变,就说明规格或推进语义变了。这是本卷全部回归判据的最小样本。

26 个前缀全部从零重放一致,说明回放没有隐藏状态。 如果引擎在推进里偷偷依赖了某个「上一次走到哪」的全局变量,那么「一次性走完」记录的第 k 步摘要,就会和「只走前 k 步」重放出的摘要对不上。两者逐位相同,说明状态完全由「初始状态 + 前 k 个选择」决定,不依赖任何外部记忆。

500 条采样序列 500 / 500 一致,排除了样本偏差。 而且这 500 条与卷二 out/ne_04_3_py.json 的 500 条摘要逐条相同——这不是本卷新算的,是同一套算法在跨卷复现,说明「同种子同序列」的口径从卷二一直延续到卷三。

这里的每一个数都是 proxy 口径下的结构量——摘要、前缀一致性、回放条数,只用于判等与相对比较,不声称任何体验结论。

自检表

检查项合格标准本节实测
同序列两次回放终局摘要相同4162796975 == 4162796975
无隐藏状态每个前缀从零重放 == 全程记录26 / 26
批量一致采样序列逐条回放一致500 / 500
跨语言复现TS 内核回放同序列摘要相同3 条断言全绿
跨卷复现与卷二同种子摘要相同500 / 500
整数不变量好感度 ∈ [0,100]越界 0

路径到这里就完整了:能被编码,也能被复现。第 2 章开始,我们不再只看一条路径,而是问:要采样多少条路径,才能把这张故事图覆盖到设计想要的程度——以及覆盖不到的地方,缺口在哪里。

阅读导航:上一节:1.2 路径的表示与编码 · 下一节:2.1 采样策略:随机、贪心、穷举 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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