- T1 表与schema:attunement_growth.tres 谓词表(m1/m2/m3+Σgrant≤slot_cap 校验+slots() 求值口径)+道具表扩列(form/charges/slot_cost/unlock_requires/effective_rounds/charge_n+effect_kinds 九kind+KEY_WHITELIST 零裸flat护栏)+七同调行 - T2 save v4(boss_kill_count 收束入账+attunement_equipped+attune_refresh_level+断点 attunement_charges+迁移链 v3→v4) - T3 Attunement 求值服务(纯函数零 await 零 RNG) - T4 营地装配覆盖层(工坊扩容程序占位+Y 升级行 attune_refresh_upgrade 行族) - T5 被动六件接线+装配注入链+刷新 X/Y 双源(BonusPickOverlay 刷新钮,autoplay 默认不刷) - T6 掘进蓄能全链+摆钟能力面(骑库存面差额注入+断点充能键);炸格 P5 甲影响评估破面大→用户裁拆后批,blast_kit deferred 留位 - T7 退役面五件(BONUS_IDS 除名 aux_item+transitional_sync_slots 过渡键退役=迭代10欠账③清偿+授予路/四容量闸拆除+nest_pendulum 换装 active_charged) - T8 冒烟六新断言(645/0)+MC attune 开关+贪心画像分层 off/on 各 N=60 双策略(on 画像功率显著带平移归调参批;off 新锚=对拍正主留盘主区 sim-data,81000 降批入口基准) - T9 连带修订九件(魔法道具定位重写/元进度v1.6/地图战斗资源v0.16/行动经济v0.7/05不做v1.3/关卡节点地图v1.4/node-reward v2.2/registry 三处/06 收口+炸格拆后批新条) - T10 重闸全绿:冒烟 645/0+熵增 PASS+doc 三闸零新增+MC off 复跑同种子逐位一致 PASS;出口人闸后移迭代 12(用户 2026-10-03 令)
351 lines
17 KiB
GDScript
351 lines
17 KiB
GDScript
# VERTICAL SLICE - NOT FOR PRODUCTION
|
||
# Validation Question: 关掉游戏之后玩家的东西还在吗——单档 JSON 存档的写入、读回、版本迁移与损坏
|
||
# 兜底能否在零玩法依赖下独立成立,元进度四件(钥石余额/永久强化等级表/每关通关次数表/Boss 首杀标记位)
|
||
# 与最近冒险摘要(cap 10)能否跨进程存活(P0)
|
||
# Date: 2026-09-21(存档序列化批——设计权威 design/gdd/systems/表现与支撑/存档序列化.md)
|
||
#
|
||
# 存档技术契约承载方(Core 层零 UI、零玩法依赖——禁 preload/import 任何玩法脚本、禁碰 ConfigDB/
|
||
# RngService,可独立实例化独立验证〔存档序列化.md 验收标准〕)。
|
||
#
|
||
# 域清单与 owner 分工(内容 owner 定口径,本类照单承载、不替任何人定义进度):
|
||
# keystone_balance / forge_levels / item_unlocks / level_clear_counts / boss_first_kill
|
||
# —— 内容 owner = 元进度.md(营地三柜台:钥石余额/属性锻造/道具工坊/通关次数/首杀标记)
|
||
# boss_kill_count / attunement_equipped / attune_refresh_level
|
||
# —— 内容 owner = 魔法道具.md(同调位账本,M-4 迭代 11 第①批 T2):Boss 击杀总数〔含重复〕=
|
||
# attunement_growth 谓词表计数源;装配关系 = 局外进度事实(元进度域同族——非契约 2 反面清单的
|
||
# 「局内道具库存」,后者=冒险进行中瞬态);attune_refresh_level = Y 刷新升级购买等级
|
||
# breakpoint.attunement_charges(主动件充能余量)——每冒险重置语义的断点瞬态附随件,内容装配归
|
||
# AdventureRunner(随 T6 接线入断点),本类照单承载;契约 2 域界注记同断点域豁免先例
|
||
# recent_summaries(冒险摘要五件事副本)—— 内容 owner = 冒险结构.md(摘要五件事口径)
|
||
# breakpoint(冒险断点快照——停点粒度续玩域)—— 内容 owner = 冒险结构.md(七字段拍板面
|
||
# +恢复必需附随件:主种子/取数计数器/钥石累计/已到节点序/道具库存);契约 2 域界反面清单
|
||
# 只约束 meta 永久进度域——断点域=冒险进行中瞬态快照,生命/GP/道具库存按拍板面入域
|
||
# (M-3 迭代 8 卡①,存档序列化.md 契约 2 随批修订)
|
||
# 承载 owner = 存档序列化.md(格式/时机/迁移/损坏/域登记)
|
||
#
|
||
# 契约登记(存档序列化.md 核心规则——违反即返工):
|
||
# 契约 1 表与档分家:存档只记进度事实,禁止把数值配方写进存档(数值改版后旧档按加载时的表解释);
|
||
# 契约 2 域界干净:行动点/RAP/道具库存/生命账本/GP 不出现在存档内容里(反面清单,冒烟有断言);
|
||
# 契约 3 钥石不丢:写入/读回/迁移不得破坏元进度「钥石只增不减(消费除外)」判据——
|
||
# tmp+rename 原子写(写一半断电不留烂档)+ 损坏兜底坏档备份;
|
||
# 契约 4 高版本拒读:version > SAVE_FORMAT_VERSION → 不加载且禁写盘(保护未来版本的档不被覆写),
|
||
# 内存以默认新档运行。
|
||
#
|
||
# JSON 键纪律:字典键一律 String——JSON 键非字符串的坑,int 键(关卡序号)读写两侧都要 str() 换算,
|
||
# 混用必出静默 miss。
|
||
class_name SaveManager
|
||
extends RefCounted
|
||
|
||
const SAVE_FORMAT_VERSION := 4 # registry schema_version:v4=同调批扩域(meta 三新域 boss_kill_count/attunement_equipped/attune_refresh_level——M-4 迭代 11 第①批 T2;断点充能余量随 T6 接线,旧档缺键 = 合法空态);v3=断点附随件扩列+摘要 keystone_drops(M-3 迭代 10 奖励重构);v2=断点域出生(M-3 迭代 8 卡①);v1=首版;高版本拒读 owner=存档序列化
|
||
const SUMMARY_CAP := 10 # 最近冒险摘要保留条数(最新在前)
|
||
|
||
const FORGE_ATTRS := ["max_hp", "atk", "armor_class"] # 属性锻造三档合法键(元进度.md 属性锻造柜台)
|
||
|
||
var _path: String # 存档文件路径(默认 user://save.json;冒烟注入隔离路径)
|
||
var _read_only := false # 高版本拒读置位——write_to_disk 一律拒绝(契约 4 禁写)
|
||
var _data: Dictionary[String, Variant] = {} # 内存数据模型(_init 即内置默认;load 后为档案内容)
|
||
var _migrations: Dictionary[int, Callable] = {} # from_version → Callable(data)->data(升一版;v1→v3 生产迁移链现行——_init 自注册)
|
||
|
||
|
||
func _init(path: String = "user://save.json") -> void:
|
||
_path = path
|
||
_data = _default_data()
|
||
# 生产迁移链自注册(v2 起——测试钩 register_migration 仍可叠挂更早版本步骤)
|
||
register_migration(1, _migrate_v1_v2)
|
||
register_migration(2, _migrate_v2_v3)
|
||
register_migration(3, _migrate_v3_v4)
|
||
|
||
|
||
# v1→v2:断点域出生(旧档无断点 = 合法空态——「继续上局」隐藏,非损坏非债务)
|
||
func _migrate_v1_v2(data: Dictionary) -> Dictionary:
|
||
if not data.has("breakpoint"):
|
||
data["breakpoint"] = {}
|
||
data["version"] = SAVE_FORMAT_VERSION
|
||
return data
|
||
|
||
|
||
# v2→v3:断点附随件扩列(迭代 10 奖励重构批——bonus_taken/bonus_pending/bonus_result/
|
||
# bonus_aux_carry/keystone_drops + bonus.ap 轴)与摘要 additive 键 keystone_drops——旧档缺键
|
||
# = 合法空态(读取方 .get 兜底),无结构迁移体;版本号推进仅为 schema_version 对齐
|
||
func _migrate_v2_v3(data: Dictionary) -> Dictionary:
|
||
data["version"] = SAVE_FORMAT_VERSION
|
||
return data
|
||
|
||
|
||
# v3→v4:同调批扩域(迭代 11 第①批 T2——meta 三新域 boss_kill_count/attunement_equipped/
|
||
# attune_refresh_level,旧档缺键由 _normalize 按默认补齐,无结构迁移体;版本号推进仅为
|
||
# schema_version 对齐——v2→v3 先例)
|
||
func _migrate_v3_v4(data: Dictionary) -> Dictionary:
|
||
data["version"] = SAVE_FORMAT_VERSION
|
||
return data
|
||
|
||
|
||
# ---- 默认数据模型(新档合法态——全零;白板存档 = 完整可玩〔元进度 单局循环零依赖〕)----
|
||
|
||
func _default_data() -> Dictionary[String, Variant]:
|
||
return {
|
||
"version": SAVE_FORMAT_VERSION,
|
||
"meta": {
|
||
"keystone_balance": 0,
|
||
"forge_levels": {"max_hp": 0, "atk": 0, "armor_class": 0},
|
||
"item_unlocks": {},
|
||
"level_clear_counts": {"1": 0, "2": 0, "3": 0},
|
||
"boss_first_kill": {},
|
||
"boss_kill_count": 0,
|
||
"attunement_equipped": [],
|
||
"attune_refresh_level": 0,
|
||
"recent_summaries": [],
|
||
"first_adventure_done": false,
|
||
},
|
||
"breakpoint": {}, # 冒险断点快照(空 = 无断点合法态——「继续上局」隐藏;迭代 8 卡①)
|
||
}
|
||
|
||
|
||
# ---- 首次冒险布尔位(关卡节点图生成 v0.4——唯一新增存档字段,M-3 迭代 5 T5)----
|
||
# false = 首次脚本模式(全关零抽签取池内模板 0=现行固定图);置位只在冒险收束挂点(MetaProgression.settle_adventure)。
|
||
# 老档迁移:缺字段 = 该档已有游玩史 → true(不重放教学脚本)。
|
||
|
||
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
|
||
|
||
|
||
# ---- 读盘(启动时调用)----
|
||
# 返回 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 原子写——写一半断电不留烂档)----
|
||
# 高版本拒读态一律拒绝(契约 4 禁写);写失败返回 false。
|
||
func write_to_disk() -> bool:
|
||
if _read_only:
|
||
push_error("[存档序列化] 写盘拒绝——档案版本高于当前(拒读禁写态)")
|
||
return false
|
||
var tmp := _path + ".tmp"
|
||
var f := FileAccess.open(tmp, FileAccess.WRITE)
|
||
if f == null:
|
||
push_error("[存档序列化] 临时文件打开失败:%s(%s)" % [tmp, error_string(FileAccess.get_open_error())])
|
||
return false
|
||
f.store_string(JSON.stringify(_data, "\t", true))
|
||
f.close()
|
||
if not _rename_overwrite(tmp, _path):
|
||
push_error("[存档序列化] 原子改名失败(tmp 保留待查):%s → %s" % [tmp, _path])
|
||
return false
|
||
return true
|
||
|
||
|
||
# 迁移注册口:from_version → from_version+1 的升级函数(读档时链式调用)。fn 契约:
|
||
# Dictionary → Dictionary,须写回 version = from_version + 1。生产迁移链现行(v1→v3,_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 forge_level(attr: String) -> int:
|
||
return int(_data["meta"]["forge_levels"].get(attr, 0))
|
||
|
||
|
||
func set_forge_level(attr: String, n: int) -> bool:
|
||
if not FORGE_ATTRS.has(attr):
|
||
push_error("[存档序列化] 属性锻造键出域拒绝:%s(合法键 %s)" % [attr, str(FORGE_ATTRS)])
|
||
return false
|
||
_data["meta"]["forge_levels"][attr] = n
|
||
return true
|
||
|
||
|
||
func item_unlocked(item_id: String) -> bool:
|
||
return _data["meta"]["item_unlocks"].has(item_id)
|
||
|
||
|
||
func unlock_item(item_id: String) -> void:
|
||
_data["meta"]["item_unlocks"][item_id] = true
|
||
|
||
|
||
func clear_count(level_index: int) -> int:
|
||
return int(_data["meta"]["level_clear_counts"].get(str(level_index), 0))
|
||
|
||
|
||
func boss_first_kill_marked(level_index: int) -> bool:
|
||
return _data["meta"]["boss_first_kill"].has(str(level_index))
|
||
|
||
|
||
# ---- 同调位账本域(迭代 11 第①批 T2——里程碑谓词表计数源 + 装配关系 + 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))
|
||
|
||
|
||
# 装配关系(同调目录 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 = []
|
||
for v in item_ids:
|
||
out.append(String(v))
|
||
_data["meta"]["attunement_equipped"] = out
|
||
|
||
|
||
# Y 刷新升级购买等级(0 = 未购 → 每冒险刷新次数 0;等级行值归营地升级行族——T4 接线)
|
||
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 attunement_counters() -> Dictionary:
|
||
var total := 0
|
||
var counts: Dictionary = _data["meta"]["level_clear_counts"]
|
||
for k in counts.keys():
|
||
total += int(counts[k])
|
||
return {"clear_count": total, "boss_kill_count": boss_kill_count()}
|
||
|
||
|
||
func recent_summaries() -> Array:
|
||
return _data["meta"]["recent_summaries"].duplicate(true) # 值拷贝出户(本类契约「禁碰内部结构」——消费方直改不得渗入存档内存态)
|
||
|
||
|
||
# 域更新原语(正式冒险收束唯一入账口——组合根经本函数入账,禁直改内部结构):
|
||
# 钥石入账(summary.keystone_total——迭代 10 起逐杀掉落已在冒险内计入,boss_drops 参数降级为
|
||
# 摘要归档副本不重复入账)+ 逐通关关次 clear_count +1 与 Boss 首杀标记 + Boss 击杀总数累计
|
||
# (迭代 11 第①批 T2——同调位里程碑计数源)+ 摘要头部插入(cap 10,最新在前,元素附
|
||
# keystone_drops 副本——复盘可归因)。
|
||
func apply_adventure_summary(summary: Dictionary, boss_drops: Array = []) -> void:
|
||
add_keystone(int(summary.get("keystone_total", 0)))
|
||
var cleared := int(summary.get("levels_cleared", 0))
|
||
for li in range(1, cleared + 1):
|
||
var key := str(li)
|
||
_data["meta"]["level_clear_counts"][key] = int(_data["meta"]["level_clear_counts"].get(key, 0)) + 1
|
||
_data["meta"]["boss_first_kill"][key] = true
|
||
_data["meta"]["boss_kill_count"] = boss_kill_count() + cleared # 迭代 11 T2——Boss 恒唯一终点:levels_cleared 即本次冒险 Boss 击杀数(含重复)
|
||
var entry: Dictionary = summary.duplicate(true)
|
||
entry["boss_drops"] = boss_drops.duplicate(true)
|
||
var arr: Array = _data["meta"]["recent_summaries"]
|
||
arr.push_front(entry)
|
||
while arr.size() > SUMMARY_CAP:
|
||
arr.pop_back()
|
||
|
||
|
||
# ---- 冒险断点域(迭代 8 卡①——停点粒度续玩快照;内容装配归 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)
|
||
|
||
|
||
# ---- 私有:损坏兜底与文件原语 ----
|
||
|
||
# 损坏兜底:坏档改名 .corrupt.bak(已存在则覆盖)→ 默认数据继续。不静默丢档(警告留痕)、不 crash。
|
||
func _corrupt_fallback(reason: String) -> bool:
|
||
var bak := _path + ".corrupt.bak"
|
||
if not _rename_overwrite(_path, bak):
|
||
push_warning("[存档序列化] 坏档备份改名失败(原文件保留原地):%s——%s" % [bak, reason])
|
||
else:
|
||
push_warning("[存档序列化] 检测到损坏档案,已备份为 %s——%s(用默认新档继续)" % [bak, reason])
|
||
_data = _default_data()
|
||
return true
|
||
|
||
|
||
# 迁移后/低版本档字段补齐(改版不弃档:缺新域按默认补齐,既有值保留)
|
||
func _normalize(data: Dictionary) -> Dictionary[String, Variant]:
|
||
var merged := _default_data()
|
||
var def_meta: Dictionary = merged["meta"] # 补齐源引用(先于 data 覆盖取出——data 自带 meta 时 merged["meta"] 被换走,此引用仍指默认值)
|
||
for key in data:
|
||
merged[key] = data[key]
|
||
var meta: Dictionary = merged.get("meta", {})
|
||
for key in def_meta:
|
||
if not meta.has(key):
|
||
meta[key] = def_meta[key]
|
||
var forge: Dictionary = meta.get("forge_levels", {})
|
||
for sk in def_meta["forge_levels"]:
|
||
if not forge.has(sk):
|
||
forge[sk] = def_meta["forge_levels"][sk]
|
||
meta["forge_levels"] = forge
|
||
merged["meta"] = meta
|
||
return merged
|
||
|
||
|
||
# 覆盖式改名(Windows 原生 rename 目标已存在时会失败——先删后改,显式覆盖语义)
|
||
func _rename_overwrite(from: String, to: String) -> bool:
|
||
var dir := DirAccess.open(from.get_base_dir())
|
||
if dir == null:
|
||
return false
|
||
if dir.file_exists(to.get_file()) and dir.remove(to.get_file()) != OK:
|
||
return false
|
||
return dir.rename(from.get_file(), to.get_file()) == OK
|