1.3 引擎与规格的同源

同源的意思是同一份规格在 Python 与 TypeScript 两侧推进同一选择序列,整数状态摘要逐位相同。本节用四种确定性策略各走一条路径:Python 侧落出四条路径的选择序列与 FNV-1a 摘要,TypeScript 侧读回产物原样重放,断言规范串与摘要两侧完全一致。

站内的NPC 对话状态架构 是服务端的网络与持久化架构:它关心的是同一段对话在多个客户端之间怎么保持同步、状态怎么落盘。本节与它分工明确——本节是单机引擎内核层面的问题:同一份规格被两个不同语言的引擎读,推进同一串选项,算出来的整数状态摘要必须逐位相同。那篇不涉及跨语言,本节的全部内容就是跨语言。

前两节把引擎立了起来:1.1 给出「状态 / 事件 / 转移」的三元组,1.2 把三元组落成一台规格驱动的状态机。这一节回答最后一个地基问题:这台引擎与它读的规格,凭什么保证是同源的? 本卷有 Python 侧的仿真脚本,也有 TypeScript 侧的确定性内核,两者是两套独立实现。如果它们对同一串选择给出不同的终局状态,那么「一份规格,三处消费」就是空话。

同源:一个可断言的性质

「同源」不是「两套代码长得像」,而是一条可断言的性质:给定同一份 story_spec.json 与同一串选项 id,两侧算出的状态规范串与 FNV-1a 摘要必须逐位相同。这里只对整数状态做摘要,不对文本做——文本涉及编码与换行,把「文本逐字相等」当判据是不可靠的;判据只落在节点名、好感度、flag、物品、路径这些整数与字符串标识符上。

要让这条性质成立,两侧必须守住三条纪律:数值一律整数(好感度 0–100、无浮点)、随机一律自实现可播种 PRNG(禁语言内置 random / Math.random())、规范串的键一律排序(否则对象键序不同会假报不一致)。本节的四条路径都是确定性策略,连随机都不需要——这排除了 PRNG 分歧这个变量,让「同源」聚焦在推进语义本身上。

四种确定性策略

本节不用随机采样,改用四种确定性策略各走一条路径。它们都是纯函数式的选择规则:给定当前可用选项列表与步号,返回一个确定的选项。

策略选哪一个特点
first第一个可用选项与目录篇、1.1 节同一条路径
last最后一个可用选项走到完全不同的分支
second第二个(不存在则第一个)介于两者之间
alternate按步号奇偶交替制造来回切换的路径

四种策略产生四条不同的路径、四个不同的终局摘要。它们的价值在于:同源必须对「所有路径」成立,不能只对一条路径成立。 一条路径相同可能只是巧合,四条都相同才说明两侧的推进语义是同一套。

S 层:Python 侧落盘四条路径

sim/ne_01_3_same_origin.py 用四种策略各走一条路径,把每条路径的选择序列、终局规范串与摘要落进 out/ne_01_3_py.json。

#!/usr/bin/env python3
# file: sim/ne_01_3_same_origin.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ne_01_3_same_origin.py
"""引擎与规格同源:同一份 story_spec.json 在 Python 与 TypeScript 两侧推进同一选择序列。

四种**确定性策略**各走一条路径(策略本身不引入随机),把每条路径的选择序列、
终局规范串与 FNV-1a 摘要落进 out/ne_01_3_py.json。TS 侧 ne_01_3_same_origin.test.ts
读这份产物、原样重放同一选择序列,断言逐位相同。只对整数状态做摘要,不对文本做。
"""
from __future__ import annotations

import json
import os
import sys

