# 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: _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: # ① 纯函数复现:图 = f(参数档, 种子)——同档同种子两次产图逐位同图,换种子必变图 var gen = NodeMapGenerator.new(_fresh_rng(910001)) var params2: Dictionary = _db.level_gen_params_row(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.level_gen_params_row(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.level_gen_params_row(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.level_count() + 1): if (_db.level_row(li) as Dictionary).has("templates"): tpl_free = false Kit.check("level 表三行均无 templates 键(模板数据已清出)〔v9〕", tpl_free and _db.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)