Files
gplan-2/game/tests/unit/battle_settlement/variant_placement_test.gd
T
DKChan ea322599f7 卡②(结构小件批p1-p7+V15): 七点定点修落地+运行期违约通道负面套件+wr10 偏运缝拆验收——①p1 状态角标判据去重(main_overlay_flow._sync_status_entry 一组重复赋值语句删除,battle_live 局部量随之退役=wr1 载体);②p2 确认窗开合归模态栈单口(main 两处 _on_confirm_* 改 _close_modal;存档删除入口改 root._open_modal(root.confirm_dialog) 后再 open,遮挡记账/挂起兜底/开侧同步同口);③p3 棋盘动作与 stop 两入口显式 settlement 空防护(锁谓词异常时不崩于空引用);④p4 会话更替即清队:_begin_session 首行 _row_pacer.flush()(待播行不住局态=机制保证非时序约定);⑤p5 缝拆:RewardEconomy 删跨域直持(_fx/_ctx/set_fx)改注入 luck_bias_query: Callable,reward_roll 发射与 bias_rewards 求和收回 BattleFxHost.luck_bias_for(cell_count)(三参形 dispatch,不带 facts——禁复用 fx_emit 的第四实参会打开 when 事实条件面),组装点 set_fx 一行换赋值一行 1 换 1;类变量 15−2+2 净 15,gdstyle 无第 16 枚;⑥p6/p7 RngService 未知用途标签与未播种取数两分支加 violation_handler 记录位+release 下 return null 空引用即刻崩(fail-fast 传导,合法路径不可达)——debug 下 assert 先中断函数本体两态不可判别,故记录位是唯一可观测面;⑦board_generator/reward_economy 各加 fatal_handler 注入位并由组装点接 orchestrator._fatal(release 剥 assert 后仍经 battle_result 封印真中止)。新件 tests/unit/core/run_time_fatal_test.gd(manifest 登记 expect=1 negative=true):wr5 协作件 _fatal 经组装点传导 p5 封印+缺省形态不外溢/wr6 未知标签通道/wr7 未播种通道(各用新建 RngService 实例,播种断言 _counter==0)。wr10 三条入 variant_placement_test=判定面夹具白盒装载(现网数据无 bias_rewards 行,沿 tree_rules_v11_test 先例)+接线同源+权重生效 77+4*2。验收:variant_placement 49 checks 全绿(wr10 ①②③ PASS:空订阅 0/命中 1 行/装载后 2;查询口有效且与宿主同源;偏运池实测 85);run_time_fatal_test 退出码 1 三点绿;pacing_queue 绿(wr4 前置夹具真入队 2 行→更替后清零);ui_click_probe 问题 0(wr2 遮挡账开/关两侧断言);round_flow_skill_pool 130 checks 绿;wr11 dump_battle 六组(L{1,2}×S{42,779401,779501})与改前 before_* 逐字节 cmp 全等;gdstyle 基线外新违规 0、恶化 1(level_map_screen 类变量 22>21=iter38 存量非本批触碰面)、基线文件零改动〔结构小件批 V15〕
2026-10-27 18:39:53 +08:00

332 lines
19 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)
# wr10 偏运判定收口+注入接线(p5 缝拆)——判定面夹具白盒装载(现网 game/data/tables/ 无产出
# bias_rewards 的数据行,先例=tree_rules_v11_test 读 _triggers);命中数前置写在消息里防空扫描面假绿。
var wrng = load("res://core/rng_service.gd").new()
wrng.set_master_seed(42)
var woc = load("res://core/battle_orchestrator.gd").new(_db, wrng)
var wfx = woc.fx_host()
var empty_bias: int = wfx.luck_bias_for(4)
wfx.engine._push_trigger("feat", "wr10_fixture", {"on": "reward_roll",
"do": {"kind": "bias_rewards", "value": 2}}, {})
var hit_rows: int = wfx.engine._triggers.size()
var loaded_bias: int = wfx.luck_bias_for(4)
Kit.check("wr10①判定面:空订阅=%d / 装载后命中 %d 行 → luck_bias_for(4)=%d(期望 0/1/2;命中 0 即判据空转)" % [empty_bias, hit_rows, loaded_bias],
empty_bias == 0 and hit_rows == 1 and loaded_bias == 2)
Kit.check("wr10②接线面:组装点注入的查询口有效且与宿主查询口同源",
woc._reward_economy.luck_bias_query.is_valid() and woc._reward_economy.luck_bias_query == wfx.luck_bias_for)
woc._reward_economy.board_skew = 0
var wbias: int = int(woc._reward_economy.luck_bias_query.call(4))
Kit.check("wr10③权重生效面:注入回调取回 %d → 偏运池合计 77+4*2(实测 %d)" % [wbias, woc._reward_economy._pool_weight(woc._reward_economy._biased_cell_rows(wbias))],
wbias == 2 and woc._reward_economy._pool_weight(woc._reward_economy._biased_cell_rows(wbias)) == 77 + 4 * 2)
# 普通图摇号全链路确定性(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 取号断言——子类拦截计数,派生与取数语义零改动)