AFF_LO, AFF_HI = 0, 100
STRATEGIES = ["first", "last", "second", "alternate"]


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


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 if op == ">=" else v < n if op == "<" else v == n if op == "==" else v != n
    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 for c in spec["characters"] if c["id"] == v)["affinity"]["init"]
           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 apply(spec: dict, s: dict, c: dict) -> dict:
    eff = c.get("effects") or {}
    aff, flags, items = dict(s["aff"]), dict(s["flags"]), dict(s["items"])
    for k in sorted((eff.get("set") or {})):
        flags[k] = eff["set"][k]
    for k in sorted((eff.get("add") or {})):
        aff[k] = min(AFF_HI, max(AFF_LO, aff.get(k, 0) + eff["add"][k]))
    for k in sorted((eff.get("grant") or {})):
        items[k] = eff["grant"][k]
    for k in sorted((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 pick(name: str, opts: list, i: int) -> dict:
    """四种确定性策略。都不含随机:同一条路径每次跑都一样。"""
    if name == "first":
        return opts[0]
    if name == "last":
        return opts[-1]
    if name == "second":
        return opts[1] if len(opts) > 1 else opts[0]
    return opts[i % 2] if len(opts) > i % 2 else opts[0]


def run(spec: dict, name: str) -> dict:
    s, trail = init_state(spec), []
    for i in range(64):
        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 = pick(name, opts, i)
        trail.append(c["id"])
        s = apply(spec, s, c)
    return {"strategy": name, "choices": trail, "node": s["node"],
            "ending": node(spec, s["node"]).get("ending", ""),
            "canonical": canonical(s), "digest": digest(s)}


def main() -> int:
    here = os.path.dirname(os.path.abspath(__file__))
    spec = load_spec()
    rows = [run(spec, name) for name in STRATEGIES]
    out = os.path.join(here, "../out/ne_01_3_py.json")
    with open(out, "w", encoding="utf-8") as fh:
        json.dump(rows, fh, ensure_ascii=False, separators=(",", ":"))
        fh.write("\n")

    print("策略        事件数  终局节点  结局    摘要")
    for r in rows:
        print(f"{r['strategy']:<11} {len(r['choices']):>5}  {r['node']:<8}  {r['ending']:<5}  {r['digest']:>10}")
    print(f"产物 out/ne_01_3_py.json  {len(rows)} 条策略")

    # 整数不变量:好感度全程落在 [0,100]。
    bad = 0
    for r in rows:
        s = init_state(spec)
        for cid in r["choices"]:
            c = next(x for x in (node(spec, s["node"]).get("choices") or []) if x["id"] == cid)
            s = apply(spec, s, c)
        bad += sum(1 for v in s["aff"].values() if not (AFF_LO <= v <= AFF_HI))
    print(f"好感度越界 {bad}")
    if bad:
        print("FAIL 好感度越界")
        return 1
    print("OK ne_01_3_same_origin:四条确定性路径已落盘,待 TS 侧重放比对")
    return 0


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

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

策略        事件数  终局节点  结局    摘要
first          25  e_lit     灯守     4162796975
last           25  e_dark    离港      797505075
second         25  e_dark    离港     2520803675
alternate      25  e_loss    沉没      118829827
产物 out/ne_01_3_py.json  4 条策略
好感度越界 0
OK ne_01_3_same_origin:四条确定性路径已落盘,待 TS 侧重放比对

P 层:TypeScript 侧重放比对

core/test/ne_01_3_same_origin.test.ts 读回 out/ne_01_3_py.json,用 engine.ts 的 replay 原样重放每条路径的选择序列,断言规范串、摘要、终局节点三项都与 Python 侧一致。

// file: core/test/ne_01_3_same_origin.test.ts
// tier: P
// verify: node --test 'core/test/ne_01_3_same_origin.test.ts'
// 引擎与规格同源:TS 侧读 Python 产物 out/ne_01_3_py.json,原样重放同一选择序列,
// 断言 canonical 与 digest 逐位相同。只对整数状态做摘要,不对文本做。

import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync, existsSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";

import { replay, canonical, 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;

type Row = { strategy: string; choices: string[]; node: string; ending: string; canonical: string; digest: number };

test("跨语言同源:Python 四条路径在 TS 侧重放,摘要逐位一致", () => {
  const pyPath = join(ROOT, "out/ne_01_3_py.json");
  assert.ok(existsSync(pyPath), "先跑 /opt/homebrew/bin/python3 sim/ne_01_3_same_origin.py");
  const rows = JSON.parse(readFileSync(pyPath, "utf8")) as Row[];
  assert.equal(rows.length, 4);

  let mismatch = 0;
  for (const r of rows) {
    const s = replay(spec, r.choices);
    if (canonical(s) !== r.canonical) mismatch += 1;
    assert.equal(canonical(s), r.canonical, `${r.strategy} 规范串不一致`);
    assert.equal(digest(s), r.digest, `${r.strategy} 摘要不一致`);
    assert.equal(s.node, r.node, `${r.strategy} 终局节点不一致`);
  }
  assert.equal(mismatch, 0);
});

test("四条策略落在三个不同结局上,且四个摘要两两不同", () => {
  const rows = JSON.parse(readFileSync(join(ROOT, "out/ne_01_3_py.json"), "utf8")) as Row[];
  const endings = [...new Set(rows.map((r) => r.ending))].sort();
  assert.deepEqual(endings, ["灯守", "离港", "沉没"].sort());
  assert.equal(new Set(rows.map((r) => r.digest)).size, 4);
});

test("first 策略摘要 = 目录篇 ne_00 的 4162796975", () => {
  const rows = JSON.parse(readFileSync(join(ROOT, "out/ne_01_3_py.json"), "utf8")) as Row[];
  const first = rows.find((r) => r.strategy === "first");
  assert.ok(first);
  assert.equal(first.digest, 4162796975);
});

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

✔ 跨语言同源:Python 四条路径在 TS 侧重放,摘要逐位一致 (2.789666ms)
✔ 四条策略落在三个不同结局上,且四个摘要两两不同 (1.242709ms)
✔ first 策略摘要 = 目录篇 ne_00 的 4162796975 (0.564875ms)
ℹ tests 3
ℹ suites 0
ℹ pass 3
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0

两侧对照

策略Python 摘要TypeScript 摘要逐位一致终局
first41627969754162796975是灯守
last797505075797505075是离港
second25208036752520803675是离港
alternate118829827118829827是沉没

四条路径全部逐位一致,4 / 4。first 的摘要 4162796975 与目录篇、1.1 节也是同一个数——三处(目录篇 Python、1.1 节 Python、1.3 节 TS)钉在同一条路径的同一个状态上。

判读:同源证明了什么

「4 / 4 逐位一致」证明两套独立实现共享同一套推进语义。 Python 侧的 apply 与 TS 侧的 applyEffects 是分别写的,但它们对四个效果算子、钳位规则、键序的处理必须完全一致,否则四条路径里只要有一条对不上,mismatch 就会大于 0。这里比的是整数状态,不是文本。

四条策略落在三个结局上,说明同源覆盖了分支。 last 与 second 都走到「离港」,alternate 走到「沉没」,first 走到「灯守」。如果只测 first 一条,就只覆盖了一个结局;四种策略把覆盖面拉到三个结局、四个不同摘要,同源才算被真正验证过。

first 摘要与目录篇一致,是跨篇的一致性锚点。 它把目录篇的 ne_00_toc_engine.py、1.1 节的 ne_01_1_trace.py、本节的 TS 重放三处串成一条链。任何一处改了推进语义,这条链就会断——这也是第 13 章「同源不同宿主」要系统性展开的东西。

本节只测了确定性策略,没测随机路径。 随机路径的同源要等第 4 章:那里引入可播种 PRNG,两侧必须用同一个 rng = rng * 48271 % 2147483647 才能对齐采样序列。本节刻意把随机这个变量排除掉,先证明「推进语义同源」,再叠加「采样同源」。

自检表

检查项合格标准
同源可断言同一规格、同一选择序列,两侧规范串与摘要逐位相同
覆盖多分支至少三条路径、覆盖多个结局,不只测一条
只对状态摘要摘要输入是整数状态,不含文本(不写文本逐字比对)
键序一致规范串两侧都按键名排序,避免假报不一致
无随机本节策略全确定性;随机路径留到第 4 章
不修改既有内核engine.ts 只读,git status 无 M

第一章到这里把执行侧的地基铺完了:三元组立起来(1.1)、规格驱动的状态机跑起来(1.2)、跨语言同源被断言(1.3)。下一章往下一层走:把 when 条件从「能求值」讲到「能短路、能判定可满足性」——一台状态机要跑得快、要能在构建期查死条件,都取决于条件求值器怎么写。

阅读导航:上一节:1.2 规格驱动的对话状态机 · 下一节:2.1 条件表达式的求值器 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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