卡①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:
1 parent
989c489c55
commit
b182073941
29 files changed
+873
-884
No files matched your search
@@ -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}) # 逐杀明细+累计单写原语
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user