# tests/unit/battle_settlement/variant_placement_test.gd — 地形变异单元测试宿主(G6 第二宿主) # 断言面 = 两套(批 B 断言迁移自 smoke_all.gd,G6 施工组): # 地形变异 布置段(编排器战斗初始化第 4 步——门控一致性/布置域/数量公式/连通片上限/确定性;L1/L2 双迭代) # 全局作用域变异(互锁负样本/门控三维/全局池空兜底回落/资源面偏斜守恒与合成序/快照投影不变) # 协议照单元侧惯例:extends SceneTree+Kit.check 主通道;--script 模式无 autoload, # ConfigDB 显式实例化构造注入(各套自建 RngService——源套掷点隔离形态,宿主不持统一流); # 表加载失败不中止(沿被迁断言面的源语义——承 G2/G3/G4/G5 同款)。 # 运行:godot --headless --path game --script res://tests/unit/battle_settlement/variant_placement_test.gd extends SceneTree const Kit := preload("res://tests/unit/test_kit.gd") const ConfigDBScript := preload("res://core/config_db.gd") var _db func _init() -> void: _db = ConfigDBScript.new() # 表加载失败不中止(承 G2/G3/G4/G5 同款:表损坏的红落在具体断言上—— # 诊断在此展开,后续断言按坏表实况逐条验红;collect_only=true 下成员照常赋值可评) var load_ok: bool = _db.load_tables("res://data/tables", true) if not load_ok: push_error("[variant_placement_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]) # 套执行序沿源编排(基点 _run_core_smoke 调用序——本组套的相对序) _smoke_variant_placement(1) _smoke_variant_placement(2) _smoke_global_variant() quit(Kit.finish("variant_placement_test")) # ---- 全局作用域变异 断言面(「全局池空 → region 兜底回落」路径 # ——互锁负样本/门控三维/资源面偏斜守恒与合成序/快照投影不变)---- # ---- 全局作用域变异 断言面(「全局池空 → region 兜底回落」路径 # ——互锁负样本/门控三维/资源面偏斜守恒与合成序/快照投影不变)---- # 取号计数桩(variant_place 取号断言——子类拦截计数,派生与取数语义零改动) class _CountVariantRng extends RngServiceCore: var variant_calls := 0 func open_stream(purpose: String) -> RandomNumberGenerator: if purpose == "variant_place": variant_calls += 1 return super.open_stream(purpose) # ---- 地形变异 布置段(棋盘设置顺序第 4 步断言面)---- # 布置发生在编排器战斗初始化(install 初始展开 + roll_board 资源格之后),生成器产物零变异—— # 本段经 begin_battle 走完整初始化序后断言:门控一致性 / 布置域 / 数量公式 / 连通片上限 / 确定性。 func _smoke_variant_placement(L: int) -> void: print("--- 地形变异 布置段(L%d)---" % L) var rng = load("res://core/rng_service.gd").new() rng.set_master_seed(42) var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng) if not settlement.begin_battle({"level_index": L, "node_depth": 0, "hero_hp": _db.hero.max_hp}): Kit.check("L%d 布置段战斗启动失败" % L, false) return var b = settlement.minesweeper.current_board() Kit.check("L%d 布置段板非空" % L, b != null) if b == null: return var pool_ids: Array = [] for r in _db.variant_pool(L, String(b.board_type)): pool_ids.append(String(r.variant_id)) var vcells: Array = b.variants.keys() var vid0 := "" var single_type := true var bad_id := false for c in vcells: var ids: Array = b.variants[c] if ids.size() != 1: single_type = false for vid in ids: if vid0 == "": vid0 = String(vid) elif vid0 != String(vid): single_type = false if not pool_ids.has(String(vid)): bad_id = true Kit.check("L%d 变异 id 全在候选池(门控一致性:%s)" % [L, str(pool_ids)], not bad_id) Kit.check("L%d 每图至多一种变异类型(单格单 id + 全图同 id)" % L, single_type) var r0 := {} for c in b.compute_r0(): r0[c] = true var domain_ok := true for c in vcells: if r0.has(c) or b.is_mine(c): domain_ok = false Kit.check("L%d 变异格全在初始展开区外且不吃危险格(布置域结构保障——closed_only)" % L, domain_ok) var cand_count := 0 for c in b.safe_cells(): if not r0.has(c): cand_count += 1 var expect_cap: int = int(round(float(cand_count) * float(_db.variation.percent_of_cells) / 100.0)) \ + (L - 1) * int(_db.variation.per_level_bonus) Kit.check("L%d 变异格数 %d ≤ 公式上限 %d(可挖池百分比 + 关卡递增)" % [L, vcells.size(), expect_cap], vcells.size() <= expect_cap) # 数量公式量随候选池缩放:L1 无关卡加成,极小池(<10 格)可 round 至 0—— # 零布置为合法态(同池空口径);非零断言为条件式,公式量>0 时才要求非零(L2/L3 关卡加成保底恒>0) Kit.check("L%d 公式量>0 时非零布置(极小池 round 至 0 = 合法零布置——2026-09-21 基数修正批)" % L, expect_cap == 0 or vcells.size() > 0) var pack_max: int = int(_db.variation.pack_max) var vset := {} for c in vcells: vset[c] = true var seen := {} var pack_ok := true for c in vcells: if seen.has(c): continue seen[c] = true var comp_size := 1 var q: Array = [c] while q.size() > 0: var x: Vector2i = q.pop_front() for n in b.neighbors8(x): if vset.has(n) and not seen.has(n): seen[n] = true comp_size += 1 q.append(n) if comp_size > pack_max: pack_ok = false Kit.check("L%d 全部连通片 ≤ %d 格(全连通生长、2×2/1×4/1×3+1 等形)" % [L, pack_max], pack_ok) # 布置确定性:全新 RngService 同种子重开,变异布置逐格相同(variant_place 流确定性) var rng2 = load("res://core/rng_service.gd").new() rng2.set_master_seed(42) var s2 = load("res://core/battle_orchestrator.gd").new(_db, rng2) s2.begin_battle({"level_index": L, "node_depth": 0, "hero_hp": _db.hero.max_hp}) var b2 = s2.minesweeper.current_board() Kit.check("L%d 同种子变异布置逐格相同(variant_place 流确定性)" % L, b2 != null and _variant_dump(b2) == _variant_dump(b)) func _variant_dump(b) -> String: var keys: Array = b.variants.keys() keys.sort_custom(func(a, b2): return a.y * 1000 + a.x < b2.y * 1000 + b2.x) var parts: Array = [] for c in keys: parts.append("%d,%d:%s" % [c.x, c.y, str(b.variants[c])]) return "|".join(parts) # ---- 完整战斗回放(自动玩家,确定性)——回放断言:同种子两遍步骤流逐行相同 ---- func _smoke_global_variant() -> void: print("--- 全局作用域变异(门控三维 + 全局池空兜底回落 + 资源面偏斜)---") # ① 跨字段互锁负样本(resource_face ⟺ scope=global 三条 + effect_value 新域 [0,50]) var vt = load("res://data/schemas/variation_table.gd").new() vt.rows.assign([{"variant_id": "g1", "display_name": "负甲", "unlock_level": 2, "scope": "region", "effect": "resource_face", "effect_value": 30, "trigger": "always", "placement": "any", "board_types": [], "counter_items": []}]) Kit.check("互锁负样本③:resource_face + scope=region 被拒(resource_face ⇒ global)", _errs_has_cross_field(vt.validate())) vt.rows.assign([{"variant_id": "g2", "display_name": "负乙", "unlock_level": 2, "scope": "global", "effect": "hide_number", "effect_value": 0, "trigger": "always", "placement": "any", "board_types": [], "counter_items": []}]) Kit.check("互锁负样本④:scope=global + effect=hide_number 被拒(global ⇒ resource_face)", _errs_has_cross_field(vt.validate())) vt.rows.assign([{"variant_id": "g3", "display_name": "负丙", "unlock_level": 2, "scope": "global", "effect": "resource_face", "effect_value": 30, "trigger": "on_open", "placement": "any", "board_types": [], "counter_items": []}]) Kit.check("互锁负样本⑤:scope=global + trigger=on_open 被拒(global ⇒ always 整图恒在)", _errs_has_cross_field(vt.validate())) vt.rows.assign([{"variant_id": "g4", "display_name": "负丁", "unlock_level": 2, "scope": "global", "effect": "resource_face", "effect_value": 30, "trigger": "always", "placement": "closed_only", "board_types": [], "counter_items": []}]) Kit.check("互锁负样本⑥:scope=global + placement=closed_only 被拒(global ⇒ any 无格子布置域)", _errs_has_cross_field(vt.validate())) vt.rows.assign([{"variant_id": "g5", "display_name": "负戊", "unlock_level": 2, "scope": "global", "effect": "resource_face", "effect_value": 51, "trigger": "always", "placement": "any", "board_types": [], "counter_items": []}]) var vterr: Array = vt.validate() var domain_hit := false for e in vterr: if String(e.get("category", "")) == "domain": domain_hit = true Kit.check("互锁负样本⑦:resource_face effect_value=51 越域 [0,50] 被拒(不适用 flat_damage [1,9] 加法核)", domain_hit) # ② 门控第三维(节点身份——global 行仅 boss/elite 入池;缺省/普通节点静默跳过零报错) var pool_boss_ids: Array = [] for r in _db.variant_pool(2, "underdark", "boss"): pool_boss_ids.append(String(r.variant_id)) Kit.check("门控三维:L2 underdark boss 池 = region 两行(贫矿脉 2026-10-16 屏蔽删行后无 global 行)", pool_boss_ids == ["magma", "ash"]) var pool_battle_ids: Array = [] for r in _db.variant_pool(2, "underdark", "battle"): pool_battle_ids.append(String(r.variant_id)) Kit.check("门控三维:battle 节点静默跳过 global 行(普通图零报错零警告)", pool_battle_ids == ["magma", "ash"]) Kit.check("门控三维:缺省双参调用 = global 行跳过(既有调用口径零扰动)", _db.variant_pool(2, "underdark").is_empty() == false and _db.variant_pool(2, "underdark").size() == 2) Kit.check("L1 Boss 免疫:unlock_level=2 门控在 boss 节点同样生效(教学保护带 B)", _db.variant_pool(1, "underdark", "boss").is_empty()) Kit.check("全局决议:battle 节点恒无全局型", _db.global_variant_row(2, "underdark", "battle").is_empty()) Kit.check("全局决议:L2 boss 全局池空 → 空行(贫矿脉屏蔽后兜底回落承载)", _db.global_variant_row(2, "underdark", "boss").is_empty()) Kit.check("全局决议:L1 boss 全局池空 → 空行(兜底回落承载)", _db.global_variant_row(1, "underdark", "boss").is_empty() and _db.global_variant_row(1, "dungeon", "boss").is_empty()) Kit.check("elite 前向词位:boss 同池恒空(贫矿脉屏蔽后——词表位不造行)", _db.global_variant_row(2, "underdark", "elite").is_empty()) # ③ 兜底回落:全局池空 → Boss 图改走 region 候选池(恰一行 region 变异 + 格子级布置 + 正常取号) var crng = _CountVariantRng.new() crng.set_master_seed(42) var boss_settlement = load("res://core/battle_orchestrator.gd").new(_db, crng) var boss_ok: bool = boss_settlement.begin_battle({"level_index": 2, "node_depth": 9, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"}) Kit.check("L2 Boss 战斗启动(node_depth=9 Boss 终点——兜底回落断言载体)", boss_ok) var boss1_ids: Array = [] var boss1_variants: Dictionary = {} var boss1_calls := 0 if boss_ok: var bb = boss_settlement.minesweeper.current_board() Kit.check("屏蔽后回落:Boss 图无整图全局型(board_variants 空——全局池空合法态)", bb.board_variant_ids().is_empty()) var boss_vids := {} for vc in bb.variants: for vid in bb.variants[vc]: boss_vids[String(vid)] = true Kit.check("屏蔽后回落:Boss 图 region 变异恰一行且在候选池内(实测 %s)" % str(boss_vids.keys()), boss_vids.size() == 1 and (boss_vids.has("magma") or boss_vids.has("ash"))) Kit.check("屏蔽后回落:Boss 图恢复 region 变异格布置(原替换制跳过 region 的逻辑随全局池空失效)", not bb.variants.is_empty()) Kit.check("屏蔽后回落:variant_place 恢复取号(原全局取用零取号态消失)", crng.variant_calls > 0) var snap: Dictionary = boss_settlement.presentation_snapshot() Kit.check("快照投影:board_variants 空态一致(横幅/角标唯一读取面——无全局型即零预告)", _arr_str_eq(snap.get("board_variants", []), bb.board_variant_ids())) boss1_ids = bb.board_variant_ids() boss1_variants = bb.variants.duplicate() boss1_calls = crng.variant_calls # Boss 图确定性:同种子两遍逐位相同 var crng2 = _CountVariantRng.new() crng2.set_master_seed(42) var boss2 = load("res://core/battle_orchestrator.gd").new(_db, crng2) if boss2.begin_battle({"level_index": 2, "node_depth": 9, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"}): var bb2 = boss2.minesweeper.current_board() Kit.check("确定性:同种子 Boss 图两遍 board_variants/变异格/取号逐位相同(region 布置路径)", _arr_str_eq(bb2.board_variant_ids(), boss1_ids) and bb2.variants == boss1_variants and crng2.variant_calls == boss1_calls) # ④ 资源面偏斜投影(RewardEconomy 纯函数面——守恒/白名单/构造例/合成序) var eco = load("res://core/reward_economy.gd").new(_db, load("res://core/rng_service.gd").new()) var base_rows: Array = eco._rollable_rows() var base_sum: int = eco._pool_weight(base_rows) Kit.check("偏斜基线:正向池现行合计 77(挖掘风险批口径)", base_sum == 77) eco.board_skew = 0 var off_rows: Array = eco._biased_cell_rows(0) # 内容数据驱动扩展批 T4:偏运权重改 reward_roll 订阅命中入参(luck_bias 注入位退役) Kit.check("off 态零扰动:board_skew=0 行集与基线逐位相同(不进投影域)", off_rows.size() == base_rows.size() and _rows_weights_equal(off_rows, base_rows)) eco.board_skew = 30 var on_rows: Array = eco._biased_cell_rows(0) Kit.check("合计守恒:偏斜后正向池合计 = 77(整数最大余数重归一)", eco._pool_weight(on_rows) == 77) var wmap := _rows_weight_map(on_rows) Kit.check("白名单豁免:weaken/gp/action 三行偏斜后逐位不动", wmap.get("weaken", -1) == 8 and wmap.get("ap_up", -1) == 6 and wmap.get("gp_coin", -1) == 2) Kit.check("生存族下调:heal 4→3 / defense 11→7(v=30 构造例)", wmap.get("heal_minor", -1) == 3 and wmap.get("ac_up", -1) == 7) Kit.check("进攻族上浮:attack 14→15 / blast 16→18 / pierce 8→9 / magic 8→9(v=30 构造例,等比上浮守恒)", wmap.get("attack_up", -1) == 15 and wmap.get("spark_aoe", -1) == 18 and wmap.get("pierce_strike", -1) == 9 and wmap.get("magic_bolt", -1) == 9) eco.board_skew = 30 var combo_rows: Array = eco._biased_cell_rows(2) # 件级加算 2=偏运硬币行 value(reward_roll bias_rewards 命中面同值) var cw := _rows_weight_map(combo_rows) Kit.check("合成序:图级偏斜先(乘法面)件级 luck_bias 后(加法 +2×四稀有类)——合计 77+8", eco._pool_weight(combo_rows) == 77 + 4 * 2) Kit.check("合成序构造例:skew(heal 3)+2=5 / gp 2+2=4 / pierce 9+2=11 / magic 9+2=11(图级定型后玩家构筑叠加)", cw.get("heal_minor", -1) == 5 and cw.get("gp_coin", -1) == 4 and cw.get("pierce_strike", -1) == 11 and cw.get("magic_bolt", -1) == 11) eco.board_skew = 50 var v50: Dictionary = _rows_weight_map(eco._biased_cell_rows(0)) Kit.check("域上界 v=50:守恒成立且逐行权重恒正(域 [0,50] 安全)", eco._pool_weight(eco._biased_cell_rows(0)) == 77 and int(v50.get("heal_minor", -1)) > 0) # 普通图摇号全链路确定性(off 路径)——同种子普通图资源映射逐位相同(golden 对拍另有批前快照) var nrng = load("res://core/rng_service.gd").new() nrng.set_master_seed(42) var normal1 = load("res://core/battle_orchestrator.gd").new(_db, nrng) normal1.begin_battle({"level_index": 2, "node_depth": 0, "hero_hp": int(_db.hero.max_hp)}) var n1: Dictionary = _board_resource_dump(normal1) var nrng2 = load("res://core/rng_service.gd").new() nrng2.set_master_seed(42) var normal2 = load("res://core/battle_orchestrator.gd").new(_db, nrng2) normal2.begin_battle({"level_index": 2, "node_depth": 0, "hero_hp": int(_db.hero.max_hp)}) Kit.check("确定性:同种子普通图布置/摇号两遍逐位相同(off 全链路)", n1 == _board_resource_dump(normal2) and not n1.is_empty()) func _arr_str_eq(a, b: Array) -> bool: var arr: Array = a if a is Array else [] if arr.size() != b.size(): return false for i in arr.size(): if String(arr[i]) != String(b[i]): return false return true func _rows_weights_equal(a: Array, b: Array) -> bool: if a.size() != b.size(): return false for i in a.size(): if String(a[i].get("reward_id", "")) != String(b[i].get("reward_id", "")) or int(a[i].weight) != int(b[i].weight): return false return true func _rows_weight_map(rows: Array) -> Dictionary: var m := {} for r in rows: m[String(r.get("reward_id", ""))] = int(r.weight) return m func _board_resource_dump(settlement) -> Dictionary: var b = settlement.minesweeper.current_board() var dump := {} for c in b.variants.keys(): dump["v:%d,%d" % [c.x, c.y]] = ",".join(b.variants[c]) for y in b.height: for x in b.width: var ident = settlement._reward_economy.query_identity(Vector2i(x, y)) if ident != null: dump["r:%d,%d" % [x, y]] = String(ident.reward_id) return dump # ---- 地形变异 布置段(棋盘设置顺序第 4 步断言面)---- # 布置发生在编排器战斗初始化(install 初始展开 + roll_board 资源格之后),生成器产物零变异—— # 本段经 begin_battle 走完整初始化序后断言:门控一致性 / 布置域 / 数量公式 / 连通片上限 / 确定性。 func _errs_has_cross_field(errs: Array) -> bool: for e in errs: if String(e.get("category", "")) == "cross_field": return true return false # ---- 全局作用域变异 断言面(「全局池空 → region 兜底回落」路径 # ——互锁负样本/门控三维/资源面偏斜守恒与合成序/快照投影不变)---- # 取号计数桩(variant_place 取号断言——子类拦截计数,派生与取数语义零改动)