## VERTICAL SLICE - NOT FOR PRODUCTION ## 存档唯一实现:3 存档位 JSON 承载(激活槽写入/读回/版本迁移/损坏兜底/旧单档一次性迁移), ## Core 层零 UI 零玩法依赖(垂直切片验证:进度跨进程存活)。 ## 禁 preload/import 任何玩法脚本、禁碰 ConfigDB/RngService——须可独立实例化独立验证。 ## 新增存档键先在序列化函数登记并升版本号;键域内容口径归各 GDD 文档,本类照单承载。 ## 存档键清单的事实源=本类序列化/读回代码,不在任何文档维护第二份键清单。 ## 拆件形态(D3 存档域):槽位文件域归 SlotStore(slots)、存档内元进度数据读写归 MetaStateStore ## (meta_state)——本类留磁盘 IO+版本迁移链+断点域+门面装配,消费方经 save.slots / save.meta_state 直连。 class_name SaveManagerCore extends Node const SAVE_FORMAT_VERSION := 7 # registry schema_version:v7=锻造工坊域(meta 新域 forge_routes〔六路线等级 levels+直购解锁清单 unlocked+当前武器/甲选中标记 weapon/armor;无特效域〕,旧档缺键由 _normalize 按默认补齐=初始持有态〔sword/light 在档余四锁、选中=初始路线〕,v6→v7 空迁移=缺键补齐模式);v6=生涯终身计数域(meta 两新域 adventure_total〔冒险总局数,开局即计含放弃与失败〕/adventure_cleared〔冒险成功局数,三关全通才计 1——判定字段 summary.cleared〕,旧档缺键由 _normalize 按默认补零,收束自增唯一入账=apply_adventure_summary);v5=天赋树域(meta 三新域 tree_nodes〔node_id→已购等级〕/fork_sides〔fork_group→a|b〕/migration_note〔三体系迁移补偿注记〕);v4=同调位域(meta 三新域 boss_kill_count/attunement_equipped/attune_refresh_level——旧档缺键 = 合法空态);v3=断点附随件扩列+摘要 keystone_drops;v2=断点域出生;v1=首版;高版本拒读 owner=存档序列化 const SUMMARY_CAP := 10 # 最近冒险摘要保留条数(最新在前) const TREE_STRUCT_VERSION := 2 # 树结构版本标记(12 节点 v0.8 结构;加载检测缺失 # 或更低 = 换代前结构 → 树域破坏性废弃重置〔契约 4:禁迁移禁折算〕) const SLOT_COUNT := 3 # 存档位数写死 3(用户裁决 2——禁参数化扩位,扩位=新裁点另立) const SLOT_PATH_TEMPLATE := "user://save_slot_%d.json" # 槽文件名唯一派生模板(槽 1 禁复用旧单档名——Q1 乙前提防误读;探针面禁各自拼槽名,测试隔离走 attach_slot_template 注入口) const LEGACY_SAVE_PATH := "user://save.json" # 旧隐式单档路径(Q1 甲:首次启动一次性字节迁入槽 1,此后无任何隐式读写) const LEGACY_MIGRATED_SUFFIX := ".migrated.bak" # 迁移后旧档改名留痕后缀(字节级留盘不删——处置留痕) 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(升一版;v1→v6 生产迁移链现行——_init 自注册) var slots: SlotStore # 槽位文件域件(槽位挂载与模板/激活与建删/摘要窥视——架构 :89) var meta_state: MetaStateStore # 存档内元进度数据读写唯一层(写口原语+只读投影——架构 :90) var _migration_fail_reason := "" # 迁移循环失败原因带出(_peek_migrated 返 null 时 load 侧落 corrupt 兜底文案单点) func _init(path: String = "") -> void: _data = _default_data() slots = SlotStore.new(self, path) meta_state = MetaStateStore.new(self) # 生产迁移链自注册(v2 起——测试钩 register_migration 仍可叠挂更早版本步骤) register_migration(1, _migrate_v1_v2) register_migration(2, _migrate_v2_v3) register_migration(3, _migrate_v3_v4) register_migration(4, _migrate_v4_v5) register_migration(5, _migrate_v5_v6) register_migration(6, _migrate_v6_v7) # autoload 生命周期(SaveManager 为跨场景持有者,仿 RngService 先例: # 类名加 Core 后缀、autoload 占净名)。独立 .new() 实例(单测/工具)不入树零 _ready, # 读盘仍由调用方显式触发。 # 启动态钉死(卡① sv2):autoload 启动不读任何槽——内存=默认合法空态(_init 置好)、 # 无激活态(注入路径空,一切写盘路径拒绝并返回 false);玩家侧经选档界面显式进档/新档后才可写。 # 旧隐式单档一次性迁入槽 1(Q1 甲,sv12)在本口执行。 func _ready() -> void: slots._migrate_legacy_save(LEGACY_SAVE_PATH, slots.slot_path(1), LEGACY_SAVE_PATH + LEGACY_MIGRATED_SUFFIX) # 迁移注册口:from_version → from_version+1 的升级函数(读档时链式调用)。fn 契约: # Dictionary → Dictionary,须写回 version = from_version + 1。生产迁移链现行(v1→v6,_init 自注册), # 冒烟经本口验证链式升级。 func register_migration(from_version: int, fn: Callable) -> void: _migrations[from_version] = fn # ---- 读盘(进档/注入时调用;启动态不读——_ready 已钉死不隐式激活)---- # 返回 true = 已就绪可用(含新档/损坏兜底/迁移成功);false = 高版本拒读(默认数据运行、禁写盘)。 # 损坏兜底:原文件改名 .corrupt.bak(已存在则覆盖)+ push_warning + 默认数据继续——不静默丢档。 func load_from_disk() -> bool: _read_only = false if not FileAccess.file_exists(slots._path): _data = _default_data() # 新档合法态 return true var f := FileAccess.open(slots._path, FileAccess.READ) if f == null: return _corrupt_fallback("读打开失败(%s)" % error_string(FileAccess.get_open_error())) return _load_parsed(f) ## 读盘解析域(load_from_disk 主体段提取——解析/版本/迁移/规整;corrupt_fallback 调用点随段迁移,副作用序逐字) func _load_parsed(f: FileAccess) -> bool: 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 var fail := [] var migrated = _peek_migrated(parsed, fail) if migrated == null: return _corrupt_fallback(fail[0]) _data = _normalize(migrated) # 老档迁移(关卡节点图生成 v0.4 布尔位——缺字段 = 已有游玩史,不重放教学脚本) if not _data["meta"].has("first_adventure_done"): _data["meta"]["first_adventure_done"] = true return true # ---- 写盘(tmp + rename 原子写——写一半断电不留烂档)---- # 无激活态一律拒绝并返回 false(sv3:启动态不隐式激活,盘面零触碰); # 高版本拒读态一律拒绝(契约 4 禁写);写失败返回 false。 func write_to_disk() -> bool: if slots._path == "": push_error("[存档序列化] 写盘拒绝——无激活存档位(启动态不隐式激活;经选档进档/新档后落盘)") return false if _read_only: push_error("[存档序列化] 写盘拒绝——档案版本高于当前(拒读禁写态)") return false var tmp := slots._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, slots._path): push_error("[存档序列化] 原子改名失败(tmp 保留待查):%s → %s" % [tmp, slots._path]) return false return true # ---- 冒险断点域(停点粒度续玩快照;内容装配归 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) # ---- 私有:迁移链 / 损坏兜底 / 规整 / 文件原语 ---- # v1→v2:断点域出生(旧档无断点 = 合法空态——「继续上局」隐藏,非损坏非债务) func _migrate_v1_v2(data: Dictionary) -> Dictionary: if not data.has("breakpoint"): data["breakpoint"] = {} data["version"] = SAVE_FORMAT_VERSION return data # v2→v3:断点附随件扩列(bonus_taken/bonus_pending/bonus_result/ # keystone_drops + bonus.ap 轴)与摘要 additive 键 keystone_drops——旧档缺键 # = 合法空态(读取方 .get 兜底),无结构迁移体;版本号推进仅为 schema_version 对齐 func _migrate_v2_v3(data: Dictionary) -> Dictionary: data["version"] = SAVE_FORMAT_VERSION return data # v3→v4:同调位域(meta 三新域 boss_kill_count/attunement_equipped/ # attune_refresh_level,旧档缺键由 _normalize 按默认补齐,无结构迁移体;版本号推进仅为 # schema_version 对齐) func _migrate_v3_v4(data: Dictionary) -> Dictionary: data["version"] = SAVE_FORMAT_VERSION return data # v4→v5:天赋树域(meta 三新域 tree_nodes/fork_sides/migration_note,缺键默认 # 补齐;开发阶段破坏性废弃口径:换代前结构由 _normalize 的 tree_struct 标记检测废弃重置, # 本函数只做缺键补齐)。 func _migrate_v4_v5(data: Dictionary) -> Dictionary: var meta: Dictionary[Variant, Variant] = data.get("meta", {}) as Dictionary if not meta.has("tree_nodes"): meta["tree_nodes"] = {} if not meta.has("fork_sides"): meta["fork_sides"] = {} data["version"] = SAVE_FORMAT_VERSION return data # v5→v6:生涯终身计数域(meta 两新域 adventure_total/adventure_cleared,旧档缺键 # 由 _normalize 按默认补零,无结构迁移体;版本号推进仅为 schema_version 对齐。 # 幂等性:重复迁移只覆写 version,既有值零改动;加载后 _normalize 补齐缺省——旧档可载可玩) func _migrate_v5_v6(data: Dictionary) -> Dictionary: data["version"] = SAVE_FORMAT_VERSION return data # v6→v7:锻造工坊域(meta 新域 forge_routes,旧档缺键由 _normalize 按默认补齐= # 初始持有态〔开发阶段无迁移禁折算——契约 4〕,无结构迁移体;版本号推进仅为 schema_version 对齐 # 幂等性:重复迁移只覆写 version,既有值零改动;加载后 _normalize 补齐缺省——旧档可载可玩) func _migrate_v6_v7(data: Dictionary) -> Dictionary: data["version"] = SAVE_FORMAT_VERSION return data # 迁移循环提取(load/peek 两调用方同构共享):成功=迁移完成 Dictionary;失败=null # (原因经 fail 数组带出——load 侧落 corrupt 兜底文案、peek 侧保持「返回未填充 out」逐字) func _peek_migrated(parsed: Variant, fail: Array) -> Variant: var v := int(parsed["version"]) while v < SAVE_FORMAT_VERSION: if not _migrations.has(v): fail.append("迁移链缺步骤 %d→%d" % [v, v + 1]) return null var stepped = _migrations[v].call(parsed) if typeof(stepped) != TYPE_DICTIONARY: fail.append("迁移步骤 %d→%d 执行失败" % [v, v + 1]) return null parsed = stepped v = int(parsed.get("version", v + 1)) return parsed ## 摘要窥视内核(只读推演单点——SlotStore 两 peek 方法经槽路径派生委托至此; ## 迁移链在此只读推演——生产迁移 Callable 为纯函数,零盘面副作用) func _peek_summary_at(path: String) -> Dictionary: var out := {"readable": false, "has_file": false, "keystone": 0, "cleared": 0, "total": 0} if not FileAccess.file_exists(path): out["readable"] = true # 空槽=可读零值合法态 return out out["has_file"] = true # 主文件在盘(可读性与占档判定分离——白板档与空槽自此可区分) var f := FileAccess.open(path, FileAccess.READ) if f == null: return out var text := f.get_as_text() f.close() var parsed = JSON.parse_string(text) if typeof(parsed) != TYPE_DICTIONARY or not parsed.has("version"): return out if int(parsed["version"]) > SAVE_FORMAT_VERSION: return out var fail := [] var migrated = _peek_migrated(parsed, fail) if migrated == null: return out var norm := _normalize(migrated) out["readable"] = true out["keystone"] = int(norm["meta"].get("keystone_balance", 0)) out["cleared"] = int(norm["meta"].get("adventure_cleared", 0)) out["total"] = int(norm["meta"].get("adventure_total", 0)) return out # ---- 默认数据模型(新档合法态——全零;白板存档 = 完整可玩〔元进度 单局循环零依赖〕)---- func _default_data() -> Dictionary[String, Variant]: return { "version": SAVE_FORMAT_VERSION, "meta": { "keystone_balance": 0, "item_unlocks": {}, "level_clear_counts": {"1": 0, "2": 0, "3": 0}, "boss_first_kill": {}, "boss_kill_count": 0, "attunement_equipped": [], "attune_refresh_level": 0, "tree_nodes": {}, # 天赋树已购等级(node_id→int) "fork_sides": {}, # Fork 激活态(fork_group→"a"|"b"——一节点双态切换承载) "tree_struct": TREE_STRUCT_VERSION, # 树结构版本标记(缺失=换代前结构,加载废弃重置) "recent_summaries": [], "first_adventure_done": false, "adventure_total": 0, # 生涯终身计数·冒险总局数(开局即计含放弃与失败,收束唯一入账) "adventure_cleared": 0, # 生涯终身计数·冒险成功局数(三关全通才计 1——判定字段 summary.cleared) "vision_used": 0, # 生涯终身计数·真视类工具累计使用次数(信标网络解锁行为条件; # 受理成功唯一计数点=battle 层经冒险层聚合、收束入账;缺键补零不升版本) "forge_routes": { # 锻造工坊域(六路线等级+直购解锁清单+当前选中标记;无特效域) "levels": {"sword": 0, "spear": 0, "staff": 0, "light": 0, "heavy": 0, "ward": 0}, "unlocked": [], # 直购解锁路线 id 清单(初始持有与练满前一档=派生解锁不落档) "weapon": "sword", # 当前选中武器路线(初始持有=sword) "armor": "light", # 当前选中甲路线(初始持有=light) }, }, "breakpoint": {}, # 冒险断点快照(空 = 无断点合法态——「继续上局」隐藏) } # 原地重置默认态(SlotStore 槽位原语的协作口——激活/建删/换注回默认数据) func _reset_to_default() -> void: _data = _default_data() # 损坏兜底:坏档改名 .corrupt.bak(已存在则覆盖)→ 默认数据继续。不静默丢档(警告留痕)、不 crash。 func _corrupt_fallback(reason: String) -> bool: var bak := slots._path + ".corrupt.bak" if not _rename_overwrite(slots._path, bak): push_warning("[存档序列化] 坏档备份改名失败(原文件保留原地):%s——%s" % [bak, reason]) else: push_warning("[存档序列化] 检测到损坏档案,已备份为 %s——%s(用默认新档继续)" % [bak, reason]) _data = _default_data() return true # 迁移后/低版本档字段补齐 + 退役域清退(改版不弃档:缺新域按默认补齐,既有值保留; # 退役域按默认模型键集白名单收口——换代退役键〔forge_levels/migration_note 等树域旧件〕不流入 # 内存不回写盘,开发阶段破坏性废弃口径〔禁迁移禁折算〕)。 # 树结构检查先于缺键补齐:换代前结构(tree_struct 标记缺失或更低)→ 树域废弃重置—— # tree_nodes/fork_sides 清空、钥石余额等他域零触碰(V16 断言面)。 func _normalize(data: Dictionary) -> Dictionary[String, Variant]: var merged := _default_data() var def_meta: Dictionary[Variant, Variant] = merged["meta"] # 补齐源引用(先于 data 覆盖取出——data 自带 meta 时 merged["meta"] 被换走,此引用仍指默认值) for key in data: merged[key] = data[key] var meta: Dictionary[Variant, Variant] = merged.get("meta", {}) if int(meta.get("tree_struct", 0)) < TREE_STRUCT_VERSION: meta["tree_nodes"] = {} meta["fork_sides"] = {} var clean_meta := {} for key in def_meta: clean_meta[key] = meta.get(key, def_meta[key]) merged["meta"] = clean_meta return merged # 覆盖式改名(Windows 原生 rename 目标已存在时会失败——先删后改,显式覆盖语义;IO 原语单点—— # SlotStore 迁移流经本件调用) 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