Files
game-code-4/tests/unit/cases/test_event_director.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

155 lines
6.6 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/EventDirector;events.csv 4 事件)
# 说明:EventDirector 是 RefCounted,不依赖场景树 → 无头可跑。
# 断言口径:效果必经 EconomyState(瞬时区/惩罚/双货币),UI 只读 view()。
func run(a, data: BalanceData) -> void:
_test_gate(a, data)
_test_output_mult(a, data)
_test_click_output_mult(a, data)
_test_challenge_fail(a, data)
_test_choice(a, data)
_test_timeout_default(a, data)
func _new_econ(data: BalanceData) -> EconomyState:
var e := EconomyState.new(data)
e.bought["up_gate_events"] = true # 事件系统路引(过 H2 后出现)
e.gen_levels[0] = 10 # 游魂 10 级 → 产出 1.0/s(gen1 rate 0.1)
return e
# ---- 出现前提 ----
func _test_gate(a, data: BalanceData) -> void:
var d := EventDirector.new(data, 7)
var e := EconomyState.new(data)
e.gen_levels[0] = 10
a.false_(d.unlocked(e), "未买路引 → 事件系统未解锁")
d.tick(600.0, e) # 10 分钟(远超首延迟)
a.false_(d.is_busy(), "未解锁时不触发事件")
e.bought["up_gate_events"] = true
a.true_(d.unlocked(e), "买路引 → 解锁")
# ---- 百鬼夜行:全产出 ×5,60s 后清零 ----
func _test_output_mult(a, data: BalanceData) -> void:
var d := EventDirector.new(data, 11)
var e := _new_econ(data)
var base := e.output_per_sec()
a.near(base, 1.0, 1e-6, "基线产出 1.0/s")
var r := d.force_start(e, "ev_01_baigui")
a.true_(bool(r.get("ok", false)), "强制开百鬼夜行")
a.eq(str(r.get("type", "")), "output_mult", "类型 output_mult")
a.near(e.output_per_sec(), base * 5.0, 1e-6, "全产出 ×5 生效(瞬时区)")
var v: Dictionary = d.view(e)
a.true_(bool(v.get("active", false)), "view 报告事件进行中")
a.eq(str(v.get("id", "")), "ev_01_baigui", "view 的 id")
a.near(float(v.get("mult", 1.0)), 5.0, 1e-9, "view 的产出倍率来自数据行")
# 跑到结束
var steps := int(float(data.event("ev_01_baigui").duration_sec) / 0.1) + 2
for _i in steps:
d.tick(0.1, e)
a.false_(d.is_busy(), "持续时间到 → 事件结束")
a.near(e.output_per_sec(), base, 1e-6, "瞬时区复位(不会永久 ×5)")
# ---- 天狗食月:点击 ×10 + 全产出 ×2 ----
func _test_click_output_mult(a, data: BalanceData) -> void:
var d := EventDirector.new(data, 13)
var e := _new_econ(data)
var base_out := e.output_per_sec()
var base_click := e.click_value()
a.near(base_click, 1.0, 1e-6, "基线点击价值 1.0")
var r := d.force_start(e, "ev_04_tiangou")
a.true_(bool(r.get("ok", false)), "强制开天狗食月")
a.near(e.output_per_sec(), base_out * 2.0, 1e-6, "全产出 ×2")
a.near(e.click_value(), base_click * 10.0, 1e-6, "点击价值 ×10(独立瞬时区)")
# 点击仍能被计数(挑战计数只对 click_challenge 生效,这里不应污染挑战状态)
d.notify_click()
a.eq(int(d.challenge_clicks), 0, "非挑战事件不计数点击")
# 结束复位
var steps := int(float(data.event("ev_04_tiangou").duration_sec) / 0.1) + 2
for _i in steps:
d.tick(0.1, e)
a.near(e.click_value(), base_click, 1e-6, "点击瞬时区复位")
a.near(e.output_per_sec(), base_out, 1e-6, "产出瞬时区复位")
# ---- 阴兵闹饷:窗口内点击不足 → 随机班底产出 -50% × 60s ----
func _test_challenge_fail(a, data: BalanceData) -> void:
var d := EventDirector.new(data, 17)
var e := _new_econ(data)
var ev := data.event("ev_02_naoxiang")
var need := int(ev.p_clicks)
var r := d.force_start(e, "ev_02_naoxiang")
a.true_(bool(r.get("ok", false)), "强制开阴兵闹饷")
# 只点了一半
for _i in need / 2:
d.notify_click()
a.eq(int(d.challenge_clicks), need / 2, "点击计数 %d" % (need / 2))
a.eq(e.mutiny_gen_index, -1, "窗口未结束 → 尚无惩罚")
# 走完窗口(p_window_sec = 15s)
var steps := int(float(ev.p_window_sec) / 0.1) + 2
for _i in steps:
d.tick(0.1, e)
a.false_(d.is_busy(), "窗口结束 → 事件结束")
a.true_(e.mutiny_gen_index >= 0, "失败 → 哗变惩罚落到某个已解锁班底")
a.near(e.mutiny_mult, 1.0 - float(ev.p_penalty), 1e-9, "惩罚倍率 = 1 - p_penalty(-50%)")
a.near(e.mutiny_left, float(ev.p_penalty_dur_sec), 1e-6, "惩罚持续时间来自数据行")
# 惩罚到期自动清除
for _i in int(float(ev.p_penalty_dur_sec) / 0.1) + 2:
e.tick(0.1)
a.eq(e.mutiny_gen_index, -1, "惩罚到期 → 自动清除")
a.near(e.mutiny_mult, 1.0, 1e-9, "惩罚倍率复位")
# ---- 冤魂申诉:二选一(超度 = 阴德爆发 30s 量 / 吞噬 = 冤气) ----
func _test_choice(a, data: BalanceData) -> void:
var ev := data.event("ev_03_shensu")
# opt_a:超度
var d1 := EventDirector.new(data, 19)
var e1 := _new_econ(data)
var out1 := e1.output_per_sec()
d1.force_start(e1, "ev_03_shensu")
a.eq(d1.state, EventDirector.State.CHOOSING, "choice 事件进入 CHOOSING")
var before1 := e1.merit_run_total
var r1 := d1.choose(e1, 0)
a.true_(bool(r1.get("ok", false)), "选 opt_a(超度)")
a.near(float(r1.get("merit", 0.0)), out1 * float(ev.opt_a_param), 1e-6,
"超度 = 产出/s × 30s 量")
a.near(e1.merit_run_total, before1 + out1 * float(ev.opt_a_param), 1e-6,
"阴德进本轮累计口径(可推殿门)")
a.near(e1.wrath, 0.0, 1e-9, "超度不给冤气")
# opt_b:吞噬
var d2 := EventDirector.new(data, 23)
var e2 := _new_econ(data)
d2.force_start(e2, "ev_03_shensu")
var r2 := d2.choose(e2, 1)
a.true_(bool(r2.get("ok", false)), "选 opt_b(吞噬)")
# lerp() 在 Godot 4.x 返回 Variant(同 floor 家族);`:=` 推断会触发
# INFERENCE_ON_VARIANT(4.7.2 默认按错误处理)→ 显式标注 float
var want: float = lerp(float(ev.p_clicks), float(ev.p_window_sec),
clampf(float(e2.halls_passed) / float(data.halls.size()), 0.0, 1.0))
a.near(float(r2.get("wrath", 0.0)), want, 1e-6, "吞噬冤气 = lerp(p_clicks, p_window_sec, 殿序进度)")
a.near(e2.wrath, want, 1e-6, "冤气入账")
a.eq(e2.devour_count, 1, "吞噬计数 +1")
# ---- 超时默认 = tune_event_timeout_default(opt_a 超度) ----
func _test_timeout_default(a, data: BalanceData) -> void:
var d := EventDirector.new(data, 29)
var e := _new_econ(data)
var default_opt := data.tune_text("tune_event_timeout_default", "opt_a")
a.eq(default_opt, "opt_a", "超时默认 = opt_a([B§7])")
d.force_start(e, "ev_03_shensu")
var before := e.merit_run_total
# 决策窗 45s 走完
var steps := int(float(data.event("ev_03_shensu").duration_sec) / 0.1) + 2
for _i in steps:
d.tick(0.1, e)
a.false_(d.is_busy(), "超时 → 事件结束")
a.true_(e.merit_run_total > before, "超时走默认分支(超度 → 阴德入账)")
a.near(e.wrath, 0.0, 1e-9, "超时不吞冤气")