11.1 存档的序列化

存档是状态的规范序序列化:键按字典序、值全是整数、无浮点、无时间戳、无随机。本节把一条 25 步路径的终局状态写成 795 字节的存档 JSON,验证字段与 canonical 串一一对应,并让序列化到反序列化再序列化两次字节完全相同;同时点明临时 UI 状态、派生量、绝对时间不能进存档。

11.1 存档的序列化

服务端叙事分支状态架构 处理的是网络与持久化:多客户端一致性、断线重连、数据库落盘、并发写。本节把镜头拉回单机存档与快照:一个纯函数状态的序列化与反序列化,不涉及网络、不涉及数据库、不涉及并发。3.3 已经说明快照是「某一刻的完整状态」,本节要回答的是——这份状态怎么写成一串字节,才能保证下次读回来还是它。

存档是什么:状态的规范序投影

存档不是「把内存里的对象原样倒出来」。内存里的对象带着一堆与故事无关的东西:当前高亮的选项按钮、鼠标悬停的节点、上一次渲染的时间戳。存档只保留能决定故事走向的那部分状态——节点、四个好感度、十八个 flag、九个物品、已走过的路径。这与 3.3 的快照是同一个对象,只是快照以规范串表示,存档以 JSON 表示。

存档字段与 canonical 串是一一对应的:canonical 把五段用 | 拼起来,存档把它们拆成五个键。

canonical 段存档字段内容
节点state.node当前对话节点 id
好感度state.aff4 个变量,键按字典序
flagstate.flags18 个布尔,全量(含 0 值)
物品state.items9 个持有标记,全量(含 0 值)
路径state.path从起点到当前的节点序列

这里有一个关键差别:canonical 是投影——只列置位的 flag 与持有的物品;存档是全量——把 18 个 flag、9 个物品连同 0 值一起写进去。存档要全量,是因为它要能被独立读回:只写置位的字段,读回来时就无法区分「这个 flag 是 0」与「这个 flag 根本不存在」。全量写、投影比,两者各司其职。

顶层为什么只有两个键

存档 JSON 的顶层只有 format 与 state 两个键。state 是状态本身,format 是存档格式的版本号——它记的不是故事规格的版本,而是「这串字节按第几版序列化规则写出来」。当前是 1。

把格式版本与故事规格分开,是因为两者演化的节奏不同:

版本含义变的原因
format存档序列化规则改了键名、改了编码方式(比如从 JSON 换成二进制)
规格故事内容与状态字段加了 flag、加了角色、加了节点(11.3 处理)

format 不变而规格变了,是常态——旧存档读新规格由迁移函数兜住;format 变了才需要专门写一个「旧格式转新格式」的读档器。本节把 format 钉死为 1,state 里就是五段全量状态。

一条 25 步路径的存档

下面这段是 S 层脚本 sim/ne_11_1_save_serialization.py。它用「总是选第一个可用选项」的确定性策略走一条 25 步路径(与目录篇、3.3 同一策略),把终局状态写成规范 JSON,并验证字段对应、字节稳定、无不可存字段三件事。三节共用的内核(条件求值、效果应用、规范序序列化)在 sim/ne_11_common.py,与 core/src/inkwell/engine.ts 同算法。

#!/usr/bin/env python3
# file: sim/ne_11_1_save_serialization.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ne_11_1_save_serialization.py
"""卷二 11.1 存档的序列化(S 层)。

存档 = 状态的**规范序**序列化:键按字典序、值全是整数、无浮点、无时间戳、无随机。
本脚本沿一条 25 步的确定性路径走到终局,写出规范序存档,并验证三件事:
  1) 存档字段与 canonical 串一一对应(node / aff / flags / items / path);
  2) 序列化 → 反序列化 → 再序列化,两次字节完全相同(字节稳定性);
  3) 存档里没有「不能存的东西」——临时 UI 状态、派生量、绝对时间、随机种子。
共用内核见 sim/ne_11_common.py;与 TS 侧同序列化口径,存档字节逐位相同。
"""
from __future__ import annotations

import os
import sys

from ne_11_common import (OUT, canonical, fnv1a, load_spec, load_state, save_bytes,
                          save_doc, walk_path)

# 存档**不能**出现的字段(临时 UI / 派生量 / 绝对时间 / 随机)。
FORBIDDEN_KEYS = {"ui", "screen", "cursor", "hover", "time", "ts", "timestamp",
                  "seed", "rng", "rand", "derived", "hash", "digest"}


