3.3 状态快照与差量

快照是某一刻的完整状态,差量是相邻两快照间真正改变的字段。本节沿一条 25 步的确定性路径输出逐步骤差量表,并点出 7 个 flag 与 1 个物品被写却从未被任何条件读取——这类「只增不减的信息」按声明未用=报告处理,不判 FAIL,只如实报出。

3.3 状态快照与差量

Phaser 选择后果日志 记的是「选了什么、后果是什么」的事件流水;本节把它压成两个可比较的对象:快照与差量。快照是某一刻的完整状态,差量是相邻两刻之间真正改变的字段——前者回答「现在是什么」,后者回答「刚刚变了什么」。存档要前者,UI 提示与调试要后者。

快照与差量是两个不同的对象

对象定义本案例的表示消费方
快照某一刻的完整状态完整 canonical 串存档、回放、跨语言摘要
差量相邻两快照间真正改变的字段字段名 + 旧值→新值 的有序列表UI 提示、调试、增量日志

差量的关键在「真正改变」四个字:只有值变了的字段才进差量。一步只置位一个 flag,差量就只有一条 flag:...:0->1,而不是把整份状态重抄一遍。快照是全量的、差量是增量的——这正是两者不能互相替代的原因。

一条路径的逐步骤差量

下面这段是 S 层脚本 sim/ne_03_3_snapshot.py。它用「总是选第一个可用选项」的确定性策略走一条路径(与目录篇同一策略,共 25 步,终局 e_lit),把每一步的差量写成 out/ne_03_3_delta.csv,并在 stdout 打印前 4 步与末步;每一步都含一条 node: 变化,第 1 步 c_pier_1a 一步改了 3 个字段(换节点 + 置位 knows_yan_missing + 置位 met_hai),另有多步是「纯换节点」(差量 n=1,如第 6、11、13、20、24、25 步)。

#!/usr/bin/env python3
# file: sim/ne_03_3_snapshot.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ne_03_3_snapshot.py
"""卷二 3.3 状态快照与差量(S 层)。

沿一条确定性路径(总是选第一个可用选项)走完:快照 = 完整 canonical 串;
差量 = 相邻快照间真正改变的字段集合。产出 out/ne_03_3_delta.csv(逐步骤差量表)。
并把「写而不读」的字段点出来——被 set/grant 却无任何条件读取,在差量里恒为
「只增不减的信息」(判据:声明未用=报告,不判 FAIL)。canonical 与 engine.ts
同算法;整数口径禁浮点。
"""
from __future__ import annotations

import csv
import json
import os
import sys

AFF_LO, AFF_HI = 0, 100
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
OUT = os.path.join(ROOT, "out")
CSV_PATH = os.path.join(OUT, "ne_03_3_delta.csv")


def clamp(v: int) -> int:
    return AFF_LO if v < AFF_LO else AFF_HI if v > AFF_HI else v


