# 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 # ---- 营地购买合成点击路径(元进度实现批——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) # 调试入口断言改向(迭代 18 卡⑦ P2 甲——开始层角标退役迁宿主两屏;探针环境 DS_DEBUG 未设 # =浮窗不默认开〔P3 门控〕、DS_DEBUG 门控由开始层角标移两屏角标) if ProbeUiUtil.find_button(main._start_overlay, "调试", "prefix") != null: _problems.append("开始层调试角标未退役(迭代 18 卡⑦ P2 甲迁宿主失守)") if main.debug_overlay.visible: _problems.append("DS_DEBUG 未设时浮窗默认开(P3 启动门控失守)") 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)])