# VERTICAL SLICE - NOT FOR PRODUCTION # Validation Question: 关掉游戏之后玩家的东西还在吗——单档 JSON 存档的写入、读回、版本迁移与损坏 # 兜底能否在零玩法依赖下独立成立,元进度四件(钥石余额/永久强化等级表/每关通关次数表/Boss 首杀标记位) # 与最近冒险摘要(cap 10)能否跨进程存活(P0) # Date: 2026-09-21(存档序列化批——设计权威 design/gdd/systems/表现与支撑/存档序列化.md) # # 存档技术契约承载方(Core 层零 UI、零玩法依赖——禁 preload/import 任何玩法脚本、禁碰 ConfigDB/ # RngService,可独立实例化独立验证〔存档序列化.md 验收标准〕)。 # # 域清单与 owner 分工(内容 owner 定口径,本类照单承载、不替任何人定义进度): # keystone_balance / forge_levels / item_unlocks / level_clear_counts / boss_first_kill # —— 内容 owner = 元进度.md(营地三柜台:钥石余额/属性锻造/道具工坊/通关次数/首杀标记) # recent_summaries(冒险摘要五件事副本)—— 内容 owner = 冒险结构.md(摘要五件事口径) # breakpoint(冒险断点快照——停点粒度续玩域)—— 内容 owner = 冒险结构.md(七字段拍板面 # +恢复必需附随件:主种子/取数计数器/钥石累计/已到节点序/道具库存);契约 2 域界反面清单 # 只约束 meta 永久进度域——断点域=冒险进行中瞬态快照,生命/GP/道具库存按拍板面入域 # (M-3 迭代 8 卡①,存档序列化.md 契约 2 随批修订) # 承载 owner = 存档序列化.md(格式/时机/迁移/损坏/域登记) # # 契约登记(存档序列化.md 核心规则——违反即返工): # 契约 1 表与档分家:存档只记进度事实,禁止把数值配方写进存档(数值改版后旧档按加载时的表解释); # 契约 2 域界干净:行动点/RAP/道具库存/生命账本/GP 不出现在存档内容里(反面清单,冒烟有断言); # 契约 3 钥石不丢:写入/读回/迁移不得破坏元进度「钥石只增不减(消费除外)」判据—— # tmp+rename 原子写(写一半断电不留烂档)+ 损坏兜底坏档备份; # 契约 4 高版本拒读:version > SAVE_FORMAT_VERSION → 不加载且禁写盘(保护未来版本的档不被覆写), # 内存以默认新档运行。 # # JSON 键纪律:字典键一律 String——JSON 键非字符串的坑,int 键(关卡序号)读写两侧都要 str() 换算, # 混用必出静默 miss。 class_name SaveManager extends RefCounted const SAVE_FORMAT_VERSION := 2 # registry schema_version:v2=断点域出生(M-3 迭代 8 卡①;v1=首版; # 高版本拒读 owner=存档序列化) const SUMMARY_CAP := 10 # 最近冒险摘要保留条数(最新在前) const FORGE_ATTRS := ["max_hp", "atk", "armor_class"] # 属性锻造三档合法键(元进度.md 属性锻造柜台) var _path: String # 存档文件路径(默认 user://save.json;冒烟注入隔离路径) var _read_only := false # 高版本拒读置位——write_to_disk 一律拒绝(契约 4 禁写) var _data: Dictionary[String, Variant] = {} # 内存数据模型(_init 即内置默认;load 后为档案内容) var _migrations: Dictionary[int, Callable] = {} # from_version → Callable(data)->data(升一版;生产链现为空——骨架先行) func _init(path: String = "user://save.json") -> void: _path = path _data = _default_data() # 生产迁移链自注册(v2 起——测试钩 register_migration 仍可叠挂更早版本步骤) register_migration(1, _migrate_v1_v2) # v1→v2:断点域出生(旧档无断点 = 合法空态——「继续上局」隐藏,非损坏非债务) func _migrate_v1_v2(data: Dictionary) -> Dictionary: if not data.has("breakpoint"): data["breakpoint"] = {} data["version"] = SAVE_FORMAT_VERSION return data # ---- 默认数据模型(新档合法态——全零;白板存档 = 完整可玩〔元进度 单局循环零依赖〕)---- func _default_data() -> Dictionary[String, Variant]: return { "version": SAVE_FORMAT_VERSION, "meta": { "keystone_balance": 0, "forge_levels": {"max_hp": 0, "atk": 0, "armor_class": 0}, "item_unlocks": {}, "level_clear_counts": {"1": 0, "2": 0, "3": 0}, "boss_first_kill": {}, "recent_summaries": [], "first_adventure_done": false, }, "breakpoint": {}, # 冒险断点快照(空 = 无断点合法态——「继续上局」隐藏;迭代 8 卡①) } # ---- 首次冒险布尔位(关卡节点图生成 v0.4——唯一新增存档字段,M-3 迭代 5 T5)---- # false = 首次脚本模式(全关零抽签取池内模板 0=现行固定图);置位只在冒险收束挂点(MetaProgression.settle_adventure)。 # 老档迁移:缺字段 = 该档已有游玩史 → true(不重放教学脚本)。 func is_first_adventure_done() -> bool: return bool(_data["meta"].get("first_adventure_done", true)) func mark_first_adventure_done() -> void: _data["meta"]["first_adventure_done"] = true # ---- 读盘(启动时调用)---- # 返回 true = 已就绪可用(含新档/损坏兜底/迁移成功);false = 高版本拒读(默认数据运行、禁写盘)。 # 损坏兜底:原文件改名 .corrupt.bak(已存在则覆盖)+ push_warning + 默认数据继续——不静默丢档。 func load_from_disk() -> bool: _read_only = false if not FileAccess.file_exists(_path): _data = _default_data() # 新档合法态 return true var f := FileAccess.open(_path, FileAccess.READ) if f == null: return _corrupt_fallback("读打开失败(%s)" % error_string(FileAccess.get_open_error())) var text := f.get_as_text() f.close() var parsed = JSON.parse_string(text) if typeof(parsed) != TYPE_DICTIONARY: return _corrupt_fallback("JSON 解析失败或顶层结构出域(非对象)") if not parsed.has("version"): return _corrupt_fallback("缺版本字段") var v := int(parsed["version"]) if v > SAVE_FORMAT_VERSION: _read_only = true _data = _default_data() push_error("[存档序列化] 档案版本 %d 高于当前 %d——拒读禁写(保护未来档不被覆写,内存默认新档运行)" % [v, SAVE_FORMAT_VERSION]) return false while v < SAVE_FORMAT_VERSION: if not _migrations.has(v): return _corrupt_fallback("迁移链缺步骤 %d→%d" % [v, v + 1]) var stepped = _migrations[v].call(parsed) if typeof(stepped) != TYPE_DICTIONARY: return _corrupt_fallback("迁移步骤 %d→%d 执行失败" % [v, v + 1]) parsed = stepped v = int(parsed.get("version", v + 1)) _data = _normalize(parsed) # 老档迁移(关卡节点图生成 v0.4 布尔位——缺字段 = 已有游玩史,不重放教学脚本) if not _data["meta"].has("first_adventure_done"): _data["meta"]["first_adventure_done"] = true return true # ---- 写盘(tmp + rename 原子写——写一半断电不留烂档)---- # 高版本拒读态一律拒绝(契约 4 禁写);写失败返回 false。 func write_to_disk() -> bool: if _read_only: push_error("[存档序列化] 写盘拒绝——档案版本高于当前(拒读禁写态)") return false var tmp := _path + ".tmp" var f := FileAccess.open(tmp, FileAccess.WRITE) if f == null: push_error("[存档序列化] 临时文件打开失败:%s(%s)" % [tmp, error_string(FileAccess.get_open_error())]) return false f.store_string(JSON.stringify(_data, "\t", true)) f.close() if not _rename_overwrite(tmp, _path): push_error("[存档序列化] 原子改名失败(tmp 保留待查):%s → %s" % [tmp, _path]) return false return true # 迁移注册口:from_version → from_version+1 的升级函数(读档时链式调用)。fn 契约: # Dictionary → Dictionary,须写回 version = from_version + 1。生产迁移当前为空(v1 首版), # 冒烟经本口验证链式升级。 func register_migration(from_version: int, fn: Callable) -> void: _migrations[from_version] = fn # ---- 类型化域访问器(消费面——只经访问器操作,禁碰内部结构)---- func keystone_balance() -> int: return int(_data["meta"]["keystone_balance"]) func add_keystone(n: int) -> void: _data["meta"]["keystone_balance"] = keystone_balance() + n # 钥石消费唯一口:余额不足拒绝且零副作用(钥石只增不减——消费除外,元进度验收判据) func spend_keystone(n: int) -> bool: if keystone_balance() < n: return false _data["meta"]["keystone_balance"] = keystone_balance() - n return true func forge_level(attr: String) -> int: return int(_data["meta"]["forge_levels"].get(attr, 0)) func set_forge_level(attr: String, n: int) -> bool: if not FORGE_ATTRS.has(attr): push_error("[存档序列化] 属性锻造键出域拒绝:%s(合法键 %s)" % [attr, str(FORGE_ATTRS)]) return false _data["meta"]["forge_levels"][attr] = n return true func item_unlocked(item_id: String) -> bool: return _data["meta"]["item_unlocks"].has(item_id) func unlock_item(item_id: String) -> void: _data["meta"]["item_unlocks"][item_id] = true func clear_count(level_index: int) -> int: return int(_data["meta"]["level_clear_counts"].get(str(level_index), 0)) func boss_first_kill_marked(level_index: int) -> bool: return _data["meta"]["boss_first_kill"].has(str(level_index)) func recent_summaries() -> Array: return _data["meta"]["recent_summaries"].duplicate(true) # 值拷贝出户(本类契约「禁碰内部结构」——消费方直改不得渗入存档内存态) # 域更新原语(正式冒险收束唯一入账口——组合根经本函数入账,禁直改内部结构): # 钥石入账(summary.keystone_total + boss_drops 逐笔 amount)+ 逐通关关次 clear_count +1 与 # Boss 首杀标记 + 摘要头部插入(cap 10,最新在前,元素附 boss_drops 副本——复盘可归因)。 # boss_drops 归元进度批消费(两档金额读 meta 表——MetaProgression.settle_adventure 组装; # 缺省 [] 零行为兼容——调用方不带两档时入账面与批前逐位一致)。 func apply_adventure_summary(summary: Dictionary, boss_drops: Array = []) -> void: add_keystone(int(summary.get("keystone_total", 0))) for drop in boss_drops: add_keystone(int(drop.get("amount", 0))) var cleared := int(summary.get("levels_cleared", 0)) for li in range(1, cleared + 1): var key := str(li) _data["meta"]["level_clear_counts"][key] = int(_data["meta"]["level_clear_counts"].get(key, 0)) + 1 _data["meta"]["boss_first_kill"][key] = true var entry: Dictionary = summary.duplicate(true) entry["boss_drops"] = boss_drops.duplicate(true) var arr: Array = _data["meta"]["recent_summaries"] arr.push_front(entry) while arr.size() > SUMMARY_CAP: arr.pop_back() # ---- 冒险断点域(迭代 8 卡①——停点粒度续玩快照;内容装配归 AdventureRunner,本类照单承载)---- # 空字典 = 无断点合法态。值拷贝进出(禁碰内部结构契约同款)——快照写入后调用方再改不渗入存档态。 func set_breakpoint(bp: Dictionary) -> void: _data["breakpoint"] = bp.duplicate(true) func clear_breakpoint() -> void: _data["breakpoint"] = {} func has_breakpoint() -> bool: return not (_data.get("breakpoint", {}) as Dictionary).is_empty() func breakpoint_snapshot() -> Dictionary: return (_data.get("breakpoint", {}) as Dictionary).duplicate(true) # ---- 私有:损坏兜底与文件原语 ---- # 损坏兜底:坏档改名 .corrupt.bak(已存在则覆盖)→ 默认数据继续。不静默丢档(警告留痕)、不 crash。 func _corrupt_fallback(reason: String) -> bool: var bak := _path + ".corrupt.bak" if not _rename_overwrite(_path, bak): push_warning("[存档序列化] 坏档备份改名失败(原文件保留原地):%s——%s" % [bak, reason]) else: push_warning("[存档序列化] 检测到损坏档案,已备份为 %s——%s(用默认新档继续)" % [bak, reason]) _data = _default_data() return true # 迁移后/低版本档字段补齐(改版不弃档:缺新域按默认补齐,既有值保留) func _normalize(data: Dictionary) -> Dictionary[String, Variant]: var merged := _default_data() for key in data: merged[key] = data[key] var meta: Dictionary = merged.get("meta", {}) var def_meta: Dictionary = _default_data()["meta"] for key in def_meta: if not meta.has(key): meta[key] = def_meta[key] var forge: Dictionary = meta.get("forge_levels", {}) for sk in def_meta["forge_levels"]: if not forge.has(sk): forge[sk] = def_meta["forge_levels"][sk] meta["forge_levels"] = forge merged["meta"] = meta return merged # 覆盖式改名(Windows 原生 rename 目标已存在时会失败——先删后改,显式覆盖语义) func _rename_overwrite(from: String, to: String) -> bool: var dir := DirAccess.open(from.get_base_dir()) if dir == null: return false if dir.file_exists(to.get_file()) and dir.remove(to.get_file()) != OK: return false return dir.rename(from.get_file(), to.get_file()) == OK