diff --git a/game/core/adventure_runner.gd b/game/core/adventure_runner.gd index 80a1afec..46bd9efe 100644 --- a/game/core/adventure_runner.gd +++ b/game/core/adventure_runner.gd @@ -369,7 +369,7 @@ func _loadout_on_level_inventory() -> void: func _boss_keystone_drop(li: int) -> void: var first := true if _save_manager != null: - first = not _save_manager.boss_first_kill_marked(li) + first = not _save_manager.meta_state.career_state().boss_first_kill.has(str(li)) var row: Dictionary[Variant, Variant] = db.meta_boss_keystone_row(li) var amount: int = int(row.get("first_kill", 0)) if first else int(row.get("repeat_kill", 0)) _journal.record_keystone_drop({"level": li, "first": first, "amount": amount}) # 逐杀明细+累计单写原语 diff --git a/game/core/attunement.gd b/game/core/attunement.gd index f426f60c..b3a66a56 100644 --- a/game/core/attunement.gd +++ b/game/core/attunement.gd @@ -12,6 +12,6 @@ static func slots(db: ConfigDb, save: Variant) -> int: return 0 var n := 0 for i in range(1, 4): - if save.tree_level("att_socket%d" % i) >= 1: + if save.meta_state.tree_state().nodes.get("att_socket%d" % i, 0) >= 1: n += 1 return n diff --git a/game/core/meta_progression.gd b/game/core/meta_progression.gd index 25749848..6e7162a7 100644 --- a/game/core/meta_progression.gd +++ b/game/core/meta_progression.gd @@ -26,7 +26,7 @@ func _ready() -> void: func keystone_balance() -> int: - return save.keystone_balance() + return save.meta_state.keystone_balance() # ---- 秘宝解锁服务(解锁混合制,契约 6)---- @@ -35,7 +35,7 @@ func keystone_balance() -> int: # 道具解锁读数访问器(组合根/呈现层解锁门控查询面——禁再穿透 meta.save 直读 save_manager) func is_item_unlocked(item_id: String) -> bool: - return save.item_unlocked(item_id) + return save.meta_state.item_unlocked(item_id) # 解锁条件计数读数单点(谓词 key → 生涯计数——封闭枚举=TreasureItemTable.UNLOCK_COUNTER_KEYS; @@ -43,11 +43,11 @@ func is_item_unlocked(item_id: String) -> bool: func _treasure_unlock_progress(key: String) -> int: match key: "adventure_cleared": - return save.adventure_cleared() + return save.meta_state.career_state().adventure_cleared "boss_kill_count": - return save.boss_kill_count() + return save.meta_state.career_state().boss_kill_count "vision_used": - return save.vision_used() + return save.meta_state.career_state().vision_used _: return 0 # 未知计数键恒 0 = 永不解锁(fail-safe 非致命——schema 词表校验兜底,不静默误解锁) @@ -59,11 +59,11 @@ func buy_treasure_unlock(item_id: String) -> bool: var price := int(arow.get("unlock_keystone_cost", 0)) if arow.is_empty() or price < 1 or (arow.get("unlock_requires", []) as Array).size() > 0: return false # 非购买件(条件件/无价行)拒绝 - if save.item_unlocked(item_id): + if save.meta_state.item_unlocked(item_id): return false # 已解锁重复购买拒绝 - if not save.spend_keystone(price): + if not save.meta_state.spend_keystone(price): return false # 余额不足(零副作用) - save.unlock_item(item_id) + save.meta_state.unlock_item(item_id) return save.write_to_disk() @@ -73,7 +73,7 @@ func refresh_treasure_unlocks() -> Array: var new_unlocks: Array[String] = [] for row in db.treasure_attunement_rows(): var iid := String(row.get("item_id", "")) - if save.item_unlocked(iid): + if save.meta_state.item_unlocked(iid): continue var ureqs: Array[Variant] = row.get("unlock_requires", []) as Array if ureqs.is_empty(): @@ -85,7 +85,7 @@ func refresh_treasure_unlocks() -> Array: met = false break if met: - save.unlock_item(iid) + save.meta_state.unlock_item(iid) new_unlocks.append(iid) if not new_unlocks.is_empty(): save.write_to_disk() @@ -98,7 +98,7 @@ func treasure_shop_status() -> Array: var out: Array[Dictionary] = [] for row in db.treasure_attunement_rows(): var iid := String(row.get("item_id", "")) - var unlocked := save.item_unlocked(iid) + var unlocked := save.meta_state.item_unlocked(iid) var ureqs: Array[Variant] = row.get("unlock_requires", []) as Array var req_view: Array[Variant] = [] for req_v in ureqs: @@ -134,12 +134,12 @@ func forge_route_unlocked(route_id: String) -> bool: return false if bool(row.get("initial_owned", false)): return true - if save.forge_unlocked_purchases().has(route_id): + if save.meta_state.forge_state().unlocked.has(route_id): return true var prereq := String(row.get("prereq_route", "")) if prereq != "": var prow: Dictionary[Variant, Variant] = db.forge_route(prereq) - if not prow.is_empty() and save.forge_route_level(prereq) >= int(prow.get("cap", 0)): + if not prow.is_empty() and save.meta_state.forge_state().levels.get(prereq, 0) >= int(prow.get("cap", 0)): return true return false @@ -150,13 +150,13 @@ func forge_buy_level(route_id: String) -> bool: var row: Dictionary[Variant, Variant] = db.forge_route(route_id) if row.is_empty() or not forge_route_unlocked(route_id): return false - var lvl := save.forge_route_level(route_id) + var lvl := save.meta_state.forge_state().levels.get(route_id, 0) if lvl >= int(row.get("cap", 0)): return false var price := ForgeTable.level_price(row, lvl) - if not save.spend_keystone(price): + if not save.meta_state.spend_keystone(price): return false - save.set_forge_route_level(route_id, lvl + 1) + save.meta_state.set_forge_route_level(route_id, lvl + 1) return save.write_to_disk() @@ -169,9 +169,9 @@ func forge_buy_unlock(route_id: String) -> bool: if forge_route_unlocked(route_id): return false var price := int(row.get("unlock_price", 0)) - if price < 1 or not save.spend_keystone(price): + if price < 1 or not save.meta_state.spend_keystone(price): return false - save.add_forge_unlock(route_id) + save.meta_state.add_forge_unlock(route_id) return save.write_to_disk() @@ -181,7 +181,7 @@ func forge_select_weapon(route_id: String) -> bool: var row: Dictionary[Variant, Variant] = db.forge_route(route_id) if row.is_empty() or String(row.get("kind", "")) != "weapon" or not forge_route_unlocked(route_id): return false - save.set_forge_weapon(route_id) + save.meta_state.set_forge_weapon(route_id) return save.write_to_disk() @@ -189,32 +189,32 @@ func forge_select_armor(route_id: String) -> bool: var row: Dictionary[Variant, Variant] = db.forge_route(route_id) if row.is_empty() or String(row.get("kind", "")) != "armor" or not forge_route_unlocked(route_id): return false - save.set_forge_armor(route_id) + save.meta_state.set_forge_armor(route_id) return save.write_to_disk() # 当前选中武器攻击加值 / 当前甲 AC 加值(路线等级×每级增量——0=裸体态/未锻造合法态)。 # 选中态异常(未解锁/表缺行)防御回退 0——注入面只认合法态,禁把脏选中带进冒险。 func forge_weapon_atk_bonus() -> int: - var rid := save.forge_weapon() + var rid := save.meta_state.forge_state().weapon var row: Dictionary[Variant, Variant] = db.forge_route(rid) if row.is_empty() or String(row.get("kind", "")) != "weapon" or not forge_route_unlocked(rid): return 0 - return save.forge_route_level(rid) * int(row.get("per_level_gain", 0)) + return save.meta_state.forge_state().levels.get(rid, 0) * int(row.get("per_level_gain", 0)) func forge_armor_ac_bonus() -> int: - var rid := save.forge_armor() + var rid := save.meta_state.forge_state().armor var row: Dictionary[Variant, Variant] = db.forge_route(rid) if row.is_empty() or String(row.get("kind", "")) != "armor" or not forge_route_unlocked(rid): return 0 - return save.forge_route_level(rid) * int(row.get("per_level_gain", 0)) + return save.meta_state.forge_state().levels.get(rid, 0) * int(row.get("per_level_gain", 0)) # 两根类型轴当前生效值(选中路线类型轴;选中态异常/未解锁=锻造配置默认值——契约 2 回退面)。 # 默认值与柜台解锁状态解耦:树钥匙未点亮时本函数照常返回默认值(配置常驻可读)。 func hero_attack_type() -> String: - var rid := save.forge_weapon() + var rid := save.meta_state.forge_state().weapon var row: Dictionary[Variant, Variant] = db.forge_route(rid) if row.is_empty() or String(row.get("kind", "")) != "weapon" or not forge_route_unlocked(rid): return db.forge_default_attack_type() @@ -222,7 +222,7 @@ func hero_attack_type() -> String: func hero_armor_type() -> String: - var rid := save.forge_armor() + var rid := save.meta_state.forge_state().armor var row: Dictionary[Variant, Variant] = db.forge_route(rid) if row.is_empty() or String(row.get("kind", "")) != "armor" or not forge_route_unlocked(rid): return db.forge_default_armor_type() @@ -237,14 +237,14 @@ func forge_shop_status() -> Array: var out: Array[Dictionary] = [] for row in db.forge_routes(): var rid := String(row.get("route_id", "")) - var lvl := save.forge_route_level(rid) + var lvl := save.meta_state.forge_state().levels.get(rid, 0) var cap_v := int(row.get("cap", 0)) var unlocked := forge_route_unlocked(rid) var prereq := String(row.get("prereq_route", "")) - var plvl := save.forge_route_level(prereq) if prereq != "" else 0 + var plvl := save.meta_state.forge_state().levels.get(prereq, 0) if prereq != "" else 0 var pcap := int(db.forge_route(prereq).get("cap", 0)) if prereq != "" else 0 - var selected := (String(row.get("kind", "")) == "weapon" and save.forge_weapon() == rid) \ - or (String(row.get("kind", "")) == "armor" and save.forge_armor() == rid) + var selected := (String(row.get("kind", "")) == "weapon" and save.meta_state.forge_state().weapon == rid) \ + or (String(row.get("kind", "")) == "armor" and save.meta_state.forge_state().armor == rid) out.append({ "route_id": rid, "display_name": String(row.get("display_name", "")), @@ -274,10 +274,10 @@ func forge_shop_status() -> Array: # apply_adventure_summary 逐关卡完成关次置位)。本函数只做标记+入账+归档。 func settle_adventure(summary: Dictionary) -> Dictionary: # 首次冒险布尔位置位(关卡节点图生成 v0.4——完成/失败/放弃三结局同经本收束挂点,写入只在收束) - save.mark_first_adventure_done() + save.meta_state.mark_first_adventure_done() var drops: Array[Variant] = (summary.get("keystone_drops", []) as Array).duplicate(true) - save.apply_adventure_summary(summary, drops) - save.add_vision_used(int(summary.get("vision_used_delta", 0))) # 真视使用计数收束聚合入档(信标网络行为条件) + save.meta_state.apply_adventure_summary(summary, drops) + save.meta_state.add_vision_used(int(summary.get("vision_used_delta", 0))) # 真视使用计数收束聚合入档(信标网络行为条件) var new_unlocks := refresh_treasure_unlocks() # 条件解锁刷新 save.write_to_disk() var total := int(summary.get("keystone_total", 0)) @@ -311,7 +311,7 @@ func hero_override() -> Dictionary: # 全树已购总等级(row-gating 输入——「投入点数累计解锁新行」) func tree_spent_total() -> int: var total := 0 - for lvl in (save.tree_nodes() as Dictionary).values(): + for lvl in (save.meta_state.tree_state().nodes as Dictionary).values(): total += int(lvl) return total @@ -324,24 +324,24 @@ func tree_can_buy(node_id: String) -> bool: # 彩蛋不经购买(觉醒唯一入口=_check_easter_egg 特化判定——V8 反例:手动购亮=红) if String(row.get("kind", "")) == "easter": return false - var lvl := save.tree_level(node_id) + var lvl := save.meta_state.tree_state().nodes.get(node_id, 0) if lvl >= int(row.get("cap", 1)): return false # 刷新升级节点:cap 主语=Y 账本(单一事实源——树上入口与装配段同 cap 同价,防双账漂移) - if node_id == "ks_refresh" and db.meta_attune_refresh_row(save.attune_refresh_level() + 1).is_empty(): + if node_id == "ks_refresh" and db.meta_attune_refresh_row(save.meta_state.attune_state().refresh_level + 1).is_empty(): return false # 行门槛(一线一节点:脊柱节点第 N 级需全树总投入 ≥4×(N−1)) if String(row.get("vein", "")) == "spine" and String(row.get("kind", "")) == "stat": if tree_spent_total() < db.meta_tree.level_gate(lvl + 1): return false for req in (row.get("requires", []) as Array): - if save.tree_level(String(req)) < 1: + if save.meta_state.tree_state().nodes.get(String(req), 0) < 1: return false # 等级阈值前置(requires_level {node_id: n}:前置节点等级 ≥ n 才可购; # 现役表零配置=空转零行为影响) var req_lv: Dictionary[Variant, Variant] = row.get("requires_level", {}) as Dictionary for req_id in req_lv.keys(): - if save.tree_level(String(req_id)) < int(req_lv[req_id]): + if save.meta_state.tree_state().nodes.get(String(req_id), 0) < int(req_lv[req_id]): return false return true @@ -357,9 +357,9 @@ func tree_buy(node_id: String) -> bool: if row.is_empty() or not tree_can_buy(node_id): return false var price := int(row.get("price", 0)) - if not save.spend_keystone(price): + if not save.meta_state.spend_keystone(price): return false - save.set_tree_level(node_id, save.tree_level(node_id) + 1) + save.meta_state.set_tree_level(node_id, save.meta_state.tree_state().nodes.get(node_id, 0) + 1) _check_easter_egg() return save.write_to_disk() @@ -369,10 +369,10 @@ func tree_buy(node_id: String) -> bool: func tree_toggle_fork(group: String, side: String) -> bool: if not db.meta_tree.FORK_GROUPS.has(group) or not (side == "a" or side == "b"): return false - var cur := save.fork_side(group) + var cur := save.meta_state.tree_state().fork_sides.get(group, "") if cur == "" or cur == side: return false # 未解锁走购买面;同态零操作 - save.set_fork_side(group, side) + save.meta_state.set_fork_side(group, side) return save.write_to_disk() @@ -384,10 +384,10 @@ func tree_buy_fork(node_id: String) -> bool: if not tree_can_buy(node_id): return false var price := int(row.get("price", 0)) - if not save.spend_keystone(price): + if not save.meta_state.spend_keystone(price): return false - save.set_tree_level(node_id, 1) - save.set_fork_side(String(row.get("fork_group", "")), "a") # 解锁默认 A 态 + save.meta_state.set_tree_level(node_id, 1) + save.meta_state.set_fork_side(String(row.get("fork_group", "")), "a") # 解锁默认 A 态 return save.write_to_disk() @@ -395,7 +395,7 @@ func tree_buy_fork(node_id: String) -> bool: func tree_stat_flat(key: String) -> int: var total := 0 for row in db.tree_rows(): - var lvl := save.tree_level(String(row.get("node_id", ""))) + var lvl := save.meta_state.tree_state().nodes.get(String(row.get("node_id", "")), 0) if lvl <= 0: continue total += int((row.get("effects", {}) as Dictionary).get(key, 0)) * lvl @@ -404,7 +404,7 @@ func tree_stat_flat(key: String) -> int: func tree_rule_keys() -> Array: var out: Array[Variant] = [] - var sides: Dictionary[Variant, Variant] = save.fork_sides() + var sides: Dictionary[Variant, Variant] = save.meta_state.tree_state().fork_sides for row in db.tree_rows(): if String(row.get("kind", "")) != "fork": continue @@ -430,7 +430,7 @@ func tree_camp_rows() -> Array: "vein": String(row.get("vein", "")), "kind": String(row.get("kind", "")), "cap": int(row.get("cap", 1)), - "level": save.attune_refresh_level() if String(row.get("node_id", "")) == "ks_refresh" else save.tree_level(String(row.get("node_id", ""))), # 刷新升级行主语=Y 账本(同账路由 V15) + "level": save.meta_state.attune_state().refresh_level if String(row.get("node_id", "")) == "ks_refresh" else save.meta_state.tree_state().nodes.get(String(row.get("node_id", "")), 0), # 刷新升级行主语=Y 账本(同账路由 V15) "price": int(row.get("price", 0)), "currency": String(row.get("currency", "keystone")), "can_buy": tree_can_buy(String(row.get("node_id", ""))), @@ -446,7 +446,7 @@ func tree_gate_text(node_id: String) -> String: var rl: Dictionary[Variant, Variant] = row.get("requires_level", {}) as Dictionary var parts: Array[String] = [] for req_id in rl.keys(): - if save.tree_level(String(req_id)) < int(rl[req_id]): + if save.meta_state.tree_state().nodes.get(String(req_id), 0) < int(rl[req_id]): var req_row: Dictionary[Variant, Variant] = db.tree_node(String(req_id)) parts.append("需 %s Lv%d" % [String(req_row.get("display_name", req_id)), int(rl[req_id])]) return "、".join(parts) @@ -454,7 +454,7 @@ func tree_gate_text(node_id: String) -> String: func tree_fork_groups() -> Array: var out: Array[Variant] = [] - var sides: Dictionary[Variant, Variant] = save.fork_sides() + var sides: Dictionary[Variant, Variant] = save.meta_state.tree_state().fork_sides for row in db.tree_rows(): if String(row.get("kind", "")) != "fork": continue @@ -464,7 +464,7 @@ func tree_fork_groups() -> Array: "node_id": String(row.get("node_id", "")), "display_name": String(row.get("display_name", "")), "active_side": String(sides.get(fg, "")), - "owned": save.tree_level(String(row.get("node_id", ""))) > 0, + "owned": save.meta_state.tree_state().nodes.get(String(row.get("node_id", "")), 0) > 0, "can_buy": tree_can_buy(String(row.get("node_id", ""))), "price": int(row.get("price", 0)), "sides": [ @@ -478,16 +478,16 @@ func tree_fork_groups() -> Array: # 彩蛋觉醒检查(特化判定——生命/命中两线拉满 且 同调位 1/2/3 全解锁 → easter_ley 免费点亮; # 不依赖 requires/requires_level 前置形态) func _check_easter_egg() -> void: - if save.tree_level("easter_ley") > 0: + if save.meta_state.tree_state().nodes.get("easter_ley", 0) > 0: return for nid in ["sp_hp", "sp_hit"]: var row: Dictionary[Variant, Variant] = db.tree_node(nid) - if row.is_empty() or save.tree_level(nid) < int(row.get("cap", 1)): + if row.is_empty() or save.meta_state.tree_state().nodes.get(nid, 0) < int(row.get("cap", 1)): return for i in range(1, 4): - if save.tree_level("att_socket%d" % i) < 1: + if save.meta_state.tree_state().nodes.get("att_socket%d" % i, 0) < 1: return - save.set_tree_level("easter_ley", 1) + save.meta_state.set_tree_level("easter_ley", 1) # ---- 同调位账本(装配关系/同调位求值/Y 刷新升级;设计权威 proposal §2)---- @@ -498,12 +498,12 @@ func _check_easter_egg() -> void: # 格 1=普通格/格 2=优秀格/格 3=稀有格递增向下兼容;槽位来源=树同调位节点点亮数,树接口接缝现役)。 # 装配关系写存档持久。 func toggle_attunement(item_id: String) -> bool: - var equipped: Array[Variant] = save.attunement_equipped() + var equipped: Array[Variant] = save.meta_state.attune_state().equipped if not equipped.has(item_id): var arow: Dictionary[Variant, Variant] = db.treasure_item_row(item_id) if arow.is_empty() or String(arow.get("form", "")) == "": return false # 非同调行(过渡/退役行)禁装配 - if not save.item_unlocked(item_id): + if not save.meta_state.item_unlocked(item_id): return false # 未解锁禁装配(解锁混合制契约 6——装配以解锁为前置) if equipped.size() >= Attunement.slots(db, save): return false # 满位禁选 @@ -513,13 +513,13 @@ func toggle_attunement(item_id: String) -> bool: equipped.append(item_id) else: equipped.erase(item_id) - save.set_attunement_equipped(equipped) + save.meta_state.set_attunement_equipped(equipped) return save.write_to_disk() # 营地装配面投影(账面归本类、拼句归 CampOverlay——_camp_data 同款分工) func attune_camp_status() -> Dictionary: - var equipped: Array[Variant] = save.attunement_equipped() + var equipped: Array[Variant] = save.meta_state.attune_state().equipped var items: Array[Dictionary] = [] for row in db.treasure_attunement_rows(): var iid := String(row.get("item_id", "")) @@ -530,9 +530,9 @@ func attune_camp_status() -> Dictionary: "tier": String(row.get("tier", "")), "form": String(row.get("form", "")), "equipped": equipped.has(iid), - "unlocked": save.item_unlocked(iid), + "unlocked": save.meta_state.item_unlocked(iid), }) - var lvl := save.attune_refresh_level() + var lvl := save.meta_state.attune_state().refresh_level var next_row: Dictionary[Variant, Variant] = db.meta_attune_refresh_row(lvl + 1) return { "slots": Attunement.slots(db, save), @@ -549,15 +549,15 @@ func attune_camp_status() -> Dictionary: # 每冒险开局刷新次数 Y(当前等级行表值;0 级 = 0 次即无刷新能力——P1 甲初始 0 同构) func attune_refresh_charges() -> int: - return int(db.meta_attune_refresh_row(save.attune_refresh_level()).get("refresh_charges", 0)) + return int(db.meta_attune_refresh_row(save.meta_state.attune_state().refresh_level).get("refresh_charges", 0)) # Y 升级购买(直购价 = 下一级行表值 keystone_cost;已满级/余额不足 false 零副作用——buy_forge 同款) func buy_attune_refresh() -> bool: - var next_row: Dictionary[Variant, Variant] = db.meta_attune_refresh_row(save.attune_refresh_level() + 1) + var next_row: Dictionary[Variant, Variant] = db.meta_attune_refresh_row(save.meta_state.attune_state().refresh_level + 1) if next_row.is_empty(): return false - if not save.spend_keystone(int(next_row.get("keystone_cost", 0))): + if not save.meta_state.spend_keystone(int(next_row.get("keystone_cost", 0))): return false - save.set_attune_refresh_level(save.attune_refresh_level() + 1) + save.meta_state.set_attune_refresh_level(save.meta_state.attune_state().refresh_level + 1) return save.write_to_disk() diff --git a/game/core/meta_state_store.gd b/game/core/meta_state_store.gd new file mode 100644 index 00000000..67eb114d --- /dev/null +++ b/game/core/meta_state_store.gd @@ -0,0 +1,176 @@ +## 存档内元进度数据件(D3 拆件——自 save_manager 收编元进度族):存档内元进度数据的读写唯一层。 +## 数据读写原语族(写口 13)+只读投影(读 6——tree/forge/attune/career 四域值拷贝出户); +## 业务规则与写口编排归 MetaProgression,本件只承载键域读写(照单承载,零业务判定)。 +## 零 _data 副本零缓存——全部读写经注入 save 的内存数据模型单点。 +class_name MetaStateStore +extends RefCounted + +var _save # SaveManagerCore 注入引用(_data 唯一取数点) + + +func _init(save_ref) -> void: + _save = save_ref + + +# ---- 写口原语族(13)---- + +# 钥石消费唯一口:余额不足拒绝且零副作用(钥石只增不减——消费除外,元进度验收判据) +func spend_keystone(n: int) -> bool: + if keystone_balance() < n: + return false + _save._data["meta"]["keystone_balance"] = keystone_balance() - n + return true + + +func unlock_item(item_id: String) -> void: + _save._data["meta"]["item_unlocks"][item_id] = true + + +func set_tree_level(node_id: String, level: int) -> void: + var tn: Dictionary[Variant, Variant] = _save._data["meta"].get("tree_nodes", {}) as Dictionary + tn[node_id] = maxi(0, level) + _save._data["meta"]["tree_nodes"] = tn + + +func set_fork_side(group: String, side: String) -> void: + var fs: Dictionary[Variant, Variant] = _save._data["meta"].get("fork_sides", {}) as Dictionary + fs[group] = side + _save._data["meta"]["fork_sides"] = fs + + +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 + _save._data["meta"]["forge_routes"] = dom + + +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 + _save._data["meta"]["forge_routes"] = dom + + +func set_forge_weapon(route_id: String) -> void: + var dom: Dictionary[Variant, Variant] = _forge_domain() + dom["weapon"] = route_id + _save._data["meta"]["forge_routes"] = dom + + +func set_forge_armor(route_id: String) -> void: + var dom: Dictionary[Variant, Variant] = _forge_domain() + dom["armor"] = route_id + _save._data["meta"]["forge_routes"] = dom + + +func set_attunement_equipped(item_ids: Array) -> void: + var out: Array[Variant] = [] + for v in item_ids: + out.append(String(v)) + _save._data["meta"]["attunement_equipped"] = out + + +func set_attune_refresh_level(n: int) -> void: + _save._data["meta"]["attune_refresh_level"] = maxi(0, n) + + +# 真视类工具累计使用入账(写入唯一经本口冒险收束聚合,受理计数点=battle 层经冒险层聚合) +func add_vision_used(n: int) -> void: + _save._data["meta"]["vision_used"] = maxi(0, career_state()["vision_used"] + maxi(0, n)) + + +# 域更新原语(正式冒险收束唯一入账口——组合根经本函数入账,禁直改内部结构): +# 钥石入账(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: + _save._data["meta"]["adventure_total"] = career_state()["adventure_total"] + 1 + if bool(summary.get("cleared", false)): + _save._data["meta"]["adventure_cleared"] = career_state()["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) + _save._data["meta"]["level_clear_counts"][key] = int(_save._data["meta"]["level_clear_counts"].get(key, 0)) + 1 + _save._data["meta"]["boss_first_kill"][key] = true + _save._data["meta"]["boss_kill_count"] = career_state()["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] = _save._data["meta"]["recent_summaries"] + arr.push_front(entry) + while arr.size() > _save.SUMMARY_CAP: + arr.pop_back() + + +func mark_first_adventure_done() -> void: + _save._data["meta"]["first_adventure_done"] = true + + +# 钥石入账原语(生产消费零——apply_adventure_summary 专用;测量面经 _ 前缀直调) +func _add_keystone(n: int) -> void: + _save._data["meta"]["keystone_balance"] = keystone_balance() + n + + +# ---- 只读投影族(6——值拷贝出户,禁碰内部结构契约同款)---- + +func keystone_balance() -> int: + return int(_save._data["meta"]["keystone_balance"]) + + +func item_unlocked(item_id: String) -> bool: + return _save._data["meta"]["item_unlocks"].has(item_id) + + +## 天赋树域只读投影:{nodes: node_id→已购等级, fork_sides: fork_group→"a"|"b"} +func tree_state() -> Dictionary: + return { + "nodes": (_save._data["meta"].get("tree_nodes", {}) as Dictionary).duplicate(), + "fork_sides": (_save._data["meta"].get("fork_sides", {}) as Dictionary).duplicate(), + } + + +## 锻造域只读投影:{levels: 六路线等级, unlocked: 直购清单, weapon/armor: 当前选中} +## (解锁态不落档——派生判定单点在 MetaProgression.forge_route_unlocked) +func forge_state() -> Dictionary: + var dom: Dictionary[Variant, Variant] = _forge_domain() + return { + "levels": (dom.get("levels", {}) as Dictionary).duplicate(), + "unlocked": (dom.get("unlocked", []) as Array).duplicate(), + "weapon": String(dom.get("weapon", "sword")), + "armor": String(dom.get("armor", "light")), + } + + +## 同调域只读投影:{equipped: 装配 item_id 有序数组(装配序即呈现序), refresh_level: Y 升级等级} +func attune_state() -> Dictionary: + return { + "equipped": (_save._data["meta"].get("attunement_equipped", []) as Array).duplicate(), + "refresh_level": int(_save._data["meta"].get("attune_refresh_level", 0)), + } + + +## 生涯域只读投影:{clear_counts, boss_first_kill, boss_kill_count, adventure_total, +## adventure_cleared, vision_used, recent_summaries, first_adventure_done} +func career_state() -> Dictionary: + return { + "clear_counts": (_save._data["meta"]["level_clear_counts"] as Dictionary).duplicate(), + "boss_first_kill": (_save._data["meta"]["boss_first_kill"] as Dictionary).duplicate(), + "boss_kill_count": int(_save._data["meta"].get("boss_kill_count", 0)), + "adventure_total": int(_save._data["meta"].get("adventure_total", 0)), + "adventure_cleared": int(_save._data["meta"].get("adventure_cleared", 0)), + "vision_used": int(_save._data["meta"].get("vision_used", 0)), + "recent_summaries": (_save._data["meta"]["recent_summaries"] as Array).duplicate(true), + "first_adventure_done": bool(_save._data["meta"].get("first_adventure_done", true)), + } + + +# 锻造域取数(本件内部单点) +func _forge_domain() -> Dictionary: + return _save._data["meta"].get("forge_routes", {}) as Dictionary diff --git a/game/core/save_manager.gd b/game/core/save_manager.gd index ed4a56fa..a4fabc6a 100644 --- a/game/core/save_manager.gd +++ b/game/core/save_manager.gd @@ -4,6 +4,8 @@ ## 禁 preload/import 任何玩法脚本、禁碰 ConfigDB/RngService——须可独立实例化独立验证。 ## 新增存档键先在序列化函数登记并升版本号;键域内容口径归各 GDD 文档,本类照单承载。 ## 存档键清单的事实源=本类序列化/读回代码,不在任何文档维护第二份键清单。 +## 拆件形态(D3 存档域):槽位文件域归 SlotStore(slots)、存档内元进度数据读写归 MetaStateStore +## (meta_state)——本类留磁盘 IO+版本迁移链+断点域+门面装配,消费方经 save.slots / save.meta_state 直连。 class_name SaveManagerCore extends Node @@ -18,18 +20,18 @@ const SLOT_PATH_TEMPLATE := "user://save_slot_%d.json" # 槽文件名唯一派 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 注入隔离命名空间,禁在呈现层/探针各自拼槽名) +var slots: SlotStore # 槽位文件域件(槽位挂载与模板/激活与建删/摘要窥视——架构 :89) +var meta_state: MetaStateStore # 存档内元进度数据读写唯一层(写口原语+只读投影——架构 :90) +var _migration_fail_reason := "" # 迁移循环失败原因带出(_peek_migrated 返 null 时 load 侧落 corrupt 兜底文案单点) func _init(path: String = "") -> void: - _path = path - _active_slot = _slot_index_of(path) _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) @@ -43,176 +45,103 @@ func _init(path: String = "") -> void: # 类名加 Core 后缀、autoload 占净名)。独立 .new() 实例(单测/工具)不入树零 _ready, # 读盘仍由调用方显式触发。 # 启动态钉死(卡① sv2):autoload 启动不读任何槽——内存=默认合法空态(_init 置好)、 -# 无激活态(_path="",一切写盘路径拒绝并返回 false);玩家侧经选档界面显式进档/新档后才可写。 +# 无激活态(注入路径空,一切写盘路径拒绝并返回 false);玩家侧经选档界面显式进档/新档后才可写。 # 旧隐式单档一次性迁入槽 1(Q1 甲,sv12)在本口执行。 func _ready() -> void: - _migrate_legacy_save(LEGACY_SAVE_PATH, slot_path(1), LEGACY_SAVE_PATH + LEGACY_MIGRATED_SUFFIX) + slots._migrate_legacy_save(LEGACY_SAVE_PATH, slots.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 +# 迁移注册口: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 = 高版本拒读(默认数据运行、禁写盘)。 +# 损坏兜底:原文件改名 .corrupt.bak(已存在则覆盖)+ push_warning + 默认数据继续——不静默丢档。 +func load_from_disk() -> bool: _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 not FileAccess.file_exists(slots._path): + _data = _default_data() # 新档合法态 + return true + var f := FileAccess.open(slots._path, FileAccess.READ) if f == null: - return out + 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 or not parsed.has("version"): - return out + 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: - 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 + _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 -# 注入路径的槽号反查(命中槽派生名返回槽号,否则 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 +# ---- 写盘(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"): @@ -266,6 +195,53 @@ func _migrate_v6_v7(data: Dictionary) -> Dictionary: 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]: @@ -299,368 +275,15 @@ func _default_data() -> Dictionary[String, Variant]: } -# ---- 首次冒险布尔位(关卡节点图生成 v1.1 确定性承载)---- -# false = 本档尚无游玩史 → 首局走固定种子 42 + 参数档生成图(教学与存档复现的固定图由此承载,无兜底模板); -# 置位只在冒险收束挂点(MetaProgression.settle_adventure)。 -# 老档迁移:缺字段 = 该档已有游玩史 → true(不重放首局固定图)。 +# 原地重置默认态(SlotStore 槽位原语的协作口——激活/建删/换注回默认数据) +func _reset_to_default() -> void: + _data = _default_data() -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 = 高版本拒读(默认数据运行、禁写盘)。 -# 损坏兜底:原文件改名 .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): + 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]) @@ -689,7 +312,8 @@ func _normalize(data: Dictionary) -> Dictionary[String, Variant]: return merged -# 覆盖式改名(Windows 原生 rename 目标已存在时会失败——先删后改,显式覆盖语义) +# 覆盖式改名(Windows 原生 rename 目标已存在时会失败——先删后改,显式覆盖语义;IO 原语单点—— +# SlotStore 迁移流经本件调用) func _rename_overwrite(from: String, to: String) -> bool: var dir := DirAccess.open(from.get_base_dir()) if dir == null: diff --git a/game/core/slot_store.gd b/game/core/slot_store.gd new file mode 100644 index 00000000..3b7a8746 --- /dev/null +++ b/game/core/slot_store.gd @@ -0,0 +1,189 @@ +## 存档槽位文件域件(D3 拆件——自 save_manager 收编槽位族):槽位挂载与模板、激活与建删、 +## 摘要窥视(peek 族)的唯一所有者。槽位态(路径/激活号/模板)随件承载;数据重置与读写盘 +## 经注入的 save 内部协作家族(_reset_to_default/_load_parsed/_rename_overwrite)完成—— +## 本件零 _data 副本零缓存(load 整体换新不陈旧)。 +class_name SlotStore +extends RefCounted + +var _save # SaveManagerCore 注入引用(IO 与数据域协作单点) +var _path: String = "" # 当前写盘目标=激活槽文件(或 attach_path 注入路径);""=无激活态——一切写盘路径拒绝 +var _active_slot := 0 # 激活槽号 1..SLOT_COUNT;0=无激活态(注入非槽派生名路径时亦 0——写盘判定以 _path 非空为准) +var _slot_template: String # 槽文件名模板(生产=常量;测试经 attach_slot_template 注入隔离命名空间,禁在呈现层/探针各自拼槽名) + + +func _init(save_ref, path: String = "") -> void: + _save = save_ref + _slot_template = _save.SLOT_PATH_TEMPLATE + _path = path + _active_slot = _slot_index_of(path) + + +## 进档注入口:换路径+回默认数据+重读盘(对象引用恒定,MetaProgression 持有的 save 引用零失效)。 +## 三槽化后单路径语义(契约 7)=注入路径即激活槽文件(写盘落该路径、无激活态时注入即激活); +## 注入槽派生名时激活号同步记账,注入任意隔离路径时激活号 0(写盘仍合法——隔离面行为零变化)。 +func attach_path(path: String) -> void: + _path = path + _save._read_only = false + _active_slot = _slot_index_of(path) + _save._reset_to_default() + _save.load_from_disk() + + +## 测试隔离注入口(契约 7 同源——槽文件名模板整体换注,探针/单测经此隔离到自命名空间, +## 禁各自拼槽文件名;换注后回无激活态,槽原语全部落注入命名空间) +func attach_slot_template(template: String) -> void: + _slot_template = template + _path = "" + _active_slot = 0 + _save._read_only = false + _save._reset_to_default() + + +# 槽文件路径唯一派生口(模板单点——禁第二处拼名) +func slot_path(slot: int) -> String: + return _slot_template % slot + + +# 激活槽号查询(0 = 无激活态——启动态钉死后 boot 需经选档显式进档/新档) +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 > _save.SLOT_COUNT: + return false + var prev_path := _path + var prev_slot := _active_slot + var prev_ro: bool = _save._read_only + var prev_data = _save._data + _path = slot_path(slot) + _active_slot = slot + _save._read_only = false + _save._reset_to_default() + if not _save.load_from_disk(): + _path = prev_path + _active_slot = prev_slot + _save._read_only = prev_ro + _save._data = prev_data + return false + return true + + +# 新档原语(sv4):空槽创建=合法白板态(_default_data 全零可玩纪律),创建即激活并落盘。 +# 生产路径只在空槽调用(有档槽开新局=先删档回空再新档);写盘失败回退切换前三态。 +func create_slot(slot: int) -> bool: + if slot < 1 or slot > _save.SLOT_COUNT: + return false + var prev_path := _path + var prev_slot := _active_slot + var prev_ro: bool = _save._read_only + var prev_data = _save._data + _path = slot_path(slot) + _active_slot = slot + _save._read_only = false + _save._reset_to_default() + if not _save.write_to_disk(): + _path = prev_path + _active_slot = prev_slot + _save._read_only = prev_ro + _save._data = prev_data + return false + return true + + +# 删档原语(sv6,契约 8):该槽文件删除+盘面即时生效+无 .tmp 残留;删激活槽时激活指针连清除 +# (内存回默认空态,该槽不再可写——激活态生命周期终点)。 +# .corrupt.bak 损坏备份不追删(用户数据留盘可考;同槽再损坏时兜底流按槽覆盖同名备份)。 +# 删不存在文件的空槽=幂等成功;越界槽号拒绝。 +func delete_slot(slot: int) -> bool: + if slot < 1 or slot > _save.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 + _save._reset_to_default() + return true + + +# 摘要三项按槽窥视读器(sv7/sv8,契约 5/9):钥石余额/冒险成功局数/冒险总局数。 +# 只读零副作用:不激活、不写盘、不触发 _corrupt_fallback 改名(.corrupt.bak 只在正式进档路径产生)。 +# 迁移链在此只读推演(内核 _peek_summary_at 归 save IO 域——本方法经槽路径派生委托)。 +func peek_slot_summary(slot: int) -> Dictionary: + if slot < 1 or slot > _save.SLOT_COUNT: + return {"readable": false, "has_file": false, "keystone": 0, "cleared": 0, "total": 0} # 越界=非槽位,非空槽 + return _save._peek_summary_at(slot_path(slot)) + + +# 窥视注入口(契约 7 隔离面同款——测试/探针经路径注入隔离,禁各自拼槽文件名) +func peek_summary_at(path: String) -> Dictionary: + return _save._peek_summary_at(path) + + +# 注入路径的槽号反查(命中槽派生名返回槽号,否则 0——激活号记账用,写盘判定以 _path 非空为准) +func _slot_index_of(path: String) -> int: + for i in range(1, _save.SLOT_COUNT + 1): + if path == slot_path(i): + return i + return 0 + + +# 旧隐式单档一次性迁移(Q1 甲,sv12):src 字节级原样搬运到 dst(零解析零改造——损坏/高版本 +# 旧档字节原样落槽 1,读取时由正式进档路径的兜底/拒读链接管,不静默丢数据); +# dst 已存在不重迁(幂等判据——判别锚④:去判据则重复迁移覆盖槽 1);迁移成功后 src 改名 +# bak 留痕(不删——处置字节留痕,防再次隐式读写)。任一步失败:源保留原地,下次启动重试。 +# tmp+rename 原子形态与 write_to_disk 同纪律(改名原语 _rename_overwrite 归 save IO 域单点)。生产调用=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 _save._rename_overwrite(tmp, dst): + push_warning("[存档序列化] 旧单档迁移改名失败(tmp 保留待查,源保留重试):%s → %s" % [tmp, dst]) + return + _save._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 diff --git a/game/presentation/hud_presenter.gd b/game/presentation/hud_presenter.gd index 408bc94f..ee43f1fc 100644 --- a/game/presentation/hud_presenter.gd +++ b/game/presentation/hud_presenter.gd @@ -260,16 +260,16 @@ static func career_data(db: ConfigDb, save_manager: SaveManagerCore, meta: MetaP for i in db.level_count(): var li: int = i + 1 clears.append({"display_name": String(db.level_row(li).get("display_name", "")), - "count": save_manager.clear_count(li)}) - if save_manager.boss_first_kill_marked(li): + "count": save_manager.meta_state.career_state().clear_counts.get(str(li), 0)}) + if save_manager.meta_state.career_state().boss_first_kill.has(str(li)): boss_first += 1 return { - "total": save_manager.adventure_total(), - "cleared": save_manager.adventure_cleared(), + "total": save_manager.meta_state.career_state().adventure_total, + "cleared": save_manager.meta_state.career_state().adventure_cleared, "clears": clears, "boss_first_kills": boss_first, "boss_first_total": db.level_count(), - "boss_kills": save_manager.boss_kill_count(), + "boss_kills": save_manager.meta_state.career_state().boss_kill_count, "keystone": meta.keystone_balance(), } @@ -280,7 +280,7 @@ static func career_data(db: ConfigDb, save_manager: SaveManagerCore, meta: MetaP static func save_select_data(save_manager: SaveManagerCore) -> Dictionary: var slots: Array[Dictionary] = [] for i in range(1, save_manager.SLOT_COUNT + 1): - var p := save_manager.peek_slot_summary(i) # 推断 Dictionary(窥视返回合同——裸注解触 R1、参数化赋值运行期严格拒) + var p := save_manager.slots.peek_slot_summary(i) # 推断 Dictionary(窥视返回合同——裸注解触 R1、参数化赋值运行期严格拒) slots.append({"slot": i, "occupied": bool(p.get("has_file", false)) and bool(p.get("readable", false)), "keystone": int(p.get("keystone", 0)), diff --git a/game/presentation/main.gd b/game/presentation/main.gd index e4adf790..f9d18da5 100644 --- a/game/presentation/main.gd +++ b/game/presentation/main.gd @@ -312,7 +312,7 @@ func _on_confirm_accepted() -> void: _close_modal(confirm_dialog) # 模态栈单口 match _confirm_consumer: "delete_slot": - save_manager.delete_slot(_confirm_consumer_slot) + save_manager.slots.delete_slot(_confirm_consumer_slot) save_select_overlay.open_save_select(HudPresenter.save_select_data(save_manager)) # 就地刷新(该槽回空态) _confirm_consumer = "" @@ -368,7 +368,7 @@ static func build_adventure_params(save_manager_ref: SaveManagerCore, meta_ref: "hero_override": meta_ref.hero_override() if meta_ref != null else {}, "attack_type": meta_ref.hero_attack_type() if meta_ref != null else "", "armor_type": meta_ref.hero_armor_type() if meta_ref != null else "", - "attunement": save_manager_ref.attunement_equipped() if save_manager_ref != null else [], + "attunement": save_manager_ref.meta_state.attune_state().equipped if save_manager_ref != null else [], "attune_refresh_y": meta_ref.attune_refresh_charges() if meta_ref != null else 0, "tree_rules": meta_ref.tree_rule_keys() if meta_ref != null else [], } @@ -906,6 +906,6 @@ func _seed_value() -> int: # 封闭流、禁 randomize()/自建 RNG——组合根建种子属引擎熵豁免面,不进玩法 RNG 消费) if _seed_edit.text.is_valid_int(): return int(_seed_edit.text) - if save_manager.is_first_adventure_done(): + if save_manager.meta_state.career_state().first_adventure_done: return randi() # rng-seed-exempt(非首局空框引擎熵——组合根建种子不进玩法 RNG 消费,static_scan S1/S2 标记豁免) return 42 diff --git a/game/presentation/main_overlay_flow.gd b/game/presentation/main_overlay_flow.gd index 3b0337d6..ff74ed03 100644 --- a/game/presentation/main_overlay_flow.gd +++ b/game/presentation/main_overlay_flow.gd @@ -68,14 +68,14 @@ static func _on_save_select_closed(root: Variant) -> void: # 空槽新档链(Q3 裁乙暂行落点=现役 CampOverlay;卡④ 重立卡后改挂场景图):卡① 创建=激活=白板落盘, # 落点开层。动线序(禁先关后开):先落点开层(开始层保持隐藏),选档层被模态栈遮挡隐藏、不关闭。 static func _on_save_select_new(root: Variant, slot: int) -> void: - root.save_manager.create_slot(slot) + root.save_manager.slots.create_slot(slot) root._camp_pres.open_camp() # 有档槽进档链(契约 8 进档即激活):卡① 激活+读盘(兜底/拒读链按槽成立)→暂行落点 CampOverlay。 static func _on_save_select_load(root: Variant, slot: int) -> void: - root.save_manager.activate_slot(slot) + root.save_manager.slots.activate_slot(slot) root._camp_pres.open_camp() diff --git a/game/probe/probe_ui_util.gd b/game/probe/probe_ui_util.gd index f7ccfb68..1ea2f8db 100644 --- a/game/probe/probe_ui_util.gd +++ b/game/probe/probe_ui_util.gd @@ -104,13 +104,13 @@ static func isolate_save(main, tag: String, mark_first_done: bool = false, with_ var iso = SaveManagerScript.new(save_path) iso.load_from_disk() if mark_first_done: - iso.mark_first_adventure_done() + iso.meta_state.mark_first_adventure_done() # --script 模式无裸 autoload 标识符(run_all.gd 头注)——经 main 节点路径解析 var sm = main.get_node("/root/SaveManager") if swap_save: - sm.attach_path(save_path) + sm.slots.attach_path(save_path) if mark_first_done: - sm.mark_first_adventure_done() + sm.meta_state.mark_first_adventure_done() iso = sm else: main.get_node("/root/MetaProgression").save = iso # 只换 meta 侧(营地段语义)——SaveManager 全局不动 @@ -123,10 +123,10 @@ static func isolate_save(main, tag: String, mark_first_done: bool = false, with_ # 驱动后写盘目标=隔离槽文件(持久化断言读 user://_slot_1.json)。返回 false=新档钮缺失(驱动中断)。 static func open_camp_via_savesel(main, tag: String) -> bool: var sm = main.get_node("/root/SaveManager") - sm.attach_slot_template("user://%s_slot_%%d.json" % tag) + sm.slots.attach_slot_template("user://%s_slot_%%d.json" % tag) for i in range(1, 4): # 夹具清场——空槽起(陈旧槽文件会翻有档态、新档钮缺席) for suf in ["", ".tmp", ".corrupt.bak"]: - var sp: String = sm.slot_path(i) + suf + var sp: String = sm.slots.slot_path(i) + suf if FileAccess.file_exists(sp): DirAccess.remove_absolute(sp) main._open_save_select() diff --git a/game/probe/screen_flow_probe.gd b/game/probe/screen_flow_probe.gd index 1ee71d1a..ac32ec58 100644 --- a/game/probe/screen_flow_probe.gd +++ b/game/probe/screen_flow_probe.gd @@ -132,7 +132,7 @@ func _step_camp_back_to_menu() -> void: # 返回钮真实信号路径关闭→ESC 语境不响应(卡⑤ 契约 2 范围外——零实现即断言)。---- func _step_save_select_open_back() -> void: ProbeUiUtil.isolate_save(main, "smoke_screen_flow_savesel", false, false, true) - main.save_manager.attach_slot_template("user://smoke_screen_flow_savesel_slot_%d.json") + main.save_manager.slots.attach_slot_template("user://smoke_screen_flow_savesel_slot_%d.json") var esb: Button = ProbeUiUtil.find_button(main._start_overlay, "进入存档", "prefix") if esb == null: _problems.append("[savesel] mn3 进入存档钮缺失(卡② 交付物)") diff --git a/game/probe/ui_click_probe.gd b/game/probe/ui_click_probe.gd index c67df5cd..be6b5550 100644 --- a/game/probe/ui_click_probe.gd +++ b/game/probe/ui_click_probe.gd @@ -71,7 +71,7 @@ func _ready() -> void: _problems.append("树淬炼柜台购买钮缺失(点击路径——四柜台重排后树=主界面)") else: # 树钮句柄用后即取(refresh_camp 随刷重建树行板——旧钮 queue_free,跨刷新句柄必失效) - iso_c.add_keystone(9999) + iso_c.meta_state._add_keystone(9999) main.camp_overlay.refresh_camp(main._camp_data()) await get_tree().process_frame var buy_btn: Button = null @@ -81,15 +81,15 @@ func _ready() -> void: if buy_btn == null: _problems.append("脊柱生命行缺失(点击路径)") else: - var bal_before: int = iso_c.keystone_balance() + var bal_before: int = iso_c.meta_state.keystone_balance() var price: int = int(main.db.tree_node("sp_hp").get("price", 0)) buy_btn.pressed.emit() # 合成购买点击 → _on_camp_tree_buy → meta.tree_buy(写调用唯一归组合根) await get_tree().process_frame - if int(iso_c.keystone_balance()) != bal_before - price: - _problems.append("营地树购买未出账(%d→%d 应 −%d)" % [bal_before, iso_c.keystone_balance(), price]) + if int(iso_c.meta_state.keystone_balance()) != bal_before - price: + _problems.append("营地树购买未出账(%d→%d 应 −%d)" % [bal_before, iso_c.meta_state.keystone_balance(), price]) var iso_chk = load("res://core/save_manager.gd").new("user://smoke_click_probe_camp_slot_1.json") iso_chk.load_from_disk() - if int(iso_chk.tree_level("sp_hp")) != 1: + if int(iso_chk.meta_state.tree_state().nodes.get("sp_hp", 0)) != 1: _problems.append("营地树购买未持久(reload 后级 != 1)") # Fork 双态切换点击路径(V20——购=解锁默认 A,钮切 B;前置=同调位 1,先点亮) var socket_btn: Button = null @@ -123,7 +123,7 @@ func _ready() -> void: await get_tree().process_frame var iso_fk = load("res://core/save_manager.gd").new("user://smoke_click_probe_camp_slot_1.json") iso_fk.load_from_disk() - if String(iso_fk.fork_side("fg_posture")) != "b": + if String(iso_fk.meta_state.tree_state().fork_sides.get("fg_posture", "")) != "b": _problems.append("Fork 切态未持久(reload 后激活态 != b)") # ---- 营地六处灰字取色断言(迭代36 wc4——真实入口开营地后按文本锚取 label, # font_color 逐处 == STONE_DEEP;六源位 :167 图鉴锁文/:417+:476 行状态字/:509 同调形态/:513 档位标签/:525 刷新行)---- @@ -189,7 +189,7 @@ func _ready() -> void: if FileAccess.file_exists(cp): DirAccess.remove_absolute(cp) ProbeUiUtil.isolate_save(main, "smoke_click_probe_savesel", true, false, true) - main.save_manager.attach_slot_template(csel_tpl) + main.save_manager.slots.attach_slot_template(csel_tpl) var f_c2 := FileAccess.open(csel_tpl % 2, FileAccess.WRITE) f_c2.store_string(JSON.stringify({"version": 7, "meta": {"keystone_balance": 100, "adventure_total": 3, "adventure_cleared": 1, "tree_nodes": {}, "fork_sides": {}, "tree_struct": 2}})) @@ -215,8 +215,8 @@ func _ready() -> void: _problems.append("[savesel] sl12 动线序违约:选档隐藏帧营地层未可见(先关后开=回闪形态)") elif samples[0][2]: _problems.append("[savesel] sl12 动线序违约:开始层在落点帧恢复(回闪一帧)") - if main.save_manager.active_slot() != 1 or not FileAccess.file_exists(csel_tpl % 1): - _problems.append("[savesel] sl5 新档链失守(active=%d 应=1 且槽1 白板应落盘)" % main.save_manager.active_slot()) + if main.save_manager.slots.active_slot() != 1 or not FileAccess.file_exists(csel_tpl % 1): + _problems.append("[savesel] sl5 新档链失守(active=%d 应=1 且槽1 白板应落盘)" % main.save_manager.slots.active_slot()) var occluded: Array = main._modals._occluded.get(main.camp_overlay, []) if not occluded.has(main.save_select_overlay): _problems.append("[savesel] sl12 选档层被关闭而非栈记账挂起(先关后开形态——遮挡挂起中间态钉死)") @@ -233,8 +233,8 @@ func _ready() -> void: else: load_btn.pressed.emit() await get_tree().process_frame - if main.save_manager.active_slot() != 2: - _problems.append("[savesel] sl6 进档链失守(active=%d 应=2——激活该槽)" % main.save_manager.active_slot()) + if main.save_manager.slots.active_slot() != 2: + _problems.append("[savesel] sl6 进档链失守(active=%d 应=2——激活该槽)" % main.save_manager.slots.active_slot()) if not main.camp_overlay.visible or main.save_select_overlay.visible: _problems.append("[savesel] sl6 落点未开营地层或选档未隐藏") main._on_camp_closed() diff --git a/game/probe/ui_input_probe.gd b/game/probe/ui_input_probe.gd index f804ce56..ba6c4ac7 100644 --- a/game/probe/ui_input_probe.gd +++ b/game/probe/ui_input_probe.gd @@ -475,7 +475,7 @@ func _ready() -> void: # attune_param_sum 只读出口→set_fog_glow_bonus〕→视野 +4 生效与卸装复原。 # settlement.attunement 手动注入=模拟 begin_battle enc 注入后的运行态〔注入面本体归 # 冒烟+单测覆盖〕;不重启冒险——case⑯ 起既有板面继续用) - main.save_manager.set_attunement_equipped(["wide_scout_band"]) + main.save_manager.meta_state.set_attunement_equipped(["wide_scout_band"]) var p_lantern: Dictionary = main.build_adventure_params(main.save_manager, main.meta) if not (p_lantern.get("attunement", []) as Array).has("wide_scout_band"): _problems.append("case⑮ 装配未进开局参数面(attunement=%s 应含 wide_scout_band)" % str(p_lantern.get("attunement", []))) @@ -491,7 +491,7 @@ func _ready() -> void: _problems.append("case⑮ 卸装后视野未复原(lantern 应=0——契约复原语义显式重算)") if int(bv.cells_probe()[ring_cell].fog_state) == 0: _problems.append("case⑮ 卸装后 dist==R+1 格未复原遮蔽") - main.save_manager.set_attunement_equipped([]) + main.save_manager.meta_state.set_attunement_equipped([]) # ---- case⑯ 状态面板输入锁语义(R3——开层锁棋盘、关层恢复无残留;真实 push_input # 路径:入口角标本体 press+release 真点击 → 状态面板开;锁期挖掘点击零穿透;「继续」关层后 # 同格挖掘重新到达——锁语义与既有模态同款,无状态残留。置于全 case 之尾:取格若需前沿移位 diff --git a/game/probe/ui_struct_probe.gd b/game/probe/ui_struct_probe.gd index 9c687982..a268521c 100644 --- a/game/probe/ui_struct_probe.gd +++ b/game/probe/ui_struct_probe.gd @@ -81,7 +81,7 @@ func _ready() -> void: if FileAccess.file_exists(spp): DirAccess.remove_absolute(spp) ProbeUiUtil.isolate_save(main, "smoke_ui_struct_probe_savesel", true, false, true) - main.save_manager.attach_slot_template(ssel_tpl) # 注入口(激活态回零——sl9 重启链同构态) + main.save_manager.slots.attach_slot_template(ssel_tpl) # 注入口(激活态回零——sl9 重启链同构态) var f_s1 := FileAccess.open(ssel_tpl % 1, FileAccess.WRITE) f_s1.store_string(JSON.stringify({"version": 7, "meta": {"keystone_balance": 555, "adventure_total": 7, "adventure_cleared": 2, "tree_nodes": {}, "fork_sides": {}, "tree_struct": 2}})) @@ -89,7 +89,7 @@ func _ready() -> void: var f_s3 := FileAccess.open(ssel_tpl % 3, FileAccess.WRITE) f_s3.store_string("broken json not a save{{{") # 槽3 坏档(窥视 readable=false→按空呈现) f_s3.close() - if main.save_manager.active_slot() != 0: + if main.save_manager.slots.active_slot() != 0: problems.append("[savesel] 开层前激活态非零(sl9 重启链——boot/注入面后应无激活)") main._open_save_select() _settle() diff --git a/game/probe/ui_tune_probe.gd b/game/probe/ui_tune_probe.gd index 51c39b7e..6d15d787 100644 --- a/game/probe/ui_tune_probe.gd +++ b/game/probe/ui_tune_probe.gd @@ -165,11 +165,11 @@ func _ready() -> void: # SaveManager 是 autoload 单例——换注走原地 attach(首局态挂载+恢复隔离档); # --script 模式无裸 autoload 标识符——经 main 节点路径解析 var sm = main.get_node("/root/SaveManager") - sm.attach_path("user://smoke_tune_probe_seed.json") # 全新空档——first_adventure_done=false(首局态;新档缺省面) + sm.slots.attach_path("user://smoke_tune_probe_seed.json") # 全新空档——first_adventure_done=false(首局态;新档缺省面) main.debug_overlay.seed_edit.text = "" if main._seed_value() != 42: problems.append("首局空框种子 != 42(P5 甲失守——教学态可复现破约)") - sm.attach_path("user://smoke_tune_probe_save.json") # 恢复隔离档(非首局态——isolate_save 已建档置位) + sm.slots.attach_path("user://smoke_tune_probe_save.json") # 恢复隔离档(非首局态——isolate_save 已建档置位) var draws: Array[int] = [] for i in range(4): draws.append(main._seed_value()) diff --git a/game/sim/verifier_batch.gd b/game/sim/verifier_batch.gd index 39165bd3..53756c9e 100644 --- a/game/sim/verifier_batch.gd +++ b/game/sim/verifier_batch.gd @@ -172,13 +172,13 @@ func _seg_campaign(db, autoplay, logger, opt: Dictionary, counts: Dictionary) -> row["keystone_income"] = int(settle.total) row["purchases"] = _spend(meta, String(opt.spend)) row["keystone_after"] = meta.keystone_balance() - row["tree"] = {"sp_hp": meta.save.tree_level("sp_hp"), "sp_hit": meta.save.tree_level("sp_hit")} + row["tree"] = {"sp_hp": meta.save.meta_state.tree_state().nodes.get("sp_hp", 0), "sp_hit": meta.save.meta_state.tree_state().nodes.get("sp_hit", 0)} row["new_unlocks"] = settle.new_unlocks _emit(row) counts.campaign += 1 if (r + 1) % 10 == 0: print("进度 campaign %s %d/%d(树 血%d/命中%d · 钥石 %d)" % [policy, r + 1, int(opt.k), - meta.save.tree_level("sp_hp"), meta.save.tree_level("sp_hit"), + meta.save.meta_state.tree_state().nodes.get("sp_hp", 0), meta.save.meta_state.tree_state().nodes.get("sp_hit", 0), meta.keystone_balance()]) _remove_save(save_path) # 段尾清理(与 smoke 存档纪律同源——不遗留隔离档) @@ -195,7 +195,7 @@ func _spend(meta, policy_name: String) -> Array: for nid in order: var price: int = int(meta.db.tree_node(nid).get("price", 0)) if price > 0 and meta.keystone_balance() >= price and meta.tree_buy(nid): - out.append({"node_id": nid, "level": meta.save.tree_level(nid), "price": price}) + out.append({"node_id": nid, "level": meta.save.meta_state.tree_state().nodes.get(nid, 0), "price": price}) bought = true break if not bought: diff --git a/game/tests/integration/cross_system_flow_test.gd b/game/tests/integration/cross_system_flow_test.gd index 89b7bc25..4946923f 100644 --- a/game/tests/integration/cross_system_flow_test.gd +++ b/game/tests/integration/cross_system_flow_test.gd @@ -112,24 +112,24 @@ func _save_summary_integration() -> void: # ⑨ 摘要集成(冒险结构批产物驱动):run_adventure 真局摘要 → apply 原语 → 域读数 → 写盘读回保持 var st9: Dictionary = LevelAutoplay.run_adventure(_db, 42, 900, 400) var f9 = sm_script.new(path9) - f9.apply_adventure_summary(st9.get("summary", {})) + f9.meta_state.apply_adventure_summary(st9.get("summary", {})) var ks_run := 0 for li in range(1, int(_db.level.rows.size()) + 1): ks_run += int(_db.meta_boss_keystone_row(li).get("first_kill", 0)) Kit.check("摘要集成:冒险驱动满配冒险成功(seed 42)", String(st9.get("result", "")) == "completed") - Kit.check("集成钥石余额 = ΣBoss 首杀行值(%d——逐杀掉落全源)" % f9.keystone_balance(), f9.keystone_balance() == ks_run) - Kit.check("集成关卡完成次数表全 1", f9.clear_count(1) == 1 and f9.clear_count(2) == 1 and f9.clear_count(3) == 1) - Kit.check("集成三关首杀标记齐", f9.boss_first_kill_marked(1) and f9.boss_first_kill_marked(2) and f9.boss_first_kill_marked(3)) - Kit.check("集成 recent 1 条且 seed 42", f9.recent_summaries().size() == 1 and int(f9.recent_summaries()[0].get("master_seed", -1)) == 42) + Kit.check("集成钥石余额 = ΣBoss 首杀行值(%d——逐杀掉落全源)" % f9.meta_state.keystone_balance(), f9.meta_state.keystone_balance() == ks_run) + Kit.check("集成关卡完成次数表全 1", f9.meta_state.career_state().clear_counts.get(str(1), 0) == 1 and f9.meta_state.career_state().clear_counts.get(str(2), 0) == 1 and f9.meta_state.career_state().clear_counts.get(str(3), 0) == 1) + Kit.check("集成三关首杀标记齐", f9.meta_state.career_state().boss_first_kill.has(str(1)) and f9.meta_state.career_state().boss_first_kill.has(str(2)) and f9.meta_state.career_state().boss_first_kill.has(str(3))) + Kit.check("集成 recent 1 条且 seed 42", f9.meta_state.career_state().recent_summaries.size() == 1 and int(f9.meta_state.career_state().recent_summaries[0].get("master_seed", -1)) == 42) var st9b: Dictionary = LevelAutoplay.run_adventure(_db, 42, 900, 400) - f9.apply_adventure_summary(st9b.get("summary", {})) - Kit.check("第二局后关卡完成次数全 2", f9.clear_count(1) == 2 and f9.clear_count(2) == 2 and f9.clear_count(3) == 2) - Kit.check("第二局后余额翻倍(2 × Σ首杀——模拟驱动恒首杀口径)", f9.keystone_balance() == 2 * ks_run) - Kit.check("集成 recent 长度 2", f9.recent_summaries().size() == 2) + f9.meta_state.apply_adventure_summary(st9b.get("summary", {})) + Kit.check("第二局后关卡完成次数全 2", f9.meta_state.career_state().clear_counts.get(str(1), 0) == 2 and f9.meta_state.career_state().clear_counts.get(str(2), 0) == 2 and f9.meta_state.career_state().clear_counts.get(str(3), 0) == 2) + Kit.check("第二局后余额翻倍(2 × Σ首杀——模拟驱动恒首杀口径)", f9.meta_state.keystone_balance() == 2 * ks_run) + Kit.check("集成 recent 长度 2", f9.meta_state.career_state().recent_summaries.size() == 2) Kit.check("集成写盘成功", f9.write_to_disk()) var r9 = sm_script.new(path9) - Kit.check("集成写盘读回全量保持", r9.load_from_disk() and r9.keystone_balance() == 2 * ks_run and r9.clear_count(3) == 2 - and r9.recent_summaries().size() == 2 and r9.boss_first_kill_marked(3)) + Kit.check("集成写盘读回全量保持", r9.load_from_disk() and r9.meta_state.keystone_balance() == 2 * ks_run and r9.meta_state.career_state().clear_counts.get(str(3), 0) == 2 + and r9.meta_state.career_state().recent_summaries.size() == 2 and r9.meta_state.career_state().boss_first_kill.has(str(3))) if FileAccess.file_exists(path9): # 段尾清理(与段首清场对称——源 _smoke_save 同款) DirAccess.remove_absolute(path9) diff --git a/game/tests/integration/node_map_determinism_test.gd b/game/tests/integration/node_map_determinism_test.gd index 1f85e314..e6ea7d47 100644 --- a/game/tests/integration/node_map_determinism_test.gd +++ b/game/tests/integration/node_map_determinism_test.gd @@ -105,12 +105,12 @@ func _smoke_generated_map_determinism() -> void: var sv = SaveManagerScript.new(_bp_path + ".flag.json") sv.load_from_disk() Kit.check("新档首次冒险布尔位 = false(默认域——首局恒走固定种子 42)", - sv.is_first_adventure_done() == false) + sv.meta_state.career_state().first_adventure_done == false) var mp = load("res://core/meta_progression.gd").new(_db, sv) mp.settle_adventure({"levels_cleared": 1, "keystone_total": 10, "cleared": false, "result": "failed"}) - Kit.check("冒险收束点置位(三结局同写——写入只在收束挂点)", sv.is_first_adventure_done() == true) + Kit.check("冒险收束点置位(三结局同写——写入只在收束挂点)", sv.meta_state.career_state().first_adventure_done == true) mp.settle_adventure({"levels_cleared": 0, "keystone_total": 0, "cleared": false, "result": "failed"}) - Kit.check("置位幂等(重复收束不翻转)", sv.is_first_adventure_done() == true) + Kit.check("置位幂等(重复收束不翻转)", sv.meta_state.career_state().first_adventure_done == true) DirAccess.remove_absolute(_bp_path) DirAccess.remove_absolute(_bp_path + ".flag.json") diff --git a/game/tests/integration/save_rng_roundtrip_test.gd b/game/tests/integration/save_rng_roundtrip_test.gd index edfba694..263b7842 100644 --- a/game/tests/integration/save_rng_roundtrip_test.gd +++ b/game/tests/integration/save_rng_roundtrip_test.gd @@ -103,7 +103,7 @@ func _test_old_version_save_migrates_and_resumes() -> void: var sm_old := SaveManagerScript.new(SAVE_PATH) Kit.check_quiet(sm_old.load_from_disk(), "旧档用例:旧档经迁移链可载") Kit.check_quiet(sm_old.has_breakpoint(), "旧档用例:迁移后断点未被迁移链吃掉") - var forge: Dictionary = sm_old.forge_routes_snapshot() + var forge: Dictionary = sm_old.meta_state.forge_state() Kit.check_quiet(str(forge.get("weapon", "")) == "sword", "旧档用例:迁移补齐当前版本新增域(缺 forge_routes 的旧档按初始持有态可玩)") diff --git a/game/tests/system_test.gd b/game/tests/system_test.gd index e61fd894..24f09f01 100644 --- a/game/tests/system_test.gd +++ b/game/tests/system_test.gd @@ -176,9 +176,9 @@ func _smoke_boot_chain() -> void: check(not sm.has_breakpoint(), "V2d boot 无断点在档(启动态不隐式激活——卡① sv2 钉死)〔装配生命批 V2·卡② mn8 改写〕") # ④-1b 启动态三断言(卡① sv2——真实 autoload boot 链) - check(sm.active_slot() == 0 and not sm.has_write_target(), + check(sm.slots.active_slot() == 0 and not sm.slots.has_write_target(), "V2-s1 boot 后无激活态(active_slot=0、写盘目标空)〔卡① sv2〕") - check(sm.keystone_balance() == 0 and not sm.has_breakpoint() and sm.adventure_total() == 0, + check(sm.meta_state.keystone_balance() == 0 and not sm.has_breakpoint() and sm.meta_state.career_state().adventure_total == 0, "V2-s2 boot 内存=默认合法空态(钥石/断点/总局数全零值)〔卡① sv2〕") check(not sm.write_to_disk(), "V2-s3 boot 无激活写盘拒绝(write_to_disk=false 生产入口)〔卡① sv3〕") @@ -187,7 +187,7 @@ func _smoke_boot_chain() -> void: for p in ["user://smoke_boot_chain.json", "user://smoke_boot_chain.json.tmp"]: if FileAccess.file_exists(p): DirAccess.remove_absolute(p) - sm.attach_path("user://smoke_boot_chain.json") + sm.slots.attach_path("user://smoke_boot_chain.json") check(main._menu_screen.entersavebtn != null and main._menu_screen.settingsbtn != null and main._menu_screen.quitbtn != null, "V2d 三钮在位·进入存档/设置/退出游戏(续玩钮族退役后主菜单操作组承接)〔卡② mn1/mn8〕") @@ -222,8 +222,8 @@ func _smoke_run_lifecycle() -> void: for p in ["user://smoke_run_lifecycle.json", "user://smoke_run_lifecycle.json.tmp"]: if FileAccess.file_exists(p): DirAccess.remove_absolute(p) - sm.attach_path("user://smoke_run_lifecycle.json") - sm.mark_first_adventure_done() + sm.slots.attach_path("user://smoke_run_lifecycle.json") + sm.meta_state.mark_first_adventure_done() # ---- 第一局:开局→注脏→放弃→终局出口→会话收束 ---- main.debug_overlay.seed_edit.text = "42" main._start_adventure_from_menu() @@ -278,8 +278,8 @@ func _smoke_sim_guards() -> void: for p in ["user://smoke_sim_guards.json", "user://smoke_sim_guards.json.tmp"]: if FileAccess.file_exists(p): DirAccess.remove_absolute(p) - sm.attach_path("user://smoke_sim_guards.json") - sm.mark_first_adventure_done() + sm.slots.attach_path("user://smoke_sim_guards.json") + sm.meta_state.mark_first_adventure_done() # wd6:闲时态 + 模拟运行态 → 调试两层入口守卫早退(开始层不收起、无结算对象) check(main.run_scope.settlement == null and not main.run_scope.is_adventure, "前置:闲时会话(settlement 空 + is_adventure=false)") @@ -420,7 +420,7 @@ func _smoke_lifecycle_matrix() -> void: for p in ["user://smoke_lifecycle.json", "user://smoke_lifecycle.json.tmp"]: if FileAccess.file_exists(p): DirAccess.remove_absolute(p) - sm.attach_path("user://smoke_lifecycle.json") + sm.slots.attach_path("user://smoke_lifecycle.json") main.debug_overlay.seed_edit.text = "42" # 阶段①闲时 await _stage_faces(main, "V3a 闲时", "null", "null", "null", @@ -513,7 +513,7 @@ func _smoke_lifecycle_matrix() -> void: # 营地经进档开层(卡② mn9 新路径——campbtn 已撤);槽模板注入隔离命名空间 #(防碰真实槽 user://save_slot_N.json——真实档红线),营地闭合后恢复生命周期 attach 写盘目标 #(卡① sv3 无激活拒写——后续阶段断点写盘依赖) - sm.attach_slot_template("user://smoke_lifecycle_slot_%d.json") + sm.slots.attach_slot_template("user://smoke_lifecycle_slot_%d.json") main._open_save_select() for i in range(2): await process_frame @@ -546,7 +546,7 @@ func _smoke_lifecycle_matrix() -> void: main._on_save_select_closed() # 关闭被栈还原的选档层(动线序中间态——卡④ 换代点) for i in range(2): await process_frame - sm.attach_path("user://smoke_lifecycle.json") # 恢复生命周期写盘目标 + sm.slots.attach_path("user://smoke_lifecycle.json") # 恢复生命周期写盘目标 for suf in ["", ".tmp"]: var slot_p: String = "user://smoke_lifecycle_slot_1.json" + suf if FileAccess.file_exists(slot_p): @@ -578,7 +578,7 @@ func _smoke_lifecycle_matrix() -> void: for p in ["user://smoke_lifecycle_resume.json", "user://smoke_lifecycle_resume.json.tmp"]: if FileAccess.file_exists(p): DirAccess.remove_absolute(p) - sm.attach_path("user://smoke_lifecycle_resume.json") + sm.slots.attach_path("user://smoke_lifecycle_resume.json") sm.set_breakpoint({ "master_seed": 42, "rng_counter": 0, "hero_hp": 20, "gp": 24, "keystone": 0, "level_index": 1, "bonus": {}, "shop_purchased": {}, "relics": [], diff --git a/game/tests/unit/core/meta_progression_forge_test.gd b/game/tests/unit/core/meta_progression_forge_test.gd index 4e8683b7..4bdfe10f 100644 --- a/game/tests/unit/core/meta_progression_forge_test.gd +++ b/game/tests/unit/core/meta_progression_forge_test.gd @@ -132,22 +132,22 @@ func _meta_pricing_and_buy() -> void: var sv = _meta_new_save(_META_SMOKE_PATHS[0]) var mp = load("res://core/meta_progression.gd").new(_db, sv) # ③ 购买成功:级+1、余额−价;buy 内含写盘——reload 后持久(树全钥石单币) - sv.add_keystone(1000) - var bal0: int = sv.keystone_balance() + sv.meta_state._add_keystone(1000) + var bal0: int = sv.meta_state.keystone_balance() var price0: int = int(_db.tree_node("sp_hp").get("price", 0)) Kit.check("树购买受理(Lv 0→1、余额 %d−%d)" % [bal0, price0], - mp.tree_buy("sp_hp") and sv.tree_level("sp_hp") == 1 and sv.keystone_balance() == bal0 - price0) + mp.tree_buy("sp_hp") and sv.meta_state.tree_state().nodes.get("sp_hp", 0) == 1 and sv.meta_state.keystone_balance() == bal0 - price0) var sv_r = _meta_new_save(_META_SMOKE_PATHS[0]) Kit.check("购买持久(reload 后级与余额不变)", - sv_r.tree_level("sp_hp") == 1 and sv_r.keystone_balance() == bal0 - price0) + sv_r.meta_state.tree_state().nodes.get("sp_hp", 0) == 1 and sv_r.meta_state.keystone_balance() == bal0 - price0) # ③ 余额不足 / 未知节点:false 零副作用 var sv_dry = _meta_new_save(_META_SMOKE_PATHS[1]) var mp_dry = load("res://core/meta_progression.gd").new(_db, sv_dry) - var tn_before: Dictionary = sv_dry.tree_nodes() - var fs_before: Dictionary = sv_dry.fork_sides() + var tn_before: Dictionary = sv_dry.meta_state.tree_state().nodes + var fs_before: Dictionary = sv_dry.meta_state.tree_state().fork_sides Kit.check("余额不足 false 零副作用(0 余额购买拒绝——三域零变化〔天赋树批 V3〕)", - not mp_dry.tree_buy("sp_hp") and sv_dry.keystone_balance() == 0 - and sv_dry.tree_nodes() == tn_before and sv_dry.fork_sides() == fs_before) + not mp_dry.tree_buy("sp_hp") and sv_dry.meta_state.keystone_balance() == 0 + and sv_dry.meta_state.tree_state().nodes == tn_before and sv_dry.meta_state.tree_state().fork_sides == fs_before) Kit.check("未知节点 false 零副作用(词表外出域)", not mp_dry.tree_buy("no_such_node")) @@ -171,7 +171,7 @@ func _meta_settle_and_workshop() -> void: Kit.check("明细 total = keystone_total(逐杀掉落全源——无二次派生加账)", int(d1.get("total", 0)) == int(row1.get("first_kill", 0))) Kit.check("余额 = 累计入账(逐杀掉落合计——无重复加账)", - sv.keystone_balance() == int(row1.get("first_kill", 0))) + sv.meta_state.keystone_balance() == int(row1.get("first_kill", 0))) # ⑥ 再通转复杀档(首杀标记置位后小额恒定不递减——标记面在 apply,金额面在逐杀裁决) var d2: Dictionary = mp.settle_adventure({"result": "failed", "cleared": false, "levels_cleared": 1, "stopped_at": 2, "keystone_total": int(row1.get("repeat_kill", 0)), @@ -196,39 +196,39 @@ func _meta_settle_and_workshop() -> void: not bool(d4["boss_drops"][0].get("first", true)) and bool(d4["boss_drops"][1].get("first", false)) and int(d4["boss_drops"][1].get("amount", 0)) == int(row2.get("first_kill", -1))) # ⑦ 败北零关卡完成摘要:余额只增不减(零入账)、clear_counts 不变 - var bal_before: int = sv.keystone_balance() - var cc1_before: int = sv.clear_count(1) + var bal_before: int = sv.meta_state.keystone_balance() + var cc1_before: int = sv.meta_state.career_state().clear_counts.get(str(1), 0) var dz: Dictionary = mp.settle_adventure({"result": "failed", "cleared": false, "levels_cleared": 0, "stopped_at": 1, "keystone_total": 0, "master_seed": 15}) Kit.check("败北零关卡完成摘要:零入账零标记(钥石只增不减——验收1;败北局 clear_counts 不变)", - dz["boss_drops"].is_empty() and sv.keystone_balance() == bal_before and sv.clear_count(1) == cc1_before) + dz["boss_drops"].is_empty() and sv.meta_state.keystone_balance() == bal_before and sv.meta_state.career_state().clear_counts.get(str(1), 0) == cc1_before) # ⑧ 秘宝解锁服务(V3 钥石购买/V4 条件解锁;注入=直调解锁写口+直置存档生涯计数, # 不走正常资源积累流程——卡片 7.1 测试方式) var sv_u = _meta_new_save(_META_SMOKE_PATHS[11]) var mp_u = load("res://core/meta_progression.gd").new(_db, sv_u) - sv_u.add_keystone(10) + sv_u.meta_state._add_keystone(10) Kit.check("条件件误购拒绝(购买件专属——契约 8 分配面)〔秘宝批 V3 反例〕", - not mp_u.buy_treasure_unlock("sky_eye") and not sv_u.item_unlocked("sky_eye") + not mp_u.buy_treasure_unlock("sky_eye") and not sv_u.meta_state.item_unlocked("sky_eye") and not mp_u.buy_treasure_unlock("ranged_pick")) Kit.check("余额不足购买拒绝且零副作用(余额 10 < 价 25)〔秘宝批 V3〕", - not mp_u.buy_treasure_unlock("twin_loader") and sv_u.keystone_balance() == 10) + not mp_u.buy_treasure_unlock("twin_loader") and sv_u.meta_state.keystone_balance() == 10) Kit.check("钥石购买解锁:余额足→解锁态落档+余额扣减正确(价 10)〔秘宝批 V3〕", - mp_u.buy_treasure_unlock("morning_order") and sv_u.item_unlocked("morning_order") - and sv_u.keystone_balance() == 0) + mp_u.buy_treasure_unlock("morning_order") and sv_u.meta_state.item_unlocked("morning_order") + and sv_u.meta_state.keystone_balance() == 0) Kit.check("已解锁重复购买拒绝〔秘宝批 V3〕", not mp_u.buy_treasure_unlock("morning_order")) Kit.check("条件未达不解锁(远程镐=冒险成功 1 次)〔秘宝批 V4〕", - mp_u.refresh_treasure_unlocks().is_empty() and not sv_u.item_unlocked("ranged_pick")) + mp_u.refresh_treasure_unlocks().is_empty() and not sv_u.meta_state.item_unlocked("ranged_pick")) sv_u._data["meta"]["adventure_cleared"] = 1 # 直置存档生涯计数(调试注入口径) Kit.check("远程镐=adventure_cleared≥1 达标恰解锁一次+重复刷新幂等(C6:冒险成功=打通整局冒险)〔秘宝批 V4〕", (mp_u.refresh_treasure_unlocks() as Array).has("ranged_pick") - and sv_u.item_unlocked("ranged_pick") and mp_u.refresh_treasure_unlocks().is_empty()) + and sv_u.meta_state.item_unlocked("ranged_pick") and mp_u.refresh_treasure_unlocks().is_empty()) sv_u._data["meta"]["boss_kill_count"] = 3 Kit.check("刷新执照=boss_kill_count≥3(阈值读 unlock_requires 行值不硬编)〔秘宝批 V4〕", (mp_u.refresh_treasure_unlocks() as Array).has("refresh_license")) sv_u._data["meta"]["adventure_cleared"] = 3 Kit.check("天眼单条件不足不解锁(AND 语义——cleared 3 达 boss 4 未达)〔秘宝批 V4〕", - not sv_u.item_unlocked("sky_eye")) + not sv_u.meta_state.item_unlocked("sky_eye")) sv_u._data["meta"]["boss_kill_count"] = 4 Kit.check("天眼双条件 AND(冒险成功≥3 且 Boss≥4)达标解锁〔秘宝批 V4〕", (mp_u.refresh_treasure_unlocks() as Array).has("sky_eye")) @@ -240,7 +240,7 @@ func _meta_settle_and_workshop() -> void: r_n += 1 if bool(st_row.get("unlocked", false)) else 0 Kit.check("解锁态持久投影一致(reload 面同源读数;5 件=购买 1+条件 4)〔秘宝批 V4〕", r_n == 5) # ⑩ recent_summaries:元素含 boss_drops 副本、最新在前、cap 10 - var rs: Array = sv.recent_summaries() + var rs: Array = sv.meta_state.career_state().recent_summaries Kit.check("摘要最新在前 + 元素含掉落明细副本(复盘可归因——验收5)", rs.size() >= 5 and String(rs[0].get("result", "")) != "" and rs[0].has("boss_drops") and (rs[1] as Dictionary).has("keystone_drops") @@ -251,7 +251,7 @@ func _meta_settle_and_workshop() -> void: mp_c.settle_adventure({"result": "failed", "cleared": false, "levels_cleared": 0, "stopped_at": 1, "keystone_total": 0, "master_seed": i}) Kit.check("摘要 cap 10 且最新在前(12 连 settle 截断后 seed 11 居首)", - sv_c.recent_summaries().size() == 10 and int(sv_c.recent_summaries()[0].get("master_seed", -1)) == 11) + sv_c.meta_state.career_state().recent_summaries.size() == 10 and int(sv_c.meta_state.career_state().recent_summaries[0].get("master_seed", -1)) == 11) # ⑪ 同调位账本(装配持久/满位禁选/Y 升级语义;写口 = MetaProgression 同调段) var sv_a = _meta_new_save(_META_SMOKE_PATHS[6]) var mp_a = load("res://core/meta_progression.gd").new(_db, sv_a) @@ -259,17 +259,17 @@ func _meta_settle_and_workshop() -> void: Attunement.slots(_db, sv_a) == 0 and int(mp_a.attune_camp_status()["refresh"]["charges"]) == 0) Kit.check("满位禁选(Slots 0 时装配拒绝;兼未解锁拒绝前置——直置解锁后下方装配)〔秘宝批 V5〕", mp_a.toggle_attunement("morning_order") == false) - sv_a.add_keystone(200) - sv_a.unlock_item("morning_order") - sv_a.unlock_item("twin_loader") + sv_a.meta_state._add_keystone(200) + sv_a.meta_state.unlock_item("morning_order") + sv_a.meta_state.unlock_item("twin_loader") Kit.check("Y 升级购买入账(Lv1 → 每冒险 +1 次刷新)", - mp_a.buy_attune_refresh() and sv_a.attune_refresh_level() == 1 + mp_a.buy_attune_refresh() and sv_a.meta_state.attune_state().refresh_level == 1 and int(mp_a.attune_camp_status()["refresh"]["charges"]) == 1) mp_a.settle_adventure({"result": "failed", "cleared": false, "levels_cleared": 1, "stopped_at": 2, "keystone_total": 0, "master_seed": 21}) Kit.check("完成 1 关 → Slots 恒 0(旧「按击杀 Boss 次数自动涨」条件已删——槽位唯一来源=树点亮)且 Boss 击杀总数=1(计数域保留)〔天赋树批 V14〕", - Attunement.slots(_db, sv_a) == 0 and sv_a.boss_kill_count() == 1) - sv_a.set_tree_level("att_socket1", 1) + Attunement.slots(_db, sv_a) == 0 and sv_a.meta_state.career_state().boss_kill_count == 1) + sv_a.meta_state.set_tree_level("att_socket1", 1) Kit.check("树上点亮同调位 1 → Slots=1(槽位收编树节点——Q2 裁甲)", Attunement.slots(_db, sv_a) == 1) Kit.check("稀有件入普通格拒绝(件档 rank 3 > 装入位序 1——契约 2 档位格校验)〔秘宝批 V5〕", @@ -279,7 +279,7 @@ func _meta_settle_and_workshop() -> void: Kit.check("卸下恒可再装配(自卸自装——营地换装=冒险开始前合法面,装配不可变只锁运行中)〔秘宝批换主语〕", mp_a.toggle_attunement("morning_order") and mp_a.toggle_attunement("morning_order")) var sv_a2 = _meta_new_save(_META_SMOKE_PATHS[7]) - sv_a2.set_attunement_equipped(["morning_order"]) + sv_a2.meta_state.set_attunement_equipped(["morning_order"]) var mp_a2 = load("res://core/meta_progression.gd").new(_db, sv_a2) Kit.check("装配关系读回持久(reload 后投影一致——写新件 id)〔秘宝批 V15〕", (mp_a2.attune_camp_status()["equipped"] as Array).has("morning_order")) @@ -305,9 +305,9 @@ func _meta_settle_and_workshop() -> void: # 三键恒空(缺陷①),冒烟夹具直呼 runner 参数面拦不住——此处直呼 main.build_adventure_params # 单测统一构造面(static 免实例化整菜单场景;真实两入口共用本构造),取值与营地投影同源) var sv_e = _meta_new_save(_META_SMOKE_PATHS[8]) - sv_e.set_attunement_equipped(["morning_order"]) - sv_e.set_attune_refresh_level(2) - sv_e.set_fork_side("fg_posture", "a") + sv_e.meta_state.set_attunement_equipped(["morning_order"]) + sv_e.meta_state.set_attune_refresh_level(2) + sv_e.meta_state.set_fork_side("fg_posture", "a") var mp_e = load("res://core/meta_progression.gd").new(_db, sv_e) var rules_e: Array = mp_e.tree_rule_keys() Kit.check("开局传参夹具前置:Fork 侧 a 激活 rule 键非空 + Y=Lv2 行表值 2(防断言空转)", @@ -315,7 +315,7 @@ func _meta_settle_and_workshop() -> void: var p_new: Dictionary = load("res://presentation/main.gd").build_adventure_params(sv_e, mp_e, {"treasure_unlocked": {"wide_scout_band": true}, "shop_mode": "pool"}) Kit.check("主菜单开新冒险参数面:同调装配 = 存档装配集(营地装了什么开局就带什么)", - (p_new.get("attunement", []) as Array) == (sv_e.attunement_equipped() as Array)) + (p_new.get("attunement", []) as Array) == (sv_e.meta_state.attune_state().equipped as Array)) Kit.check("主菜单开新冒险参数面:Y 刷新池 = 当前等级行表值(营地升几档开局就给几次)", int(p_new.get("attune_refresh_y", -1)) == mp_e.attune_refresh_charges()) Kit.check("主菜单开新冒险参数面:树 rule 键集 = Fork 激活侧 effects(点哪侧带哪侧)", @@ -325,7 +325,7 @@ func _meta_settle_and_workshop() -> void: var p_resume: Dictionary = load("res://presentation/main.gd").build_adventure_params(sv_e, mp_e, {"treasure_unlocked": {}}) Kit.check("主菜单继续上局参数面:三键与开新冒险同源同值(续玩不吃白板开局)", - (p_resume.get("attunement", []) as Array) == (sv_e.attunement_equipped() as Array) + (p_resume.get("attunement", []) as Array) == (sv_e.meta_state.attune_state().equipped as Array) and int(p_resume.get("attune_refresh_y", -1)) == mp_e.attune_refresh_charges() and (p_resume.get("tree_rules", []) as Array) == rules_e) # 续玩路径 tree_rules 受理断言:直呼 runner 续玩参数面 @@ -349,7 +349,7 @@ func _meta_injection() -> void: var mp = load("res://core/meta_progression.gd").new(_db, sv) # 树生命淬炼注入(公式:基础值+树淬炼 flat+锻造路线加值位预留 0——2 级 = +10; # 注入走调试口径直置等级〔两分法——购买链已在 _smoke_tree 覆盖,此处隔离行门槛干扰〕) - sv.set_tree_level("sp_hp", 2) + sv.meta_state.set_tree_level("sp_hp", 2) Kit.check("生命淬炼 2 级 → hero_override().max_hp == Σ 节点 effects×等级(验收4 换代〔天赋树批 V12〕)", int(mp.hero_override().get("max_hp", -1)) == 2 * int(_db.tree_node("sp_hp").get("effects", {}).get("max_hp", 0))) Kit.check("攻/AC 树零来源(契约 9——树表零 atk/armor_class 行,override 二轴恒 0+预留位 0)", @@ -406,26 +406,26 @@ func _smoke_forge_axes() -> void: var sv2 = _meta_new_save(_META_SMOKE_PATHS[13]) var mp2 = load("res://core/meta_progression.gd").new(_db, sv2) # V2①:长剑首购=20(0 级位几何价)、等级 0→1、扣币即盘、reload 持久 - sv2.add_keystone(100) + sv2.meta_state._add_keystone(100) Kit.check("长剑锻造首购:扣 20 钥石、等级 0→1(Q1——第一级=获得装备=首个 +1)〔锻造工坊批 V2〕", - mp2.forge_buy_level("sword") and sv2.forge_route_level("sword") == 1 - and sv2.keystone_balance() == 80) + mp2.forge_buy_level("sword") and sv2.meta_state.forge_state().levels.get("sword", 0) == 1 + and sv2.meta_state.keystone_balance() == 80) var sv2r = _meta_new_save(_META_SMOKE_PATHS[13]) Kit.check("购买持久(reload 后等级与余额不变——即时写盘)〔锻造工坊批 V2〕", - sv2r.forge_route_level("sword") == 1 and sv2r.keystone_balance() == 80) + sv2r.meta_state.forge_state().levels.get("sword", 0) == 1 and sv2r.meta_state.keystone_balance() == 80) # V2②:二级=30(1 级位);六路线独立(买长剑不动轻甲) Kit.check("长剑二级购:扣 30、等级→2;轻甲独立零扰动(六路线各练各的)〔锻造工坊批 V2〕", - mp2.forge_buy_level("sword") and sv2.forge_route_level("sword") == 2 - and sv2.keystone_balance() == 50 and sv2.forge_route_level("light") == 0) + mp2.forge_buy_level("sword") and sv2.meta_state.forge_state().levels.get("sword", 0) == 2 + and sv2.meta_state.keystone_balance() == 50 and sv2.meta_state.forge_state().levels.get("light", 0) == 0) # V2③:余额不足拒且零副作用(等级/选中/解锁三面零变化) var sv2d = _meta_new_save(_META_SMOKE_PATHS[14]) var mp2d = load("res://core/meta_progression.gd").new(_db, sv2d) - var fr_dry: Dictionary = sv2d.forge_routes_snapshot() + var fr_dry: Dictionary = sv2d.meta_state.forge_state() Kit.check("余额不足购买拒绝且零副作用(0 余额——三域零变化)〔锻造工坊批 V2〕", - not mp2d.forge_buy_level("sword") and sv2d.keystone_balance() == 0 - and sv2d.forge_routes_snapshot() == fr_dry) + not mp2d.forge_buy_level("sword") and sv2d.meta_state.keystone_balance() == 0 + and sv2d.meta_state.forge_state() == fr_dry) # V2④:已满级拒购(cap 闸);未解锁拒购 - sv2.set_forge_route_level("sword", 5) + sv2.meta_state.set_forge_route_level("sword", 5) Kit.check("已满级拒购/未解锁拒购(cap 闸+解锁闸——零副作用)〔锻造工坊批 V2〕", not mp2.forge_buy_level("sword") and not mp2d.forge_buy_level("spear")) # V3① 通道一:练满前一档(长剑 cap)→ 长枪/法杖派生解锁(零收费);轻甲未满→甲线仍锁 @@ -433,40 +433,40 @@ func _smoke_forge_axes() -> void: mp2.forge_route_unlocked("spear") and mp2.forge_route_unlocked("staff") and not mp2.forge_route_unlocked("heavy") and not mp2.forge_route_unlocked("ward")) Kit.check("派生解锁后长枪可购(首购 20——余额 50−20)〔锻造工坊批 V3〕", - mp2.forge_buy_level("spear") and sv2.forge_route_level("spear") == 1 - and sv2.keystone_balance() == 30) + mp2.forge_buy_level("spear") and sv2.meta_state.forge_state().levels.get("spear", 0) == 1 + and sv2.meta_state.keystone_balance() == 30) # V3② 通道二:一次性解锁价直购(未练满也可买——两通道互不影响) var sv3 = _meta_new_save(_META_SMOKE_PATHS[15]) var mp3 = load("res://core/meta_progression.gd").new(_db, sv3) - sv3.add_keystone(500) + sv3.meta_state._add_keystone(500) Kit.check("通道二:魔法护甲一次性解锁价 200 直购开(轻甲未练满也可买)〔锻造工坊批 V3〕", not mp3.forge_route_unlocked("ward") and mp3.forge_buy_unlock("ward") - and sv3.keystone_balance() == 300 and mp3.forge_route_unlocked("ward")) + and sv3.meta_state.keystone_balance() == 300 and mp3.forge_route_unlocked("ward")) # V3③ 直购后练满不重复收(练满通道=派生零收费;已解锁再直购=拒绝) - sv3.set_forge_route_level("light", 5) + sv3.meta_state.set_forge_route_level("light", 5) Kit.check("直购后练满不重复收(已解锁=拒绝零收费——两通道互不影响)〔锻造工坊批 V3〕", mp3.forge_route_unlocked("ward") and not mp3.forge_buy_unlock("ward") - and sv3.keystone_balance() == 300) + and sv3.meta_state.keystone_balance() == 300) # V3④ 不设串联门槛(法杖直购开——长枪零状态不阻;dry 档充值走直购) - sv2d.add_keystone(500) + sv2d.meta_state._add_keystone(500) Kit.check("不设串联门槛(法杖直购开——长枪零状态不阻)〔锻造工坊批 V3〕", mp2d.forge_buy_unlock("staff") and mp2d.forge_route_unlocked("staff") - and sv2d.forge_route_level("spear") == 0 and not mp2d.forge_route_unlocked("spear")) + and sv2d.meta_state.forge_state().levels.get("spear", 0) == 0 and not mp2d.forge_route_unlocked("spear")) # V4:任一购买/解锁只扣钥石——GP 侧零触碰(meta 域无 GP 键=域界扫描同款;此处钉余额差=当级价) - var ks4: int = sv3.keystone_balance() + var ks4: int = sv3.meta_state.keystone_balance() Kit.check("两币分工:购买只扣钥石(余额差=当级价 20——GP 零触碰、无材料计数)〔锻造工坊批 V4〕", - mp3.forge_buy_level("ward") and sv3.keystone_balance() == ks4 - 20 - and sv3.forge_route_level("ward") == 1) + mp3.forge_buy_level("ward") and sv3.meta_state.keystone_balance() == ks4 - 20 + and sv3.meta_state.forge_state().levels.get("ward", 0) == 1) # V6①:营地切换=写存档域+免费(余额零动);旧路线练度保留(只不生效) - var ks6: int = sv3.keystone_balance() + var ks6: int = sv3.meta_state.keystone_balance() Kit.check("选中切换免费(余额零动——写存档域即盘)〔锻造工坊批 V6〕", - mp3.forge_select_armor("ward") and sv3.forge_armor() == "ward" - and sv3.keystone_balance() == ks6) + mp3.forge_select_armor("ward") and sv3.meta_state.forge_state().armor == "ward" + and sv3.meta_state.keystone_balance() == ks6) Kit.check("切换后旧路线练度保留(轻甲 5 级不丢——回家待命只不生效)〔锻造工坊批 V6〕", - sv3.forge_route_level("light") == 5) + sv3.meta_state.forge_state().levels.get("light", 0) == 5) # V6②:未解锁路线拒选零副作用(dry 档:长枪未解锁) Kit.check("未解锁路线拒选零副作用〔锻造工坊批 V6〕", - not mp2d.forge_select_weapon("spear") and sv2d.forge_weapon() == "sword") + not mp2d.forge_select_weapon("spear") and sv2d.meta_state.forge_state().weapon == "sword") # V6③:类型轴读数随选中走(切换后读 ward;未解锁选中态防御回退默认值) Kit.check("类型轴当前生效值随选中走(ward;未解锁档回退默认 normal/light)〔锻造工坊批 V5/V6〕", mp3.hero_armor_type() == "ward" and mp2d.hero_attack_type() == "normal" @@ -480,7 +480,7 @@ func _smoke_forge_axes() -> void: Kit.check("build_adventure_params 注入默认值 normal/light(树钥匙未点亮——新档初始持有态)〔锻造工坊批 V5〕", String(bp_params.get("attack_type", "")) == "normal" and String(bp_params.get("armor_type", "")) == "light") - sv4.add_keystone(1000) + sv4.meta_state._add_keystone(1000) Kit.check("长枪直购解锁+首购+选中(注入面夹具——200+20 钥石)", mp4.forge_buy_unlock("spear") and mp4.forge_buy_level("spear") and mp4.forge_select_weapon("spear")) var bp_sel: Dictionary = load("res://presentation/main.gd").build_adventure_params(sv4, mp4) @@ -493,7 +493,7 @@ func _smoke_forge_axes() -> void: and int(mp4.hero_override().get("max_hp", -1)) == 0 and int(mp4.hero_override().get("armor_class", -1)) == 0 and not mp4.hero_override().has("hit")) - sv4.set_forge_route_level("sword", 3) # 长剑练度待命(未选中) + sv4.meta_state.set_forge_route_level("sword", 3) # 长剑练度待命(未选中) Kit.check("换当前武器→攻击加值随切换(长剑 3 级待命不生效——只认选中)〔锻造工坊批 V7〕", int(mp4.hero_override().get("atk", -1)) == 1) Kit.check("切回长剑→加值=3 级量(旧路线练度保留且生效)〔锻造工坊批 V7〕", @@ -501,7 +501,7 @@ func _smoke_forge_axes() -> void: # V7 三层 AC:重甲(基础 12)+路线 Lv1 加值+局内 0——经参数面起冒险断 runner 生效值 var sv5 = _meta_new_save(_META_SMOKE_PATHS[17]) var mp5 = load("res://core/meta_progression.gd").new(_db, sv5) - sv5.add_keystone(1000) + sv5.meta_state._add_keystone(1000) Kit.check("重甲直购解锁+首购+选中(三层 AC 夹具)", mp5.forge_buy_unlock("heavy") and mp5.forge_buy_level("heavy") and mp5.forge_select_armor("heavy")) var adr5 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(77)) @@ -582,9 +582,9 @@ func _smoke_tree() -> void: # 购买面(V2 主干) var sv = _meta_new_save(_META_SMOKE_PATHS[8]) var mp = load("res://core/meta_progression.gd").new(_db, sv) - sv.add_keystone(1000) + sv.meta_state._add_keystone(1000) Kit.check("树购买受理(脊柱生命 L1——树全钥石单币)", - mp.tree_buy("sp_hp") and sv.tree_level("sp_hp") == 1) + mp.tree_buy("sp_hp") and sv.meta_state.tree_state().nodes.get("sp_hp", 0) == 1) Kit.check("行门槛:第 2 级不可购(总等级 1 < 4——一线一节点主语,买第 N 级需全树总投入 ≥4×(N−1))〔天赋树批 V2〕", not mp.tree_can_buy("sp_hp")) Kit.check("命中 L1/锻造钥匙(free 无前置=初始零购零币可点亮)/同调位 1(根节点无前置)受理(总等级 1→4)", @@ -592,33 +592,33 @@ func _smoke_tree() -> void: Kit.check("锻造工坊钥匙零币(free)", int(_db.tree_node("forge_workshop").get("price", -1)) == 0) Kit.check("行门槛恰达开放(总等级 4 ≥ 4×1)", mp.tree_can_buy("sp_hp")) Kit.check("第 2 级购买受理(门槛恰达当刻开放)", - mp.tree_buy("sp_hp") and sv.tree_level("sp_hp") == 2) + mp.tree_buy("sp_hp") and sv.meta_state.tree_state().nodes.get("sp_hp", 0) == 2) # Fork 一节点双态(V9) - sv.add_keystone(100) + sv.meta_state._add_keystone(100) Kit.check("Fork 购买=解锁+默认 A 态(一节点双态——单行承载)", - mp.tree_buy_fork("fork_posture") and sv.tree_level("fork_posture") == 1 and sv.fork_side("fg_posture") == "a") + mp.tree_buy_fork("fork_posture") and sv.meta_state.tree_state().nodes.get("fork_posture", 0) == 1 and sv.meta_state.tree_state().fork_sides.get("fg_posture", "") == "a") Kit.check("已解锁 Fork 重复购买拒绝(cap 1)", not mp.tree_can_buy("fork_posture")) Kit.check("同态重复切换零操作(false)", not mp.tree_toggle_fork("fg_posture", "a")) Kit.check("未解锁 Fork 切换拒绝(解锁唯一入口=购买面)", not mp.tree_toggle_fork("fg_sense", "b")) - var bal_pre_toggle: int = sv.keystone_balance() + var bal_pre_toggle: int = sv.meta_state.keystone_balance() Kit.check("零币切态 b(购买解锁后 A/B 可切)", - mp.tree_toggle_fork("fg_posture", "b") and sv.fork_side("fg_posture") == "b") - Kit.check("切态零消费(钥石余额差分不动)", int(sv.keystone_balance()) == bal_pre_toggle) + mp.tree_toggle_fork("fg_posture", "b") and sv.meta_state.tree_state().fork_sides.get("fg_posture", "") == "b") + Kit.check("切态零消费(钥石余额差分不动)", int(sv.meta_state.keystone_balance()) == bal_pre_toggle) Kit.check("规则键投影=激活态效果键(B 态稳健拓荒——tree_rule_keys 随切态换键集)", (mp.tree_rule_keys() as Array).has("posture_bulwark") and not (mp.tree_rule_keys() as Array).has("posture_first_strike")) Kit.check("切回 A 态键集随换(六键词表在册——V11 校验面归单元段)", mp.tree_toggle_fork("fg_posture", "a") and (mp.tree_rule_keys() as Array).has("posture_first_strike")) # 彩蛋特化觉醒(V8) Kit.check("彩蛋不经购买(手动购亮=红——觉醒唯一入口=特化判定)", not mp.tree_can_buy("easter_ley")) - Kit.check("觉醒前置未满足不亮(两线未拉满)", sv.tree_level("easter_ley") == 0) - sv.set_tree_level("sp_hp", 4) - sv.set_tree_level("sp_hit", 4) - sv.set_tree_level("att_socket2", 1) + Kit.check("觉醒前置未满足不亮(两线未拉满)", sv.meta_state.tree_state().nodes.get("easter_ley", 0) == 0) + sv.meta_state.set_tree_level("sp_hp", 4) + sv.meta_state.set_tree_level("sp_hit", 4) + sv.meta_state.set_tree_level("att_socket2", 1) mp.tree_buy("att_socket3") # 经购买口触发 _check_easter_egg - Kit.check("两线拉满+同调位全解锁 → 地脉共鸣自动点亮(零币特化判定)", sv.tree_level("easter_ley") == 1) - var bal_now: int = sv.keystone_balance() + Kit.check("两线拉满+同调位全解锁 → 地脉共鸣自动点亮(零币特化判定)", sv.meta_state.tree_state().nodes.get("easter_ley", 0) == 1) + var bal_now: int = sv.meta_state.keystone_balance() mp.tree_buy("sp_hp") - Kit.check("觉醒零消费(钥石余额不动)", int(sv.keystone_balance()) == bal_now) + Kit.check("觉醒零消费(钥石余额不动)", int(sv.meta_state.keystone_balance()) == bal_now) _meta_smoke_cleanup() diff --git a/game/tests/unit/core/save_boot_no_activate_test.gd b/game/tests/unit/core/save_boot_no_activate_test.gd index 7f6a60e1..9e1d6629 100644 --- a/game/tests/unit/core/save_boot_no_activate_test.gd +++ b/game/tests/unit/core/save_boot_no_activate_test.gd @@ -23,14 +23,14 @@ func _init() -> void: # ① 无参实例(autoload 实例化形态)=无激活态+默认合法空态(sv2 类级) func _test_no_arg_instance_has_no_activation() -> void: var sm := SaveManagerScript.new() - Kit.check_quiet(sm._path == "" and sm.active_slot() == 0 and not sm.has_write_target(), + Kit.check_quiet(sm._path == "" and sm.slots.active_slot() == 0 and not sm.slots.has_write_target(), "sv2 无参实例无激活态(路径空/激活号 0/写盘目标空)〔卡① sv2〕") - Kit.check_quiet(sm.keystone_balance() == 0 and not sm.has_breakpoint() - and sm.adventure_total() == 0 and sm.adventure_cleared() == 0, + Kit.check_quiet(sm.meta_state.keystone_balance() == 0 and not sm.has_breakpoint() + and sm.meta_state.career_state().adventure_total == 0 and sm.meta_state.career_state().adventure_cleared == 0, "sv2 内存=默认合法空态(钥石/断点/总局数/冒险成功数全零值——访问器 fail-safe)〔卡① sv2〕") # attach_path 注入任意隔离路径=注入即激活(契约 7 单路径语义——隔离面行为零变化) - sm.attach_path("user://smoke_boot_noact_iso.json") - Kit.check_quiet(sm.has_write_target() and sm.keystone_balance() == 0, + sm.slots.attach_path("user://smoke_boot_noact_iso.json") + Kit.check_quiet(sm.slots.has_write_target() and sm.meta_state.keystone_balance() == 0, "sv2 attach_path 注入即激活(写盘目标在位、数据回默认空态)〔卡① sv2/契约7〕") if FileAccess.file_exists("user://smoke_boot_noact_iso.json"): DirAccess.remove_absolute("user://smoke_boot_noact_iso.json") @@ -43,7 +43,7 @@ func _test_write_rejected_without_target() -> void: var sm := SaveManagerScript.new() var before := {} for i in range(1, 4): - var p: String = sm.slot_path(i) + var p: String = sm.slots.slot_path(i) before[i] = [FileAccess.file_exists(p), FileAccess.get_file_as_bytes(p) if FileAccess.file_exists(p) else PackedByteArray(), FileAccess.get_modified_time(p) if FileAccess.file_exists(p) else 0] @@ -55,7 +55,7 @@ func _test_write_rejected_without_target() -> void: "sv3 无激活写盘拒绝(write_to_disk=false)〔卡① sv3〕") var unchanged := true for i in range(1, 4): - var p: String = sm.slot_path(i) + var p: String = sm.slots.slot_path(i) var now_state: Array = [FileAccess.file_exists(p), FileAccess.get_file_as_bytes(p) if FileAccess.file_exists(p) else PackedByteArray(), FileAccess.get_modified_time(p) if FileAccess.file_exists(p) else 0] @@ -88,11 +88,11 @@ func _test_settle_chain_write_rejected() -> void: for sp in ["res://.tmp", "res://.tmp.tmp"]: if FileAccess.file_exists(sp): DirAccess.remove_absolute(sp) - Kit.check_quiet(not sm.has_write_target() and not sm.write_to_disk(), + Kit.check_quiet(not sm.slots.has_write_target() and not sm.write_to_disk(), "sv3 链前置:无激活态在位且直写被拒〔卡① sv3〕") meta.settle_adventure({"result": "failed", "cleared": false, "levels_cleared": 1, "stopped_at": 2, "keystone_total": 0, "master_seed": 3}) - Kit.check_quiet(not sm.has_write_target() and sm.adventure_total() == 1, + Kit.check_quiet(not sm.slots.has_write_target() and sm.meta_state.career_state().adventure_total == 1, "sv3 生产链:settle_adventure 落账内存(adventure_total=1)而写盘仍无目标(被拒不落盘)〔卡① sv3〕") Kit.check_quiet(not FileAccess.file_exists("res://.tmp") and not FileAccess.file_exists("res://.tmp.tmp"), @@ -102,11 +102,11 @@ func _test_settle_chain_write_rejected() -> void: # ③ 越界槽号三原语拒绝+窥视越界返回不可读(原语边界 fail-safe) func _test_out_of_range_slots_rejected() -> void: var sm := SaveManagerScript.new() - sm.attach_slot_template("user://smoke_boot_noact_%d.json") - Kit.check_quiet(not sm.activate_slot(0) and not sm.activate_slot(4) - and not sm.create_slot(0) and not sm.create_slot(4) - and not sm.delete_slot(0) and not sm.delete_slot(4), + sm.slots.attach_slot_template("user://smoke_boot_noact_%d.json") + Kit.check_quiet(not sm.slots.activate_slot(0) and not sm.slots.activate_slot(4) + and not sm.slots.create_slot(0) and not sm.slots.create_slot(4) + and not sm.slots.delete_slot(0) and not sm.slots.delete_slot(4), "原语边界:槽号 0/4 全拒绝(位数写死 3——契约 1)〔卡① sv1〕") - var out: Dictionary = sm.peek_slot_summary(4) + var out: Dictionary = sm.slots.peek_slot_summary(4) Kit.check_quiet(not bool(out.get("readable", true)), "原语边界:越界窥视返回不可读标记〔卡① sv7〕") diff --git a/game/tests/unit/core/save_manager_domain_test.gd b/game/tests/unit/core/save_manager_domain_test.gd index 8e23fd32..70ea1100 100644 --- a/game/tests/unit/core/save_manager_domain_test.gd +++ b/game/tests/unit/core/save_manager_domain_test.gd @@ -67,55 +67,55 @@ func _smoke_save() -> void: # ① 往返:全访问器写非默认值 → write → 新实例 load → 逐域逐值相等 var w = sm_script.new(paths[0]) - w.add_keystone(7) - w.set_tree_level("sp_hp", 2) - w.set_tree_level("sp_hit", 1) - w.set_fork_side("fg_posture", "b") # 树域写入 - w.set_forge_route_level("spear", 3) # 锻造工坊域写入(等级+直购解锁+选中标记) - 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, + w.meta_state._add_keystone(7) + w.meta_state.set_tree_level("sp_hp", 2) + w.meta_state.set_tree_level("sp_hit", 1) + w.meta_state.set_fork_side("fg_posture", "b") # 树域写入 + w.meta_state.set_forge_route_level("spear", 3) # 锻造工坊域写入(等级+直购解锁+选中标记) + w.meta_state.add_forge_unlock("spear") + w.meta_state.set_forge_weapon("spear") + w.meta_state.set_forge_armor("ward") + w.meta_state.unlock_item("probe_compass") + w.meta_state.apply_adventure_summary({"result": "failed", "cleared": false, "levels_cleared": 2, "stopped_at": 3, "keystone_total": 3, "master_seed": 99}) Kit.check("apply 原语入账(钥石 7+3、关卡完成次数 1/1/0)", - w.keystone_balance() == 10 and w.clear_count(1) == 1 and w.clear_count(2) == 1 and w.clear_count(3) == 0) + w.meta_state.keystone_balance() == 10 and w.meta_state.career_state().clear_counts.get(str(1), 0) == 1 and w.meta_state.career_state().clear_counts.get(str(2), 0) == 1 and w.meta_state.career_state().clear_counts.get(str(3), 0) == 0) Kit.check("首杀标记随关卡完成置位(1/2 有、3 无)", - w.boss_first_kill_marked(1) and w.boss_first_kill_marked(2) and not w.boss_first_kill_marked(3)) + w.meta_state.career_state().boss_first_kill.has(str(1)) and w.meta_state.career_state().boss_first_kill.has(str(2)) and not w.meta_state.career_state().boss_first_kill.has(str(3))) Kit.check("生涯终身计数随 apply 自增恰一(中止局:总局数 1、冒险成功局数 0——迭代 18 卡④)", - w.adventure_total() == 1 and w.adventure_cleared() == 0) + w.meta_state.career_state().adventure_total == 1 and w.meta_state.career_state().adventure_cleared == 0) Kit.check("写盘成功", w.write_to_disk()) var r = sm_script.new(paths[0]) Kit.check("读回成功", r.load_from_disk()) - Kit.check("钥石余额往返相等(10)", r.keystone_balance() == 10) + Kit.check("钥石余额往返相等(10)", r.meta_state.keystone_balance() == 10) Kit.check("树域往返相等(tree_nodes/fork_sides——迭代 21 换代承载)", - r.tree_level("sp_hp") == 2 and r.tree_level("sp_hit") == 1 and r.fork_side("fg_posture") == "b") + r.meta_state.tree_state().nodes.get("sp_hp", 0) == 2 and r.meta_state.tree_state().nodes.get("sp_hit", 0) == 1 and r.meta_state.tree_state().fork_sides.get("fg_posture", "") == "b") Kit.check("锻造工坊域往返相等(levels/unlocked/weapon/armor——迭代 23 T5)〔锻造工坊批 V8〕", - 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") - Kit.check("道具解锁往返相等", r.item_unlocked("probe_compass") and not r.item_unlocked("rope")) - Kit.check("关卡完成次数表往返相等", r.clear_count(1) == 1 and r.clear_count(2) == 1 and r.clear_count(3) == 0) + r.meta_state.forge_state().levels.get("spear", 0) == 3 and r.meta_state.forge_state().levels.get("sword", 0) == 0 + and r.meta_state.forge_state().unlocked == ["spear"] + and r.meta_state.forge_state().weapon == "spear" and r.meta_state.forge_state().armor == "ward") + Kit.check("道具解锁往返相等", r.meta_state.item_unlocked("probe_compass") and not r.meta_state.item_unlocked("rope")) + Kit.check("关卡完成次数表往返相等", r.meta_state.career_state().clear_counts.get(str(1), 0) == 1 and r.meta_state.career_state().clear_counts.get(str(2), 0) == 1 and r.meta_state.career_state().clear_counts.get(str(3), 0) == 0) Kit.check("首杀标记往返相等", - r.boss_first_kill_marked(1) and r.boss_first_kill_marked(2) and not r.boss_first_kill_marked(3)) - Kit.check("生涯终身计数往返相等(1/0)", r.adventure_total() == 1 and r.adventure_cleared() == 0) - var s0: Dictionary = r.recent_summaries()[0] + r.meta_state.career_state().boss_first_kill.has(str(1)) and r.meta_state.career_state().boss_first_kill.has(str(2)) and not r.meta_state.career_state().boss_first_kill.has(str(3))) + Kit.check("生涯终身计数往返相等(1/0)", r.meta_state.career_state().adventure_total == 1 and r.meta_state.career_state().adventure_cleared == 0) + var s0: Dictionary = r.meta_state.career_state().recent_summaries[0] Kit.check("冒险摘要往返相等(1 条,seed 99)", - r.recent_summaries().size() == 1 and int(s0.get("master_seed", -1)) == 99 and int(s0.get("keystone_total", -1)) == 3) + r.meta_state.career_state().recent_summaries.size() == 1 and int(s0.get("master_seed", -1)) == 99 and int(s0.get("keystone_total", -1)) == 3) # ② 新档合法:不存在路径 load → true + 全零默认值 var f2 = sm_script.new(paths[1]) Kit.check("新档合法态(load true + 全零默认)", - f2.load_from_disk() and f2.keystone_balance() == 0 and f2.tree_level("sp_hp") == 0 - and not f2.item_unlocked("anything") and f2.clear_count(1) == 0 and f2.recent_summaries().is_empty()) + f2.load_from_disk() and f2.meta_state.keystone_balance() == 0 and f2.meta_state.tree_state().nodes.get("sp_hp", 0) == 0 + and not f2.meta_state.item_unlocked("anything") and f2.meta_state.career_state().clear_counts.get(str(1), 0) == 0 and f2.meta_state.career_state().recent_summaries.is_empty()) Kit.check("新档生涯终身计数缺省全零(迭代 18 卡④两新域——白板存档完整可玩)", - f2.adventure_total() == 0 and f2.adventure_cleared() == 0) + f2.meta_state.career_state().adventure_total == 0 and f2.meta_state.career_state().adventure_cleared == 0) # ③ spend 拒绝:余额不足 false 且零副作用(钥石只增不减——消费除外) var f3 = sm_script.new(paths[2]) - f3.add_keystone(5) - Kit.check("余额 5 花 8 拒绝且余额不动", not f3.spend_keystone(8) and f3.keystone_balance() == 5) - Kit.check("余额内消费成功(5→2)", f3.spend_keystone(3) and f3.keystone_balance() == 2) + f3.meta_state._add_keystone(5) + Kit.check("余额 5 花 8 拒绝且余额不动", not f3.meta_state.spend_keystone(8) and f3.meta_state.keystone_balance() == 5) + Kit.check("余额内消费成功(5→2)", f3.meta_state.spend_keystone(3) and f3.meta_state.keystone_balance() == 2) # ⑤ 高版本拒读:load false、原文件字节不动、后续写盘拒绝且文件仍字节不动(契约 4) var hv_text := "{\"version\": 99, \"meta\": {\"keystone_balance\": 1}}" @@ -135,7 +135,7 @@ func _smoke_save() -> void: fj.close() var f6 = sm_script.new(paths[4]) Kit.check("损坏档兜底(默认数据继续)", - f6.load_from_disk() and f6.keystone_balance() == 0 and f6.recent_summaries().is_empty()) + f6.load_from_disk() and f6.meta_state.keystone_balance() == 0 and f6.meta_state.career_state().recent_summaries.is_empty()) Kit.check("坏档备份 .corrupt.bak 存在", FileAccess.file_exists(paths[4] + ".corrupt.bak")) Kit.check("备份内容 = 原坏档字节", FileAccess.open(paths[4] + ".corrupt.bak", FileAccess.READ).get_as_text() == junk) @@ -149,7 +149,7 @@ func _smoke_save() -> void: fm.store_string(JSON.stringify({"version": 0, "keystone_old": 12})) fm.close() Kit.check("v0 旧档经迁移链入账(keystone_old 12 → 余额 12)", - f7.load_from_disk() and f7.keystone_balance() == 12) + f7.load_from_disk() and f7.meta_state.keystone_balance() == 12) Kit.check("迁移链 0→1(测试钩)→7(生产) 写盘 version == 7(v7=锻造工坊域,迭代 23 T5——v6=生涯终身计数域迭代 18 卡④)", f7.write_to_disk() and int(JSON.parse_string(FileAccess.open(paths[5], FileAccess.READ).get_as_text()).get("version", -1)) == 7) @@ -167,9 +167,9 @@ func _smoke_save() -> void: # ⑩ 摘要 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, + f10.meta_state.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() + var arr: Array = f10.meta_state.career_state().recent_summaries Kit.check("摘要 cap 10(12 份 → 10 条)", arr.size() == 10) Kit.check("最新在前(头 seed 11 / 尾 seed 2)", int(arr[0].get("master_seed", -1)) == 11 and int(arr[9].get("master_seed", -1)) == 2) @@ -227,7 +227,7 @@ func _smoke_save() -> void: fold.store_string("{\"version\": 1, \"meta\": {\"keystone_balance\": 4}}") fold.close() Kit.check("v1 旧档迁移 v7:断点域补空(合法空态,无断点域不炸)", - fo.load_from_disk() and fo.keystone_balance() == 4 and not fo.has_breakpoint()) + fo.load_from_disk() and fo.meta_state.keystone_balance() == 4 and not fo.has_breakpoint()) Kit.check("v1 旧档迁移后写盘 version == 7(v1→v7 链式——锻造工坊域 2026-10-12)", fo.write_to_disk() and int(JSON.parse_string(FileAccess.open(paths[9], FileAccess.READ).get_as_text()).get("version", -1)) == 7) @@ -241,7 +241,7 @@ func _smoke_save() -> void: "visited_ids": []}})) fv2f.close() Kit.check("v2 旧档迁移 v3:断点域与 meta 域原样保留(缺新附随件=合法空态)", - fv2.load_from_disk() and fv2.has_breakpoint() and fv2.keystone_balance() == 7) + fv2.load_from_disk() and fv2.has_breakpoint() and fv2.meta_state.keystone_balance() == 7) Kit.check("v2 旧断点字段零丢失(向后兼容——breakpoint 内容不因 schema 升版重写)", int((fv2.breakpoint_snapshot() as Dictionary).get("master_seed", 0)) == 777) var adr_v2 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(777)) @@ -255,22 +255,22 @@ func _smoke_save() -> void: fv5u.store_string(JSON.stringify({"version": 5, "meta": {"keystone_balance": 9}})) fv5u.close() Kit.check("v5 旧档升 v7:计数域缺省补零且既有余额原样(迁移只增字段不改值——v5→v6→v7 链式)", - f13.load_from_disk() and f13.adventure_total() == 0 and f13.adventure_cleared() == 0 - and f13.keystone_balance() == 9) + f13.load_from_disk() and f13.meta_state.career_state().adventure_total == 0 and f13.meta_state.career_state().adventure_cleared == 0 + and f13.meta_state.keystone_balance() == 9) Kit.check("升档幂等:v7 档写盘再载零漂移(重复迁移零副作用——锻造工坊域同批补齐)", 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)) == 7 - and f13.adventure_total() == 0 and f13.adventure_cleared() == 0) + and f13.meta_state.career_state().adventure_total == 0 and f13.meta_state.career_state().adventure_cleared == 0) var f13b = sm_script.new(paths[12]) f13b.load_from_disk() - f13b.apply_adventure_summary({"result": "completed", "cleared": true, "levels_cleared": 3, + f13b.meta_state.apply_adventure_summary({"result": "completed", "cleared": true, "levels_cleared": 3, "stopped_at": 0, "keystone_total": 0, "master_seed": 1}) Kit.check("收束自增恰一·冒险成功局(三关全通 cleared=true:总局数+1 且冒险成功局数+1)", - f13b.adventure_total() == 1 and f13b.adventure_cleared() == 1) - f13b.apply_adventure_summary({"result": "abandoned", "cleared": false, "levels_cleared": 1, + f13b.meta_state.career_state().adventure_total == 1 and f13b.meta_state.career_state().adventure_cleared == 1) + f13b.meta_state.apply_adventure_summary({"result": "abandoned", "cleared": false, "levels_cleared": 1, "stopped_at": 2, "keystone_total": 0, "master_seed": 2}) Kit.check("收束自增恰一·中止局(放弃局:仅总局数+1,冒险成功局数不动)", - f13b.adventure_total() == 2 and f13b.adventure_cleared() == 1) + f13b.meta_state.career_state().adventure_total == 2 and f13b.meta_state.career_state().adventure_cleared == 1) # ⑮ 旧档破坏性废弃(契约 4:换代前结构检测=树域废弃重置,禁迁移禁折算)〔V16〕 var f15 = sm_script.new(paths[13]) @@ -281,12 +281,12 @@ func _smoke_save() -> void: "fork_sides": {"fg_posture": "a", "fg_mastery": "b"}}})) fv16.close() Kit.check("换代前结构存档:树域废弃重置(旧节点 id/fork 侧/forge_levels 旧域残留清退)、钥石余额零折算零变动", - f15.load_from_disk() and f15.keystone_balance() == 33 - and f15.tree_nodes().is_empty() and f15.fork_sides().is_empty()) + f15.load_from_disk() and f15.meta_state.keystone_balance() == 33 + and f15.meta_state.tree_state().nodes.is_empty() and f15.meta_state.tree_state().fork_sides.is_empty()) var mp16 = load("res://core/meta_progression.gd").new(_db, f15) - f15.add_keystone(50) + f15.meta_state._add_keystone(50) Kit.check("废弃重置后后续购买正常受理(无残留脏节点)", - mp16.tree_buy("sp_hp") and f15.tree_level("sp_hp") == 1) + mp16.tree_buy("sp_hp") and f15.meta_state.tree_state().nodes.get("sp_hp", 0) == 1) # ⑯ 锻造工坊域 forge_routes(V8):v6 旧档缺键补齐=初始持有态+无特效域+域界干净 var f16 = sm_script.new(paths[14]) @@ -295,26 +295,26 @@ func _smoke_save() -> void: "tree_nodes": {"sp_hp": 1}, "tree_struct": 2, "adventure_total": 4, "adventure_cleared": 1}})) fv6.close() Kit.check("v6 旧档升 v7:既有域逐位不动(钥石/树/生涯计数零折算——迁移只增不改)〔锻造工坊批 V8〕", - 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) + f16.load_from_disk() and f16.meta_state.keystone_balance() == 21 + and f16.meta_state.tree_state().nodes.get("sp_hp", 0) == 1 and f16.meta_state.career_state().adventure_total == 4 and f16.meta_state.career_state().adventure_cleared == 1) Kit.check("v6 旧档缺键补齐=初始持有态(六路线 0 级/直购清单空/选中=sword+light)〔锻造工坊批 V8〕", - 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") - var fr_keys: Array = (f16.forge_routes_snapshot() as Dictionary).keys() + f16.meta_state.forge_state().levels.get("sword", 0) == 0 and f16.meta_state.forge_state().levels.get("spear", 0) == 0 + and f16.meta_state.forge_state().levels.get("light", 0) == 0 and f16.meta_state.forge_state().levels.get("ward", 0) == 0 + and f16.meta_state.forge_state().unlocked.is_empty() + and f16.meta_state.forge_state().weapon == "sword" and f16.meta_state.forge_state().armor == "light") + var fr_keys: Array = (f16.meta_state.forge_state() as Dictionary).keys() fr_keys.sort() Kit.check("forge_routes 域键集恰四项(无特效域——满级特效本期不做,契约 7)〔锻造工坊批 V8/V11〕", fr_keys == ["armor", "levels", "unlocked", "weapon"]) - var lv_keys: Array = ((f16.forge_routes_snapshot() as Dictionary).get("levels", {}) as Dictionary).keys() + var lv_keys: Array = ((f16.meta_state.forge_state() as Dictionary).get("levels", {}) as Dictionary).keys() lv_keys.sort() Kit.check("levels 六路线键集恰全(sword/spear/staff/light/heavy/ward)〔锻造工坊批 V8〕", lv_keys == ["heavy", "light", "spear", "staff", "sword", "ward"]) # 值拷贝出户(改快照不渗档——本类契约) - var snap: Dictionary = f16.forge_routes_snapshot() + var snap: Dictionary = f16.meta_state.forge_state() (snap.get("levels", {}) as Dictionary)["sword"] = 99 Kit.check("forge_routes_snapshot 值拷贝出户(改快照不渗档)〔锻造工坊批 V8〕", - f16.forge_route_level("sword") == 0) + f16.meta_state.forge_state().levels.get("sword", 0) == 0) # ⑭ 收束链入账(真实收束挂点全链恰一:主流程 adventure_finished → # MetaProgression.settle_adventure → apply_adventure_summary 单口;两夹具经同链入账) @@ -322,11 +322,11 @@ func _smoke_save() -> void: mp13.settle_adventure({"result": "failed", "cleared": false, "levels_cleared": 2, "stopped_at": 3, "keystone_total": 0, "master_seed": 3}) Kit.check("收束链自增·中止局(settle_adventure 真实挂点:总局数+1、冒险成功局数不动)", - f13b.adventure_total() == 3 and f13b.adventure_cleared() == 1) + f13b.meta_state.career_state().adventure_total == 3 and f13b.meta_state.career_state().adventure_cleared == 1) mp13.settle_adventure({"result": "completed", "cleared": true, "levels_cleared": 3, "stopped_at": 0, "keystone_total": 0, "master_seed": 4}) Kit.check("收束链自增·冒险成功局(三关全通:总局数+1 且冒险成功局数+1——全链恰一)", - f13b.adventure_total() == 4 and f13b.adventure_cleared() == 2) + f13b.meta_state.career_state().adventure_total == 4 and f13b.meta_state.career_state().adventure_cleared == 2) # 段尾清理(测试文件绝不残留) for p in stale: @@ -352,39 +352,39 @@ func _smoke_save_slots() -> void: DirAccess.remove_absolute(p) var t = sm_script.new() - t.attach_slot_template(slot_tpl) + t.slots.attach_slot_template(slot_tpl) # ① 三槽往返×3+位间隔离+进档(sv1/sv5/sv10) for i in range(1, 4): - Kit.check("sv5 进档受理·槽 %d(激活+读盘)〔卡① sv5〕" % i, t.activate_slot(i)) - t.apply_adventure_summary({"result": "completed", "cleared": i == 1, "levels_cleared": i, + Kit.check("sv5 进档受理·槽 %d(激活+读盘)〔卡① sv5〕" % i, t.slots.activate_slot(i)) + t.meta_state.apply_adventure_summary({"result": "completed", "cleared": i == 1, "levels_cleared": i, "stopped_at": 0, "keystone_total": 100 * i, "master_seed": i}) Kit.check("sv3 激活态写盘受理·槽 %d〔卡① sv1〕" % i, t.write_to_disk()) var t2 = sm_script.new() - t2.attach_slot_template(slot_tpl) + t2.slots.attach_slot_template(slot_tpl) for i in range(1, 4): Kit.check("sv1 三槽往返一致·槽 %d(异值按位读回)〔卡① sv1〕" % i, - t2.activate_slot(i) and t2.keystone_balance() == 100 * i - and t2.adventure_total() == 1 and t2.adventure_cleared() == (1 if i == 1 else 0)) + t2.slots.activate_slot(i) and t2.meta_state.keystone_balance() == 100 * i + and t2.meta_state.career_state().adventure_total == 1 and t2.meta_state.career_state().adventure_cleared == (1 if i == 1 else 0)) var slot2_before := FileAccess.get_file_as_bytes(slot_tpl % 2) - t.activate_slot(1) - t.add_keystone(7) + t.slots.activate_slot(1) + t.meta_state._add_keystone(7) Kit.check("sv3 写盘受理·槽 1 加值后〔卡① sv1〕", t.write_to_disk()) Kit.check("sv1 位间隔离:改槽 1 槽 2 字节不动〔卡① sv1〕", FileAccess.get_file_as_bytes(slot_tpl % 2) == slot2_before) # ② 删档原语(sv6):删激活槽(指针连清除)→ 删非激活槽(指针保持+他槽字节不动) - Kit.check("sv5 切激活至槽 3(删激活槽夹具前置)〔卡① sv5〕", t.activate_slot(3)) - Kit.check("sv6 删档受理·激活槽 3〔卡① sv6〕", t.delete_slot(3)) + Kit.check("sv5 切激活至槽 3(删激活槽夹具前置)〔卡① sv5〕", t.slots.activate_slot(3)) + Kit.check("sv6 删档受理·激活槽 3〔卡① sv6〕", t.slots.delete_slot(3)) Kit.check("sv6 删后文件不在盘+无 .tmp 残留〔卡① sv6〕", not FileAccess.file_exists(slot_tpl % 3) and not FileAccess.file_exists(slot_tpl % 3 + ".tmp")) Kit.check("sv6 删激活槽激活指针连清除(该槽不再可写)〔卡① sv6〕", - t.active_slot() == 0 and not t.has_write_target()) + t.slots.active_slot() == 0 and not t.slots.has_write_target()) var slot1_before := FileAccess.get_file_as_bytes(slot_tpl % 1) - t.activate_slot(1) - Kit.check("sv6 删非激活槽受理·槽 2〔卡① sv6〕", t.delete_slot(2)) + t.slots.activate_slot(1) + Kit.check("sv6 删非激活槽受理·槽 2〔卡① sv6〕", t.slots.delete_slot(2)) Kit.check("sv6 删非激活槽:文件不在盘+激活指针保持〔卡① sv6〕", - not FileAccess.file_exists(slot_tpl % 2) and t.active_slot() == 1) + not FileAccess.file_exists(slot_tpl % 2) and t.slots.active_slot() == 1) Kit.check("sv6 删槽 2 槽 1 字节不动(位间互隔离)〔卡① sv6〕", FileAccess.get_file_as_bytes(slot_tpl % 1) == slot1_before) @@ -394,10 +394,10 @@ func _smoke_save_slots() -> void: fold.store_string(JSON.stringify({"version": 7, "meta": {"keystone_balance": 999, "adventure_total": 5, "adventure_cleared": 2, "tree_nodes": {}, "fork_sides": {}, "tree_struct": 2}})) fold.close() - Kit.check("sv4 新档受理·槽 3(创建即激活)〔卡① sv4〕", t.create_slot(3)) + Kit.check("sv4 新档受理·槽 3(创建即激活)〔卡① sv4〕", t.slots.create_slot(3)) Kit.check("sv4 新档=全零可玩合法白板态(旧内容 999/5 零带入——钥石/总局数/断点全零)〔卡① sv4〕", - t.active_slot() == 3 and t.keystone_balance() == 0 and not t.has_breakpoint() - and t.adventure_total() == 0) + t.slots.active_slot() == 3 and t.meta_state.keystone_balance() == 0 and not t.has_breakpoint() + and t.meta_state.career_state().adventure_total == 0) var created: Dictionary = JSON.parse_string(FileAccess.get_file_as_string(slot_tpl % 3)) Kit.check("sv4 新档落盘=白板内容(盘面核验非 999 旧值)〔卡① sv4〕", int((created.get("meta", {}) as Dictionary).get("keystone_balance", -1)) == 0) @@ -407,14 +407,14 @@ func _smoke_save_slots() -> void: fhv.store_string(JSON.stringify({"version": 99, "meta": {}})) fhv.close() var hv = sm_script.new() - hv.attach_slot_template(slot_tpl) + hv.slots.attach_slot_template(slot_tpl) Kit.check("sv5 进档槽 1 读回(107)〔卡① sv10〕", - hv.activate_slot(1) and hv.keystone_balance() == 107) + hv.slots.activate_slot(1) and hv.meta_state.keystone_balance() == 107) var slot2_hv := FileAccess.get_file_as_bytes(slot_tpl % 2) - Kit.check("sv9 高版本槽进档拒绝(拒读禁写链按槽)〔卡① sv9〕", not hv.activate_slot(2)) + Kit.check("sv9 高版本槽进档拒绝(拒读禁写链按槽)〔卡① sv9〕", not hv.slots.activate_slot(2)) Kit.check("sv10 激活失败恢复切换前三态(路径/激活号/数据归槽 1)〔卡① sv10〕", - hv.active_slot() == 1 and hv.keystone_balance() == 107) - hv.add_keystone(1) + hv.slots.active_slot() == 1 and hv.meta_state.keystone_balance() == 107) + hv.meta_state._add_keystone(1) Kit.check("sv10 恢复后写盘落激活槽 1(高版本槽 2 零误写)〔卡① sv10〕", hv.write_to_disk() and FileAccess.get_file_as_bytes(slot_tpl % 2) == slot2_hv) @@ -425,14 +425,14 @@ func _smoke_save_slots() -> void: fm.close() for i in range(1, 4): var mi = sm_script.new() - mi.attach_slot_template(slot_tpl) + mi.slots.attach_slot_template(slot_tpl) Kit.check("sv9 迁移链按槽成立·槽 %d(v5→v7 各槽独立+写盘 version=7)〔卡① sv9〕" % i, - mi.activate_slot(i) and mi.keystone_balance() == i * 11 + mi.slots.activate_slot(i) and mi.meta_state.keystone_balance() == i * 11 and mi.write_to_disk()) # ⑥ 窥视三态零副作用(sv7/sv8):异值三槽逐槽三项+空槽零值+坏/高版本字节 mtime 双比对 var pv = sm_script.new() - pv.attach_slot_template(slot_tpl) + pv.slots.attach_slot_template(slot_tpl) for i in range(1, 4): var fp := FileAccess.open(slot_tpl % i, FileAccess.WRITE) fp.store_string(JSON.stringify({"version": 7, "meta": {"keystone_balance": 200 * i, @@ -440,12 +440,12 @@ func _smoke_save_slots() -> void: "tree_nodes": {}, "fork_sides": {}, "tree_struct": 2}})) fp.close() for i in range(1, 4): - var ps: Dictionary = pv.peek_slot_summary(i) + var ps: Dictionary = pv.slots.peek_slot_summary(i) Kit.check("sv7 窥视三项逐槽正确·槽 %d〔卡① sv7〕" % i, bool(ps.get("readable", false)) and int(ps.get("keystone", -1)) == 200 * i and int(ps.get("cleared", -1)) == i and int(ps.get("total", -1)) == 5 * i) - Kit.check("sv6 删槽 3 备空槽窥视〔卡① sv6〕", pv.delete_slot(3)) - var pe: Dictionary = pv.peek_slot_summary(3) + Kit.check("sv6 删槽 3 备空槽窥视〔卡① sv6〕", pv.slots.delete_slot(3)) + var pe: Dictionary = pv.slots.peek_slot_summary(3) Kit.check("sv7 空槽窥视=零值合法态〔卡① sv7〕", bool(pe.get("readable", false)) and int(pe.get("keystone", -1)) == 0 and int(pe.get("cleared", -1)) == 0 and int(pe.get("total", -1)) == 0) @@ -454,7 +454,7 @@ func _smoke_save_slots() -> void: fbad.close() var bad_bytes := FileAccess.get_file_as_bytes(slot_tpl % 1) var bad_mtime := FileAccess.get_modified_time(slot_tpl % 1) - var pb: Dictionary = pv.peek_slot_summary(1) + var pb: Dictionary = pv.slots.peek_slot_summary(1) Kit.check("sv8 坏槽窥视=显式损坏标记〔卡① sv8〕", not bool(pb.get("readable", true))) Kit.check("sv8 坏槽窥视零副作用(字节+mtime 双比对不变+零 .corrupt.bak 改名)〔卡① sv8〕", FileAccess.get_file_as_bytes(slot_tpl % 1) == bad_bytes @@ -465,7 +465,7 @@ func _smoke_save_slots() -> void: fhv2.close() var hv_bytes := FileAccess.get_file_as_bytes(slot_tpl % 2) var hv_mtime := FileAccess.get_modified_time(slot_tpl % 2) - var ph: Dictionary = pv.peek_slot_summary(2) + var ph: Dictionary = pv.slots.peek_slot_summary(2) Kit.check("sv8 高版本槽窥视=不可读标记〔卡① sv8〕", not bool(ph.get("readable", true))) Kit.check("sv8 高版本窥视零副作用(字节+mtime 不变+零改名)〔卡① sv8〕", FileAccess.get_file_as_bytes(slot_tpl % 2) == hv_bytes @@ -473,7 +473,7 @@ func _smoke_save_slots() -> void: # ⑦ 旧单档迁移(sv12):字节搬运+源改名留痕+幂等+他槽零触碰 var ml = sm_script.new() - ml.attach_slot_template(slot_tpl) + ml.slots.attach_slot_template(slot_tpl) var fsrc := FileAccess.open(legacy_src, FileAccess.WRITE) fsrc.store_string(JSON.stringify({"version": 7, "meta": {"keystone_balance": 1234}})) fsrc.close() diff --git a/game/tests/unit/core/save_slot_isolation_test.gd b/game/tests/unit/core/save_slot_isolation_test.gd index 20cb7260..3dc1d017 100644 --- a/game/tests/unit/core/save_slot_isolation_test.gd +++ b/game/tests/unit/core/save_slot_isolation_test.gd @@ -39,46 +39,46 @@ func _write_v7_keystone(path: String, keystone: int) -> void: # ① 三槽往返:各槽写异值 → 写盘 → 新实例按位读回逐域相等;改槽 1 槽 2 字节不动(sv1) func _test_three_slot_roundtrip_and_isolation() -> void: var t := SaveManagerScript.new() - t.attach_slot_template(TPL) + t.slots.attach_slot_template(TPL) for i in range(1, 4): - Kit.check_quiet(t.activate_slot(i), "sv5 进档受理·槽 %d〔卡① sv5〕" % i) - t.add_keystone(100 * i) - t.apply_adventure_summary({"result": "completed", "cleared": i == 1, "levels_cleared": i, + Kit.check_quiet(t.slots.activate_slot(i), "sv5 进档受理·槽 %d〔卡① sv5〕" % i) + t.meta_state._add_keystone(100 * i) + t.meta_state.apply_adventure_summary({"result": "completed", "cleared": i == 1, "levels_cleared": i, "stopped_at": 0, "keystone_total": 0, "master_seed": i}) Kit.check_quiet(t.write_to_disk(), "sv3 激活态写盘受理·槽 %d〔卡① sv1〕" % i) var t2 := SaveManagerScript.new() - t2.attach_slot_template(TPL) + t2.slots.attach_slot_template(TPL) for i in range(1, 4): - Kit.check_quiet(t2.activate_slot(i) and t2.keystone_balance() == 100 * i - and t2.adventure_total() == 1 and t2.adventure_cleared() == (1 if i == 1 else 0), + Kit.check_quiet(t2.slots.activate_slot(i) and t2.meta_state.keystone_balance() == 100 * i + and t2.meta_state.career_state().adventure_total == 1 and t2.meta_state.career_state().adventure_cleared == (1 if i == 1 else 0), "sv1 三槽往返一致·槽 %d(异值按位读回)〔卡① sv1〕" % i) var slot2_before := FileAccess.get_file_as_bytes(TPL % 2) - t.activate_slot(1) - t.add_keystone(7) + t.slots.activate_slot(1) + t.meta_state._add_keystone(7) Kit.check_quiet(t.write_to_disk(), "sv3 写盘受理·槽 1 加值后〔卡① sv1〕") Kit.check_quiet(FileAccess.get_file_as_bytes(TPL % 2) == slot2_before, "sv1 位间隔离:改槽 1 槽 2 字节不动〔卡① sv1〕") # 激活号记账:activate 槽派生名后 active_slot 同步;attach_path 注入槽名亦然 - Kit.check_quiet(t.active_slot() == 1 and t.has_write_target(), + Kit.check_quiet(t.slots.active_slot() == 1 and t.slots.has_write_target(), "sv1 activate 后激活号同步(active_slot=1 写盘目标在位)〔卡① sv1〕") # ② 高版本槽进档失败:拒绝+恢复切换前三态(路径/激活号/数据)+拒读槽零误写(sv9/sv10) func _test_activate_failure_restores_previous_slot() -> void: var t := SaveManagerScript.new() - t.attach_slot_template(TPL) + t.slots.attach_slot_template(TPL) _write_v7_keystone(TPL % 1, 311) _write_v7_keystone(TPL % 2, 422) var fhv := FileAccess.open(TPL % 3, FileAccess.WRITE) fhv.store_string(JSON.stringify({"version": 99, "meta": {}})) fhv.close() - Kit.check_quiet(t.activate_slot(1) and t.keystone_balance() == 311, + Kit.check_quiet(t.slots.activate_slot(1) and t.meta_state.keystone_balance() == 311, "sv5 进档槽 1 读回(311)〔卡① sv10〕") var slot2_before := FileAccess.get_file_as_bytes(TPL % 2) - Kit.check_quiet(not t.activate_slot(3), "sv9 高版本槽进档拒绝(拒读链按槽)〔卡① sv9〕") - Kit.check_quiet(t.active_slot() == 1 and t.has_write_target() and t.keystone_balance() == 311, + Kit.check_quiet(not t.slots.activate_slot(3), "sv9 高版本槽进档拒绝(拒读链按槽)〔卡① sv9〕") + Kit.check_quiet(t.slots.active_slot() == 1 and t.slots.has_write_target() and t.meta_state.keystone_balance() == 311, "sv10 激活失败恢复切换前三态(路径/激活号/数据归槽 1)〔卡① sv10〕") - t.add_keystone(1) + t.meta_state._add_keystone(1) Kit.check_quiet(t.write_to_disk(), "sv3 恢复后写盘受理〔卡① sv10〕") Kit.check_quiet(FileAccess.get_file_as_bytes(TPL % 2) == slot2_before, "sv10 拒读槽零误写(写盘落激活槽 1,槽 2/3 字节不动)〔卡① sv10〕") @@ -87,17 +87,17 @@ func _test_activate_failure_restores_previous_slot() -> void: # ③ MetaProgression 宿主链切槽:引用同一性保持+写盘落当前槽(sv10——禁换实例先例) func _test_metaprogression_reference_identity() -> void: var t := SaveManagerScript.new() - t.attach_slot_template(TPL) + t.slots.attach_slot_template(TPL) _write_v7_keystone(TPL % 1, 533) _write_v7_keystone(TPL % 2, 644) - Kit.check_quiet(t.activate_slot(1), "sv5 进档槽 1〔卡① sv10〕") + Kit.check_quiet(t.slots.activate_slot(1), "sv5 进档槽 1〔卡① sv10〕") var meta := MetaProgressionScript.new(null, t) Kit.check_quiet(meta.save == t and meta.keystone_balance() == 533, "sv10 宿主链取数走动态分发(meta.save 同一实例、读槽 1)〔卡① sv10〕") - Kit.check_quiet(t.activate_slot(2) and meta.save == t and meta.keystone_balance() == 644, + Kit.check_quiet(t.slots.activate_slot(2) and meta.save == t and meta.keystone_balance() == 644, "sv10 切槽后引用同一性保持(零换实例)且读数随槽(644)〔卡① sv10〕") - meta.save.add_keystone(6) - Kit.check_quiet(t.write_to_disk() and t.keystone_balance() == 650, + meta.save.meta_state._add_keystone(6) + Kit.check_quiet(t.write_to_disk() and t.meta_state.keystone_balance() == 650, "sv10 宿主链写盘落当前槽 2(650=644+6)〔卡① sv10〕") Kit.check_quiet(int((JSON.parse_string(FileAccess.get_file_as_string(TPL % 2)) as Dictionary)\ .get("meta", {}).get("keystone_balance", -1)) == 650, diff --git a/game/tests/unit/core/save_slot_summary_test.gd b/game/tests/unit/core/save_slot_summary_test.gd index ae0be20e..468045c9 100644 --- a/game/tests/unit/core/save_slot_summary_test.gd +++ b/game/tests/unit/core/save_slot_summary_test.gd @@ -37,20 +37,20 @@ func _write_slot_json(path: String, content: String) -> void: # ① 三槽异值逐槽三项正确+不激活不写内存(sv7) func _test_peek_three_slots_distinct_values() -> void: var t := SaveManagerScript.new() - t.attach_slot_template(TPL) + t.slots.attach_slot_template(TPL) for i in range(1, 4): _write_slot_json(TPL % i, JSON.stringify({"version": 7, "meta": { "keystone_balance": 200 * i, "adventure_total": 5 * i, "adventure_cleared": i, "tree_nodes": {}, "fork_sides": {}, "tree_struct": 2}})) for i in range(1, 4): - var ps: Dictionary = t.peek_slot_summary(i) + var ps: Dictionary = t.slots.peek_slot_summary(i) Kit.check_quiet(bool(ps.get("readable", false)) and int(ps.get("keystone", -1)) == 200 * i and int(ps.get("cleared", -1)) == i and int(ps.get("total", -1)) == 5 * i, "sv7 窥视三项逐槽正确·槽 %d(钥石/冒险成功/总局)〔卡① sv7〕" % i) - Kit.check_quiet(t.active_slot() == 0 and not t.has_write_target() and t.keystone_balance() == 0, + Kit.check_quiet(t.slots.active_slot() == 0 and not t.slots.has_write_target() and t.meta_state.keystone_balance() == 0, "sv7 窥视不激活不写内存(激活号 0/无写盘目标/内存余额零)〔卡① sv7〕") # 路径注入口同语义(契约 7 注入面) - var pinj: Dictionary = t.peek_summary_at(TPL % 2) + var pinj: Dictionary = t.slots.peek_summary_at(TPL % 2) Kit.check_quiet(bool(pinj.get("readable", false)) and int(pinj.get("keystone", -1)) == 400, "sv7 peek_summary_at 注入口同语义(槽 2 钥石 400)〔卡① sv7〕") @@ -58,32 +58,32 @@ func _test_peek_three_slots_distinct_values() -> void: # ② 空槽零值合法态+不可读三态显式标记(sv7/契约 9) func _test_peek_empty_and_unreadable_states() -> void: var t := SaveManagerScript.new() - t.attach_slot_template(TPL) + t.slots.attach_slot_template(TPL) DirAccess.remove_absolute(TPL % 3) # 清①段残留——空槽夹具 - var pe: Dictionary = t.peek_slot_summary(3) # 槽 3 无文件=空槽 + var pe: Dictionary = t.slots.peek_slot_summary(3) # 槽 3 无文件=空槽 Kit.check_quiet(bool(pe.get("readable", false)) and int(pe.get("keystone", -1)) == 0 and int(pe.get("cleared", -1)) == 0 and int(pe.get("total", -1)) == 0, "sv7 空槽窥视=零值合法态(可读+三项全零)〔卡① sv7〕") _write_slot_json(TPL % 1, "this is not json{{{") - Kit.check_quiet(not bool(t.peek_slot_summary(1).get("readable", true)), + Kit.check_quiet(not bool(t.slots.peek_slot_summary(1).get("readable", true)), "sv7 坏槽窥视=显式损坏标记〔卡① sv7〕") _write_slot_json(TPL % 2, JSON.stringify({"version": 99, "meta": {}})) - Kit.check_quiet(not bool(t.peek_slot_summary(2).get("readable", true)), + Kit.check_quiet(not bool(t.slots.peek_slot_summary(2).get("readable", true)), "sv7 高版本槽窥视=不可读标记〔卡① sv7〕") _write_slot_json(TPL % 3, JSON.stringify({"meta": {"keystone_balance": 5}})) # 缺版本字段 - Kit.check_quiet(not bool(t.peek_slot_summary(3).get("readable", true)), + Kit.check_quiet(not bool(t.slots.peek_slot_summary(3).get("readable", true)), "sv7 缺版本字段槽窥视=不可读标记〔卡① sv7/契约9〕") # ③ 窥视零副作用:字节+mtime 双比对不变+零 .corrupt.bak 改名(sv8——真实入口冒烟段同判据) func _test_peek_zero_side_effect() -> void: var t := SaveManagerScript.new() - t.attach_slot_template(TPL) + t.slots.attach_slot_template(TPL) _write_slot_json(TPL % 1, "corrupt bytes {{{{") var bytes_before := FileAccess.get_file_as_bytes(TPL % 1) var mtime_before := FileAccess.get_modified_time(TPL % 1) - t.peek_slot_summary(1) - t.peek_summary_at(TPL % 1) + t.slots.peek_slot_summary(1) + t.slots.peek_summary_at(TPL % 1) Kit.check_quiet(FileAccess.get_file_as_bytes(TPL % 1) == bytes_before and FileAccess.get_modified_time(TPL % 1) == mtime_before and not FileAccess.file_exists(TPL % 1 + ".corrupt.bak"), @@ -91,7 +91,7 @@ func _test_peek_zero_side_effect() -> void: _write_slot_json(TPL % 2, JSON.stringify({"version": 99, "meta": {}})) var hv_bytes := FileAccess.get_file_as_bytes(TPL % 2) var hv_mtime := FileAccess.get_modified_time(TPL % 2) - t.peek_slot_summary(2) + t.slots.peek_slot_summary(2) Kit.check_quiet(FileAccess.get_file_as_bytes(TPL % 2) == hv_bytes and FileAccess.get_modified_time(TPL % 2) == hv_mtime and not FileAccess.file_exists(TPL % 2 + ".corrupt.bak"), @@ -99,7 +99,7 @@ func _test_peek_zero_side_effect() -> void: # .tmp 残留按空槽处理(契约 9) DirAccess.remove_absolute(TPL % 3) # 主文件清场——只留 .tmp 残留 _write_slot_json(TPL % 3 + ".tmp", "{}") - var pt: Dictionary = t.peek_slot_summary(3) + var pt: Dictionary = t.slots.peek_slot_summary(3) Kit.check_quiet(bool(pt.get("readable", false)) and int(pt.get("keystone", -1)) == 0, "sv8 .tmp 残留按空槽处理(主文件不在=空槽零值)〔卡① sv8〕") if FileAccess.file_exists(TPL % 3 + ".tmp"): diff --git a/game/tests/unit/core/tree_requires_level_test.gd b/game/tests/unit/core/tree_requires_level_test.gd index 46d143b5..732f8dcd 100644 --- a/game/tests/unit/core/tree_requires_level_test.gd +++ b/game/tests/unit/core/tree_requires_level_test.gd @@ -53,7 +53,7 @@ func _init() -> void: var mp = load("res://core/meta_progression.gd").new(db, sv) mt.rows = [base_pre.duplicate(true), base_dep.duplicate(true)] db.set("meta_tree", mt) - sv.set_tree_level("gate_pre", 1) + sv.meta_state.set_tree_level("gate_pre", 1) if mp.tree_can_buy("gate_dep"): failures += 1 print("[r1] FAIL 前置 Lv1 < 阈值 2 应拒购") @@ -62,17 +62,17 @@ func _init() -> void: failures += 1 print("[r1] FAIL 无 requires_level 行判定不受机制位影响") # ⑦ 判定面:达标开放 - sv.set_tree_level("gate_pre", 2) + sv.meta_state.set_tree_level("gate_pre", 2) if not mp.tree_can_buy("gate_dep"): failures += 1 print("[r1] FAIL 前置 Lv2 达阈值应可购") # ⑧ 投影:gate_text 未达标拼门槛文案、达标清空 - sv.set_tree_level("gate_pre", 1) + sv.meta_state.set_tree_level("gate_pre", 1) var gate: String = mp.tree_gate_text("gate_dep") if gate != "需 门槛前置 Lv2": failures += 1 print("[r1] FAIL 未达标 gate_text 应=「需 门槛前置 Lv2」实得「%s」" % gate) - sv.set_tree_level("gate_pre", 2) + sv.meta_state.set_tree_level("gate_pre", 2) if mp.tree_gate_text("gate_dep") != "": failures += 1 print("[r1] FAIL 达标后 gate_text 应为空串") diff --git a/game/tests/unit/presentation/forge_workbench_test.gd b/game/tests/unit/presentation/forge_workbench_test.gd index e0c09afe..ec25fd5d 100644 --- a/game/tests/unit/presentation/forge_workbench_test.gd +++ b/game/tests/unit/presentation/forge_workbench_test.gd @@ -85,7 +85,7 @@ func _run() -> void: rows.sword.buy_btn.disabled and rows.spear.unlock_btn.disabled) # ⑥ 满级行:无购买钮、状态=已满级(直置存档域构造——两分法调试口径) - sv.set_forge_route_level("sword", 5) + sv.meta_state.set_forge_route_level("sword", 5) ov.refresh_camp({"balance": 500, "forge": mp.forge_shop_status(), "tree": [{"node_id": "forge_workshop", "level": 1}]}) rows = _row_by_id(ov) diff --git a/game/tests/unit/presentation/pacing_queue_test.gd b/game/tests/unit/presentation/pacing_queue_test.gd index 0b4fbdff..de6afc4a 100644 --- a/game/tests/unit/presentation/pacing_queue_test.gd +++ b/game/tests/unit/presentation/pacing_queue_test.gd @@ -51,8 +51,8 @@ func _init() -> void: if FileAccess.file_exists(p): DirAccess.remove_absolute(p) var sm = main.get_node("/root/SaveManager") - sm.attach_path("user://test_pacing_queue_save.json") - sm.mark_first_adventure_done() + sm.slots.attach_path("user://test_pacing_queue_save.json") + sm.meta_state.mark_first_adventure_done() # 行信号计数器(真实信号路径旁挂计数——零触碰既有接线) var rows_seen: Array = [] main._start_adventure_from_menu() diff --git a/game/tests/unit/presentation/theme_projection_test.gd b/game/tests/unit/presentation/theme_projection_test.gd index fc43789c..90f7966a 100644 --- a/game/tests/unit/presentation/theme_projection_test.gd +++ b/game/tests/unit/presentation/theme_projection_test.gd @@ -831,21 +831,21 @@ func _career_overlay_projection() -> void: var mp = load("res://core/meta_progression.gd").new(_db, sv) # 已玩态夹具:两局收束(一通一止)+ 钥石非零——投影可辨非全零白板 # (生涯页不统计天赋树与账号成长态,只统计终身计数) - sv.add_keystone(45) - sv.apply_adventure_summary({"result": "completed", "cleared": true, "levels_cleared": 3, + sv.meta_state._add_keystone(45) + sv.meta_state.apply_adventure_summary({"result": "completed", "cleared": true, "levels_cleared": 3, "stopped_at": 0, "keystone_total": 0, "master_seed": 21}) - sv.apply_adventure_summary({"result": "abandoned", "cleared": false, "levels_cleared": 1, + sv.meta_state.apply_adventure_summary({"result": "abandoned", "cleared": false, "levels_cleared": 1, "stopped_at": 2, "keystone_total": 0, "master_seed": 22}) sv.write_to_disk() # 投影拼装(HudPresenter.career_data 同构)+ 视图喂入 - var data := {"total": sv.adventure_total(), "cleared": sv.adventure_cleared(), + var data := {"total": sv.meta_state.career_state().adventure_total, "cleared": sv.meta_state.career_state().adventure_cleared, "clears": [], "boss_first_kills": 0, "boss_first_total": _db.level_count(), - "boss_kills": sv.boss_kill_count(), "keystone": mp.keystone_balance()} + "boss_kills": sv.meta_state.career_state().boss_kill_count, "keystone": mp.keystone_balance()} for i in _db.level_count(): var li: int = i + 1 data["clears"].append({"display_name": String(_db.level_row(li).get("display_name", "")), - "count": sv.clear_count(li)}) - if sv.boss_first_kill_marked(li): + "count": sv.meta_state.career_state().clear_counts.get(str(li), 0)}) + if sv.meta_state.career_state().boss_first_kill.has(str(li)): data["boss_first_kills"] += 1 var co = load("res://presentation/views/career_overlay.gd").new() co.open_career(data) @@ -881,14 +881,14 @@ func _career_overlay_projection() -> void: fv5c.store_string(JSON.stringify({"version": 5, "meta": {"keystone_balance": 3}})) fv5c.close() Kit.check("v5 现役档升 v6 载入成功(计数域缺省补零)", - v5.load_from_disk() and v5.adventure_total() == 0 and v5.adventure_cleared() == 0) + v5.load_from_disk() and v5.meta_state.career_state().adventure_total == 0 and v5.meta_state.career_state().adventure_cleared == 0) var mp_v5 = load("res://core/meta_progression.gd").new(_db, v5) - var data_v5 := {"total": v5.adventure_total(), "cleared": v5.adventure_cleared(), + var data_v5 := {"total": v5.meta_state.career_state().adventure_total, "cleared": v5.meta_state.career_state().adventure_cleared, "clears": [], "boss_first_kills": 0, "boss_first_total": _db.level_count(), - "boss_kills": v5.boss_kill_count(), "keystone": mp_v5.keystone_balance()} + "boss_kills": v5.meta_state.career_state().boss_kill_count, "keystone": mp_v5.keystone_balance()} for i in _db.level_count(): data_v5["clears"].append({"display_name": String(_db.level_row(i + 1).get("display_name", "")), - "count": v5.clear_count(i + 1)}) + "count": v5.meta_state.career_state().clear_counts.get(str(i + 1), 0)}) var co_v5 = load("res://presentation/views/career_overlay.gd").new() co_v5.open_career(data_v5) Kit.check("旧档载入后生涯页正常显示(v5 升 v6:总局数/冒险成功局数显示 0,既有钥石余额原样呈现)",