Files
gplan-2/game/core/save_manager.gd
T
DKChan 9a72394652 chore(代码卫生): 头注存量清偿批——82 文件按两类白名单改写为 ## 文档注释载体
按 code-conventions §1.4.1 第三轮定稿执行:core 22+schemas 18+presentation 根 14+views 28,全部头注 #→##、删日期/变更叙事/GDD 与架构反向链接/字段与域清单/机制描述,保留职责摘要+真实使用约束(防事故禁令/单一入口/信号契约每信号一行)。main.gd 31→5 行、save_manager 45→5 行、adventure_runner 37→5 行为最大单件。子代理分模块执行(波一 core+schemas、波二 presentation+views,并发≤2)。验收=tools/verify_comment_only_change.py 逐字节校验(82 文件头注区外逐行一致=零行为变更)+audit_file_headers 全绿;纯注释批免冒烟(先例 5843270)
2026-10-16 23:28:07 +08:00

482 lines
23 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## VERTICAL SLICE - NOT FOR PRODUCTION
## 存档唯一实现:JSON 单档写入/读回/版本迁移/损坏兜底,Core 层零 UI 零玩法依赖(垂直切片验证:进度跨进程存活)。
## 禁 preload/import 任何玩法脚本、禁碰 ConfigDB/RngService——须可独立实例化独立验证。
## 新增存档键先在序列化函数登记并升版本号;键域内容口径归各 GDD 文档,本类照单承载。
## 存档键清单的事实源=本类序列化/读回代码,不在任何文档维护第二份键清单。
class_name SaveManagerCore
extends Node
const SAVE_FORMAT_VERSION := 7 # registry schema_version:v7=锻造工坊域(迭代 23 T5——meta 新域 forge_routes〔六路线等级 levels+直购解锁清单 unlocked+当前武器/甲选中标记 weapon/armor;无特效域——裁 6 满级特效本期不做〕,旧档缺键由 _normalize 按默认补齐=初始持有态〔sword/light 在档余四锁、选中=初始路线〕,v6→v7 空迁移=缺键补齐模式——v3→v4 先例);v6=生涯终身计数域(迭代 18 卡④——meta 两新域 adventure_total〔冒险总局数,开局即计含放弃与失败〕/adventure_cleared〔通关局数,三关全通才计 1——判定字段 summary.cleared〕,旧档缺键由 _normalize 按默认补零,收束自增唯一入账=apply_adventure_summary);v5=天赋树域(迭代 12 T9——meta 三新域 tree_nodes〔node_id→已购等级〕/fork_sides〔fork_group→a|b〕/migration_note〔三体系迁移补偿注记〕——M-4 迭代 12 批次二;需求卡片「v3→v4」系同调批未落时行文,v4 已被同调批占用顺延 v5 披露);v4=同调批扩域(meta 三新域 boss_kill_count/attunement_equipped/attune_refresh_level——M-4 迭代 11 第①批 T2;断点充能余量随 T6 接线,旧档缺键 = 合法空态);v3=断点附随件扩列+摘要 keystone_drops(M-3 迭代 10 奖励重构);v2=断点域出生(M-3 迭代 8 卡①);v1=首版;高版本拒读 owner=存档序列化
const SUMMARY_CAP := 10 # 最近冒险摘要保留条数(最新在前)
const TREE_STRUCT_VERSION := 2 # 树结构版本标记(迭代 21 T5——12 节点 v0.8 结构;加载检测缺失
# 或更低 = 换代前结构 → 树域破坏性废弃重置〔契约 4:禁迁移禁折算〕)
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(升一版;v1→v6 生产迁移链现行——_init 自注册)
func _init(path: String = "user://save.json") -> void:
_path = 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 生命周期(迭代 27 T1 裁点①——SaveManager 升格跨场景持有者,RngService 先例:
# 类名加 Core 后缀、autoload 占净名;读盘挂 _ready=autoload 先于主场景就绪,时序与旧
# main._ready 读盘等价)。独立 .new() 实例(单测/工具)不入树零 _ready,读盘仍由调用方显式触发。
func _ready() -> void:
load_from_disk()
# 原地重置(迭代 27 T1——探针隔离面,沿 RngService.set_master_seed 单例原地重置先例:
# 换路径+回默认数据+重读盘;对象引用恒定,MetaProgression 持有的 save 引用零失效)
func attach_path(path: String) -> void:
_path = path
_read_only = false
_data = _default_data()
load_from_disk()
# 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:断点附随件扩列(迭代 10 奖励重构批——bonus_taken/bonus_pending/bonus_result/
# keystone_drops + bonus.ap 轴)与摘要 additive 键 keystone_drops——旧档缺键〔bonus_aux_carry 键随迭代 22 T5 替换子交互拆除退役〕
# = 合法空态(读取方 .get 兜底),无结构迁移体;版本号推进仅为 schema_version 对齐
func _migrate_v2_v3(data: Dictionary) -> Dictionary:
data["version"] = SAVE_FORMAT_VERSION
return data
# v3→v4:同调批扩域(迭代 11 第①批 T2——meta 三新域 boss_kill_count/attunement_equipped/
# attune_refresh_level,旧档缺键由 _normalize 按默认补齐,无结构迁移体;版本号推进仅为
# schema_version 对齐——v2→v3 先例)
func _migrate_v3_v4(data: Dictionary) -> Dictionary:
data["version"] = SAVE_FORMAT_VERSION
return data
# v4→v5:天赋树域(迭代 12 T9 建域——meta 三新域 tree_nodes/fork_sides/migration_note,缺键默认
# 补齐;迭代 21 T5 换代:原「三体系迁移」映射体随 migrate_tree 一并删除——开发阶段破坏性废弃
# 〔Q3 用户裁〕,换代前结构由 _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:生涯终身计数域(迭代 18 卡④——meta 两新域 adventure_total/adventure_cleared,旧档缺键
# 由 _normalize 按默认补零,无结构迁移体;版本号推进仅为 schema_version 对齐——v3→v4 先例。
# 幂等性:重复迁移只覆写 version,既有值零改动;加载后 _normalize 补齐缺省——旧档可载可玩)
func _migrate_v5_v6(data: Dictionary) -> Dictionary:
data["version"] = SAVE_FORMAT_VERSION
return data
# v6→v7:锻造工坊域(迭代 23 T5——meta 新域 forge_routes,旧档缺键由 _normalize 按默认补齐=
# 初始持有态〔开发阶段无迁移禁折算——契约 4〕,无结构迁移体;版本号推进仅为 schema_version 对齐
# ——v3→v4 先例。幂等性:重复迁移只覆写 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——迭代 12 T9 建域)
"fork_sides": {}, # Fork 激活态(fork_group→"a"|"b"——一节点双态切换承载,迭代 21 收编语义)
"tree_struct": TREE_STRUCT_VERSION, # 树结构版本标记(迭代 21 T5——缺失=换代前结构,加载废弃重置)
"recent_summaries": [],
"first_adventure_done": false,
"adventure_total": 0, # 生涯终身计数·冒险总局数(迭代 18 卡④——开局即计含放弃与失败,收束唯一入账)
"adventure_cleared": 0, # 生涯终身计数·通关局数(三关全通才计 1——判定字段 summary.cleared)
"vision_used": 0, # 生涯终身计数·真视类工具累计使用次数(迭代 22 T2——信标网络解锁行为条件;
# 受理成功唯一计数点=battle 层经冒险层聚合、收束入账;缺键补零不升版本——迭代 21 先例)
"forge_routes": { # 锻造工坊域(迭代 23 T5——六路线等级+直购解锁清单+当前选中标记;无特效域)
"levels": {"sword": 0, "spear": 0, "staff": 0, "light": 0, "heavy": 0, "ward": 0},
"unlocked": [], # 直购解锁路线 id 清单(初始持有与练满前一档=派生解锁不落档)
"weapon": "sword", # 当前选中武器路线(初始持有=sword)
"armor": "light", # 当前选中甲路线(初始持有=light)
},
},
"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 = 高版本拒读(默认数据运行、禁写盘)。
# 损坏兜底:原文件改名 <path>.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→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))
# ---- 同调位账本域(迭代 11 第①批 T2——里程碑谓词表计数源 + 装配关系 + 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))
# ---- 生涯终身计数域(迭代 18 卡④——跨局汇总页数据源;只读面经访问器,
# 写入唯一经 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))
# 真视类工具累计使用次数(迭代 22 T2——信标网络解锁行为条件;写入唯一经 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))
# ---- 天赋树域(迭代 12 T9——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(迭代 23 T5——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;等级行值归营地升级行族——T4 接线)
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——迭代 10 起逐杀掉落已在冒险内计入,boss_drops 参数降级为
# 摘要归档副本不重复入账)+ 逐通关关次 clear_count +1 与 Boss 首杀标记 + Boss 击杀总数累计
# (迭代 11 第①批 T2——同调位里程碑计数源)+ 摘要头部插入(cap 10,最新在前,元素附
# keystone_drops 副本——复盘可归因)+ 生涯终身计数自增(迭代 18 卡④——总局数开局即计:
# 三结局同经本口恰计一次;通关局数按 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 # 迭代 11 T2——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()
# ---- 冒险断点域(迭代 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
# 迁移后/低版本档字段补齐 + 退役域清退(改版不弃档:缺新域按默认补齐,既有值保留;
# 退役域按默认模型键集白名单收口——换代退役键〔forge_levels/migration_note 等树域旧件〕不流入
# 内存不回写盘,开发阶段破坏性废弃口径〔迭代 21 T5,Q3 用户裁:禁迁移禁折算〕)。
# 树结构检查先于缺键补齐:换代前结构(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