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 增长,上面这条会红 → 说明可胜性结构已变,需重评