卡①T5(核心数据面结构收敛批): 存档域拆件——SlotStore建件(槽位族10公开+槽位态随迁+_migrate_legacy_save/_copy_file/_remove_file随迁,_rename_overwrite留save IO单点)/MetaStateStore建件(写口原语13+只读投影6+add_keystone私有)/save_manager瘦身(52→7公开:load_from_disk/write_to_disk/register_migration+断点族4;_load_parsed/_peek_migrated读盘域提取,迁移链文案经fail数组逐字带出;类变量6)/全域改址449处(槽位族→save.slots.,元进度族→save.meta_state.,读面投影改形career/tree/forge/attune_state;meta转发与GainJournal.vision_used按卡留T6);[用户特许2026-10-28:本批测试动作移交用户,套件与闸停跑,实现继续]

This commit is contained in:
DKChan committed 2026-10-28 08:14:14 +08:00
1 parent 989c489c55
commit b182073941
29 files changed
+873 -884

No files matched your search

+1 -1
View File
@@ -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}) # 逐杀明细+累计单写原语
+1 -1
View File
@@ -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
+65 -65
View File
@@ -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()
+176
View File
@@ -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
+143 -519
View File
@@ -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 = 高版本拒读(默认数据运行、禁写盘)。
# 损坏兜底:原文件改名 <path>.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 = 高版本拒读(默认数据运行、禁写盘)。
# 损坏兜底:原文件改名 <path>.corrupt.bak(已存在则覆盖)+ push_warning + 默认数据继续——不静默丢档。
func load_from_disk() -> bool:
_read_only = false
if not FileAccess.file_exists(_path):
_data = _default_data() # 新档合法态
return true
var f := FileAccess.open(_path, FileAccess.READ)
if f == null:
return _corrupt_fallback("读打开失败(%s)" % error_string(FileAccess.get_open_error()))
var text := f.get_as_text()
f.close()
var parsed = JSON.parse_string(text)
if typeof(parsed) != TYPE_DICTIONARY:
return _corrupt_fallback("JSON 解析失败或顶层结构出域(非对象)")
if not parsed.has("version"):
return _corrupt_fallback("缺版本字段")
var v := int(parsed["version"])
if v > SAVE_FORMAT_VERSION:
_read_only = true
_data = _default_data()
push_error("[存档序列化] 档案版本 %d 高于当前 %d——拒读禁写(保护未来档不被覆写,内存默认新档运行)" % [v, SAVE_FORMAT_VERSION])
return false
while v < SAVE_FORMAT_VERSION:
if not _migrations.has(v):
return _corrupt_fallback("迁移链缺步骤 %d→%d" % [v, v + 1])
var stepped = _migrations[v].call(parsed)
if typeof(stepped) != TYPE_DICTIONARY:
return _corrupt_fallback("迁移步骤 %d→%d 执行失败" % [v, v + 1])
parsed = stepped
v = int(parsed.get("version", v + 1))
_data = _normalize(parsed)
# 老档迁移(关卡节点图生成 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:
+189
View File
@@ -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
+6 -6
View File
@@ -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)),
+3 -3
View File
@@ -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
+2 -2
View File
@@ -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()
+5 -5
View File
@@ -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://<tag>_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()
+1 -1
View File
@@ -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 进入存档钮缺失(卡② 交付物)")
+11 -11
View File
@@ -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()
+2 -2
View File
@@ -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 之尾:取格若需前沿移位
+2 -2
View File
@@ -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()
+2 -2
View File
@@ -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())
+3 -3
View File
@@ -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:
@@ -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)
@@ -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")
@@ -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 的旧档按初始持有态可玩)")
+11 -11
View File
@@ -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": [],
@@ -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()
@@ -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〕")
@@ -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()
@@ -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,
+14 -14
View File
@@ -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"):
@@ -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 应为空串")
@@ -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)
@@ -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()
@@ -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,既有钥石余额原样呈现)",