# 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 TEST_SEED := 20260928 # 夹具种子(基建值非玩法数值——回放确定性契约的输入) var _failures := 0 var _checks := 0 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) _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_requires_gate_and_zero(db) _test_hero_death_halt(db) _test_ac_merge(db) _test_pool_clamp(db) if _failures > 0: push_error("[damage_controller_test] %d/%d 项失败" % [_failures, _checks]) quit(1) return print("[damage_controller_test] all green (%d checks)" % _checks) quit(0) func _check(desc: String, ok: bool) -> void: _checks += 1 if ok: print(" [PASS] " + desc) else: _failures += 1 push_error(" [FAIL] " + desc) # ---- 夹具 ---- func _on_row(type, source_class, origin_cell, ri, display, value, extra, highlight) -> void: _rows.append({"type": type, "source_class": source_class, "origin_cell": origin_cell, "round_index": ri, "display": display, "value": value, "extra": extra, "highlight": highlight}) func _harness(db): 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) ctrl.emit_row_cb = Callable(self, "_on_row") effects.notify_battle_start() var spawn_ok: bool = units.spawn(1, 0, int(db.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: _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.settlement.attack_bonus) var atk_type := "" var eff_keys: Array = h.db.settlement.armor_effectiveness.keys() if eff_keys.size() > 0: atk_type = String(eff_keys[0]) var floor_v: int = int(h.db.settlement.damage_floor) var t1: int = ctrl.submit(ctrl.open_ticket(DamageControllerScript.KIND_HERO_ATTACK, "hero_attack", "T1主角攻击", "战斗段", int(h.db.hero.atk), DamageControllerScript.TARGET_FRONT_ALIVE, 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敌方技", "战斗段", int(src0.atk), DamageControllerScript.TARGET_HERO, 1, Vector2i(-1, -1), "", floor_v, "", {"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克制直伤", "棋盘兑付", floor_v, DamageControllerScript.TARGET_FRONT_ALIVE, 1, Vector2i(-1, -1), atk_type)) var t4: int = ctrl.submit(ctrl.open_ticket(DamageControllerScript.KIND_BOARD_BLAST, "clear_reward", "T4群伤", "棋盘兑付", floor_v, DamageControllerScript.TARGET_ALL_ALIVE, 1)) var t5: int = ctrl.submit(ctrl.open_ticket(DamageControllerScript.KIND_BOARD_FLAT_HERO, "mine_hit", "T5反噬", "棋盘兑付", floor_v, DamageControllerScript.TARGET_HERO, 1, Vector2i(-1, -1), "", 0, "", {"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 _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反噬") _check("主角扣血 = 敌方技行值 + 反噬行值(逐行归因闭合)", hero_before - hero_after == int(r2.get("value", -1)) + int(r5.get("value", -1))) _check("敌方技行值 ≥ 下限 + rider(rider 后置不进乘区)", int(r2.get("value", 0)) >= floor_v + floor_v) _check("反噬行值 = 落地下限(中性无类型表值)", int(r5.get("value", 0)) == floor_v) var r1: Dictionary = _row_by_display_prefix("T1主角攻击") var ex1: Dictionary = r1.get("extra", {}) _check("主角攻击行 AC = 防守方 AC + 攻击加成(开单前快照一致)", int(ex1.get("ac", -1)) == threshold_expected) _check("掷点 ∈ [1, 骰面]", int(ex1.get("roll", -1)) >= 1 and int(ex1.get("roll", -1)) <= int(h.db.settlement.roll_faces)) var tier_ok := false for dv in h.db.settlement.roll_tier_discounts: if int(dv) == int(ex1.get("tier", -1)): tier_ok = true if int(ex1.get("tier", -1)) == 100: tier_ok = true _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: _check("敌方总扣血 ≥ 攻击+克制+群伤(死亡溢出合法,下界归因)", enemy_drop_total >= attack_landed + typed_landed + int(r4.get("value", 0)) * maxi(0, alive_at_blast)) else: _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.settlement.damage_floor): protocol_ok = false if not (r.extra is Dictionary) or not r.extra.has("target"): protocol_ok = false _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) _check("五配方序列产出 5 行伤害行", run1.size() == 5) var run2: Array = _run_recipe_sequence(db) _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.settlement.damage_floor) var t: int = h.ctrl.submit(h.ctrl.open_ticket(DamageControllerScript.KIND_BOARD_FLAT_HERO, "mine_hit", "A模式反噬", "棋盘兑付", floor_v, DamageControllerScript.TARGET_HERO, 1)) _check("A 模式开单即结(无 flush 即 redeemed)", h.ctrl.outcome(t) == "redeemed") _check("A 模式主角即时扣血", int(h.units.hero_unit().hp) == int(h.db.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", "亡敌未结单", "战斗段", int(src.atk), DamageControllerScript.TARGET_HERO, 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() _check("来源敌死亡后其未结单据 voided(死亡即不动手)", h.ctrl.outcome(t) == "voided") _check("作废单零行零扣血", int(h.units.hero_unit().hp) == hero_before and _rows.is_empty()) # ---- requires 前置门:前置单未实际有效兑付 → 后继单静默作废;无存活敌 blast = 合法零兑付 ---- 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.settlement.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", "清场群伤", "棋盘兑付", big, DamageControllerScript.TARGET_ALL_ALIVE, 1)) _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", "空场群伤", "棋盘兑付", floor_v, DamageControllerScript.TARGET_ALL_ALIVE, 1)) var gated_ticket: Dictionary = h.ctrl.open_ticket(DamageControllerScript.KIND_HERO_ATTACK, "hero_attack", "前置门攻击", "战斗段", int(h.db.hero.atk), DamageControllerScript.TARGET_FRONT_ALIVE, 1) gated_ticket["requires"] = t_zero var t_gated: int = h.ctrl.submit(gated_ticket) h.ctrl.flush() _check("无存活敌 blast = 合法零兑付(zero 非 voided)", h.ctrl.outcome(t_zero) == "zero") _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.hero.max_hp) var t_kill: int = h.ctrl.submit(h.ctrl.open_ticket(DamageControllerScript.KIND_BOARD_FLAT_HERO, "mine_hit", "致死反噬", "棋盘兑付", max_hp, DamageControllerScript.TARGET_HERO, 1)) var t_after: int = h.ctrl.submit(h.ctrl.open_ticket(DamageControllerScript.KIND_BOARD_FLAT_HERO, "mine_hit", "余单", "棋盘兑付", 1, DamageControllerScript.TARGET_HERO, 2)) h.ctrl.flush() _check("致死单 redeemed 且主角 hp 归零", h.ctrl.outcome(t_kill) == "redeemed" and int(h.units.hero_unit().hp) == 0) _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.hero.armor_class) _check("缺省兜底:defender_ac(hero) = hero 表值", h.ctrl.defender_ac(h.units.hero_unit()) == base_ac) var override_v: int = base_ac + int(h.db.settlement.damage_floor) h.ctrl.set_hero_ac_override(override_v) _check("override 合流入参生效", h.ctrl.defender_ac(h.units.hero_unit()) == override_v) # ac 型状态效果叠其上(找 hero 侧 unit=ac 行) var ac_eff := {} for row in h.db.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(): _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), src) var expected: int = override_v + int(ac_eff.magnitude) _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.settlement.pool_clamp_lo) var hi: int = int(h.db.settlement.pool_clamp_hi) _check("空效果池读 0(表 clamp 域含 0 前提)", h.ctrl.clamped_pool("hero_offense") == 0 and lo <= 0 and hi >= 0) # 找 hero_offense 池的 percent 型 hero 侧效果行 var pool_eff := {} for row in h.db.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.settlement.effect_pool_map.get(eid, "")) == "hero_offense": pool_eff = row break if pool_eff.is_empty(): _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), src) var expected1: int = clampi(int(pool_eff.magnitude), lo, hi) _check("池值 = 单实例 L×m 加算后 clamp", first_ok and h.ctrl.clamped_pool("hero_offense") == expected1) var second_ok: bool = h.effects.apply_effect(String(pool_eff.effect_id), src) _check("refresh 政策重复授予受理且层数恒 1(叠层改续时裁决)——池值不变", second_ok and h.ctrl.clamped_pool("hero_offense") == expected1) # ac 型效果不入 percent 池 var ac_eff2 := {} for row2 in h.db.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") h.effects.apply_effect(String(ac_eff2.effect_id), src) _check("ac 型效果不入池(percent 池隔离)", h.ctrl.clamped_pool("hero_offense") == before) # stack 政策效果:全新容器授予至满层 → 再授拒绝(满层拒绝原子——容器零写入) var stack_eff := {} for row3 in h.db.status_effects.rows: if String(row3.get("stack_policy", "")) == "stack": stack_eff = row3 break if stack_eff.is_empty(): _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), src): granted += 1 _check("stack 政策恰授予 max_stack 层后拒绝(满层拒绝原子)", granted == max_n)