Files
game-code-4/tests/unit/cases/test_boss_battle.gd
T
程基岩(engineering-lead) c534e95458 fix: 首跑错误批次(Godot 4.7.2 引擎实证修复 R1-R14)
本机首次具备引擎自验证能力后的首跑修复。四条命令 + 主场景 180 帧全绿:
- --import exit=0(漂移门 git diff data/build 空)
- --validate-only exit=0 RESULT: PASS
- test_runner exit=0 PASS(584 检查,8/8 用例)
- sim_main exit=0(报告落 tests/sim_reports/report-2026-10-20.json)
- 主场景 --quit-after 180 exit=0 零 ERROR/WARNING(F5 可玩)

预定位错误(team-lead 清单):
- R1 economy_state.gd:596 enthone_ready -> enthrone_ready(单点故障连锁)
- R2 hud_root.gd:142,160 HorizontalAlignment.LEFT -> HORIZONTAL_ALIGNMENT_LEFT
- R3 boss_arena.gd 补 var _pending_hall_index := -1
- R4 hall_band_world.gd:95 删 "_ = ext"(非法语句;--check-only 定位)
- R5 sheet_importer._type_ok 补 object->Dictionary / array->Array(9 条假违规)
- R6 game_state.gd econ==null 防护(防 autoload 崩每帧刷错打满管道挂死)

引擎新暴露错误:
- R7 prestige_model floor() Variant 推断(4.7.2 按错误)连锁编译失败
- R8 gates.gd var pass 保留字;R9 %g/%.*f 格式符 ×6 处
- R10 greedy.gd Dictionary.get() 推断;R11 probe_p1 条件表达式推断
- R12 测试用例 data 参数统一标 BalanceData(无类型参数成员调用返 Variant)
- R13 boss_battle 用例 Dictionary 下标运算显式 float 化
- R14 band_world_root.compute_ranges 返回 Array[Vector2](类型化数组不隐式转换)

测试侧数值修正(根因已核实,实现符合设计):
- gen3 可购断言 merit 1000->2000(800 文书 + 1200 首购)
- 开场伤害断言改"循环至 ACTIVE 即停"(INTRO 结束 tick 不计军列伤)
- 容量断言预算 30->34s(战斗真实给 intro 3s + active 30s)

工程加固:
- EconomyState 红线守卫首次越线才报(sim 长跑日志 6.5MB->1KB)
- stage_world.gd stretch=false(SubViewport 手动尺寸驱动生效)

