diff --git a/game/core/save_manager.gd b/game/core/save_manager.gd index f381145e..5a8a72d1 100644 --- a/game/core/save_manager.gd +++ b/game/core/save_manager.gd @@ -1,5 +1,6 @@ ## VERTICAL SLICE - NOT FOR PRODUCTION -## 存档唯一实现:JSON 单档写入/读回/版本迁移/损坏兜底,Core 层零 UI 零玩法依赖(垂直切片验证:进度跨进程存活)。 +## 存档唯一实现:3 存档位 JSON 承载(激活槽写入/读回/版本迁移/损坏兜底/旧单档一次性迁移), +## Core 层零 UI 零玩法依赖(垂直切片验证:进度跨进程存活)。 ## 禁 preload/import 任何玩法脚本、禁碰 ConfigDB/RngService——须可独立实例化独立验证。 ## 新增存档键先在序列化函数登记并升版本号;键域内容口径归各 GDD 文档,本类照单承载。 ## 存档键清单的事实源=本类序列化/读回代码,不在任何文档维护第二份键清单。 @@ -12,14 +13,22 @@ const SUMMARY_CAP := 10 # 最近冒险摘要保留条数(最新在前 const TREE_STRUCT_VERSION := 2 # 树结构版本标记(12 节点 v0.8 结构;加载检测缺失 # 或更低 = 换代前结构 → 树域破坏性废弃重置〔契约 4:禁迁移禁折算〕) -var _path: String # 存档文件路径(默认 user://save.json;冒烟注入隔离路径) +const SLOT_COUNT := 3 # 存档位数写死 3(用户裁决 2——禁参数化扩位,扩位=新裁点另立) +const SLOT_PATH_TEMPLATE := "user://save_slot_%d.json" # 槽文件名唯一派生模板(槽 1 禁复用旧单档名——Q1 乙前提防误读;探针面禁各自拼槽名,测试隔离走 attach_slot_template 注入口) +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 注入隔离命名空间,禁在呈现层/探针各自拼槽名) -func _init(path: String = "user://save.json") -> void: +func _init(path: String = "") -> void: _path = path + _active_slot = _slot_index_of(path) _data = _default_data() # 生产迁移链自注册(v2 起——测试钩 register_migration 仍可叠挂更早版本步骤) register_migration(1, _migrate_v1_v2) @@ -31,21 +40,177 @@ func _init(path: String = "user://save.json") -> void: # autoload 生命周期(SaveManager 为跨场景持有者,仿 RngService 先例: -# 类名加 Core 后缀、autoload 占净名;读盘挂 _ready=autoload 先于主场景就绪)。独立 -# .new() 实例(单测/工具)不入树零 _ready,读盘仍由调用方显式触发。 +# 类名加 Core 后缀、autoload 占净名)。独立 .new() 实例(单测/工具)不入树零 _ready, +# 读盘仍由调用方显式触发。 +# 启动态钉死(卡① sv2):autoload 启动不读任何槽——内存=默认合法空态(_init 置好)、 +# 无激活态(_path="",一切写盘路径拒绝并返回 false);玩家侧经选档界面显式进档/新档后才可写。 +# 旧隐式单档一次性迁入槽 1(Q1 甲,sv12)在本口执行。 func _ready() -> void: - load_from_disk() + _migrate_legacy_save(LEGACY_SAVE_PATH, slot_path(1), LEGACY_SAVE_PATH + LEGACY_MIGRATED_SUFFIX) # 原地重置(探针隔离面,仿 RngService.set_master_seed 单例原地重置: -# 换路径+回默认数据+重读盘;对象引用恒定,MetaProgression 持有的 save 引用零失效) +# 换路径+回默认数据+重读盘;对象引用恒定,MetaProgression 持有的 save 引用零失效)。 +# 三槽化后单路径语义(契约 7)=注入路径即激活槽文件(写盘落该路径、无激活态时注入即激活); +# 注入槽派生名时激活号同步记账,注入任意隔离路径时激活号 0(写盘仍合法——隔离面行为零变化)。 func attach_path(path: String) -> void: _path = path _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", "keystone", "cleared", "total"}——readable=true=可读槽(空槽=零值合法态, +# .tmp 残留按空槽——主文件不在即空);readable=false=不可读槽(损坏/高版本/缺版本字段/打开失败, +# 三值无意义——卡③ Q2 裁甲:UI 按空槽呈现,进档时走正式 load 兜底/拒读链)。 +# 迁移链在此只读推演(生产迁移 Callable 为纯函数,零盘面副作用)。 +func peek_slot_summary(slot: int) -> Dictionary: + if slot < 1 or slot > SLOT_COUNT: + return {"readable": 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, "keystone": 0, "cleared": 0, "total": 0} + if not FileAccess.file_exists(path): + out["readable"] = true # 空槽=可读零值合法态 + return out + 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 + 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 + + +# 注入路径的槽号反查(命中槽派生名返回槽号,否则 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 + + # v1→v2:断点域出生(旧档无断点 = 合法空态——「继续上局」隐藏,非损坏非债务) func _migrate_v1_v2(data: Dictionary) -> Dictionary: if not data.has("breakpoint"): @@ -144,7 +309,7 @@ func mark_first_adventure_done() -> void: _data["meta"]["first_adventure_done"] = true -# ---- 读盘(启动时调用)---- +# ---- 读盘(进档/注入时调用;启动态不读——_ready 已钉死不隐式激活)---- # 返回 true = 已就绪可用(含新档/损坏兜底/迁移成功);false = 高版本拒读(默认数据运行、禁写盘)。 # 损坏兜底:原文件改名 .corrupt.bak(已存在则覆盖)+ push_warning + 默认数据继续——不静默丢档。 func load_from_disk() -> bool: @@ -184,8 +349,12 @@ func load_from_disk() -> bool: # ---- 写盘(tmp + rename 原子写——写一半断电不留烂档)---- +# 无激活态一律拒绝并返回 false(sv3:启动态不隐式激活,盘面零触碰); # 高版本拒读态一律拒绝(契约 4 禁写);写失败返回 false。 func write_to_disk() -> bool: + if _path == "": + push_error("[存档序列化] 写盘拒绝——无激活存档位(启动态不隐式激活;经选档进档/新档后落盘)") + return false if _read_only: push_error("[存档序列化] 写盘拒绝——档案版本高于当前(拒读禁写态)") return false @@ -439,6 +608,52 @@ func breakpoint_snapshot() -> Dictionary: # ---- 私有:损坏兜底与文件原语 ---- +# 旧隐式单档一次性迁移(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" diff --git a/game/smoke/smoke_all.gd b/game/smoke/smoke_all.gd index 7f8cf2e8..452829ea 100644 --- a/game/smoke/smoke_all.gd +++ b/game/smoke/smoke_all.gd @@ -264,11 +264,20 @@ func _smoke_boot_chain() -> void: "V2c 启动态 _start_overlay.visible=true(boot 后玩家见菜单)〔装配生命批 V2〕") # ④ 续玩钮可见态==SaveManager 断点在档态 var sm = main.get_node("/root/SaveManager") - # ④-1 boot 路径:boot 时按钮态==真实档断点态(关系判据——与真实档当时内容无关) - var real_bp: bool = sm.has_breakpoint() - check(main._resume_btn.visible == real_bp, - "V2d 续玩钮 boot 态==断点在档态(visible=%s has_breakpoint=%s)〔装配生命批 V2〕" % [ - str(main._resume_btn.visible), str(real_bp)]) + # ④-1 boot 路径:启动态钉死(卡① sv2)——boot 无激活态、断点在档恒否,续玩钮恒隐 + #(原「按钮态==真实档断点态」关系判据随启动态换代改写:test-conventions §9.5 申报在案,卡② mn8 再承接) + check(not sm.has_breakpoint() and not main._resume_btn.visible, + "V2d 续玩钮 boot 态恒隐(无激活态——启动态不隐式激活;断点在档=%s visible=%s)〔装配生命批 V2·卡① sv2 改写〕" % [ + str(sm.has_breakpoint()), str(main._resume_btn.visible)]) + # ④-1b 启动态三断言+旧单档迁移不变式(卡① sv2/sv12——真实 autoload boot 链) + check(sm.active_slot() == 0 and not sm.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, + "V2-s2 boot 内存=默认合法空态(钥石/断点/总局数全零值)〔卡① sv2〕") + check(not sm.write_to_disk(), + "V2-s3 boot 无激活写盘拒绝(write_to_disk=false 生产入口)〔卡① sv3〕") + check(not (FileAccess.file_exists("user://save.json") and not FileAccess.file_exists(sm.slot_path(1))), + "V2-s4 旧单档迁移不变式(旧档在则槽 1 必在——Q1 甲一次性迁移已发生)〔卡① sv12〕") # ④-2 隔离新档双向(attach 原地重置——绝不碰真实档 user://save.json;刷新单口与 boot 路径同源) for p in ["user://smoke_boot_chain.json", "user://smoke_boot_chain.json.tmp"]: if FileAccess.file_exists(p): @@ -694,6 +703,7 @@ func _run_core_smoke(db, rng) -> void: _smoke_adventure() _smoke_feats() _smoke_save() + _smoke_save_slots() _smoke_meta() _smoke_tree() _smoke_tree_rules() @@ -6840,3 +6850,172 @@ func _smoke_save() -> void: for p in stale: if FileAccess.file_exists(p): DirAccess.remove_absolute(p) + + +# ---- 三存档位承载(迭代 33 卡①:三槽往返/新档/进档/删档/窥视三态/各槽迁移/旧单档迁移)---- +# 测试文件统一 user://smoke_save_slot_%d.json 模板隔离(attach_slot_template 注入口——禁各自拼槽名), +# 段首清场+段尾清理——绝不碰 user://save.json 与真实槽 user://save_slot_N.json(红线)。 +func _smoke_save_slots() -> void: + print("--- SaveManager 三存档位承载(迭代 33 卡①)---") + var sm_script = load("res://core/save_manager.gd") + var slot_tpl := "user://smoke_save_slot_%d.json" + var legacy_src := "user://smoke_save_legacy_src.json" + var stale: Array = [] + for i in range(1, 4): + for suf in ["", ".tmp", ".corrupt.bak"]: + stale.append(slot_tpl % i + suf) + stale.append_array([legacy_src, legacy_src + ".migrated.bak"]) + for p in stale: + if FileAccess.file_exists(p): + DirAccess.remove_absolute(p) + + var t = sm_script.new() + t.attach_slot_template(slot_tpl) + + # ① 三槽往返×3+位间隔离+进档(sv1/sv5/sv10) + for i in range(1, 4): + check(t.activate_slot(i), "sv5 进档受理·槽 %d(激活+读盘)〔卡① sv5〕" % i) + t.apply_adventure_summary({"result": "completed", "cleared": i == 1, "levels_cleared": i, + "stopped_at": 0, "keystone_total": 100 * i, "master_seed": i}) + check(t.write_to_disk(), "sv3 激活态写盘受理·槽 %d〔卡① sv1〕" % i) + var t2 = sm_script.new() + t2.attach_slot_template(slot_tpl) + for i in range(1, 4): + check(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), + "sv1 三槽往返一致·槽 %d(异值按位读回)〔卡① sv1〕" % i) + var slot2_before := FileAccess.get_file_as_bytes(slot_tpl % 2) + t.activate_slot(1) + t.add_keystone(7) + check(t.write_to_disk(), "sv3 写盘受理·槽 1 加值后〔卡① sv1〕") + check(FileAccess.get_file_as_bytes(slot_tpl % 2) == slot2_before, + "sv1 位间隔离:改槽 1 槽 2 字节不动〔卡① sv1〕") + + # ② 删档原语(sv6):删激活槽(指针连清除)→ 删非激活槽(指针保持+他槽字节不动) + check(t.activate_slot(3), "sv5 切激活至槽 3(删激活槽夹具前置)〔卡① sv5〕") + check(t.delete_slot(3), "sv6 删档受理·激活槽 3〔卡① sv6〕") + check(not FileAccess.file_exists(slot_tpl % 3) and not FileAccess.file_exists(slot_tpl % 3 + ".tmp"), + "sv6 删后文件不在盘+无 .tmp 残留〔卡① sv6〕") + check(t.active_slot() == 0 and not t.has_write_target(), + "sv6 删激活槽激活指针连清除(该槽不再可写)〔卡① sv6〕") + var slot1_before := FileAccess.get_file_as_bytes(slot_tpl % 1) + t.activate_slot(1) + check(t.delete_slot(2), "sv6 删非激活槽受理·槽 2〔卡① sv6〕") + check(not FileAccess.file_exists(slot_tpl % 2) and t.active_slot() == 1, + "sv6 删非激活槽:文件不在盘+激活指针保持〔卡① sv6〕") + check(FileAccess.get_file_as_bytes(slot_tpl % 1) == slot1_before, + "sv6 删槽 2 槽 1 字节不动(位间互隔离)〔卡① sv6〕") + + # ③ 新档原语(sv4):非白板旧内容覆盖为全零可玩白板(判别锚:新档禁读旧档——变异可红), + # 创建即激活并落盘(生产路径只在空槽调用;本段预置旧内容是对原语白板保证的钉死,超生产面) + var fold := FileAccess.open(slot_tpl % 3, FileAccess.WRITE) + 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() + check(t.create_slot(3), "sv4 新档受理·槽 3(创建即激活)〔卡① sv4〕") + check(t.active_slot() == 3 and t.keystone_balance() == 0 and not t.has_breakpoint() + and t.adventure_total() == 0, + "sv4 新档=全零可玩合法白板态(旧内容 999/5 零带入——钥石/总局数/断点全零)〔卡① sv4〕") + var created: Dictionary = JSON.parse_string(FileAccess.get_file_as_string(slot_tpl % 3)) + check(int((created.get("meta", {}) as Dictionary).get("keystone_balance", -1)) == 0, + "sv4 新档落盘=白板内容(盘面核验非 999 旧值)〔卡① sv4〕") + + # ④ 高版本拒读槽按槽成立+激活失败恢复切换前三态(sv9/sv10) + var fhv := FileAccess.open(slot_tpl % 2, FileAccess.WRITE) + fhv.store_string(JSON.stringify({"version": 99, "meta": {}})) + fhv.close() + var hv = sm_script.new() + hv.attach_slot_template(slot_tpl) + check(hv.activate_slot(1) and hv.keystone_balance() == 107, "sv5 进档槽 1 读回(107)〔卡① sv10〕") + var slot2_hv := FileAccess.get_file_as_bytes(slot_tpl % 2) + check(not hv.activate_slot(2), "sv9 高版本槽进档拒绝(拒读禁写链按槽)〔卡① sv9〕") + check(hv.active_slot() == 1 and hv.keystone_balance() == 107, + "sv10 激活失败恢复切换前三态(路径/激活号/数据归槽 1)〔卡① sv10〕") + hv.add_keystone(1) + check(hv.write_to_disk() and FileAccess.get_file_as_bytes(slot_tpl % 2) == slot2_hv, + "sv10 恢复后写盘落激活槽 1(高版本槽 2 零误写)〔卡① sv10〕") + + # ⑤ 迁移链各槽成立(sv9):三槽各喂 v5 旧档 → 各自激活读回迁移值 + for i in range(1, 4): + var fm := FileAccess.open(slot_tpl % i, FileAccess.WRITE) + fm.store_string(JSON.stringify({"version": 5, "meta": {"keystone_balance": i * 11}})) + fm.close() + for i in range(1, 4): + var mi = sm_script.new() + mi.attach_slot_template(slot_tpl) + check(mi.activate_slot(i) and mi.keystone_balance() == i * 11 + and mi.write_to_disk(), + "sv9 迁移链按槽成立·槽 %d(v5→v7 各槽独立+写盘 version=7)〔卡① sv9〕" % i) + + # ⑥ 窥视三态零副作用(sv7/sv8):异值三槽逐槽三项+空槽零值+坏/高版本字节 mtime 双比对 + var pv = sm_script.new() + pv.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, + "adventure_total": 5 * i, "adventure_cleared": i, + "tree_nodes": {}, "fork_sides": {}, "tree_struct": 2}})) + fp.close() + for i in range(1, 4): + var ps: Dictionary = pv.peek_slot_summary(i) + check(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) + check(pv.delete_slot(3), "sv6 删槽 3 备空槽窥视〔卡① sv6〕") + var pe: Dictionary = pv.peek_slot_summary(3) + check(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〕") + var fbad := FileAccess.open(slot_tpl % 1, FileAccess.WRITE) + fbad.store_string("not json{{{") + 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) + check(not bool(pb.get("readable", true)), "sv8 坏槽窥视=显式损坏标记〔卡① sv8〕") + check(FileAccess.get_file_as_bytes(slot_tpl % 1) == bad_bytes + and FileAccess.get_modified_time(slot_tpl % 1) == bad_mtime + and not FileAccess.file_exists(slot_tpl % 1 + ".corrupt.bak"), + "sv8 坏槽窥视零副作用(字节+mtime 双比对不变+零 .corrupt.bak 改名)〔卡① sv8〕") + var fhv2 := FileAccess.open(slot_tpl % 2, FileAccess.WRITE) + fhv2.store_string(JSON.stringify({"version": 99, "meta": {}})) + 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) + check(not bool(ph.get("readable", true)), "sv8 高版本槽窥视=不可读标记〔卡① sv8〕") + check(FileAccess.get_file_as_bytes(slot_tpl % 2) == hv_bytes + and FileAccess.get_modified_time(slot_tpl % 2) == hv_mtime, + "sv8 高版本窥视零副作用(字节+mtime 不变+零改名)〔卡① sv8〕") + + # ⑦ 旧单档迁移(sv12):字节搬运+源改名留痕+幂等+他槽零触碰 + var ml = sm_script.new() + ml.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() + DirAccess.remove_absolute(slot_tpl % 1) + ml._migrate_legacy_save(legacy_src, slot_tpl % 1, legacy_src + ".migrated.bak") + var migrated: Dictionary = JSON.parse_string(FileAccess.get_file_as_string(slot_tpl % 1)) + check(int((migrated.get("meta", {}) as Dictionary).get("keystone_balance", -1)) == 1234, + "sv12 旧档字节迁入槽 1(逐域相等——余额 1234)〔卡① sv12〕") + check(not FileAccess.file_exists(legacy_src) and FileAccess.file_exists(legacy_src + ".migrated.bak"), + "sv12 迁移后源改名留痕(.migrated.bak 在盘、原路径不在)〔卡① sv12〕") + var s2_before := FileAccess.get_file_as_bytes(slot_tpl % 2) + var s3_before := FileAccess.get_file_as_bytes(slot_tpl % 3) + var fsrc2 := FileAccess.open(legacy_src, FileAccess.WRITE) + fsrc2.store_string(JSON.stringify({"version": 7, "meta": {"keystone_balance": 777}})) + fsrc2.close() + ml._migrate_legacy_save(legacy_src, slot_tpl % 1, legacy_src + ".migrated.bak") + var slot1_now: Dictionary = JSON.parse_string(FileAccess.get_file_as_string(slot_tpl % 1)) + check(int((slot1_now.get("meta", {}) as Dictionary).get("keystone_balance", -1)) == 1234 + and FileAccess.file_exists(legacy_src), + "sv12 幂等:槽 1 已存在不重迁(预置 777 零覆盖、源保留)〔卡① sv12〕") + check(FileAccess.get_file_as_bytes(slot_tpl % 2) == s2_before + and FileAccess.get_file_as_bytes(slot_tpl % 3) == s3_before, + "sv12 迁移他槽零触碰(槽 2/3 字节不动)〔卡① sv12〕") + + # 段尾清理(测试文件绝不残留) + for p in stale: + if FileAccess.file_exists(p): + DirAccess.remove_absolute(p) diff --git a/game/tests/run_all.gd b/game/tests/run_all.gd index 9f282a4c..75305397 100644 --- a/game/tests/run_all.gd +++ b/game/tests/run_all.gd @@ -68,6 +68,9 @@ func _suites() -> Array: {"path": "res://tests/unit/core/pool_case_d_test.gd", "expect": 0}, # 池结构案D 行为(V6 令牌三态/V7 曝光槽 2+1 回退/V8 概率上限解耦提前退役) {"path": "res://tests/unit/core/feat_vouchers_test.gd", "expect": 0}, # 凭证五件+留任二件(V9 路由逐件/F-1 存量账/V10 留任路由+AP 属性轴) {"path": "res://tests/unit/core/settle_segments_test.gd", "expect": 0}, # 结算五段 Segment(V4:直接 new/编排器接线/真实一轮走段序) + {"path": "res://tests/unit/core/save_slot_isolation_test.gd", "expect": 0}, # 卡① 三槽位间隔离/进档失败恢复/宿主链引用同一(sv1/sv5/sv9/sv10) + {"path": "res://tests/unit/core/save_boot_no_activate_test.gd", "expect": 0}, # 卡① 启动态类级契约:无激活态/拒写盘面零触碰/越界拒绝(sv2/sv3) + {"path": "res://tests/unit/core/save_slot_summary_test.gd", "expect": 0}, # 卡① 摘要三项按槽窥视:异值/空槽/坏槽三态/零副作用(sv7/sv8) {"path": "res://tests/unit/presentation/modal_flow_test.gd", "expect": 0}, # 模态编排纯逻辑(V6:直接 new/互斥域谓词/三例外锁/演播后排窗) # ---- L2 集成层(跨系统用例,与 unit/ 的隔离断言面分工)---- {"path": "res://tests/integration/save_rng_roundtrip_test.gd", "expect": 0}, # 存档⇄RNG 快照往返(SaveManager 迁移链 × 断点消费 × RNG 续跑 × 停点态确定性) diff --git a/game/tests/unit/core/save_boot_no_activate_test.gd b/game/tests/unit/core/save_boot_no_activate_test.gd new file mode 100644 index 00000000..19afb2d4 --- /dev/null +++ b/game/tests/unit/core/save_boot_no_activate_test.gd @@ -0,0 +1,112 @@ +# tests/unit/core/save_boot_no_activate_test.gd — 卡① sv2/sv3:启动态钉死类级契约 +# (无参实例=无激活态、内存=默认合法空态、无激活写盘一律拒绝并返回 false 且盘面零触碰 +# 〔含空路径派生的裸 ".tmp" 退化写目标——拆闸变异的可判别锚〕、MetaProgression.settle_adventure +# 生产链无激活触达写盘被拒、访问器 fail-safe 合法空值、越界槽号拒绝。 +# 真实 autoload boot 链断言在冒烟 V2-s1..s4 段——--script 模式无 autoload,本套件为类级 +# 白盒面+生产链对照,双落点在实现报告实际对照表对账。) +# 载体:run_all 单元套件(显式实例化夹具——--script 模式无 autoload)。 +extends SceneTree + +const SaveManagerScript := preload("res://core/save_manager.gd") +const MetaProgressionScript := preload("res://core/meta_progression.gd") +const Kit := preload("res://tests/unit/test_kit.gd") + + +func _init() -> void: + _test_no_arg_instance_has_no_activation() + _test_write_rejected_without_target() + _test_settle_chain_write_rejected() + _test_out_of_range_slots_rejected() + quit(Kit.finish("save_boot_no_activate")) + + +# ① 无参实例(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(), + "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, + "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, + "sv2 attach_path 注入即激活(写盘目标在位、数据回默认空态)〔卡① sv2/契约7〕") + if FileAccess.file_exists("user://smoke_boot_noact_iso.json"): + DirAccess.remove_absolute("user://smoke_boot_noact_iso.json") + + +# ② 无激活态写盘一律拒绝并返回 false,盘面零触碰(sv3 类级——真实入口=冒烟 V2-s3 生产链) +# 判别锚:空 _path 的退化写目标=裸 ".tmp"(落 res://,拆闸实验实测杂散落点)—— +# 断言先清退化目标再核零新增,闸短路变异(拒绝口旁路、写盘真实发生)必红。 +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) + 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] + var stray: Array[String] = ["res://.tmp", "res://.tmp.tmp"] # 空 _path 的 tmp 原子写派生名 + for sp in stray: + if FileAccess.file_exists(sp): + DirAccess.remove_absolute(sp) # 夹具清场——退化目标从零起 + Kit.check_quiet(not sm.write_to_disk(), + "sv3 无激活写盘拒绝(write_to_disk=false)〔卡① sv3〕") + var unchanged := true + for i in range(1, 4): + var p: String = sm.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] + if now_state[0] != before[i][0] or now_state[1] != before[i][1] \ + or now_state[2] != before[i][2]: + unchanged = false + Kit.check_quiet(unchanged, + "sv3 拒写盘面零触碰(三槽存在性/字节/mtime 全等)〔卡① sv3〕") + var stray_clean := true + for sp in stray: + if FileAccess.file_exists(sp): + stray_clean = false + Kit.check_quiet(stray_clean, + "sv3 拒写退化写目标零触碰(裸 .tmp 零新增——拒绝口可判别锚)〔卡① sv3〕") + # 拒读禁写态语义在位(高版本拒读→写拒绝——既有契约回归锚) + sm._read_only = true + Kit.check_quiet(not sm.write_to_disk(), "sv3 拒读禁写态写盘拒绝(既有契约保持)〔卡① sv3〕") + + +# ③ 生产链对照面(sv3 真实入口):MetaProgression.settle_adventure 无激活态触达写盘被拒 +# (生产结算链全段在位:mark_first_adventure_done→apply_adventure_summary→refresh_treasure_unlocks +# →write_to_disk;无激活态下落账内存、写盘拒绝、盘面零触碰——已知中间态的机检面) +func _test_settle_chain_write_rejected() -> void: + var db = load("res://core/config_db.gd").new() + if not db.load_tables("res://data/tables", false): + Kit.check_quiet(false, "sv3 链测试前置:数值表加载失败〔卡① sv3〕") + return + var sm := SaveManagerScript.new() + var meta := MetaProgressionScript.new(db, sm) + 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(), + "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, + "sv3 生产链:settle_adventure 落账内存(adventure_total=1)而写盘仍无目标(被拒不落盘)〔卡① sv3〕") + Kit.check_quiet(not FileAccess.file_exists("res://.tmp") + and not FileAccess.file_exists("res://.tmp.tmp"), + "sv3 生产链盘面:结算触达后退化写目标零新增(链上真实入口的盘面核验)〔卡① sv3〕") + + +# ③ 越界槽号三原语拒绝+窥视越界返回不可读(原语边界 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), + "原语边界:槽号 0/4 全拒绝(位数写死 3——契约 1)〔卡① sv1〕") + var out: Dictionary = sm.peek_slot_summary(4) + Kit.check_quiet(not bool(out.get("readable", true)), + "原语边界:越界窥视返回不可读标记〔卡① sv7〕") diff --git a/game/tests/unit/core/save_boot_no_activate_test.gd.uid b/game/tests/unit/core/save_boot_no_activate_test.gd.uid new file mode 100644 index 00000000..0fee5787 --- /dev/null +++ b/game/tests/unit/core/save_boot_no_activate_test.gd.uid @@ -0,0 +1 @@ +uid://ria52wdjq6tj diff --git a/game/tests/unit/core/save_slot_isolation_test.gd b/game/tests/unit/core/save_slot_isolation_test.gd new file mode 100644 index 00000000..ca49a91f --- /dev/null +++ b/game/tests/unit/core/save_slot_isolation_test.gd @@ -0,0 +1,104 @@ +# tests/unit/core/save_slot_isolation_test.gd — 卡① sv1/sv10:三槽承载位间隔离+切槽实例同一性 +# (三槽往返异值逐域读回、改 A 槽 B 槽字节不动、高版本槽进档失败恢复切换前三态、 +# MetaProgression 宿主链切槽前后引用同一且写盘落当前槽、attach_slot_template 注入语义。 +# 测试隔离:槽模板注入 user://smoke_slotiso_%d.json 命名空间——绝不碰真实槽与 user://save.json。) +# 载体:run_all 单元套件(显式实例化夹具——--script 模式无 autoload)。 +extends SceneTree + +const SaveManagerScript := preload("res://core/save_manager.gd") +const MetaProgressionScript := preload("res://core/meta_progression.gd") +const Kit := preload("res://tests/unit/test_kit.gd") + +const TPL := "user://smoke_slotiso_%d.json" + + +func _init() -> void: + _clear_fixtures() + _test_three_slot_roundtrip_and_isolation() + _test_activate_failure_restores_previous_slot() + _test_metaprogression_reference_identity() + _clear_fixtures() + quit(Kit.finish("save_slot_isolation")) + + +func _clear_fixtures() -> void: + for i in range(1, 4): + for suf in ["", ".tmp", ".corrupt.bak"]: + var p: String = TPL % i + suf + if FileAccess.file_exists(p): + DirAccess.remove_absolute(p) + + +func _write_v7_keystone(path: String, keystone: int) -> void: + var f := FileAccess.open(path, FileAccess.WRITE) + f.store_string(JSON.stringify({"version": 7, "meta": {"keystone_balance": keystone, + "tree_nodes": {}, "fork_sides": {}, "tree_struct": 2}})) + f.close() + + +# ① 三槽往返:各槽写异值 → 写盘 → 新实例按位读回逐域相等;改槽 1 槽 2 字节不动(sv1) +func _test_three_slot_roundtrip_and_isolation() -> void: + var t := SaveManagerScript.new() + t.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, + "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) + 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), + "sv1 三槽往返一致·槽 %d(异值按位读回)〔卡① sv1〕" % i) + var slot2_before := FileAccess.get_file_as_bytes(TPL % 2) + t.activate_slot(1) + t.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(), + "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) + _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, + "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, + "sv10 激活失败恢复切换前三态(路径/激活号/数据归槽 1)〔卡① sv10〕") + t.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〕") + + +# ③ MetaProgression 宿主链切槽:引用同一性保持+写盘落当前槽(sv10——禁换实例先例) +func _test_metaprogression_reference_identity() -> void: + var t := SaveManagerScript.new() + t.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〕") + 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, + "sv10 切槽后引用同一性保持(零换实例)且读数随槽(644)〔卡① sv10〕") + meta.save.add_keystone(6) + Kit.check_quiet(t.write_to_disk() and t.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, + "sv10 盘面核验:槽 2 文件落 650、槽 1 零触碰〔卡① sv10〕") diff --git a/game/tests/unit/core/save_slot_isolation_test.gd.uid b/game/tests/unit/core/save_slot_isolation_test.gd.uid new file mode 100644 index 00000000..4f5ecf1c --- /dev/null +++ b/game/tests/unit/core/save_slot_isolation_test.gd.uid @@ -0,0 +1 @@ +uid://bsibspaf7delx diff --git a/game/tests/unit/core/save_slot_summary_test.gd b/game/tests/unit/core/save_slot_summary_test.gd new file mode 100644 index 00000000..4d83a816 --- /dev/null +++ b/game/tests/unit/core/save_slot_summary_test.gd @@ -0,0 +1,106 @@ +# tests/unit/core/save_slot_summary_test.gd — 卡① sv7:摘要三项按槽窥视读器 +# (三槽异值逐槽三项正确、空槽零值合法态、坏槽/高版本/缺版本字段=显式损坏标记、 +# 窥视零副作用(字节+mtime 双比对+零 .corrupt.bak 改名+不激活不写内存)、路径注入口 peek_summary_at。 +# 测试隔离:模板注入 user://smoke_slotsum_%d.json 命名空间——绝不碰真实槽与 user://save.json。) +# 载体:run_all 单元套件(显式实例化夹具——--script 模式无 autoload)。 +extends SceneTree + +const SaveManagerScript := preload("res://core/save_manager.gd") +const Kit := preload("res://tests/unit/test_kit.gd") + +const TPL := "user://smoke_slotsum_%d.json" + + +func _init() -> void: + _clear_fixtures() + _test_peek_three_slots_distinct_values() + _test_peek_empty_and_unreadable_states() + _test_peek_zero_side_effect() + _clear_fixtures() + quit(Kit.finish("save_slot_summary")) + + +func _clear_fixtures() -> void: + for i in range(1, 4): + for suf in ["", ".tmp", ".corrupt.bak"]: + var p: String = TPL % i + suf + if FileAccess.file_exists(p): + DirAccess.remove_absolute(p) + + +func _write_slot_json(path: String, content: String) -> void: + var f := FileAccess.open(path, FileAccess.WRITE) + f.store_string(content) + f.close() + + +# ① 三槽异值逐槽三项正确+不激活不写内存(sv7) +func _test_peek_three_slots_distinct_values() -> void: + var t := SaveManagerScript.new() + t.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) + 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, + "sv7 窥视不激活不写内存(激活号 0/无写盘目标/内存余额零)〔卡① sv7〕") + # 路径注入口同语义(契约 7 注入面) + var pinj: Dictionary = t.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〕") + + +# ② 空槽零值合法态+不可读三态显式标记(sv7/契约 9) +func _test_peek_empty_and_unreadable_states() -> void: + var t := SaveManagerScript.new() + t.attach_slot_template(TPL) + DirAccess.remove_absolute(TPL % 3) # 清①段残留——空槽夹具 + var pe: Dictionary = t.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)), + "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)), + "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)), + "sv7 缺版本字段槽窥视=不可读标记〔卡① sv7/契约9〕") + + +# ③ 窥视零副作用:字节+mtime 双比对不变+零 .corrupt.bak 改名(sv8——真实入口冒烟段同判据) +func _test_peek_zero_side_effect() -> void: + var t := SaveManagerScript.new() + t.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) + 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"), + "sv8 坏槽窥视零副作用(字节+mtime 不变+零 .corrupt.bak 改名)〔卡① sv8〕") + _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) + 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"), + "sv8 高版本窥视零副作用(字节+mtime 不变+零改名)〔卡① sv8〕") + # .tmp 残留按空槽处理(契约 9) + DirAccess.remove_absolute(TPL % 3) # 主文件清场——只留 .tmp 残留 + _write_slot_json(TPL % 3 + ".tmp", "{}") + var pt: Dictionary = t.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"): + DirAccess.remove_absolute(TPL % 3 + ".tmp") diff --git a/game/tests/unit/core/save_slot_summary_test.gd.uid b/game/tests/unit/core/save_slot_summary_test.gd.uid new file mode 100644 index 00000000..18c99425 --- /dev/null +++ b/game/tests/unit/core/save_slot_summary_test.gd.uid @@ -0,0 +1 @@ +uid://43rqlcpx6ur3 diff --git a/reports/iter33-代拍板追认单-2026-10-21.md b/reports/iter33-代拍板追认单-2026-10-21.md new file mode 100644 index 00000000..d1d55170 --- /dev/null +++ b/reports/iter33-代拍板追认单-2026-10-21.md @@ -0,0 +1,14 @@ +# 迭代 33 代拍板追认单(2026-10-21) + +> 累积文档:各卡 §9 白名单内的代拍项随卡施工留痕于此;用户在会话中追认(veto 窗口开放至用户明示)。授权性质:卡片审问阶段的预授权执行,本单为备案确认,非事后授权。 + +## 卡① 存档制数据层改造批 + +| # | 代拍项(卡片 §9 条目) | 取法 | 约束核对 | veto 影响 | +|---|---|---|---|---| +| 1 | 槽位存储机制与文件命名(§9-1) | **三文件制** `user://save_slot_1/2/3.json` | 每槽与现行 v7 逐键同构 ✓;迁移链按槽独立成立 ✓(sv9 冒烟段);坏档兜底按槽 ✓(.corrupt.bak 按槽文件名派生);槽 1 禁复用 `user://save.json` ✓(独立派生名) | 改单文件三键=承载面重写,回炉 T1 | +| 2 | 启动激活态机制(§9-2) | **纯内存态,不持久化**(`_active_slot` 字段) | 卡③ Q1 裁甲「每轮回选档」——裁甲则本条按卡① 契约作废持久化需求 ✓ | 若用户改裁卡③ Q1 乙,本条需重开(激活位持久化补建) | +| 3 | 类 API 命名与文件落点(§9-3) | `slot_path`/`active_slot`/`has_write_target`/`activate_slot`/`create_slot`/`delete_slot`/`peek_slot_summary`/`peek_summary_at`/`attach_slot_template`;承载面不拆(696 行单文件) | 词族 save_slot_*/activate_*/peek_* ✓;禁裸名 slots(同调位已占)✓;测试隔离注入口镜像 attach_path 哲学 | 改名=接口面重排,牵连卡③ 消费面 | +| 4 | 删档边界(§9-4) | 激活槽可删;删后回合法空态+指针连清除;**`.corrupt.bak` 损坏备份不追删**(用户数据留盘可考,同槽再损坏时兜底流按槽覆盖同名备份) | 禁崩禁误写 ✓;激活态生命周期终点=显式清除/进程结束/该槽被删 ✓;回主菜单不清除 ✓(内存态) | 追删备份=删档语义扩一步,改 delete_slot 一处 | +| 5 | 提交切分与 worktree 命名(§9-5) | worktree `iter33card1-save`/分支 `iter33-card1-save`;两笔:T1+T2+T3 实现(6a7c2575)+T4 产出物 | 单 worktree 单分支 ✓;定义面(迭代行/卡片)不入本批提交 ✓ | 仅过程命名,veto 无实质影响 | +| 6 | Q1 甲执行取法(实现取法,超出字面处陈明) | 旧档迁移取「字节级原样搬运、零解析零改造」——损坏/高版本旧档字节原样落槽 1(「原样落入槽 1」字面成立),读取时兜底/拒读链接管;未走 §8 L3 呈报(意外形态不再触发承诺失效) | 不静默丢数据 ✓;源改名 `.migrated.bak` 留痕不删 ✓;失败源保留重试 ✓ | 若用户裁「损坏旧档应 L3 停机」——则补迁移前预检解析、损坏即停的分支 | diff --git a/reports/iter33-反哺点-2026-10-21.md b/reports/iter33-反哺点-2026-10-21.md new file mode 100644 index 00000000..cea3310f --- /dev/null +++ b/reports/iter33-反哺点-2026-10-21.md @@ -0,0 +1,14 @@ +# 迭代 33 GDD 反哺点清单(2026-10-21) + +> 累积文档:实现与设计权威文档的差异/缺口逐条登记于此;用户在会话中裁决后由文档批写入 `design/`(实现批禁写 design/,2026-10-10 用户立规)。每条写清:哪个文件哪一节、改成什么、依据是什么。 + +## 卡① 存档制数据层改造批 + +| # | 文档与落点 | 现行陈述 | 应改成 | 依据 | +|---|---|---|---|---| +| 1 | `design/gdd/systems/表现与支撑/存档序列化.md` `:61`(现行口径节) | 「存档是单文件 JSON(user://save.json)」 | 三存档位承载:`user://save_slot_1/2/3.json` 三文件制,写盘目标=当前激活槽文件;boot 启动态无激活不读写;激活态经选档界面进档/新档建立(卡③) | 卡① 完成定义 1/3+契约 1/6 实装(`save_manager.gd` SLOT_COUNT=3) | +| 2 | 同上 `:20`/`:16`(系统范围负责表) | 「单档 JSON 存档的域划分与读写约定」 | 「3 存档位 JSON 存档的域划分与读写约定」;消费面纪律补「无激活态一切写盘路径拒绝并返回 false」 | 卡① 契约 6(sv3 断言在役) | +| 3 | 同上 `:165`(P2 后置表「多存档槽位」行) | 「单档 MVP(2026-09-21 裁决);多档等有真实需求再立项」 | 该条注销(去向=迭代 33 卡① 已落地)——后置表此行删除或标「已实现(迭代 33)」 | 卡① F7:本批即该条预写的「真实需求」 | +| 4 | 同上(新增条款建议) | 无旧档迁移记载 | 补一段旧单档一次性迁移口径:`user://save.json` 首次启动字节迁入槽 1、源改名 `.migrated.bak` 留痕、槽 1 已存在不重迁(幂等) | 卡① Q1 裁甲+sv12 实装(契约 4 旧档不静默丢失的机制化) | +| 5 | `待办清单.md` 营地交互重做条目 | 状态「…已编迭代 33」 | 卡① 完工后条目状态推进(归流水线登记段/文档批按 §12.1 执行) | doc-conventions §12.1;实现批禁写两份流程清单 | +| 6 | `roadmap.md` 迭代 33 行与顶部「当前位」 | 状态「待开工」 | 卡① 合并后行内状态换代(归文档批) | 同上 | diff --git a/reports/iter33-实现报告-2026-10-21.md b/reports/iter33-实现报告-2026-10-21.md new file mode 100644 index 00000000..3807ead6 --- /dev/null +++ b/reports/iter33-实现报告-2026-10-21.md @@ -0,0 +1,78 @@ +# 迭代 33 实现报告(2026-10-21) + +> 累积文档:卡①→卡③→卡② 依序施工,各卡收口时在本文追加该卡章节;随各卡合并提交进 master。三卡全部合并且总收口完成后本文即为迭代终稿。 +> 基线复读(T0,2026-10-21,master=7280070f):冒烟 **1156 项 / 0 失败**;单元 **35 套件全绿**;probe_gate `--click` **全绿**(残留 Godot=0)。 + +--- + +## 卡① 存档制数据层改造批(隐式单档 → 3 存档位)——已合并(见合并记录) + +**状态**:T0–T4 完成;批末重闸 --code 全绿(回执见合并提交信息);独立验收审计两轮——首轮未过(sv3 拆闸实验判「拒写断言无判别力+生产链零对照面」、sv4 申报变异未执行且无判别力,两行缺漏回炉),回炉后断言强化(sv3 退化写目标锚+生产链对照面、sv4 旧内容判别锚)并补执行变异⑥⑦,复审通过(`reports/iter33-验收审计-2026-10-21.md` 卡① 节)。 + +### 一、实际改动—验收对照表(§7.1 需求侧表的执行对账,六列+结果) + +> 断言尾缀族〔sv#〕均可 grep 定位。落点行号=合并时点实值。 + +| 需求点 | 改动实值(文件:行) | 验收测试实值(落点+尾缀) | 层级 | 真实入口 | 撤销或变异证明 | 结果 | +|---|---|---|---|---|---|---| +| sv1 三槽承载+位间隔离 | `save_manager.gd:15-27` 常量与字段(SLOT_COUNT/SLOT_PATH_TEMPLATE/_slot_template);`:77` slot_path 唯一派生口;`:66` attach_slot_template 注入口 | 冒烟 `_smoke_save_slots` ①段 `smoke_all.gd:6858` 起〔sv1 往返×3+位间隔离〕+单元 `save_slot_isolation_test.gd`〔sv1〕(22 检查) | 接口 | `SaveManagerCore` 实例按槽 API 读写往返,不经 UI | 变异①=slot_path 派生写死 `% 1` → 三槽往返一致断言红(已执行,见变异表) | 通过 | +| sv2 启动态不读盘 | `save_manager.gd:29` `_init` 默认参改 `""`+`:48-53` `_ready` 只做旧档迁移、零读槽 | 冒烟 V2-s1/V2-s2(`smoke_all.gd:273-278`)〔sv2〕真实 boot 链+单元 `save_boot_no_activate_test.gd`(12 检查)类级白盒+生产链对照 | 冒烟+单元 | 组合根 boot 链 `/root/SaveManager`(真实入口=冒烟);单元为 --script 无 autoload 环境的类级对照面+settle 生产链(**双落点对账:计划仅列单元,实测拆为冒烟真实链+单元类级/链级**) | 变异④=_ready 偷调 activate_slot(1) → V2-s1/V2-s3 红(已执行) | 通过 | +| sv3 无激活写盘拒绝 | `save_manager.gd:354-358` `write_to_disk` 入口 `_path == ""` 判定 | 冒烟 V2-s3(`smoke_all.gd:278`)+单元〔sv3〕「拒写+返 false+三槽快照全等+**退化写目标(裸 .tmp)零新增**+**MetaProgression.settle_adventure 生产链触达后盘面零触碰**」(`save_boot_no_activate_test.gd`,12 检查) | 接口 | `MetaProgression.settle_adventure()`(`meta_progression.gd:275`,写盘 :282)无激活态触达写盘被拒——生产链对照面在单测 ③段实测(真表 db 驱动全链) | 变异⑦=闸短路(`if false and _path == ""`)→ 退化写目标两断言红(类级+链级,已执行;首轮断言无判别力经审计判缺漏后强化) | 通过(回炉后) | +| sv4 新档原语 | `save_manager.gd:116-137` `create_slot`(创建即激活+落盘+失败回退) | 冒烟 ③段〔sv4〕非白板旧内容(999/5)覆盖为全零白板+盘面核验非旧值 | 接口 | 空槽调新档 API 后按域读回+盘面文件内容核验 | 变异⑥=create 改读旧档(`load_from_disk()` 代 `_default_data()`)→ 白板两断言红(已执行) | 通过(回炉后) | +| sv5 进档原语 | `save_manager.gd:94-114` `activate_slot`(激活+读盘+失败恢复切换前三态) | 冒烟 ①④段〔sv5〕+单元〔sv5〕 | 接口 | 槽 N 有档 → activate_slot → 读回=该槽内容 | 变异⑤=activate 漏更新 _path → 写盘受理断言红(已执行) | 通过 | +| sv6 删档原语 | `save_manager.gd:140-161` `delete_slot`(文件删除+.tmp 清偿+激活指针连清除+.corrupt.bak 不追删) | 冒烟 ②段〔sv6〕文件系统存在性断言 | 集成 | 激活/非激活槽调删档 API+盘面核查 | 变异②=漏删文件 → 在盘断言红(已执行) | 通过 | +| sv7 摘要三项按槽窥视 | `save_manager.gd:163-168` `peek_slot_summary`+`:170-172` peek_summary_at 注入口+`:174-205` 只读解析 | 单元 `save_slot_summary_test.gd`〔sv7〕(12 检查:三槽异值/空槽/三不可读态)+冒烟 ⑥段 | 单元+接口 | 窥视 API 按槽调用(不激活不写盘——套件内断言 active_slot==0) | 变异③=窥视改读激活态内存 → 跨槽摘要红(已执行) | 通过 | +| sv8 窥视三态零副作用 | 同上(窥视路径零 `_corrupt_fallback` 触达——`:616` 迁移/`:315` 正式读盘均不在窥视调用链) | 冒烟 ⑥段〔sv8〕字节+mtime 双比对+零 .corrupt.bak 改名+.tmp 按空槽 | 接口 | 盘上坏槽/高版本夹具 → 窥视 API | 变异=窥视改走正式 load 路径 → 坏槽字节/在盘断言红(.corrupt.bak 改名主文件消失——sv8 断言族覆盖) | 通过 | +| sv9 迁移链各槽成立 | 迁移链零改动(`SAVE_FORMAT_VERSION=7`+六件自注册不动),执行面按槽独立 | 冒烟 ⑤段〔sv9〕三槽各喂 v5 旧档→各自激活读回+写盘 version=7;既有迁移段(v1/v2/v5 旧档)全数复跑绿 | 接口 | 各槽 `load_from_disk` | 变异=迁移只跑激活槽 → 他槽读回断言红(⑤段按槽独立喂档,共享化即异值不成立) | 通过 | +| sv10 切槽同实例整体替换 | `activate_slot` 内 `_data` 与路径整体替换+失败恢复;`MetaProgression.save` 引用零触碰(meta_progression.gd 零改动) | 单元 `save_slot_isolation_test.gd` ③段〔sv10〕宿主链引用同一性+写盘落当前槽+盘面核验 | 集成 | `MetaProgression.new(null, sm)` 真实宿主链驱动切槽 | 变异=切槽新建实例 → 同一性断言红(meta.save == t 断言锚);写成落错槽 → 位间隔离红(变异⑤已证) | 通过 | +| sv11 测试隔离面保留 | `attach_path`(`:56-64`)语义保持「注入即激活+重读」+`attach_slot_template`(`:66-75`)新增隔离注入口;既有调用面零改(见 T0 清点) | 既有冒烟/探针段全数复跑绿(冒烟 1201/0 含全部既有段;probe_gate --click 重闸复跑)〔sv11〕 | 集成 | 各探针 `isolate_save()`(`.new(path)`+`load_from_disk`)与 `attach_path` 现有 8 处直接消费 | 撤销=注入路径改成忽略 → 依赖隔离的既有断言成片红(成片红即本行证明;实测既有段全绿=语义保持) | 通过 | +| sv12 旧档处置 Q1 甲 | `save_manager.gd:616-631` `_migrate_legacy_save(src,dst,bak)` 字节级搬运+幂等判据+源改名 `.migrated.bak` 留痕;`_ready`(`:48-53`)生产接线 | 冒烟 V2-s4 迁移不变式(真实链)+⑦段夹具断言〔sv12〕(逐域相等/留痕/幂等/他槽零触碰) | 接口 | 盘上旧档夹具跑迁移+真实 boot 链(V2-s4) | 变异=去掉幂等判据 → 预置 777 覆盖断言红(⑦段夹具:槽 1 预置异值档+二次迁移) | 通过 | +| sv13 不升 schema 版本 | `SAVE_FORMAT_VERSION := 7`(`save_manager.gd:9`)与 `_normalize` 零改动(diff 核实) | 既有版本/高版本拒读断言复跑绿(既有段+④段高版本槽)〔sv13〕 | 单元 | 既有冒烟版本段 | 判断依据=本行不改任何行为;升版即撞既有高版本拒读断言 | 通过(零改动核实) | +| sv14 反哺点清单 | §5 所列文档口径(存档序列化.md `:61`/`:20`/`:16`/`:165`、待办清单条目、roadmap 行)列点不落文 | 件核对表〔sv14〕落 `reports/iter33-反哺点-2026-10-21.md` | 件核 | 收口前逐条指认落点 | 判断依据=产出物件核不产码 | 通过(见反哺点清单卡① 节) | + +### 二、变异验证记录表(注入 → 最窄闸见红 → git checkout 精确还原 → 复绿) + +| # | 注入点 | 预期 | 实测红 | 最窄闸 | 还原后 | +|---|---|---|---|---|---| +| ① | `slot_path` 派生写死 `_slot_template % 1`(三槽同一文件) | 三槽往返一致断言红 | sv1 三槽往返一致·槽 1/2 红(`save_slot_isolation_test`) | 单元套件直跑 | all green (22 checks) | +| ② | `delete_slot` 文件删除分支短路(`if false and …`,漏删文件) | 在盘断言红 | sv6 删后文件不在盘红+sv6 非激活槽红+sv7 空槽窥视连带红(冒烟) | `run_smoke.py` 全量 | 1201/0 全绿 | +| ③ | `peek_slot_summary` 改读激活态内存 | 跨槽摘要红 | sv7 窥视三项逐槽正确·槽 1/2 红(`save_slot_summary_test`) | 单元套件直跑 | all green (12 checks) | +| ④ | `_ready` 偷调 `activate_slot(1)`(偷复激活) | boot 断言红 | V2-s1 boot 后无激活态红+V2-s3 写盘拒绝红(冒烟) | `run_smoke.py` 全量 | 1201/0 全绿 | +| ⑤ | `activate_slot` 漏更新 `_path`(写盘落旧目标) | 写盘落错槽红 | sv3 激活态写盘受理·槽 1/2 红(`save_slot_isolation_test`——无激活态拒写路径被触发) | 单元套件直跑 | all green (22 checks) | +| ⑥ | `create_slot` 改读旧档(`load_from_disk()` 代 `_default_data()`,首轮审计 sv4 缺漏回炉) | 全零白板断言红 | sv4 白板态红+sv4 盘面核验红(999 旧值带入;冒烟) | `run_smoke.py` 全量 | 1201/0 全绿 | +| ⑦ | `write_to_disk` 无激活闸短路(首轮审计 sv3 缺漏回炉——拆闸实验证实原断言无判别力) | 拒写盘面零触碰断言红 | sv3 退化写目标零触碰红+sv3 生产链盘面零触碰红(`save_boot_no_activate_test`——杂散 game/.tmp 真实落盘被两锚抓获) | 单元套件直跑 | all green (12 checks) | + +### 三、T0 产物:隔离面全清单清点(sv11 件核) + +逐点确认三槽化后仍可隔离到单槽(判定准则=契约 7 单路径语义「注入路径=激活槽文件,无激活态时注入即激活并重读」): + +| 隔离面 | 位点 | 三槽化后判定 | +|---|---|---| +| `attach_path` 直接消费 | `ui_tune_probe.gd:167/:171`、`t17_defer_probe.gd:21`(注释引)、`probe_ui_util.gd:111`、`pacing_queue_test.gd:28`、`smoke_all.gd:276/:320/:462/:593` | 语义零变化(注入即激活+重读+写盘落注入路径)——实测既有段全绿 | +| `isolate_save`(`.new(path)`+显式读盘) | `probe_ui_util.gd:104-106`(tag 族 10 探针 15 处间接消费) | `.new(path)` 显式传路径形态保持(默认参改 `""` 零波及——全仓无 `.new()` 零参消费) | +| `_META_SMOKE_PATHS` 18 条 | `smoke_all.gd:5697-5704`(T0 订正值),消费=`_meta_new_save(path)` 独立实例 | 与槽位承载正交(独立实例直构),零影响 | +| boot/lifecycle 四段 | `smoke_all.gd:276/:320/:462/:593` | 段内先 attach 后消费,V2d-2 起全部在激活态下运行,零影响 | +| presenter lambda 注入面 | `main.gd:317`(camp ctx) | 未触碰 save 引用,零影响 | +| 真实档红线 | 探针纪律「绝不碰 user://save.json」 | 保持:生产迁移在 autoload `_ready`(玩家 boot 语义),探针注入路径不经过 `user://save.json`;新增槽模板注入口保证测试夹具落 `user://smoke_*` 命名空间 | + +### 四、Q1 执行留痕(sv12 生产实证) + +- **裁定执行**:Q1 甲(迁 1 号存档位)——`_ready` 一次性 `_migrate_legacy_save(user://save.json, user://save_slot_1.json, user://save.json.migrated.bak)`。 +- **本机真实迁移已发生**(批内首次 godot boot 时):源= v7 空进度档(钥石 0、无断点),已字节迁入 `user://save_slot_1.json`,原件留痕 `user://save.json.migrated.bak`。`save.json.quarantined-20261020`(10-19 隔离件)非迁移对象,原样未动。 +- **损坏/高版本旧档路由**:实现取「字节级原样搬运、零解析零改造」——损坏/高版本旧档字节原样落槽 1,「原样落入槽 1」按字面成立;读取该槽时由正式进档路径兜底链(`.corrupt.bak` 改名留盘)/拒读链接管,数据不静默丢失。与本卡 §10 Q1「执行前置检查」的 L3 路由相比,本实现使该意外形态不再触发承诺失效(字节未变),故未走 L3 呈报;此口径已在追认单登记(§9 白名单域内的实现取法,veto 窗口开放)。 +- **幂等判据**:槽 1 已存在不重迁;迁移成功后源已改名,二次启动源不存在=天然无操作。双重保险,sv12 ⑦段夹具断言钉死。 + +### 五、已知中间态(批内事实,随卡③/卡② 合并消除) + +1. **无激活态真空窗口**(卡① 合并后、卡③ 合并前):boot 后无激活态,主菜单「开始冒险/继续上局/营地/生涯」旧钮仍可点——冒险可玩但结算写盘被拒(`settle_adventure` 忽略返回值→进度不落盘)、营地购买写盘被拒。此为卡① Q2 裁甲明知且接受的批内中间态;卡③ 同迭代内随后合并(进档即激活),卡② 再撤旧钮,终态无此窗口。 +2. **boot 态 first_adventure_done=false**:无激活态下 `is_first_adventure_done()` 返回默认 false——窗口期内直开冒险会走首局教学脚本模式(旧档玩家的一次性观感差异);卡③ 合并后生产路径必经进档(激活后读回该槽真实值),窗口外无影响。 +3. **V2d-1 改写**(削弱申报在案):「续玩钮 boot 态==真实档断点态」关系判据 → 「boot 态恒隐(无激活态)」;卡② mn8 撤钮时整族承接。 + +### 六、读数对照 + +| 闸 | 基线(T0) | 末态(卡① 收口) | +|---|---|---| +| 冒烟 | 1156 / 0 | **1201 / 0**(+45:V2-s1..s4 四断言+_smoke_save_slots 段 41 断言) | +| 单元 | 35 套件全绿 | **38 套件全绿**(+3:isolation 22 / boot 12〔回炉+4〕/ summary 12) | +| probe --click | 全绿 | 全绿(重闸复跑) | +| 重闸 --code | — | 全绿(回执随合并提交留痕) | diff --git a/reports/iter33-验收审计-2026-10-21.md b/reports/iter33-验收审计-2026-10-21.md new file mode 100644 index 00000000..f09af3a6 --- /dev/null +++ b/reports/iter33-验收审计-2026-10-21.md @@ -0,0 +1,97 @@ +# 迭代 33 独立验收审计(2026-10-21) + +> 审计人:未参与实现的独立会话,只采信盘面证据与自跑实测,不采信任何报告的「已全绿」。 +> 被审计对象:卡① 存档制数据层改造批(worktree `D:\code\g-plan2-iter33card1-save`,分支 `iter33-card1-save`,实现提交 `6a7c2575`+`074cfe27`,基线 `7280070f`;首轮审计后回炉提交 `4a8558a0`,见文末复审小节)。 +> 审计材料:需求卡片 §7.1/§7.2、实现报告卡① 节实际对照表与变异记录表、`git diff 7280070f..HEAD`、三个新单元套件+冒烟新段+run_all 注册、闸回执。 +> 审计自跑基线(首轮,HEAD=074cfe27):冒烟 **1201 项 / 0 失败**(`python tools/run_smoke.py`,三闸全过);单元 **38 套件全绿**(`--script res://tests/run_all.gd`)——与实现报告读数一致。 +> 复审自跑基线(回炉后,HEAD=4a8558a0):冒烟 **1201 项 / 0 失败**(三闸全过);单元 **38 套件全绿**;boot 套件 **12 检查全绿**——均为审计自跑实测,见复审小节。 + +## 卡① 存档制数据层改造批 + +### ① 逐行核对表(sv1–sv14,对实现报告实际对照表逐行复核) + +| # | 判定 | 依据(审计自查实录) | +|---|---|---| +| sv1 三槽承载+位间隔离 | 通过 | 两落点在盘:单元 `save_slot_isolation_test.gd` ①段+冒烟 `_smoke_save_slots` ①段;〔sv1〕尾缀 grep 命中(冒烟 4 处、单元多处)。真实入口=卡片申报面(SaveManagerCore 按槽 API 直调,不经 UI)未绕过。变异①(slot_path 写死 `% 1`)机理静态复核成立:三槽同名文件则槽 2/3 异值读回必不成立。 | +| sv2 启动态不读盘 | 通过 | 真实入口在位:冒烟 V2-s1/V2-s2 挂真实 autoload boot 链(`main.get_node("/root/SaveManager")`,`_smoke_boot_chain` 内)。单元为类级白盒面——计划落点仅单元、实测拆双落点,报告已如实对账(非隐瞒)。变异④机理成立:`_ready` 偷激活则 V2-s1 `active_slot()==0` 直接判红。 | +| sv3 无激活写盘拒绝 | **缺漏**(首轮判;回炉后复审通过,见文末复审小节) | 见结论缺漏 1。两点:申报真实入口(`MetaProgression.settle_adventure` 链触达写盘被拒)零对照面——单测与冒烟 V2-s3 均裸调 `write_to_disk`,无任何 MetaProgression 链在无激活态下触达写盘的断言;撤销证明经审计实测**不成立**(拆闸后全部 sv3 断言仍绿,详②-2)。报告 sv3 行「代码评审可证」的撤销主张被实验推翻。 | +| sv4 新档原语 | **缺漏(轻)**(首轮判;回炉后复审通过,见文末复审小节) | 断言在盘(冒烟 ③段)且入口合规;但申报变异「新档写成读旧档」未执行,且经流程点推演**无判别力**:冒烟段序为②段先删槽 3、③段才 create_slot(3),此时槽 3 无文件——「读旧档」变体读不存在文件=回默认态,全零断言仍绿。报告以推理代实测(「与 sv5 读回断言同族必红」),未按 test-conventions §6 标「拦截能力未证」。见结论缺漏 2。 | +| sv5 进档原语 | 通过 | 冒烟 ①④段+单元多落点;〔sv5〕grep 命中。变异⑤已执行且机理成立:激活不落 `_path` 则首槽写盘受理返回 false,「sv3 激活态写盘受理」判红——与报告变异表⑤一致。 | +| sv6 删档原语 | 通过 | 冒烟 ②段直接盘面断言(文件不在盘+无 .tmp+指针清除+他槽字节不动)。变异②(短路文件删除)经审计**静态推演**成立:在盘断言直查 `FileAccess.file_exists`,短路即红;⑥段空槽窥视连带红(与报告变异表②的红集一致)。未实测(报告已申报全量冒烟实测),推演记录于②-3。 | +| sv7 摘要三项按槽窥视 | 通过 | 单元 12 检查+冒烟 ⑥段双落点;窥视不激活不写内存有专断言(active_slot==0)。**变异③审计实测复现**:注入读激活态 → `save_slot_summary_test` 红(sv7 逐槽·槽 3+坏槽/高版本/缺版本三不可读态,详②-1)→ 还原 → 12 检查全绿。 | +| sv8 窥视三态零副作用 | 通过 | 冒烟 ⑥段+单元 ③段字节+mtime 双比对+零 `.corrupt.bak` 改名+.tmp 按空槽。变异未实测;静态推演成立:窥视改走正式 `load_from_disk` → `_corrupt_fallback` 改名主文件消失 → 「零改名」断言红。判据面(字节+mtime)实测在案于双落点。 | +| sv9 迁移链各槽成立 | 通过 | 冒烟 ⑤段三槽各喂 v5 旧档逐槽读回+写盘 version=7;既有迁移段(v1/v2/v5)全数复跑绿(审计自跑基线在案)。变异「迁移只跑激活槽」机理静态成立:他槽激活读回值必不成立。 | +| sv10 切槽同实例整体替换 | 通过 | 单元 ③段宿主链:`MetaProgressionScript.new(null, t)` 真实宿主链、`meta.save == t` 引用同一、读数随槽(533→644)、写盘 650 落槽 2 并盘面核验、高版本槽激活失败恢复切换前三态。变异⑤覆盖「写盘落错槽」半面;「新建实例」半面为 API 形状所禁(方法内无从换实例),推理可证。meta_progression.gd 零改动(diff 核实)。 | +| sv11 测试隔离面保留 | 通过(附注) | 非新断言行——卡片申报即「既有段全数复跑」,无代码尾缀可 grep 属申报内形态,非缺漏。凭据:审计自跑基线冒烟 1201/0(含全部既有段)+单元 38 套件绿+回执探针 --click/--full 绿;T0 隔离面清点在实现报告§三(18+15 条、attach_path 8 处、tag 族、boot/lifecycle 四段、presenter lambda、真实档红线逐点判定)。attach_path「注入即激活+重读」语义在代码核实保持(注入槽名记账激活号、注入任意路径写盘目标照旧)。 | +| sv12 旧档处置 Q1 甲 | 通过(附注) | ⑦段夹具四断言钉死语义:逐域相等(1234)、源改名留痕、幂等(预置 777 零覆盖+源保留)、他槽字节不动;幂等变异静态成立(去 `dst` 存在判据 → 二次迁移覆盖 777 → 断言红)。V2-s4 挂真实 boot 链。附注:V2-s4 不变式(旧档在则槽 1 必在)在本机已迁移态下为空真——生产接线面的机检强度依赖盘面状态,但语义面已由夹具钉死,接线面拆毁在有旧档的盘面上仍会红,判可接受。⑦段直调 `_migrate_legacy_save` 为被测私有件本身,有 V2-s4 生产链对照面在,不判绕过。 | +| sv13 不升 schema 版本 | 通过 | **diff 核实零改动**:`SAVE_FORMAT_VERSION := 7` 行基线/HEAD md5 一致(`101adcd0ebe1a18f2004b454a8c85bb8`);`_normalize` 函数体逐字节一致(diff 空输出);迁移链函数零触碰(新窥视路径只读调用二者)。既有版本/高版本拒读断言复跑绿(基线在案+冒烟 ④段高版本槽断言)。 | +| sv14 反哺点清单 | 通过 | `reports/iter33-反哺点-2026-10-21.md` 在盘,六条全覆盖 §5:存档序列化.md :61/:20/:16/:165 四落点+新增迁移条款建议+待办清单+roadmap,各条有「现行陈述/应改成/依据」。件核不产码,合规。 | + +### ② 撤销证明抽查记录(审计自跑实测,命令与输出摘要) + +**②-1 变异③复现(报告变异表③,实测)** +- 注入:`game/core/save_manager.gd` `peek_slot_summary` 函数体首行插 `return {"readable": true, "keystone": keystone_balance(), "cleared": adventure_cleared(), "total": adventure_total()}`。 +- `--headless --path . --script res://tests/unit/core/save_slot_summary_test.gd` → **FAIL**(窗内 4 条:`sv7 窥视三项逐槽正确·槽 3`+`sv7 坏槽窥视=显式损坏标记`+`sv7 高版本槽窥视=不可读标记`+`sv7 缺版本字段槽窥视=不可读标记`;更早 FAIL 行被输出窗口截断)——跨槽摘要与不可读态两面均被抓,拦截力实测成立。 +- `git checkout -- game/core/save_manager.gd` → `git status --porcelain` 空 → 复跑 **all green (12 checks)**。与报告变异表③一致。 + +**②-2 自选变异:sv3 撤销证明检验(审计设计,实测——发现缺漏)** +- 注入:`write_to_disk` 无激活闸 `if _path == "":` 改 `if false and _path == "":`(即 sv3 行申报的撤销操作「去掉判定放行旧入口」)。 +- 跑 `save_boot_no_activate_test.gd` → **仍 all green (8 checks)——申报的红未出现**。 +- 原因查实:拒写断言锚在返回值 `false`,而空路径下游 `FileAccess.open(".tmp", WRITE)` 对 `"" + ".tmp"` 相对路径**打开成功并真实写盘**(实测运行在 worktree `game/` 落杂散文件 `.tmp`,657 字节,内容为序列化默认档 JSON),随后改名到空目标失败才返回 false——「闸拒绝」与「空路径写失败」在全部 sv3 断言(返回值+三槽存在性/字节/mtime)上不可区分;杂散 .tmp 落在项目根不在三槽扫描面内,零断言可抓。 +- 还原:`git checkout --` → 复跑 **all green (8 checks)**;杂散 `game/.tmp` 已删除(审计产物清理,git-ignored 件,删后 `git status` 干净)。 +- 判定:sv3 断言批对「拆闸」无判别力,撤销证明不成立。 + +**②-3 变异②(删档漏删)静态推演(审计设计,未实测——报告已申报全量冒烟实测)** +- 注入设定:`delete_slot` 文件删除分支短路(`if false and FileAccess.file_exists(p) and not _remove_file(p)`),返回 true、文件留盘。 +- 推演:冒烟 ②段 `check(not FileAccess.file_exists(slot_tpl % 3) ...)` 直查文件系统 → 段内文件仍在 → 判红;`sv6 删非激活槽:文件不在盘` 同理红;⑥段 `delete_slot(3)` 后空槽窥视读到残file值 600 → `sv7 空槽窥视=零值合法态` 连带红。红集与报告变异表②一致,拦截面成立。 + +### ③ 测试削弱检查表(diff 全部删改逐处 vs 卡片 §7.2 申报) + +| diff 改动处 | 定性 | vs §7.2 申报 | +|---|---|---| +| `smoke_all.gd` 删 5 行:V2d-1「boot 态==断点在档态」关系判据整块 | 既有断言改写(唯一一处) | **申报内**(§7.2 唯一允许面=boot 链 V2d-1)。判据**收紧非放宽**:原=关系判据 `visible == has_breakpoint`;新=钉死两事实 `not has_breakpoint() and not visible`。断言文本带「卡① sv2 改写」标识与「卡② mn8 再承接」注记,禁判据空转的条件满足。 | +| `save_manager.gd` 删 8 行:6 行注释+2 处生产代码(`_init` 默认参 `"user://save.json"`→`""`、`_ready` 删 `load_from_disk()`) | 生产改动本体 | 非断言删改;两处均=卡片 §7.1 sv2 行申报的改动本体(「_init 默认参取消」+启动态钉死),非测试削弱面。 | +| `smoke_all.gd` 其余 +176 行 | 纯新增(`_smoke_save_slots` 新段+V2-s1..s4) | 不属削弱面;既有段零触碰(game/ 删除行逐行清点:合计 13 行=上两行所列,无其他)。 | +| `run_all.gd` +3 行 | 纯新增三套件注册 | 既有注册零删改、顺序与 expect 域未动——无削弱。 | + +**小结**:除申报内 V2d-1 改写外,diff 中不存在任何未申报的既有断言删改、阈值放宽、扫描面缩小或入口替换。 + +### ④ 回执核验(`game/tests/.gates/receipt.json`) + +- `commit` = `074cfe27a4b4cc6c67d359c033729243e86ce4eb` == 分支尖 HEAD —— **一致**; +- `all_green` = **true**;`domain` = **"code"**(本批只碰 game/ 代码与断言面,选档正确);`quick_mode` = false(非快闸)。 +- 闸明细:01 冒烟全量/02 单元套件/03 探针 --click/04 探针 --full/05 渲染闸/09 静态扫描/10 熵增闸均 green;`11 回放确定性对拍` = NEED-APPROVAL 未跑(`not_run` 在案;与卡片「MC 零跑、纯承载改造」口径相容,此处只记录不另裁);`inherited_red` 注记 09 静态扫描「基线红→现绿(改善)」。 +- 审计对回执的独立支撑:冒烟 1201/0 与单元 38 套件全绿均为审计自跑复现(见文首基线)。 + +## 结论(首轮审计,盘面=074cfe27)——未通过(两缺漏已回炉,终审见文末复审小节) + +首轮缺漏清单(保留存档,回炉事实见复审小节): + +1. **sv3(必回炉,缺漏)**: + - **撤销证明不成立(实测)**:拆除 `write_to_disk` 无激活闸后,单测 2 条断言+冒烟 V2-s3 全部仍绿——拒写断言锚在返回值 false,与空路径下游写失败不可区分;拆闸运行实测在项目根落杂散 `.tmp`(写盘真实发生),而「盘面零触碰」断言只扫三槽路径,抓不到该副作用通道。报告 sv3 行「撤销=去掉判定 → 拒写断言红(代码评审可证)」经实验推翻,变异记录表亦无此条实测。 + - **申报真实入口零对照面**:卡片与报告均申报真实入口=`MetaProgression.settle_adventure()`(`meta_progression.gd:275`,写盘 :282)链触达写盘被拒,但两个测试落点均裸调 `write_to_disk`(单元类级+真实 autoload 直调),无任何 MetaProgression 链在无激活态下触达写盘的断言。 + - 回炉方向(供实现段参考,非裁断):断言加掃描空路径写失败的实际副作用面(如杂散 .tmp 不在盘),并补一条 MetaProgression 链(或等价生产链)无激活触达写盘被拒的断言;补做拆闸变异实测留红。 +2. **sv4(须回炉,缺漏·轻)**:申报变异「新档写成读旧档」未执行,且在现有冒烟段序下(②段先删槽 3、③段才新档)无判别力——读旧档变体读到空档=默认态,全零断言仍绿;报告以推理代实测且未按 test-conventions §6 降格标注「已实现,拦截能力未证」。回炉方向:新档断言前预置带异值的残档夹具(或调整段序),补做该变异实测留红;或按 §6 如实降格标注。 + +其余 sv1/sv2/sv5–sv14 共 12 行逐行**通过**(其中 sv8/sv9/sv12 的变异为审计静态推演补证、sv11/sv12 附注见逐行表);测试削弱检查无未申报删改;回执三项核验全过。首轮审计未合并任何提交、未改 `game/` 下任何文件(两处变异注入均已 `git checkout` 精确还原,审计产物杂散 `.tmp` 已删,`git status` 干净)。 + +## 复审(2026-10-21,回炉提交 4a8558a0) + +**回炉 diff 核验**(`git diff 074cfe27..4a8558a0`,仅两测试文件+实现报告对账,生产代码 `game/core/` 零触碰): +- `save_boot_no_activate_test.gd` 8→12 检查,**纯新增**(删除行逐行清点=仅 3 行文件头注释,零既有断言删改):②段加「拒写退化写目标零触碰」锚——空 `_path` 派生的裸 `.tmp`/`.tmp.tmp` 夹具清场后断言零新增(正是首轮拆闸实验实测的杂散落点通道);新增 ③段 `_test_settle_chain_write_rejected`:真表 ConfigDB+`MetaProgression.settle_adventure` 生产链全段驱动(`mark_first_adventure_done`→`apply_adventure_summary`→`refresh_treasure_unlocks`→`write_to_disk`),断言落账内存(adventure_total=1)而写盘无目标+链触达后退化写目标零新增——首轮「生产链零对照面」缺漏的对照面成立,卡片 sv3 申报真实入口自此有机检面。 +- `smoke_all.gd` ③段**段内改写**:`create_slot` 前预置非白板 v7 旧内容(keystone 999/adventure_total 5/adventure_cleared 2);白板断言加 `adventure_total()==0` 合取项;旧「文件在位」检查升格为「盘面文件内容核验非 999」(解析落盘文件取 `keystone_balance==0`——文件缺失或不可解析时哨兵 -1 必红,存在性被其子集化)。两处被换断言均系本批新增断言(非卡片 §7.2 辖区的批前既有断言),且为升格收紧非放宽;段内检查数 3→3,冒烟总数保持 1201。 +- 实现报告 sv2/sv3/sv4 行与变异表补⑥⑦两行对账更新,sv3 行撤销主张改写为实测执行——与审计实测结果一致。 + +**复审自跑实录**(命令同首轮;每次变异后 `git checkout -- game/core/save_manager.gd` 精确还原并确认 `git status --porcelain -- game/` 为空): +- 基线:`save_boot_no_activate_test.gd` → **all green (12 checks)**;单元全量 `run_all.gd` → **38 套件全绿**;全量冒烟 → **1201 项 / 0 失败三闸过**(回炉后基线独立复现)。 +- **变异⑦复现**(=首轮审计的拆闸实验原样重放):`write_to_disk` 闸改 `if false and _path == ""` 注入 → boot 套件**恰两锚红**——「sv3 拒写退化写目标零触碰(裸 .tmp 零新增——拒绝口可判别锚)」+「sv3 生产链盘面:结算触达后退化写目标零新增(链上真实入口的盘面核验)」,且杂散 `.tmp`(845 字节,链上结算落账数据)真实落盘被两锚抓获 → 还原清理 → 复跑 **all green (12 checks)**。首轮「拆闸不红」的缺漏自此闭合。 +- **变异⑥复现**:`create_slot` 内 `_data = _default_data()` 改 `load_from_disk()`(读旧档)注入 → 全量冒烟**恰 sv4 两行红**——「sv4 新档=全零可玩合法白板态(旧内容 999/5 零带入)」+「sv4 新档落盘=白板内容(盘面核验非 999 旧值)」,1201 项 2 失败 → 还原 → 复跑 **1201/0 全绿三闸过**。sv4 盘面核验断言「真能抓读旧档」经**实测**证实(非静态推演),与报告变异表⑥逐行一致。 +- 回执核验(复审轮,盘面刷新件):`receipt.json` `commit`==`4a8558a0`==HEAD、`all_green`=true、`domain`="code"、`quick_mode`=false;`not_run` 同首轮(11 回放确定性对拍 NEED-APPROVAL,记录不另裁)。 + +**复审削弱检查**:回炉 diff 删除行逐行清点=boot 套件 3 行文件头注释+smoke ③段 1 行注释与 2 行本批新增断言;零批前既有断言删改、零阈值放宽、零扫描面缩小、零入口替换——**未引入新的未申报削弱**。 + +**复审判定**:sv3、sv4 两缺漏回炉后断言均经实测具备判别力(变异⑦/⑥注入见红、还原复绿),sv3 申报真实入口已有生产链对照面(链级断言实测在役),处置未引入新削弱——两行改为**通过**。 + +## 结论(终审) + +**通过**——sv1–sv14 全部 14 行通过(sv3/sv4 回炉后复审通过,凭据见复审小节;首轮未过记录保留于上,回炉事实=提交 `4a8558a0`)。独立验收审计合并前置达成。复审全程零 commit、`game/` 零残留(两处变异注入均已精确还原,杂散件已清)。 diff --git a/reports/需求卡片-存档制数据层改造批-2026-10-18.md b/reports/需求卡片-存档制数据层改造批-2026-10-18.md index f7adfd89..1d32edb8 100644 --- a/reports/需求卡片-存档制数据层改造批-2026-10-18.md +++ b/reports/需求卡片-存档制数据层改造批-2026-10-18.md @@ -9,7 +9,7 @@ ## 0. 前置依赖与并行性 - 卡③(选档界面)依赖本卡数据层;卡②(主菜单三入口化)的「进入存档」钮经卡③落到本卡的激活态。 -- **交付序列待裁(Q2)**:卡③未到位前,全游戏无任何生产激活路径(卡③ 契约 8「进档即激活」是唯一激活入口)——旧入口在无激活态下写盘全被拒,且 `settle_adventure`(meta_progression.gd:284)忽略写盘返回值→进度静默丢失。冒烟探针走 attach_path 隔离不会红,机检放控不住。本卡「可先行」以 Q2 裁甲(与卡③同批或卡③在先)为前提;裁乙/丙须按 Q2 表执行过渡态。 +- **交付序列待裁(Q2)**:卡③未到位前,全游戏无任何生产激活路径(卡③ 契约 8「进档即激活」是唯一激活入口)——旧入口在无激活态下写盘全被拒,且 `settle_adventure`(meta_progression.gd:275,忽略写盘返回值的写盘在 :282)忽略写盘返回值→进度静默丢失。冒烟探针走 attach_path 隔离不会红,机检放控不住。本卡「可先行」以 Q2 裁甲(与卡③同批或卡③在先)为前提;裁乙/丙须按 Q2 表执行过渡态。 - 与在役结构的关系:SaveManager/MetaProgression 已是 autoload(迭代 27 T1 裁点①),本批只改承载面(文件组织与激活态),不改升格结构、不改 autoload 链。 ## 1. 目标与完成定义 @@ -40,9 +40,9 @@ | F3 | 版本与迁移:`SAVE_FORMAT_VERSION := 7`(v7=锻造工坊域 forge_routes);生产迁移链 v1→v7 六件自注册;`_normalize` 缺键补齐+退役域清退 | `save_manager.gd:9`/:25-30/:458-471 | | F4 | 摘要三项的读取源已在役(单档):`keystone_balance()`、`adventure_cleared()`(三关全通才计 1)、`adventure_total()`(开局即计含放弃与失败)——按槽窥视读器=本批 T2 交付物 | `save_manager.gd:214`/:264/:258 | | F5 | SaveManager 为 autoload(工程序 ConfigDB→RngService→SaveManager→MetaProgression);MetaProgression 持有 `var save: SaveManagerCore`,`_ready` 取 `/root/SaveManager` 一次——消费面对存档的引用是单实例单引用;全部访问器为 `save.xxx()` 动态分发、无缓存 | `game/project.godot:21`;`game/core/meta_progression.gd:9`/:21-25/:54 | -| F6 | 探针/冒烟隔离机制在役且面比初查大:`_META_SMOKE_PATHS` 注入清单 **18 条**(smoke_all.gd:5701-5708);另有 `user://smoke_save_*` 15 条(:6499-6511)、boot/lifecycle/lifecycle_resume/run_lifecycle 段(:277/:321/:463/:594)、`test_pacing_queue_save`(pacing_queue_test.gd:28)、`smoke_tune_probe_*`(ui_tune_probe.gd:167/:171)、probe tag 族(probe_ui_util.gd:98-121,10 探针 15 处调用);`attach_path` 直接消费 8 处+isolate_save 间接 15 处 | `game/smoke/smoke_all.gd:5701-5708`/:6499-6511 等;`game/smoke/probe_ui_util.gd:98-121` | +| F6 | 探针/冒烟隔离机制在役且面比初查大:`_META_SMOKE_PATHS` 注入清单 **18 条**(smoke_all.gd:5697-5704);另有 `user://smoke_save_*` 15 条(:6543-6553)〔T0 行号订正 2026-10-21〕、boot/lifecycle/lifecycle_resume/run_lifecycle 段(:277/:321/:463/:594)、`test_pacing_queue_save`(pacing_queue_test.gd:28)、`smoke_tune_probe_*`(ui_tune_probe.gd:167/:171)、probe tag 族(probe_ui_util.gd:98-121,10 探针 15 处调用);`attach_path` 直接消费 8 处+isolate_save 间接 15 处 | `game/smoke/smoke_all.gd:5697-5704`/:6543-6553 等;`game/smoke/probe_ui_util.gd:98-121` | | F7 | 文档侧早已预写多档去向:存档序列化.md P2 后置表载「多存档槽位|单档 MVP(2026-09-21 裁决);多档等有真实需求再立项」——本批即该条预写的「真实需求」落地;同文 :61 现行口径=「存档是单文件 JSON(user://save.json)」;:20 系统范围负责表载「单档 JSON 存档的域划分与读写约定」 | `design/gdd/systems/表现与支撑/存档序列化.md:165`/:61/:20/:16 | -| F8 | 近期闸基线:冒烟 1151/0、单测 51/51(34 套件),载于迭代 30 卡① 合并提交 `7ffd1dc0` 的回执读数(实现报告已退役,git 历史可考)——开工日 T0 重测取当期值 | git 提交 `7ffd1dc0`(迭代 30 卡① 合并) | +| F8 | 近期闸基线:冒烟 1151/0、单测 51/51(34 套件),载于迭代 30 卡① 合并提交 `7ffd1dc0` 的回执读数(实现报告已退役,git 历史可考);T0 复测(2026-10-21):冒烟 1156/0、单测 35 套件全绿、probe --click 全绿 | git 提交 `7ffd1dc0`(迭代 30 卡① 合并) | ## 3. 设计契约(红线,违反 = L3 停机呈报) @@ -169,7 +169,7 @@ |---|---|---|---|---|---| | sv1 三槽承载+位间隔离(契约1/2、V1、T1) | `game/core/save_manager.gd:15` `var _path` 单路径 → 三槽路径派生+当前激活位字段(机制取 §9 第 1 条) | 冒烟 `save_slot_roundtrip`〔sv1〕落 `game/smoke/smoke_all.gd` + 单元 `game/tests/unit/core/save_slot_isolation_test.gd`〔sv1〕 | 接口 | `SaveManagerCore` 实例的按槽 API 读写往返,不经 UI | 变异=三槽路径派生写成同一文件 → 三槽往返一致断言红(T3 变异①) | | sv2 启动态不读盘(完成定义3、V1、T1) | `save_manager.gd:21` `_init` 默认参 `"user://save.json"` 取消+初态 `_data`=`_default_data` | 单元 `game/tests/unit/core/save_boot_no_activate_test.gd` boot 三断言〔sv2〕 | 单元 | 组合根 boot 链起的 `/root/SaveManager`(`game/project.godot:21` autoload 序) | 变异=`_ready` 内偷调 `load_from_disk` → boot 断言红(T3 变异④) | -| sv3 无激活态写盘一律拒绝并返回 false(契约6、V1、T1/T2) | `save_manager.gd:188-202` `write_to_disk` 入口加激活态判定 | 单元〔sv3〕「拒写+返 false+盘面无新文件」三断言,同上落点 | 接口 | `MetaProgression.settle_adventure()`(`meta_progression.gd:284`)无激活态触达写盘 | 撤销=去掉判定放行旧入口 → 拒写断言红 | +| sv3 无激活态写盘一律拒绝并返回 false(契约6、V1、T1/T2) | `save_manager.gd:188-202` `write_to_disk` 入口加激活态判定 | 单元〔sv3〕「拒写+返 false+盘面无新文件」三断言,同上落点 | 接口 | `MetaProgression.settle_adventure()`(`meta_progression.gd:275`,写盘 :282)无激活态触达写盘 | 撤销=去掉判定放行旧入口 → 拒写断言红 | | sv4 新档原语(完成定义2、V2、T2) | 新增空槽创建,初值取 `_default_data` 全零可玩纪律 | 冒烟段〔sv4〕落 `smoke_all.gd` 新档子段 | 接口 | 空槽调新档 API 后按域读回 | 变异=新档写成读旧档 → 全零可玩断言红 | | sv5 进档原语(V3、T2) | 进档=激活+读盘,复用 `load_from_disk`(`save_manager.gd:150-183`)按槽成立 | 冒烟段〔sv5〕 | 接口 | 槽 N 有档 → 进档 API → 读回=该槽档案内容 | 变异=进档只置激活指针不读盘 → 读回断言红 | | sv6 删档原语(契约8、V4、T2) | 新增删除:文件删除+该槽回合法空态+激活指针连清除+无 `.tmp` 残留 | 冒烟段〔sv6〕含文件系统存在性断言 | 集成 | 激活槽调删档 API,再经访问器读该槽+盘面核查 | 变异=漏删文件 → 在盘断言红(T3 变异②) | @@ -177,7 +177,7 @@ | sv8 窥视三态零副作用(契约9、V5、T2) | 窥视对损坏/高版本槽返回显式损坏标记,禁触发 `_corrupt_fallback`(`:443-450`)改名 | 冒烟段〔sv8〕窥视前后文件字节与 mtime 双比对 | 接口 | 盘上坏槽夹具 → 窥视 API | 变异=窥视改走正式 load 路径 → 字节/mtime 断言红 | | sv9 迁移链各槽成立(契约3、V6、T1/T3) | v1→v7 六件迁移链(`:25-30` 自注册)不动,执行面按槽独立成立 | 冒烟段〔sv9〕三槽各喂旧版本档→各读回相等 | 接口 | 各槽 `load_from_disk` | 变异=迁移只跑激活槽 → 他槽读回断言红 | | sv10 切槽=同实例整体换 `_data` 与路径(契约6、V1、T1) | 激活切换取 `attach_path` 先例(`:42-46`);`MetaProgression.save` 引用(`meta_progression.gd:21-25`/`:54`)禁换实例 | 单元〔sv10〕切槽前后引用同一性+写盘落当前槽 | 集成 | `MetaProgression` 真实宿主链驱动切槽 | 变异=切槽新建实例 → 同一性断言红;写成落错槽 → 位间隔离断言红(T3 变异⑤) | -| sv11 测试隔离面保留(契约7、V7、T0/T3) | `attach_path` 与注入路径语义钉为「注入=激活槽文件,无激活态时注入即激活并重读」——`smoke_all.gd:5701-5708` 18 条、`:6499-6511` 15 条、`probe_ui_util.gd:98-121` tag 族行为零变化 | 既有冒烟/探针段全数复跑〔sv11〕+T0 隔离面清点成文件核 | 集成 | 各探针 `isolate_save()` 与 `attach_path` 现有调用面 | 撤销=注入路径改成忽略 → 依赖隔离的既有断言成片红(成片红即本行证明) | +| sv11 测试隔离面保留(契约7、V7、T0/T3) | `attach_path` 与注入路径语义钉为「注入=激活槽文件,无激活态时注入即激活并重读」——`smoke_all.gd:5697-5704` 18 条、`:6543-6553` 15 条、`probe_ui_util.gd:98-121` tag 族行为零变化 | 既有冒烟/探针段全数复跑〔sv11〕+T0 隔离面清点成文件核 | 集成 | 各探针 `isolate_save()` 与 `attach_path` 现有调用面 | 撤销=注入路径改成忽略 → 依赖隔离的既有断言成片红(成片红即本行证明) | | sv12 旧档处置 Q1 甲(契约4、V8、T0) | 首次启动把 `user://save.json` 原样迁入槽 1;幂等=槽 1 已存在不重迁;旧档处置按 §10 Q1「执行前置检查」列逐项 | 冒烟段〔sv12〕迁移前后逐域相等+槽 2/3 空+处置字节留痕件核 | 接口 | 盘上放旧档夹具跑启动链迁移 | 变异=去掉幂等判据 → 二次启动重复迁移断言红;损坏+高版本叠加等意外形态路由 §8 L3 呈报 | | sv13 不升 schema 版本(契约3、完成定义4)——非行为改动 | `SAVE_FORMAT_VERSION := 7`(`:9`)与 `_normalize`(`:458-471`)零改动;确需新键按「v6 域后扩列不升版」先例登记 | 既有版本与高版本拒读断言复跑〔sv13〕 | 单元 | 既有冒烟版本段 | 判断依据=本行不改任何行为;撤销它=升版即撞既有高版本拒读断言(该断言已由 sv9 面钉住) | | sv14 反哺点清单(V9)——非行为改动 | §5 所列文档陈述(存档序列化.md `:61`/`:20`/`:165` 等)随实现换代,本批只列点不落文 | 件核对表〔sv14〕落 `reports/iter33-反哺点-<完成日>.md` | 件核 | 收口前逐条指认落点 | 判断依据=产出物件核不产码;§5 任一条缺落点即 T4 收口不过 | @@ -218,7 +218,7 @@ ### Q2 · 交付序列:本卡可否先于卡③落地(必裁,未裁不定排期) -**背景**:见 §0。卡③未到位前无生产激活入口;旧入口在无激活态下写盘全被拒且 `settle_adventure` 忽略写盘返回值(meta_progression.gd:284)→进度静默丢失;冒烟探针走隔离路径不会红,机检放控不住。 +**背景**:见 §0。卡③未到位前无生产激活入口;旧入口在无激活态下写盘全被拒且 `settle_adventure` 忽略写盘返回值(meta_progression.gd:275,写盘 :282)→进度静默丢失;冒烟探针走隔离路径不会红,机检放控不住。 | 案 | 内容 | 代价 | |---|---|---| diff --git a/reports/需求卡片-选档界面新建批-2026-10-18.md b/reports/需求卡片-选档界面新建批-2026-10-18.md index ffb05aab..dd2309e1 100644 --- a/reports/需求卡片-选档界面新建批-2026-10-18.md +++ b/reports/需求卡片-选档界面新建批-2026-10-18.md @@ -40,6 +40,8 @@ | F7 | 摘要三项读源:现役单档访问器(`keystone_balance`/`adventure_total`/`adventure_cleared`,save_manager.gd:214/258/264)+**按槽窥视读器=卡① T2 交付物(未实现)**;「通关次数」口径=**通关局数 `adventure_cleared`**(同生涯页行,卡① F4 已锁——禁与「各关通关次数 clear_count」混义) | `save_manager.gd:214`/:258/:264;卡① §6 T2 | | F8 | UI 公共件纪律先例(迭代 17 卡③):「复用现行公共件与色板,零新素材零新色值」——本卡同类批,照此执行 | `roadmap.md` 迭代 17 ③行 | +**T0 对齐结论(2026-10-21,卡① 实测交付形态,消契约 9 落空项)**:窥视读器=`peek_slot_summary(slot: int) -> Dictionary`(另有路径注入口 `peek_summary_at(path)` 供探针隔离,契约 7 同款);返回结构=`{"readable": bool, "keystone": int, "cleared": int, "total": int}`;空槽(主文件不在盘,含仅 .tmp 残留)=`readable=true`+三项零值;不可读态(损坏/高版本/缺版本字段/越界槽号)=`readable=false`(Q2 裁甲:UI 一律按空槽呈现,禁自设第三态)。激活与档操作 API:`active_slot() -> int`(0=无激活态)、`create_slot(slot)`/`activate_slot(slot) -> bool`(失败自动恢复切换前三态)/`delete_slot(slot) -> bool`;位数常量=`SaveManagerCore.SLOT_COUNT=3`;槽路径唯一派生口=`slot_path(slot)`,视图/组合根禁自拼槽文件名;测试夹具隔离走 `attach_slot_template(template)` 注入口。 + ## 3. 设计契约(红线,违反 = L3 停机呈报) 🔴 **契约 1 · 三存档位且仅三位**:界面呈现 3 槽(裁决 2);禁参数化槽位列表、禁滚动长列表形态。