def cond_ok(c, s) -> 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 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_effects(eff: dict, s: dict, goto: str) -> dict:
    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] = clamp(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": goto, "aff": aff, "flags": flags, "items": items, "path": s["path"] + [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 delta(prev: dict, nxt: dict) -> list:
    """两个快照之间真正改变的字段,逐条列出(有序)。"""
    out = []
    if prev["node"] != nxt["node"]:
        out.append(f"node:{prev['node']}->{nxt['node']}")
    for k in sorted(nxt["aff"]):
        if prev["aff"][k] != nxt["aff"][k]:
            out.append(f"aff:{k}:{prev['aff'][k]}->{nxt['aff'][k]}")
    for k in sorted(nxt["flags"]):
        if prev["flags"][k] != nxt["flags"][k]:
            out.append(f"flag:{k}:{prev['flags'][k]}->{nxt['flags'][k]}")
    for k in sorted(nxt["items"]):
        if prev["items"][k] != nxt["items"][k]:
            out.append(f"item:{k}:{prev['items'][k]}->{nxt['items'][k]}")
    return out


def walk(spec: dict) -> tuple:
    s, rows = init_state(spec), []
    for _ in range(64):
        cur = next(n for n in spec["dialogue"] if n["id"] == 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
        nxt = apply_effects(opts[0].get("effects") or {}, s, opts[0]["goto"])
        rows.append((len(rows) + 1, opts[0]["id"], s["node"], nxt["node"], delta(s, nxt)))
        s = nxt
    return s, rows


def read_keys(spec: dict) -> tuple:
    """扫全 spec 的条件(对话 / 场景图 / 地点 / 结局),收集被读取的 flag / item。"""
    acc = {"flag": set(), "item": set()}

    def rec(c):
        if c is None:
            return
        k = c[0]
        if k in acc:
            acc[k].add(c[1])
        elif k in ("and", "or"):
            rec(c[1]); rec(c[2])
        elif k == "not":
            rec(c[1])

    conds = [n.get("line_when") for n in spec["dialogue"]]
    conds += [c.get("when") for n in spec["dialogue"] for c in (n.get("choices") or [])]
    conds += [e.get("when") for e in spec.get("scene_graph", {}).get("edges", [])]
    conds += [nd.get("enter_when") for nd in spec.get("scene_graph", {}).get("nodes", [])]
    conds += [loc.get("unlock_when") for loc in spec.get("world", {}).get("locations", [])]
    conds += [e.get("when") for e in spec.get("endings", [])]
    for c in conds:
        rec(c)
    return acc["flag"], acc["item"]


def main() -> int:
    with open(os.path.join(ROOT, "config/story_spec.json"), encoding="utf-8") as fh:
        spec = json.load(fh)
    s, rows = walk(spec)
    s2, _ = walk(spec)
    end = next(n for n in spec["dialogue"] if n["id"] == s["node"])
    print(f"spec={spec['meta']['title']}  策略=总是选第一个可用选项  步数 {len(rows)}  "
          f"终局 {s['node']}({end.get('ending', '?')})")
    print(f"终局规范串 {canonical(s)}")
    print(f"重跑一致(同一策略两次规范串相同){canonical(s2) == canonical(s)}")
    print("逐步骤差量(列:step choice node_from node_to delta n_delta;此处打印前 4 步与末步):")
    for st, cid, nf, nt, d in rows[:4]:
        print(f"  step {st:>2}  {cid:<12} {nf}->{nt}  {';'.join(d)}  (n={len(d)})")
    print(f"  …(共 {len(rows)} 步,完整表写入 out/ne_03_3_delta.csv)")
    st, cid, nf, nt, d = rows[-1]
    print(f"  step {st:>2}  {cid:<12} {nf}->{nt}  {';'.join(d)}  (n={len(d)})")

    rf, ri = read_keys(spec)
    wf = sorted({k for n in spec["dialogue"] for c in (n.get("choices") or [])
                 for k in ((c.get("effects") or {}).get("set") or {})})
    gi = sorted({k for n in spec["dialogue"] for c in (n.get("choices") or [])
                 for k in ((c.get("effects") or {}).get("grant") or {})})
    wrf = [k for k in wf if k not in rf]
    wri = [k for k in gi if k not in ri]
    print("写而不读(差量里恒为「只增不减的信息」):")
    print(f"  flag {len(wrf)} 个:{wrf}(被 set 却无任何条件读取)")
    print(f"  item {len(wri)} 个:{wri}(被 grant 却无任何条件读取)")
    print("  判据:声明未用=报告,不判 FAIL")

    res = sum(1 for v in s["aff"].values() if not (AFF_LO <= v <= AFF_HI))
    print(f"I1 好感度越界 {res}")
    if res or canonical(s2) != canonical(s):
        return 1

    os.makedirs(OUT, exist_ok=True)
    with open(CSV_PATH, "w", encoding="utf-8", newline="") as fh:
        w = csv.writer(fh)
        w.writerow(["step", "choice", "node_from", "node_to", "delta", "n_delta"])
        for st, cid, nf, nt, d in rows:
            w.writerow([st, cid, nf, nt, ";".join(d), len(d)])
    print(f"wrote out/ne_03_3_delta.csv rows={len(rows)}")
    print("OK ne_03_3_snapshot:快照与差量一致,重跑逐位相同")
    return 0


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

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

spec=雾港夜航  策略=总是选第一个可用选项  步数 25  终局 e_lit(灯守)
终局规范串 e_lit|hui=60,mu=65,shen=50,yan=65|cave_explored,found_yan_journal,knows_tide_rule,knows_yan_missing,met_hai,met_hui,met_mu,saw_lighthouse_light|journal,ledger_page,rope_knot,salt_letter|d_pier_1>d_pier_2>d_tavern_1>d_tavern_2>d_tavern_3>d_hub_a1>d_ch_1>d_ch_2>d_ch_3>d_hub_a2>d_lb_1>d_lb_2>d_lb_3>d_tc_1>d_tc_2>d_tc_3>d_ho_1>d_ho_2>d_ho_3>d_hub_b2>d_lt_1>d_lt_2>d_lt_3>d_dq_1>d_dq_2>e_lit
重跑一致(同一策略两次规范串相同)True
逐步骤差量(列:step choice node_from node_to delta n_delta;此处打印前 4 步与末步):
  step  1  c_pier_1a    d_pier_1->d_pier_2  node:d_pier_1->d_pier_2;flag:knows_yan_missing:0->1;flag:met_hai:0->1  (n=3)
  step  2  c_pier_2a    d_pier_2->d_tavern_1  node:d_pier_2->d_tavern_1;flag:knows_tide_rule:0->1  (n=2)
  step  3  c_tavern_1a  d_tavern_1->d_tavern_2  node:d_tavern_1->d_tavern_2;aff:hui:50->55;flag:met_hui:0->1  (n=3)
  step  4  c_tavern_2a  d_tavern_2->d_tavern_3  node:d_tavern_2->d_tavern_3;aff:hui:55->60  (n=2)
  …(共 25 步,完整表写入 out/ne_03_3_delta.csv)
  step 25  c_dq_2a      d_dq_2->e_lit  node:d_dq_2->e_lit  (n=1)
写而不读(差量里恒为「只增不减的信息」):
  flag 7 个:['has_lantern', 'hui_trusts', 'met_hai', 'met_lin', 'met_mu', 'promised_hai', 'shen_trusts'](被 set 却无任何条件读取)
  item 1 个:['fog_glass'](被 grant 却无任何条件读取)
  判据:声明未用=报告,不判 FAIL
I1 好感度越界 0
wrote out/ne_03_3_delta.csv rows=25
OK ne_03_3_snapshot:快照与差量一致,重跑逐位相同

写而不读:只增不减的信息

输出末段点出 7 个 flag 与 1 个物品被写却从未被任何条件读取:has_lantern、hui_trusts、met_hai、met_lin、met_mu、promised_hai、shen_trusts,以及物品 fog_glass。它们被 set/grant 写进状态,差量里也如实记下「0→1」,但没有任何条件会读它们——在差量里恒为「只增不减的信息」。这不是缺陷:本套判据是「声明未用=报告,不判 FAIL」,一个 flag 被写而不读,可能是给未来分支留的钩子,也可能是早期设计的残留;真正要警惕的是反过来——读了却从未被写,那才是死条件。

P 层:把快照与差量写成测试

分析逻辑在 core/src/inkwell/ne_03_3_snapshot.ts(snapshot / delta / firstAvailablePath / writtenNotRead),测试复用 engine.ts 的 canonical / applyChoice,不改动内核。

// file: core/test/ne_03_3_snapshot.test.ts
// tier: P
// verify: node --test 'core/test/ne_03_3_snapshot.test.ts'
// 3.3 状态快照与差量:快照 = canonical 串;差量 = 两快照间真正改变的字段。

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 { canonical, initState } from "../src/inkwell/engine.ts";
import type { Spec } from "../src/inkwell/engine.ts";
import { delta, firstAvailablePath, snapshot, writtenNotRead } from "../src/inkwell/ne_03_3_snapshot.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;

test("确定性路径 25 步,终局 e_lit;快照即 canonical 串", () => {
  const steps = firstAvailablePath(spec);
  assert.equal(steps.length, 25);
  assert.equal(steps[steps.length - 1].next.node, "e_lit");
  assert.equal(snapshot(steps[0].prev), canonical(initState(spec)));
});

test("每一步差量都含节点改变;差量只列真正变化的字段", () => {
  const steps = firstAvailablePath(spec);
  for (const st of steps) {
    const d = delta(st.prev, st.next);
    assert.ok(d[0].startsWith("node:"), `step ${st.choice} 的差量应以 node 开头`);
    for (const k of Object.keys(st.next.aff)) {
      const changed = st.prev.aff[k] !== st.next.aff[k];
      const listed = d.some((x) => x.startsWith(`aff:${k}:`));
      assert.equal(listed, changed, `${k} 的差量列出与变化不一致`);
    }
  }
});

test("第 1 步 c_pier_1a 的差量:置位 knows_yan_missing 与 met_hai", () => {
  const st = firstAvailablePath(spec)[0];
  assert.equal(st.choice, "c_pier_1a");
  assert.deepEqual(delta(st.prev, st.next), [
    "node:d_pier_1->d_pier_2",
    "flag:knows_yan_missing:0->1",
    "flag:met_hai:0->1",
  ]);
});

test("写而不读:7 个 flag 与 1 个 item 被写却无任何条件读取", () => {
  const r = writtenNotRead(spec);
  assert.deepEqual(r.flags, ["has_lantern", "hui_trusts", "met_hai", "met_lin", "met_mu", "promised_hai", "shen_trusts"]);
  assert.deepEqual(r.items, ["fog_glass"]);
  console.log(`写而不读:flag ${r.flags.length} 个,item ${r.items.length} 个`);
});

test("重跑逐位相同:同一策略两次的终局规范串一致", () => {
  const a = firstAvailablePath(spec).at(-1)!.next;
  const b = firstAvailablePath(spec).at(-1)!.next;
  assert.equal(canonical(a), canonical(b));
});

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

写而不读:flag 7 个,item 1 个
✔ 确定性路径 25 步,终局 e_lit;快照即 canonical 串 (1.025209ms)
✔ 每一步差量都含节点改变;差量只列真正变化的字段 (0.425083ms)
✔ 第 1 步 c_pier_1a 的差量:置位 knows_yan_missing 与 met_hai (0.502291ms)
✔ 写而不读:7 个 flag 与 1 个 item 被写却无任何条件读取 (0.649708ms)
✔ 重跑逐位相同:同一策略两次的终局规范串一致 (0.204167ms)
ℹ tests 5
ℹ pass 5
ℹ fail 0

五条全过,fail 0。第二条是差量的核心约定:对每个好感度变量,差量里是否列出它必须与它的值是否真的变了逐一对应——列出而不变、变了而不列,都是差量算错了。

自检表

检查项合格标准
快照全量快照 = 完整 canonical 串,覆盖节点 / 好感度 / flag / 物品 / 路径
差量增量只列真正改变的字段,值没变的不进差量
逐步骤可追溯每一步都能给出 node_from → node_to 与改动的字段
写而不读诚实被写却无人读的字段显式报出,不判 FAIL

快照与差量把「状态变了什么」讲清了。第 4 章把单步推进接成整条选择序列——同一序列重复回放要得到逐位相同的状态,那是确定性的完整形态。

阅读导航:上一节:3.2 确定性与幂等 · 下一节:4.1 选择序列与路径 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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