Files
gplan-2/game/core/save_manager.gd
T
2026-10-24 16:08:57 +08:00

700 lines
32 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
## 存档唯一实现: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": {}, # 冒险断点快照(空 = 无断点合法态——「继续上局」隐藏)
}
# ---- 首次冒险布尔位(关卡节点图生成 v1.1 确定性承载)----
# false = 本档尚无游玩史 → 首局走固定种子 42 + 参数档生成图(教学与存档复现的固定图由此承载,无兜底模板);
# 置位只在冒险收束挂点(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 = 高版本拒读(默认数据运行、禁写盘)。
# 损坏兜底:原文件改名 <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 原子写——写一半断电不留烂档)----
# 无激活态一律拒绝并返回 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