11.1 存档的序列化
服务端叙事分支状态架构 处理的是网络与持久化:多客户端一致性、断线重连、数据库落盘、并发写。本节把镜头拉回单机存档与快照:一个纯函数状态的序列化与反序列化,不涉及网络、不涉及数据库、不涉及并发。3.3 已经说明快照是「某一刻的完整状态」,本节要回答的是——这份状态怎么写成一串字节,才能保证下次读回来还是它。
存档是什么:状态的规范序投影
存档不是「把内存里的对象原样倒出来」。内存里的对象带着一堆与故事无关的东西:当前高亮的选项按钮、鼠标悬停的节点、上一次渲染的时间戳。存档只保留能决定故事走向的那部分状态——节点、四个好感度、十八个 flag、九个物品、已走过的路径。这与 3.3 的快照是同一个对象,只是快照以规范串表示,存档以 JSON 表示。
存档字段与 canonical 串是一一对应的:canonical 把五段用 | 拼起来,存档把它们拆成五个键。
| canonical 段 | 存档字段 | 内容 |
|---|---|---|
| 节点 | state.node | 当前对话节点 id |
| 好感度 | state.aff | 4 个变量,键按字典序 |
| flag | state.flags | 18 个布尔,全量(含 0 值) |
| 物品 | state.items | 9 个持有标记,全量(含 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 读档与状态恢复 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。