313 lines
18 KiB
GDScript
313 lines
18 KiB
GDScript
# 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 取号断言——子类拦截计数,派生与取数语义零改动)
|