建 game/tests/integration/(此前只有章程句无实体):首个集成用例 save_rng_roundtrip_test.gd =存档 ⇄ RNG 快照往返,17 条断言,跨 SaveManager(落盘+版本迁移链)× AdventureRunner(断点消费) × RngService(续跑零跳号)× 停点态确定性。与 unit/core/breakpoint_resume_test 的分工:那件是单测 只验首战开板一致,本件验跨系统闭环——含「旧版本档经迁移链后断点仍可续玩」这一真实玩家路径。 并入 run_all manifest(27 套,零新机制)。 变异验证:把断点 rng_counter 改坏 → 精确命中「续玩取数号位 = 断点号位」断言见红。 tools/run_all_gates.sh 新增 11 号「回放确定性对拍」**条件闸件**:每次跑批都对 merge-base 做一遍 触面判定(data/tables/rng_service/save_manager/meta_progression/settlement/damage_math/ damage_controller/battle_orchestrator/adventure_monte_carlo),未触面 SKIP;触面也**不自动跑**—— 对拍调的是 MC 脚本,属「跑批发起前须用户批准」红线,闸只负责把触面判定做一遍并显式要求批准 (批准后 PARITY_APPROVED=1 重跑),杜绝「该跑却没人记得」。指纹逻辑把 SKIP/NEED-APPROVAL 同记 skip, 两种未跑状态互变不算闸况变化。baseline.json 登记 11 闸条目(新增闸件否则会判「无基线」而红)。 README:目录布局增 integration/;新增「测试三层与承载目录」节(L1/L2/L3 各自的判什么/承载/入闸方式 与分层判据);integration 章程句由「将来落地」改为「已建承载」。
133 lines
6.2 KiB
GDScript
133 lines
6.2 KiB
GDScript
# tests/integration/save_rng_roundtrip_test.gd — L2 集成用例:存档 ⇄ RNG 快照往返
|
|
# 跨系统面 = SaveManager(落盘 + 版本迁移链)× AdventureRunner(断点消费)×
|
|
# RngService(种子/取数号位续跑)× BoardGenerator(开板确定性)。
|
|
# 与 tests/unit/core/breakpoint_resume_test.gd 的分工:那件是单测,只验「断点写读 + 首战开板一致」;
|
|
# 本件验的是**跨系统闭环**——盘上往返(含旧版本档经迁移链)之后,断点仍能被冒险层消费且 RNG 续跑
|
|
# 零跳号,是「玩家的旧存档还能不能接着打」这条真实路径。
|
|
# 运行:godot --headless --path game --script res://tests/integration/save_rng_roundtrip_test.gd
|
|
extends SceneTree
|
|
|
|
const SaveManagerScript := preload("res://core/save_manager.gd")
|
|
const AdventureRunnerScript := preload("res://core/adventure_runner.gd")
|
|
const RngServiceScript := preload("res://core/rng_service.gd")
|
|
const Kit := preload("res://tests/unit/test_kit.gd")
|
|
|
|
const SEED := 4242
|
|
const SAVE_PATH := "user://test_integration_save_rng.json"
|
|
|
|
var _db: ConfigDb
|
|
var _disk: String
|
|
|
|
|
|
func _init() -> void:
|
|
_disk = ProjectSettings.globalize_path(SAVE_PATH)
|
|
_clear_disk()
|
|
_db = load("res://core/config_db.gd").new() as ConfigDb
|
|
if not _db.load_tables("res://data/tables", false):
|
|
Kit.check("数值表加载", false)
|
|
quit(Kit.finish("save_rng_roundtrip"))
|
|
return
|
|
_test_disk_roundtrip_preserves_breakpoint()
|
|
_test_old_version_save_migrates_and_resumes()
|
|
_clear_disk()
|
|
quit(Kit.finish("save_rng_roundtrip"))
|
|
|
|
|
|
# ---- 用例一:盘上往返保真——写档再读回,断点逐键零漂移 ----
|
|
func _test_disk_roundtrip_preserves_breakpoint() -> void:
|
|
var rng := RngServiceScript.new()
|
|
var sm := SaveManagerScript.new(SAVE_PATH)
|
|
var adr := AdventureRunnerScript.new(_db, rng)
|
|
adr.set_save_manager(sm)
|
|
Kit.check_quiet(adr.start_adventure(SEED, {"hero_override": {}, "treasure_unlocked": {}}),
|
|
"往返用例:冒险启动")
|
|
Kit.check_quiet(sm.has_breakpoint(), "往返用例:入口停点断点在档")
|
|
Kit.check_quiet(sm.write_to_disk(), "往返用例:断点写盘")
|
|
|
|
var before: Dictionary = sm.breakpoint_snapshot()
|
|
var sm2 := SaveManagerScript.new(SAVE_PATH)
|
|
Kit.check_quiet(sm2.load_from_disk(), "往返用例:自盘读回受理")
|
|
Kit.check_quiet(sm2.has_breakpoint(), "往返用例:读回后断点仍在档")
|
|
var after: Dictionary = sm2.breakpoint_snapshot()
|
|
Kit.check_quiet(int(after.get("master_seed", 0)) == SEED, "往返用例:主种子往返保真")
|
|
Kit.check_quiet(int(after.get("rng_counter", -1)) == int(before.get("rng_counter", -2)),
|
|
"往返用例:RNG 取数号位往返保真(存档不得重耗/跳号)")
|
|
var bonus_before: Dictionary = before.get("bonus", {}) as Dictionary
|
|
var bonus_after: Dictionary = after.get("bonus", {}) as Dictionary
|
|
var bonus_same := bonus_after.size() == bonus_before.size()
|
|
for k in bonus_before:
|
|
if int(bonus_after.get(k, -99999)) != int(bonus_before[k]):
|
|
bonus_same = false
|
|
Kit.check_quiet(bonus_same, "往返用例:永久强化账本往返保真(逐键比整数值——JSON 往返不改值)")
|
|
Kit.check_quiet(int(after.get("hero_hp", -1)) == int(before.get("hero_hp", -2)),
|
|
"往返用例:生命账本往返保真")
|
|
|
|
|
|
# ---- 用例二:旧版本档经迁移链后仍可续玩(单测不覆盖的跨系统集成面)----
|
|
func _test_old_version_save_migrates_and_resumes() -> void:
|
|
var rng := RngServiceScript.new()
|
|
var sm := SaveManagerScript.new(SAVE_PATH)
|
|
var adr := AdventureRunnerScript.new(_db, rng)
|
|
adr.set_save_manager(sm)
|
|
if not adr.start_adventure(SEED, {"hero_override": {}, "treasure_unlocked": {}}):
|
|
Kit.check("旧档用例:冒险启动前置", false)
|
|
return
|
|
var runner = adr.runner()
|
|
var c0: int = rng.counter()
|
|
var cand: Array = runner.next_candidate_ids()
|
|
if cand.is_empty():
|
|
Kit.check("旧档用例:入口停点有候选路", false)
|
|
return
|
|
var fp_before := _board_fingerprint(runner)
|
|
var hp_before: int = runner.hero_hp
|
|
sm.write_to_disk()
|
|
|
|
# 降级为上一版格式档:抹掉当前版本新增域并回退版本号,模拟玩家手里的旧档。
|
|
var raw := FileAccess.get_file_as_string(_disk)
|
|
var parsed: Variant = JSON.parse_string(raw)
|
|
if not (parsed is Dictionary):
|
|
Kit.check("旧档用例:盘上档可解析", false)
|
|
return
|
|
var old: Dictionary = (parsed as Dictionary).duplicate(true)
|
|
old["version"] = 6
|
|
var meta: Dictionary = old.get("meta", {}) as Dictionary
|
|
meta.erase("forge_routes")
|
|
old["meta"] = meta
|
|
var f := FileAccess.open(_disk, FileAccess.WRITE)
|
|
if f == null:
|
|
Kit.check("旧档用例:旧档可落盘", false)
|
|
return
|
|
f.store_string(JSON.stringify(old))
|
|
f.close()
|
|
|
|
var sm_old := SaveManagerScript.new(SAVE_PATH)
|
|
Kit.check_quiet(sm_old.load_from_disk(), "旧档用例:旧档经迁移链可载")
|
|
Kit.check_quiet(sm_old.has_breakpoint(), "旧档用例:迁移后断点未被迁移链吃掉")
|
|
var forge: Dictionary = sm_old.forge_routes_snapshot()
|
|
Kit.check_quiet(str(forge.get("weapon", "")) == "sword",
|
|
"旧档用例:迁移补齐当前版本新增域(缺 forge_routes 的旧档按初始持有态可玩)")
|
|
|
|
# 旧档断点仍能被冒险层消费,且 RNG 续跑零跳号、开板与中断前逐位一致
|
|
var bp: Dictionary = sm_old.breakpoint_snapshot()
|
|
var rng2 := RngServiceScript.new()
|
|
var adr2 := AdventureRunnerScript.new(_db, rng2)
|
|
Kit.check_quiet(adr2.resume_adventure(bp, {"hero_override": {}, "treasure_unlocked": {}}),
|
|
"旧档用例:旧档断点续玩受理")
|
|
Kit.check_quiet(int(adr2.master_seed) == SEED, "旧档用例:续玩主种子回填")
|
|
Kit.check_quiet(rng2.counter() == c0, "旧档用例:续玩取数号位 = 断点号位(零重耗零跳号)")
|
|
var runner2 = adr2.runner()
|
|
Kit.check_quiet(runner2.hero_hp == hp_before, "旧档用例:续玩生命账本回填")
|
|
Kit.check_quiet(_board_fingerprint(runner2) == fp_before,
|
|
"旧档用例:续玩停点态与中断前逐位一致")
|
|
|
|
|
|
func _board_fingerprint(runner) -> String:
|
|
# 停点态指纹:候选路 + 生命 + 取数号位(无战斗对象时的可比面)
|
|
var cand: Array = runner.next_candidate_ids()
|
|
return "%s|%d" % [str(cand), runner.hero_hp]
|
|
|
|
|
|
func _clear_disk() -> void:
|
|
if FileAccess.file_exists(_disk):
|
|
DirAccess.remove_absolute(_disk)
|