151 lines
9.1 KiB
GDScript
151 lines
9.1 KiB
GDScript
# tests/integration/node_map_determinism_test.gd — L2 集成用例:关卡节点图生成·确定性承载
|
||
# 断言面 = 生成图确定性套(批 B 断言迁移自 smoke_all.gd,G12 施工组;
|
||
# 主会话裁决六整套集成不拆——跨四系统且走落盘)。
|
||
# 跨系统面 = RngService(纯派生流零计数器占用)×
|
||
# LevelRunner(start_level/start_level_external 受理·生成图装配·单一图源)×
|
||
# AdventureRunner(开局种子承载·断点写读后同图)×
|
||
# SaveManager+MetaProgression(首次冒险布尔位收束置位落盘——真实档写盘)。
|
||
# 与单元侧同入口同协议:extends SceneTree+Kit.check 主通道;--script 模式无 autoload,
|
||
# ConfigDB 显式实例化构造注入;表加载失败不中止(沿被迁断言面的源语义——诊断展开,红由具体断言落)。
|
||
# 运行:godot --headless --path game --script res://tests/integration/node_map_determinism_test.gd
|
||
extends SceneTree
|
||
|
||
const Kit := preload("res://tests/unit/test_kit.gd")
|
||
const SaveManagerScript := preload("res://core/save_manager.gd")
|
||
const AdventureRunnerScript := preload("res://core/adventure_runner.gd")
|
||
|
||
var _db
|
||
var _bp_path := "user://smoke_node_map_save.json"
|
||
|
||
|
||
func _init() -> void:
|
||
# oracle: design/engineering/test-conventions.md:56 (判定上报通道——验证函数组)
|
||
_db = load("res://core/config_db.gd").new()
|
||
# 表加载失败不中止(承 G2/G3/G4/G5 同款:表损坏的红落在具体断言上)
|
||
var load_ok: bool = _db.load_tables("res://data/tables", true)
|
||
if not load_ok:
|
||
push_error("[node_map_determinism_test] 表加载返回失败——诊断如下(红由具体断言落)")
|
||
for d in _db.diagnostics():
|
||
if d.severity == "error" or d.severity == "fatal":
|
||
push_error(" [%s] %s: %s" % [d.severity, d.category, d.message])
|
||
_smoke_generated_map_determinism()
|
||
quit(Kit.finish("node_map_determinism_test"))
|
||
|
||
|
||
# ---- 关卡节点图生成·确定性承载断言面 ----
|
||
func _smoke_generated_map_determinism() -> void:
|
||
# oracle: design/gdd/systems/冒险循环/关卡节点图生成.md:134 (起关/选路/断点重生成同图组)
|
||
# ① 纯函数复现:图 = f(参数档, 种子)——同档同种子两次产图逐位同图,换种子必变图
|
||
var gen = NodeMapGenerator.new(_fresh_rng(910001))
|
||
var params2: Dictionary = _db.run.level_gen_params.row_by_level_index(2)
|
||
var g_a: Dictionary = gen.generate(params2, 910002)
|
||
var g_b: Dictionary = gen.generate(params2, 910002)
|
||
var g_c: Dictionary = gen.generate(params2, 910003)
|
||
var nodes_a: Array = g_a.get("nodes", [])
|
||
Kit.check("同参数档同种子两次产图逐位同图(D2 执行边界)〔v7〕",
|
||
nodes_a == (g_b.get("nodes", []) as Array))
|
||
Kit.check("同参数档换种子即变图(图确为种子函数,非恒定输出)",
|
||
nodes_a != (g_c.get("nodes", []) as Array))
|
||
Kit.check("产图节点携图内唯一句柄 id 与自入口 depth(消费侧寻址面)",
|
||
_not_empty_graph(nodes_a) and _handles_unique(nodes_a))
|
||
# ② 消费侧单一图源:起关装配的图与生成器直取逐位一致(runner 内不存第二份图数据)
|
||
var runner1 = load("res://core/level_runner.gd").new(_db, _fresh_rng(910004))
|
||
Kit.check("单关起关受理(种子 910004——生成图直供,无兜底图)",
|
||
runner1.start_level(2, 910004))
|
||
Kit.check("起关生效图 = 生成器同档同种子直取逐位一致(消费侧无第二图源)〔v7〕",
|
||
runner1.level_row.get("nodes", []) == (gen.generate(params2, 910004) as Dictionary).get("nodes", []))
|
||
# ③ 产图零统一流消费(纯派生流不占计数器号位——断点在任意号位重生成同图的机制承载)
|
||
var rng_c = _fresh_rng(910005)
|
||
var counter_before: int = rng_c.counter()
|
||
var runner2 = load("res://core/level_runner.gd").new(_db, rng_c)
|
||
runner2.hero_hp = 20 # 冒险模式生命经主角账预置(旧 ledger 注入参数的等效构造面)
|
||
Kit.check("冒险模式起关受理(入口停点)", runner2.start_level_external(3,
|
||
Callable(self, "_smoke_rest_stub"), true))
|
||
Kit.check("起关产图零计数器消费(同图不依赖取数号位)", rng_c.counter() == counter_before)
|
||
var params3: Dictionary = _db.run.level_gen_params.row_by_level_index(3)
|
||
var entry_count: int = int(params3.get("entry_count", -1))
|
||
Kit.check("入口停点候选数=参数档入口个数(L3 档 %d——多入口首跳即选路,禁写死单入口)〔v2〕" % entry_count,
|
||
entry_count >= 1 and (runner2.next_candidates() as Array).size() == entry_count)
|
||
if entry_count >= 2:
|
||
var eids: Array = runner2.next_candidate_ids()
|
||
Kit.check("第二入口经句柄显式进入(句柄 %d)〔v2〕" % int(eids[1]),
|
||
runner2.pick_next_node(int(eids[1])) and int(runner2.current_node_id()) == int(eids[1]))
|
||
# ④ 开局种子承载:新档(首局)种子 42 两次开局逐位同图,且与生成器直取一致
|
||
DirAccess.remove_absolute(_bp_path) # 测试隔离——上轮落盘残留先删,断点面从零档起
|
||
var sv_w = SaveManagerScript.new(_bp_path)
|
||
var adr_x = AdventureRunnerScript.new(_db, _fresh_rng(42))
|
||
adr_x.set_save_manager(sv_w) # 先挂档再开局——停点快照由真实写手落盘
|
||
Kit.check("首局口径种子 42 开局受理(defer_entry 路径)", adr_x.start_adventure(42,
|
||
{"hero_override": {}, "treasure_unlocked": {}}))
|
||
var adr_y = AdventureRunnerScript.new(_db, _fresh_rng(42))
|
||
Kit.check("同种子第二次开局受理", adr_y.start_adventure(42,
|
||
{"hero_override": {}, "treasure_unlocked": {}}))
|
||
Kit.check("种子 42 两次开局图逐位同图(教学局恒同图——首局固定种子的确定性承载)〔v7〕",
|
||
adr_x.runner().nodes() == adr_y.runner().nodes())
|
||
Kit.check("开局图 = L1 参数档在种子 42 下的生成图(开局无隐式取数分叉)",
|
||
adr_x.runner().nodes() == (gen.generate(_db.run.level_gen_params.row_by_level_index(1), 42) as Dictionary).get("nodes", []))
|
||
# ⑤ 断点写→恢复→再产图同图(续玩按主种子+参数档重生成,断点不存图快照)
|
||
Kit.check("停点断点落盘在档", sv_w.has_breakpoint())
|
||
var bp: Dictionary = sv_w.breakpoint_snapshot()
|
||
var adr_z = AdventureRunnerScript.new(_db, load("res://core/rng_service.gd").new())
|
||
Kit.check("断点续玩受理(代差标记键在档)〔v8〕", adr_z.resume_adventure(bp,
|
||
{"hero_override": {}, "treasure_unlocked": {}}))
|
||
Kit.check("续玩重生成图与中断前逐位同图〔v7〕", adr_z.runner().nodes() == adr_x.runner().nodes())
|
||
var adr_old = AdventureRunnerScript.new(_db, load("res://core/rng_service.gd").new())
|
||
var bp_old: Dictionary = bp.duplicate(true)
|
||
bp_old.erase("graph_gen_v2") # 换代前旧断点形态(模板池时代无代差标记键)
|
||
Kit.check("缺生成图代差标记键的旧断点被显式拒绝(禁按新语义静默回放)〔v8〕",
|
||
adr_old.resume_adventure(bp_old, {}) == false)
|
||
# ⑥ 关卡表不再携带图数据(图源换代后 level 行只剩行级字段)
|
||
var tpl_free := true
|
||
for li in range(1, _db.run.level.level_count() + 1):
|
||
if (_db.run.level.row_by_level_index(li) as Dictionary).has("templates"):
|
||
tpl_free = false
|
||
Kit.check("level 表三行均无 templates 键(模板数据已清出)〔v9〕",
|
||
tpl_free and _db.run.level.level_count() == 3)
|
||
# ⑦ 收束置位:新档 false → settle 置 true 恰一次(幂等)
|
||
var sv = SaveManagerScript.new(_bp_path + ".flag.json")
|
||
sv.load_from_disk()
|
||
Kit.check("新档首次冒险布尔位 = false(默认域——首局恒走固定种子 42)",
|
||
sv.meta_state.career_state().first_adventure_done == false)
|
||
var mp = load("res://core/meta_progression.gd").new(_db, sv)
|
||
mp.settle_adventure({"levels_cleared": 1, "keystone_total": 10, "cleared": false, "result": "failed"})
|
||
Kit.check("冒险收束点置位(三结局同写——写入只在收束挂点)", sv.meta_state.career_state().first_adventure_done == true)
|
||
mp.settle_adventure({"levels_cleared": 0, "keystone_total": 0, "cleared": false, "result": "failed"})
|
||
Kit.check("置位幂等(重复收束不翻转)", sv.meta_state.career_state().first_adventure_done == true)
|
||
DirAccess.remove_absolute(_bp_path)
|
||
DirAccess.remove_absolute(_bp_path + ".flag.json")
|
||
|
||
|
||
# 图数据良构前置(防空集假绿——节点数 > 1、句柄唯一、depth 与 next 键在位)
|
||
func _not_empty_graph(nodes: Array) -> bool:
|
||
if nodes.size() < 2:
|
||
return false
|
||
for node in nodes:
|
||
var n := node as Dictionary
|
||
if not n.has("id") or not n.has("depth") or not n.has("next"):
|
||
return false
|
||
return true
|
||
|
||
|
||
func _handles_unique(nodes: Array) -> bool:
|
||
var seen := {}
|
||
for node in nodes:
|
||
var id: int = int((node as Dictionary).get("id", -1))
|
||
if id < 0 or seen.has(id):
|
||
return false
|
||
seen[id] = true
|
||
return true
|
||
|
||
|
||
func _fresh_rng(seed_val: int):
|
||
var r = load("res://core/rng_service.gd").new()
|
||
r.new_adventure(seed_val)
|
||
return r
|
||
|
||
|
||
# ---- 恢复执行体(LevelRunner 注入用——与 AdventureRunner.apply_rest 同式占位;
|
||
# smoke_all.gd helper 的宿主自持私有副本——源文件收尾整体删除,宿主自给自足)----
|
||
func _smoke_rest_stub(pct: int, hp: int, max_hp: int) -> int:
|
||
return mini(int(floor(float(max_hp) * float(pct) / 100.0)), max_hp - hp)
|