知识缺口登记:docs/engineering/2026-10-20-f-engine-first-run-notes.md
sim 首轮数据:主口径 11/14 过(#2/#3/#12/#13/#14 FAIL 待文策渊裁定)
2026-10-20 18:47:07 +08:00

393 lines
18 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.
extends RefCounted
# 用例:W4 殿王战逻辑状态机(game/BossBattle;boss_battles.csv 十场;方案 B)
# 口径全部来自数据行(systems-spec §1.2,用户 2026-10-20 拍板):
# HP = 进入时产出/s × boss_army_seconds(S_n);点击伤害 = 产出/s × κ × combo_mult;
# 开场冲锋 = HP × 0.10 × clamp(超度/souls_par, 0, 1)。
# 本类不用随机 → 结果可复现(W4 sim 门 #11 直接复用)。
#
# W4 变更说明:原"可胜性事实记录"哨兵(8 座可胜 / 2 座必超时 / 点击天花板 25.6)
# 钉的是旧 HP 公式下的**缺陷事实**;方案 B 落地后按 systems-spec 第七章第 1 条
# 重写为"十殿全可胜 + S_n + κ + 修订门 #11 五判据"的**设计意图断言**。
func run(a, data: BalanceData) -> void:
_test_hp_formula(a, data)
_test_win(a, data)
_test_intro_damage(a, data)
_test_combo_cap(a, data)
_test_lose_and_retry(a, data)
_test_availability(a, data)
_test_kappa_scaling(a, data)
_test_winnability_b(a, data)
func _new_econ(data: BalanceData, merit_run: float, gen0_level: int = 10) -> EconomyState:
var e := EconomyState.new(data)
e.gen_levels[0] = gen0_level # 默认 10 级 → 产出 1.0/s(gen1 rate 0.1)
e.merit_run_total = merit_run
e.poll_halls()
return e
## 满配点击:12 条点击升级全购 + m06 斩鬼刃 1 阶(直接置位 + recompute,同旧用例对
## up_gate_boss_retry 的手法;购买合法性由 test_economy_state 覆盖)。
func _full_click_config(e: EconomyState, data: BalanceData) -> void:
for u in data.upgrades:
if u.type == "click":
e.bought[u.id] = true
e.meta_ranks["meta_06"] = 1
e.recompute()
## 战斗模拟器(dt=0.1;click_every = 点击间隔秒,0 = 挂机;keep_alive = 每 tick 前补满 HP,
## 防止 dummy 提前死亡,用于测满时长内的军列+点击伤害容量)。返回实测字典。
func _run_battle(b: BossBattle, e: EconomyState, max_sec: float,
click_every: float = 0.0, keep_alive: bool = false) -> Dictionary:
var out := {"win": false, "active_sec": 0.0, "army_dmg": 0.0,
"click_dmg": 0.0, "clicks": 0}
var dt := 0.1
var steps := int(round(max_sec / dt))
var click_acc := 0.0
for _i in steps:
if b.phase == BossBattle.Phase.RESOLVED:
break
if keep_alive:
b.hp = b.hp_max
b.tick(dt, e)
if b.phase == BossBattle.Phase.ACTIVE:
out.army_dmg += e.output_per_sec() * dt
out.active_sec += dt
if click_every > 0.0:
click_acc += dt
while click_acc >= click_every - 1e-9:
click_acc -= click_every
var dmg := b.register_click(e)
out.click_dmg += dmg
out.clicks += 1
if keep_alive and b.phase == BossBattle.Phase.RESOLVED:
# 测试手法:点击击杀 dummy 后拉回 ACTIVE 续打(只动 phase/hp,
# 不动任何公式;save_soul 的阴德副作用对本断言无影响)。
b.phase = BossBattle.Phase.ACTIVE
b.win = false
b.hp = b.hp_max
out.win = bool(b.view().get("win", false))
return out
# ---- HP = 产出/s × boss_army_seconds(方案 B1;hall_factor 降级为纯叙事参数) ----
func _test_hp_formula(a, data: BalanceData) -> void:
var e := _new_econ(data, 2000.0) # 过 H1
a.eq(e.halls_passed, 1, "H1 门达成(2000 阴德)")
var out := e.output_per_sec()
a.near(out, 1.0, 1e-6, "基线产出 1.0/s")
var expect_s := [23.0, 23.5, 24.0, 24.5, 25.0, 25.5, 26.0, 26.5, 27.0, 28.0]
var expect_h := [1.0, 1.1, 1.2, 1.3, 1.4, 1.6, 1.8, 2.0, 2.1, 2.2]
for i in data.halls.size():
var h: HallDef = data.halls[i]
var def := data.boss_for_hall(h.id)
a.true_(def != null, "殿 %s 有 boss 行" % h.id)
if def == null:
continue
a.near(def.boss_army_seconds, expect_s[i], 1e-9,
"%s:S_n = %.1f(军列单推秒数)" % [h.id, expect_s[i]])
a.near(def.hp_at(out), expect_s[i] * out, 1e-6,
"%s:HP = 产出/s × S_n" % h.id)
a.near(float(def.hall_factor), expect_h[i], 1e-9,
"%s:hall_factor = %.1f(纯叙事/视觉参数,值不动)" % [h.id, expect_h[i]])
a.true_(def.boss_army_seconds < float(def.active_sec),
"%s:S_n < active_sec(无必败殿)" % h.id)
# H1:产出 1/s → HP 23;H10:产出 1/s → HP 28(旧式 15/33 已废止)
var d1 := data.boss_for_hall("hall_01_qinguang")
var d10 := data.boss_for_hall("hall_10_zhuanlun")
a.near(d1.hp_at(out), 23.0, 1e-6, "H1:产出 1/s → HP 23")
a.near(d10.hp_at(out), 28.0, 1e-6, "H10:产出 1/s → HP 28")
# HP 与 hall_factor 无关(叙事参数不参与计算的证明)
a.near(d10.hp_at(100.0), 2800.0, 1e-6, "H10:产出 100/s → HP 2800(不含殿系数 2.2)")
# ---- 军列 DPS = 产出/s × 1.0;S_n 秒打完 → 胜 + 阴德爆发 HP×0.2 ----
func _test_win(a, data: BalanceData) -> void:
var e := _new_econ(data, 2000.0)
var b := BossBattle.new(data)
var info: Dictionary = b.available(e)
a.true_(bool(info.get("ok", false)), "过 H1 后 H1 殿王可挑战")
a.eq(int(info.get("hall_index", -1)), 0, "可挑战的殿 index = 0")
var r := b.start(e, 0)
a.true_(bool(r.get("ok", false)), "开战")
a.near(float(r.get("hp_max", 0.0)), 23.0, 1e-6, "进入时锁定 HP = 23(P=1/s × S_1=23)")
a.eq(b.phase, BossBattle.Phase.INTRO, "先走 INTRO(3s 军列冲锋)")
# 超度值为 0 且 H1 souls_par = 0 → 开场伤害 0
a.near(b.hp, 23.0, 1e-6, "开场伤害 = 0(超度值 0 / H1 基准 0)")
# 走完 INTRO
for _i in 31:
b.tick(0.1, e)
a.eq(b.phase, BossBattle.Phase.ACTIVE, "3s 后进入 ACTIVE")
a.near(float(b.view().get("timer", 0.0)), 30.0, 1.0, "ACTIVE 计时 30s 起步")
# 军列 DPS = 1.0 → 23s 打完(S_1)
var before := e.merit_run_total
for _i in 240:
b.tick(0.1, e)
if b.phase == BossBattle.Phase.RESOLVED:
break
a.eq(b.phase, BossBattle.Phase.RESOLVED, "HP 归零 → RESOLVED")
a.true_(bool(b.view().get("win", false)), "判定为胜")
a.near(float(b.view().get("reward", 0.0)), 23.0 * 0.2, 1e-6, "奖励 = HP × 0.2 = 4.6")
a.near(e.merit_run_total, before + 23.0 * 0.2, 1e-6, "阴德爆发进本轮累计口径")
# RESOLVED(结算面板挂着)与"真在打"要能区分:原因串不许一律是"战斗中"
var during: Dictionary = b.available(e)
a.false_(bool(during.get("ok", false)), "结算未确认 → 不可开新战")
a.true_(str(during.get("reason", "")).contains("结算"), "RESOLVED 的原因写明待确认结算")
# UI 点确认(cmd_boss_ack → reset)后回到 IDLE,此时才该报"已通关"
b.reset()
var after: Dictionary = b.available(e)
a.false_(bool(after.get("ok", false)), "本轮已通关 → 不可再战")
a.true_(str(after.get("reason", "")).contains("已通关"), "原因写明已通关")
b.reset_for_new_run()
a.true_(bool(b.available(e).get("ok", false)), "新轮回后重新可挑战")
# ---- 开场冲锋 v2(方案 B3):HP × 0.10 × clamp(超度/souls_par, 0, 1) ----
func _test_intro_damage(a, data: BalanceData) -> void:
a.near(data.tune_num("tune_boss_intro_cap", 0.0), 0.10, 1e-9, "intro_cap = 0.10(旧 20% 已废止)")
a.near(data.tune_num("tune_boss_intro_enabled", 0.0), 1.0, 1e-9, "intro_enabled = 1(默认启用)")
# H5(souls_par = 600):超度 600 → clamp = 1 → 开场伤害 = 10% HP
var e := _new_econ(data, 6000000.0) # 超度 = floor(6e6/1e4) = 600
a.eq(e.souls_saved(), 600, "超度值 600")
var b := BossBattle.new(data)
var r := b.start(e, 4) # H5 殿王
a.true_(bool(r.get("ok", false)), "H5 开战")
a.near(float(r.get("hp_max", 0.0)), 25.0, 1e-6, "H5 HP = 25(P=1/s × S_5=25)")
# 走到 ACTIVE 即停(INTRO 结束那一次 tick 不造成军列伤害;多跑会掺入 0.1s 军列伤)
for _i in 40:
b.tick(0.1, e)
if b.phase == BossBattle.Phase.ACTIVE:
break
a.eq(b.phase, BossBattle.Phase.ACTIVE, "进入 ACTIVE")
a.near(b.hp, 25.0 * 0.9, 1e-6, "开场伤害 = HP × 10% × clamp(600/600) = 2.5")
# 超度 300 → clamp = 0.5 → 开场伤害 = 5% HP
var e2 := _new_econ(data, 3000000.0)
var b2 := BossBattle.new(data)
b2.start(e2, 4)
for _i in 40:
b2.tick(0.1, e2)
if b2.phase == BossBattle.Phase.ACTIVE:
break
a.near(b2.hp, 25.0 * 0.95, 1e-6, "开场伤害 = HP × 10% × clamp(300/600) = 1.25")
# H1(souls_par = 0)→ 无开场加成(防 0 除),即使超度很大
var e3 := _new_econ(data, 6000000.0)
var b3 := BossBattle.new(data)
b3.start(e3, 0)
for _i in 40:
b3.tick(0.1, e3)
if b3.phase == BossBattle.Phase.ACTIVE:
break
a.near(b3.hp, 23.0, 1e-6, "H1 基准 0 → 开场伤害 0")
# ---- 连击:+2%/层,封顶 +60%;点击伤害 = P × κ × 连击 ----
# 用例前提:HP 大到"100 刀砍不死"。κ(零投资) = 0.010,P = 20/s(军列 200 级)
# ⇒ HP = 23 × 20 = 460 >> 100 刀总伤 ≈ 20 × 0.010 × ~130 ≈ 26。
func _test_combo_cap(a, data: BalanceData) -> void:
var e := _new_econ(data, 2000.0, 200)
var b := BossBattle.new(data)
b.start(e, 0)
for _i in 31:
b.tick(0.1, e)
a.eq(b.phase, BossBattle.Phase.ACTIVE, "进入 ACTIVE")
a.near(b.entry_kappa, 0.010, 1e-9, "零投资 κ = 0.010(tune_boss_kappa_base)")
a.true_(b.hp_max > 100.0 * e.output_per_sec() * b.entry_kappa * 1.6,
"用例前提:HP 足够撑过 100 刀(HP=%s)" % NumberFormat.short(b.hp_max))
a.near(b.combo_mult(), 1.0, 1e-9, "初始连击 ×1.0")
var dmg1 := b.register_click(e) # 第 1 刀:1 层 → ×1.02
a.eq(b.combo, 1, "连击计数 1")
a.near(b.combo_mult(), 1.02, 1e-6, "1 层 → +2%")
a.near(dmg1, e.output_per_sec() * 0.010 * 1.02, 1e-6, "伤害 = 产出/s × κ × 连击")
# 连点 100 下(窗口内)→ 封顶 +60%
for _i in 99:
b.register_click(e)
a.eq(b.phase, BossBattle.Phase.ACTIVE, "100 刀后仍在 ACTIVE(未被打死)")
a.eq(b.combo, 100, "连击计数到 100")
a.near(b.combo_mult(), 1.6, 1e-9, "连击封顶 +60%(combo_cap 0.6)")
# 窗口过期(0.6s)后连击清零
for _i in 7:
b.tick(0.1, e)
a.eq(b.combo, 0, "窗口过期 → 连击清零")
a.near(b.combo_mult(), 1.0, 1e-9, "清零后回到 ×1.0")
# INTRO 阶段点击无效
var b2 := BossBattle.new(data)
var e2 := _new_econ(data, 2000.0)
b2.start(e2, 0)
a.near(b2.register_click(e2), 0.0, 1e-9, "INTRO 期间点击不生效")
# ---- 败北:超时 → 冷却 60s;购路引可立即再战 ----
func _test_lose_and_retry(a, data: BalanceData) -> void:
var e := _new_econ(data, 2000.0)
var b := BossBattle.new(data)
b.start(e, 0)
for _i in 31:
b.tick(0.1, e)
a.eq(b.phase, BossBattle.Phase.ACTIVE, "进入 ACTIVE")
# 单测手段:把进入时产出压到 0(军列不出力)→ 30s 必然打不完
b.entry_output = 0.0
for _i in 302:
b.tick(0.1, e)
if b.phase == BossBattle.Phase.RESOLVED:
break
a.eq(b.phase, BossBattle.Phase.RESOLVED, "超时 → RESOLVED")
a.false_(bool(b.view().get("win", false)), "判定为败")
a.near(float(b.view().get("cooldown", 0.0)), 60.0, 1e-6, "败 → 60s 冷却(数据行)")
a.near(float(b.view().get("reward", 0.0)), 0.0, 1e-9, "败无奖励")
# 未购路引 → 不能消冷却
a.false_(b.force_retry(e), "未购「殿王再战」→ 不能立即重来")
b.reset()
a.false_(bool(b.available(e).get("ok", false)), "冷却中不可挑战")
# 购路引 → 可立即再战
e.bought["up_gate_boss_retry"] = true
a.true_(b.force_retry(e), "购路引 → 消冷却")
a.near(float(b.view().get("cooldown", 0.0)), 0.0, 1e-9, "冷却清零")
a.true_(bool(b.available(e).get("ok", false)), "可立即再战")
# ---- 出现条件:未过任何殿门 / 产出为 0 → 不可挑战 ----
func _test_availability(a, data: BalanceData) -> void:
var e0 := EconomyState.new(data)
var b0 := BossBattle.new(data)
a.false_(bool(b0.available(e0).get("ok", false)), "未过殿门 + 产出 0 → 不可挑战")
a.true_(str(b0.available(e0).get("reason", "")).contains("殿门"), "原因写明殿门条件")
# 有殿门但产出为 0(军列未成)
var e1 := EconomyState.new(data)
e1.merit_run_total = 2000.0
e1.poll_halls()
var b1 := BossBattle.new(data)
var r1: Dictionary = b1.available(e1)
a.false_(bool(r1.get("ok", false)), "军列未成 → 不可挑战")
a.true_(str(r1.get("reason", "")).contains("军列"), "原因写明军列未成")
# 战斗中不可再开
var e2 := _new_econ(data, 2000.0)
var b2 := BossBattle.new(data)
b2.start(e2, 0)
a.false_(bool(b2.available(e2).get("ok", false)), "战斗中 → 不可挑战")
# 越界保护
a.false_(bool(b2.start(e2, 99).get("ok", false)), "殿序越界被拒")
# ---- 点击伤害缩放 κ(方案 B2):零投资 0.010 / 满配 0.0806 / 分母 12 / DPS 占比 20.5% ----
func _test_kappa_scaling(a, data: BalanceData) -> void:
# 点击树 = 12 条(type == "click" 的数据行数;防双重计入:L5-12 的 +1% 产出/s 走全局区,
# 不进 κ 分子——见 economy_state.recompute 注释)
var click_total := 0
for u in data.upgrades:
if u.type == "click":
click_total += 1
a.eq(click_total, 12, "点击树 12 条(κ 的 click_ratio 分母)")
# 零投资
var e := _new_econ(data, 2000.0)
var b := BossBattle.new(data)
b.start(e, 0)
a.near(b.entry_kappa, 0.010, 1e-9, "零投资 κ = 0.010")
a.near(b.click_line_ratio(e), 0.0, 1e-9, "法器线进度 0/12")
# 半线(6/12,无 m06):κ = 0.010 + 0.052×0.5 = 0.036
var e2 := _new_econ(data, 2000.0)
var n := 0
for u in data.upgrades:
if u.type == "click":
e2.bought[u.id] = true
n += 1
if n >= 6:
break
e2.recompute()
var b2 := BossBattle.new(data)
b2.start(e2, 0)
a.near(b2.click_line_ratio(e2), 0.5, 1e-9, "法器线进度 6/12 = 0.5")
a.near(b2.entry_kappa, 0.036, 1e-9, "半线 κ = 0.036")
# 满配(12/12 + m06):κ = 0.062 × 1.30 = 0.0806
var e3 := _new_econ(data, 2000.0)
_full_click_config(e3, data)
var b3 := BossBattle.new(data)
b3.start(e3, 0)
a.near(b3.click_line_ratio(e3), 1.0, 1e-9, "法器线进度 12/12 = 1.0")
a.near(b3.entry_kappa, 0.0806, 1e-9, "满配 κ = (0.010+0.052) × 1.30 = 0.0806")
# 封顶连击下的点击 DPS 占比 = 2κ×1.6 / (1 + 2κ×1.6) = 20.5%(spec §1.3 的"兵七成我三成")
var kappa := 0.0806
var click_dps := 2.0 * kappa * 1.6 # 2 次/s × κ × 连击封顶
a.near(click_dps / (1.0 + click_dps), 0.205, 0.005,
"封顶连击点击 DPS 占比 = 20.5%(军列 79.5% / 点击 20.5%)")
# m06 单独验证:0 阶 → ×1.0;1 阶 → ×1.30
var e4 := _new_econ(data, 2000.0)
for u in data.upgrades:
if u.type == "click":
e4.bought[u.id] = true
e4.recompute()
var b4 := BossBattle.new(data)
b4.start(e4, 0)
a.near(b4.entry_kappa, 0.062, 1e-9, "12/12 无 m06 → κ = 0.062")
# ---- 可胜性结构(方案 B 后重写;替代旧"8 胜 / 2 必超时 / 天花板 25.6"事实哨兵) ----
# 断言 = systems-spec §1.4 修订门 #11 五判据 + 用户 W4 要求:
# a) 十殿胜率 100%(挂机单推即过) b) 挂机余时 ∈ [2,8]s
# c) 满配余时 ∈ [7,12]s d) 余时差(满配−挂机)≥ 4s
# e) 点击伤害占比 ∈ [15%,25%] + 满配 30s 容量:军列+点击 ≥ HP
func _test_winnability_b(a, data: BalanceData) -> void:
var expect_s := [23.0, 23.5, 24.0, 24.5, 25.0, 25.5, 26.0, 26.5, 27.0, 28.0]
for i in data.halls.size():
var h: HallDef = data.halls[i]
var s_n: float = expect_s[i]
# --- 挂机单推(0 点击;merit_run = 0 ⇒ 超度 0 ⇒ 开场 0,纯军列口径) ---
var e_idle := _new_econ(data, 0.0, 1000) # 产出 100/s
var b_idle := BossBattle.new(data)
var ri: Dictionary = b_idle.start(e_idle, i)
a.true_(bool(ri.get("ok", false)), "%s:挂机开局" % h.id)
var ri_run := _run_battle(b_idle, e_idle, 31.0, 0.0)
a.true_(ri_run.win, "%s:挂机单推可胜(十殿全可胜,a 判据)" % h.id)
a.near(ri_run.active_sec, s_n, 0.15,
"%s:挂机耗时 = S_n = %.1fs(恒等式)" % [h.id, s_n])
a.true_(ri_run.active_sec < 30.0, "%s:挂机不超时(富余 %.1fs)" % [h.id, 30.0 - s_n])
var idle_left: float = 30.0 - float(ri_run.active_sec)
a.true_(idle_left >= 2.0 - 0.15 and idle_left <= 8.0 + 0.15,
"%s:挂机余时 %.1fs ∈ [2,8](b 判据)" % [h.id, idle_left])
# --- 满配点击(12/12 + m06,2 次/s) ---
var e_full := _new_econ(data, 0.0, 1000)
_full_click_config(e_full, data)
var b_full := BossBattle.new(data)
b_full.start(e_full, i)
var rf := _run_battle(b_full, e_full, 31.0, 0.5)
a.true_(rf.win, "%s:满配点击可胜" % h.id)
var full_left: float = 30.0 - float(rf.active_sec)
a.true_(full_left >= 7.0 and full_left <= 12.0,
"%s:满配余时 %.1fs ∈ [7,12](c 判据)" % [h.id, full_left])
var margin: float = full_left - idle_left
a.true_(margin >= 4.0,
"%s:余时差 %.1fs ≥ 4s(d 判据:点击线有决定性意义)" % [h.id, margin])
var share: float = float(rf.click_dmg) / float(b_full.hp_max)
a.true_(share >= 0.15 and share <= 0.25,
"%s:点击伤害占比 %.1f%% ∈ [15%%,25%%](e 判据)" % [h.id, share * 100.0])
# --- 满配 30s 伤害容量:军列 + 点击 ≥ HP(keep_alive 实测,无提前死亡)---
# 预算须含 INTRO 3s:战斗给 intro_sec(3) + active_sec(30) = 33s,+1s 余量
var e_cap := _new_econ(data, 0.0, 1000)
_full_click_config(e_cap, data)
var b_cap := BossBattle.new(data)
b_cap.start(e_cap, i)
var rc := _run_battle(b_cap, e_cap, 34.0, 0.5, true)
var cap_ratio: float = (float(rc.army_dmg) + float(rc.click_dmg)) / float(b_cap.hp_max)
a.true_(cap_ratio >= 1.0,
"%s:30s 军列+点击容量 = HP × %.2f ≥ 1(余量充足)" % [h.id, cap_ratio])
# 挂机容量同样 ≥ 1(纯军列 30s 也能磨死,只是压哨)
var e_cap2 := _new_econ(data, 0.0, 1000)
var b_cap2 := BossBattle.new(data)
b_cap2.start(e_cap2, i)
var rc2 := _run_battle(b_cap2, e_cap2, 34.0, 0.0, true)
var cap_ratio2: float = float(rc2.army_dmg) / float(b_cap2.hp_max)
a.true_(cap_ratio2 >= 1.0,
"%s:30s 纯军列容量 = HP × %.2f ≥ 1" % [h.id, cap_ratio2])
# H10 点击占比 ≈ 20.5% ±3pp(spec 表头值;实测为累计口径 ~18%,见 provenance P-25 说明)
var e10 := _new_econ(data, 0.0, 1000)
_full_click_config(e10, data)
var b10 := BossBattle.new(data)
b10.start(e10, 9)
var r10 := _run_battle(b10, e10, 31.0, 0.5)
var share10: float = float(r10.click_dmg) / float(b10.hp_max)
a.true_(share10 >= 0.175 and share10 <= 0.235,
"H10:满配点击占总伤害 %.1f%% ≈ 20.5%%(容差 ±3 个百分点)" % (share10 * 100.0))