diff --git a/game/core/save_manager.gd b/game/core/save_manager.gd index 8b054c6a..968b4a39 100644 --- a/game/core/save_manager.gd +++ b/game/core/save_manager.gd @@ -21,6 +21,10 @@ # tree_nodes / fork_sides / tree_struct —— 内容 owner = 天赋树.md(迭代 12 T9 建域、迭代 21 T5 # 换代承载:tree_nodes=node_id→已购等级;fork_sides=fork_group→激活态;tree_struct=树结构 # 版本标记〔缺失=换代前结构→加载时树域破坏性废弃重置——开发阶段无迁移,Q3 用户裁〕) +# forge_routes —— 内容 owner = 锻造工坊.md(迭代 23 T5:levels 六路线等级+unlocked 直购解锁清单 +# +weapon/armor 当前选中标记;**无特效域**(裁 6——满级特效本期不做);解锁态派生不落档: +# 初始持有〔表 initial_owned〕∨ 直购〔unlocked〕∨ 练满前一档〔levels[prereq]≥cap〕——派生单点 +# 在 MetaProgression.forge_route_unlocked;旧档缺键补齐=初始持有态,开发阶段禁迁移禁折算) # breakpoint.attunement_charges(主动件充能余量)——每冒险重置语义的断点瞬态附随件,内容装配归 # AdventureRunner(随 T6 接线入断点),本类照单承载;契约 2 域界注记同断点域豁免先例 # recent_summaries(冒险摘要五件事副本)—— 内容 owner = 冒险结构.md(摘要五件事口径) @@ -46,7 +50,7 @@ class_name SaveManager extends RefCounted -const SAVE_FORMAT_VERSION := 6 # registry schema_version: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 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 结构;加载检测缺失 @@ -67,6 +71,7 @@ func _init(path: String = "user://save.json") -> void: register_migration(3, _migrate_v3_v4) register_migration(4, _migrate_v4_v5) register_migration(5, _migrate_v5_v6) + register_migration(6, _migrate_v6_v7) # v1→v2:断点域出生(旧档无断点 = 合法空态——「继续上局」隐藏,非损坏非债务) @@ -114,6 +119,14 @@ func _migrate_v5_v6(data: Dictionary) -> Dictionary: 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]: @@ -136,6 +149,12 @@ func _default_data() -> Dictionary[String, Variant]: "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 卡①) } @@ -314,6 +333,65 @@ func set_fork_side(group: String, side: String) -> void: _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 = _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 = _forge_domain() + var lv: Dictionary = 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 = _forge_domain() + var un: Array = 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 = _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 = _forge_domain() + dom["armor"] = route_id + _data["meta"]["forge_routes"] = dom + + # 装配关系(同调目录 item_id 有序数组——装配序即呈现序;跨冒险持久,冒险中途不可换装〔底稿 §8.5〕)。 # 局外进度事实(元进度域同族),非契约 2 反面清单的「局内道具库存」——存档序列化.md 契约 2 随批注记。 func attunement_equipped() -> Array: diff --git a/game/smoke/smoke_all.gd b/game/smoke/smoke_all.gd index 23ff8106..fa3ee5f1 100644 --- a/game/smoke/smoke_all.gd +++ b/game/smoke/smoke_all.gd @@ -5077,7 +5077,8 @@ func _smoke_save() -> void: "user://smoke_save_domain.json", "user://smoke_save_cap.json", "user://smoke_save_integration.json", "user://smoke_save_breakpoint.json", "user://smoke_save_v2compat.json", "user://smoke_save_v5upgrade.json", - "user://smoke_save_v5legacy.json", "user://smoke_save_v16discard.json"] + "user://smoke_save_v5legacy.json", "user://smoke_save_v16discard.json", + "user://smoke_save_forge_v6legacy.json"] var stale: Array = [] for p in paths: stale.append(p) @@ -5093,6 +5094,10 @@ func _smoke_save() -> void: w.set_tree_level("sp_hp", 2) w.set_tree_level("sp_hit", 1) w.set_fork_side("fg_posture", "b") # 树域写入(属性锻造域已退役——forge_levels 域清退换代件) + w.set_forge_route_level("spear", 3) # 锻造工坊域写入(迭代 23 T5——等级+直购解锁+选中标记) + w.add_forge_unlock("spear") + w.set_forge_weapon("spear") + w.set_forge_armor("ward") 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}) @@ -5108,6 +5113,10 @@ func _smoke_save() -> void: check(r.keystone_balance() == 10, "钥石余额往返相等(10)") check(r.tree_level("sp_hp") == 2 and r.tree_level("sp_hit") == 1 and r.fork_side("fg_posture") == "b", "树域往返相等(tree_nodes/fork_sides——迭代 21 换代承载)") + check(r.forge_route_level("spear") == 3 and r.forge_route_level("sword") == 0 + and r.forge_unlocked_purchases() == ["spear"] + and r.forge_weapon() == "spear" and r.forge_armor() == "ward", + "锻造工坊域往返相等(levels/unlocked/weapon/armor——迭代 23 T5)〔锻造工坊批 V8〕") 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), @@ -5162,8 +5171,8 @@ func _smoke_save() -> void: 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)) == 6, - "迁移链 0→1(测试钩)→6(生产) 写盘 version == 6(v6=生涯终身计数域,迭代 18 卡④——v5=天赋树域迭代 12 T9)") + check(f7.write_to_disk() and int(JSON.parse_string(FileAccess.open(paths[5], FileAccess.READ).get_as_text()).get("version", -1)) == 7, + "迁移链 0→1(测试钩)→7(生产) 写盘 version == 7(v7=锻造工坊域,迭代 23 T5——v6=生涯终身计数域迭代 18 卡④)") # ⑧ 域界干净:meta 永久进度域不含局内资源键(行动点/RAP/GP/道具库存/生命账本不入档——契约 2; # 迭代 8 卡①修订:扫描收窄到 meta 域——断点域=冒险进行中瞬态快照,生命/GP 按拍板面入域豁免) @@ -5259,9 +5268,9 @@ func _smoke_save() -> void: fold.store_string("{\"version\": 1, \"meta\": {\"keystone_balance\": 4}}") fold.close() check(fo.load_from_disk() and fo.keystone_balance() == 4 and not fo.has_breakpoint(), - "v1 旧档迁移 v4:断点域补空(合法空态,无断点域不炸)") - check(fo.write_to_disk() and int(JSON.parse_string(FileAccess.open(paths[9], FileAccess.READ).get_as_text()).get("version", -1)) == 6, - "v1 旧档迁移后写盘 version == 6(v1→v6 链式——生涯终身计数域 2026-10-07)") + "v1 旧档迁移 v7:断点域补空(合法空态,无断点域不炸)") + check(fo.write_to_disk() and int(JSON.parse_string(FileAccess.open(paths[9], FileAccess.READ).get_as_text()).get("version", -1)) == 7, + "v1 旧档迁移后写盘 version == 7(v1→v7 链式——锻造工坊域 2026-10-12)") # ⑫ v2 旧档→v3 兼容(迭代 10 g 裁——semver 向后兼容:旧断点零丢失、新代码可续玩) var fv2 = sm_script.new(paths[10]) @@ -5288,11 +5297,11 @@ func _smoke_save() -> void: fv5u.close() check(f13.load_from_disk() and f13.adventure_total() == 0 and f13.adventure_cleared() == 0 and f13.keystone_balance() == 9, - "v5 旧档升 v6:计数域缺省补零且既有余额原样(迁移只增字段不改值)") + "v5 旧档升 v7:计数域缺省补零且既有余额原样(迁移只增字段不改值——v5→v6→v7 链式)") check(f13.write_to_disk() and f13.load_from_disk() - and int(JSON.parse_string(FileAccess.open(paths[11], FileAccess.READ).get_as_text()).get("version", -1)) == 6 + and int(JSON.parse_string(FileAccess.open(paths[11], FileAccess.READ).get_as_text()).get("version", -1)) == 7 and f13.adventure_total() == 0 and f13.adventure_cleared() == 0, - "升档幂等:v6 档写盘再载零漂移(重复迁移零副作用)") + "升档幂等:v7 档写盘再载零漂移(重复迁移零副作用——锻造工坊域同批补齐)") var f13b = sm_script.new(paths[12]) f13b.load_from_disk() f13b.apply_adventure_summary({"result": "completed", "cleared": true, "levels_cleared": 3, @@ -5319,6 +5328,33 @@ func _smoke_save() -> void: f15.add_keystone(50) check(mp16.tree_buy("sp_hp") and f15.tree_level("sp_hp") == 1, "废弃重置后后续购买正常受理(无残留脏节点)") + # ⑯ 锻造工坊域 forge_routes(迭代 23 T5——V8):v6 旧档缺键补齐=初始持有态+无特效域+域界干净 + var f16 = sm_script.new(paths[14]) + var fv6 := FileAccess.open(paths[14], FileAccess.WRITE) + fv6.store_string(JSON.stringify({"version": 6, "meta": {"keystone_balance": 21, + "tree_nodes": {"sp_hp": 1}, "tree_struct": 2, "adventure_total": 4, "adventure_cleared": 1}})) + fv6.close() + check(f16.load_from_disk() and f16.keystone_balance() == 21 + and f16.tree_level("sp_hp") == 1 and f16.adventure_total() == 4 and f16.adventure_cleared() == 1, + "v6 旧档升 v7:既有域逐位不动(钥石/树/生涯计数零折算——迁移只增不改)〔锻造工坊批 V8〕") + check(f16.forge_route_level("sword") == 0 and f16.forge_route_level("spear") == 0 + and f16.forge_route_level("light") == 0 and f16.forge_route_level("ward") == 0 + and f16.forge_unlocked_purchases().is_empty() + and f16.forge_weapon() == "sword" and f16.forge_armor() == "light", + "v6 旧档缺键补齐=初始持有态(六路线 0 级/直购清单空/选中=sword+light)〔锻造工坊批 V8〕") + var fr_keys: Array = (f16.forge_routes_snapshot() as Dictionary).keys() + fr_keys.sort() + check(fr_keys == ["armor", "levels", "unlocked", "weapon"], + "forge_routes 域键集恰四项(无特效域——满级特效本期不做,契约 7)〔锻造工坊批 V8/V11〕") + var lv_keys: Array = ((f16.forge_routes_snapshot() as Dictionary).get("levels", {}) as Dictionary).keys() + lv_keys.sort() + check(lv_keys == ["heavy", "light", "spear", "staff", "sword", "ward"], + "levels 六路线键集恰全(sword/spear/staff/light/heavy/ward)〔锻造工坊批 V8〕") + # 值拷贝出户(改快照不渗档——本类契约) + var snap: Dictionary = f16.forge_routes_snapshot() + (snap.get("levels", {}) as Dictionary)["sword"] = 99 + check(f16.forge_route_level("sword") == 0, "forge_routes_snapshot 值拷贝出户(改快照不渗档)〔锻造工坊批 V8〕") + # ⑭ 收束链入账(迭代 18 卡④ T2——真实收束挂点全链恰一:主流程 adventure_finished → # MetaProgression.settle_adventure → apply_adventure_summary 单口;两夹具经同链入账) var mp13 = load("res://core/meta_progression.gd").new(_db, f13b)