feat(save): SaveManager 存档承载落地——单档 JSON 读写/版本迁移/损坏兜底+域访问器(冒烟段①-⑧⑩ 26 项)
- core/save_manager.gd:契约 1-4(表档分家/域界干净/钥石不丢/高版本拒读禁写);tmp+rename 原子写; register_migration 迁移链骨架;类型化访问器 + apply_adventure_summary 原语(Boss 金额归元进度批) - smoke _smoke_save 段:往返/新档/拒花/出域拒/高版本拒读/损坏兜底/迁移链/域界/cap(437→463 全绿) - 摘要集成断言(run_adventure 驱动)待冒险结构批落 master 后补段
This commit is contained in:
1 parent
1d9d4c4799
commit
a7f35c970f
3 files changed
+351
No files matched your search
@@ -0,0 +1,228 @@
|
||||
# VERTICAL SLICE - NOT FOR PRODUCTION
|
||||
# Validation Question: 关掉游戏之后玩家的东西还在吗——单档 JSON 存档的写入、读回、版本迁移与损坏
|
||||
# 兜底能否在零玩法依赖下独立成立,元进度四件(钥石余额/永久强化等级表/每关通关次数表/Boss 首杀标记位)
|
||||
# 与最近冒险摘要(cap 10)能否跨进程存活(P0)
|
||||
# Date: 2026-09-21(存档序列化批——设计权威 design/gdd/systems/表现与支撑/存档序列化.md)
|
||||
#
|
||||
# 存档技术契约承载方(Core 层零 UI、零玩法依赖——禁 preload/import 任何玩法脚本、禁碰 ConfigDB/
|
||||
# RngService,可独立实例化独立验证〔存档序列化.md 验收标准〕)。
|
||||
#
|
||||
# 域清单与 owner 分工(内容 owner 定口径,本类照单承载、不替任何人定义进度):
|
||||
# keystone_balance / forge_levels / item_unlocks / level_clear_counts / boss_first_kill
|
||||
# —— 内容 owner = 元进度.md(营地三柜台:钥石余额/属性锻造/道具工坊/通关次数/首杀标记)
|
||||
# recent_summaries(冒险摘要五件事副本)—— 内容 owner = 冒险结构.md(摘要五件事口径)
|
||||
# 承载 owner = 存档序列化.md(格式/时机/迁移/损坏/域登记)
|
||||
#
|
||||
# 契约登记(存档序列化.md 核心规则——违反即返工):
|
||||
# 契约 1 表与档分家:存档只记进度事实,禁止把数值配方写进存档(数值改版后旧档按加载时的表解释);
|
||||
# 契约 2 域界干净:行动点/RAP/道具库存/生命账本/GP 不出现在存档内容里(反面清单,冒烟有断言);
|
||||
# 契约 3 钥石不丢:写入/读回/迁移不得破坏元进度「钥石只增不减(消费除外)」判据——
|
||||
# tmp+rename 原子写(写一半断电不留烂档)+ 损坏兜底坏档备份;
|
||||
# 契约 4 高版本拒读:version > SAVE_FORMAT_VERSION → 不加载且禁写盘(保护未来版本的档不被覆写),
|
||||
# 内存以默认新档运行。
|
||||
#
|
||||
# JSON 键纪律:字典键一律 String——JSON 键非字符串的坑,int 键(关卡序号)读写两侧都要 str() 换算,
|
||||
# 混用必出静默 miss。
|
||||
class_name SaveManager
|
||||
extends RefCounted
|
||||
|
||||
const SAVE_FORMAT_VERSION := 1 # registry schema_version:MVP=1(owner=数据配置;高版本拒读 owner=存档序列化)
|
||||
const SUMMARY_CAP := 10 # 最近冒险摘要保留条数(最新在前)
|
||||
|
||||
const FORGE_ATTRS := ["max_hp", "atk", "armor_class"] # 属性锻造三档合法键(元进度.md 属性锻造柜台)
|
||||
|
||||
var _path: String # 存档文件路径(默认 user://save.json;冒烟注入隔离路径)
|
||||
var _read_only := false # 高版本拒读置位——write_to_disk 一律拒绝(契约 4 禁写)
|
||||
var _data: Dictionary = {} # 内存数据模型(_init 即内置默认;load 后为档案内容)
|
||||
var _migrations: Dictionary = {} # from_version → Callable(data)->data(升一版;生产链现为空——骨架先行)
|
||||
|
||||
|
||||
func _init(path: String = "user://save.json") -> void:
|
||||
_path = path
|
||||
_data = _default_data()
|
||||
|
||||
|
||||
# ---- 默认数据模型(新档合法态——全零;白板存档 = 完整可玩〔元进度 单局循环零依赖〕)----
|
||||
|
||||
func _default_data() -> Dictionary:
|
||||
return {
|
||||
"version": SAVE_FORMAT_VERSION,
|
||||
"meta": {
|
||||
"keystone_balance": 0,
|
||||
"forge_levels": {"max_hp": 0, "atk": 0, "armor_class": 0},
|
||||
"item_unlocks": {},
|
||||
"level_clear_counts": {"1": 0, "2": 0, "3": 0},
|
||||
"boss_first_kill": {},
|
||||
"recent_summaries": [],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ---- 读盘(启动时调用)----
|
||||
# 返回 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)
|
||||
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 首版),
|
||||
# 冒烟经本口验证链式升级。
|
||||
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 forge_level(attr: String) -> int:
|
||||
return int(_data["meta"]["forge_levels"].get(attr, 0))
|
||||
|
||||
|
||||
func set_forge_level(attr: String, n: int) -> bool:
|
||||
if not FORGE_ATTRS.has(attr):
|
||||
push_error("[存档序列化] 属性锻造键出域拒绝:%s(合法键 %s)" % [attr, str(FORGE_ATTRS)])
|
||||
return false
|
||||
_data["meta"]["forge_levels"][attr] = 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))
|
||||
|
||||
|
||||
func recent_summaries() -> Array:
|
||||
return _data["meta"]["recent_summaries"]
|
||||
|
||||
|
||||
# 域更新原语(正式冒险收束唯一入账口——组合根经本函数入账,禁直改内部结构):
|
||||
# 钥石入账 + 逐通关关次 clear_count +1 与 Boss 首杀标记 + 摘要头部插入(cap 10,最新在前)。
|
||||
# 本批不含 Boss 金额(首杀全量/重复杀小额归元进度批——届时增第二参 boss_drops,默认 [] 零行为兼容)。
|
||||
func apply_adventure_summary(summary: Dictionary) -> void:
|
||||
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
|
||||
var arr: Array = _data["meta"]["recent_summaries"]
|
||||
arr.push_front(summary.duplicate(true))
|
||||
while arr.size() > SUMMARY_CAP:
|
||||
arr.pop_back()
|
||||
|
||||
|
||||
# ---- 私有:损坏兜底与文件原语 ----
|
||||
|
||||
# 损坏兜底:坏档改名 .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
|
||||
|
||||
|
||||
# 迁移后/低版本档字段补齐(改版不弃档:缺新域按默认补齐,既有值保留)
|
||||
func _normalize(data: Dictionary) -> Dictionary:
|
||||
var merged := _default_data()
|
||||
for key in data:
|
||||
merged[key] = data[key]
|
||||
var meta: Dictionary = merged.get("meta", {})
|
||||
var def_meta: Dictionary = _default_data()["meta"]
|
||||
for key in def_meta:
|
||||
if not meta.has(key):
|
||||
meta[key] = def_meta[key]
|
||||
var forge: Dictionary = meta.get("forge_levels", {})
|
||||
for sk in def_meta["forge_levels"]:
|
||||
if not forge.has(sk):
|
||||
forge[sk] = def_meta["forge_levels"][sk]
|
||||
meta["forge_levels"] = forge
|
||||
merged["meta"] = 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
|
||||
@@ -0,0 +1 @@
|
||||
uid://ctnurntr6p37h
|
||||
@@ -98,6 +98,7 @@ func _run_core_smoke(db, rng) -> void:
|
||||
_smoke_shop_room()
|
||||
_smoke_shop_graph()
|
||||
_smoke_nine_types_gp_stream()
|
||||
_smoke_save()
|
||||
|
||||
|
||||
# ---- 战斗屏棋盘布局·格边长公式回归(2026-09-19 真人试玩 bug⑥:L2 12×12 板下缘被裁约 1/6 格高——
|
||||
@@ -2860,3 +2861,124 @@ func _smoke_battle_log_view() -> void:
|
||||
var before: int = view.parsed_text().length()
|
||||
view.append_row(rest[row_type])
|
||||
check(view.parsed_text().length() > before, "行型 %s 格式化落地(解析文本增长)" % String(row_type))
|
||||
|
||||
|
||||
# ---- 存档序列化冒烟(SaveManager——读写往返/新档合法/余额拒花/锻造出域拒/高版本拒读禁写/
|
||||
# 损坏兜底/迁移链/域界干净/摘要 cap)。测试文件统一 user://smoke_save_*.json 隔离,段首清场+
|
||||
# 段尾清理——绝不碰 user://save.json(红线)。摘要集成断言(run_adventure 驱动,B1 冒险结构批产物)
|
||||
# 待其落 master 后补段。
|
||||
func _smoke_save() -> void:
|
||||
print("--- SaveManager 存档读写/迁移/兜底 ---")
|
||||
var sm_script = load("res://core/save_manager.gd")
|
||||
var paths := ["user://smoke_save_roundtrip.json", "user://smoke_save_fresh.json",
|
||||
"user://smoke_save_spend.json", "user://smoke_save_highver.json",
|
||||
"user://smoke_save_corrupt.json", "user://smoke_save_migrate.json",
|
||||
"user://smoke_save_domain.json", "user://smoke_save_cap.json"]
|
||||
var stale: Array = []
|
||||
for p in paths:
|
||||
stale.append(p)
|
||||
stale.append(p + ".tmp")
|
||||
stale.append(p + ".corrupt.bak")
|
||||
for p in stale:
|
||||
if FileAccess.file_exists(p):
|
||||
DirAccess.remove_absolute(p)
|
||||
|
||||
# ① 往返:全访问器写非默认值 → write → 新实例 load → 逐域逐值相等
|
||||
var w = sm_script.new(paths[0])
|
||||
w.add_keystone(7)
|
||||
check(w.set_forge_level("atk", 2) and w.set_forge_level("max_hp", 1) and w.set_forge_level("armor_class", 1),
|
||||
"属性锻造三档写入")
|
||||
w.unlock_item("probe_compass")
|
||||
w.apply_adventure_summary({"result": "failed", "cleared": false, "levels_cleared": 2,
|
||||
"stopped_at": 3, "keystone_total": 3, "master_seed": 99})
|
||||
check(w.keystone_balance() == 10 and w.clear_count(1) == 1 and w.clear_count(2) == 1 and w.clear_count(3) == 0,
|
||||
"apply 原语入账(钥石 7+3、通关次数 1/1/0)")
|
||||
check(w.boss_first_kill_marked(1) and w.boss_first_kill_marked(2) and not w.boss_first_kill_marked(3),
|
||||
"首杀标记随通关关卡置位(1/2 有、3 无)")
|
||||
check(w.write_to_disk(), "写盘成功")
|
||||
var r = sm_script.new(paths[0])
|
||||
check(r.load_from_disk(), "读回成功")
|
||||
check(r.keystone_balance() == 10, "钥石余额往返相等(10)")
|
||||
check(r.forge_level("atk") == 2 and r.forge_level("max_hp") == 1 and r.forge_level("armor_class") == 1,
|
||||
"锻造三档往返相等")
|
||||
check(r.item_unlocked("probe_compass") and not r.item_unlocked("rope"), "道具解锁往返相等")
|
||||
check(r.clear_count(1) == 1 and r.clear_count(2) == 1 and r.clear_count(3) == 0, "通关次数表往返相等")
|
||||
check(r.boss_first_kill_marked(1) and r.boss_first_kill_marked(2) and not r.boss_first_kill_marked(3),
|
||||
"首杀标记往返相等")
|
||||
var s0: Dictionary = r.recent_summaries()[0]
|
||||
check(r.recent_summaries().size() == 1 and int(s0.get("master_seed", -1)) == 99 and int(s0.get("keystone_total", -1)) == 3,
|
||||
"冒险摘要往返相等(1 条,seed 99)")
|
||||
|
||||
# ② 新档合法:不存在路径 load → true + 全零默认值
|
||||
var f2 = sm_script.new(paths[1])
|
||||
check(f2.load_from_disk() and f2.keystone_balance() == 0 and f2.forge_level("atk") == 0
|
||||
and not f2.item_unlocked("anything") and f2.clear_count(1) == 0 and f2.recent_summaries().is_empty(),
|
||||
"新档合法态(load true + 全零默认)")
|
||||
|
||||
# ③ spend 拒绝:余额不足 false 且零副作用(钥石只增不减——消费除外)
|
||||
var f3 = sm_script.new(paths[2])
|
||||
f3.add_keystone(5)
|
||||
check(not f3.spend_keystone(8) and f3.keystone_balance() == 5, "余额 5 花 8 拒绝且余额不动")
|
||||
check(f3.spend_keystone(3) and f3.keystone_balance() == 2, "余额内消费成功(5→2)")
|
||||
|
||||
# ④ attr 出域拒绝(push_error,值不变)
|
||||
check(not f3.set_forge_level("luck", 1) and f3.forge_level("luck") == 0 and f3.forge_level("atk") == 0,
|
||||
"锻造键出域拒绝(值不变)")
|
||||
|
||||
# ⑤ 高版本拒读:load false、原文件字节不动、后续写盘拒绝且文件仍字节不动(契约 4)
|
||||
var hv_text := "{\"version\": 99, \"meta\": {\"keystone_balance\": 1}}"
|
||||
var fhv := FileAccess.open(paths[3], FileAccess.WRITE)
|
||||
fhv.store_string(hv_text)
|
||||
fhv.close()
|
||||
var f5 = sm_script.new(paths[3])
|
||||
check(not f5.load_from_disk(), "高版本拒读(load false)")
|
||||
check(FileAccess.open(paths[3], FileAccess.READ).get_as_text() == hv_text, "拒读后原文件字节不动")
|
||||
check(not f5.write_to_disk() and FileAccess.open(paths[3], FileAccess.READ).get_as_text() == hv_text,
|
||||
"拒读态写盘拒绝且文件仍字节不动")
|
||||
|
||||
# ⑥ 损坏兜底:非 JSON 字节 → 默认数据 + .corrupt.bak(不静默丢档)
|
||||
var junk := "not json {{{"
|
||||
var fj := FileAccess.open(paths[4], FileAccess.WRITE)
|
||||
fj.store_string(junk)
|
||||
fj.close()
|
||||
var f6 = sm_script.new(paths[4])
|
||||
check(f6.load_from_disk() and f6.keystone_balance() == 0 and f6.recent_summaries().is_empty(),
|
||||
"损坏档兜底(默认数据继续)")
|
||||
check(FileAccess.file_exists(paths[4] + ".corrupt.bak"), "坏档备份 .corrupt.bak 存在")
|
||||
check(FileAccess.open(paths[4] + ".corrupt.bak", FileAccess.READ).get_as_text() == junk, "备份内容 = 原坏档字节")
|
||||
|
||||
# ⑦ 迁移链:register_migration 测试钩 → 手工 v0 旧结构档 → 链式升级 v1
|
||||
var mig := func(d: Dictionary) -> Dictionary:
|
||||
return {"version": 1, "meta": {"keystone_balance": int(d.get("keystone_old", 0))}}
|
||||
var f7 = sm_script.new(paths[5])
|
||||
f7.register_migration(0, mig)
|
||||
var fm := FileAccess.open(paths[5], FileAccess.WRITE)
|
||||
fm.store_string(JSON.stringify({"version": 0, "keystone_old": 12}))
|
||||
fm.close()
|
||||
check(f7.load_from_disk() and f7.keystone_balance() == 12, "v0 旧档经迁移链入账(keystone_old 12 → 余额 12)")
|
||||
check(f7.write_to_disk() and int(JSON.parse_string(FileAccess.open(paths[5], FileAccess.READ).get_as_text()).get("version", -1)) == 1,
|
||||
"迁移后写盘 version == 1")
|
||||
|
||||
# ⑧ 域界干净:盘上文本不含局内资源键(行动点/RAP/GP/道具库存/生命账本不入档——契约 2)
|
||||
var disk_text := FileAccess.open(paths[0], FileAccess.READ).get_as_text()
|
||||
var banned := ["\"ap\"", "\"rap\"", "\"gp\"", "\"inventory\"", "\"hero_hp\""]
|
||||
var leaked := []
|
||||
for b in banned:
|
||||
if disk_text.contains(b):
|
||||
leaked.append(b)
|
||||
check(leaked.is_empty(), "域界干净——盘上无局内资源键(发现 %s)" % str(leaked))
|
||||
|
||||
# ⑩ 摘要 cap:12 份 → 保留 10、最新在前
|
||||
var f10 = sm_script.new(paths[7])
|
||||
for i in range(12):
|
||||
f10.apply_adventure_summary({"result": "completed", "cleared": true, "levels_cleared": 0,
|
||||
"stopped_at": 0, "keystone_total": 0, "master_seed": i})
|
||||
var arr: Array = f10.recent_summaries()
|
||||
check(arr.size() == 10, "摘要 cap 10(12 份 → 10 条)")
|
||||
check(int(arr[0].get("master_seed", -1)) == 11 and int(arr[9].get("master_seed", -1)) == 2,
|
||||
"最新在前(头 seed 11 / 尾 seed 2)")
|
||||
|
||||
# 段尾清理(测试文件绝不残留)
|
||||
for p in stale:
|
||||
if FileAccess.file_exists(p):
|
||||
DirAccess.remove_absolute(p)
|
||||
Reference in new issue
Block a user