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))