# tests/integration/node_map_templates_test.gd — L2 集成用例:关卡节点图生成·模板池 # 断言面 = 模板池套(批 B 断言迁移自 smoke_all.gd `_smoke_node_map_templates`,G12 施工组; # 主会话裁决六整套集成不拆——跨四系统且走落盘)。 # 跨系统面 = RngService(node_map 用途标签/抽签确定性/零消耗计账)× # LevelRunner(start_level_external 受理/模板 0 回滚档)× # AdventureRunner(first_script 全关零抽签/逐关恰一抽签)× # SaveManager+MetaProgression(首次冒险布尔位收束置位落盘——真实档写盘)。 # 与单元侧同入口同协议:extends SceneTree+Kit.check 主通道;--script 模式无 autoload, # ConfigDB 显式实例化构造注入;表加载失败不中止(沿被迁断言面的源语义——诊断展开,红由断言落)。 # 运行:godot --headless --path game --script res://tests/integration/node_map_templates_test.gd extends SceneTree const Kit := preload("res://tests/unit/test_kit.gd") var _db 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_templates_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_node_map_templates() quit(Kit.finish("node_map_templates_test")) # ---- 关卡节点图生成·模板池冒烟(v0.4 §P0.6 断言面)---- func _smoke_node_map_templates() -> void: # ① 扩签:node_map 入用途标签封闭词表(派生零改动——标签只进事件日志) var svc = load("res://core/rng_service.gd").new() Kit.check("node_map 标签入 rng_service 封闭词表(扩签通道=改代码+冒烟守卫)", (svc.PURPOSE_TAGS as Array).has("node_map")) # ② 池参数:三关各 5 模板、模板 0 存在(回滚档成员) for li in range(1, 4): var row := {} for r in _db.level.rows: if int(r.level_index) == li: row = r var tpls: Array = row.get("templates", []) Kit.check("L%d 模板池 = 5 张(拍板点④——首版池规模)" % li, tpls.size() == 5) Kit.check("L%d 模板 0 = 现行固定图(回滚档成员)" % li, not tpls.is_empty() and String(tpls[0].get("display_name", "")).ends_with("(现行)")) # ③ 强制取 0 = 模板 0 节点表逐位相等 + 零 scope 消费 var rng0 = load("res://core/rng_service.gd").new() rng0.new_adventure(910001) var runner0 = load("res://core/level_runner.gd").new(_db, rng0) var c_before: int = rng0.counter() Kit.check("强制 template_pick=0 起关受理", runner0.start_level_external(2, {"hero_hp": 20, "gp": 0}, {}, # start_level_external 签名收口(bonus 参数由 RunLedger 单类下传) Callable(self, "_smoke_rest_stub"), {}, true, 0)) Kit.check("强制取模板零 RNG 消耗(零抽签——首次脚本语义)", rng0.counter() == c_before) var tpl0: Dictionary = {} for r in _db.level.rows: if int(r.level_index) == 2: tpl0 = r.get("templates", [])[0] Kit.check("强制 0 的生效节点表 = 模板 0 逐位相等(回滚档成立)", runner0.level_row.get("nodes", []) == tpl0.get("nodes", [])) # ④ 抽签确定性:同种子两实例同模板(同种子同操作序列 → 逐位同图) var names := [] for i in range(2): var rg = load("res://core/rng_service.gd").new() rg.new_adventure(910002) var rn = load("res://core/level_runner.gd").new(_db, rg) rn.start_level_external(2, {"hero_hp": 20, "gp": 0}, {}, Callable(self, "_smoke_rest_stub"), {}, true, -1) names.append(rn.active_template_name) Kit.check("抽签确定性:同种子逐位同图(实测 %s)" % String(names[0]), names[0] == names[1] and String(names[0]) != "") # ⑤ N=1 池与现行固定图等价:单模板行过图校验(抽签取模 1 恒 0 = 回滚档) var lt = load("res://data/schemas/level_table.gd").new() lt.rows.assign([{"level_index": 9, "display_name": "t", "rest_heal_pct": 30, "templates": [tpl0]}]) Kit.check("N=1 池表通过图校验(回滚档形态合法)", lt.validate().is_empty()) # ⑥ 首次脚本分支:first_script=true 全关模板 0;false = 抽签(scope 恰一差异) var rngF = load("res://core/rng_service.gd").new() rngF.new_adventure(910003) var adrF = load("res://core/adventure_runner.gd").new(_db, rngF) adrF.start_adventure(910003, {"first_script": true}) Kit.check("首次脚本模式:L1 取模板 0(全关零抽签——存档布尔位 false 语义)", String(adrF.runner().active_template_name).ends_with("(现行)")) var cF: int = rngF.counter() var rngT = load("res://core/rng_service.gd").new() rngT.new_adventure(910003) var adrT = load("res://core/adventure_runner.gd").new(_db, rngT) adrT.start_adventure(910003, {"first_script": false}) Kit.check("抽签模式恰多消费 1 scope(node_map 逐关恰一——L1 起关抽签)", rngT.counter() == cF + 1) Kit.check("抽签模式 active_template_name 有值(MC 分层出数取数面在位)", String(adrT.runner().active_template_name) != "") # ⑦ 收束置位:新档 false → settle 置 true 恰一次(幂等) DirAccess.remove_absolute("user://smoke_node_map_save.json") # 测试隔离——上轮 settle 写盘的残留档先删 var sv = load("res://core/save_manager.gd").new("user://smoke_node_map_save.json") sv.load_from_disk() Kit.check("新档首次冒险布尔位 = false(默认域)", sv.is_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.is_first_adventure_done() == true) mp.settle_adventure({"levels_cleared": 0, "keystone_total": 0, "cleared": false, "result": "failed"}) Kit.check("置位幂等(重复收束不翻转)", sv.is_first_adventure_done() == true) # ---- 冒烟恢复执行体(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)