- CLAUDE.md 硬性红线新增「文件头零工作日志」;code-conventions.md 新增 §1.4.1(含反面案例) - 全仓 .gd 文件头注清偿:删除全部带日期条目、批次叙事段与迁移退役叙述,保留并改写为 现在时的职责、接口契约、GDD 锚内容(main.gd 头注 42→31 行、save_manager.gd 49→45 行) - 纯注释改动零行为变更,按用户令免测试免冒烟
338 lines
16 KiB
GDScript
338 lines
16 KiB
GDScript
# 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 # 夹具种子(基建值非玩法数值——回放确定性契约的输入)
|
||
|
||
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_requires_gate_and_zero(db)
|
||
_test_hero_death_halt(db)
|
||
_test_ac_merge(db)
|
||
_test_pool_clamp(db)
|
||
quit(Kit.finish("damage_controller_test"))
|
||
|
||
|
||
# ---- 夹具 ----
|
||
|
||
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:
|
||
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.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
|
||
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.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
|
||
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.settlement.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.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))
|
||
Kit.check("A 模式开单即结(无 flush 即 redeemed)", h.ctrl.outcome(t) == "redeemed")
|
||
Kit.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()
|
||
Kit.check("来源敌死亡后其未结单据 voided(死亡即不动手)", h.ctrl.outcome(t) == "voided")
|
||
Kit.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))
|
||
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",
|
||
"空场群伤", "棋盘兑付", 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()
|
||
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.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()
|
||
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.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.settlement.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.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), 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.settlement.pool_clamp_lo)
|
||
var hi: int = int(h.db.settlement.pool_clamp_hi)
|
||
Kit.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():
|
||
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), 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") == expected1)
|
||
var second_ok: bool = h.effects.apply_effect(String(pool_eff.effect_id), src)
|
||
Kit.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)
|
||
Kit.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():
|
||
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), src):
|
||
granted += 1
|
||
Kit.check("stack 政策恰授予 max_stack 层后拒绝(满层拒绝原子)", granted == max_n)
|