Files
game-code-4/tests/unit/cases/test_boss_battle.gd
T
程基岩(engineering-lead) 4f650aeb39 W3:相机 rig + band 组装器 + 事件系统 + 殿王战(灰盒);ADR-5 落定
P0 文档
- 架构文档:殿王战 HP 系数 50× → 15×(Balance Bible [B§5] 口径),全文档无残留
- 新增 ADR-5:2D 骨骼动画(Spine)取代 ADR-1 的单位/殿王 billboard 条目
  * 背景/决策/后果/三项被否决方案(多帧 billboard、Skeleton2D、DragonBones)
  * Godot 4.7 接入方式标注"需立项时复核",不臆造 API;挂探针 P12
  * 资产规格约束:部件分离 PNG / 图集 / 锚点命名 / 动画命名 / AI 绑骨质检门槛
  * 数据行纪律影响:sprite_row → 动画资源路径行,坚持"新资产 = 加行"
  * 美术工时对照(pipeline 报告 115-185h)
  * 3D 特效落地约定:走 flipbook 序列帧,理由是 3D 粒子直塞 2D 会绕过单趟
    后处理(ADR-2 红线)导致风格撕裂;挂探针 P13
- ADR-1 标注"单位/殿王条目被 ADR-5 取代",降级为回退规格(§5.1 保留)
- 架构 §8 清单 / §11 探针表 / 【确定】【探针】标注段同步;CLAUDE.md ADR 节同步

P1 横版核心
- camera.csv(10 行,一行一殿)+ camera.schema.json + core/data/camera_def.gd
  * 独立成表的四条理由写入 schema description,不污染 halls.csv
  * 全局标量(弹簧刚度/阻尼/平滑 tau/偷看上限/战前预告分钟)进 balance_tuning.csv
- presentation/stage:camera_rig.gd(进度锚定 + 偷看回弹 + 殿王战推近)
  + hall_band_world.gd + band_world_root.gd(四层视差)+ stage_world.gd
- stage_root 接入横版舞台,取代 W2 十殿色带(hall_band.gd 保留作回退)

P2 玩法系统
- game/event_director.gd:events.csv 四事件 → 触发/持续/效果/二选一/失败哗变
  * 效果全走 EconomyState 既有 API(瞬时区 set_event_mult / set_event_click_mult /
    set_mutiny),不直接改字段;四个出口统一 _clear_runtime 防"漏清永久 ×N"
- game/boss_battle.gd:30s 状态机(INTRO/ACTIVE/RESOLVED)
  * HP = 15 × 进入时产出/s × 殿系数(1.0→2.2),连击 +2%/层封顶 +60%
  * 胜 = 阴德爆发 HP×0.2(进本轮累计口径);败 = 60s 冷却(购路引可消)
  * 不用随机数 → sim 门 #11 可直接复用
- EconomyState 接事件瞬时区与哗变惩罚;GameState 接线 + 快照扩展
- presentation:event_panel.gd(事件卡/二选一)+ boss_arena.gd
  (预告/HP/连击/倒计时/结算/再战);stage_root 布局接入
- debug 构建 dev 热键 F1-F6/F9,走 GameState 既有命令口,不改数值

P3 质量
- 新增 3 个用例:test_camera_data / test_event_director / test_boss_battle
  (进 test_runner.CASE_FILES,共 7 个用例文件)
- 用户验证手册:docs/engineering/2026-10-19-w3-verification-manual.md(文档区)

修掉的两个潜伏缺陷(从根因修,非打补丁)
- gen_balance_sheets.py:halls.is_prestige_gate 落成字符串 "false",
  Godot 的 bool("false") == true → 十殿会被全部判为 prestige gate。
  改为真布尔,并把自检查从 == "true" 收紧为 is True(再犯即断言失败)
- boss_battles.schema.json:hp_factor 漏 type,GDScript SheetImporter 会产出
  字符串 "15"(Python 生成器产出的是数字),两条路径不一致。补 type: number

上报(未擅自改动,等裁定)
- H9/H10 殿王战数学上不可胜:纯军列耗时 = 15 × 殿系数(与产出/s 无关),
  殿系数 >2.0 → 31.5s/33.0s > 30s 限时;而缺口 ∝ 产出/s,点击伤害是常数
  (上限 25.6,不随产出/s 增长)→ 玩家越强越打不过,难度曲线是反的。
  已在 test_boss_battle._test_winnability_doc 把当前事实钉成断言哨兵,
  一旦有人改 hall_factor/active_sec/hp_factor 立刻变红强制重评。修数权属文策渊。

本机验证(无 Godot,靠移植仿真,非代替引擎实跑)
- 把 CameraDef/BossBattle/EventDirector 逐行移植成 JS 喂真实 build JSON,
  134 条断言全绿;9 张表 schema 校验通过且 build 逐字节一致(含新增 camera)
- 静态检查:跨文件方法/字段引用审计、静态调用合法性、src/core 禁 extends Node、
  缩进/括号配平/空函数体 —— 全部 0 问题
- 待本机验证:全部 GDScript 从未被引擎执行过;SubViewport 舞台与 Camera2D
  在 gl_compatibility 下的行为未实测(探针 P12 范畴)
2026-10-20 01:39:14 +08:00