def main() -> int:
    spec = load_spec()
    _, states = walk_path(spec)
    s = states[-1]
    end = next(n for n in spec["dialogue"] if n["id"] == s["node"])
    print(f"spec={spec['meta']['title']}  策略=总是选第一个可用选项  步数 {len(s['path']) - 1}  "
          f"终局 {s['node']}({end.get('ending', '?')})")
    print(f"终局规范串 {canonical(s)}")

    b1 = save_bytes(s)
    doc = save_doc(s)
    print(f"存档字段(顶层):{sorted(doc)}  状态字段:{sorted(doc['state'])}")
    print(f"存档 JSON {b1.decode('utf-8')}")
    print(f"存档体积 {len(b1)} bytes  FNV-1a {fnv1a(b1)}")

    b2 = save_bytes(load_state(b1))
    print(f"字节稳定性(序列化→反序列化→再序列化):相同 {b1 == b2}  "
          f"长度 {len(b1)} == {len(b2)}")
    print(f"往返后规范串不变:{canonical(load_state(b1)) == canonical(s)}")

    bad = sorted((set(doc) | set(doc["state"])) & FORBIDDEN_KEYS)
    print(f"不能存的字段(UI / 派生 / 时间 / 随机)命中 {len(bad)} 个 {bad}")
    nonint = [k for k, v in doc["state"]["aff"].items() if not isinstance(v, int)]
    print(f"存档内非整数好感度 {len(nonint)} 个(整数口径)")

    ok = (b1 == b2) and (canonical(load_state(b1)) == canonical(s)) and not bad and not nonint
    if not ok:
        print("FAIL 存档序列化不满足规范序或字节稳定性")
        return 1
    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "ne_11_1_save.json"), "wb") as fh:
        fh.write(b1)
    print(f"wrote out/ne_11_1_save.json {len(b1)} bytes")
    print("OK ne_11_1_save_serialization:存档规范序,字节稳定,无不可存字段")
    return 0


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

本机实跑 /opt/homebrew/bin/python3 sim/ne_11_1_save_serialization.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
存档字段(顶层):['format', 'state']  状态字段:['aff', 'flags', 'items', 'node', 'path']
存档 JSON {"format":1,"state":{"aff":{"hui":60,"mu":65,"shen":50,"yan":65},"flags":{"bell_rung":0,"cave_explored":1,"dawn_choice_made":0,"found_yan_journal":1,"has_lantern":0,"hui_trusts":0,"knows_bell_legend":0,"knows_tide_rule":1,"knows_yan_missing":1,"met_hai":1,"met_hui":1,"met_lin":0,"met_mu":1,"met_shen":0,"mu_trusts":0,"promised_hai":0,"saw_lighthouse_light":1,"shen_trusts":0},"items":{"bell_key":0,"fog_glass":0,"journal":1,"lantern":0,"ledger_page":1,"prayer_bead":0,"rope_knot":1,"salt_letter":1,"tide_table":0},"node":"e_lit","path":["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"]}}
存档体积 795 bytes  FNV-1a 1595253072
字节稳定性(序列化→反序列化→再序列化):相同 True  长度 795 == 795
往返后规范串不变:True
不能存的字段(UI / 派生 / 时间 / 随机)命中 0 个 []
存档内非整数好感度 0 个(整数口径)
wrote out/ne_11_1_save.json 795 bytes
OK ne_11_1_save_serialization:存档规范序,字节稳定,无不可存字段

存档体积与字节稳定性

终局存档是 795 字节,FNV-1a 摘要 1595253072。这个体积的构成是:路径 26 个节点 id 占了大头,十八个 flag 与九个物品各占一段,四个好感度与节点 id 占剩下的零头。存档体积主要被路径吃掉——这也提示一条工程取舍:路径可以只存「选过的选项序列」,读档时重放一遍即可重建。但那会引入「重放必须逐位可复现」的前提,是 4.3 已经证明的性质,本节先按全量状态存档,把「读回来就是它」讲清楚。

第二件要验证的是字节稳定性:序列化 → 反序列化 → 再序列化,两次得到的字节必须完全相同。脚本断言 795 == 795 且逐位相同。这条性质不是自动成立的——如果序列化时键的顺序依赖字典插入顺序、或者数值被写成了 60.0,两次就会不同。规范序把它钉死:键名排序 + 无空格分隔 + 非 ASCII 不转义,两侧都按这套口径写。

不能存什么

存档里不能出现四类东西。脚本用一个禁止键集合扫一遍,命中 0 个。

不能存例子为什么
临时 UI 状态高亮的选项、鼠标悬停的节点、滚动位置重开游戏时这些都要重新算,存了反而会和真实状态打架
派生量张力值、好感度档位、覆盖率派生量由状态算出;存了它,状态一变就不同步
绝对时间存档时刻、游玩时长会让「同一状态」在两次存档下字节不同,破坏字节稳定性
随机种子当前 RNG 状态本套的确定性来自选择序列,不来自种子;存种子等于把确定性寄托在别处

