219 lines
14 KiB
GDScript
219 lines
14 KiB
GDScript
# VERTICAL SLICE - NOT FOR PRODUCTION
|
||
# 触发条件机制单测(技能池情境条件触发器批——V1 出域拒绝/V2 双面过滤/V3 保底不逼/V4 闩型语义/
|
||
# V6 事实面供给/V7 池构成纪律/V8 低血防御/V9 停手偷袭/V10 缺省零扰动;V5 对拍由 skill_unlock_test
|
||
# 迁移断言与 round_flow 编排集成断言承载)。
|
||
# 运行:godot --headless --path game --script res://tests/unit/battle_settlement/trigger_cond_test.gd(expect 0)
|
||
extends SceneTree
|
||
|
||
const Kit := preload("res://tests/unit/test_kit.gd")
|
||
|
||
|
||
func _init() -> void:
|
||
var db = load("res://core/config_db.gd").new()
|
||
if not db.load_tables("res://data/tables", false):
|
||
print("[trigger_cond_test] FAIL — 数值表加载失败")
|
||
quit(1)
|
||
return
|
||
|
||
# ---- V1 出域拒绝(六反例+合法行零 error——构造行夹具直改 rows 后重校验)----
|
||
var skill = db.combat.skill
|
||
var base_rows: Array = skill.rows.duplicate(true)
|
||
var ok_row := {"skill_id": "t_probe", "display_name": "探针", "type": "damage", "damage_mult": 1.5,
|
||
"cooldown_beats": 2, "trigger": {"fact": "round_index", "op": "gte", "value": 3, "latch": false}}
|
||
var bad_rows := [
|
||
{"skill_id": "t_bad_fact", "display_name": "坏事实", "type": "damage", "damage_mult": 1.5, "cooldown_beats": 2,
|
||
"trigger": {"fact": "nope", "op": "eq", "value": 1}},
|
||
{"skill_id": "t_bad_op", "display_name": "坏算子", "type": "damage", "damage_mult": 1.5, "cooldown_beats": 2,
|
||
"trigger": {"fact": "round_index", "op": "xor", "value": 1}},
|
||
{"skill_id": "t_bad_key", "display_name": "坏键", "type": "damage", "damage_mult": 1.5, "cooldown_beats": 2,
|
||
"trigger": {"fact": "round_index", "op": "eq", "value": 1, "latch": false, "extra": 1}},
|
||
{"skill_id": "t_bad_latch", "display_name": "坏闩", "type": "damage", "damage_mult": 1.5, "cooldown_beats": 2,
|
||
"trigger": {"fact": "round_index", "op": "eq", "value": 1, "latch": "yes"}},
|
||
{"skill_id": "t_bad_enum_op", "display_name": "枚举坏算子", "type": "damage", "damage_mult": 1.5, "cooldown_beats": 2,
|
||
"trigger": {"fact": "last_round_end", "op": "gt", "value": "stopped"}},
|
||
{"skill_id": "t_bad_enum_val", "display_name": "枚举坏值", "type": "damage", "damage_mult": 1.5, "cooldown_beats": 2,
|
||
"trigger": {"fact": "last_round_end", "op": "eq", "value": "win"}},
|
||
{"skill_id": "t_bad_basic", "display_name": "普攻坏配", "type": "basic",
|
||
"trigger": {"fact": "round_index", "op": "eq", "value": 1}},
|
||
]
|
||
for bad in bad_rows:
|
||
skill.rows = base_rows.duplicate(true)
|
||
skill.rows.append(bad)
|
||
var errs: Array = skill.validate()
|
||
var hit := false
|
||
for e in errs:
|
||
if String((e as Dictionary).get("category", "")) != "empty_table" and String((e as Dictionary).get("message", "")).contains(String(bad.get("skill_id", ""))):
|
||
hit = true
|
||
Kit.check_quiet(hit, "V1 出域拒绝:%s(%s)〔触发器批 V1〕" % [String(bad.get("skill_id", "")), String(bad.get("display_name", ""))])
|
||
skill.rows = base_rows.duplicate(true)
|
||
skill.rows.append(ok_row)
|
||
Kit.check_quiet(skill.validate().is_empty(), "V1 合法触发行零 error〔触发器批 V1〕")
|
||
skill.rows = base_rows
|
||
|
||
# ---- V2 门槛过滤双面(spawn 真实表→直调 rollable/special)----
|
||
var ec = load("res://core/unit_container.gd").new(db)
|
||
Kit.check_quiet(ec.spawn({"level_index": 2, "node_depth": 9, "hero_hp": 30, "hero_atk_override": 8, "hero_max_hp_override": 30, "row_kind": "boss"}), "V2 前置:卓尔 Boss spawn")
|
||
var u = ec.all_enemies()[0]
|
||
Kit.check_quiet(int(u.hp) == int(u.max_hp), "V2 前置:满血(hp=max_hp)")
|
||
var r_full: Array = ec.rollable_skills(u)
|
||
var s_full: Array = ec.special_skills(u)
|
||
Kit.check_quiet(not _has(r_full, "venom_curb") and not _has(s_full, "venom_curb"),
|
||
"V2 条件假(满血):venom_curb 双面不入集合〔触发器批 V2〕")
|
||
u["hp"] = 15
|
||
ec.check_trigger_unlocks(u)
|
||
Kit.check_quiet(_has(ec.rollable_skills(u), "venom_curb") and _has(ec.special_skills(u), "venom_curb"),
|
||
"V2 条件真(半血过闩):venom_curb 双面入集合〔触发器批 V2〕")
|
||
|
||
# ---- V3 保底不逼条件不满足技(保底集合取 special_skills 已含排除——编排级:
|
||
# 无条件特殊技 A w1+条件假技 B w9999+普攻;保底命中摇出的是 A,撤销过滤态同种子必中 B)----
|
||
var ec_v3 = load("res://core/unit_container.gd").new(db)
|
||
Kit.check_quiet(ec_v3.spawn({"level_index": 1, "node_depth": 0, "hero_hp": 8, "hero_atk_override": 6, "hero_max_hp_override": 8}), "V3 前置:地精 spawn")
|
||
var g3 = ec_v3.all_enemies()[0]
|
||
g3["pool"] = [
|
||
{"skill_id": "attack", "weight": 9, "cooldown_left": 0},
|
||
{"skill_id": "heavy", "weight": 1, "cooldown_left": 0}, # A=无条件特殊技
|
||
{"skill_id": "desperate_guard", "weight": 9999, "cooldown_left": 0,
|
||
"trigger": {"fact": "self_hp_pct", "op": "lte", "value": 35, "latch": false}}, # B=条件假技 w9999
|
||
]
|
||
g3["basic_streak"] = int(db.combat.skill.pity_threshold) # 保底已触发位(夹具内存改行——round_flow 白盒先例)
|
||
var specials_v3: Array = ec_v3.special_skills(g3)
|
||
Kit.check_quiet(_has(specials_v3, "heavy") and not _has(specials_v3, "desperate_guard"),
|
||
"V3 保底集合含 A 排除 B(special_skills 真实入口——条件假技不逼出)〔触发器批 V3〕")
|
||
var drawn: String = ""
|
||
for i in range(30):
|
||
var stream_v3 = _rng(700123).open_stream("behavior_roll")
|
||
var pick = load("res://core/skill_lottery.gd").draw(specials_v3, stream_v3)
|
||
if String(pick) == "desperate_guard":
|
||
drawn = "desperate_guard"
|
||
break
|
||
Kit.check_quiet(drawn == "",
|
||
"V3 保底摇号 30 次零逼出 B(SkillLottery 真实摇号——撤销过滤态 w9999 同种子必中,红/绿两态由撤销证明承载)〔触发器批 V3〕")
|
||
|
||
# ---- V4 闩型语义(过门恰一次/治疗抬血不回锁/阈值式判别 max_hp=8 value=35:hp=3 触发 hp=4 不触发)----
|
||
Kit.check_quiet(int(u.hp) == 15 and ec.check_trigger_unlocks(u).is_empty(),
|
||
"V4 闩已置:二次复查零新解锁(过门恰一次)〔触发器批 V4〕")
|
||
u["hp"] = int(u.max_hp) # 治疗抬回满血
|
||
Kit.check_quiet(ec.check_trigger_unlocks(u).is_empty() and _has(ec.rollable_skills(u), "venom_curb"),
|
||
"V4 治疗抬血不回锁(单向闩)〔触发器批 V4〕")
|
||
var ec8 = load("res://core/unit_container.gd").new(db)
|
||
Kit.check_quiet(ec8.spawn({"level_index": 1, "node_depth": 0, "hero_hp": 8, "hero_atk_override": 6, "hero_max_hp_override": 8}), "V4 前置:地精 spawn(hp 基线 8)")
|
||
var g = ec8.all_enemies()[0]
|
||
g["pool"] = [{"skill_id": "attack", "weight": 9, "cooldown_left": 0},
|
||
{"skill_id": "desperate_guard", "weight": 1, "cooldown_left": 0,
|
||
"trigger": {"fact": "self_hp_pct", "op": "lte", "value": 35, "latch": false}}]
|
||
g["hp"] = 4
|
||
Kit.check_quiet(_not_has(ec8.rollable_skills(g), "desperate_guard"),
|
||
"V4 阈值式判别:hp=4 > T=round(8×35/100)=3 不入可摇集合〔触发器批 V4〕")
|
||
g["hp"] = 3
|
||
Kit.check_quiet(_has(ec8.rollable_skills(g), "desperate_guard"),
|
||
"V4 阈值式判别:hp=3 ≤ T=3 入可摇集合(取整读数判别会在此漂移——阈值式钉死)〔触发器批 V4〕")
|
||
|
||
# ---- V6 事实面供给(负向 blast 递增/正向 blast 不递增/轮末转存/开战清零+facts 组包四键在位)----
|
||
var RigS = load("res://core/battle_orchestrator.gd")
|
||
var orch = RigS.new(db, _rng(700001))
|
||
Kit.check_quiet(orch.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": 40, "hero_atk": 8,
|
||
"hero_max_hp": 40, "hero_armor_class": 10, "hero_ap_bonus": 0, "attunement": [],
|
||
"feats": [], "tree_rules": [], "battle_index": 1, "voucher_rap": 0, "voucher_time_sand": 0,
|
||
"node_type": "battle", "adv_bucket": {}}), "V6 前置:战斗受理")
|
||
# facts 组包经真实摇号路径消费(mines 全零时条件技 sneak_ambush 不入集合——facts 四键在位的可观察面)
|
||
Kit.check_quiet(int(orch._mines_hits["total"]) == 0 and int(orch._mines_hits["last"]) == 0 and int(orch._mines_hits["round"]) == 0,
|
||
"V6 开战清零:三计数全零起〔触发器批 V6〕")
|
||
# 正向 blast(spark_aoe)不递增——负向计数守卫(flat 行应用面自编排器迁至奖励授予件
|
||
# ——迭代40 卡① 拆件;计数经授予件 battle_hooks.negative_blast 回宿主 _mines_hits)
|
||
var att_v6 := {"source_class": "reward", "cell": Vector2i(-1, -1), "ri": 1}
|
||
orch.reward_grant()._apply_flat_row({"cell_type": "blast", "polarity": "positive", "power": 2,
|
||
"display_name": "爆裂", "reward_id": "spark_aoe"}, att_v6)
|
||
Kit.check_quiet(int(orch._mines_hits["total"]) == 0 and int(orch._mines_hits["round"]) == 0,
|
||
"V6 正向 blast(spark_aoe)不递增(负向守卫——清图/爆裂格零误计)〔触发器批 V6〕")
|
||
# 负向 blast(雷)递增
|
||
orch.reward_grant()._apply_flat_row({"cell_type": "blast", "polarity": "negative", "power": 2,
|
||
"display_name": "警觉", "reward_id": "enemy_alert"}, att_v6)
|
||
Kit.check_quiet(int(orch._mines_hits["total"]) == 1 and int(orch._mines_hits["round"]) == 1,
|
||
"V6 负向 blast(雷)递增恰一次〔触发器批 V6〕")
|
||
|
||
# ---- V7 池构成纪律(现行全表零违例+构造全条件池报错——跨表校验真实入口)----
|
||
var cv = load("res://core/config_db_cross_validator.gd").new(db)
|
||
var pool_errs: Array = cv.validate_skill_pool_links(db.combat.skill, db.combat.enemy, db.combat.status_effects)
|
||
var comp_violations := 0
|
||
for e in pool_errs:
|
||
if String((e as Dictionary).get("category", "")) == "pool_composition" and String((e as Dictionary).get("message", "")).contains("无条件特殊技"):
|
||
comp_violations += 1
|
||
Kit.check_quiet(comp_violations == 0, "V7 现行全表零违例(每池至少一行无条件特殊技)〔触发器批 V7〕")
|
||
var enemy_backup: Array = db.combat.enemy.rows.duplicate(true)
|
||
var cond_only_row: Dictionary = (db.combat.enemy.rows[0] as Dictionary).duplicate(true)
|
||
var cond_pool: Array[Variant] = []
|
||
for entry in (cond_only_row.get("pool", []) as Array):
|
||
var sid := String((entry as Dictionary).get("skill_id", ""))
|
||
var srow_c: Dictionary[Variant, Variant] = db.combat.skill.row_by_id(sid)
|
||
if not srow_c.is_empty() and String(srow_c.get("type", "")) == "basic":
|
||
cond_pool.append(entry) # 普攻保留(普攻不计入无条件特殊技)
|
||
cond_pool.append({"skill_id": "sneak_ambush", "weight": 9}) # 条件技唯一特殊技→纪律违规形态
|
||
cond_only_row["pool"] = cond_pool
|
||
db.combat.enemy.rows[0] = cond_only_row
|
||
var errs2: Array = cv.validate_skill_pool_links(db.combat.skill, db.combat.enemy, db.combat.status_effects)
|
||
var hit2 := false
|
||
for e in errs2:
|
||
if String((e as Dictionary).get("message", "")).contains("无条件特殊技"):
|
||
hit2 = true
|
||
Kit.check_quiet(hit2, "V7 构造全条件池报 pool_composition 错〔触发器批 V7〕")
|
||
db.combat.enemy.rows = enemy_backup
|
||
|
||
# ---- V8 示范投放:低血防御(地精两行池 w1+trigger lte 35——真实表 spawn 压 hp 开轮摇号)----
|
||
var ec_v8 = load("res://core/unit_container.gd").new(db)
|
||
Kit.check_quiet(ec_v8.spawn({"level_index": 1, "node_depth": 0, "hero_hp": 8, "hero_atk_override": 6, "hero_max_hp_override": 8}), "V8 前置:地精 spawn")
|
||
var g8 = ec_v8.all_enemies()[0]
|
||
var has_dg := false
|
||
for e in (g8.get("pool", []) as Array):
|
||
if String((e as Dictionary).get("skill_id", "")) == "desperate_guard":
|
||
has_dg = true
|
||
Kit.check_quiet(has_dg and int(g8.max_hp) == 8,
|
||
"V8 前置:地精池含孤注守势 w1(真实表读值——阈值 T=round(8×35/100)=3)〔触发器批 V8〕")
|
||
Kit.check_quiet(_not_has(ec_v8.rollable_skills(g8), "desperate_guard"),
|
||
"V8 hp=8>3 不入可摇集合〔触发器批 V8〕")
|
||
g8["hp"] = 3
|
||
Kit.check_quiet(_has(ec_v8.rollable_skills(g8), "desperate_guard"),
|
||
"V8 hp=3≤3 入可摇集合(真实入口开轮摇号判据)〔触发器批 V8〕")
|
||
|
||
# ---- V9 示范投放:停手偷袭(钩爪恐魔池 w2+last_round_end eq stopped——facts 缺键=拦 fail-safe)----
|
||
var ec_v9 = load("res://core/unit_container.gd").new(db)
|
||
Kit.check_quiet(ec_v9.spawn({"level_index": 3, "node_depth": 1, "hero_hp": 30, "hero_atk_override": 8, "hero_max_hp_override": 30}), "V9 前置:钩爪恐魔 spawn")
|
||
var h9 = ec_v9.all_enemies()[0]
|
||
Kit.check_quiet(_not_has(ec_v9.rollable_skills(h9), "sneak_ambush"),
|
||
"V9 首轮(facts 缺 last_round_end 键=fail-safe 拦)不入集合〔触发器批 V9〕")
|
||
Kit.check_quiet(_not_has(ec_v9.rollable_skills(h9, {"last_round_end": "cleared", "round_index": 2,
|
||
"mines_hit_last": 0, "mines_hit_total": 0}), "sneak_ambush"),
|
||
"V9 cleared 轮后不入集合〔触发器批 V9〕")
|
||
Kit.check_quiet(_has(ec_v9.rollable_skills(h9, {"last_round_end": "stopped", "round_index": 2,
|
||
"mines_hit_last": 0, "mines_hit_total": 0}),
|
||
"sneak_ambush"), "V9 stopped 轮后入集合(真实表 w2)〔触发器批 V9〕")
|
||
|
||
# ---- V10 缺省零扰动(无 trigger 行的池快照逐键无新增+摇号同种子不变——快照键策略钉死)----
|
||
var ec_v10 = load("res://core/unit_container.gd").new(db)
|
||
Kit.check_quiet(ec_v10.spawn({"level_index": 1, "node_depth": 0, "hero_hp": 8, "hero_atk_override": 6, "hero_max_hp_override": 8}), "V10 前置:地精 spawn")
|
||
var g10 = ec_v10.all_enemies()[0]
|
||
var stray := 0
|
||
for e in (g10.get("pool", []) as Array):
|
||
for k in (e as Dictionary).keys():
|
||
if not ["skill_id", "weight", "cooldown_left", "trigger", "trigger_latched"].has(String(k)):
|
||
stray += 1
|
||
Kit.check_quiet(stray == 0, "V10 无 trigger 行池快照零新键(快照键策略钉死——防未来给普行塞键)〔触发器批 V10〕")
|
||
|
||
quit(Kit.finish("trigger_cond_test", "PASS — 触发条件 V1/V2/V4/V6/V7/V8/V9/V10 全绿(V5 对拍由 skill_unlock/round_flow 编排集成承载)"))
|
||
|
||
|
||
func _rng(seed_val: int):
|
||
var r = load("res://core/rng_service.gd").new()
|
||
r.set_master_seed(seed_val)
|
||
return r
|
||
|
||
|
||
func _has(arr: Array, sid: String) -> bool:
|
||
for e in arr:
|
||
if String((e as Dictionary).get("skill_id", "")) == sid:
|
||
return true
|
||
return false
|
||
|
||
|
||
func _not_has(arr: Array, sid: String) -> bool:
|
||
return not _has(arr, sid)
|