iter33 卡① T1+T2+T3: 存档制数据层三槽承载+启动态钉死+档操作原语+窥视读器+断言批
基线复读(T0,2026-10-21):冒烟 1156/0、单测 35 套件全绿、probe_gate --click 全绿。 承载面(save_manager.gd):SLOT_COUNT=3 三文件制 user://save_slot_N.json(§9 白名单①拍板, 槽1 禁复用旧名单独派生);启动态钉死 _ready 不读槽(内存=默认空态、无激活态、写盘一律拒——sv2/sv3); 旧单档一次性字节迁槽1(Q1 甲:幂等判据槽1已存在不重迁、源改名 .migrated.bak 留痕——sv12); 原语 activate_slot/create_slot/delete_slot(失败恢复切换前三态、删激活槽指针连清除——sv4/sv5/sv6); 窥视读器 peek_slot_summary 三态零副作用(sv7/sv8);测试隔离注入口 attach_slot_template+ peek_summary_at(契约7 单路径语义保持,attach_path/注入面零变化——sv11)。 断言批:三新单元套件(isolation 22/boot 8/summary 12)+冒烟 V2-s1..s4 启动态段+ _smoke_save_slots 段(往返×3/删档/新档/各槽迁移/窥视/旧档迁移);V2d-1 按削弱申报改写 boot 恒隐(卡② mn8 再承接)。 真实链实证:本机 user://save.json(v7 空档)已随批内首次 boot 迁入槽1,原件留痕 .migrated.bak。
This commit is contained in:
1 parent
7280070f3d
commit
6a7c25755f
9 files changed
+687
-13
No files matched your search
+223
-8
@@ -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 = 高版本拒读(默认数据运行、禁写盘)。
|
||||
# 损坏兜底:原文件改名 <path>.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"
|
||||
|
||||
+176
-5
@@ -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,164 @@ 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):空槽创建=全零可玩白板,创建即激活并落盘
|
||||
check(t.create_slot(3), "sv4 新档受理·槽 3(创建即激活)〔卡① sv4〕")
|
||||
check(t.active_slot() == 3 and t.keystone_balance() == 0 and not t.has_breakpoint(),
|
||||
"sv4 新档=全零可玩合法白板态(激活号 3/钥石 0/无断点)〔卡① sv4〕")
|
||||
check(FileAccess.file_exists(slot_tpl % 3), "sv4 新档落盘(文件在位)〔卡① 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)
|
||||
@@ -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 续跑 × 停点态确定性)
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# tests/unit/core/save_boot_no_activate_test.gd — 卡① sv2/sv3:启动态钉死类级契约
|
||||
# (无参实例=无激活态、内存=默认合法空态、无激活写盘一律拒绝并返回 false 且盘面零触碰、
|
||||
# 访问器 fail-safe 合法空值、越界槽号拒绝。真实 autoload boot 链断言在冒烟 V2-s1..s4 段——
|
||||
# --script 模式无 autoload,本套件为类级白盒面,双落点在实现报告实际对照表对账。)
|
||||
# 载体:run_all 单元套件(显式实例化夹具——--script 模式无 autoload)。
|
||||
extends SceneTree
|
||||
|
||||
const SaveManagerScript := preload("res://core/save_manager.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_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 生产链)
|
||||
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]
|
||||
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〕")
|
||||
# 拒读禁写态语义在位(高版本拒读→写拒绝——既有契约回归锚)
|
||||
sm._read_only = true
|
||||
Kit.check_quiet(not sm.write_to_disk(), "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〕")
|
||||
@@ -0,0 +1 @@
|
||||
uid://ria52wdjq6tj
|
||||
@@ -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〕")
|
||||
@@ -0,0 +1 @@
|
||||
uid://bsibspaf7delx
|
||||
@@ -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")
|
||||
@@ -0,0 +1 @@
|
||||
uid://43rqlcpx6ur3
|
||||
Reference in new issue
Block a user