## VERTICAL SLICE - NOT FOR PRODUCTION ## 存档唯一实现:3 存档位 JSON 承载(激活槽写入/读回/版本迁移/损坏兜底/旧单档一次性迁移), ## Core 层零 UI 零玩法依赖(垂直切片验证:进度跨进程存活)。 ## 禁 preload/import 任何玩法脚本、禁碰 ConfigDB/RngService——须可独立实例化独立验证。 ## 新增存档键先在序列化函数登记并升版本号;键域内容口径归各 GDD 文档,本类照单承载。 ## 存档键清单的事实源=本类序列化/读回代码,不在任何文档维护第二份键清单。 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 _path: String # 当前写盘目标=激活槽文件(或 attach_path 注入路径);""=无激活态——一切写盘路径拒绝 var _active_slot := 0 # 激活槽号 1..SLOT_COUNT;0=无激活态(注入非槽派生名路径时亦 0——写盘判定以 _path 非空为准) 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 _slot_template := SLOT_PATH_TEMPLATE # 槽文件名模板(生产=常量;测试经 attach_slot_template 注入隔离命名空间,禁在呈现层/探针各自拼槽名) func _init(path: String = "") -> void: _path = path _active_slot = _slot_index_of(path) _data = _default_data() # 生产迁移链自注册(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 置好)、 # 无激活态(_path="",一切写盘路径拒绝并返回 false);玩家侧经选档界面显式进档/新档后才可写。 # 旧隐式单档一次性迁入槽 1(Q1 甲,sv12)在本口执行。 func _ready() -> void: _migrate_legacy_save(LEGACY_SAVE_PATH, slot_path(1), LEGACY_SAVE_PATH + LEGACY_MIGRATED_SUFFIX) # 原地重置(探针隔离面,仿 RngService.set_master_seed 单例原地重置: # 换路径+回默认数据+重读盘;对象引用恒定,MetaProgression 持有的 save 引用零失效)。 # 三槽化后单路径语义(契约 7)=注入路径即激活槽文件(写盘落该路径、无激活态时注入即激活); # 注入槽派生名时激活号同步记账,注入任意隔离路径时激活号 0(写盘仍合法——隔离面行为零变化)。 func attach_path(path: String) -> void: _path = path _read_only = false _active_slot = _slot_index_of(path) _data = _default_data() load_from_disk() # 测试隔离注入口(契约 7 同源——槽文件名模板整体换注,探针/单测经此隔离到自命名空间, # 禁各自拼槽文件名;换注后回无激活态,槽原语全部落注入命名空间) func attach_slot_template(template: String) -> void: _slot_template = template _path = "" _active_slot = 0 _read_only = false _data = _default_data() # ---- 三存档位承载(卡①:隐式单档 → 3 存档位;位数与文件名口径=SLOT_COUNT/SLOT_PATH_TEMPLATE)---- # 槽文件路径唯一派生口(模板单点——禁第二处拼名) func slot_path(slot: int) -> String: return _slot_template % slot # 激活槽号查询(0 = 无激活态——启动态钉死后 boot 需经选档显式进档/新档,卡③ Q1 甲) func active_slot() -> int: return _active_slot # 写盘目标在位与否(激活槽或注入路径任一;无激活态一切写盘路径拒绝——sv3 判定口) func has_write_target() -> bool: return _path != "" # 进档原语(sv5,契约 6/8):激活槽 N+读盘,复用 load_from_disk 迁移/兜底链按槽成立。 # 高版本拒读槽:激活不成立、恢复切换前三态(路径/激活号/禁写位/数据)——防误写他槽; # 损坏槽:兜底链接管(.corrupt.bak 改名+空态继续,激活成立)。 func activate_slot(slot: int) -> bool: if slot < 1 or slot > SLOT_COUNT: return false var prev_path := _path var prev_slot := _active_slot var prev_ro := _read_only var prev_data := _data _path = slot_path(slot) _active_slot = slot _read_only = false _data = _default_data() if not load_from_disk(): _path = prev_path _active_slot = prev_slot _read_only = prev_ro _data = prev_data return false return true # 新档原语(sv4):空槽创建=合法白板态(_default_data 全零可玩纪律),创建即激活并落盘。 # 生产路径只在空槽调用(有档槽开新局=先删档回空再新档,卡③ 契约 4);写盘失败回退切换前三态。 func create_slot(slot: int) -> bool: if slot < 1 or slot > SLOT_COUNT: return false var prev_path := _path var prev_slot := _active_slot var prev_ro := _read_only var prev_data := _data _path = slot_path(slot) _active_slot = slot _read_only = false _data = _default_data() if not write_to_disk(): _path = prev_path _active_slot = prev_slot _read_only = prev_ro _data = prev_data return false return true # 删档原语(sv6,契约 8):该槽文件删除+盘面即时生效(卡内工程规定,挪用现役断点覆盖流语义) # +无 .tmp 残留;删激活槽时激活指针连清除(内存回默认空态,该槽不再可写——激活态生命周期终点)。 # .corrupt.bak 损坏备份不追删(用户数据留盘可考;同槽再损坏时兜底流按槽覆盖同名备份)。 # 删不存在文件的空槽=幂等成功;越界槽号拒绝。 func delete_slot(slot: int) -> bool: if slot < 1 or slot > SLOT_COUNT: return false var p := slot_path(slot) if FileAccess.file_exists(p) and not _remove_file(p): push_error("[存档序列化] 删档失败(文件删除被拒):%s" % p) return false var tmp := p + ".tmp" if FileAccess.file_exists(tmp): _remove_file(tmp) # 残留清偿尽力而为(主文件已删即达成空槽语义) if _active_slot == slot: _path = "" _active_slot = 0 _data = _default_data() return true # 摘要三项按槽窥视读器(sv7/sv8,契约 5/9):钥石余额/通关局数/冒险总局数。 # 只读零副作用:不激活、不写盘、不触发 _corrupt_fallback 改名(.corrupt.bak 只在正式进档路径产生)。 # 返回 {"readable", "has_file", "keystone", "cleared", "total"}——readable=true=可读槽(空槽=零值合法态, # .tmp 残留按空槽——主文件不在即空);readable=false=不可读槽(损坏/高版本/缺版本字段/打开失败, # 三值无意义——卡③ Q2 裁甲:UI 按空槽呈现,进档时走正式 load 兜底/拒读链); # has_file=主文件在盘与否(空/有档操作集二分判定来处——卡③ T0 对齐结论;非摘要第四项,零 UI 语义)。 # 迁移链在此只读推演(生产迁移 Callable 为纯函数,零盘面副作用)。 func peek_slot_summary(slot: int) -> Dictionary: if slot < 1 or slot > SLOT_COUNT: return {"readable": false, "has_file": false, "keystone": 0, "cleared": 0, "total": 0} # 越界=非槽位,非空槽 return _peek_summary_at(slot_path(slot)) # 窥视注入口(契约 7 隔离面同款——测试/探针经路径注入隔离,禁各自拼槽文件名) func peek_summary_at(path: String) -> Dictionary: return _peek_summary_at(path) 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 var v := int(parsed["version"]) if v > SAVE_FORMAT_VERSION: return out while v < SAVE_FORMAT_VERSION: if not _migrations.has(v): return out var stepped = _migrations[v].call(parsed) if typeof(stepped) != TYPE_DICTIONARY: return out parsed = stepped v = int(parsed.get("version", v + 1)) var norm := _normalize(parsed) 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 # 注入路径的槽号反查(命中槽派生名返回槽号,否则 0——激活号记账用,写盘判定以 _path 非空为准) func _slot_index_of(path: String) -> int: for i in range(1, SLOT_COUNT + 1): if path == slot_path(i): return i return 0 # 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 # ---- 默认数据模型(新档合法态——全零;白板存档 = 完整可玩〔元进度 单局循环零依赖〕)---- 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": {}, # 冒险断点快照(空 = 无断点合法态——「继续上局」隐藏) } # ---- 首次冒险布尔位(关卡节点图生成 v0.4)---- # 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 # ---- 读盘(进档/注入时调用;启动态不读——_ready 已钉死不隐式激活)---- # 返回 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 原子写——写一半断电不留烂档)---- # 无激活态一律拒绝并返回 false(sv3:启动态不隐式激活,盘面零触碰); # 高版本拒读态一律拒绝(契约 4 禁写);写失败返回 false。 func write_to_disk() -> bool: if _path == "": push_error("[存档序列化] 写盘拒绝——无激活存档位(启动态不隐式激活;经选档进档/新档后落盘)") return false 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→v6,_init 自注册), # 冒烟经本口验证链式升级。 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 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)) # ---- 同调位账本域(里程碑谓词表计数源 + 装配关系 + Y 升级态)---- # Boss 击杀总数(含重复——底稿裁决 4 计数源;收束入账 = apply_adventure_summary 逐通关关次 +1, # Boss 恒唯一终点故 levels_cleared 即本次冒险 Boss 击杀数) func boss_kill_count() -> int: return int(_data["meta"].get("boss_kill_count", 0)) # ---- 生涯终身计数域(跨局汇总页数据源;只读面经访问器, # 写入唯一经 apply_adventure_summary 收束口,禁第二写入点)---- # 冒险总局数(口径=开局即计:三结局 completed/failed/abandoned 同经收束恰计一次,含放弃与失败) func adventure_total() -> int: return int(_data["meta"].get("adventure_total", 0)) # 通关局数(口径=三关全通才计 1——判定字段 summary.cleared,adventure_runner「completed」 # 唯一出口=末关通关) func adventure_cleared() -> int: return int(_data["meta"].get("adventure_cleared", 0)) # 真视类工具累计使用次数(信标网络解锁行为条件;写入唯一经 add_vision_used # 冒险收束聚合入账,受理计数点=battle 层经冒险层聚合) func vision_used() -> int: return int(_data["meta"].get("vision_used", 0)) func add_vision_used(n: int) -> void: _data["meta"]["vision_used"] = maxi(0, vision_used() + maxi(0, n)) # ---- 天赋树域(MetaProgression 树段读写面)---- func tree_nodes() -> Dictionary: return (_data["meta"].get("tree_nodes", {}) as Dictionary).duplicate() func tree_level(node_id: String) -> int: return int((_data["meta"].get("tree_nodes", {}) as Dictionary).get(node_id, 0)) func set_tree_level(node_id: String, level: int) -> void: var tn: Dictionary[Variant, Variant] = _data["meta"].get("tree_nodes", {}) as Dictionary tn[node_id] = maxi(0, level) _data["meta"]["tree_nodes"] = tn func fork_sides() -> Dictionary: return (_data["meta"].get("fork_sides", {}) as Dictionary).duplicate() func fork_side(group: String) -> String: return String((_data["meta"].get("fork_sides", {}) as Dictionary).get(group, "")) func set_fork_side(group: String, side: String) -> void: var fs: Dictionary[Variant, Variant] = _data["meta"].get("fork_sides", {}) as Dictionary fs[group] = side _data["meta"]["fork_sides"] = fs # ---- 锻造工坊域 forge_routes(MetaProgression 锻造写口读写面;无特效域)---- # 解锁态不落档(派生):初始持有=表 initial_owned、练满前一档=levels[prereq]≥cap——判定单点在 # MetaProgression.forge_route_unlocked(读表+读档);本类只承载直购清单 unlocked 与选中标记。 # 域结构:{levels:{六路线 id→等级}, unlocked:[直购路线 id], weapon:当前武器路线 id, armor:当前甲路线 id} func _forge_domain() -> Dictionary: return _data["meta"].get("forge_routes", {}) as Dictionary func forge_routes_snapshot() -> Dictionary: return _forge_domain().duplicate(true) # 值拷贝出户(本类契约「禁碰内部结构」——消费方直改不得渗入存档内存态) func forge_route_level(route_id: String) -> int: var lv: Dictionary[Variant, Variant] = _forge_domain().get("levels", {}) as Dictionary return int(lv.get(route_id, 0)) func set_forge_route_level(route_id: String, level: int) -> void: var dom: Dictionary[Variant, Variant] = _forge_domain() var lv: Dictionary[Variant, Variant] = dom.get("levels", {}) as Dictionary lv[route_id] = maxi(0, level) dom["levels"] = lv _data["meta"]["forge_routes"] = dom func forge_unlocked_purchases() -> Array: return (_forge_domain().get("unlocked", []) as Array).duplicate() func add_forge_unlock(route_id: String) -> void: var dom: Dictionary[Variant, Variant] = _forge_domain() var un: Array[Variant] = dom.get("unlocked", []) as Array if not un.has(route_id): un.append(route_id) dom["unlocked"] = un _data["meta"]["forge_routes"] = dom func forge_weapon() -> String: return String(_forge_domain().get("weapon", "sword")) func set_forge_weapon(route_id: String) -> void: var dom: Dictionary[Variant, Variant] = _forge_domain() dom["weapon"] = route_id _data["meta"]["forge_routes"] = dom func forge_armor() -> String: return String(_forge_domain().get("armor", "light")) func set_forge_armor(route_id: String) -> void: var dom: Dictionary[Variant, Variant] = _forge_domain() dom["armor"] = route_id _data["meta"]["forge_routes"] = dom # 装配关系(同调目录 item_id 有序数组——装配序即呈现序;跨冒险持久,冒险中途不可换装〔底稿 §8.5〕)。 # 局外进度事实(元进度域同族),非契约 2 反面清单的「局内道具库存」——口径见 存档序列化.md 契约 2。 func attunement_equipped() -> Array: return (_data["meta"].get("attunement_equipped", []) as Array).duplicate() func set_attunement_equipped(item_ids: Array) -> void: var out: Array[Variant] = [] for v in item_ids: out.append(String(v)) _data["meta"]["attunement_equipped"] = out # Y 刷新升级购买等级(0 = 未购 → 每冒险刷新次数 0;等级行值归营地升级行族) func attune_refresh_level() -> int: return int(_data["meta"].get("attune_refresh_level", 0)) func set_attune_refresh_level(n: int) -> void: _data["meta"]["attune_refresh_level"] = maxi(0, n) # 同调位计数源组装(Attunement 服务取数口——clear_count = 逐关通关次数合计〔含重复关,底稿 §2.1 口径〕; # clear_count(level_index) 逐关访问器零改动复用) func recent_summaries() -> Array: return _data["meta"]["recent_summaries"].duplicate(true) # 值拷贝出户(本类契约「禁碰内部结构」——消费方直改不得渗入存档内存态) # 域更新原语(正式冒险收束唯一入账口——组合根经本函数入账,禁直改内部结构): # 钥石入账(summary.keystone_total——逐杀掉落已在冒险内计入,boss_drops 参数为 # 摘要归档副本不重复入账)+ 逐通关关次 clear_count +1 与 Boss 首杀标记 + Boss 击杀总数累计 # (同调位里程碑计数源)+ 摘要头部插入(cap 10,最新在前,元素附 # keystone_drops 副本——复盘可归因)+ 生涯终身计数自增(总局数开局即计: # 三结局同经本口恰计一次;通关局数按 summary.cleared 判定三关全通才计 1。终身计数写入 # 单口=本函数,禁第二写入点)。 func apply_adventure_summary(summary: Dictionary, boss_drops: Array = []) -> void: _data["meta"]["adventure_total"] = adventure_total() + 1 if bool(summary.get("cleared", false)): _data["meta"]["adventure_cleared"] = adventure_cleared() + 1 add_keystone(int(summary.get("keystone_total", 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 _data["meta"]["boss_kill_count"] = boss_kill_count() + cleared # Boss 恒唯一终点:levels_cleared 即本次冒险 Boss 击杀数(含重复) var entry: Dictionary[Variant, Variant] = summary.duplicate(true) entry["boss_drops"] = boss_drops.duplicate(true) var arr: Array[Variant] = _data["meta"]["recent_summaries"] arr.push_front(entry) while arr.size() > SUMMARY_CAP: arr.pop_back() # ---- 冒险断点域(停点粒度续玩快照;内容装配归 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) # ---- 私有:损坏兜底与文件原语 ---- # 旧隐式单档一次性迁移(Q1 甲,sv12):src 字节级原样搬运到 dst(零解析零改造——损坏/高版本 # 旧档字节原样落槽 1,读取时由正式进档路径的兜底/拒读链接管,不静默丢数据); # dst 已存在不重迁(幂等判据——变异④锚点:去判据则重复迁移覆盖槽 1);迁移成功后 src 改名 # bak 留痕(不删——处置字节留痕,防再次隐式读写)。任一步失败:源保留原地,下次启动重试。 # tmp+rename 原子形态与 write_to_disk 同纪律。生产调用=src/dst/bak 为 旧单档/槽 1/留痕名。 func _migrate_legacy_save(src: String, dst: String, bak: String) -> void: if not FileAccess.file_exists(src): return # 执行前置:旧档不存在=无操作 if FileAccess.file_exists(dst): return # 幂等判据:槽 1 已存在不重迁 var tmp := dst + ".tmp" if not _copy_file(src, tmp): push_warning("[存档序列化] 旧单档迁移失败(源保留,下次启动重试):%s → %s" % [src, tmp]) return if not _rename_overwrite(tmp, dst): push_warning("[存档序列化] 旧单档迁移改名失败(tmp 保留待查,源保留重试):%s → %s" % [tmp, dst]) return _rename_overwrite(src, bak) push_warning("[存档序列化] 旧单档已迁入 %s,原件留痕于 %s" % [dst, bak]) # 字节级文件拷贝(迁移专用——buffer 直拷,零解析零改造) func _copy_file(from: String, to: String) -> bool: var src := FileAccess.open(from, FileAccess.READ) if src == null: return false var dst := FileAccess.open(to, FileAccess.WRITE) if dst == null: src.close() return false while not src.eof_reached(): var chunk := src.get_buffer(65536) if chunk.size() > 0: dst.store_buffer(chunk) src.close() dst.close() return true # 文件删除(删档原语专用——呈现层禁 DirAccess 直删,承载面单点) func _remove_file(p: String) -> bool: var dir := DirAccess.open(p.get_base_dir()) if dir == null: return false return dir.remove(p.get_file()) == OK # 损坏兜底:坏档改名 .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 # 迁移后/低版本档字段补齐 + 退役域清退(改版不弃档:缺新域按默认补齐,既有值保留; # 退役域按默认模型键集白名单收口——换代退役键〔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 目标已存在时会失败——先删后改,显式覆盖语义) 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