# 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: 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: _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: 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: _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: _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: 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)