ui_probe 入口停点段:+独立性断言二支(level_screen.visible 且 _battle_root.hidden=零透出契约;press_candidate 进首战后 _battle_root 恢复+level_screen 不可见=屏级互斥恢复);断言消息 route_overlay 残留清零+注释措辞随独立屏。ui_click_probe 入口段:+独立性断言(屏激活期宿主隐藏)。 轻闸=冒烟 557/0+五探针全 PASS 零脚本错误。
351 lines
17 KiB
GDScript
351 lines
17 KiB
GDScript
# VERTICAL SLICE - NOT FOR PRODUCTION
|
||
# 实现 GDD:design/ux/battle.md(§AC 场景级测试 + §Interaction Map——UI 行为随机化探针 v3)。
|
||
# Date: 2026-09-12(战斗屏改造批扩展;基础 = 2026-09-11 问题3 复现器 v2——既有断言全保留)
|
||
# 合成鼠标事件走真实呈现层路径(BoardView.click_cell / 按钮信号 → 组合根动作通道),
|
||
# 随机混合玩家行为——插旗/拔旗、提前停手、弃图刷新确认链、图例模态、明细回看模态、
|
||
# tooltip 悬停、无效点击——扫描结算停滞(缺陷②回归载体;缺陷④窗口契约随道具窗口删除退役 2026-09-15)
|
||
# + 模态遮挡断言(缺陷⑤回归:图例/回看/终局 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:自动玩家策略单一实现
|
||
|
||
var main
|
||
var _last_state := {}
|
||
var _stall_frames := 0
|
||
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_refresh_chain := false
|
||
var _tested_tooltip := false
|
||
var _inter_confirms := 0 # 大奖励屏确认计数(冒险推进停点——首见确认推进 L2,再见 = L2 通关收口)
|
||
var _route_clicks := 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_camp.json", "user://smoke_click_probe_camp.json.tmp",
|
||
"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 = load("res://core/save_manager.gd").new("user://smoke_click_probe_camp.json")
|
||
iso_c.load_from_disk()
|
||
main.meta = load("res://core/meta_progression.gd").new(main.db, iso_c)
|
||
var campbtn: Button = _find_button_by_prefix(main._start_overlay, "营地")
|
||
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._forge_rows.is_empty():
|
||
_problems.append("锻造柜台行缺失(点击路径)")
|
||
else:
|
||
var buy_btn: Button = main.camp_overlay._forge_rows[0].btn
|
||
if not buy_btn.disabled:
|
||
_problems.append("零余额锻造购买按钮未禁用(禁用态失守)")
|
||
iso_c.add_keystone(9999)
|
||
main.camp_overlay.refresh_camp(main._camp_data())
|
||
await get_tree().process_frame
|
||
if buy_btn.disabled:
|
||
_problems.append("余额充足后购买按钮仍禁用(禁用态未随刷新)")
|
||
var bal_before: int = iso_c.keystone_balance()
|
||
var buy_attr: String = String(main.camp_overlay._forge_rows[0].attr)
|
||
var price: int = main.meta.forge_price(buy_attr)
|
||
buy_btn.pressed.emit() # 合成购买点击 → _on_camp_buy → meta.buy_forge(写调用唯一归组合根)
|
||
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.forge_level(buy_attr)) != 1:
|
||
_problems.append("营地购买未持久(reload 后级 != 1)")
|
||
main._on_camp_closed()
|
||
await get_tree().process_frame
|
||
if main.camp_overlay.visible or not main._start_overlay.visible:
|
||
_problems.append("营地关闭后未恢复开始层")
|
||
# 换回全新零档(营地购买态不泄入后续冒险段——确定性;档案残留下次段首清理兜底)
|
||
var iso_reset = load("res://core/save_manager.gd").new("user://smoke_click_probe_reset.json")
|
||
iso_reset.load_from_disk()
|
||
main.meta = load("res://core/meta_progression.gd").new(main.db, iso_reset)
|
||
main.tune_panel.set_sliders(400, 200) # 高加成保 L1 可通关——L2 岔路选路段只能经冒险推进到达(单关直入口退役)
|
||
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.is_empty():
|
||
_problems.append("首格未可点(cells=0)")
|
||
print("[click_probe] 首格可点计时 %dms(进入战斗屏口径,≤500ms AC)· 场景冷启动 %dms(含 UI 构建 %dms,观测项)" % [
|
||
first_cell_ms, t_total, t_ui_ready])
|
||
var action_budget := 20000
|
||
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.level_screen.visible:
|
||
# 节点图选路口(零自动推进停点)——图上命中路径交替选候选(岔路使用行为;布局决策 2 改造)
|
||
main.level_screen.press_candidate(_route_clicks % 2)
|
||
_route_clicks += 1
|
||
_flags_placed.clear()
|
||
continue
|
||
if main.shop_overlay.visible:
|
||
# 商店房覆盖层(GP+商店房批——L2 商店为 Boss 前必经位):零购离开(真实 leave 信号路径)放行选路
|
||
main.shop_overlay.leave_pressed.emit()
|
||
_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 = _find_button_by_prefix(main._inter_overlay, "进入下一层")
|
||
if next_btn == null:
|
||
_problems.append("大奖励屏确认按钮缺失")
|
||
else:
|
||
next_btn.pressed.emit()
|
||
await get_tree().process_frame
|
||
await get_tree().process_frame
|
||
if main.runner == null or int(main.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():
|
||
# 冒险终局(失败/放弃/打穿)——单关直入口退役后无二段入口,见终局即收口(模态断言照旧)
|
||
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.settlement == null or main.runner == null:
|
||
continue
|
||
var diag: Dictionary = main.settlement.diagnostic_snapshot() # 诊断观测面(私有字段零直读)
|
||
var st := {
|
||
"node": main.runner.node_index, "round": main.settlement.round_index,
|
||
"phase": main.settlement.action_economy.phase, "ap": main.settlement.action_economy.ap,
|
||
"settling": diag.settling,
|
||
}
|
||
if JSON.stringify(st) == JSON.stringify(_last_state):
|
||
_stall_frames += 1
|
||
if _stall_frames == 30:
|
||
_report_stall(st)
|
||
return
|
||
else:
|
||
_stall_frames = 0
|
||
_ap_spent_this_round = 0
|
||
_last_state = st
|
||
if main.settlement.battle_result == "" and main.settlement.action_economy.phase == "Active":
|
||
_run_modal_probes_once()
|
||
_play_one_action()
|
||
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.review_overlay.is_open() or 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("图例关闭后未恢复输入/可见性")
|
||
# ② 结算明细回看模态(pattern 6):B 区明细带点击入口 → overlay → 关闭恢复
|
||
if not _tested_review:
|
||
_tested_review = true
|
||
main._open_review_from_band()
|
||
if not main.review_overlay.is_open():
|
||
_problems.append("明细回看打开失败")
|
||
if not main._input_locked():
|
||
_problems.append("明细回看打开时输入未锁定")
|
||
main.review_overlay.close()
|
||
if main.review_overlay.is_open() or main._input_locked():
|
||
_problems.append("明细回看关闭后未恢复")
|
||
# ③ 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.settlement.action_economy.rap)
|
||
var board_before = main.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.settlement.action_economy.rap) != rap_before - 8:
|
||
_problems.append("弃图刷新未扣 RAP8(%d→%d)" % [rap_before, int(main.settlement.action_economy.rap)])
|
||
if main.settlement.action_economy.phase != "Active":
|
||
_problems.append("轮内刷新后相位丢失(%s)" % main.settlement.action_economy.phase)
|
||
if main.settlement.minesweeper.current_board() == board_before:
|
||
_problems.append("轮内刷新未当场换图")
|
||
if main._refresh_used_round != 1:
|
||
_problems.append("刷新轮内计数错误(%d)" % main._refresh_used_round)
|
||
|
||
|
||
func _play_one_action() -> void:
|
||
var settlement = main.settlement
|
||
if settlement == null:
|
||
return # 结算竞态防抖:battle_finished 与本轮动作调度同帧到达时 settlement 可能已被置空(基线既有噪声——加固)
|
||
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()
|
||
|
||
|
||
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.settlement.battle_result == "":
|
||
why = "Idle 相位但战斗未结束(begin 失败?)"
|
||
else:
|
||
why = "状态无推进(battle_result='%s')" % str(main.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.settlement.diagnostic_snapshot() # 诊断观测面(私有字段零直读)
|
||
print(" node_index=%s round=%s phase=%s ap=%s rap=%s" % [
|
||
str(main.runner.node_index), str(main.settlement.round_index),
|
||
str(main.settlement.action_economy.phase), str(main.settlement.action_economy.ap), str(main.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)])
|
||
|
||
|
||
# 开始层按钮按文案前缀寻址(campbtn 句柄未存组合根成员——树遍历只读;与 ui_probe 同款助手)
|
||
func _find_button_by_prefix(root: Node, prefix: String) -> Button:
|
||
if root is Button and String(root.text).begins_with(prefix):
|
||
return root
|
||
for child in root.get_children():
|
||
var found := _find_button_by_prefix(child, prefix)
|
||
if found != null:
|
||
return found
|
||
return null
|