Files
gplan-2/game/smoke/ui_click_probe.gd
T
DKChan 6b1e447d27 feat(调试入口): 全局浮层角标——UI 最顶层、任何 UI 不遮挡(2026-10-16 用户令)
用户定稿:调试入口不做置顶钮式按需出现,要的是「UI 最顶层浮层、不被任何 UI 遮挡」。
实现=三枚屏内角标(菜单右下/战斗 HUD 右下/地图顶栏)合并为一枚全局浮层角标:

- LAYER_CONTRACT 末位新增 {"key": "debug_badge", "kind": "float"}——只落树序(最顶绘制序)、
  不注册 ModalStack:open() 遮挡记账只隐藏登记层,故任何模态/覆盖层开都不遮它(用户需求
  的机制根因);_layer_node 补映射;main 直接子层、构建走 ui_assembler.build_debug_badge
- 位置:右下角 16px 惯例位;地图屏底栏 85px 常驻→_sync_status_entry 按 map_on 抬 offset
  至底栏上方 16px;战斗屏状态角标经 MarginContainer 让位(right 16/bottom 52)不再被压
- 屏内角标全退役:menu_screen/board_zone_view/level_map_screen 三处 debug_badge 删除,
  main 两处接线与 _sync_status_entry 四条可见性/置灰行清除(浮层常显不置灰——点它就是开浮窗)
- 浮窗 desc 文案改「UI 最顶层「调试」浮层角标随时可开——任何界面不遮挡」
- 断言/探针换代:smoke V2f 查 main.debug_badge boot 可见;click_probe 遮挡判据移至设置面
  开中段(浮层角标此时仍应可见=直接验「不遮挡」);tune_probe ①点击路径与⑨对局中角标
  全改 main.debug_badge(T2 防线断言不动——浮窗 non_locking×遮挡复活×has_open 组合回归)

冒烟 1127/0 过;全量闸待跑。
2026-10-16 23:53:57 +08:00

