Files
gplan-2/game/core/save_manager.gd
T

324 lines
17 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 文档,本类照单承载。
## 存档键清单的事实源=本类序列化/读回代码,不在任何文档维护第二份键清单。
## 拆件形态(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 = 高版本拒读(默认数据运行、禁写盘)。
# 损坏兜底:原文件改名 <path>.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