diff --git a/prototypes/dungeon-sweeper-vertical-slice/core/save_manager.gd b/prototypes/dungeon-sweeper-vertical-slice/core/save_manager.gd new file mode 100644 index 00000000..59775b4e --- /dev/null +++ b/prototypes/dungeon-sweeper-vertical-slice/core/save_manager.gd @@ -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 = 高版本拒读(默认数据运行、禁写盘)。 +# 损坏兜底:原文件改名 .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 diff --git a/prototypes/dungeon-sweeper-vertical-slice/core/save_manager.gd.uid b/prototypes/dungeon-sweeper-vertical-slice/core/save_manager.gd.uid new file mode 100644 index 00000000..13ed8b75 --- /dev/null +++ b/prototypes/dungeon-sweeper-vertical-slice/core/save_manager.gd.uid @@ -0,0 +1 @@ +uid://ctnurntr6p37h diff --git a/prototypes/dungeon-sweeper-vertical-slice/smoke/smoke_all.gd b/prototypes/dungeon-sweeper-vertical-slice/smoke/smoke_all.gd index b1162fe7..eec345eb 100644 --- a/prototypes/dungeon-sweeper-vertical-slice/smoke/smoke_all.gd +++ b/prototypes/dungeon-sweeper-vertical-slice/smoke/smoke_all.gd @@ -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)