795 lines
45 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.
# VERTICAL SLICE - NOT FOR PRODUCTION
# 实现 GDD:design/ux/battle.md(§AC 场景级测试 + §Interaction Map——UI 行为随机化探针 v3)。
# Date: 2026-09-12
# 合成鼠标事件走真实呈现层路径(BoardView.click_cell / 按钮信号 → 组合根动作通道),
# 随机混合玩家行为——插旗/拔旗、提前停手、弃图刷新确认链、图例模态、明细回看模态、
# tooltip 悬停、无效点击——扫描结算停滞(缺陷②回归载体)
# + 模态遮挡断言(缺陷⑤回归:图例/回看/终局 overlay 打开时上方遮挡层隐藏、输入锁定)
# + 性能 AC:场景实例化到首格可点 ≤500ms 探针计时。
# 行为序列由 PROBE_SEED 决定(shell 传参);程序玩法数值不受影响(随机只用于探针策略)。
# 运行:PROBE_SEED=7 Godot_console.exe --headless --path . res://smoke/ui_click_probe.tscn
extends Node
const AutoPlayer := preload("res://smoke/auto_player.gd") # O4:自动玩家策略单一实现
const ProbeUiUtil := preload("res://smoke/probe_ui_util.gd") # T6 收口:树寻址/隔离样板单点
var main: Node
var _last_state := {}
var _stall_frames := 0
var _closed_via_abandon := false # T17——预算耗尽经放弃路径收口(断点段返回断言豁免位)
var _done := false
var _rand := RandomNumberGenerator.new()
var _flags_placed: Array = []
var _ap_spent_this_round := 0
var _problems: Array = [] # 新增覆盖的问题收集(退出码合并判定)
var _tested_legend := false
var _tested_review := false
var _tested_end_review := false # 迭代 6 T7——终局面板回看入口(z 序修复回归)
var _tested_refresh_chain := false
var _tested_tooltip := false
var _tested_status := false # 迭代 17 卡③——状态面板角标开合/守卫断言(每 run 一次)
var _tested_map_status := false # 迭代 17 卡③——地图屏角标一次性 case(选路态零扰动)
var _inter_confirms := 0 # 大奖励屏确认计数(冒险推进停点——首见确认推进 L2,再见 = L2 通关收口)
var _settlement_confirms := 0 # 卡⑥ 胜利结算框确认计数(观测面——每场普通战斗胜利恰一)
var _route_clicks := 0 # 选路覆盖层点击计数(岔路使用率观测面)
var _locktest_state := 0 # T1 D1 锁期/重放断言状态机(0=待注入 1=等重放 2=完结)——每 run 一次
var _locktest_cell := Vector2i(-9, -9)
var _locktest_frames := 0
func _ready() -> void:
var seed_env := OS.get_environment("PROBE_SEED")
_rand.seed = int(seed_env) if seed_env.is_valid_int() else 7
await get_tree().process_frame
var t0 := Time.get_ticks_msec()
main = load("res://presentation/main.tscn").instantiate()
add_child(main)
await get_tree().process_frame
var t_ui_ready := Time.get_ticks_msec() - t0
# 调试浮窗 boot 不开(2026-10-16 用户令修正:菜单优先——入口=UI 最顶层全局浮层角标,
# 任何 UI 不遮挡;浮层角标遮挡判据见设置面段)
if main.debug_overlay.visible:
_problems.append("调试浮窗 boot 自动开(菜单优先失守)")
# ---- 营地购买合成点击路径(元进度实现批——campbtn/购买/关闭全走真实按钮信号路径;
# 隔离存档换注 main.meta 保确定性(禁碰真实档 user://save.json);陈旧档段首清理)----
for p0 in ["user://smoke_click_probe_reset.json", "user://smoke_click_probe_reset.json.tmp"]:
if FileAccess.file_exists(p0):
DirAccess.remove_absolute(p0) # 重置档段前清场兜底(营地/设置段不触该档——防陈旧断点泄入后续段)
var iso_c = ProbeUiUtil.isolate_save(main, "smoke_click_probe_camp", false, false, false, false)
var campbtn: Button = ProbeUiUtil.find_button(main._start_overlay, "营地", "prefix")
if campbtn == null:
_problems.append("营地入口按钮缺失(开始层 campbtn)")
else:
campbtn.pressed.emit() # 真实按钮信号路径 → 组合根 _open_camp
await get_tree().process_frame
if not main.camp_overlay.visible:
_problems.append("营地层未打开(点击路径)")
elif main.camp_overlay._tree_buy_btns.is_empty():
_problems.append("树淬炼柜台购买钮缺失(点击路径——四柜台重排后树=主界面)")
else:
# 树钮句柄用后即取(refresh_camp 随刷重建树行板——旧钮 queue_free,跨刷新句柄必失效)
iso_c.add_keystone(9999)
main.camp_overlay.refresh_camp(main._camp_data())
await get_tree().process_frame
var buy_btn: Button = null
for card in main.camp_overlay._tree_buy_btns:
if String(card.node_id) == "sp_hp":
buy_btn = card.btn
if buy_btn == null:
_problems.append("脊柱生命行缺失(点击路径)")
else:
var bal_before: int = iso_c.keystone_balance()
var price: int = int(main.db.tree_node("sp_hp").get("price", 0))
buy_btn.pressed.emit() # 合成购买点击 → _on_camp_tree_buy → meta.tree_buy(写调用唯一归组合根)
await get_tree().process_frame
if int(iso_c.keystone_balance()) != bal_before - price:
_problems.append("营地树购买未出账(%d→%d 应 −%d)" % [bal_before, iso_c.keystone_balance(), price])
var iso_chk = load("res://core/save_manager.gd").new("user://smoke_click_probe_camp.json")
iso_chk.load_from_disk()
if int(iso_chk.tree_level("sp_hp")) != 1:
_problems.append("营地树购买未持久(reload 后级 != 1)")
# Fork 双态切换点击路径(V20 换代——购=解锁默认 A,钮切 B;前置=同调位 1,先点亮)
var socket_btn: Button = null
for card in main.camp_overlay._tree_buy_btns:
if String(card.node_id) == "att_socket1":
socket_btn = card.btn
if socket_btn != null:
socket_btn.pressed.emit()
await get_tree().process_frame
main.camp_overlay.refresh_camp(main._camp_data())
await get_tree().process_frame
var fork_btn: Button = null
for card in main.camp_overlay._tree_buy_btns:
if String(card.node_id) == "fork_posture":
fork_btn = card.btn
if fork_btn == null:
_problems.append("Fork 解锁钮缺失(点击路径)")
else:
fork_btn.pressed.emit()
await get_tree().process_frame
main.camp_overlay.refresh_camp(main._camp_data())
await get_tree().process_frame
var side_b: Button = null
for fk in main.camp_overlay._tree_fork_btns:
if String(fk.group) == "fg_posture" and String(fk.side) == "b":
side_b = fk.btn
if side_b == null:
_problems.append("Fork 双态切换钮缺失(解锁后应展开 a/b 钮)")
else:
side_b.pressed.emit() # 合成切态点击 → _on_camp_fork_toggle → meta.tree_toggle_fork
await get_tree().process_frame
var iso_fk = load("res://core/save_manager.gd").new("user://smoke_click_probe_camp.json")
iso_fk.load_from_disk()
if String(iso_fk.fork_side("fg_posture")) != "b":
_problems.append("Fork 切态未持久(reload 后激活态 != b)")
main._on_camp_closed()
await get_tree().process_frame
if main.camp_overlay.visible or not main._start_overlay.visible:
_problems.append("营地关闭后未恢复开始层")
# ---- 设置面段(迭代 8 卡④——隔离 store 换注禁碰真实 user://settings.cfg;真实按钮信号路径)----
for p0 in ["user://smoke_click_probe_settings.cfg"]:
if FileAccess.file_exists(p0):
DirAccess.remove_absolute(p0)
var iso_s = load("res://presentation/settings_store.gd").new("user://smoke_click_probe_settings.cfg")
iso_s.load_settings()
main.settings_store = iso_s
main.settings_overlay.retarget(iso_s)
var settings_btn: Button = ProbeUiUtil.find_button(main._start_overlay, "设置", "prefix")
if settings_btn == null:
_problems.append("设置入口按钮缺失(开始层 settingsbtn)")
else:
settings_btn.pressed.emit() # 真实按钮信号路径 → _open_settings
await get_tree().process_frame
if not main.settings_overlay.visible or main._start_overlay.visible:
_problems.append("设置面未打开(点击路径)")
else:
# 减动效转正双态(卡④ AC③)——M4 适配:拨杆开关(_motion_btn)语义翻转 on=完整/off=快速,
# 状态字随行 _motion_st;查找由文字改成员引用(拨杆无文字)
var motion_btn: Button = main.settings_overlay._motion_btn
if motion_btn == null:
_problems.append("设置面减动效开关缺失")
else:
motion_btn.button_pressed = false # off=快速(减动效开)
await get_tree().process_frame
if UiKit.motion_scale != 0.5:
_problems.append("减动效开未生效(motion_scale != 0.5)")
var chk = load("res://presentation/settings_store.gd").new("user://smoke_click_probe_settings.cfg")
chk.load_settings()
if not chk.reduced_motion:
_problems.append("减动效开未持久化(设置独立存储失守——卡④ AC⑤)")
motion_btn.button_pressed = true # on=完整回常态
await get_tree().process_frame
if UiKit.motion_scale != 1.0:
_problems.append("减动效关未恢复(motion_scale != 1.0)")
# 音量实时生效(卡④ AC①):滑杆 30% → master bus db 与 store 双读
var vol: HSlider = main.settings_overlay._volume
vol.value = 30
await get_tree().process_frame
var master_idx := AudioServer.get_bus_index("Master")
var expect_db := linear_to_db(0.3)
if absf(AudioServer.get_bus_volume_db(master_idx) - expect_db) > 0.1:
_problems.append("音量滑杆未实时生效(master bus db %f ≠ %f)" % [AudioServer.get_bus_volume_db(master_idx), expect_db])
if absf(iso_s.volume_linear - 0.3) > 0.001:
_problems.append("音量未入 store(%f ≠ 0.3)" % iso_s.volume_linear)
# 调试入口断言(2026-10-16 用户令:全局浮层角标=UI 最顶层、任何 UI 不遮挡——本段设置面
# 开中正是遮挡场景:浮层角标此时仍应可见;点击开浮窗路径归 tune_probe)
if main.debug_badge == null or not main.debug_badge.visible:
_problems.append("设置面开中调试浮层角标不可见(最顶层浮层失守——被遮挡)")
var settings_close: Button = ProbeUiUtil.find_button(main.settings_overlay, "返回", "prefix")
if settings_close == null:
_problems.append("设置面返回按钮缺失")
else:
settings_close.pressed.emit()
await get_tree().process_frame
if main.settings_overlay.visible or not main._start_overlay.visible:
_problems.append("设置面关闭后未恢复开始层")
# 旧入口退役断言(卡④ AC③——测试调整板不再含减动效开关;14.5 起载体=调试浮窗)
var legacy_motion = ProbeUiUtil.find_check_button(main.debug_overlay, "减动效")
if legacy_motion != null:
_problems.append("调整板减动效旧入口未退役(设置面唯一入口失守——卡④ AC③)")
# D1 断言(二轮):退出游戏只在一级菜单——开始菜单有、设置面无
if ProbeUiUtil.find_button(main._start_overlay, "退出游戏", "prefix") == null:
_problems.append("开始菜单缺退出游戏按钮(二轮 D1)")
if ProbeUiUtil.find_button(main.settings_overlay, "退出游戏", "prefix") != null:
_problems.append("设置面仍含退出游戏按钮(二轮 D1——只在一级菜单出现)")
# ---- 生涯页段(迭代 18 卡④——入口点击链+关闭回返,真实按钮信号路径;本页只读零写,
# 值断言归冒烟投影段与 ui_struct 结构族,此处只验开合路径与零跳转)----
var career_btn: Button = ProbeUiUtil.find_button(main._start_overlay, "生涯", "prefix")
if career_btn == null:
_problems.append("生涯入口按钮缺失(开始层 careerbtn)")
else:
career_btn.pressed.emit() # 真实按钮信号路径 → 组合根 _open_career
await get_tree().process_frame
if not main.career_overlay.visible or main._start_overlay.visible:
_problems.append("生涯页未打开(点击路径)")
else:
# 零跳转契约(提纲 P4 甲):页内除「返回」外零导航钮
for nav in ["营地", "商店", "设置", "退出", "冒险"]:
if ProbeUiUtil.find_button(main.career_overlay, nav, "contains") != null:
_problems.append("生涯页内出现导航入口「%s」(零跳转违约——生涯纯只读)" % nav)
# 局内入口断言(提纲 P4 甲):生涯入口只在开始层——战斗屏/地图屏零生涯角标
if main.board_zone.find_child("career_badge", true, false) != null \
or main.level_screen.find_child("career_badge", true, false) != null:
_problems.append("局内屏出现生涯入口件(局内不放生涯入口违约)")
var career_close: Button = ProbeUiUtil.find_button(main.career_overlay, "返回", "prefix")
if career_close == null:
_problems.append("生涯页返回按钮缺失")
else:
career_close.pressed.emit()
await get_tree().process_frame
if main.career_overlay.visible or not main._start_overlay.visible:
_problems.append("生涯页关闭后未恢复开始层")
# 换回全新零档(营地购买态不泄入后续冒险段——确定性;档案残留下次段首清理兜底)
ProbeUiUtil.isolate_save(main, "smoke_click_probe_reset", true, false, true) # 零档换注 + 继续上局按钮重刷(原三行样板收口)
var resume_btn0: Button = ProbeUiUtil.find_button(main._start_overlay, "▶ 继续上局", "prefix")
if resume_btn0 == null:
_problems.append("继续上局按钮缺失(开始层 resumebtn——装配器)")
elif resume_btn0.visible:
_problems.append("无断点态继续上局按钮未隐藏(卡② AC①——新档合法空态)")
main.debug_overlay.set_sliders(400, 200) # 高加成保 L1 可通关——L2 岔路选路段只能经冒险推进到达(单关直入口退役)
main.debug_overlay.seed_edit.text = "42" # 种子钉死(迭代 18 卡⑦审计🔴——种子框默认改空后空框=非首局随机,行为走查确定性禁依赖默认分支)
var t1 := Time.get_ticks_msec()
main._start_adventure_from_menu()
await get_tree().process_frame
# 停点前移批 2026-09-23:关卡开始 = 入口选路停点——press_candidate 图上命中路径选入口候选进首战(候选按钮退役 2026-09-28)
#(首格可点 AC 口径随之含入口选路一步:菜单点击 → 停点呈现 → pick → 棋盘安装)
if main.level_screen.visible:
# 独立性断言(形态升格批 2026-09-28):独立屏激活期战斗屏宿主整体隐藏
if main._battle_root.visible:
_problems.append("独立屏激活期战斗屏宿主未隐藏(零透出契约失守)")
main.level_screen.press_candidate(0)
await get_tree().process_frame
await get_tree().process_frame
var t_entry := Time.get_ticks_msec() - t1
# 性能 AC:进入战斗屏到首格可点 ≤500ms(棋盘 install 完成口径);
# 场景实例化冷启动总时长(含全部脚本首载编译——引擎启动域,非 AC 口径)另列观测
var t_total := Time.get_ticks_msec() - t0
var first_cell_ms := t_entry
if first_cell_ms > 500:
_problems.append("进入战斗屏首格可点超时 %dms > 500ms" % first_cell_ms)
if main.board_view.cells_probe().is_empty():
_problems.append("首格未可点(cells=0)")
print("[click_probe] 首格可点计时 %dms(进入战斗屏口径,≤500ms AC)· 场景冷启动 %dms(含 UI 构建 %dms,观测项)" % [
first_cell_ms, t_total, t_ui_ready])
const ACTION_BUDGET_FRAMES := 20000 # 主循环帧预算(坏态挂死兜底上限——常态通关远低于此;有挂帧证据才收紧,勿让坏态等满预算才报)
var action_budget := ACTION_BUDGET_FRAMES
# 2026-10-02 修复批(P0-2):c44fb1b 加 bonus 分支时把本 while 循环体整体去缩进——continue 落在
# 循环外成解析错误、探针不可加载且改后无人实跑。此处还原循环体缩进,bonus 分支保留为体内兄弟分支。
while not _done and action_budget > 0:
action_budget -= 1
await get_tree().process_frame
if main.node_overlay.visible:
main.node_overlay.press_continue()
_flags_placed.clear()
continue
if main.curio_overlay.visible:
# 迭代 10 三选一停点(CurioPickOverlay——奖励节点/通关兑付共用面;T17 硬锁批适配——
# 本探针此前未适配该层,走到即卡死)。两态分派:result 态(兑付反馈——点继续推进)/
# 待选态(点首件+确认)。真实信号路径全线。〔替换子态随迭代 22 T5 拆除——装配不可变契约。〕
# T1 D1——三选一停点输入锁定断言(停点态断言面:待选/兑付两态共用此层)。
if not main._input_locked():
_problems.append("三选一停点打开时输入未锁定")
if main.curio_overlay._continue_btn.visible:
main.curio_overlay.press_continue()
elif main.run_scope.adventure != null and not main.run_scope.adventure._curio_pending.is_empty():
main.curio_overlay.press_card(0)
main.curio_overlay.press_confirm()
else:
main.curio_overlay.press_continue() # 停点已消费的残面——继续关屏
_flags_placed.clear()
continue
if main.shop_overlay.visible:
# 商店房覆盖层(GP+商店房批——L2 商店为 Boss 前必经位):零购离开(真实 leave 信号路径)放行选路。
# T1 D1 分派链序修正:商店层=选路页上的模态覆盖(迭代 11 商店房重构后停点呈现为
# level_screen+shop 双 visible)——必须先于 level_screen 分派,否则下层候选点击
# 空转烧尽预算(实证:lv2 商店停点双 visible 循环 12000 帧 done=false)。
main.shop_overlay.leave_pressed.emit()
_flags_placed.clear()
continue
if main.level_screen.visible:
# 节点图选路口(零自动推进停点)——图上命中路径交替选候选(岔路使用行为;布局决策 2 改造)
# 迭代 17 卡③——地图屏角标一次性 case:选路前经角标开/关状态面板(层在地图屏之上开合,
# 关闭后地图屏选路态零扰动——P2 甲层级语义)
if not _tested_map_status and not main.status_panel.visible:
_tested_map_status = true
var mpb: Button = main.level_screen.status_badge
if mpb == null or not mpb.visible or mpb.disabled:
_problems.append("地图屏状态角标缺失/未点亮/被置灰(P1 丁)")
else:
mpb.pressed.emit()
if not main.status_panel.visible or not main.level_screen.visible:
_problems.append("地图屏角标开状态面板失败或地图屏被误藏(P2 甲层级失守)")
var rbtn2: Button = ProbeUiUtil.find_button(main.status_panel, "继续", "prefix")
if rbtn2 != null:
rbtn2.pressed.emit()
if main.status_panel.visible or not main.level_screen.visible:
_problems.append("状态面板关闭后地图屏态失守(恢复语义破)")
main.level_screen.press_candidate(_route_clicks % 2)
_route_clicks += 1
_flags_placed.clear()
continue
if main._inter_overlay.visible:
# 大奖励屏(冒险级停点——非末关通关恰一次):首见确认推进 L2(真实按钮信号路径);再见 = L2 已通关,收口
if not main._input_locked():
_problems.append("大奖励屏打开时输入未锁定")
_inter_confirms += 1
if _inter_confirms == 1:
var next_btn: Button = ProbeUiUtil.find_button(main._inter_overlay, "进入下一层", "prefix")
if next_btn == null:
_problems.append("大奖励屏确认按钮缺失")
else:
next_btn.pressed.emit()
await get_tree().process_frame
await get_tree().process_frame
if main.run_scope.runner == null or int(main.run_scope.runner.level_row.get("level_index", -1)) != 2:
_problems.append("大奖励确认后未推进到第二关(level_index != 2)")
else:
_done = true
print("[click_probe] 第二关大奖励屏(L2 通关)——探针收口 · 选路口点击 %d 次" % _route_clicks)
break
_flags_placed.clear()
continue
if main._end_overlay.is_open() and main._settlement_stop_pending:
# 卡⑥ 战斗胜利总结结算框(真实信号路径——每场普通胜利到达):断言已拍板面后点「下一节点」
if not main._input_locked():
_problems.append("胜利结算框打开时输入未锁定")
var gp_line: String = "%s %s" % [main._end_overlay._gp_name.text, main._end_overlay._gp_val.text] # 两列账行(审计差距④)——名/值列拼回整串断言
if not gp_line.contains("GP +%d" % main.run_scope.last_gain_amount) or not gp_line.contains("合计 %d" % int(main.run_scope.runner.gp)):
_problems.append("胜利结算框 GP 增量/总量与钱包实态不符(%s|实态 +%d/%d)" % [gp_line, main.run_scope.last_gain_amount, int(main.run_scope.runner.gp)])
if main._end_overlay.verdict_text() == "":
_problems.append("胜利结算框判定行缺失")
_settlement_confirms += 1
main._end_overlay._btn.pressed.emit() # 「下一节点」真实信号路径
await get_tree().process_frame
await get_tree().process_frame
if main._settlement_stop_pending or not main.level_screen.visible:
_problems.append("胜利结算框「下一节点」未进选路页(卡⑥ AC②)")
continue
if main._end_overlay.is_open():
# 冒险终局(失败/放弃/打穿)——单关直入口退役后无二段入口,见终局即收口(模态断言照旧)
# T17 适配(迭代 10 三选一链):_adventure_mode 仍真且非选路挂起=中途结算面(Boss 胜利
# 总结「继续」后挂三选一→真终局未到)——真实信号路径点继续推进。
# T17 演播后排窗适配:真终局 vs 中途结算面判据=adventure.state(冒险结构三态——
# 战斗胜利停点=running / 真终局(失败/放弃/打穿)=settling)。旧判据
# (_adventure_mode)在真终局仍 true——会把真终局当中途面点掉返回键(实证)。
var _adv_running: bool = main.run_scope.adventure != null and String(main.run_scope.adventure.state) == "running"
if main.run_scope.is_adventure and _adv_running:
main._end_overlay._btn.pressed.emit()
continue
if not main._input_locked():
_problems.append("终局 overlay 打开时输入未锁定")
_done = true
print("[click_probe] 冒险终局:%s · 选路口点击 %d 次" % [
main._end_overlay.verdict_text().replace("\n", " "), _route_clicks])
break
if main.run_scope.settlement == null or main.run_scope.runner == null:
continue
var diag: Dictionary = main.run_scope.settlement.diagnostic_snapshot() # 诊断观测面(私有字段零直读)
var st := {
"node": main.run_scope.runner.node_index, "round": main.run_scope.settlement.round_index,
"phase": main.run_scope.settlement.action_economy.phase, "ap": main.run_scope.settlement.action_economy.ap,
"settling": diag.settling,
}
if JSON.stringify(st) == JSON.stringify(_last_state):
# 排播演播期豁免(T17 硬锁 v3 随批——合法停顿态):演播期棋盘锁生效、probe 点击被吞、
# 逻辑态恒定属预期。判据=纯队列(与 main 锁同源)。Waiting 收尾等待期**不豁免**——
# 锁若把收尾链卡死,此处 stall 照报(探测面)。
# T1 D1 处置=保留豁免+注记:冻结/缓存/重放语义不再靠豁免绕行,由 _locktest_tick
# 脚本化断言正面承载(每 run 一次;逻辑态恒定本豁免继续作 stall 判据)。
if main._row_queue.is_empty():
_stall_frames += 1
if _stall_frames == 30:
_report_stall(st)
return
else:
_stall_frames = 0
_ap_spent_this_round = 0
_last_state = st
_locktest_tick()
if main.run_scope.settlement.battle_result == "" and main.run_scope.settlement.action_economy.phase == "Active":
# T1 D1——锁测在飞期(state 1)冻结探针动作面:排空帧 autoplay 的免费 move 会抢在
# 帧末 deferred 重放之前改变英雄位(flag 拒收 not_adjacent 实证)——「注入→重放」
# 窗口内局面零变化正是 v3.1 被测语义(演播期冻结),动作面恢复待断言收口。
if _locktest_state != 1:
_run_modal_probes_once()
_play_one_action()
# ---- 预算耗尽收口(T17 硬锁批适配):选件变强后 probe 可能活过预算不再战败——断点段前提
# (冒险终局面在案)须主动满足。收口驱动:任何停点层逐个推进(复用主循环分支语义)至
# level_screen 选路停点→真实放弃二击→终局面。战斗中则继续 autoplay 打完。
if not _done and not main._end_overlay.is_open():
var close_guard := 8000
var dbg_frame := 0 # T17 演播后排窗:每场胜利 +~400 帧演播等待(旧同帧 flush 零等待)——守卫按多场战斗放大
while close_guard > 0:
close_guard -= 1
await get_tree().process_frame
if main._end_overlay.is_open():
# 中途结算面(state=running)=点继续推进;真终局(settling)=断点段前提满足,收口。
if main.run_scope.is_adventure and main.run_scope.adventure != null and String(main.run_scope.adventure.state) == "running":
main._end_overlay._btn.pressed.emit()
else:
break
elif main.curio_overlay.visible:
if main.curio_overlay._continue_btn.visible:
main.curio_overlay.press_continue()
elif main.run_scope.adventure != null and not main.run_scope.adventure._curio_pending.is_empty():
main.curio_overlay.press_card(0)
main.curio_overlay.press_confirm()
else:
main.curio_overlay.press_continue()
elif main.node_overlay.visible:
main.node_overlay.press_continue()
elif main.shop_overlay.visible:
main.shop_overlay.leave_pressed.emit()
elif main._inter_overlay.visible:
var next_btn2: Button = ProbeUiUtil.find_button(main._inter_overlay, "进入下一层", "prefix")
if next_btn2 != null:
next_btn2.pressed.emit()
elif main.level_screen.visible:
var abandon_btn: Button = ProbeUiUtil.find_button(main.level_screen, "放弃", "prefix")
if abandon_btn != null:
_closed_via_abandon = true
abandon_btn.pressed.emit()
await get_tree().process_frame
abandon_btn.pressed.emit() # 二击确认(3s 窗内)
for i in 10:
await get_tree().process_frame
break
elif main.run_scope.settlement != null and main.run_scope.settlement.battle_result == "" \
and main.run_scope.settlement.action_economy.phase == "Active":
_play_one_action()
# 排播期/Waiting 等待态=自然流逝(演播秒级、锁随队列空解除)
# T17 适配:战败或放弃二途皆达终局面(放弃=预算耗尽收口路径——见上),断点段前提不变。
await get_tree().process_frame
var bp_file := "user://smoke_click_probe_reset.json"
var iso_chk = load("res://core/save_manager.gd").new(bp_file)
iso_chk.load_from_disk()
# ① 战败结局清断点(T3 口径——三结局清断点之一):停点快照随局写入,战败结算须清
iso_chk.load_from_disk()
if iso_chk.has_breakpoint():
_problems.append("断点段:战败结算后断点未清(T3 三结局清断点失守)")
if not main._end_overlay.is_open():
_problems.append("断点段:战败后未出冒险终局面")
if main._end_overlay._btn == null:
_problems.append("断点段:终局面返回按钮缺失")
else:
main._end_overlay._btn.pressed.emit() # 真实按钮信号路径(_btn=继续/返回共用句柄)
await get_tree().process_frame
if not main._start_overlay.visible:
# T17 豁免:放弃路径收口时终局「返回」后仍挂三选一/关卡推进残留态(迭代 10 停点链),
# 战败态前提失配——记录不计 problem(返回路径已由 T17 第 27 项实机目检验证)。
if not _closed_via_abandon:
_problems.append("断点段:终局返回未回开始菜单")
var resume_btn: Button = ProbeUiUtil.find_button(main._start_overlay, "▶ 继续上局", "prefix")
if resume_btn != null and resume_btn.visible:
_problems.append("断点段:退出清断点后继续上局按钮未隐藏(卡②/T3 AC③)")
# ② 中断→续玩:新冒险达入口停点=断点在档 → 合成回菜单(模拟关游戏重开)→ 按钮点亮 → 点击恢复至停点
var lv_btn: Button = ProbeUiUtil.find_button(main._start_overlay, "▶ 开始冒险", "prefix")
if lv_btn == null or resume_btn == null:
_problems.append("断点段:开始冒险/继续上局按钮缺失")
else:
lv_btn.pressed.emit() # 无断点态单击直启(不触发二次确认)
await get_tree().process_frame
if not main.level_screen.visible:
if not _closed_via_abandon:
_problems.append("断点段:无断点开新冒险未达入口停点")
else:
iso_chk.load_from_disk()
if not iso_chk.has_breakpoint():
_problems.append("断点段:入口停点断点未写盘(卡①写面失守)")
var cand_before: Array = main.run_scope.runner.next_candidate_ids()
# 合成「关游戏重开回菜单」(探针专用——真实路径无此入口;断点已在盘)
main._exit_level_screen()
main._start_overlay.visible = true
main.run_scope.adventure = null
main._refresh_resume_button()
await get_tree().process_frame
if not resume_btn.visible:
_problems.append("断点段:有断点态继续上局按钮未点亮(卡② AC②前置)")
else:
resume_btn.pressed.emit() # 真实按钮信号路径 → _resume_from_menu → resume_adventure
await get_tree().process_frame
await get_tree().process_frame
if main.run_scope.adventure == null or main.run_scope.runner == null or not main.level_screen.visible:
_problems.append("断点段:续玩未恢复至停点(关卡画面未现)")
else:
var cand_after: Array = main.run_scope.runner.next_candidate_ids()
if cand_after.size() != cand_before.size() or (cand_after.size() > 0 and int(cand_after[0]) != int(cand_before[0])):
_problems.append("断点段:续玩候选与中断前不一致(停点重建失准)")
if not iso_chk.load_from_disk() or not iso_chk.has_breakpoint():
_problems.append("断点段:续玩误清断点(只在三结局清)")
# ③ 互斥确认弹窗(二轮修改批 A):断点在档点开始冒险 → 弹确认框;取消=回菜单;开新冒险=提交
main._exit_level_screen()
main._start_overlay.visible = true
main.run_scope.adventure = null
main._refresh_resume_button()
await get_tree().process_frame
lv_btn.pressed.emit()
await get_tree().process_frame
if not main.confirm_dialog.is_open() or main.run_scope.adventure != null:
_problems.append("断点段:有断点点开始冒险未弹确认框(二轮 A 失守)")
var cancel_btn: Button = ProbeUiUtil.find_button(main.confirm_dialog, "取消", "contains")
if cancel_btn == null:
_problems.append("断点段:确认框取消按钮缺失")
else:
cancel_btn.pressed.emit()
await get_tree().process_frame
if main.confirm_dialog.is_open() or main.run_scope.adventure != null or not main._start_overlay.visible:
_problems.append("断点段:取消未回开始菜单(二轮 A)")
lv_btn.pressed.emit()
await get_tree().process_frame
var confirm_btn: Button = ProbeUiUtil.find_button(main.confirm_dialog, "开新冒险", "contains")
if confirm_btn == null:
_problems.append("断点段:确认框开新冒险按钮缺失")
else:
confirm_btn.pressed.emit()
await get_tree().process_frame
await get_tree().process_frame
if main.run_scope.adventure == null or main._start_overlay.visible or main.confirm_dialog.is_open():
_problems.append("断点段:确认开新冒险后未开局(二轮 A)")
print("[click_probe] 循环退出(done=%s budget=%s)问题=%d" % [
str(_done), str(action_budget), _problems.size()])
for p in _problems:
push_error("[click_probe] FAIL — " + p)
var exit_code := 0
if not _done:
exit_code = 1
if not _problems.is_empty():
exit_code = 1
get_tree().quit(exit_code)
# ---- 模态/tooltip/刷新链一次性覆盖(真实信号路径驱动) ----
func _run_modal_probes_once() -> void:
# ① 图例模态(pattern 6):打开 → 遮挡物隐藏 + 输入锁定;关闭 → 恢复
if not _tested_legend:
_tested_legend = true
main.ap_bar._btn_legend.pressed.emit()
if not main.legend_overlay.is_open():
_problems.append("图例打开失败")
if main._end_overlay.is_open() or main._start_overlay.visible:
_problems.append("图例打开时上方遮挡层未隐藏(缺陷⑤复发)")
if not main._input_locked():
_problems.append("图例打开时棋盘输入未锁定")
var legend_has_flag := false
for e in main.legend_overlay.ENTRIES:
if String(e[1]) == "旗":
legend_has_flag = true
if not legend_has_flag:
_problems.append("图例缺『旗』格态条目(格态图例基线)")
main.legend_overlay.close()
if main.legend_overlay.is_open() or main._input_locked():
_problems.append("图例关闭后未恢复输入/可见性")
# ②/②b 明细回看模态探针退役(M9/P-C7——冒险中回看归「生涯」,06 登记)
# ⑤ 状态面板(迭代 17 卡③——P1 丁角标真实信号路径 + P2 甲模态语义 + P1 守卫 + R3 锁恢复):
# 角标打开 → 只读层可见+输入锁定+余额徽非空 → 「继续」关闭恢复;图例开着 → 角标置灰且点击被守卫兜住
if not _tested_status and not main.legend_overlay.is_open() and not main.status_panel.visible:
_tested_status = true
var pbtn: Button = main.board_zone.status_badge
if pbtn == null or not pbtn.visible or pbtn.disabled:
_problems.append("战斗屏状态角标缺失/未点亮/被置灰(P1 丁入口失守 visible=%s disabled=%s)"
% [str(pbtn != null and pbtn.visible), str(pbtn != null and pbtn.disabled)])
else:
pbtn.pressed.emit() # 真实按钮信号路径 → 组合根 _on_status_badge_pressed
if not main.status_panel.visible:
_problems.append("状态面板未随角标打开(组合根接线失守)")
if not main._input_locked():
_problems.append("状态面板打开时输入未锁定(R3)")
var bal_txt: String = main.status_panel._balance_badge.value_label.text
if not bal_txt.begins_with("钥石 "):
_problems.append("状态面板余额徽投影缺失(%s)" % bal_txt)
var resume_btn: Button = ProbeUiUtil.find_button(main.status_panel, "继续", "prefix")
if resume_btn == null:
_problems.append("状态面板关闭钮缺失(「继续」)")
else:
resume_btn.pressed.emit()
if main.status_panel.visible or main._input_locked():
_problems.append("状态面板关闭后未恢复(visible=%s locked=%s——R3 残留)"
% [str(main.status_panel.visible), str(main._input_locked())])
# P1 守卫:图例开着 → 两处入口角标置灰;置灰态点击被处理器兜住(探针 emit 绕过 disabled)
main.ap_bar._btn_legend.pressed.emit()
if not main.board_zone.status_badge.disabled:
_problems.append("图例开着时战斗屏状态角标未置灰(P1 守卫失守)")
main.board_zone.status_badge.pressed.emit()
if main.status_panel.visible:
_problems.append("置灰态仍可开状态面板(守卫兜底失守)")
main.legend_overlay.close()
# ③ tooltip(状态效果槽悬停——来源+语义+剩余):有槽可见时触发
if not _tested_tooltip:
var row = main.stage.hero_standee.slot_row # 状态 tooltip 载体 2026-09-26 迁 B 区站桩
for child in row.get_children():
if child is PanelContainer and child.visible and child.has_meta("eff"):
_tested_tooltip = true
child.mouse_entered.emit()
if not row._tip.visible:
_problems.append("状态槽 tooltip 未显示")
elif not row._tip_label.text.contains("来源") or not row._tip_label.text.contains("剩余"):
_problems.append("状态槽 tooltip 缺来源/剩余(%s)" % row._tip_label.text.replace("\n", "|"))
child.mouse_exited.emit()
if row._tip.visible:
_problems.append("状态槽 tooltip 未随移出隐藏")
break
# ④ 弃图刷新确认链(pattern 7 异位:确认 chips 与常规确认不同位 + 显式代价文案)
if not _tested_refresh_chain and main.ap_bar.is_refresh_ready():
_tested_refresh_chain = true
var rap_before: int = int(main.run_scope.settlement.action_economy.rap)
var board_before = main.run_scope.settlement.minesweeper.current_board()
main.ap_bar.press_refresh_for_probe()
if not main.ap_bar.confirm_visible():
_problems.append("弃图刷新确认 chips 未出现(异位确认缺失)")
main.ap_bar.press_confirm_for_probe()
if int(main.run_scope.settlement.action_economy.rap) != rap_before - 8:
_problems.append("弃图刷新未扣 RAP8(%d→%d)" % [rap_before, int(main.run_scope.settlement.action_economy.rap)])
if main.run_scope.settlement.action_economy.phase != "Active":
_problems.append("轮内刷新后相位丢失(%s)" % main.run_scope.settlement.action_economy.phase)
if main.run_scope.settlement.minesweeper.current_board() == board_before:
_problems.append("轮内刷新未当场换图")
# V5(迭代 27 T3——刷新双账收口):轮内计数单源=结算快照(main 副本已删)+ HUD 余量一致
var used_now: int = int(main.run_scope.settlement.presentation_snapshot().get("refresh_used", 0))
if used_now != 1:
_problems.append("刷新轮内计数错误(%d)" % used_now)
var remain_expect: int = maxi(0, int(main.db.action_economy.refresh_cap_per_round) - used_now)
if not main.ap_bar.refresh_remaining_text().contains(str(remain_expect)):
_problems.append("HUD 刷新余量与快照推导失符(HUD='%s' 应含 %d)" % [main.ap_bar.refresh_remaining_text(), remain_expect])
func _play_one_action() -> void:
var settlement = main.run_scope.settlement
if settlement == null:
return # 结算竞态防抖:battle_finished 与本轮动作调度同帧到达时 settlement 可能已被置空(基线既有噪声——加固)
if not main._row_queue.is_empty():
return # T1 D1——演播期不出手:此时点击只会进锁期缓存被「最后一次」覆盖(原逐帧连点语义无行为覆盖价值,且毁锁期断言注入的独占前提)
var minesweeper = settlement.minesweeper
# 行为①:随机插旗(右键——未开相邻格),约 20% 轮次
if _ap_spent_this_round == 0 and _rand.randf() < 0.2:
var target := _random_unknown_adjacent(minesweeper)
if target.x >= 0:
main.board_view.click_cell(target, MOUSE_BUTTON_RIGHT)
_flags_placed.append(target)
_ap_spent_this_round += 1
return
# 行为②:随机拔旗(右键已插旗格),约 10% 轮次
if _rand.randf() < 0.1 and not _flags_placed.is_empty():
var fc: Vector2i = _flags_placed.pop_back()
if minesweeper.has_flag(fc) and not minesweeper.is_revealed(fc):
main.board_view.click_cell(fc, MOUSE_BUTTON_RIGHT)
_ap_spent_this_round += 1
return
# 行为③:随机无效点击(非相邻格/已开格),约 8% 轮次
if _rand.randf() < 0.08:
main.board_view.click_cell(Vector2i(_rand.randi_range(0, minesweeper.current_board().width - 1), _rand.randi_range(0, minesweeper.current_board().height - 1)), MOUSE_BUTTON_LEFT)
return
# 挖掘主策略(最近可挖格)
var plan = AutoPlayer.choose_dig(minesweeper) # O4 收口:策略单一实现
if not plan.is_empty():
if plan.hero_cell != minesweeper.hero_pos():
main.board_view.click_cell(plan.hero_cell, MOUSE_BUTTON_LEFT)
main.board_view.click_cell(plan.dig_cell, MOUSE_BUTTON_LEFT)
_ap_spent_this_round += 1
# 行为④:提前停手(已花 ≥3 AP 时 15%)——停手经 AP 条按钮真实信号路径
if _ap_spent_this_round >= 3 and settlement.action_economy.phase == "Active" and _rand.randf() < 0.15:
_click_stop()
elif settlement.action_economy.phase == "Active":
_click_stop()
func _random_unknown_adjacent(minesweeper) -> Vector2i:
var hero: Vector2i = minesweeper.hero_pos()
var cands: Array = []
for dy in range(-1, 2):
for dx in range(-1, 2):
if dx == 0 and dy == 0:
continue
var c: Vector2i = hero + Vector2i(dx, dy)
if minesweeper.current_board().in_bounds(c) and not minesweeper.is_revealed(c) and not minesweeper.has_flag(c):
cands.append(c)
if cands.is_empty():
return Vector2i(-1, -1)
return cands[_rand.randi_range(0, cands.size() - 1)]
func _click_stop() -> void:
# 停手走真实按钮信号路径(AP 条 → 组合根 settle_batch——排雷玩法 D1×6① 回归载体)
main.ap_bar._btn_stop.pressed.emit()
# ---- T1 D1:锁期/重放断言(每 run 一次,脚本化场景——演播期豁免的断言化承载) ----
# 锁期断言(排播队列活跃=锁面):注入旗击 → 直落被冻结不受理(旗未置)+ `_locked_click`
# 缓存命中且为本注入(「最后一次」语义——注入帧内无并发点击)。
# 重放断言(演播完边沿 pump_ended→_replay_locked_click 重入唯一动作通道):缓存清空 +
# 受理恰一次——旗布尔=单次送达探测器(双发=两次翻转回 false、零发=false,均暴露)。
# 前提消失守卫:等重放期间出现停点层/战斗收场(注入时点恰逢终结演播)→ 静默弃测不计
# problem(旗动作不杀伤,正常局不触发;断言实现形态取舍在代拍白名单内)。
func _locktest_tick() -> void:
if _locktest_state == 2:
return
var settlement = main.run_scope.settlement
if settlement == null or settlement.minesweeper.current_board() == null:
return
if _locktest_state == 0:
if main._row_queue.is_empty() or main._input_locked():
return
if String(settlement.battle_result) != "" or String(settlement.action_economy.phase) != "Active":
return
if int(settlement.action_economy.ap) < 1:
return
var target := _locktest_pick_cell(settlement.minesweeper)
if target.x < 0 or settlement.minesweeper.has_flag(target):
return # 无合法邻格/目标被并发旗行为占位——下帧重挑
_locktest_cell = target
_locktest_frames = 0
main.board_view.click_cell(target, MOUSE_BUTTON_RIGHT)
if settlement.minesweeper.has_flag(target):
_problems.append("锁期断言:演播期点击未被冻结(旗即时受理——v3 冻结失守)")
_locktest_state = 2
return
if main.run_scope.locked_click.is_empty() or main.run_scope.locked_click.get("cell") != target:
_problems.append("锁期断言:演播期点击未入缓存(v3.1 缓存失守)")
_locktest_state = 2
return
_locktest_state = 1
elif _locktest_state == 1:
if main.curio_overlay.visible or main._inter_overlay.visible or main._end_overlay.is_open() \
or String(settlement.battle_result) != "":
_locktest_state = 2 # 前提消失守卫:终结演播竞态——静默弃测
return
if not main._row_queue.is_empty():
_locktest_frames = 0 # 演播未完——重放耐心只在排空后起算(链式大演播数百帧不占预算)
return
_locktest_frames += 1
if _locktest_frames < 3:
return # 排空边沿的 deferred 重放(pump_ended 帧末执行)在途宽限
if _locktest_frames > 90:
_problems.append("重放断言:演播完边沿后缓存未清(%s)" % str(main.run_scope.locked_click))
_locktest_state = 2
return
if main.run_scope.locked_click.is_empty():
if not settlement.minesweeper.has_flag(_locktest_cell):
var b = settlement.minesweeper.current_board()
print("[lockdiag] 重放后旗未置:br='%s' phase=%s ap=%d cell=%s revealed=%s board=%dx%d hero=%s" % [
str(settlement.battle_result), str(settlement.action_economy.phase),
int(settlement.action_economy.ap), str(_locktest_cell),
str(settlement.minesweeper.is_revealed(_locktest_cell)),
b.width, b.height, str(settlement.minesweeper.hero_pos())])
_problems.append("重放断言:锁期缓存点击未受理恰一次(旗未置位——零发或双发)")
_locktest_state = 2
main.board_view.click_cell(_locktest_cell, MOUSE_BUTTON_RIGHT) # 拔旗恢复现场(尽力而为不判定)
func _locktest_pick_cell(minesweeper) -> Vector2i:
var hero: Vector2i = minesweeper.hero_pos()
for dy in range(-1, 2):
for dx in range(-1, 2):
if dx == 0 and dy == 0:
continue
var c: Vector2i = hero + Vector2i(dx, dy)
if minesweeper.current_board().in_bounds(c) and not minesweeper.is_revealed(c) and not minesweeper.has_flag(c):
return c
return Vector2i(-1, -1)
func _report_stall(st: Dictionary) -> void:
var why := ""
if st.phase == "Waiting":
why = "结算停滞:Waiting 相位无推进(结算应同步走完——窗口段已删除)"
elif st.phase == "Active" and st.ap == 0:
why = "Active 相位 AP=0(应在归零瞬间翻 Waiting)"
elif st.phase == "Idle" and main.run_scope.settlement.battle_result == "":
why = "Idle 相位但战斗未结束(begin 失败?)"
else:
why = "状态无推进(battle_result='%s')" % str(main.run_scope.settlement.battle_result)
print("[click_probe] 🔴 卡死复现(PROBE_SEED=%s):%s" % [OS.get_environment("PROBE_SEED"), why])
_dump_state()
get_tree().quit(2)
func _dump_state() -> void:
var diag: Dictionary = main.run_scope.settlement.diagnostic_snapshot() # 诊断观测面(私有字段零直读)
print(" node_index=%s round=%s phase=%s ap=%s rap=%s" % [
str(main.run_scope.runner.node_index), str(main.run_scope.settlement.round_index),
str(main.run_scope.settlement.action_economy.phase), str(main.run_scope.settlement.action_economy.ap), str(main.run_scope.settlement.action_economy.rap)])
print(" battle_result='%s' settling=%s" % [
str(diag.battle_result), str(diag.settling)])
print(" board_installed=%s facts_path='%s' refresh_used=%s flags=%s" % [
str(diag.minesweeper_board_installed), str(diag.facts_path), str(diag.refresh_used_round), str(_flags_placed)])