212 lines
10 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
# 用例:W3 殿王战逻辑状态机(game/BossBattle;boss_battles.csv 十场)
# 口径全部来自数据行([B§5]):HP = 15 × 进入时产出/s × 殿系数(1.0→2.2)。
# 本类不用随机 → 结果可复现(W4 起 sim 门 #11 直接复用)。
func run(a, data) -> void:
_test_hp_formula(a, data)
_test_win(a, data)
_test_combo_cap(a, data)
_test_lose_and_retry(a, data)
_test_availability(a, data)
_test_winnability_doc(a, data)
func _new_econ(data, 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
# ---- HP = 15 × 产出/s × 殿系数([B§5],GDD 旧值 50× 已废止) ----
func _test_hp_formula(a, data) -> 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")
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(float(def.hp_factor), 15.0, 1e-9, "%s:hp_factor = 15" % h.id)
a.near(def.hp_at(out), 15.0 * out * float(def.hall_factor), 1e-6,
"%s:HP = 15 × 产出/s × 殿系数" % h.id)
# H1 殿系数 1.0 → HP = 15;H10 = 2.2 → 2.2 倍
var d1 := data.boss_for_hall("hall_01_qinguang")
var d10 := data.boss_for_hall("hall_10_zhuanlun")
a.near(float(d1.hall_factor), 1.0, 1e-9, "H1 殿系数 1.0")
a.near(float(d10.hall_factor), 2.2, 1e-9, "H10 殿系数 2.2")
a.near(d1.hp_at(out), 15.0, 1e-6, "H1:产出 1/s → HP 15")
a.near(d10.hp_at(out), 33.0, 1e-6, "H10:产出 1/s → HP 33")
# ---- 军列 DPS = 产出/s × 1.0;30s 内打完 → 胜 + 阴德爆发 HP×0.2 ----
func _test_win(a, data) -> 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)), 15.0, 1e-6, "进入时锁定 HP = 15")
a.eq(b.phase, BossBattle.Phase.INTRO, "先走 INTRO(3s 军列冲锋)")
# 超度值为 0 → 开场伤害 0(min(0×0.5, HP×0.2))
a.near(b.hp, 15.0, 1e-6, "开场伤害 = 0(超度值 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 → 15s 打完
var before := e.merit_run_total
for _i in 152:
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)), 15.0 * 0.2, 1e-6, "奖励 = HP × 0.2 = 3")
a.near(e.merit_run_total, before + 15.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)), "新轮回后重新可挑战")
# ---- 连击:+2%/层,封顶 +60% ----
# 注意:本用例必须让 HP 大到"100 刀砍不死",否则第 14 刀就把 boss 打死(HP=15×产出/s,
# 而点击价值恒为 1.0),战斗提前进 RESOLVED,连击永远涨不到封顶 —— 这是用例设计陷阱,
# 不是被测代码的 bug。故这里把军列拉到 200 级(产出 20/s → HP 300 >> 100 刀总伤 ≈151)。
func _test_combo_cap(a, data) -> 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.true_(b.hp_max > 100.0 * e.click_value() * 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.near(b.combo_mult(), 1.02, 1e-6, "1 层 → +2%")
a.near(dmg1, e.click_value() * 1.02, 1e-6, "伤害 = 点击价值 × 连击")
# 连点 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) -> 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) -> 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)), "殿序越界被拒")
# ---- 可胜性结构(**事实记录**,不是"应当如此"的断言) ----
# 推导(与产出/s 无关,故对任何存档都成立):
# 纯军列耗时 = HP ÷ (产出/s × army_dps_factor)
# = (hp_factor × 产出/s × hall_factor) ÷ (产出/s × army_dps_factor)
# = hp_factor × hall_factor ÷ army_dps_factor ← 产出/s 被约掉了
# 于是:殿系数 > active_sec ÷ (hp_factor ÷ army_dps_factor) = 30 ÷ 15 = 2.0 的殿,
# 军列单推必定超时;而缺口 ∝ 产出/s,点击伤害却是常数(不随产出/s 增长)
# → **玩家越强,缺口越大,越打不过**。这是 W3 交付时点名上报文策渊的平衡缺陷,
# 本用例把当前数据的事实钉死:一旦有人改 hall_factor / active_sec / hp_factor,
# 这里会立刻变红,提醒重新评估可胜性。修数值的权力在文策渊,不在本文件。
func _test_winnability_doc(a, data) -> void:
var ratio: float = float(data.bosses[0].hp_factor) / float(data.bosses[0].army_dps_factor)
a.near(ratio, 15.0, 1e-9, "hp_factor ÷ army_dps_factor = 15(纯军列耗时的基准秒数)")
var hard: Array = []
var easy: Array = []
for d in data.bosses:
var t := float(d.hp_factor) * float(d.hall_factor) / float(d.army_dps_factor)
var label := "%s(%.1fs)" % [d.hall_id, t]
if t > float(d.active_sec):
hard.append(label)
else:
easy.append(label)
# 事实:殿系数 ≤2.0 的殿(H1-H8)军列可胜;>2.0 的殿(H9/H10)必超时
a.eq(easy.size(), 8, "军列单推可胜的殿 = 8 座:%s" % ", ".join(easy))
a.eq(hard.size(), 2, "军列单推必超时的殿 = 2 座:%s(上报文策渊的平衡缺陷)" % ", ".join(hard))
# 点击侧天花板(常数,不随产出/s 增长)—— 后期补不上缺口的根源。
# 2^升级数 不写死:从 upgrades.csv 里数 click_mult 型的行数(数据行纪律)。
var click_up_count := 0
for u in data.upgrades:
if u.effect_type == "click_mult":
click_up_count += 1
var click_cap := data.tune_num("tune_click_base", 1.0) \
* pow(2.0, float(click_up_count)) * (1.0 + float(data.bosses[0].combo_cap))
a.near(click_cap, 25.6, 1e-6,
"点击单次伤害天花板 = 基数1 × 2^%d(点击升级) × 1.6(连击封顶) = 25.6(常数)" % click_up_count)
# 若哪天把点击伤害改成随产出/s 增长,上面这条会红 → 说明可胜性结构已变,需重评