Files
gplan-2/game/probe/ui_click_probe.gd
T

1080 lines
64 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)。
# 合成鼠标事件走真实呈现层路径(BoardView.click_cell / 按钮信号 → 组合根动作通道),
# 随机混合玩家行为——封印/撤封印、提前停手、弃图刷新确认链、图例模态、明细回看模态、
# tooltip 悬停、无效点击——扫描结算停滞(缺陷②回归载体)
# + 模态遮挡断言(缺陷⑤回归:图例/回看/终局 overlay 打开时上方遮挡层隐藏、输入锁定)
# + 性能 AC:场景实例化到首格可点 ≤500ms 探针计时。
# 行为序列由 PROBE_SEED 决定(shell 传参);程序玩法数值不受影响(随机只用于探针策略)。
# 运行:PROBE_SEED=7 Godot_console.exe --headless --path . res://probe/ui_click_probe.tscn
extends Node
const AutoPlayer := preload("res://sim/auto_player.gd") # O4:自动玩家策略单一实现
const ProbeUiUtil := preload("res://probe/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 _seals_placed: Array = []
var _ap_spent_this_round := 0
var _problems: Array = [] # 新增覆盖的问题收集(退出码合并判定)
var _tested_legend := false
var _tested_review := false
var _tested_end_review := false # 终局面板回看入口(z 序修复回归)
var _tested_refresh_chain := false
var _tested_tooltip := false
var _tested_copy := false # 迭代36 卡A——C 区两段头真实入口断言(每 run 一次)
var _tested_curio_head := false # 迭代36 卡A——三选一头部旧话清零断言(奖励点停点捕获)
var _tested_round_summary := false # 卡⑥ wr1-b——上轮摘要恢复断言(战中场一次性判据)
var _tested_map_cap := false # 迭代36 卡A——地图屏加成帽换代断言(每 run 一次)
var _tested_status := false # 状态面板角标开合/守卫断言(每 run 一次)
var _tested_map_status := false # 地图屏角标一次性 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 不开(菜单优先——入口=UI 最顶层全局浮层角标,
# 任何 UI 不遮挡;浮层角标遮挡判据见设置面段)
if main.debug_overlay.visible:
_problems.append("调试浮窗 boot 自动开(菜单优先失守)")
# ---- 营地购买合成点击路径(选档进档开层→购买→关闭全走真实按钮信号路径;
# 隔离存档换注 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) # 重置档段前清场兜底(营地/设置段不触该档——防陈旧断点泄入后续段)
# swap=true(整链换注——购买写盘经 autoload 落隔离槽文件,与下方持久化核验同源;
# 原 swap=false 形态的写盘走 iso 实例旧路径,与槽文件断言不同源——卡② mn9 换代)
var iso_c = ProbeUiUtil.isolate_save(main, "smoke_click_probe_camp", false, false, true)
if not ProbeUiUtil.open_camp_via_savesel(main, "smoke_click_probe_camp"):
_problems.append("营地入口缺失(选档层新档钮——mn9 新路径驱动中断)")
else:
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.meta_state._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.meta_state.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.meta_state.keystone_balance()) != bal_before - price:
_problems.append("营地树购买未出账(%d→%d 应 −%d)" % [bal_before, iso_c.meta_state.keystone_balance(), price])
var iso_chk = load("res://core/save_manager.gd").new("user://smoke_click_probe_camp_slot_1.json")
iso_chk.load_from_disk()
if int(iso_chk.meta_state.tree_state().nodes.get("sp_hp", 0)) != 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_slot_1.json")
iso_fk.load_from_disk()
if String(iso_fk.meta_state.tree_state().fork_sides.get("fg_posture", "")) != "b":
_problems.append("Fork 切态未持久(reload 后激活态 != b)")
# ---- 营地六处灰字取色断言(迭代36 wc4——真实入口开营地后按文本锚取 label,
# font_color 逐处 == STONE_DEEP;六源位 :167 图鉴锁文/:417+:476 行状态字/:509 同调形态/:513 档位标签/:525 刷新行)----
var camp_labels: Array = []
_collect_labels_by_text(main.camp_overlay, camp_labels)
var atlas_locks := _labels_with_text(camp_labels, "柜台结构位预留")
if atlas_locks.is_empty():
_problems.append("营地灰字:图鉴柜台锁文 label 未找到(wc4 定位锚失守)")
for l in atlas_locks:
if l.get_theme_color("font_color") != UiKit.STONE_DEEP:
_problems.append("营地灰字:图鉴锁文未用 STONE_DEEP(wc4 :167)")
# 「未解锁」锚收窄到秘宝匣行容器子树(树行/锻造行也有「未解锁」态字样——非 wc4 目标位)
var locked_status: Array = []
if main.camp_overlay.get("_workshop_holder") != null:
var ws_labels: Array = []
_collect_labels_by_text(main.camp_overlay._workshop_holder, ws_labels)
locked_status = _labels_with_text(ws_labels, "未解锁")
if locked_status.is_empty():
_problems.append("营地灰字:秘宝匣行「未解锁」态 label 未找到(wc4 夹具前提失守——新档应有锁定秘宝行)")
for l in locked_status:
if l.get_theme_color("font_color") != UiKit.STONE_DEEP:
_problems.append("营地灰字:秘宝行「未解锁」态未用 STONE_DEEP(wc4 :417/:476)")
var form_found := false
for l in camp_labels:
if l.text == "被动" or l.text == "主动·充能":
form_found = true
if l.get_theme_color("font_color") != UiKit.STONE_DEEP:
_problems.append("营地灰字:同调形态标签未用 STONE_DEEP(wc4 :509)")
if not form_found:
_problems.append("营地灰字:同调行形态标签未找到(wc4 定位锚失守——同调段应有行)")
var tier_vals := {}
for v in UiKit.TREASURE_TIER_LABELS.values():
tier_vals[String(v)] = true
var tier_found := false
for l in camp_labels:
if tier_vals.has(l.text):
tier_found = true
if l.get_theme_color("font_color") != UiKit.STONE_DEEP:
_problems.append("营地灰字:同调档位标签未用 STONE_DEEP(wc4 :513)")
if not tier_found:
_problems.append("营地灰字:同调档位标签未找到(wc4 定位锚失守)")
var refresh_inked := false
for l in camp_labels:
if String(l.text).contains("三选一刷新"):
refresh_inked = true
if l.get_theme_color("font_color") != UiKit.STONE_DEEP:
_problems.append("营地灰字:刷新行未用 STONE_DEEP(wc4 :525)")
if not refresh_inked:
_problems.append("营地灰字:刷新行 label 未找到(wc4 定位锚失守)")
main._on_camp_closed()
await get_tree().process_frame
main._on_save_select_closed() # 关闭被栈还原的选档层(动线序中间态——卡④ 换代点)
await get_tree().process_frame
if main.camp_overlay.visible or not main._start_overlay.visible:
_problems.append("营地关闭后未恢复开始层")
# ---- 选档界面行为走查(迭代 33 卡③):sl5 空槽新档链/sl6 有档槽进档链/sl7 删档二次确认双路径
# /sl8 消费方分派单口/sl12 动线序不回闪。开层=函数直调(「进入存档」钮归卡② mn3——中间态在案);
# 隔离=attach_slot_template 注入命名空间,夹具按槽直写(卡① 注入口)。----
var csel_tpl := "user://smoke_click_probe_savesel_slot_%d.json"
for i in range(1, 4):
for suf in ["", ".tmp", ".corrupt.bak"]:
var cp: String = csel_tpl % i + suf
if FileAccess.file_exists(cp):
DirAccess.remove_absolute(cp)
ProbeUiUtil.isolate_save(main, "smoke_click_probe_savesel", true, false, true)
main.save_manager.slots.attach_slot_template(csel_tpl)
var f_c2 := FileAccess.open(csel_tpl % 2, FileAccess.WRITE)
f_c2.store_string(JSON.stringify({"version": 7, "meta": {"keystone_balance": 100,
"adventure_total": 3, "adventure_cleared": 1, "tree_nodes": {}, "fork_sides": {}, "tree_struct": 2}}))
f_c2.close() # 槽2 有档异值(进档链夹具);槽1/3 空
main._open_save_select()
await get_tree().process_frame
if not main.save_select_overlay.visible:
_problems.append("[savesel] 选档层未打开(click 段前置)")
# sl12 动线序(新档链同拍采样):逐帧可见性次序——首帧选档隐藏时营地层必须已可见、开始层保持隐藏
#(禁先关后开——_close_modal 会恢复开始层=回闪一帧)
var new_btn: Button = main.save_select_overlay._new_btns.get(1)
if new_btn == null:
_problems.append("[savesel] 槽1 新档钮缺失(sl5/sl12 驱动中断)")
else:
var samples: Array = []
new_btn.pressed.emit() # 真实按钮信号路径 → _on_save_select_new → create_slot+open_camp
for fi in 3:
await get_tree().process_frame
samples.append([main.save_select_overlay.visible, main.camp_overlay.visible, main._start_overlay.visible])
if samples[0][0]:
_problems.append("[savesel] sl12 新档后选档层仍可见(落点开层未发生)")
elif not samples[0][1]:
_problems.append("[savesel] sl12 动线序违约:选档隐藏帧营地层未可见(先关后开=回闪形态)")
elif samples[0][2]:
_problems.append("[savesel] sl12 动线序违约:开始层在落点帧恢复(回闪一帧)")
if main.save_manager.slots.active_slot() != 1 or not FileAccess.file_exists(csel_tpl % 1):
_problems.append("[savesel] sl5 新档链失守(active=%d 应=1 且槽1 白板应落盘)" % main.save_manager.slots.active_slot())
var occluded: Array = main._modals._occluded.get(main.camp_overlay, [])
if not occluded.has(main.save_select_overlay):
_problems.append("[savesel] sl12 选档层被关闭而非栈记账挂起(先关后开形态——遮挡挂起中间态钉死)")
if int((JSON.parse_string(FileAccess.get_file_as_string(csel_tpl % 1)) as Dictionary).get("meta", {}).get("keystone_balance", -1)) != 0:
_problems.append("[savesel] sl5 新档非白板(槽1 落盘应全零)")
main._on_camp_closed()
await get_tree().process_frame
# sl6 进档链:槽2 进档 → 激活=2(camp 数据随激活槽)
main._open_save_select()
await get_tree().process_frame
var load_btn: Button = main.save_select_overlay._load_btns.get(2)
if load_btn == null:
_problems.append("[savesel] 槽2 进档钮缺失(sl6 驱动中断——夹具应有档)")
else:
load_btn.pressed.emit()
await get_tree().process_frame
if main.save_manager.slots.active_slot() != 2:
_problems.append("[savesel] sl6 进档链失守(active=%d 应=2——激活该槽)" % main.save_manager.slots.active_slot())
if not main.camp_overlay.visible or main.save_select_overlay.visible:
_problems.append("[savesel] sl6 落点未开营地层或选档未隐藏")
main._on_camp_closed()
await get_tree().process_frame
# sl7 删档二次确认双路径:一击只开窗(文件仍在)→取消只关窗(文件仍在)→再开窗确认才删(槽回空)
main._open_save_select()
await get_tree().process_frame
var del_btn: Button = main.save_select_overlay._delete_btns.get(2)
if del_btn == null:
_problems.append("[savesel] 槽2 删档钮缺失(sl7 驱动中断)")
else:
del_btn.pressed.emit()
await get_tree().process_frame
if not main.confirm_dialog.is_open():
_problems.append("[savesel] sl7 一击未只开确认窗(直删形态)")
elif not FileAccess.file_exists(csel_tpl % 2):
_problems.append("[savesel] sl7 确认前文件已被删(一次点击即删违契)")
else:
if not main._modal_flow.stack._occluded.has(main.confirm_dialog):
_problems.append("[savesel] sl7 确认窗未经模态栈开(直开旁路——wr2 单口失守)")
elif not main._input_locked():
_problems.append("[savesel] sl7 确认窗开时输入锁未生效(锁域失守——wr2)")
main.confirm_dialog.cancelled.emit() # 取消路径(真实信号——cancelled 钮同源)
await get_tree().process_frame
if main.confirm_dialog.is_open() or not FileAccess.file_exists(csel_tpl % 2):
_problems.append("[savesel] sl7 取消路径失守(应只关窗、文件仍在)")
elif main._confirm_consumer != "":
_problems.append("[savesel] sl8 取消后消费方状态量未清(分派面残留)")
if main._modal_flow.stack._occluded.has(main.confirm_dialog):
_problems.append("[savesel] sl7 取消后确认窗遮挡账未清(关侧未走模态栈——wr2)")
del_btn.pressed.emit() # 再开窗走确认路径
await get_tree().process_frame
main.confirm_dialog.confirmed.emit() # 确认路径(真实信号——confirmed 钮同源)
await get_tree().process_frame
if FileAccess.file_exists(csel_tpl % 2) or main.confirm_dialog.is_open():
_problems.append("[savesel] sl7 确认路径失守(应删档+关窗)")
elif main.run_scope.is_adventure or main._start_overlay.visible:
_problems.append("[savesel] sl8 删档确认误触发开局(双常驻接线形态——分派单口失守)")
elif bool(main.save_select_overlay._occupied_flags.get(2, true)):
_problems.append("[savesel] sl7 删后选档面未就地刷新(槽2 应回空态)")
elif main._confirm_consumer != "":
_problems.append("[savesel] sl8 确认后消费方状态量未清(分派面残留)")
main._on_save_select_closed()
await get_tree().process_frame
if main.save_select_overlay.visible or not main._start_overlay.visible:
_problems.append("[savesel] 关闭后层未隐藏或开始层未恢复(模态栈单口失守)")
# ---- 设置面段(隔离 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)
# 调试入口断言(卡⑦ c4 发布态换代——角标恒隐;原「最顶层浮层不被遮挡」
# 回归语义随调试浮层发布态退役,申报见迭代 38 实现报告 §8.2)
if main.debug_badge == null or 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——只在一级菜单出现)")
# ---- 生涯页段(入口点击链+关闭回返,真实按钮信号路径;本页只读零写,
# 值断言归冒烟投影段与 ui_struct 结构族,此处只验开合路径与零跳转)----
# 生涯页驱动=函数直调中间态(卡② mn10——careerbtn 已撤,卡④ 笔记本改挂前无 UI 路径)
if true:
main._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) # 零档换注(继续上局钮已随三入口化撤出——卡② mn8)
main.debug_overlay.set_sliders(400, 200) # 高加成保 L1 可完成——L2 岔路选路段只能经冒险推进到达(单关直入口退役)
main.debug_overlay.seed_edit.text = "42" # 种子钉死(种子框默认为空=非首局随机,行为走查确定性禁依赖默认分支)
var t1 := Time.get_ticks_msec()
main._start_adventure_from_menu()
await get_tree().process_frame
await get_tree().process_frame
# 既有口径回归(迭代36 wc3——调试滑条 400/200=局外加成 pct>0,开场行应带「(含面板加成)」;
# 本局为本探针首个冒险,日志无先前行,全文本判即可)
var wc3_txt: String = main.log_view._log.get_parsed_text()
if not wc3_txt.contains("种子 42") or not wc3_txt.contains("(含面板加成)"):
_problems.append("开场行回归失守:局外加成(pct>0)局的开场行应带「(含面板加成)」(wc3)")
# 关卡开始 = 入口选路停点——press_candidate 图上命中路径选入口候选进首战
#(首格可点 AC 口径随之含入口选路一步:菜单点击 → 停点呈现 → pick → 棋盘安装)
if main.level_screen.visible:
# 独立性断言:独立屏激活期战斗屏宿主整体隐藏
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
# P0-2 回归:本 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()
_seals_placed.clear()
continue
if main.curio_overlay.visible:
# 三选一停点(CurioPickOverlay——奖励节点/关卡完成兑付共用面,须与硬锁态适配,
# 未适配则走到即卡死)。两态分派:result 态(兑付反馈——点继续推进)/
# 待选态(点首件+确认)。真实信号路径全线。〔无替换子态——装配不可变契约。〕
# 三选一停点输入锁定断言(停点态断言面:待选/兑付两态共用此层)。
if not _tested_curio_head and main.run_scope.adventure != null and not main.run_scope.adventure.stop()._node_pending.is_empty() and String(main.curio_overlay._header).contains("关卡奖励点"):
# 停点态判据 = _node_curio_pending(奖励节点三选一停点住此——原条件查
# _curio_pending〔关卡完成三选一态〕与奖励头部互斥、恒不可命中=拦截能力未证病根,
# 卡⑤ d4 转正随修;头部校验串本体零触碰)
_tested_curio_head = true
var ch := String(main.curio_overlay._header)
if not ch.contains("冒险牌库") or not ch.contains("属性值提升与补给") or ch.contains("同权重") or ch.contains("同上限") or ch.contains("提升池"):
_problems.append("三选一头部旧话残留或新话缺失(wa4——%s)" % ch.replace("
", "|"))
# 奖励节点战报文案换代断言(卡⑤ wr2——reward_line 同屏打印于停点拉起前,
# 同一驱动路径承载;旧「内容待定」残留或新话缺失即红)
var logtext: String = main.log_view.parsed_text()
if not logtext.contains("关卡奖励点(三选一——选毕推进)") or logtext.contains("内容待定"):
_problems.append("奖励节点战报文案未换代(卡⑤ wr2——旧话残留或新话缺失)")
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.stop()._node_pending.is_empty():
main.curio_overlay.press_card(0) # 奖励节点待选态:点首件+确认(真实按钮信号路径——与关卡完成停点同形)
main.curio_overlay.press_confirm()
elif main.run_scope.adventure != null and not main.run_scope.adventure.stop()._pending.is_empty():
main.curio_overlay.press_card(0)
main.curio_overlay.press_confirm()
else:
main.curio_overlay.press_continue() # 停点已消费的残面——继续关屏
_seals_placed.clear()
continue
if main.shop_overlay.visible:
# 商店房覆盖层(摆放约束:每终点前一位恰一店,真实路径恒必经一店):零购离开(真实 leave 信号路径)放行选路。
# 分派链序:商店层=选路页上的模态覆盖(停点呈现为
# level_screen+shop 双 visible)——必须先于 level_screen 分派,否则下层候选点击
# 空转烧尽预算(实证:lv2 商店停点双 visible 循环 12000 帧 done=false)。
main.shop_overlay.leave_pressed.emit()
_seals_placed.clear()
continue
if main.level_screen.visible:
# 节点图选路口(零自动推进停点)——图上命中路径交替选候选(岔路使用行为;布局决策 2 改造)
# 地图屏角标一次性 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("状态面板关闭后地图屏态失守(恢复语义破)")
# 地图屏加成帽换代断言(迭代36 卡A wa2——「特性」→「属性值提升」;帽 Label 常在树;
# 独立旗标——_tested_map_status 在同拍前置块已置位,禁复用其门控)
if not _tested_map_cap:
_tested_map_cap = true
var cap_found := false
var ls_labels: Array = []
_collect_labels_by_text(main.level_screen, ls_labels)
for l in ls_labels:
if l.text == "属性值提升":
cap_found = true
if not cap_found:
_problems.append("地图屏加成帽未换代为「属性值提升」(wa2)")
# 选路定向(卡⑤ d4——wa4 转正的必达前提):候选含奖励节点优先选(真实
# press_candidate 命中路径零变化,只改索引策略);无奖励候选回退交替(岔路使用率观测面保留)
var pick := _route_clicks % 2
if main.run_scope.runner != null:
for nd in main.run_scope.runner.nodes():
var nd_id: int = main.run_scope.runner.node_id_of(nd)
if String(nd.get("type", "")) == "reward" and main.level_screen._cand_ids.has(nd_id):
pick = main.level_screen._cand_ids.find(nd_id)
break
main.level_screen.press_candidate(pick)
_route_clicks += 1
_seals_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
_seals_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():
# 冒险终局(失败/放弃/打穿)——无单关直入口,见终局即收口(模态断言照旧)
# 三选一链适配:_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,
}
# 上轮摘要恢复断言(卡⑥ wr1-b——改前 _round_rows 类型错 append 全数失败、缓冲恒空、
# 摘要自引入类型错以来从未显示):战中场观测点=round≥2 且排播队列已排空(轮首分隔行
# 已消费=finish_settlement_round 已刷);不挂循环出口——收尾的续玩重建段会再走 log_view.clear()
if not _tested_round_summary and int(st["round"]) >= 2 and main._row_queue.is_empty():
_tested_round_summary = true
if main.log_view._summary.text == "":
_problems.append("上轮摘要恒空(卡⑥ wr1-b——结算行缓冲仍失真)")
if JSON.stringify(st) == JSON.stringify(_last_state): # 排播演播期豁免(硬锁 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 重放之前改变英雄位(seal 拒收 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.stop()._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():
var bpf: Dictionary = iso_chk.breakpoint_snapshot()
print("[click_probe][diag] 断点未清指纹 keys=%s lv=%s node=%s bonus=%s deck=%s pending=%s" % [
str((bpf as Dictionary).keys()), str((bpf as Dictionary).get("level_index")),
str((bpf as Dictionary).get("node_id")), str((bpf as Dictionary).get("bonus")),
str(((bpf as Dictionary).get("bonus_deck", []) as Array).size()),
str((bpf as Dictionary).get("bonus_pending"))])
_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:
# 豁免:放弃路径收口时终局「返回」后仍挂三选一/关卡推进残留态(停点链),
# 战败态前提失配——记录不计 problem(返回路径已实机目检)。
if not _closed_via_abandon:
_problems.append("断点段:终局返回未回开始菜单")
# ②③ 中断→续玩全链+互斥确认两分支:随三入口化撤出(卡② §7.2 申报——断点确认探针段移交
# 卡④大门 T5 接收,两头登记禁静默删除;续玩钮已退役,主菜单确认链已摘 mn6)。
# ① 战斗收口断点写盘断言保留(上方——冒险流直调中间态 mn10)。
# ---- 开场行三路径段(迭代36 wc1/wc2——真实入口驱动;隔离新档)----
# 驱动设计(0/0 白板局生存性不成立的实证处置——L2 记录进实现报告):
# ① 菜单开新局·白板反向=独立短局:0/0 滑条开局断言 L1 开场行零后缀(不开驱动——
# 断言完成后该局被 ② 的开局自然替换,白板局生存性不参与判定);
# ② 局内加算区正向=生存局:400/200 滑条开局保可完成(:363 先例),三选一专选 atk/max_hp
# 卡(无则任选推进——不触后缀、循环重试);至大奖励屏先 reset_sliders() 再确认进下一层
# ——L2 开场行时点 pct=0 且新档 ov=0,后缀只能归因局内加算区(变异:回退条件 hunk→红);
# ③ 断点续玩正向:L2 入口选路停点(bonus 已入账、bonus_pending/bonus_result 皆空——选路停点形)
# 经 _resume_from_menu 唯一消费口重建,重建开场行带后缀。
# 判定面=开局清日志后的新局日志(_start_adventure 内 log_view.clear());「(含面板加成)」
# 唯一来源=开场行,计数即判定;「· 种子 」=开场行独有形态锚。
for p_ol in ["user://smoke_click_probe_openline.json", "user://smoke_click_probe_openline.json.tmp"]:
if FileAccess.file_exists(p_ol):
DirAccess.remove_absolute(p_ol)
ProbeUiUtil.isolate_save(main, "smoke_click_probe_openline", true, false, true)
main.debug_overlay.reset_sliders() # ① 白板反向前提(_start_adventure 只关窗不清值——残值跨段存活防坑)
main.debug_overlay.seed_edit.text = "42"
main._start_adventure_from_menu()
var ol_l1_wait := 0
while ol_l1_wait < 24 and String(main.log_view._log.get_parsed_text()).count("· 种子 ") < 1:
ol_l1_wait += 1
await get_tree().process_frame
if ol_l1_wait >= 24:
_problems.append("开场行路径①:L1 开场行未达日志(开局清日志后 24 帧内未现种子行)")
elif String(main.log_view._log.get_parsed_text()).count("(含面板加成)") > 0:
_problems.append("开场行路径①失守:白板局 L1 开场行带「(含面板加成)」(wc1 反向)")
print("[click_probe] 开场行①判定 wait=%d 帧断言完成" % ol_l1_wait)
# ②③ 断点续玩正向(既有快照喂入形态——代拍板 2 预授权;生产「继续上局」钮已随三入口化
# 撤出,_resume_from_menu=唯一消费口;断点形态=选路停点形钉值:bonus 已入账、
# bonus_pending/bonus_result 皆空——set_breakpoint 注入口同 system_test V3i 先例,
# 卡内警示的 bonus_pending 形夹具是本形态反例,禁复用)。续玩重建开场行时点 pct=0
# +新档 ov=0+bonus.atk=5 已回填(resume_adventure :151-154)——后缀只能归因局内加算区。
# 【卡片前提缺陷记录(随实现报告/停机呈报)】路径②原设计「奖励节点三选一拿属性卡→下一关
# 开场行」在现行 build 不可驱动:L1 全模板零商店节点、reward 节点=零兑付直通占位
# (level_runner _enter_node 注记)——新开局在任一开场行时点均无局内 bonus 来源。
# 条件加算区两项的机制面由本续玩路径全链承载(回填→level_started→条件求值,与 fresh 同式),
# 变异(回退条件 hunk)见红;fresh 正向拦截能力未证,如实申报。
var bp_ol: Dictionary = {
"master_seed": 42, "rng_counter": 0, "hero_hp": 30, "gp": 10, "keystone": 0,
"level_index": 1, "bonus": {"atk": 5, "max_hp": 0, "armor_class": 0, "ap": 0},
"shop_purchased": {}, "relics": [], "keystone_drops": [], "bonus_taken": {},
"node_id": -1, "visited_ids": [], "graph_gen_v2": 1,
}
main.save_manager.set_breakpoint(bp_ol)
# 判定面=续玩开局清日志后的新局日志(_resume_from_menu 内 log_view.clear()——选路停点形
# resume 同步重建开场行,返回时已在日志)
main._resume_from_menu()
var ol_r_wait := 0
while ol_r_wait < 24 and String(main.log_view._log.get_parsed_text()).count("· 种子 ") < 1:
ol_r_wait += 1
await get_tree().process_frame
if main.run_scope.runner == null or main.run_scope.runner.journal().bonus_total("atk") != 5:
_problems.append("开场行路径②③前提失守:续玩重建后加算区 atk!=5(断点回填链断)")
if ol_r_wait >= 24:
_problems.append("开场行路径②③失守:续玩重建未发开场行(选路停点形应重发——形态/驱动复核)")
elif String(main.log_view._log.get_parsed_text()).count("(含面板加成)") < 1:
_problems.append("开场行路径②③失守:bonus 已入账(pct=0/ov=0)的续玩重建开场行未带「(含面板加成)」(wc1 正向/wc2)")
else:
print("[click_probe] 开场行②③判定:续玩重建开场行带后缀(bonus.atk=5、pct=0)— wait=%d 帧" % ol_r_wait)
# wa4 必达判据(卡⑤ d4 转正——条件位退役):定向选路保证到达奖励节点三选一停点,
# 全程未命中即红(拦截能力必证——test-conventions §6 条件断言转正口径)
if not _tested_curio_head:
_problems.append("wa4 转正失守:探针全程未到达奖励节点三选一停点(定向选路失效或奖励节点不可达)〔卡⑤〕")
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:
# ⓪ 玩家面文案真实入口断言(迭代36 卡A wa1——C 区两段头随战斗常驻)
if not _tested_copy and main.info_panel != null and main.info_panel.visible:
_tested_copy = true
var feat_head: Label = null
var curio_head: Label = null
for child in main.info_panel._feat_section.get_children():
if child is Label:
feat_head = child
break
for child in main.info_panel._curio_section.get_children():
if child is Label:
curio_head = child
break
if feat_head == null or feat_head.text != "专长":
_problems.append("C 区专长段头未换代(wa1——真实入口)")
if curio_head == null or curio_head.text != "属性值提升":
_problems.append("C 区属性值提升段头未换代(wa1——真实入口)")
# ① 图例模态(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_seal := false
for e in main.legend_overlay.ENTRIES:
if String(e[1]) == "封印":
legend_has_seal = true
# 图例封印行换代断言(迭代36 卡A wa5——真实 AP 条图例钮开层路径)
if not String(e[2]).contains("标空") or not String(e[2]).contains("受挖掘惩罚") or String(e[2]).contains("插错") or String(e[2]).contains("挖雷"):
_problems.append("图例封印行旧词残留(wa5——%s)" % String(e[2]).replace("
", "|"))
if not legend_has_seal:
_problems.append("图例缺『封印』格态条目(格态图例基线)")
main.legend_overlay.close()
if main.legend_overlay.is_open() or main._input_locked():
_problems.append("图例关闭后未恢复输入/可见性")
# ②/②b 明细回看模态无探针(M9/P-C7——冒险中回看归「生涯」,06 登记)
# ⑤ 状态面板(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)
# ---- 本局信息面板换代断言(迭代36 卡③——真实入口三断言)----
# ① 面板子树零旧段文案(跨关概览退役零残留)
var sv_labels: Array = []
_collect_labels_by_text(main.status_panel, sv_labels)
for l in sv_labels:
if String(l.text).contains("道具工坊") or String(l.text).contains("天赋树"):
_problems.append("状态面板子树残留旧段文案「%s」(sv6①——零旧段失守)" % String(l.text))
# ② 属性行全值 = 契约4 格式 % runner 五现值(main 侧投影值层真链路)
var rnr = main.run_scope.runner
if rnr == null:
_problems.append("状态面板开层时 runner 空指针(契约5——状态机缺陷,禁软回退)")
else:
var attr_expect := "生命 %d/%d · 攻击 %d · 护甲 AC %d · GP %d" % [
int(rnr.hero_hp), rnr.effective_max_hp(), rnr.effective_atk(),
rnr.effective_ac(), int(rnr.gp)]
if String(main.status_panel._attrs_label.text) != attr_expect:
_problems.append("状态面板属性行投影失真(sv6②——期望 %s 实测 %s)" % [attr_expect, main.status_panel._attrs_label.text])
# ③ _status_data() 直调对拍(sv7——键集恰八键+curios/feats 独立 oracle)
var sd: Dictionary = main._status_data()
var keys := ["balance", "hero_hp", "max_hp", "atk", "ac", "gp", "curios", "feats"]
var keys_ok: bool = (sd as Dictionary).size() == 8
for k in keys:
keys_ok = keys_ok and (sd as Dictionary).has(k)
if not keys_ok:
_problems.append("_status_data() 键集≠八键(sv7——实测 %s)" % str((sd as Dictionary).keys()))
var oracle: Array = []
for rec in rnr.treasure_inventory.query_inventory(Callable()):
var rr: Dictionary = rec
oracle.append({"name": String(main.db.treasure_item_row(String(rr.get("item_id", ""))).get("display_name", "")),
"used": bool(rr.get("used", false))})
var sd_curios: Array = (sd as Dictionary).get("curios", [])
var curios_ok: bool = (sd_curios as Array).size() == (oracle as Array).size()
if curios_ok:
for i in (oracle as Array).size():
curios_ok = curios_ok and String((sd_curios[i] as Dictionary).get("name", "")) == String((oracle[i] as Dictionary).get("name", "")) and bool((sd_curios[i] as Dictionary).get("used", false)) == bool((oracle[i] as Dictionary).get("used", false))
if not curios_ok:
_problems.append("_status_data().curios 对拍独立 oracle 失守(sv7——实测 %s vs %s)" % [str(sd_curios), str(oracle)])
var feats_expect: Array = load("res://presentation/hud_presenter.gd").feat_holdings_lines(main.db, rnr.feats_held())
if str((sd as Dictionary).get("feats", [])) != str(feats_expect):
_problems.append("_status_data().feats 对拍 feat_holdings_lines 失守(sv7)")
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 载体(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(刷新双账收口):轮内计数单源=结算快照(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])
# ---- 营地灰字取色断言辅助(迭代36 wc4——文本锚定位,全 Label 递归收集)----
func _collect_labels_by_text(root: Node, out: Array) -> void:
if root is Label:
out.append(root)
for child in root.get_children():
_collect_labels_by_text(child, out)
func _labels_with_text(labels: Array, needle: String) -> Array:
var found: Array = []
for l in labels:
if String(l.text).contains(needle):
found.append(l)
return found
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)
_seals_placed.append(target)
_ap_spent_this_round += 1
return
# 行为②:随机撤封印(右键已封印格),约 10% 轮次
if _rand.randf() < 0.1 and not _seals_placed.is_empty():
var fc: Vector2i = _seals_placed.pop_back()
if minesweeper.has_seal(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_seal(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_seal(target):
return # 无合法邻格/目标被并发封印行为占位——下帧重挑
_locktest_cell = target
_locktest_frames = 0
main.board_view.click_cell(target, MOUSE_BUTTON_RIGHT)
if settlement.minesweeper.has_seal(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_seal(_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_seal(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 seals=%s" % [
str(diag.minesweeper_board_installed), str(diag.facts_path), str(diag.refresh_used_round), str(_seals_placed)])