Files
gplan-2/game/tests/unit/battle_settlement/damage_controller_test.gd
T

456 lines
23 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/damage_controller_test.gd — 伤害控制器·伤害单据五配方单元测试
# 运行:godot --headless --path game --script res://tests/unit/battle_settlement/damage_controller_test.gd
# 覆盖:hero_attack / enemy_skill / board_typed / board_blast / board_flat_hero 五配方 +
# 结账时机双模式(A 即结 / B 攒批 flush)+ 死亡作废(来源敌死/主角倒下 halt)+ requires 前置门 +
# 池乘区 clamped_pool(池内加算→clamp;ac 型不入池)+ AC 合流(override/表值兜底/状态叠层)+
# 行协议公共字段(architecture.md API Boundaries B1→D1)+ 同种子逐位复现。
# 红线:期望值由表读值推导(零玩法数值字面量);零自建 RNG(RngService 统一流,夹具种子仅求复现)。
# 断言口径:归因等式在「无敌人死亡」时严格成立;有死亡时降为下界(超额伤害溢出属合法语义)。
extends SceneTree
const ConfigDBScript := preload("res://core/config_db.gd")
const RngServiceScript := preload("res://core/rng_service.gd")
const UnitContainerScript := preload("res://core/unit_container.gd")
const StatusContainerScript := preload("res://core/status_container.gd")
const DamageControllerScript := preload("res://core/damage_controller.gd")
const BattleFactsScript := preload("res://core/battle_facts.gd")
const TEST_SEED := 20260928 # 夹具种子(基建值非玩法数值——回放确定性契约的输入)
const Kit := preload("res://tests/unit/test_kit.gd") # T6 收口:_check/汇总退出样板单点
var _rows: Array = [] # 当前 run 的行协议收集(_on_row 绑定法——避 lambda 多行控制流坑)
func _init() -> void:
# oracle: design/gdd/systems/战斗循环/战斗结算.md:360 伤害单据编排宿主(装载组)
var db = ConfigDBScript.new()
var load_ok: bool = db.load_tables("res://data/tables", true)
Kit.check("前置:表加载零 error", load_ok and db.diagnostics_count("error") == 0)
if load_ok:
_test_five_recipes_and_replay(db)
_test_policy_immediate_mode(db)
_test_source_death_void(db)
_test_identical_content_dual_enemy(db)
_test_target_id_payload(db)
_test_requires_gate_and_zero(db)
_test_hero_death_halt(db)
_test_ac_merge(db)
_test_pool_clamp(db)
_test_pool_consumption_per_unit(db)
_test_defender_ac_per_unit(db)
quit(Kit.finish("damage_controller_test"))
# ---- 夹具 ----
func _on_row(head, ri, display, value, extra) -> void:
_rows.append({"type": head.get("type", ""), "source_class": head.get("source_class", ""), "origin_cell": head.get("origin_cell", Vector2i(-1, -1)),
"round_index": ri, "display": display, "value": value, "extra": extra, "highlight": head.get("highlight", "")})
func _harness(db, depth: int = 0):
var rng = RngServiceScript.new()
rng.set_master_seed(TEST_SEED)
var units = UnitContainerScript.new(db)
var effects = StatusContainerScript.new(db)
var ctrl = DamageControllerScript.new(db, rng, units, effects, BattleFactsScript.new()) # facts 构造注入(5.1 封装修复——hero_atk 派生读消费面;夹具空 encounter=表值缺省)
ctrl.emit_row_cb = Callable(self, "_on_row")
effects.notify_battle_start()
var spawn_ok: bool = units.spawn({"level_index": 1, "node_depth": depth, "hero_hp": int(db.combat.hero.max_hp)})
return {"db": db, "rng": rng, "units": units, "effects": effects, "ctrl": ctrl, "spawn_ok": spawn_ok}
func _row_by_display_prefix(prefix: String) -> Dictionary:
for r in _rows:
if String(r.display).begins_with(prefix):
return r
return {}
# ---- 五配方一次全流程 + 双跑逐位复现 ----
func _run_recipe_sequence(db) -> Array:
# oracle: design/gdd/systems/战斗循环/战斗结算.md:362(五段全序——配方序列组)
_rows = []
var h = _harness(db)
var units = h.units
var ctrl = h.ctrl
if not h.spawn_ok:
Kit.check("前置:units.spawn(1,0) 成功(遭遇行在案)", false)
return []
var hero_before: int = int(units.hero_unit().hp)
var enemy_hp_before: Array = []
for e in units.all_enemies():
enemy_hp_before.append(int(e.hp))
var front = units.default_target()
var threshold_expected: int = ctrl.defender_ac(front) + int(h.db.combat.settlement.damage_roll.attack_bonus)
var atk_type := ""
var eff_keys: Array = h.db.combat.settlement.damage_roll.armor_effectiveness.keys()
if eff_keys.size() > 0:
atk_type = String(eff_keys[0])
var floor_v: int = int(h.db.combat.settlement.damage_roll.damage_floor)
var t1: int = ctrl.submit(ctrl.open_ticket(DamageControllerScript.KIND_HERO_ATTACK, "hero_attack", "T1主角攻击",
{"channel": "战斗段", "base_value": int(h.db.combat.hero.atk),
"target_rule": DamageControllerScript.TARGET_FRONT_ALIVE, "ri": 1}))
# 敌方技来源取后位敌(前位是攻击/直伤/群伤的靶位——来源与靶位分离,免死置干扰配方 redeemed 断言)
var alive_list: Array = units.query_alive("enemy")
var src0: Dictionary = alive_list[alive_list.size() - 1]
var ticket2: Dictionary = ctrl.open_ticket(DamageControllerScript.KIND_ENEMY_SKILL, "enemy_action", "T2敌方技",
{"channel": "战斗段", "base_value": int(src0.atk), "target_rule": DamageControllerScript.TARGET_HERO,
"ri": 1, "rider": floor_v, "context": {"skill_id": "fixture_skill", "baseline": int(src0.atk)}})
ticket2["source_unit"] = src0
var t2: int = ctrl.submit(ticket2)
var t3: int = ctrl.submit(ctrl.open_ticket(DamageControllerScript.KIND_BOARD_TYPED, "clear_reward", "T3克制直伤",
{"channel": "棋盘兑付", "base_value": floor_v, "target_rule": DamageControllerScript.TARGET_FRONT_ALIVE,
"ri": 1, "attack_type": atk_type}))
var t4: int = ctrl.submit(ctrl.open_ticket(DamageControllerScript.KIND_BOARD_BLAST, "clear_reward", "T4群伤",
{"channel": "棋盘兑付", "base_value": floor_v, "target_rule": DamageControllerScript.TARGET_ALL_ALIVE, "ri": 1}))
var t5: int = ctrl.submit(ctrl.open_ticket(DamageControllerScript.KIND_BOARD_FLAT_HERO, "mine_hit", "T5反噬",
{"channel": "棋盘兑付", "base_value": floor_v, "target_rule": DamageControllerScript.TARGET_HERO,
"ri": 1, "context": {"terrain": "fixture"}}))
ctrl.flush()
var outcomes_redeemed := true
for id in [t1, t2, t3, t4, t5]:
if ctrl.outcome(int(id)) != "redeemed":
outcomes_redeemed = false
Kit.check("五配方单据 flush 后全部 redeemed(B 模式段界结账)", outcomes_redeemed)
var hero_after: int = int(units.hero_unit().hp)
var r2: Dictionary = _row_by_display_prefix("T2敌方技")
var r5: Dictionary = _row_by_display_prefix("T5反噬")
Kit.check("主角扣血 = 敌方技行值 + 反噬行值(逐行归因闭合)",
hero_before - hero_after == int(r2.get("value", -1)) + int(r5.get("value", -1)))
Kit.check("敌方技行值 ≥ 下限 + rider(rider 后置不进乘区)", int(r2.get("value", 0)) >= floor_v + floor_v)
Kit.check("反噬行值 = 落地下限(中性无类型表值)", int(r5.get("value", 0)) == floor_v)
var r1: Dictionary = _row_by_display_prefix("T1主角攻击")
var ex1: Dictionary = r1.get("extra", {})
Kit.check("主角攻击行 AC = 防守方 AC + 攻击加成(开单前快照一致)", int(ex1.get("ac", -1)) == threshold_expected)
Kit.check("掷点 ∈ [1, 骰面]", int(ex1.get("roll", -1)) >= 1 and int(ex1.get("roll", -1)) <= int(h.db.combat.settlement.damage_roll.roll_faces))
var tier_ok := false
for dv in h.db.combat.settlement.damage_roll.roll_tier_discounts:
if int(dv) == int(ex1.get("tier", -1)):
tier_ok = true
if int(ex1.get("tier", -1)) == 100:
tier_ok = true
Kit.check("掷点档位 ∈ 表档位值 ∪ 满额", tier_ok)
var r4: Dictionary = _row_by_display_prefix("T4群伤")
var alive_at_blast: int = int(r4.get("extra", {}).get("count", -1))
var any_enemy_dead := false
var enemy_drop_total := 0
var idx := 0
for e in units.all_enemies():
var drop: int = enemy_hp_before[idx] - int(e.hp)
enemy_drop_total += maxi(0, drop)
if not bool(e.alive):
any_enemy_dead = true
idx += 1
var attack_landed: int = int(r1.get("value", 0))
var typed_landed: int = int(_row_by_display_prefix("T3克制直伤").get("value", 0))
if any_enemy_dead:
Kit.check("敌方总扣血 ≥ 攻击+克制+群伤(死亡溢出合法,下界归因)",
enemy_drop_total >= attack_landed + typed_landed + int(r4.get("value", 0)) * maxi(0, alive_at_blast))
else:
Kit.check("敌方总扣血 = 攻击 + 克制直伤 + 群伤×存活数(逐行归因闭合)",
enemy_drop_total == attack_landed + typed_landed + int(r4.get("value", 0)) * alive_at_blast)
var protocol_ok := true
for r in _rows:
if String(r.type) != "damage":
continue
if String(r.source_class).is_empty() or String(r.display).is_empty():
protocol_ok = false
if int(r.value) < int(h.db.combat.settlement.damage_roll.damage_floor):
protocol_ok = false
if not (r.extra is Dictionary) or not r.extra.has("target"):
protocol_ok = false
Kit.check("行协议公共字段封闭(type/source_class/display/value≥下限/extra.target)", protocol_ok)
# 复现序列(供双跑对拍):逐行 (display, value)
var seq: Array = []
for r in _rows:
seq.append([String(r.display), int(r.value)])
return seq
func _test_five_recipes_and_replay(db) -> void:
# oracle: design/gdd/systems/战斗循环/战斗结算.md:363(同种子回放逐行相同组)
var run1: Array = _run_recipe_sequence(db)
Kit.check("五配方序列产出 5 行伤害行", run1.size() == 5)
var run2: Array = _run_recipe_sequence(db)
Kit.check("同种子双跑行序列逐位一致(回放确定性契约)", run1 == run2)
# ---- A 模式(开单即结——回切位) ----
func _test_policy_immediate_mode(db) -> void:
# oracle: design/gdd/systems/战斗循环/战斗结算.md:362(结算策略即时面组)
_rows = []
var h = _harness(db)
if not h.spawn_ok:
return
h.ctrl.policy = DamageControllerScript.POLICY_IMMEDIATE
var floor_v: int = int(h.db.combat.settlement.damage_roll.damage_floor)
var t: int = h.ctrl.submit(h.ctrl.open_ticket(DamageControllerScript.KIND_BOARD_FLAT_HERO, "mine_hit", "A模式反噬",
{"channel": "棋盘兑付", "base_value": floor_v, "target_rule": DamageControllerScript.TARGET_HERO, "ri": 1}))
Kit.check("A 模式开单即结(无 flush 即 redeemed)", h.ctrl.outcome(t) == "redeemed")
Kit.check("A 模式主角即时扣血", int(h.units.hero_unit().hp) == int(h.db.combat.hero.max_hp) - floor_v)
# ---- 来源敌死亡:队列中未结单据作废(死亡即不动手) ----
func _test_source_death_void(db) -> void:
# oracle: design/gdd/systems/战斗循环/敌人系统.md:133(摇定后兑现前被杀本拍作废组)
_rows = []
var h = _harness(db)
if not h.spawn_ok:
return
var alive_list: Array = h.units.query_alive("enemy")
var src: Dictionary = alive_list[alive_list.size() - 1]
var hero_before: int = int(h.units.hero_unit().hp)
var ticket: Dictionary = h.ctrl.open_ticket(DamageControllerScript.KIND_ENEMY_SKILL, "enemy_action", "亡敌未结单",
{"channel": "战斗段", "base_value": int(src.atk), "target_rule": DamageControllerScript.TARGET_HERO, "ri": 1})
ticket["source_unit"] = src
var t: int = h.ctrl.submit(ticket)
h.units.apply_damage(src, int(src.max_hp)) # 敌当场倒下 → unit_died → 该敌未结单作废
h.ctrl.flush()
Kit.check("来源敌死亡后其未结单据 voided(死亡即不动手)", h.ctrl.outcome(t) == "voided")
Kit.check("作废单零行零扣血", int(h.units.hero_unit().hp) == hero_before and _rows.is_empty())
# ---- 同内容双敌:单据归属按实例身份判定(生产 handle_id 使内容全同不可达——夹具显式钉住语义面) ----
# 单位字典键集复刻(剥离 handle_id:生产键集含句柄,`==` 误命的可达条件被破坏,夹具刻意不携句柄)
func _unit_fixture() -> Dictionary:
return {
"handle_id": 0, # 生成序轴(个体化批——个体键导出沿 handle_id;手搭夹具同契约)
"enemy_id": "fixture_enemy",
"display_name": "地精",
"hp": 5, "max_hp": 5, "atk": 1,
"pool": [], "pending_skill": "", "roll_seq": 0, "basic_streak": 0,
"armor_type": "light", "armor_class": 0, "attack_type": "normal",
"alive": true, "is_hero": false,
}
func _test_identical_content_dual_enemy(db: ConfigDb) -> void:
# oracle: design/gdd/systems/战斗循环/敌人系统.md:303(双敌同内容顺位组)
var a := _unit_fixture()
var b := _unit_fixture()
Kit.check("前置:双敌字典内容全同且非同一实例", a == b and not is_same(a, b))
var h = _harness(db)
if not h.spawn_ok:
return
var ticket: Dictionary = h.ctrl.open_ticket(DamageControllerScript.KIND_BOARD_FLAT_HERO, "mine_hit", "同容双敌单",
{"channel": "棋盘兑付", "base_value": 1, "target_rule": DamageControllerScript.TARGET_HERO, "ri": 1})
ticket["source_unit"] = a
var t: int = h.ctrl.submit(ticket)
h.units.unit_died.emit(b) # 真实死亡事件(单位自行宣布)——同容异实例
h.ctrl.flush()
Kit.check("同内容异实例敌死亡不误废他人单据〔wd1〕", h.ctrl.outcome(t) == "redeemed")
var h2 = _harness(db)
if not h2.spawn_ok:
return
var ticket2: Dictionary = h2.ctrl.open_ticket(DamageControllerScript.KIND_BOARD_FLAT_HERO, "mine_hit", "同容双敌单·自亡",
{"channel": "棋盘兑付", "base_value": 1, "target_rule": DamageControllerScript.TARGET_HERO, "ri": 1})
ticket2["source_unit"] = a
var t2: int = h2.ctrl.submit(ticket2)
h2.units.unit_died.emit(a)
h2.ctrl.flush()
Kit.check("来源敌自身死亡单据作废(正向对照)〔wd1〕", h2.ctrl.outcome(t2) == "voided")
# ---- requires 前置门:前置单未实际有效兑付 → 后继单静默作废;无存活敌 blast = 合法零兑付 ----
# d12 产发端增键:目标身份随行/随载荷下发(同名敌按 id 精确解析的产发半)
func _test_target_id_payload(db: ConfigDb) -> void:
_rows = []
var h = _harness(db)
if not h.spawn_ok:
Kit.check("前置:units.spawn(1,0) 成功(target_id 断言)", false)
return
var target = h.units.default_target()
var expect_id := String(target.get("enemy_id", ""))
Kit.check("前置:目标敌携非空 enemy_id", expect_id != "")
var ticket: Dictionary = h.ctrl.open_ticket(DamageControllerScript.KIND_HERO_ATTACK, "hero_attack", "T6目标id",
{"channel": "战斗段", "base_value": int(db.combat.hero.atk),
"target_rule": DamageControllerScript.TARGET_FRONT_ALIVE, "ri": 1})
var fx_payload: Dictionary = ticket.context.get("fx_attack_payload", {})
Kit.check("attack_commit fx 载荷携 target_id〔wd12〕",
String(fx_payload.get("target_id", "")) == expect_id and String(fx_payload.get("target", "")) != "")
h.ctrl.submit(ticket)
h.ctrl.flush()
var row := _row_by_display_prefix("T6目标id")
Kit.check("伤害行携 target_id == 目标敌 enemy_id〔wd12〕",
not row.is_empty() and String(row.extra.get("target_id", "")) == expect_id)
func _test_requires_gate_and_zero(db) -> void:
_rows = []
var h = _harness(db)
if not h.spawn_ok:
return
var floor_v: int = int(h.db.combat.settlement.damage_roll.damage_floor)
# 先清场(flush_submit 同步开单——清图批专用通道的契约面同时被覆盖)
var front_hp: int = int(h.units.query_alive("enemy")[0].max_hp)
var big: int = front_hp * 10
var t_clear: int = h.ctrl.flush_submit(h.ctrl.open_ticket(DamageControllerScript.KIND_BOARD_BLAST, "clear_reward", "清场群伤",
{"channel": "棋盘兑付", "base_value": big, "target_rule": DamageControllerScript.TARGET_ALL_ALIVE, "ri": 1}))
Kit.check("flush_submit 同步开单即结(清图批契约)", h.ctrl.outcome(t_clear) == "redeemed" and h.units.query_alive("enemy").is_empty())
var t_zero: int = h.ctrl.submit(h.ctrl.open_ticket(DamageControllerScript.KIND_BOARD_BLAST, "clear_reward", "空场群伤",
{"channel": "棋盘兑付", "base_value": floor_v, "target_rule": DamageControllerScript.TARGET_ALL_ALIVE, "ri": 1}))
var gated_ticket: Dictionary = h.ctrl.open_ticket(DamageControllerScript.KIND_HERO_ATTACK, "hero_attack", "前置门攻击",
{"channel": "战斗段", "base_value": int(h.db.combat.hero.atk),
"target_rule": DamageControllerScript.TARGET_FRONT_ALIVE, "ri": 1})
gated_ticket["requires"] = t_zero
var t_gated: int = h.ctrl.submit(gated_ticket)
h.ctrl.flush()
Kit.check("无存活敌 blast = 合法零兑付(zero 非 voided)", h.ctrl.outcome(t_zero) == "zero")
Kit.check("前置单未实际有效 → 后继单 voided(requires 门)", h.ctrl.outcome(t_gated) == "voided")
# ---- 主角倒下:flush 余单全部作废(_halted 终止) ----
func _test_hero_death_halt(db) -> void:
_rows = []
var h = _harness(db)
if not h.spawn_ok:
return
var max_hp: int = int(h.db.combat.hero.max_hp)
var t_kill: int = h.ctrl.submit(h.ctrl.open_ticket(DamageControllerScript.KIND_BOARD_FLAT_HERO, "mine_hit", "致死反噬",
{"channel": "棋盘兑付", "base_value": max_hp, "target_rule": DamageControllerScript.TARGET_HERO, "ri": 1}))
var t_after: int = h.ctrl.submit(h.ctrl.open_ticket(DamageControllerScript.KIND_BOARD_FLAT_HERO, "mine_hit", "余单",
{"channel": "棋盘兑付", "base_value": 1, "target_rule": DamageControllerScript.TARGET_HERO, "ri": 2}))
h.ctrl.flush()
Kit.check("致死单 redeemed 且主角 hp 归零", h.ctrl.outcome(t_kill) == "redeemed" and int(h.units.hero_unit().hp) == 0)
Kit.check("主角倒下后余单 voided(flush 终止)", h.ctrl.outcome(t_after) == "voided")
# ---- AC 合流:override 入参 / 表值兜底 / ac 型状态叠层(不入池) ----
func _test_ac_merge(db) -> void:
_rows = []
var h = _harness(db)
if not h.spawn_ok:
return
var base_ac: int = int(h.db.combat.hero.armor_class)
Kit.check("缺省兜底:defender_ac(hero) = hero 表值", h.ctrl.defender_ac(h.units.hero_unit()) == base_ac)
var override_v: int = base_ac + int(h.db.combat.settlement.damage_roll.damage_floor)
h.ctrl.set_hero_ac_override(override_v)
Kit.check("override 合流入参生效", h.ctrl.defender_ac(h.units.hero_unit()) == override_v)
# ac 型状态效果叠其上(找 hero 侧 unit=ac 行)
var ac_eff := {}
for row in h.db.combat.status_effects.rows:
if String(row.get("unit", "")) == "ac" and String(row.get("target_side", "")) == "hero":
ac_eff = row
break
if ac_eff.is_empty():
Kit.check("前置:状态表存在 hero 侧 ac 型行", false)
return
var src := {"source_class": "safe_cell", "origin_cell": Vector2i(-1, -1), "round_index": 0}
h.effects.apply_effect(String(ac_eff.effect_id), StatusContainerScript.OWNER_HERO, src)
var expected: int = override_v + int(ac_eff.magnitude)
Kit.check("ac 型效果叠层入 AC(层数×幅度,不入池)", h.ctrl.defender_ac(h.units.hero_unit()) == expected)
# ---- 池乘区收口:池内加算 → clamp;ac 型不入池 ----
func _test_pool_clamp(db) -> void:
_rows = []
var h = _harness(db)
if not h.spawn_ok:
return
var lo: int = int(h.db.combat.settlement.core.pool_clamp_lo)
var hi: int = int(h.db.combat.settlement.core.pool_clamp_hi)
Kit.check("空效果池读 0(表 clamp 域含 0 前提)", h.ctrl.clamped_pool("hero_offense", StatusContainerScript.OWNER_HERO) == 0 and lo <= 0 and hi >= 0)
# 找 hero_offense 池的 percent 型 hero 侧效果行
var pool_eff := {}
for row in h.db.combat.status_effects.rows:
var eid := String(row.get("effect_id", ""))
if String(row.get("unit", "")) == "percent" and String(row.get("target_side", "")) == "hero" \
and String(h.db.combat.settlement.core.effect_pool_map.get(eid, "")) == "hero_offense":
pool_eff = row
break
if pool_eff.is_empty():
Kit.check("前置:hero_offense 池存在 percent 型 hero 侧行", false)
return
var src := {"source_class": "safe_cell", "origin_cell": Vector2i(-1, -1), "round_index": 0}
var first_ok: bool = h.effects.apply_effect(String(pool_eff.effect_id), StatusContainerScript.OWNER_HERO, src)
var expected1: int = clampi(int(pool_eff.magnitude), lo, hi)
Kit.check("池值 = 单实例 L×m 加算后 clamp", first_ok and h.ctrl.clamped_pool("hero_offense", StatusContainerScript.OWNER_HERO) == expected1)
var second_ok: bool = h.effects.apply_effect(String(pool_eff.effect_id), StatusContainerScript.OWNER_HERO, src)
Kit.check("累加政策重复授予受理且层数恒 1(同名重复授予加时长)——池值不变",
second_ok and h.ctrl.clamped_pool("hero_offense", StatusContainerScript.OWNER_HERO) == expected1)
# ac 型效果不入 percent 池
var ac_eff2 := {}
for row2 in h.db.combat.status_effects.rows:
if String(row2.get("unit", "")) == "ac" and String(row2.get("target_side", "")) == "hero":
ac_eff2 = row2
break
if not ac_eff2.is_empty():
var before: int = h.ctrl.clamped_pool("hero_offense", StatusContainerScript.OWNER_HERO)
h.effects.apply_effect(String(ac_eff2.effect_id), StatusContainerScript.OWNER_HERO, src)
Kit.check("ac 型效果不入池(percent 池隔离)", h.ctrl.clamped_pool("hero_offense", StatusContainerScript.OWNER_HERO) == before)
# stack 政策效果:全新容器授予至满层 → 再授拒绝(满层拒绝原子——容器零写入)
var stack_eff := {}
for row3 in h.db.combat.status_effects.rows:
if String(row3.get("stack_policy", "")) == "stack":
stack_eff = row3
break
if stack_eff.is_empty():
Kit.check("前置:状态表存在 stack 政策行", false)
return
var effects2 = StatusContainerScript.new(h.db)
effects2.notify_battle_start()
var granted := 0
var max_n: int = int(stack_eff.max_stack)
for i in range(max_n + 1):
if effects2.apply_effect(String(stack_eff.effect_id), StatusContainerScript.OWNER_HERO, src):
granted += 1
Kit.check("stack 政策恰授予 max_stack 层后拒绝(满层拒绝原子)", granted == max_n)
# ---- 个体池读数 / 个体 AC 合流(增益状态 v1.5——敌方状态个体化批)----
func _test_pool_consumption_per_unit(db) -> void:
var h = _harness(db, 3) # depth 3 = 混编行集(熊地精 1 + 地精 1–2,最少 2 只——个体对照面需 ≥2 敌)
if not h.spawn_ok:
return
var enemies: Array = h.units.all_enemies()
Kit.check("V6 前置:行集 ≥2 敌(个体池对照面)", enemies.size() >= 2)
if enemies.size() < 2:
return
var wmag: int = int(db.combat.status_effects.row_by_id("weaken").magnitude)
var lo: int = int(db.combat.settlement.core.pool_clamp_lo)
var hi: int = int(db.combat.settlement.core.pool_clamp_hi)
var src := {"source_class": "seal_hit", "origin_cell": Vector2i(-1, -1), "round_index": 0}
var k1: String = StatusContainerScript.owner_key_for(enemies[0])
var k2: String = StatusContainerScript.owner_key_for(enemies[1])
Kit.check("V6 前置:e1 受理削弱", h.effects.apply_effect("weaken", k1, src))
Kit.check("B 敌的削弱不进 A 敌的账:e1 池含 weaken 值(clamp 域内)、e2 恒 0〔个体化批 V6〕",
h.ctrl.clamped_pool("enemy_output", k1) == clampi(wmag, lo, hi) and h.ctrl.clamped_pool("enemy_output", k2) == 0)
func _test_defender_ac_per_unit(db) -> void:
var h = _harness(db, 3)
if not h.spawn_ok:
return
var enemies: Array = h.units.all_enemies()
Kit.check("V7 前置:行集 ≥2 敌(个体 AC 对照面)", enemies.size() >= 2)
if enemies.size() < 2:
return
var amag: int = int(db.combat.status_effects.row_by_id("alert").magnitude)
var src := {"source_class": "mine_hit", "origin_cell": Vector2i(-1, -1), "round_index": 0}
var u1: Dictionary = enemies[0]
var u2: Dictionary = enemies[1]
var k1: String = StatusContainerScript.owner_key_for(u1)
var base1: int = int(u1.get("armor_class", 0))
var base2: int = int(u2.get("armor_class", 0))
Kit.check("V7 前置:e1 受理警觉", h.effects.apply_effect("alert", k1, src))
Kit.check("alert 各算各的:e1 AC = 自身基数 + 警觉幅度、e2 AC = 自身基数(该敌自己名下 ac 型)〔个体化批 V7〕",
h.ctrl.defender_ac(u1) == base1 + amag and h.ctrl.defender_ac(u2) == base2)