这四类东西的共同点是:它们都不是「能决定故事走向的状态」。判断一个字段能不能进存档,只需问一句——换一个玩家、换一台机器、换一个时刻,这个字段的值会不会变?会变的,就不该进存档。

P 层:把存档写成测试

分析逻辑在 core/src/inkwell/ne_11_1_save_serialization.ts(saveDoc / saveBytes / loadBytes / fnv1aBytes / finalState),测试复用 engine.ts 的 canonical,不改动内核。最后一个测试直接读 Python 落盘的 out/ne_11_1_save.json,与 TS 自己序列化的字节逐位比对——两侧存档字节相同。

// file: core/test/ne_11_1_save_serialization.test.ts
// tier: P
// verify: node --test 'core/test/ne_11_1_save_serialization.test.ts'
// 11.1 存档的序列化:规范序存档的字节稳定性与跨语言字节相同。

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 } from "../src/inkwell/engine.ts";
import type { Spec } from "../src/inkwell/engine.ts";
import { finalState, fnv1aBytes, loadBytes, saveBytes, saveDoc } from "../src/inkwell/ne_11_1_save_serialization.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("存档字段与 canonical 串一一对应:顶层 format+state,state 五段", () => {
  const doc = saveDoc(finalState(spec));
  assert.deepEqual(Object.keys(doc).sort(), ["format", "state"]);
  assert.deepEqual(Object.keys(doc.state).sort(), ["aff", "flags", "items", "node", "path"]);
  assert.equal(doc.format, 1);
});

test("存档是全量状态:18 个 flag 与 9 个 item 都在(含 0 值)", () => {
  const st = saveDoc(finalState(spec)).state;
  assert.equal(Object.keys(st.flags).length, 18);
  assert.equal(Object.keys(st.items).length, 9);
  assert.equal(Object.keys(st.aff).length, 4);
});

test("字节稳定性:序列化 → 反序列化 → 再序列化,两次字节完全相同", () => {
  const b1 = saveBytes(finalState(spec));
  const b2 = saveBytes(loadBytes(b1));
  assert.equal(Buffer.from(b1).toString("hex"), Buffer.from(b2).toString("hex"));
  assert.equal(b1.length, 795);
});

test("存档字节 FNV-1a 与 Python 侧逐位相同", () => {
  const b = saveBytes(finalState(spec));
  assert.equal(fnv1aBytes(b), 1595253072);
  console.log(`TS 存档字节 FNV-1a = ${fnv1aBytes(b)}  长度 ${b.length}`);
});

test("与 Python 产物 out/ne_11_1_save.json 逐字节相同", () => {
  const py = readFileSync(join(ROOT, "out/ne_11_1_save.json"));
  const ts = Buffer.from(saveBytes(finalState(spec)));
  assert.equal(py.toString("hex"), ts.toString("hex"));
  assert.equal(canonical(loadBytes(new Uint8Array(py))), canonical(finalState(spec)));
});

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

TS 存档字节 FNV-1a = 1595253072  长度 795
✔ 存档字段与 canonical 串一一对应:顶层 format+state,state 五段 (1.439291ms)
✔ 存档是全量状态:18 个 flag 与 9 个 item 都在(含 0 值) (0.16275ms)
✔ 字节稳定性:序列化 → 反序列化 → 再序列化,两次字节完全相同 (0.333041ms)
✔ 存档字节 FNV-1a 与 Python 侧逐位相同 (0.632292ms)
✔ 与 Python 产物 out/ne_11_1_save.json 逐字节相同 (29.248833ms)
ℹ tests 5
ℹ suites 0
ℹ pass 5
ℹ fail 0

五条全过,fail 0。1595253072 同时出现在 Python 与 TS 的输出里,这不是巧合:两侧约定同一套序列化口径(键名排序、无空格分隔、非 ASCII 不转义),同一份状态序列化出的字节必然逐位相同。最后一个测试把「跨语言字节相同」从口头承诺变成了断言。

自检表

检查项合格标准
规范序键按字典序、值全是整数、无浮点、无时间戳、无随机
字段对应存档五段与 canonical 串一一对应,canonical 是投影、存档是全量
字节稳定序列化 → 反序列化 → 再序列化两次字节完全相同
无不可存字段临时 UI 状态、派生量、绝对时间、随机种子命中 0 个
跨语言一致Python 与 TS 序列化出的存档字节逐位相同

存档写好了,下一个问题是把它读回来:从存档点继续推进,得到的终局必须与「从头一次走完」逐位相同。

阅读导航:上一节:10.3 多路径批跑 · 下一节:11.2 读档与状态恢复 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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