refactor(波2 目录收尾): game/smoke/ 目录退役——44 件探针三件套迁 game/probe/、30 件测量/诊断/驱动件迁 game/sim/;全仓 100+ 处 res://smoke 与 game/smoke 引用改指(闸件/产品码 tune_sim_controller/宿主 preload/根规则/README/设计文档);熵增闸 RETIRED 收编 game/smoke 防回潮;AGENTS.md Project Layout 换代;static_scan 豁免理由换新宿主;波 2 施工件预计算表全部命中(8d 预扫零红灯);全量域闸验收通过零新增红

This commit is contained in:
DKChan committed 2026-10-24 02:09:27 +08:00
1 parent c5f1383766
commit 16c6f1362e
110 files changed
+139 -7305

No files matched your search

+217
View File
@@ -0,0 +1,217 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# 棋盘渲染帧取证探针(目视验证面:机检≠看得清)。
# 必须**窗口化**运行(headless 无渲染面):
# Godot_console.exe --path . res://probe/board_shot_probe.tscn -- <输出目录>
# 流程:L1/L2/L3 各起一板(真实 LevelRunner+settle_batch 驱动 ~18 步 novice——自然揭示数字/封印),
# BoardView 真渲染,frame_post_draw 同步后抓视口 PNG。零存档触碰(无 save_manager 引用)。
# 护栏:headless fail-fast(shot_probe 同款);空纹理取证 fail——取证段失败=闸红,
# 零字节/空纹理不允许静默落盘;board_meta.json 取样坐标落盘(棋盘视图全局矩形——
# golden 前置裁剪坐标源,禁硬编码像素)。
# 双帧取证:每关抓 微光开(生产默认态·Q4 丙案)/关(golden 基线保护面)
# 两帧——① 开帧逐像素判定「非生效格零变化」(禁整格染色/禁越圈;生效格内只准落在内缘
# 带 = cell÷8)② 每个生效格须有实绘像素(微光不空转)③ 关帧即 golden 对比帧(原名落盘,
# 基线不重制——契约 1 off 态基线)。隔离纪律 = 与 type_glow_shot_probe 同款:
# 前置 UiKit.motion_scale=0.5 冻结接触带呼吸相位(静态带色),两帧差异唯一定位在类型微光。
extends Node
const AutoPlayer := preload("res://sim/auto_player.gd")
const ActionEconomy := preload("res://core/action_economy.gd")
var _meta := {} # 棋盘视图实测几何(全局 rect——tools/golden_board_crop.py 取样坐标源)
var _problems: Array = []
var _level_readings := {} # 关 → 双帧比对读数(实现报告读数表源)
var _cur_settlement = null # 当关 settlement(身份回调读取面——仿生产 run_scope.settlement 现值;
# 不用 Callable.bind 传参:bind 的 Callable 动态派发时对类型化参数做 Object 硬校验,
# 报「Cannot convert argument 2 from Object to Vector2i」——直接方法引用+成员现值无此坑)
func _ready() -> void:
# 运行前提=窗口化真渲染面(shot_probe 同款护栏——headless 下 frame_post_draw 永不触发+视口纹理恒空)
if DisplayServer.get_name() == "headless":
print("[board_shot] FAIL — 本探针必须窗口化运行(headless 无渲染面,截图必空);现行事实源=tools/render_gate.sh 取证段")
get_tree().quit(1)
return
var args := OS.get_cmdline_user_args()
var out := args[0] if args.size() >= 1 and args[0] != "" else "user://board-shots"
DirAccess.make_dir_recursive_absolute(out)
await get_tree().process_frame
var db = load("res://core/config_db.gd").new()
var ok: bool = db.load_tables("res://data/tables", true)
if not ok:
push_error("[board_shot] 表加载失败")
get_tree().quit(1)
return
# 双帧单一变量隔离:冻结接触带呼吸相位——motion_scale<1.0 ⇒ _apply_contact_bands
# 施静态色(减动效停格路径)。必须在 rebuild 前置位(rebuild/set_dig_glow_enabled 按当时相位
# 重施带色)。golden 侧影响:静态带色=呼吸周期中值,与基线捕获相的像素差最坏 ≈灰度24 于
# 接触带像素(占比 <0.4%),≤2% 判据不受影响;停格同时令「非生效格零变化」可精确判定。
UiKit.motion_scale = 0.5
var ui_font: Font = load("res://presentation/views/ui_kit.gd").load_font(load("res://presentation/views/ui_kit.gd").FONT_UI)
var targets := [[1, "L1_dungeon"], [2, "L2_underdark"], [3, "L3_deep_underdark"]]
for t in targets:
var level_index: int = t[0]
var tag: String = t[1]
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(420000 + level_index)
var runner = load("res://core/level_runner.gd").new(db, rng)
if not runner.start_level(level_index, 420000 + level_index, {}, 0):
push_error("[board_shot] 开关失败 level=%d" % level_index)
get_tree().quit(1)
return
var settlement = runner.current_battle()
_cur_settlement = settlement
for i in range(18):
var act: Dictionary = AutoPlayer.novice_action(settlement.minesweeper)
var kind := String(act.get("kind", "stop"))
if kind == "stop":
break
var op := kind
if kind == "seal":
op = "seal_place"
settlement.minesweeper.settle_batch([{"action": op, "cell": act.get("cell")}])
var view = load("res://presentation/views/board_view.gd").new()
view.size = Vector2(900, 900)
view.position = Vector2(40, 40)
if ui_font != null:
view.set_world_font(ui_font)
add_child(view)
# 身份解析接线(生产同源——main.gd:251 set_identity_resolver(_identity_lookup)):必须在
# rebuild / set_dig_glow_enabled 前接好,二者尾链 _recompute_glow 同步读身份建作用集。
# 裸视图不接 = _identity_cb 无效 ⇒ info 恒 null ⇒ 作用集恒空(三关 glow=0 根因——
# 双帧比对空转、PASS 假绿)。现值经 _cur_settlement 成员读入
# (仿生产 run_scope.settlement;bind 传参有 Callable 类型校验坑,见成员注)。
view.set_identity_resolver(_probe_identity_lookup)
view.rebuild(settlement.minesweeper.current_board(), settlement.minesweeper)
# 进挖掘相位(生产态 = hud_presenter.set_dig_glow_enabled(not settling) 接线——裸视图默认
# _dig_glow=false,接触带与类型微光同源门控都不渲染;golden 基线即捕获于
# 该裸态。双帧取证要求生产默认态,故显式开——两帧均含静态接触带(motion_scale=0.5 停格),
# 差异唯一定位在类型微光开关)
view.set_dig_glow_enabled(true)
# 抓帧前等入场动画完结(S2 观察项根因):rebuild 有全板淡入
# motion(0.2)+R0 涟漪逐格延迟显形(motion(0.04×离板心距离)+motion(0.12),最远格 ≈0.56s),
# 原制 6 个 process_frame 热机后 ≈0.1s=捕在涟漪中途——逐跑帧时序抖动使揭示 alpha 漂移,
# golden L2/L3 跨 2% 容差线波动(实测 0.00-2.53%)。改时间制 1.2s(2×余量)=锚定动画完结态。
await get_tree().create_timer(1.2).timeout
await get_tree().process_frame
await RenderingServer.frame_post_draw
var on_img := get_viewport().get_texture().get_image()
if on_img == null or on_img.is_empty():
push_error("[board_shot] FAIL — 抓帧空纹理: %s(Q7 取证语义=闸红)" % tag)
get_tree().quit(1)
return
var on_path := "%s/board_%s_glow_on.png" % [out, tag]
on_img.save_png(on_path)
# 作用集快照必须在关开关之前取(set_type_glow_enabled(false) 会清空 _glow 重算)
var glow_cells: Dictionary = (view.type_glow_probe().get("cells", {}) as Dictionary).duplicate()
# 帧 B = 微光关(golden 基线保护面——与基线同文件名落盘,golden 对比取本帧)
view.set_type_glow_enabled(false)
await get_tree().process_frame
await RenderingServer.frame_post_draw
var off_img := get_viewport().get_texture().get_image()
if off_img == null or off_img.is_empty():
push_error("[board_shot] FAIL — 抓帧空纹理: %s_off(Q7 取证语义=闸红)" % tag)
get_tree().quit(1)
return
var path := "%s/board_%s.png" % [out, tag]
off_img.save_png(path)
_meta[tag] = {"x": view.global_position.x, "y": view.global_position.y,
"w": view.size.x, "h": view.size.y}
print("[board_shot] saved %s (%dx%d) + %s" % [path, off_img.get_width(), off_img.get_height(), on_path])
_check_dual_frames(tag, view, glow_cells, off_img, on_img)
view.queue_free()
await get_tree().process_frame
var mf := FileAccess.open(out.path_join("board_meta.json"), FileAccess.WRITE)
if mf != null:
mf.store_string(JSON.stringify(_meta))
mf.close()
var rf := FileAccess.open(out.path_join("board_dual_frame_readings.json"), FileAccess.WRITE)
if rf != null:
rf.store_string(JSON.stringify(_level_readings, " "))
rf.close()
if _problems.is_empty():
print("[board_shot] PASS — 三关双帧取证:非生效格零变化/生效格内缘带外零侵入/生效格实绘非空;golden 帧=微光关态(契约 1 基线保护面)")
get_tree().quit(0)
else:
for p in _problems:
push_error("[board_shot] FAIL — " + p)
print("[board_shot] FAIL — " + "; ".join(_problems))
get_tree().quit(1)
# 身份解析回调(生产同源面——main.gd:758 _identity_lookup 同构:板在守卫 + Active 相位守卫 +
# query_reward_identity 只读代理;奖励映射生命周期〔ready〕守卫归 query_identity 自带)。
# 相位为何是 Active:每轮 settle_batch 末尾 _exit_distribution 已 notify_round_start 兑现下轮
# AP ⇒ 18 步 novice 驱动后相位回 Active,即生产「挖掘期看板」态;本回调零玩法数据写入
# (🔴 契约 1——只读投影面)。
func _probe_identity_lookup(c: Vector2i):
if _cur_settlement == null or _cur_settlement.minesweeper.current_board() == null:
return null
if String(_cur_settlement.action_economy.phase) != ActionEconomy.PHASE_ACTIVE:
return null
return _cur_settlement.query_reward_identity(c)
# 双帧逐像素比对:所有变化像素必须落在生效格的内缘带内
# (de < cell÷8——带外含砖心零侵入=禁整格染色/禁越圈);每个生效格须有实绘像素(不空转)。
func _check_dual_frames(tag: String, view, glow_cells: Dictionary, off_img: Image, on_img: Image) -> void:
var board = view.board()
if board == null:
_problems.append("%s 双帧比对取板失败(view.board() 为 null)" % tag)
return
var w: int = off_img.get_width()
var h: int = off_img.get_height()
var cell: float = view.cell_size
var band_px: float = cell * 0.125
# 逐格窗口矩形 + 到格缘距离判据(与 type_glow_shot_probe 同口径)
var rects := {}
for gy in range(board.height):
for gx in range(board.width):
var c := Vector2i(gx, gy)
rects[c] = view.cell_rect_probe(c)
var changed_total := 0
var changed_outside := 0
var first_outside := ""
var glow_hits := {}
for c in glow_cells:
glow_hits[c] = 0
for gy in range(board.height):
for gx in range(board.width):
var c2 := Vector2i(gx, gy)
var r: Rect2 = rects[c2]
if r.size.x <= 0.0:
continue
var is_glow: bool = glow_cells.has(c2)
var x0: int = int(floor(r.position.x))
var y0: int = int(floor(r.position.y))
var x1: int = int(ceil(r.position.x + r.size.x))
var y1: int = int(ceil(r.position.y + r.size.y))
for yy in range(maxi(0, y0), mini(h, y1)):
for xx in range(maxi(0, x0), mini(w, x1)):
var de: int = mini(mini(xx - x0, x1 - 1 - xx), mini(yy - y0, y1 - 1 - yy))
var p1: Color = off_img.get_pixel(xx, yy)
var p2: Color = on_img.get_pixel(xx, yy)
if p1.r == p2.r and p1.g == p2.g and p1.b == p2.b and p1.a == p2.a:
continue
changed_total += 1
if is_glow:
if float(de) < band_px:
glow_hits[c2] = int(glow_hits[c2]) + 1
else:
changed_outside += 1
if first_outside == "":
first_outside = "%s 带外(de=%d≥%.0f)" % [str(c2), de, band_px]
else:
changed_outside += 1
if first_outside == "":
first_outside = "%s 非生效格" % str(c2)
_level_readings[tag] = {"glow_cells": glow_cells.size(), "changed_total": changed_total,
"changed_outside": changed_outside, "band_px": band_px}
print("[board_shot] %s 双帧读数 glow=%d changed=%d outside=%d band=%.0fpx"
% [tag, glow_cells.size(), changed_total, changed_outside, band_px])
if changed_outside != 0:
_problems.append("%s 非生效格/带外像素变化 %d 个(首例 %s——禁整格染色/禁越圈)" % [tag, changed_outside, first_outside])
if glow_cells.size() > 0 and changed_total == 0:
_problems.append("%s 微光开帧整板零变化(微光未生效)" % tag)
for c in glow_cells:
if int(glow_hits[c]) == 0:
_problems.append("%s 生效格 %s 零实绘像素(微光空转)" % [tag, str(c)])
+1
View File
@@ -0,0 +1 @@
uid://ex47eiu0q8iv
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://probe/board_shot_probe.gd" id="1"]
[node name="BoardShotProbe" type="Node"]
script = ExtResource("1")
+165
View File
@@ -0,0 +1,165 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# 演出密度探针(样张 v2 冻结批 T3——演出四项可触发+探针断言覆盖):
# ①死亡碎裂(standee mark_dead → 碎块计数)②意图三档动画(damage/buff/debuff/charge 摇定不炸+徽标可见)
# ③胜利纹章+失败掸灰+封印光收回(battle_end open_victory/open_defeat → fx_probe 计数)
# ④结算轮光带(settlement_fx play_round_sweep → 子节点落位)。
# 运行:Godot_console.exe --headless --path . res://probe/fx_probe.tscn
extends Node
func _ready() -> void:
await get_tree().process_frame
var problems: Array = []
# ---- ①② 站桩侧:死亡碎裂 + 意图三档 ----
var sb_script := load("res://presentation/views/standee_body.gd")
var sb = sb_script.new("enemy")
sb.box = Vector2(150, 176) # 站桩盒几何(Node2D 自定义字段——非 Control.size)
add_child(sb)
sb.set_data({"display_name": "地精", "enemy_id": "goblin", "hp": 10, "max_hp": 10})
await get_tree().process_frame
# 意图三档:特殊准备(damage/buff/debuff)与蓄力档(heavy)摇定——动画触发+徽标可见
for tier in [["damage", "slash"], ["buff", "atk_up"], ["debuff", "weaken_hit"], ["damage", "heavy"]]:
sb.set_pending(tier[0], tier[1])
if not sb.intent_probe().visible:
problems.append("意图徽标应可见(%s/%s)" % [tier[0], tier[1]])
await get_tree().process_frame
sb.set_pending("basic", "") # 档①普通攻击=零表示
if sb.intent_probe().visible:
problems.append("普通攻击徽标应零表示(§4.6.4 档①)")
# 死亡碎裂
sb.mark_dead()
await get_tree().process_frame
var dp: Dictionary = sb.fx_probe()
if int(dp.get("death_shards", 0)) != 5:
problems.append("死亡碎块应 5 块(§8.4 冻结值),实得 %s" % [dp.get("death_shards")])
if not sb.is_dead():
problems.append("mark_dead 后 is_dead 应真")
# ①b 命中火花(打击瞬间特效计数)
var sb2 = sb_script.new("enemy")
sb2.box = Vector2(150, 176)
add_child(sb2)
sb2.play_hit_impact()
await get_tree().process_frame
var hp2: Dictionary = sb2.fx_probe()
if int(hp2.get("hit_sparks", 0)) != 6:
problems.append("命中火花应 6 枚(迭代 6 T4),实得 %s" % [hp2.get("hit_sparks")])
# ---- ③ 终局侧:胜利纹章 / 失败掸灰+封印光 ----
var end_ov := BattleEndOverlay.new()
add_child(end_ov)
var br := Rect2(300, 300, 400, 300)
end_ov.open_victory("胜利", "继续", br)
await get_tree().process_frame
var vp: Dictionary = end_ov.fx_probe()
if int(vp.get("crest_shards", 0)) != 10:
problems.append("胜利纹章碎砖应 10 枚(§2.4),实得 %s" % [vp.get("crest_shards")])
end_ov.close()
end_ov.open_defeat("失败", "重来", br)
await get_tree().process_frame
var fp: Dictionary = end_ov.fx_probe()
if int(fp.get("dust_motes", 0)) != 8:
problems.append("失败掸灰尘絮应 8 枚(§2.5),实得 %s" % [fp.get("dust_motes")])
if int(fp.get("seal_dots", 0)) != 8:
problems.append("封印光收回应 8 点,实得 %s" % [fp.get("seal_dots")])
end_ov.close()
# ---- ④ 结算轮光带(含前导亮缘) ----
var sfx := SettlementFxView.new()
add_child(sfx)
sfx.play_round_sweep(br) # 光带几何全由 board_rect 驱动——无需容器尺寸
await get_tree().process_frame
var n_fx := 0
for ch in sfx.get_children():
if ch is ColorRect:
n_fx += 1
if n_fx < 2:
problems.append("结算光带应含带体+前导缘线 ≥2 节点,实得 %d" % n_fx)
# ---- ④b 轮切换横幅双态(常态=淡入驻留消隐动画链;减动效=静态呈现零动画)----
sfx.play_round_banner(3)
await get_tree().process_frame
var banner_nodes := 0
for ch in sfx.get_children():
if ch is Panel:
banner_nodes += 1
if banner_nodes != 1:
problems.append("轮横幅应落位 1 面(卡⑦),实得 %d" % banner_nodes)
if int(sfx.probe_banners()) != 1:
problems.append("轮横幅计数失守(probe_banners=%s)" % sfx.probe_banners())
var saved_motion: float = UiKit.motion_scale
UiKit.motion_scale = 0.5
sfx.play_round_banner(4)
await get_tree().process_frame
if int(sfx.probe_banners()) != 2:
problems.append("减动效轮横幅未发射(卡⑦ AC③——静态化仍呈现不跳过)")
UiKit.motion_scale = saved_motion
# ---- ④c 逐条结算标出(三轮 #3——状态脉冲+伤害数字浮现断言面) ----
sfx.play_status_pulse(Vector2(120, 120), Color("7cc47c"))
sfx.play_damage_number(Vector2(120, 120), "-7", Color("d9402a"), false)
await get_tree().process_frame
await get_tree().process_frame
if int(sfx.probe_settlement_marks()) != 2:
problems.append("逐条结算标出未落位(状态脉冲+数字浮现——三轮 #3),实得 %s" % sfx.probe_settlement_marks())
# ---- ⑤ 主角三态切换(attack/hit 短驻自回 + 死亡复活态回 idle) ----
var hero_sb = sb_script.new("hero")
hero_sb.box = Vector2(150, 176)
add_child(hero_sb)
var t_idle: Texture2D = UiKit.load_tex(UiKit.TEX_HERO_PORTRAIT)
var t_atk: Texture2D = UiKit.load_tex(UiKit.TEX_HERO_ATTACK)
var t_hit: Texture2D = UiKit.load_tex(UiKit.TEX_HERO_HIT)
if t_idle == null or t_atk == null or t_hit == null:
problems.append("主角三态素材缺位(portrait/attack/hit——迭代 6 T3)")
else:
hero_sb.set_portrait_texture(t_idle, true)
hero_sb.register_hero_state("idle", t_idle)
hero_sb.register_hero_state("attack", t_atk)
hero_sb.register_hero_state("hit", t_hit)
hero_sb.set_hero_state("attack")
if String(hero_sb.hero_state_probe().get("state", "")) != "attack":
problems.append("主角攻击态切换失败(set_hero_state attack)")
await get_tree().create_timer(UiKit.motion(0.5)).timeout
if String(hero_sb.hero_state_probe().get("state", "")) != "idle":
problems.append("attack 态未短驻自回 idle(0.35s)")
hero_sb.set_hero_state("hit")
if String(hero_sb.hero_state_probe().get("state", "")) != "hit":
problems.append("主角受击态切换失败(set_hero_state hit)")
hero_sb.set_hero_state("idle")
hero_sb.mark_dead()
hero_sb.set_hero(6, 10) # 新战斗开场 HP 回正——死亡残位复活(敌侧 set_data 同款语义)
if hero_sb.is_dead() or String(hero_sb.hero_state_probe().get("state", "")) != "idle":
problems.append("主角死亡后 set_hero 复活失败(is_dead 应假+态回 idle)")
# ---- ⑥ 主角受击/死亡路由(battle_stage 主角走完整呈现链的断言) ----
var db2 = load("res://core/config_db.gd").new()
if db2.load_tables("res://data/tables", false):
var stage2 = BattleStageView.new(db2)
add_child(stage2)
var flash_before: int = int(stage2.hero_standee.fx_probe().get("flash_count", 0))
stage2.on_step_row_fx({"type": "damage", "target": "hero", "killed": false})
await get_tree().create_timer(UiKit.motion(0.45)).timeout # >到达拍 0.33s
var hfx: Dictionary = stage2.hero_standee.fx_probe()
if int(hfx.get("flash_count", 0)) <= flash_before:
problems.append("主角受击闪白未触发(on_step_row_fx hero 路由——迭代 6 T5)")
if int(hfx.get("hit_sparks", 0)) < 6:
problems.append("主角受击火花未触发(演出对等——迭代 6 T5)")
stage2.on_step_row_fx({"type": "damage", "target": "hero", "killed": true})
await get_tree().create_timer(UiKit.motion(0.45)).timeout
if not stage2.hero_standee.is_dead():
problems.append("主角击杀塌陷未触发(killed 行——迭代 6 T5)")
stage2.queue_free()
else:
problems.append("fx_probe⑥ 表加载失败(config_db)")
# ---- 汇总 ----
if problems.is_empty():
print("[fx_probe] PASS — 死亡碎裂5块/命中火花6/意图三档+零表示/胜利纹章10/失败掸灰8+封印光8/结算光带+缘线/轮横幅双态/逐条结算标出/主角三态切换 全链路通过")
get_tree().quit(0)
else:
for p in problems:
print("[fx_probe] FAIL — " + p)
print("[fx_probe] FAIL — %d 项断言未过" % problems.size())
get_tree().quit(1)
+1
View File
@@ -0,0 +1 @@
uid://bufflvmhgf2cc
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://probe/fx_probe.gd" id="1"]
[node name="FxProbe" type="Node"]
script = ExtResource("1")
+121
View File
@@ -0,0 +1,121 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# 演出渲染证据探针(真实渲染设备抓战斗演出瞬间帧)。
# 必须**窗口化**运行(真渲染设备——headless 无渲染面):Godot_console.exe --path . res://probe/fx_shot_probe.tscn -- <输出目录>
# 流程 = shot_probe 同款隔离面(存档/设置/元进度换注隔离档,禁碰真实档)+ 真实按钮路径开冒险进首战;
# 自动玩家挖格驱动行结算,在 行到达/弹道飞行/结算排播 三时刻抓帧——演出链「逻辑在(fx_probe PASS)×
# 渲染在(本探针 PNG)」双证的第二半边。PNG 落输出目录(artwork/qa-fx-*/,素材纪律不入 git)。
extends Node
const ProbeUiUtil := preload("res://probe/probe_ui_util.gd") # T6 收口:树寻址/隔离样板单点
# ── 演出采样窗口口径(headless/窗口化帧率不定,帧时刻以墙钟近似锚定;改窗口 = 改抓帧
# 时刻观测面,非断言语义。窗口值与字面量同值——仅提为具名常量。) ──
const T_DIG_FLIGHT := 0.12 # 挖格行到达/弹道在飞窗口(settle_batch 回执后停留)
const T_SETTLE := 0.35 # 结算排播窗口(行到达后二连抓)
const T_ROUND_TICK := 0.25 # 停手轮末(敌方攻击弹道/受击闪白 0.2-1.6s 流过)连抓 6 帧的采样步距
const T_FINAL := 0.6 # 收尾帧前静置
var main: Node
var out_dir := ""
var _problems: Array = []
func _ready() -> void:
var args := OS.get_cmdline_user_args()
out_dir = args[0] if args.size() >= 1 and args[0] != "" else "user://qa-fx"
DirAccess.make_dir_recursive_absolute(out_dir)
await get_tree().process_frame
main = load("res://presentation/main.tscn").instantiate()
add_child(main)
await get_tree().process_frame
ProbeUiUtil.isolate_save(main, "fx_shot_probe", false, true, true) # 末参=swap_save(卡②签名换代——原 refresh_resume 槽位语义退役)
# 隔离档即时应用(真实档 fullscreen 态会污染抓帧尺寸〔shot_probe 同款口径〕)
main.settings_store.apply()
for i in 8:
await get_tree().process_frame # UI 装配完成等足帧(canvas_item 空引用窗口)
main._start_adventure_from_menu() # 函数直调中间态(卡② mn10——开始冒险钮已撤)
# 等关卡画面出图(choices_presented 异步到达——最多 3s 轮询)
for i in 60:
await get_tree().process_frame
if main.level_screen != null and main.level_screen.visible:
break
if main.level_screen == null or not main.level_screen.visible:
_fail("关卡画面未出现(函数直调中间态路径)")
return
for i in 5:
await get_tree().process_frame
main.level_screen.press_candidate(0)
for i in 20:
await get_tree().process_frame
var st = main.run_scope.settlement
if st == null:
_fail("首战未开(settlement 空)")
return
var rules = main.run_scope.settlement.minesweeper
var board = rules.current_board()
# 自有驱动(只挖/走——测试驱动可用板面数据挑安全格;新手策略档的封印拒绝会耗尽预算):挖到 3 格停
var dug := 0
var guard := 40
while dug < 3 and guard > 0:
guard -= 1
var target = null
for n in board.neighbors8(rules.hero_pos()):
if not rules.is_revealed(n) and not board.is_mine(n):
target = n
break
if target != null:
var out: Array = rules.settle_batch([{"action": "dig", "cell": target}])
if out.is_empty() or not bool(out[0].get("accepted", false)):
continue
dug += 1
await get_tree().create_timer(T_DIG_FLIGHT).timeout
await _snap("fx_dig%d" % dug) # 行到达/弹道在飞窗口
await get_tree().create_timer(T_SETTLE).timeout
await _snap("fx_settle%d" % dug) # 结算排播窗口
continue
# 无相邻可挖 → 走向有未开安全格邻居的已揭开格(挖掘期移动免费)
var dest = null
for c in rules.revealed_cells():
if c == rules.hero_pos() or rules.compute_move_path(c).is_empty():
continue
for n in board.neighbors8(c):
if not rules.is_revealed(n) and not board.is_mine(n):
dest = c
break
if dest != null:
break
if dest == null:
break
rules.settle_batch([{"action": "move", "cell": dest}])
# 停手 → 轮末结算(敌方攻击弹道/受击闪白/结算排播在 0.2-1.6s 窗口流过)——连抓 6 帧
rules.settle_batch([{"action": "stop"}])
for i in 6:
await get_tree().create_timer(T_ROUND_TICK).timeout
await _snap("fx_round%d" % (i + 1))
if dug == 0:
_fail("无安全格可挖(首板全雷不可信)")
return
await get_tree().create_timer(T_FINAL).timeout
await _snap("fx_final")
if _problems.is_empty():
print("[fx_shot_probe] PASS — 演出帧 %d 张落 %s(挖格 %d)" % [dug * 2 + 1, out_dir, dug])
get_tree().quit(0)
else:
for p in _problems:
push_error("[fx_shot_probe] FAIL — " + p)
get_tree().quit(1)
func _snap(tag: String) -> void:
await RenderingServer.frame_post_draw
var img: Image = get_viewport().get_texture().get_image()
if img == null or img.is_empty():
_problems.append("抓帧空纹理: " + tag)
return
img.save_png("%s/%s.png" % [out_dir, tag])
func _fail(msg: String) -> void:
_problems.append(msg)
push_error("[fx_shot_probe] FAIL — " + msg)
get_tree().quit(1)
+1
View File
@@ -0,0 +1 @@
uid://cjphtdru77a1r
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://probe/fx_shot_probe.gd" id="1"]
[node name="FxShotProbe" type="Node"]
script = ExtResource("1")
+94
View File
@@ -0,0 +1,94 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# 布局几何断言探针(现行事实源=tools/probe_gate.sh 默认段;呈现层三区布局的分辨率维度验收)
# 用途:battle.md §验收「1280×800 与 16:9 全屏下三区无遮挡/无重叠/无裁切」的可执行化
# GDD 锚:design/gdd/ux/battle.md §Layout Zones(B/C/D 三区几何)
# 硬约束:档位表是数据、断言是一份函数据——加档只改表不动断言(禁每档建一个探针)
extends Node
const ProbeUiUtil := preload("res://probe/probe_ui_util.gd") # 隔离样板单点
# 分辨率档位表(逻辑视口尺寸;数据侧——增删档位只改这里,断言代码零改动)
const VIEWPORTS := [
{"label": "基座 1280x800", "size": Vector2i(1280, 800)},
{"label": "16:9 全屏 1920x1080", "size": Vector2i(1920, 1080)},
]
const MIN_CELL := 36.0 # 格子可读门槛(battle.md §棋盘格尺寸公式 1280 档 12×12 → 36px)
var _problems: PackedStringArray = []
func _ready() -> void:
await get_tree().process_frame
var main: Node = load("res://presentation/main.tscn").instantiate()
add_child(main)
await get_tree().process_frame
ProbeUiUtil.isolate_save(main, "smoke_layout_probe", true, false, true)
main.debug_overlay.seed_edit.text = "42" # 种子钉死(确定性禁依赖默认随机分支)
main._start_adventure_from_menu()
for i in range(8):
await get_tree().process_frame
# 进战斗屏——三区(B 战斗画面/C 角色信息/D 扫雷图)同屏常驻,菜单屏不构成断言面
main.level_screen.press_candidate(0)
for i in range(20):
await get_tree().process_frame
for vp in VIEWPORTS:
# content_scale_size 是headless 下改逻辑视口的唯一有效入口(get_window().size
# 在 headless 下不改变视口——用旧 API 会得到永远为真的假断言)
get_tree().root.content_scale_size = vp["size"]
for i in range(5):
await get_tree().process_frame
_check_one(vp["label"], main)
# 复原本项目基座(探针不该把进程留在末档尺寸)
get_tree().root.content_scale_size = Vector2i(1280, 800)
if _problems.is_empty():
print("[layout_probe] PASS — %d 档几何不变量全过(三区非零/不越视口/互不重叠/view 含于 D 区/格子 ≥%.0fpx)"
% [VIEWPORTS.size(), MIN_CELL])
get_tree().quit(0)
else:
for p in _problems:
print("[layout_probe] FAIL — " + p)
print("[layout_probe] FAIL — %d 项断言未过" % _problems.size())
get_tree().quit(1)
# 一份不变量断言,逐档代入——代码量与档数无关
func _check_one(label: String, main: Node) -> void:
var vr: Rect2 = get_viewport().get_visible_rect()
if vr.size != Vector2(VIEWPORTS[0]["size"]) and vr.size.y < 1.0:
_problems.append("[%s] 逻辑视口退化 %s" % [label, vr.size])
var rects := {}
for key in ["stage", "info_panel", "board_zone"]:
var ctl: Control = main.get(key)
if ctl == null:
_problems.append("[%s] %s = null(三区未装配)" % [label, key])
return
var r := Rect2(ctl.global_position, ctl.size)
rects[key] = r
if r.size.x <= 0.0 or r.size.y <= 0.0:
_problems.append("[%s] %s 零尺寸 %s" % [label, key, r.size])
elif not vr.encloses(r):
_problems.append("[%s] %s 越视口 %s ⊄ %s" % [label, key, r, vr])
# 判据覆盖边界(如实记录,勿当已拦截):本探针按名点名三区做几何判定,
# 不遍历战斗屏宿主的全部后代。变异验证第二发证——向装配器注入一个全幅 ColorRect
# (挂在 battle_root 的内层根下)时探针仍全绿:「未登记兄弟控件」这一缺陷类不在本探针
# 拦截面内。要拦它得改为递归遍历全子树找重叠,那是另一套判据(且极易误报容器父子关系),
# 本探针不设此判据。
var bv: Control = main.board_view
if bv == null:
_problems.append("[%s] board_view = null" % label)
return
var br := Rect2(bv.global_position, bv.size)
if not vr.encloses(br):
_problems.append("[%s] board_view 越视口 %s ⊄ %s" % [label, br, vr])
if rects.has("board_zone") and not rects["board_zone"].encloses(br):
_problems.append("[%s] board_view 越出 board_zone(%s ⊄ %s)"
% [label, br, rects["board_zone"]])
var keys := rects.keys()
for i in range(keys.size()):
for j in range(i + 1, keys.size()):
if rects[keys[i]].intersects(rects[keys[j]]):
_problems.append("[%s] 顶层区重叠 %s×%s(%s ∩ %s)"
% [label, keys[i], keys[j], rects[keys[i]], rects[keys[j]]])
var cell: float = main.board_view.get("cell_size")
if cell < MIN_CELL:
_problems.append("[%s] 格子边长 %.1fpx < 可读门槛 %.0fpx" % [label, cell, MIN_CELL])
+1
View File
@@ -0,0 +1 @@
uid://cr76a6h2cmkml
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3 uid="uid://clayoutprobe01"]
[ext_resource type="Script" path="res://probe/layout_probe.gd" id="1"]
[node name="LayoutProbe" type="Node"]
script = ExtResource("1")
+137
View File
@@ -0,0 +1,137 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# 探针共用面:树寻址助手 + 存档/设置/元进度隔离样板单点。
# 覆盖:ui_probe / ui_click_probe / shot_probe / fx_shot_probe 四探针的树寻址样板
# 与隔离样板(各探针隔离文件名后缀 tag 保持不变)。
# 用法:探针侧 `const ProbeUiUtil := preload("res://probe/probe_ui_util.gd")` 后静态调用
#(探针载体含 --script 场景两种,均无全局标识符——禁 class_name 裸引用)。
extends RefCounted
const SaveManagerScript := preload("res://core/save_manager.gd")
const MetaProgressionScript := preload("res://core/meta_progression.gd")
const SettingsStoreScript := preload("res://presentation/settings_store.gd")
# ── 树寻址(递归只读——按钮/标签句柄未存组合根成员的探针专用面) ──────────────
# 匹配模式参数化(历史两种语义,禁混用禁静默改变):
# mode="prefix" = text.begins_with(needle)(ui_probe / ui_click_probe 历史语义)
# mode="contains" = text.contains(needle)(shot_probe / fx_shot_probe 历史语义)
# 调用点替换时逐一核对原语义后选参。
static func find_button(root: Node, needle: String, mode: String = "prefix") -> Button:
if root == null:
return null
if root is Button and _text_match(String(root.text), needle, mode):
return root
for child in root.get_children():
var found := find_button(child, needle, mode)
if found != null:
return found
return null
# CheckButton 同款寻址(ui_click_probe 语义 = contains——断言设置面旧入口不在)
static func find_check_button(root: Node, needle: String) -> CheckButton:
if root == null:
return null
if root is CheckButton and String(root.text).contains(needle):
return root
for child in root.get_children():
var found := find_check_button(child, needle)
if found != null:
return found
return null
# Label 按文案包含寻址(shot_probe GP 徽章读数锚)
static func find_label(root: Node, text_fragment: String) -> Label:
if root == null:
return null
if root is Label and String(root.text).contains(text_fragment):
return root
for child in root.get_children():
var found := find_label(child, text_fragment)
if found != null:
return found
return null
# 控件实测几何(shot_probe checker 文字框取样坐标源——禁硬编码像素,分辨率档位自适应)
static func label_rect(root: Node, text_fragment: String) -> Dictionary:
if root == null:
return {}
if root is Label and String(root.text).contains(text_fragment):
return {"x": root.global_position.x, "y": root.global_position.y,
"w": root.size.x, "h": root.size.y}
for child in root.get_children():
var r := label_rect(child, text_fragment)
if not r.is_empty():
return r
return {}
# 按脚本名寻址(resource_path contains——shot_probe hero 面板锚)
static func find_by_script(root: Node, script_name: String) -> Node:
if root == null:
return null
var s: Script = root.get_script()
if s != null and String(s.resource_path).contains(script_name):
return root
for child in root.get_children():
var r := find_by_script(child, script_name)
if r != null:
return r
return null
static func _text_match(text: String, needle: String, mode: String) -> bool:
return text.begins_with(needle) if mode == "prefix" else text.contains(needle)
# ── 存档/设置/元进度三面隔离(探针禁碰真实档 user://save.json——SaveManager 路径注入纪律) ──
# tag = 隔离档名(不带扩展名,如 "smoke_input_probe_save"——各探针后缀保持不变);先删后建防陈旧档泄入。
# mark_first_done = 对齐真实档游玩史口径(模板抽签路径不变);with_settings = 连设置面换注
#(隔离 cfg 名 = <tag>_settings.cfg,与被收口探针原文件名逐一同名);
# 「继续上局」按钮重刷;swap_save = 是否换注全局 SaveManager(营地先行段只换 meta 侧不换 SaveManager)。
# 返回隔离 SaveManager 实例(调用方常需后续直读余额/重读档)。
# SaveManager/MetaProgression 是 autoload 单例——隔离面走单例原地重置
#(attach_path 沿 RngService.set_master_seed 先例;MetaProgression 持 save 引用透传,
# swap_save=false 分支换 meta 侧引用=独立 iso 实例,只换 meta 不换 save_manager)。
static func isolate_save(main, tag: String, mark_first_done: bool = false, with_settings: bool = false,
swap_save: bool = true): # refresh_resume 参数已随卡② _refresh_resume_button 删除退役(mn7)
var save_path := "user://%s.json" % tag
for p in [save_path, save_path + ".tmp", "user://%s_settings.cfg" % tag]:
if FileAccess.file_exists(p):
DirAccess.remove_absolute(p)
var iso = SaveManagerScript.new(save_path)
iso.load_from_disk()
if mark_first_done:
iso.mark_first_adventure_done()
# --script 模式无裸 autoload 标识符(run_all.gd 头注)——经 main 节点路径解析
var sm = main.get_node("/root/SaveManager")
if swap_save:
sm.attach_path(save_path)
if mark_first_done:
sm.mark_first_adventure_done()
iso = sm
else:
main.get_node("/root/MetaProgression").save = iso # 只换 meta 侧(营地段语义)——SaveManager 全局不动
if with_settings:
main.settings_store = SettingsStoreScript.new("user://%s_settings.cfg" % tag)
return iso
# 营地经进档开层(卡② mn9 新路径——campbtn 已撤;槽模板注入隔离命名空间,防碰真实槽
# user://save_slot_N.json——真实档红线)。创建=激活=暂行开营地(卡③ Q3 乙落点);
# 驱动后写盘目标=隔离槽文件(持久化断言读 user://<tag>_slot_1.json)。返回 false=新档钮缺失(驱动中断)。
static func open_camp_via_savesel(main, tag: String) -> bool:
var sm = main.get_node("/root/SaveManager")
sm.attach_slot_template("user://%s_slot_%%d.json" % tag)
for i in range(1, 4): # 夹具清场——空槽起(陈旧槽文件会翻有档态、新档钮缺席)
for suf in ["", ".tmp", ".corrupt.bak"]:
var sp: String = sm.slot_path(i) + suf
if FileAccess.file_exists(sp):
DirAccess.remove_absolute(sp)
main._open_save_select()
var new_btn: Button = main.save_select_overlay._new_btns.get(1)
if new_btn == null:
return false
new_btn.pressed.emit() # 真实按钮信号路径 → _on_save_select_new → create_slot+open_camp
return true
+1
View File
@@ -0,0 +1 @@
uid://c1probeuiutil
+231
View File
@@ -0,0 +1,231 @@
# screen_flow_probe.gd — L3 屏壳切换探针
# 判的是「跑起来之后,屏与场景的切换对不对」:菜单 ⇄ 营地 ⇄ 冒险 ⇄ 终局 的主流程与回退路径,
# 每一步断言 **屏级互斥**(同一时刻最多一个屏可见)+ 流程状态位落到正确值。
# 与 ui_click_probe 的分工:那件走的是「点某个按钮有没有反应」的行为走查(含战斗推进与结算数值面);
# 本件只走屏壳与流程状态,不推进战斗、不碰结算数值——屏级互斥是最容易被装配顺序改动悄悄破坏
# 又最难被单测覆盖的面(全库 change_scene 零命中,屏切换全靠 visible 互斥)。
# 运行:Godot_console.exe --headless --path . res://probe/screen_flow_probe.tscn
extends Node
const ProbeUiUtil := preload("res://probe/probe_ui_util.gd")
var main: Node
var _problems: Array[String] = []
func _ready() -> void:
await get_tree().process_frame
main = load("res://presentation/main.tscn").instantiate()
add_child(main)
await get_tree().process_frame
await get_tree().process_frame
# 隔离存档(全 worktree 共享同一份 user://save.json——探针必须换注,禁碰真实档)
ProbeUiUtil.isolate_save(main, "smoke_screen_flow", false, false, false)
await get_tree().process_frame
await _step_boot_menu()
await _step_menu_to_camp()
await _step_camp_back_to_menu()
await _step_save_select_open_back() # 迭代 33 卡③ sl10——选档开合+返回路径+ESC 语境不响应
await _step_menu_to_adventure()
await _step_level_to_battle_and_back()
await _step_abandon_to_end()
await _step_end_back_to_menu()
if _problems.is_empty():
print("[screen_flow_probe] PASS — 六步主流程+回退路径全绿(屏级互斥:任意时刻 ≤1 屏可见)")
get_tree().quit(0)
return
print("[screen_flow_probe] FAIL — %d 处问题:" % _problems.size())
for p in _problems:
print("[screen_flow_probe] - " + p)
get_tree().quit(1)
# ---- 屏级互斥判据 ----
# 屏标记取法:菜单/营地/终局看各自主内容层的 visible;冒险取流程状态位 run_scope.is_adventure
#(战斗容器 battle_root 在局外也 visible——裸看 visible 会把 boot 态误判成「菜单与冒险同时在场」)。
# **合法叠加只有一对**:终局覆盖层盖在冒险内容之上(终局态 is_adventure 尚未收束,是设计如此)。
# 其余任意两屏同时为真=互斥失守。
func _screen_seals() -> Dictionary:
return {
"菜单": main._start_overlay != null and main._start_overlay.visible,
"营地": main.camp_overlay != null and main.camp_overlay.visible,
"冒险": main.run_scope != null and main.run_scope.is_adventure,
"终局": main._end_overlay != null and main._end_overlay.visible,
}
func _seals_text(f: Dictionary) -> String:
var on: Array[String] = []
for k in ["菜单", "营地", "冒险", "终局"]:
if bool(f[k]):
on.append(k)
return "/".join(on) if not on.is_empty() else "无"
# ---- 流程状态位(与可见性是两回事,逐步单独判)----
func _expect_adventure_seal(step: String, expect: bool) -> void:
var got: bool = main.run_scope != null and main.run_scope.is_adventure
if got != expect:
_problems.append("%s:流程状态位 is_adventure 应为 %s,实测 %s" % [step, str(expect), str(got)])
func _expect_exclusive(step: String, expect: String) -> void:
var f: Dictionary = _screen_seals()
var pairs := [["菜单", "营地"], ["菜单", "冒险"], ["营地", "冒险"], ["终局", "营地"], ["终局", "菜单"]]
for pair in pairs:
var a: String = pair[0]
var b: String = pair[1]
if bool(f[a]) and bool(f[b]):
_problems.append("%s:屏级互斥失守(%s 与 %s 同时在场)" % [step, a, b])
if not bool(f[expect]):
_problems.append("%s:期望屏「%s」不在场(实测 %s)" % [step, expect, _seals_text(f)])
return
for k in ["菜单", "营地", "冒险", "终局"]:
if k == expect:
continue
if k == "冒险" and expect == "终局":
continue # 合法叠加:终局覆盖在冒险内容之上
if bool(f[k]):
_problems.append("%s:屏「%s」不该在场(实测 %s)" % [step, k, _seals_text(f)])
func _frames(n: int = 2) -> void:
for _i in n:
await get_tree().process_frame
# ---- ① boot:菜单独占 ----
func _step_boot_menu() -> void:
_expect_exclusive("①boot", "菜单")
_expect_adventure_seal("①boot", false)
if main.debug_overlay != null and main.debug_overlay.visible:
_problems.append("①boot:调试浮窗 boot 自动开(菜单优先失守)")
# ---- ② 菜单 → 营地(真实按钮信号路径)----
func _step_menu_to_camp() -> void:
# 营地经进档开层(卡② mn9 新路径——campbtn 已撤;槽模板注入隔离命名空间)
if not ProbeUiUtil.open_camp_via_savesel(main, "smoke_screen_flow_camp"):
_problems.append("②菜单→营地:选档层新档钮缺失(新路径驱动中断)")
return
await _frames()
_expect_exclusive("②菜单→营地", "营地")
# ---- ③ 营地 → 菜单(回退路径:营地关闭发自覆盖层自身信号)----
func _step_camp_back_to_menu() -> void:
if main.camp_overlay == null:
_problems.append("③营地→菜单:营地覆盖层句柄缺失")
return
main.camp_overlay.closed.emit()
await _frames()
main._on_save_select_closed() # 关闭被栈还原的选档层(动线序中间态——卡④ 换代点)
await _frames()
_expect_exclusive("③营地→菜单(回退)", "菜单")
# ---- ④ 菜单 → 冒险(开新冒险 → 入口停点落关卡地图屏)----
# ---- 选档界面开合(卡③ sl10):函数直调开层(「进入存档」钮归卡② mn3)→屏级互斥采样→
# 返回钮真实信号路径关闭→ESC 语境不响应(卡⑤ 契约 2 范围外——零实现即断言)。----
func _step_save_select_open_back() -> void:
ProbeUiUtil.isolate_save(main, "smoke_screen_flow_savesel", false, false, true)
main.save_manager.attach_slot_template("user://smoke_screen_flow_savesel_slot_%d.json")
var esb: Button = ProbeUiUtil.find_button(main._start_overlay, "进入存档", "prefix")
if esb == null:
_problems.append("[savesel] mn3 进入存档钮缺失(卡② 交付物)")
return
esb.pressed.emit() # 真实按钮信号路径 → _open_save_select(卡② mn3 点击链——卡③ 直调中间态就此闭环)
await get_tree().process_frame
var seals := _screen_seals()
if not main.save_select_overlay.visible or not seals_check_single(seals):
_problems.append("[savesel] sl10 开层后屏级互斥失守(选档=%s)" % str(main.save_select_overlay.visible))
var back_btn: Button = ProbeUiUtil.find_button(main.save_select_overlay, "返回", "prefix")
if back_btn == null:
_problems.append("[savesel] sl10 返回钮缺失(页壳顶条)")
else:
back_btn.pressed.emit()
await get_tree().process_frame
if main.save_select_overlay.visible or not main._start_overlay.visible:
_problems.append("[savesel] sl10 返回后选档未隐藏或开始层未恢复(关闭链失守)")
# ESC 语境:再开选档→合成 ESC 键→层不关、流程零扰动(ESC 不属选档语境——零实现断言)
main._open_save_select()
await get_tree().process_frame
var ev := InputEventKey.new()
ev.keycode = KEY_ESCAPE
ev.physical_keycode = KEY_ESCAPE
ev.pressed = true
main.get_viewport().push_input(ev, true)
await get_tree().process_frame
if not main.save_select_overlay.visible:
_problems.append("[savesel] sl10 ESC 关闭了选档层(选档语境 ESC 应不响应——卡⑤ 范围外)")
main._on_save_select_closed()
await get_tree().process_frame
# 互斥判据(本步局部——四屏 seal 之外新增选档位的同形判据)
func seals_check_single(f: Dictionary) -> bool:
var on := 0
for k in f:
if bool(f[k]):
on += 1
if main.save_select_overlay.visible:
on += 1 # 选档自身计入(调用点已开层)
return on == 1
func _step_menu_to_adventure() -> void:
# 开始冒险钮已随三入口化撤出——函数直调中间态(卡② mn10;卡④ 大门两分支换代)
main._start_adventure_from_menu()
await _frames(3)
_expect_exclusive("④菜单→冒险", "冒险")
_expect_adventure_seal("④菜单→冒险", true)
if main.run_scope != null and main.run_scope.adventure == null:
_problems.append("④菜单→冒险:冒险实例未建立(入口停点未达)")
if main.level_screen != null and not main.level_screen.visible:
_problems.append("④菜单→冒险:入口停点未落在关卡地图屏(level_screen 不可见)")
# ---- ⑤ 冒险内屏切换:关卡地图屏 ⇄ 战斗屏(屏内两态,屏级仍独占)----
func _step_level_to_battle_and_back() -> void:
if not main.level_screen.visible:
_problems.append("⑤冒险内切换:前置不在关卡地图屏")
return
main._exit_level_screen()
await _frames()
if main.level_screen.visible:
_problems.append("⑤冒险内切换:退出关卡屏后 level_screen 仍可见")
if main._battle_root != null and not main._battle_root.visible:
_problems.append("⑤冒险内切换:战斗屏未接手(battle_root 不可见)")
_expect_exclusive("⑤冒险内切换(关卡→战斗)", "冒险")
main._enter_level_screen()
await _frames()
if not main.level_screen.visible:
_problems.append("⑤冒险内切换:回到关卡地图屏失败")
_expect_exclusive("⑤冒险内切换(战斗→关卡)", "冒险")
# ---- ⑥ 放弃 → 终局(放弃路径=回退路径的非正常出口)----
func _step_abandon_to_end() -> void:
if main.run_scope == null or main.run_scope.adventure == null:
_problems.append("⑥放弃→终局:前置无冒险实例")
return
main._on_abandon_requested()
await _frames(4)
_expect_exclusive("⑥放弃→终局", "终局")
if main._end_overlay != null and not main._end_overlay.visible:
_problems.append("⑥放弃→终局:终局覆盖层未开")
# ---- ⑦ 终局 → 菜单(终局出口返回开始界面)----
func _step_end_back_to_menu() -> void:
if main._end_overlay == null or not main._end_overlay.visible:
_problems.append("⑦终局→菜单:前置不在终局")
return
main._end_overlay.continue_pressed.emit()
await _frames(3)
_expect_exclusive("⑦终局→菜单", "菜单")
if main.run_scope != null and main.run_scope.is_adventure:
_problems.append("⑦终局→菜单:冒险态未收束(run_scope 仍标 is_adventure)")
+1
View File
@@ -0,0 +1 @@
uid://dbm16gb31dhto
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://probe/screen_flow_probe.gd" id="1"]
[node name="ScreenFlowProbe" type="Node"]
script = ExtResource("1")
+220
View File
@@ -0,0 +1,220 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# 整屏截图探针(呈现层测试分级 B 档基建:视口截图程序化产出;七屏——
# 菜单/设置面/生涯页/营地/关卡画面/战斗/状态面板,现行事实源=tools/render_gate.sh)。
# 必须**窗口化**运行(真渲染设备——headless 无渲染面,get_image 得到空纹理):
# Godot_console.exe --path . res://probe/shot_probe.tscn -- <输出目录>
# 流程 = 真实按钮信号路径驱动(ui_click_probe 同款;存档/设置/元进度三面换注隔离档——禁碰真实档,
# 坑册「断点写盘随 save_manager 引用走」教训同款覆盖):菜单(启动态)→ 设置面(settingsbtn
# 真实信号路径)→ 生涯页(函数直调中间态——卡② mn10)→ 营地(进档开层新路径——mn9)→ 关卡画面(函数直调中间态→停点出图)→
# 战斗(level_screen.press_candidate(0) 真实选路提交)→ 状态面板(地图屏状态角标真实信号路径)。
# 每屏双帧 + frame_post_draw 同步后抓 get_viewport().get_texture(),PNG 落输出目录;
# 最小自检 = 七件存在且非零字节(像素方差断言归 tools/shot_checker.py——B 档骨架分工)。
# 商店房不纳屏(真实路径口径):真实路径必经 L1 打穿→L2 商店节点
# 选路(商店层在 L2 Boss 前必经位)——探针现行驱动只进首战节点,低代价确定性
# 到达不成立;合成投喂(ui_struct_probe 同款)破坏本探针真实路径口径 → 不纳屏。
extends Node
const ProbeUiUtil := preload("res://probe/probe_ui_util.gd") # T6 收口:树寻址/隔离样板单点
var main: Node
var out_dir := ""
var _problems: Array = []
var _meta := {} # 控件实测几何(全局 rect——shot_checker 文字框/明度带取样坐标源,禁硬编码像素)
func _ready() -> void:
# 运行前提=窗口化真渲染面:headless 下 frame_post_draw 永不触发+视口纹理恒空——无此守卫时 headless 调用方必挂死
if DisplayServer.get_name() == "headless":
print("[shot_probe] FAIL — 本探针必须窗口化运行(headless 无渲染面,截图必空);headless 自动闸禁跑本探针(现行事实源=tools/probe_gate.sh)")
get_tree().quit(1)
return
var args := OS.get_cmdline_user_args()
out_dir = args[0] if args.size() >= 1 and args[0] != "" else "user://qa-screens"
DirAccess.make_dir_recursive_absolute(out_dir)
await get_tree().process_frame
main = load("res://presentation/main.tscn").instantiate()
add_child(main)
await get_tree().process_frame
# ── 存档/元进度/设置三面隔离(ui_click_probe 同款——真档零污染;历史语义 = contains 寻址) ──
# refresh_resume 开——续玩钮按隔离档(无断点)定基,菜单截图/golden 基线不依赖
# 真实档断点态(boot 关系由冒烟 _smoke_boot_chain V2d 断,此处只保截图确定性)
ProbeUiUtil.isolate_save(main, "smoke_shot_probe", false, true, true)
# 隔离档即时应用(main 装配已按真实设置恢复过窗口态〔本机实测:真实档
# fullscreen=true 时窗口进全屏,视口纹理 2304x1440≠1280 基准档,checker 尺寸/取样全盘失真〕;
# apply 隔离档出厂默认=窗口化 1280x800,恢复本探针设计前提。探针侧环境修正,零运行时改动)
main.settings_store.apply()
await get_tree().process_frame
# ① 菜单(启动态)——无帮助文腿(玩法速览牌不在画面,checker 无对应断言段);
# 无副标题件,菜单文字锚只剩标题
# 自检一:boot 态顶层可见全屏层=start_overlay(内容层树遍历——屏壳是容器不当候选:
# camp 壳 boot 态 vis=true 但成员恒隐,裸树遍历自顶向下会先命中透明壳)
var top: Node = null
var top_rx := -1
var top_ry := -1
for c: Node in [main._start_overlay, main._battle_root, main._inter_overlay,
main.legend_overlay, main.node_overlay, main.level_screen, main.curio_overlay,
main._end_overlay, main.shop_overlay, main.camp_overlay, main.settings_overlay,
main.status_panel, main.career_overlay, main.confirm_dialog, main.debug_overlay]:
if c == null or not (c is CanvasItem) or not (c as CanvasItem).visible:
continue
var rx: int = c.get_parent().get_index()
var ry: int = c.get_index()
if top == null or rx > top_rx or (rx == top_rx and ry > top_ry):
top = c
top_rx = rx
top_ry = ry
if top != main._start_overlay:
_problems.append("boot 态顶层可见层≠start_overlay(实测 %s——菜单被埋)〔装配生命批 V4〕"
% (top.name if top != null else "null"))
# 自检二:菜单标题 label 必须 is_visible_in_tree 且 rect 落屏内
#(对比度只判反差不判「字在」——本条+checker 墨色占比共同补内容判据)
var vp := get_viewport().get_visible_rect()
for pair in [["menu_title", "Dungeon Sweeper"]]:
var lbl: Label = ProbeUiUtil.find_label(main._start_overlay, String(pair[1]))
if lbl == null:
_problems.append("菜单文字缺失: %s(find_label 零命中)〔装配生命批 V4〕" % String(pair[1]))
continue
if not lbl.is_visible_in_tree():
_problems.append("菜单文字不可见: %s(is_visible_in_tree=false——层被埋或隐藏)〔装配生命批 V4〕" % String(pair[1]))
var gr: Rect2 = lbl.get_global_rect()
if not vp.encloses(gr):
_problems.append("菜单文字 rect 越出视口: %s(%s ⊄ %s)〔装配生命批 V4〕" % [String(pair[1]), str(gr), str(vp)])
_meta[String(pair[0])] = ProbeUiUtil.label_rect(main._start_overlay, String(pair[1]))
await _snap("menu")
# ①b 设置面(settingsbtn 真实信号路径,开合同款真实钮)
var setbtn: Button = ProbeUiUtil.find_button(main._start_overlay, "设置", "prefix")
if setbtn == null:
_problems.append("设置入口缺失(开始层 settingsbtn)")
else:
setbtn.pressed.emit()
for i in 3:
await get_tree().process_frame
if not main.settings_overlay.visible:
_problems.append("设置面未打开(settingsbtn 路径)")
else:
_meta["settings_title"] = ProbeUiUtil.label_rect(main.settings_overlay, "设置")
await _snap("settings")
var set_close: Button = ProbeUiUtil.find_button(main.settings_overlay, "返回", "prefix")
if set_close == null:
_problems.append("设置面返回钮缺失")
else:
set_close.pressed.emit()
await get_tree().process_frame
# ①c 生涯页(函数直调中间态——卡② mn10:careerbtn 已撤,卡④ 笔记本改挂前无 UI 路径)
if true:
main._open_career()
for i in 3:
await get_tree().process_frame
if not main.career_overlay.visible or main._start_overlay.visible:
_problems.append("生涯页未打开或开始层未隐藏(函数直调中间态路径)")
else:
_meta["career_title"] = ProbeUiUtil.label_rect(main.career_overlay, "生 涯")
await _snap("career")
var career_close: Button = ProbeUiUtil.find_button(main.career_overlay, "返回", "prefix")
if career_close == null:
_problems.append("生涯页返回钮缺失")
else:
career_close.pressed.emit()
for i in 3:
await get_tree().process_frame
if main.career_overlay.visible or not main._start_overlay.visible:
_problems.append("生涯页关闭后页未隐藏或开始层未恢复")
# ② 营地(进档开层新路径——卡② mn9;槽模板注入隔离命名空间)
if not ProbeUiUtil.open_camp_via_savesel(main, "smoke_shot_probe_camp"):
_problems.append("营地入口缺失(选档层新档钮——新路径驱动中断)")
else:
await get_tree().process_frame
await _snap("camp")
var close_btn: Button = ProbeUiUtil.find_button(main.camp_overlay, "关闭营地", "contains")
if close_btn != null:
close_btn.pressed.emit()
await get_tree().process_frame
main._on_save_select_closed() # 关闭被栈还原的选档层(动线序中间态)
await get_tree().process_frame
# ③ 关卡画面(开始冒险→停点出图——入口停点语义;函数直调中间态 mn10)
main._start_adventure_from_menu()
for i in 4:
await get_tree().process_frame
await _snap("level")
# GP 常驻徽章断言(读数=runner.gp 现值,刷新=写路径回执后重查〔open_choices〕)
var gp_label: Label = ProbeUiUtil.find_label(main.level_screen, "◈ ")
if gp_label == null or not gp_label.is_visible_in_tree():
_problems.append("GP 徽章不可见(关卡画面顶栏)")
elif main.level_screen.get("_runner") != null:
var expect := "◈ %d" % int(main.level_screen.get("_runner").gp)
if gp_label.text != expect:
_problems.append("GP 徽章读数不符: %s != %s" % [gp_label.text, expect])
# ④ 战斗(节点一——真实选路提交路径 press_candidate,ui_click_probe :199 同款)
if main.level_screen != null and main.level_screen.visible:
main.level_screen.press_candidate(0)
for i in 10:
await get_tree().process_frame
await _snap("battle")
# 战斗屏文字框/明度带锚点(全局 rect——checker 取样坐标源;明细带锚 S1 裁后改认现行文案)
_meta["battle_log_hint"] = ProbeUiUtil.label_rect(main.stage, "上轮摘要")
_meta["hero_panel"] = _node_rect(ProbeUiUtil.find_by_script(main, "hero_info_panel_view"))
_meta["stage_view"] = _node_rect(main.stage)
_meta["board_zone"] = _node_rect(main.board_zone)
# draw calls 真机测量(基线 270→目标 <200;窗口化渲染帧读数,
# 取值时机=战斗截图同帧后一帧——呈现终态满负荷)
await get_tree().process_frame
var dc := int(Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME))
_meta["battle_draw_calls"] = dc
print("[shot_probe] battle draw_calls=%d(基线 270/目标 <200)" % dc)
# ⑤ 状态面板(战斗屏状态角标真实信号路径;层在战斗屏之上开合)
var pb: Button = main.board_zone.status_badge
if pb == null or not pb.visible or pb.disabled:
_problems.append("状态面板入口角标缺失/置灰(战斗屏 board_zone)")
else:
pb.pressed.emit()
for i in 3:
await get_tree().process_frame
if not main.status_panel.visible:
_problems.append("状态面板未打开(角标路径)")
else:
_meta["status_title"] = ProbeUiUtil.label_rect(main.status_panel, "状态")
await _snap("status")
var status_close: Button = ProbeUiUtil.find_button(main.status_panel, "继续", "prefix")
if status_close == null:
_problems.append("状态面板继续钮缺失")
else:
status_close.pressed.emit()
await get_tree().process_frame
if main.status_panel.visible:
_problems.append("状态面板关闭后未隐藏(继续钮路径)")
else:
_problems.append("关卡画面未出现(函数直调中间态路径)")
# ── 几何元数据落盘(shot_checker 取样坐标源) ──
var mf := FileAccess.open(out_dir.path_join("shot_meta.json"), FileAccess.WRITE)
if mf != null:
mf.store_string(JSON.stringify(_meta))
mf.close()
# ── 最小自检:七件存在且非零字节 ──
for s in ["menu", "settings", "career", "camp", "level", "battle", "status"]:
var p := out_dir.path_join("shot_%s.png" % s)
if not FileAccess.file_exists(p):
_problems.append("截图缺失: %s" % p)
else:
var fa := FileAccess.open(p, FileAccess.READ)
if fa == null or fa.get_length() < 1000:
_problems.append("截图疑似空文件: %s" % p)
print("[shot_probe] 完成 out=%s 问题=%d %s" % [out_dir, _problems.size(), str(_problems)])
get_tree().quit(1 if _problems.size() > 0 else 0)
func _snap(key: String) -> void:
await RenderingServer.frame_post_draw
await RenderingServer.frame_post_draw
var img: Image = get_viewport().get_texture().get_image()
if img == null:
_problems.append("视口纹理为空: %s" % key)
return
img.save_png(out_dir.path_join("shot_%s.png" % key))
print("[shot_probe] saved %s (%dx%d)" % [key, img.get_width(), img.get_height()])
func _node_rect(n: Node) -> Dictionary:
if n == null or not (n is CanvasItem):
return {}
return {"x": n.global_position.x, "y": n.global_position.y,
"w": n.size.x, "h": n.size.y}
+1
View File
@@ -0,0 +1 @@
uid://dv81k73ruw7xe
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://probe/shot_probe.gd" id="1"]
[node name="ShotProbe" type="Node"]
script = ExtResource("1")
+117
View File
@@ -0,0 +1,117 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# 演播后排窗定点探针(规则=「伤害过程演完才切战斗胜利结算窗」——该行为的唯一关联测试):
# 真实信号路径驱动一场战斗到击杀胜利(结算行在排播队列),断言两条:
# ①胜利同帧:终局面未开 + 挂起态在案 + 队列非空(旧行为=同帧 flush+开窗,本断言即红——判别性);
# ②排播放完:终局面开启(演播完边沿派发——_on_pump_ended)。
# 存档/设置/元进度三面隔离(isolate_save 现行事实源,meta 经 autoload 链同源),零真实档触碰。headless 可跑(不抓帧)。
extends Node
const ProbeUiUtil := preload("res://probe/probe_ui_util.gd") # 隔离样板单点
var main
var _problems: Array = []
func _ready() -> void:
await get_tree().process_frame
main = load("res://presentation/main.tscn").instantiate()
add_child(main)
await get_tree().process_frame
# 存档/设置两面隔离(ProbeUiUtil.isolate_save 现行事实源,先删后建防陈旧档泄入)——
# 隔离面走 attach_path 原地重置 autoload 单例:组合根 save_manager/meta 是getter-only
# 读视图,直接赋值无效,隔离必须落在单例上。
ProbeUiUtil.isolate_save(main, "t17_defer_probe", false, true, true) # 末参=swap_save(卡②签名换代——原 refresh_resume 槽位语义退役)
# 本探针验证完整动画档的演播后排窗——强制 motion_scale=1.0(隔离档替换在 boot 后,
# 减动效已随真档 settings.cfg 应用于引擎态;减动效档=consume_now 快路、开窗即时,另属语义)
UiKit.motion_scale = 1.0
for i in 8:
await get_tree().process_frame
main._start_adventure_from_menu() # 函数直调中间态(卡② mn10——开始冒险钮已撤)
for i in 60:
await get_tree().process_frame
if main.level_screen != null and main.level_screen.visible:
break
if main.level_screen == null or not main.level_screen.visible:
_fail("关卡画面未出现")
return
for i in 5:
await get_tree().process_frame
main.level_screen.press_candidate(0)
for i in 20:
await get_tree().process_frame
if main.run_scope.settlement == null:
_fail("战斗未启动")
return
# 直挖至击杀胜利(settle_batch 真实管线——step_row 信号→排播队列,与点击路径同链)
var ms = main.run_scope.settlement.minesweeper
var guard := 200
var t_ap_out := -1 # AP 用完/停手帧
var ap_clock := "" # 墙上时钟时间戳
var settle_clock := ""
while guard > 0 and String(main.run_scope.settlement.battle_result) == "":
guard -= 1
var plan = load("res://sim/auto_player.gd").choose_dig(ms)
if plan.is_empty():
main.run_scope.settlement.settle_batch([{"action": "stop", "cell": Vector2i(-1, -1)}])
await get_tree().process_frame
continue
if plan.hero_cell != ms.hero_pos():
ms.settle_batch([{"action": "move", "cell": plan.hero_cell}])
ms.settle_batch([{"action": "dig", "cell": plan.dig_cell}])
if t_ap_out < 0 and main.run_scope.settlement.action_economy.phase != "Active":
t_ap_out = Time.get_ticks_msec()
ap_clock = _clock_ms()
settle_clock = ap_clock # 触发结算与 AP 用完同帧(核心同步)——同一墙上时刻
await get_tree().process_frame
if String(main.run_scope.settlement.battle_result) != "victory":
_fail("战斗未胜利(result='%s')" % str(main.run_scope.settlement.battle_result))
return
var t_settle := Time.get_ticks_msec()
var _rows_at_victory: int = main._row_pacer.pending_rows().size() # 时间戳①:结算入队完成帧(AP 耗尽/停手→五行段同步跑完→行全在队)
# 断言①胜利同帧:终局面未开 + 挂起在案 + 队列非空(判别性——旧行为同帧开窗即红)
if main._end_overlay.is_open():
_problems.append("胜利同帧终局面已开(演播后排窗失效——同帧 flush 旧路径回潮)")
if main._deferred_modal == null:
_problems.append("胜利同帧挂起态缺失(_deferred_modal == null)")
if main._row_pacer.pending_rows().is_empty():
_problems.append("胜利同帧排播队列已空(结算行未走排播链)")
var _played_rows: int = _rows_at_victory
# 断言②排播放完:终局面开启(20s 守卫——0.22s×行数 ×10 余量)
var waited := 0.0
while not main._end_overlay.is_open() and waited < 20.0:
await get_tree().process_frame
waited += get_process_delta_time()
var t_popup := Time.get_ticks_msec()
var popup_clock := _clock_ms() # 弹窗出现:墙上时钟时间戳
print("[t17_defer] AP点用完: %s" % ap_clock)
print("[t17_defer] 触发结算: %s(与 AP 用完同帧——核心同步,相差 0ms)" % settle_clock)
print("[t17_defer] 弹窗出现: %s(距触发结算 %d ms = 逐行演播时长,行数 %d)" % [popup_clock, t_popup - t_ap_out, _played_rows])
if not main._end_overlay.is_open():
_problems.append("排播放完后终局面未开(%.1fs——演播完边沿派发失守)" % waited)
if _problems.is_empty():
print("[t17_defer] PASS — 胜利同帧挂起+排播完开窗 两断言全过(waited=%.1fs)" % waited)
get_tree().quit(0)
else:
for p in _problems:
print("[t17_defer] FAIL — " + p)
get_tree().quit(2)
func _clock_ms() -> String:
var unix := Time.get_unix_time_from_system()
return "%s.%03d" % [Time.get_datetime_string_from_system(), int(fmod(unix, 1.0) * 1000.0) % 1000]
func _fail(why: String) -> void:
print("[t17_defer] FAIL — " + why)
get_tree().quit(2)
func _find_button_by_sub(root: Node, sub: String) -> Button:
if root is Button and String(root.text).contains(sub):
return root
for c in root.get_children():
var r := _find_button_by_sub(c, sub)
if r != null:
return r
return null
+1
View File
@@ -0,0 +1 @@
uid://7nn2b0crxvas
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://probe/t17_defer_probe.gd" id="1"]
[node name="T17DeferProbe" type="Node"]
script = ExtResource("1")
+261
View File
@@ -0,0 +1,261 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# 未开格类型微光渲染取证探针(像素侧——V4 禁侵/V5 不压破/V6 五组互辨)。
# 必须**窗口化**运行(headless 无渲染面,抓帧恒空):
# Godot_console.exe --path . res://probe/type_glow_shot_probe.tscn -- <输出目录>
# 流程:起一板(真实 LevelRunner + settle_batch 驱动 18 步 novice——自然揭示数字/封印)→ 注入夹具身份回调
# (九 cell_type 循环 ⇒ 五组同屏齐现)→ BoardView 真渲染:
# 帧 A = 微光关(基线态)→ type_glow_off.png
# 帧 B = 微光开(生产默认态)→ type_glow_on.png
# 逐像素比对两帧:① 所有变化像素必须落在生效格的内缘带内(非生效格零变化=禁整格染色/禁越圈)
# ② 五组带内取样两两灰度对比对 CR ≥ GRAY_CR_MIN(色弱互辨通道)。
# 零存档触碰(无 save_manager 引用)。取证件落调用方给的目录(库外),不入 git。
extends Node
const AutoPlayer := preload("res://sim/auto_player.gd")
# 夹具:九 cell_type 全值域(地图战斗资源九型枚举同源)
const FIXTURE_TYPES := ["attack", "defense", "blast", "heal", "weaken",
"pierce", "magic", "action", "gp"]
# 8 邻方位 → 九型确定性指派(V6 五组同屏齐现的取证前提——组间互辨要求一帧内五组俱在)。
# 方位序:NW N NE W E SW S SE;探针起板后 N/NE/E 三位被 novice 挖开(恒定 seed 确定性),
# 故五组各占一个存活方位:NW=BUFF/W=PIERCE/SW=AOE/S=MAGIC/SE=HEAL,其余三位补 BUFF。
# 同屏组数不足 5 = 取证失败(见 _report 硬断言)——幸存集若变,探针报红而非静默少测。
const FIXTURE_DIR_TYPES := ["attack", "gp", "defense", "pierce",
"weaken", "blast", "magic", "heal"]
# 判据阈值(provisional——首版取严;实现报告登记,复核转正归战斗一屏样张)
const CORE_PAD_FRAC := 0.20
const GRAY_CR_MIN := 1.20
var _hero_pos := Vector2i.ZERO
func _ready() -> void:
if DisplayServer.get_name() == "headless":
print("[type_glow_shot] FAIL — 本探针必须窗口化运行(headless 无渲染面,截图必空)")
get_tree().quit(1)
return
var args := OS.get_cmdline_user_args()
var out := args[0] if args.size() >= 1 and args[0] != "" else "user://type-glow-shots"
DirAccess.make_dir_recursive_absolute(out)
await get_tree().process_frame
var db = load("res://core/config_db.gd").new()
if not db.load_tables("res://data/tables", true):
push_error("[type_glow_shot] 表加载失败")
get_tree().quit(1)
return
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(421001)
var runner = load("res://core/level_runner.gd").new(db, rng)
if not runner.start_level(1, 421001, {}, 0):
push_error("[type_glow_shot] 开局失败")
get_tree().quit(1)
return
var settlement = runner.current_battle()
for i in range(18):
var act: Dictionary = AutoPlayer.novice_action(settlement.minesweeper)
var kind := String(act.get("kind", "stop"))
if kind == "stop":
break
var op := kind
if kind == "seal":
op = "seal_place"
settlement.minesweeper.settle_batch([{"action": op, "cell": act.get("cell")}])
var view = load("res://presentation/views/board_view.gd").new()
view.size = Vector2(900, 900)
view.position = Vector2(40, 40)
add_child(view)
# 冻结接触带呼吸(减动效路径 motion_scale<1.0 ⇒ _apply_contact_bands 施静态色):
# 两帧比对要隔离「类型微光开关」这一单一变量,英雄格接触带相位漂移会污染非生效格零变化判据。
# 必须在 rebuild/set_dig_glow_enabled 之前置位——那两处会按当时相位重施带色。
UiKit.motion_scale = 0.5
var ui_font: Font = load("res://presentation/views/ui_kit.gd").load_font(load("res://presentation/views/ui_kit.gd").FONT_UI)
if ui_font != null:
view.set_world_font(ui_font)
# 夹具身份回调(返回形状与 query_identity 同构;8 邻按方位确定性指派,五组同屏齐现)
view.set_identity_resolver(_fixture_identity)
_hero_pos = settlement.minesweeper.hero_pos()
view.rebuild(settlement.minesweeper.current_board(), settlement.minesweeper)
# 挖掘相位:接触带呼吸与类型微光同源门控(_dig_glow);此处显式开以承取证
view.set_dig_glow_enabled(true)
# 作用集快照必须在关开关之前取(set_type_glow_enabled(false) 会清空 _glow 重算)
var glow_snapshot: Dictionary = view.type_glow_probe()
# 抓帧前等入场动画完结(board_shot_probe 同款时间制 1.2s——锚定动画完结态)
await get_tree().create_timer(1.2).timeout
await get_tree().process_frame
await RenderingServer.frame_post_draw
var on_img := get_viewport().get_texture().get_image()
if on_img == null or on_img.is_empty():
push_error("[type_glow_shot] FAIL — 抓帧空纹理(取证失败=闸红)")
get_tree().quit(1)
return
on_img.save_png(out.path_join("type_glow_on.png"))
view.set_type_glow_enabled(false)
await get_tree().process_frame
await RenderingServer.frame_post_draw
var off_img := get_viewport().get_texture().get_image()
if off_img == null or off_img.is_empty():
push_error("[type_glow_shot] FAIL — 抓帧空纹理(取证失败=闸红)")
get_tree().quit(1)
return
off_img.save_png(out.path_join("type_glow_off.png"))
_report(view, glow_snapshot, off_img, on_img, out)
# 逐像素比对与读数:V4(非生效格零变化+砖心零侵入)/V5(灰度分类一致)/V6(五组互辨)
# glow_snapshot = 关开关前的作用集快照(set_type_glow_enabled(false) 会清空 _glow 重算,故必须外传)
func _report(view, glow_snapshot: Dictionary, off_img: Image, on_img: Image, out: String) -> void:
var glow_cells: Dictionary = glow_snapshot.get("cells", {})
var bad: Array[String] = []
var readings := {}
if not bool(glow_snapshot.get("enabled", false)):
bad.append("微光开关态非默认开")
if glow_cells.is_empty():
bad.append("微光作用集为空(英雄 8 邻未开格未派生出组号)")
var board = view.board()
var w: int = off_img.get_width()
var h: int = off_img.get_height()
var cell: float = view.cell_size
var band_px: float = cell * 0.125
var core_pad: float = cell * CORE_PAD_FRAC
var changed_total := 0
var changed_outside := 0
var by_group := {}
if board != null:
for gy in range(board.height):
for gx in range(board.width):
var c := Vector2i(gx, gy)
var r: Rect2 = view.cell_rect_probe(c)
if r.size.x <= 0.0:
continue
var is_glow: bool = glow_cells.has(c)
var g: int = int(glow_cells[c]) if is_glow else 0
var x0: int = int(floor(r.position.x))
var y0: int = int(floor(r.position.y))
var x1: int = int(ceil(r.position.x + r.size.x))
var y1: int = int(ceil(r.position.y + r.size.y))
var gsum := 0.0
var gn := 0
for yy in range(maxi(0, y0), mini(h, y1)):
for xx in range(maxi(0, x0), mini(w, x1)):
var de: int = mini(mini(xx - x0, x1 - 1 - xx), mini(yy - y0, y1 - 1 - yy))
var p1: Color = off_img.get_pixel(xx, yy)
var p2: Color = on_img.get_pixel(xx, yy)
if is_glow and float(de) < band_px:
gsum += _lum(p2)
gn += 1
if p1.r == p2.r and p1.g == p2.g and p1.b == p2.b and p1.a == p2.a:
continue
changed_total += 1
if (not is_glow) or float(de) >= core_pad - 1.0:
changed_outside += 1
if is_glow and gn > 0 and not by_group.has(g):
by_group[g] = gsum / float(gn)
readings["glow_cells"] = glow_cells.size()
readings["groups"] = _sorted_keys(by_group)
readings["changed_total"] = changed_total
readings["changed_outside"] = changed_outside
readings["band_lum"] = by_group
var rects := {}
for c in glow_cells:
var rr: Rect2 = view.cell_rect_probe(c)
rects[str(c)] = [rr.position.x, rr.position.y, rr.size.x, rr.size.y]
readings["rects"] = rects
if changed_total == 0:
bad.append("开微光后整帧零像素变化(微光未生效)")
if changed_outside != 0:
bad.append("非生效格像素变化 %d 个(禁整格染色/禁越圈)" % changed_outside)
if by_group.size() < 5:
bad.append("同屏组数 %d < 5(V6 五组并列互辨的取证前提不成立)" % by_group.size())
var ks: Array = _sorted_keys(by_group)
var pairs: Array[String] = []
var worst_cr := 99.0
var worst_pair := ""
for i in range(ks.size()):
for j in range(i + 1, ks.size()):
# WCAG 相对亮度对比对:band 均值先经 sRGB→线性反伽马(+0.05 偏移定义在线性域),
# 与 tools/shot_checker.py 的 WCAG 段同口径(色弱互辨通道的机检同款公式)。
var la: float = _to_linear(float(by_group[ks[i]]))
var lb: float = _to_linear(float(by_group[ks[j]]))
var cr: float = (maxf(la, lb) + 0.05) / (minf(la, lb) + 0.05)
pairs.append("组%d/组%d CR=%.3f ΔL*=%.1f" % [ks[i], ks[j], cr, absf(_lstar(la) - _lstar(lb))])
if cr < worst_cr:
worst_cr = cr
worst_pair = "组%d/组%d" % [ks[i], ks[j]]
readings["pairs"] = " ".join(pairs)
readings["worst_cr"] = worst_cr
readings["worst_pair"] = worst_pair
if ks.size() >= 2 and worst_cr < GRAY_CR_MIN:
bad.append("灰度互辨最弱对 %s CR=%.3f < %.3f(色弱通道不可分)" % [worst_pair, worst_cr, GRAY_CR_MIN])
var mf := FileAccess.open(out.path_join("type_glow_readings.json"), FileAccess.WRITE)
if mf != null:
mf.store_string(JSON.stringify(readings, " "))
mf.close()
print("[type_glow_shot] 读数 %s" % JSON.stringify(readings))
if bad.is_empty():
print("[type_glow_shot] PASS — 非生效格零变化/砖心零侵入/五组灰度互辨全过")
get_tree().quit(0)
else:
for m in bad:
print("[type_glow_shot] FAIL — %s" % m)
get_tree().quit(1)
# 夹具身份回调(形状与 query_identity 返回同构;8 邻按方位取九型之一,其余格按坐标兜底)
func _fixture_identity(c: Vector2i) -> Variant:
var ctype: String
var di: int = _dir_index(c - _hero_pos)
if di >= 0:
ctype = FIXTURE_DIR_TYPES[di]
else:
ctype = FIXTURE_TYPES[int(abs(c.x * 31 + c.y * 17)) % FIXTURE_TYPES.size()]
return {"reward_id": "fix_%d_%d" % [c.x, c.y], "cell_type": ctype, "tier": 1}
# 方位偏移 → 0..7(NW N NE W E SW S SE;非 8 邻返回 -1)
static func _dir_index(off: Vector2i) -> int:
match off:
Vector2i(-1, -1):
return 0
Vector2i(0, -1):
return 1
Vector2i(1, -1):
return 2
Vector2i(-1, 0):
return 3
Vector2i(1, 0):
return 4
Vector2i(-1, 1):
return 5
Vector2i(0, 1):
return 6
Vector2i(1, 1):
return 7
return -1
static func _sorted_keys(d: Dictionary) -> Array:
var ks: Array = d.keys()
ks.sort()
return ks
# 相对亮度(Rec.709 系数——灰度互辨通道与 shot_checker 同口径)
# 相对亮度(Rec.709 系数——灰度互辨通道与 shot_checker 同口径;输入为帧缓冲 sRGB 值)
static func _lum(c: Color) -> float:
return 0.2126 * c.r + 0.7152 * c.g + 0.0722 * c.b
# sRGB(伽马)值 → 线性相对亮度(WCAG 2.x 反伽马——对比对公式的输入域)
static func _to_linear(y_srgb: float) -> float:
var c: float = clampf(y_srgb, 0.0, 1.0)
if c <= 0.04045:
return c / 12.92
return pow((c + 0.055) / 1.055, 2.4)
# CIE L*(Y∈[0,1] → L*∈[0,100];ΔL* 读数供互辨裕量评估)
static func _lstar(y: float) -> float:
var yy: float = clampf(y, 0.0, 1.0)
if yy <= 0.008856:
return 903.3 * yy
return 116.0 * pow(yy, 1.0 / 3.0) - 16.0
+1
View File
@@ -0,0 +1 @@
uid://djmrucr7nxl5j
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://probe/type_glow_shot_probe.gd" id="1"]
[node name="TypeGlowShotProbe" type="Node"]
script = ExtResource("1")
+821
View File
@@ -0,0 +1,821 @@
# 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_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.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_slot_1.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_slot_1.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
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.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.active_slot() != 1 or not FileAccess.file_exists(csel_tpl % 1):
_problems.append("[savesel] sl5 新档链失守(active=%d 应=1 且槽1 白板应落盘)" % main.save_manager.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.active_slot() != 2:
_problems.append("[savesel] sl6 进档链失守(active=%d 应=2——激活该槽)" % main.save_manager.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:
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 取消后消费方状态量未清(分派面残留)")
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)
# 调试入口断言(全局浮层角标=UI 最顶层、任何 UI 不遮挡——本段设置面
# 开中正是遮挡场景:浮层角标此时仍应可见;点击开浮窗路径归 tune_probe)
if main.debug_badge == null or not main.debug_badge.visible:
_problems.append("设置面开中调试浮层角标不可见(最顶层浮层失守——被遮挡)")
var settings_close: Button = ProbeUiUtil.find_button(main.settings_overlay, "返回", "prefix")
if settings_close == null:
_problems.append("设置面返回按钮缺失")
else:
settings_close.pressed.emit()
await get_tree().process_frame
if main.settings_overlay.visible or not main._start_overlay.visible:
_problems.append("设置面关闭后未恢复开始层")
# 旧入口零残留断言(卡④ AC③——测试调整板不含减动效开关;14.5 起载体=调试浮窗)
var legacy_motion = ProbeUiUtil.find_check_button(main.debug_overlay, "减动效")
if legacy_motion != null:
_problems.append("调整板减动效旧入口未退役(设置面唯一入口失守——卡④ AC③)")
# D1 断言(二轮):退出游戏只在一级菜单——开始菜单有、设置面无
if ProbeUiUtil.find_button(main._start_overlay, "退出游戏", "prefix") == null:
_problems.append("开始菜单缺退出游戏按钮(二轮 D1)")
if ProbeUiUtil.find_button(main.settings_overlay, "退出游戏", "prefix") != null:
_problems.append("设置面仍含退出游戏按钮(二轮 D1——只在一级菜单出现)")
# ---- 生涯页段(入口点击链+关闭回返,真实按钮信号路径;本页只读零写,
# 值断言归冒烟投影段与 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
# 关卡开始 = 入口选路停点——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 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() # 停点已消费的残面——继续关屏
_seals_placed.clear()
continue
if main.shop_overlay.visible:
# 商店房覆盖层(L2 商店为 Boss 前必经位):零购离开(真实 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("状态面板关闭后地图屏态失守(恢复语义破)")
main.level_screen.press_candidate(_route_clicks % 2)
_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,
}
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._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:
# 豁免:放弃路径收口时终局「返回」后仍挂三选一/关卡推进残留态(停点链),
# 战败态前提失配——记录不计 problem(返回路径已实机目检)。
if not _closed_via_abandon:
_problems.append("断点段:终局返回未回开始菜单")
# ②③ 中断→续玩全链+互斥确认两分支:随三入口化撤出(卡② §7.2 申报——断点确认探针段移交
# 卡④大门 T5 接收,两头登记禁静默删除;续玩钮已退役,主菜单确认链已摘 mn6)。
# ① 战斗收口断点写盘断言保留(上方——冒险流直调中间态 mn10)。
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_seal := false
for e in main.legend_overlay.ENTRIES:
if String(e[1]) == "封印":
legend_has_seal = true
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)
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])
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)])
+1
View File
@@ -0,0 +1 @@
uid://dokvbpqulbdjl
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://probe/ui_click_probe.gd" id="1"]
[node name="UIClickProbe" type="Node"]
script = ExtResource("1")
+135
View File
@@ -0,0 +1,135 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# UI 文字对比度机检(probe_gate 默认必跑)
# 断言面 = 文字墨对直背景 ≥4.5:1(art-bible §7.2 无区域例外;文字禁直压纹理底——垫牌后按牌色计)。
# 测点表:血数垫牌 / 意图徽标四墨(PANEL_DATA 垫牌)/ C区槽位两态墨 / RAP 标签 + 全站锚点组。
# 测点对一律取 UiKit 常量实时求值——代码侧换色即本闸红,禁只改注释不动值;α<1 墨先对背景混成再算。
# 运行:Godot_console.exe --headless --path . res://probe/ui_contrast_probe.tscn
extends Node
const MIN_RATIO := 4.5
# R1 过渡白名单:呈现层(ui_kit.gd 之外)允许的裸 Color8( 在册处——file→恰计数。
# 产品码裸取色已全部收进 ui_kit——本表应为空表;
# 新增裸取色 = 本闸红(先立常量再消费)。
const BARE_COLOR8_WL := {}
# 测点表:[名称, 墨色, 直背景, 墨α](语义见行尾;新增文字位入表,白名单豁免须注理由)
func _pairs() -> Array:
return [
["站桩血数·垫牌", Color(1, 1, 1), UiKit.HP_BAR_BACK, 0.95], # N1-1/2 白 α.95 压 HP_BAR_BACK 素牌
["意图atk·垫牌", UiKit.RED_MINE, UiKit.PANEL_DATA, 1.0], # N1-3 ⚔ 攻击预告(零换墨)
["意图buf·垫牌", UiKit.HERO_BLUE_DEEP, UiKit.PANEL_DATA, 1.0], # N1-4 ✦ 增益(同族深档)
["意图debuff·垫牌", UiKit.DEBUFF_PURPLE, UiKit.PANEL_DATA, 1.0], # ☠ 妨害(零换墨随牌达标)
["意图charge·垫牌", UiKit.GOLD_INK, UiKit.PANEL_DATA, 1.0], # 蓄力重击(深金墨)
["C区槽·已用", UiKit.STONE_DEEP, UiKit.PANEL_DATA, 1.0], # N1-5 占位暗字(灰石深化)
["C区槽·不可用", UiKit.INK_LABEL, UiKit.PANEL_DATA, 1.0], # N1-6 置灰(标签深墨)
["RAP标签", UiKit.INK_LABEL, UiKit.PANEL_DATA, 1.0], # N1-7(T0 实测已合规,显式化)
["锚·正文墨/数据板", UiKit.INK, UiKit.PANEL_DATA, 1.0], # 全站锚点:正文墨对数据板
["锚·暖白/屏根", UiKit.WARM, UiKit.BG_ROOT, 1.0], # 全站锚点:暗底正文
["锚·暖白弱/舞台底", UiKit.WARM_FAINT, UiKit.STAGE_BG, 1.0], # 全站锚点:B区辅助墨
["钥石徽·深墨/烛光黄", UiKit.INK_ON_GOLD, UiKit.CANDLE, 1.0], # badge 工厂件文字(营地徽+状态面板余额行同形制)
["生涯页·行名墨/暖亮页底", UiKit.WARM, UiKit.BG_BRIGHT, 1.0], # 生涯页行名列/提示文(WARM_SOFT 同族亮墨)
["生涯页·行值墨/暖亮页底", UiKit.CANDLE, UiKit.BG_BRIGHT, 1.0], # 生涯页数值列(烛光黄 bold)
["生涯页·组帽墨/暖亮页底", UiKit.INK_L_GOLD, UiKit.BG_BRIGHT, 1.0],# 生涯页分组小节头(金亮 bold)
# ---- 旧屏文字位追溯收测;石皮纹面板
# (panel_skinned)缺素材回退牌色 PANEL_DATA,机检按回退牌色计(素材不入 git 冒烟安全惯例)。
# 存量违例对首跑红=登记呈报不修(处置权归用户)。
# 已回绿对:随代码侧换墨同步改指新常量(比率见各行尾注)。
["结算·判定墨/结算板", UiKit.INK_TITLE, UiKit.PANEL_DATA, 1.0], # battle_end 判定行(INK_TITLE 亮底标题墨)
["结算·GP账行墨/结算板", UiKit.GOLD_INK, UiKit.PANEL_DATA, 1.0], # battle_end 战利品 GP 行名/值(深金墨 5.21:1)
["结算·账行墨/结算板", UiKit.INK_LABEL, UiKit.PANEL_DATA, 1.0], # battle_end 钥石/加成账行(标签深墨)
["商店·行卡名墨/暗底行卡", UiKit.WARM_SOFT, UiKit.BG_OVERLAY, 1.0], # legacy 行卡商品名(显式传暖白次级 9.53:1)
["商店·行卡现价墨/暗底行卡", UiKit.WARM_SOFT, UiKit.BG_OVERLAY, 1.0],# legacy 行卡「现价 N GP」
["商店·行卡已购墨/暗底行卡", UiKit.WARM_FAINT, UiKit.BG_OVERLAY, 1.0],# legacy 行卡「已购 N 档」
["商店·爬价规则墨/暖亮页底", UiKit.WARM_SOFT, UiKit.BG_BRIGHT, 1.0],# 货架面爬价规则行(page_shell 亮底)
["商店·货架卡名墨/货架板", UiKit.INK_TITLE, UiKit.PANEL_DATA, 1.0], # bcard 货架卡商品名(换 TG 带后未机检位)
["商店·货架价签墨/货架板", UiKit.GOLD_INK, UiKit.PANEL_DATA, 1.0], # bcard 价签「N ◈」(深金墨 5.21:1)
["营地·等级章字/头带深棕", UiKit.INK_ON_DEEP, UiKit.CAMP_HEADER_BAND, 1.0],# 锻造行 Lv 章(木底小章亮墨)
["营地·柜台头带字/头带深棕", UiKit.WARM, UiKit.CAMP_HEADER_BAND, 1.0], # 三柜台头带标题(暖白 bold)
["营地·铭牌副题墨/铭牌板", UiKit.STONE_DEEP, UiKit.PANEL_DATA, 1.0],# 营地铭牌副题「跨局成长账本…」(灰石深化 5.98:1)
["营地·行名墨/行板", UiKit.INK, UiKit.CAMP_ROW_PLATE, 1.0], # 锻造/工坊行名(墨族压暖米行板)
["营地·行价格墨/行板", UiKit.STONE_DEEP, UiKit.CAMP_ROW_PLATE, 1.0],# 锻造行「N 钥石」价签/工坊进度行(灰石深化 6.70:1)
["营地·树分组头墨/柜台板", UiKit.GOLD_INK, UiKit.PANEL_DATA, 1.0],# 天赋树脉分组头「·锻造脉·」(深金墨 5.21:1)
["营地·树行名墨/柜台板", UiKit.INK_LABEL, UiKit.PANEL_DATA, 1.0], # 天赋树行「名 Lv0/1」(标签深墨)
["营地·工坊状态墨/行板", UiKit.GOLD_INK, UiKit.CAMP_ROW_PLATE, 1.0],# 工坊状态字「已解锁」(深金墨 5.84:1·最不利现役态)
["营地·最近冒险墨/暖亮底", UiKit.WARM_SOFT, UiKit.BG_BRIGHT, 1.0], # 底栏最近冒险条带(暖白次级)
["设置·警示文/设置板", UiKit.INK_HINT, UiKit.PANEL_DATA, 1.0], # 设置警示文/后果说明(暖棕提示墨)
["设置·选项标签/设置板", UiKit.INK_LABEL, UiKit.PANEL_DATA, 1.0], # 设置行标签/状态字(标签深墨)
["三选一·停点头部墨/暗页底", UiKit.WARM_SOFT, UiKit.BG_ROOT, 1.0], # 三选一停点头部/回血反馈行(page_shell 根底档)
["三选一·卡名墨/货架板", UiKit.INK_TITLE, UiKit.PANEL_DATA, 1.0], # bcard 选项卡名(与商店货架同构卡)
["迷雾·记忆墨/灰罩合成底", UiKit.FOG_MEMORY_INK, # fog_state2 记忆板 α.66 压 floor avg #453020
Color(UiKit.FOG_MEMORY_PLATE.r * 0.66 + 69.0 / 255.0 * 0.34, # 合成底(素材侧值无常量形态——取 art-bible §389 在册复算值,素材更新随动;
UiKit.FOG_MEMORY_PLATE.g * 0.66 + 48.0 / 255.0 * 0.34, # 三关逐关 avg 缺在册且素材不入 git 不可实查——最坏情况判据扩对归移交)
UiKit.FOG_MEMORY_PLATE.b * 0.66 + 32.0 / 255.0 * 0.34), 1.0],
]
func _lum(c: Color) -> float:
var f := func(x: float) -> float:
return x / 12.92 if x <= 0.03928 else pow((x + 0.055) / 1.055, 2.4)
return 0.2126 * f.call(c.r) + 0.7152 * f.call(c.g) + 0.0722 * f.call(c.b)
func _ratio(a: Color, b: Color) -> float:
var la := _lum(a)
var lb := _lum(b)
var hi := la if la >= lb else lb
var lo := lb if la >= lb else la
return (hi + 0.05) / (lo + 0.05)
func _ready() -> void:
var problems: Array = []
var pairs := _pairs()
for p in pairs:
var fg: Color = p[1]
var bg: Color = p[2]
var alpha: float = p[3]
if alpha < 1.0:
fg = Color(fg.r * alpha + bg.r * (1.0 - alpha),
fg.g * alpha + bg.g * (1.0 - alpha),
fg.b * alpha + bg.b * (1.0 - alpha))
var r := _ratio(fg, bg)
if r < MIN_RATIO:
problems.append("[contrast] %s = %.2f:1 < %.1f(测点对须垫牌或换墨)" % [p[0], r, MIN_RATIO])
problems.append_array(_scan_static())
if problems.is_empty():
print("[ui_contrast_probe] PASS — 对比度 %d 对测点全绿(≥%.1f:1 无区域例外)+ 裸色白名单/preload 红线静态段绿" % [pairs.size(), MIN_RATIO])
get_tree().quit(0)
else:
for msg in problems:
print(msg)
print("[ui_contrast_probe] FAIL — %d 项越带(含静态段)" % problems.size())
get_tree().quit(1)
# ---- 机检段②③(需求卡片 §7):呈现层裸 Color8( 计数 vs 白名单 + 素材 preload 计数 = 0 ----
func _scan_static() -> Array:
var problems: Array = []
var root := "res://presentation"
var dirs := [root]
while not dirs.is_empty():
var d: String = dirs.pop_front()
for f in DirAccess.get_files_at(d):
if not f.ends_with(".gd"):
continue
var path := d + "/" + f
var src := FileAccess.get_file_as_string(path)
var c8 := src.count("Color8(")
var pre := 0
for line in src.split("\n"):
if line.contains("preload(") and (line.contains("res://assets/") or line.contains(".png")):
pre += 1
if path.ends_with("/ui_kit.gd"):
if pre != 0:
problems.append("[static] ui_kit.gd 素材 preload=%d(红线=0)" % pre)
continue # ui_kit=色板工厂本体,Color8 定义面合法
var wl: int = BARE_COLOR8_WL.get(path, 0)
if c8 != wl:
problems.append("[static] %s 裸 Color8=%d 白名单=%d(R1 先立常量再消费)" % [path, c8, wl])
if pre != 0:
problems.append("[static] %s 素材 preload=%d(R2 红线=0)" % [path, pre])
for sub in DirAccess.get_directories_at(d):
dirs.append(d + "/" + sub)
return problems
+1
View File
@@ -0,0 +1 @@
uid://dgsu2kiaf42og
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://probe/ui_contrast_probe.gd" id="1"]
[node name="UIContrastProbe" type="Node"]
script = ExtResource("1")
+739
View File
@@ -0,0 +1,739 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# push_input 端到端输入探针(场景结构登记项转义务 + Node2D 呈现层 Risks 缓解面)。
# 现役探针全走 BoardView.click_cell 直调(合成鼠标事件零命中已实证)——
# 本探针是真实输入路径的唯一自动化覆盖:鼠标事件 → 壳层 _gui_input → 整图公式换算(_cell_at)→
# 悬停/点选/chord 既有出口 → 组合根信号。四 case:①悬停可挖域(金框+十字光标)②悬停禁入
# (hover_forbidden 信号+灰禁红框)③悬停已开格(路径预览点迹+移动光标)④右键封印(seal_place
# 到达组合根+核心封印态)⑤双击 chord(禁入格双左击,核心拒挖零副作用)⑥左键挖(dig 到达组合根+核心揭格)
# ⑦英雄精灵世界落位(回归闸——SubViewport 世界坐标判据,素材缺位树跳过)
# ⑧影子构成与贴脚 ⑨右移朝向+到位回站帧+缩放档 ⑩变格边长即时重排 ⑪贴图缺位回退字形
#(⑧–⑪=Node2D 展示几何/状态面单元化断言)。
# 事件路径:Viewport.push_input(ev, in_local_coords=true)——落点用 BoardView.cell_center_local
# (与 _cell_at 同一公式族)换算画布坐标,SubViewportContainer mouse_filter=IGNORE 不拦截。
# 运行:Godot_console.exe --headless --path . res://probe/ui_input_probe.tscn
extends Node
const ProbeUiUtil := preload("res://probe/probe_ui_util.gd") # T6 收口:隔离样板单点
var main: Node
var _problems: Array = []
var _actions: Array = [] # action_requested 收集([cell, kind])
var _chords: Array = [] # chord_attempted 收集
var _forbids: Array = [] # hover_forbidden 收集
func _ready() -> void:
await get_tree().process_frame # autoload 就绪
main = load("res://presentation/main.tscn").instantiate()
add_child(main)
await get_tree().process_frame
# 存档隔离(断点写盘面随冒险挂 main.save_manager;探针禁碰真实档 user://save.json,
# 同 ui_click_probe 隔离纪律。首次冒险布尔位置位=对齐真实档游玩史口径,模板抽签路径不变;
# isolate_save 先删后建——上次运行的残留断点会触发互斥确认早退)
ProbeUiUtil.isolate_save(main, "smoke_input_probe_save", true, false, true)
main.debug_overlay.seed_edit.text = "42" # 种子钉死(种子框默认为空=非首局随机,探针确定性禁依赖默认分支)
main._start_adventure_from_menu()
await get_tree().process_frame
# ---- ⑫ 图上命中真实输入(回归闸):入口停点候选节点中心
# push_input 真实鼠标路径 motion+click → 悬停预承诺置位 → route_chosen 到达组合根。
# 由来=真机 bug:深层节点(L1 Boss 内容 x=802)在固定画布命中区外,press_candidate 直调
# 命中函数探针全绿、真鼠标点不中——本 case 封住输入管线,⑬封住几何拟合。
if main.level_screen.visible:
var ro = main.level_screen
var cands: Array = main.run_scope.runner.next_candidate_ids()
# ---- ⑱ 信息迷雾呈现接线(回归闸):真机 display_probe 读实际绘制记录(雾下节点类型未画、
# Boss 描边画了、候选类型画了)+点击雾下非候选节点零提交(雾不扩大可点域——候选集即合法集)----
var disp: Dictionary = ro.display_probe()
var vis: Array = main.run_scope.runner.type_visible_ids()
var cur_id: int = main.run_scope.runner.current_node_id()
var fog_id := -1
var boss_id := -1
var nds_vis: Array = main.run_scope.runner.nodes()
for n in nds_vis:
var vnid: int = main.run_scope.runner.node_id_of(n)
if String(n.get("type", "")) == "boss":
boss_id = vnid
elif fog_id < 0 and vnid != cur_id and not cands.has(vnid) and not vis.has(vnid):
# 选区只按核心可见集判(不读绘制记录——选区与断言解耦,防「按记录选、再断言记录」自指)
fog_id = vnid
if boss_id < 0 or fog_id < 0 or not disp.has(fog_id) or not disp.has(boss_id) \
or not disp.has(int(cands[0])):
_pre_fail("case⑱ 雾面节点选取失败或绘制记录缺位(boss=%d fog=%d 记录数=%d)"
% [boss_id, fog_id, disp.size()])
return
if bool(disp[fog_id]["draw_icon"]) or bool(disp[fog_id]["draw_label"]):
_pre_fail("case⑱ 雾下节点类型仍出示(图标=%s 文字=%s——范围外只见结构)〔fv2〕"
% [str(disp[fog_id]["draw_icon"]), str(disp[fog_id]["draw_label"])])
return
if not bool(disp[boss_id]["boss_border"]):
_pre_fail("case⑱ Boss 节点未带描边标记(id=%d)〔fv3〕" % boss_id)
return
if not bool(disp[int(cands[0])]["draw_icon"]):
_pre_fail("case⑱ 候选节点类型未出示(id=%d——下一跳恒可见)〔fv4〕" % int(cands[0]))
return
var fog_node := {}
for n in nds_vis:
if main.run_scope.runner.node_id_of(n) == fog_id:
fog_node = n
var fog_pos: Vector2 = ro.canvas_probe()["origin"] + ro.layout_probe(nds_vis)[fog_node]
# 零提交判据直接绑 route_chosen 发射:终态会被雾下节点类型(奖励/恢复/商店保持本屏、
# 战斗/Boss 出屏)与运行器候选校验兜底,单看终态测不出命中面穿透——信号面才是提交面
var submitted: Array = []
var on_route := func(nid: int) -> void: submitted.append(nid)
ro.route_chosen.connect(on_route)
var evf := InputEventMouseButton.new()
evf.button_index = MOUSE_BUTTON_LEFT
evf.pressed = true
evf.position = fog_pos
evf.global_position = fog_pos
main.get_viewport().push_input(evf, true)
await get_tree().process_frame
await get_tree().process_frame
ro.route_chosen.disconnect(on_route)
if not submitted.is_empty() or not main.level_screen.visible or not main.run_scope.runner.awaiting_choice():
_pre_fail("case⑱ 雾下节点点击产生提交(提交=%s visible=%s 选路态=%s——雾不扩大可点域)〔fv9〕"
% [str(submitted), str(main.level_screen.visible), str(main.run_scope.runner.awaiting_choice())])
return
var nds: Array = main.run_scope.runner.nodes()
var tgt := {}
for n in nds:
if main.run_scope.runner.node_id_of(n) == int(cands[0]):
tgt = n
var npos: Vector2 = ro.layout_probe(nds)[tgt]
var gpos: Vector2 = ro.canvas_probe()["origin"] + npos
var evm := InputEventMouseMotion.new()
evm.position = gpos
evm.global_position = gpos
main.get_viewport().push_input(evm, true)
await get_tree().process_frame
if int(ro._hover_id) != int(cands[0]):
_pre_fail("图上悬停预承诺未置位(hover=%s 候选=%s)" % [str(ro._hover_id), str(cands[0])])
var evc := InputEventMouseButton.new()
evc.button_index = MOUSE_BUTTON_LEFT
evc.pressed = true
evc.position = gpos
evc.global_position = gpos
main.get_viewport().push_input(evc, true)
await get_tree().process_frame
await get_tree().process_frame
if main.level_screen.visible or main.run_scope.runner.awaiting_choice():
_pre_fail("图上真实点击未提交选路(overlay 可见=%s 选路态=%s)" % [str(main.level_screen.visible), str(main.run_scope.runner.awaiting_choice())])
for i in range(3):
await get_tree().process_frame
# ---- ⑬ 画布随图拟合(同 bug 的几何面回归):复刻分层公式独立推导最深节点内容 x,
# 断言画布 min 尺寸覆盖之(open_choices 每 stop 拟合——L1 单链 Boss x=70+6*122=802 曾越界)
var ro2 = main.level_screen
var nds2: Array = main.run_scope.runner.nodes()
var layer2 := {}
var inbound2 := {}
for n2 in nds2:
for tid2 in main.run_scope.runner.node_out_ids(n2):
inbound2[tid2] = true
var queue2: Array = []
for n2 in nds2:
if not inbound2.has(main.run_scope.runner.node_id_of(n2)):
queue2.append(n2)
layer2[n2] = 0
while not queue2.is_empty():
var cur2: Dictionary = queue2.pop_front()
for tid2 in main.run_scope.runner.node_out_ids(cur2):
var tg2 := {}
for n2 in nds2:
if main.run_scope.runner.node_id_of(n2) == tid2:
tg2 = n2
if tg2.is_empty() or layer2.has(tg2):
continue
layer2[tg2] = int(layer2[cur2]) + 1
queue2.append(tg2)
var maxl2 := 0
for k2 in layer2.keys():
maxl2 = maxi(maxl2, int(layer2[k2]))
# 常数锚(画布拟合探针):直接引屏内导出常量(LAYER_BASE_X/LAYER_STEP_X/NODE_HALF)——
# 与 _content_size 公式(LAYER_BASE_X + maxl*LAYER_STEP_X + NODE_HALF.x + 8)逐项同源,禁再手抄数字
var need_x2: float = LevelMapScreen.LAYER_BASE_X + maxl2 * LevelMapScreen.LAYER_STEP_X \
+ LevelMapScreen.NODE_HALF.x + 8.0
var cvmin: Dictionary = ro2.canvas_probe()
if float(cvmin["min_size"].x) < need_x2 - 0.5:
_pre_fail("画布拟合不足(min.x=%.0f < 最深内容 %.0f——深层节点不可点回归)" % [cvmin["min_size"].x, need_x2])
var bv = main.board_view
var ms = main.run_scope.settlement.minesweeper if main.run_scope.settlement != null else null
if ms == null or bv.cells_probe().is_empty():
_pre_fail("战斗未就绪(cells=%d settlement=%s)" % [bv.cells_probe().size(), str(main.run_scope.settlement)])
return
bv.action_requested.connect(func(c: Vector2i, kind: String): _actions.append([c, kind]))
bv.chord_attempted.connect(func(c: Vector2i): _chords.append(c))
bv.hover_forbidden.connect(func(c: Vector2i): _forbids.append(c))
# ---- 先行移动(真实点击路径):R₀ 展开后主角邻域全已开(_diggable 空)——点击前沿已开格
# 迫近未开边界(与 ui_click_probe 行为同构:先 move 后 dig)----
var hero0: Vector2i = ms.hero_pos()
var mv := Vector2i(-9, -9)
for c in bv._board.neighbors8(hero0):
if ms.is_revealed(c) and _has_hidden_neighbor(bv, ms, c):
mv = c
break
if mv.x == -9:
mv = _pick_frontier(bv, ms)
if mv.x == -9:
_pre_fail("前沿已开格选取失败(板面无可迫近边界)")
return
_push_click(_pos(bv, mv), MOUSE_BUTTON_LEFT)
await get_tree().process_frame
await get_tree().process_frame
if not _has_action(mv, "move") or ms.hero_pos() == hero0:
_pre_fail("先导移动未到达(actions=%s hero=%s→%s)" % [str(_actions), hero0, ms.hero_pos()])
return
# ---- 目标格实证选取(不写死坐标;两枚相邻可挖格 + 禁入格 + 已开格)----
# 首战板面种子随全局序漂移——_diggable(主角 8 邻未开域)
# 不足两枚时,沿已开区真点击移位到「≥2 未开邻」的前沿格再选;移位失败即收手交由选取失败分支报状态
for _expand in range(16):
if bv.diggable_probe().size() >= 2:
break
var mv_t := Vector2i(-9, -9)
for y in bv._board.height:
for x in bv._board.width:
var c := Vector2i(x, y)
if not ms.is_revealed(c):
continue
var hidden := 0
for n in bv._board.neighbors8(c):
if not ms.is_revealed(n):
hidden += 1
if hidden >= 2:
mv_t = c
break
if mv_t.x != -9:
break
if mv_t.x == -9:
break
var hero_before: Vector2i = ms.hero_pos()
_push_click(_pos(bv, mv_t), MOUSE_BUTTON_LEFT)
await get_tree().process_frame
await get_tree().process_frame
if ms.hero_pos() == hero_before:
break
var seal_t := Vector2i(-9, -9)
var dig_t := Vector2i(-9, -9)
for c in bv.diggable_probe():
if not ms.has_seal(c):
if seal_t.x == -9:
seal_t = c
elif dig_t.x == -9:
dig_t = c
break
var forbid_t := _pick_forbidden(bv, ms)
var reveal_t := _pick_revealed(ms)
if seal_t.x == -9 or dig_t.x == -9 or forbid_t.x == -9 or reveal_t.x == -9:
_pre_fail("目标格选取失败 seal=%s dig=%s forbid=%s reveal=%s" % [seal_t, dig_t, forbid_t, reveal_t])
return
# ---- case① 悬停可挖域:motion → 悬停态到达(金框 + 十字光标)----
_push_motion(_pos(bv, seal_t))
await get_tree().process_frame
if bv.hover_probe() != seal_t:
_problems.append("case① 悬停未到达(_hover=%s 应=%s)" % [bv.hover_probe(), seal_t])
var u1 = bv.cells_probe()[seal_t]
if not u1.hl_visible or u1.hl_color != UiKit.GOLD:
_problems.append("case① 悬停金框缺失(visible=%s color=%s)" % [str(u1.hl_visible), str(u1.hl_color)])
if bv.mouse_default_cursor_shape != Input.CURSOR_CROSS:
_problems.append("case① 挖掘光标未切十字(%d)" % bv.mouse_default_cursor_shape)
# ---- case② 悬停禁入:hover_forbidden 信号 + 灰禁红框 ----
_push_motion(_pos(bv, forbid_t))
await get_tree().process_frame
if _forbids.is_empty() or _forbids[_forbids.size() - 1] != forbid_t:
_problems.append("case② hover_forbidden 未达(%s)" % [str(_forbids)])
if bv.cells_probe()[forbid_t].hl_color != UiKit.RED_FORBID:
_problems.append("case② 禁入红框缺失(color=%s)" % str(bv.cells_probe()[forbid_t].hl_color))
# ---- case③ 悬停已开格:路径预览点迹 + 移动光标 ----
_push_motion(_pos(bv, reveal_t))
await get_tree().process_frame
var dots := 0
for c in bv.cells_probe():
if bv.cells_probe()[c].dot_visible:
dots += 1
if dots < 1:
_problems.append("case③ 路径预览点迹未亮(dots=0)")
if bv.mouse_default_cursor_shape != Input.CURSOR_ARROW:
_problems.append("case③ 移动光标缺失(%d)" % bv.mouse_default_cursor_shape)
# ---- case④ 右键封印:seal_place 到达组合根 + 核心封印态落位 ----
_push_click(_pos(bv, seal_t), MOUSE_BUTTON_RIGHT)
await get_tree().process_frame
if not _has_action(seal_t, "seal_place"):
_problems.append("case④ seal_place 未达组合根(actions=%s)" % [str(_actions)])
await get_tree().process_frame
if not ms.has_seal(seal_t):
_problems.append("case④ 核心封印态未落位")
# ---- case④b 封印纹理槽回归断言(P0-1):paint 尾部若无条件
# `u.tex_seal = null` 会覆盖条件赋值=素材在位时封印不可见(headless 缺盘走字形回退
# 掩盖)——有盘断言纹理在槽、缺盘断言字形回退,两分支防双写踩踏复活 ----
var seal_unit = bv.cells_probe().get(seal_t)
if bv._seal_tex != null:
if seal_unit == null or seal_unit.tex_seal != bv._seal_tex:
_problems.append("case④b 封印纹理槽未挂封印印记纹理(tex_seal=%s)" % ["无单元" if seal_unit == null else str(seal_unit.tex_seal)])
elif seal_unit == null or seal_unit.glyph != "封":
_problems.append("case④b 缺盘字形回退缺失(glyph=%s)" % ["无单元" if seal_unit == null else String(seal_unit.glyph)])
# ---- case⑤ 双击 chord:禁入格双左击(核心拒挖零副作用)→ chord_attempted ----
_push_click(_pos(bv, forbid_t), MOUSE_BUTTON_LEFT)
_push_click(_pos(bv, forbid_t), MOUSE_BUTTON_LEFT)
await get_tree().process_frame
if _chords.is_empty() or _chords[_chords.size() - 1] != forbid_t:
_problems.append("case⑤ chord_attempted 未达(chords=%s)" % [str(_chords)])
# ---- case⑥ 左键挖:dig 到达组合根 + 核心揭格落位 ----
_push_click(_pos(bv, dig_t), MOUSE_BUTTON_LEFT)
await get_tree().process_frame
if not _has_action(dig_t, "dig"):
_problems.append("case⑥ dig 未达组合根(actions=%s)" % [str(_actions)])
await get_tree().process_frame
if not ms.is_revealed(dig_t):
_problems.append("case⑥ 核心揭格未落位(目标=%s)" % dig_t)
# ---- case⑦ 英雄精灵世界落位(回归闸):精灵在 SubViewport 世界内,
# 落位 = 格×步距 + 脚底锚(横=格中线/纵=格底往上 25% 线=HERO_GROUND_FRAC 自底边口径——「脚踩
# 格子 1/4 处·从下往上数」;禁带壳层 _svp_container.position 居中偏移——上批误用壳层
# cell_origin 实测右偏 ~7 格+偏离格心,实机截图用户报缺陷)。素材缺位树(hero==null,字形
# 「人」回退在役)跳过;断言前等 0.3s 墙钟——headless 帧率无上限,帧数等待 < 位移 Tween
# 140ms 时会采到半途位置(首跑实证)。 ----
if bv._world.hero != null:
await get_tree().create_timer(0.3).timeout
var expect: Vector2 = Vector2(ms.hero_pos()) * (bv.cell_size + float(bv.SEP)) \
+ Vector2(bv.cell_size * 0.5, bv.cell_size * (1.0 - bv.HERO_GROUND_FRAC))
if bv._world.hero.position.distance_to(expect) > 0.5:
_problems.append("case⑦ 英雄精灵世界落位偏移 pos=%s expect=%s" % [str(bv._world.hero.position), str(expect)])
# ---- case⑧ 影子构成与贴脚(回归闸):椭圆影=英雄子件、中心贴脚锚
#((0,2) 下坠脱脚=用户报缺陷)、z 相对回落格面之上英雄之下、黑色半透明、渐变纹理组件
# 合成;父子 use_parent_material 双挂(结算期随世界去饱和与格面同口径)。 ----
if bv._world.hero != null:
var kids: Array = bv._world.hero.get_children()
if kids.size() != 1 or not (kids[0] is Sprite2D):
_problems.append("case⑧ 影子子件缺失或类型不对(children=%d)" % kids.size())
else:
var sh: Sprite2D = kids[0]
if sh.position != Vector2.ZERO:
_problems.append("case⑧ 影子未贴脚(pos=%s 应=ZERO)" % str(sh.position))
if sh.z_index != -1:
_problems.append("case⑧ 影子 z 相对档不对(%d 应=-1)" % sh.z_index)
if absf(sh.modulate.a - 0.35) > 0.01:
_problems.append("case⑧ 影子透明度漂移(%s 应=0.35)" % str(sh.modulate.a))
if not (sh.texture is GradientTexture2D):
_problems.append("case⑧ 影子纹理非径向渐变组件(%s)" % str(sh.texture))
if not sh.use_parent_material or not bv._world.hero.use_parent_material:
_problems.append("case⑧ 去饱和材质链漏挂(shadow=%s hero=%s)" % [str(sh.use_parent_material), str(bv._world.hero.use_parent_material)])
# ---- case⑨ 右移朝向+到位回站帧+缩放档(回归闸):素材基准朝左——向右移动 flip_h=true;
# 到位(0.3s 墙钟 > 位移 Tween 140ms)纹理回站帧、scale 恒 1.5 均匀档。无可达右邻已开格跳过。 ----
if bv._world.hero != null:
var hp9: Vector2i = ms.hero_pos()
var mv9 := Vector2i(-9, -9)
for n in bv._board.neighbors8(hp9):
if n.x == hp9.x + 1 and ms.is_revealed(n):
mv9 = n
break
if mv9.x == -9:
print("[ui_input_probe] SKIP case⑨(无可达右邻已开格)")
else:
_push_click(_pos(bv, mv9), MOUSE_BUTTON_LEFT)
await get_tree().create_timer(0.3).timeout
var sp9: Sprite2D = bv._world.hero
if not sp9.flip_h:
_problems.append("case⑨ 右移后未翻转朝向(flip_h=false,素材基准朝左)")
if sp9.texture != bv._hero_tex_idle:
_problems.append("case⑨ 到位未回站帧(texture=%s)" % str(sp9.texture))
if sp9.scale != Vector2.ONE * bv.HERO_SPRITE_SCALE:
_problems.append("case⑨ 缩放档漂移(%s 应=%s 均匀)" % [str(sp9.scale), str(bv.HERO_SPRITE_SCALE)])
# ---- case⑩ 变格边长即时重排(窗口缩放路径回归闸):set_cell_area 换小格 → 英雄即时落新脚锚、
# 缩放均匀不残弹跳形;还原 → 46px 档复验。 ----
if bv._world.hero != null:
var hp10: Vector2i = ms.hero_pos()
bv.set_cell_area(Vector2(300, 300))
var expect10: Vector2 = Vector2(hp10) * (bv.cell_size + float(bv.SEP)) \
+ Vector2(bv.cell_size * 0.5, bv.cell_size * (1.0 - bv.HERO_GROUND_FRAC))
if bv._world.hero.position.distance_to(expect10) > 0.5 or bv._world.hero.scale != Vector2.ONE * bv.HERO_SPRITE_SCALE:
_problems.append("case⑩ 缩格后英雄未即时重排(pos=%s expect=%s scale=%s)" % [str(bv._world.hero.position), str(expect10), str(bv._world.hero.scale)])
bv.set_cell_area(bv.size)
var expect10b: Vector2 = Vector2(hp10) * (bv.cell_size + float(bv.SEP)) \
+ Vector2(bv.cell_size * 0.5, bv.cell_size * (1.0 - bv.HERO_GROUND_FRAC))
if bv._world.hero.position.distance_to(expect10b) > 0.5:
_problems.append("case⑩ 还原后英雄未复位(pos=%s expect=%s)" % [str(bv._world.hero.position), str(expect10b)])
# ---- case⑪ 贴图缺位回退(字形「人」回归闸):texture 置 null + refresh → 英雄格数字 0 时字形
# 必须回退「人」(数字格仍显数字——回退判据与抑制判据同源);验毕还原纹理重绘。 ----
if bv._world.hero != null:
var hp11: Vector2i = ms.hero_pos()
var u11 = bv.cells_probe()[hp11]
var tex_saved: Texture2D = bv._hero_tex_idle
bv._hero_tex_idle = null
bv.refresh()
var digit11: int = int(ms.reveal_record(hp11).get("digit", 0))
if digit11 == 0:
if u11.glyph != "人":
_problems.append("case⑪ 贴图缺位未回退字形「人」(glyph=%s)" % u11.glyph)
elif u11.glyph != str(digit11):
_problems.append("case⑪ 缺位态数字格字形被破坏(glyph=%s 应=%d)" % [u11.glyph, digit11])
bv._hero_tex_idle = tex_saved
bv.refresh()
# ---- case⑭ 变异角标(flat_damage 未开格视觉标记;resolver 注入面+落位+复位) ----
var mark_t: Vector2i = _pick_forbidden(bv, ms) # 任一未开格(禁入=未开非相邻)
if mark_t.x >= 0:
bv.set_variant_mark_resolver(func(_c): return "flat_damage")
bv.refresh()
if String(bv.cells_probe()[mark_t].mutation_mark) != "flat_damage":
_problems.append("case⑭ 变异角标未落位(mutation_mark=%s)" % str(bv.cells_probe()[mark_t].mutation_mark))
bv.set_variant_mark_resolver(Callable())
bv.refresh()
if String(bv.cells_probe()[mark_t].mutation_mark) != "":
_problems.append("case⑭ 角标未随 resolver 复位(mutation_mark=%s)" % str(bv.cells_probe()[mark_t].mutation_mark))
else:
_problems.append("case⑭ 无未开格可验(板面异常)")
# ---- case⑮ 视野迷雾(三态呈态/半径圈/移动跟随/开关两态零扰动/提灯插值位;
# 纯呈现零逻辑——迷雾开关切换前后 minesweeper 状态摘要逐位一致) ----
var fogp: Dictionary = bv.fog_params_probe()
if int(bv.FOG_RADIUS) != 8 or int(fogp["lantern"]) != 0 or not fogp["enabled"]:
_problems.append("case⑮ 迷雾参数漂移(R=%s lantern=%s enabled=%s 应=8/0/true——旧提灯常量随行退役摘除)"
% [str(bv.FOG_RADIUS), str(fogp["lantern"]), str(fogp["enabled"])])
else:
var hero15: Vector2i = ms.hero_pos()
var b15 = ms.current_board()
var fog_ok := true
var cells_f1: Dictionary = bv.cells_probe()
for y in b15.height:
for x in b15.width:
var c15 := Vector2i(x, y)
var d15: int = absi(c15.x - hero15.x) + absi(c15.y - hero15.y)
var expect15: int = 0
if d15 > bv.FOG_RADIUS:
expect15 = 2 if ms.is_revealed(c15) else 1
var got15: int = int(cells_f1[c15].fog_state)
if got15 != expect15:
fog_ok = false
_problems.append("case⑮ 迷雾呈态不符(%s dist=%d got=%d expect=%d)" % [str(c15), d15, got15, expect15])
break
if not fog_ok:
break
# 半径边界格恰在圈内(曼哈顿距离恰 =R 的格 fog_state=0——存在性校验,12×12 板必有)
var has_boundary := false
var cells_f2: Dictionary = bv.cells_probe()
for y in b15.height:
for x in b15.width:
if absi(x - hero15.x) + absi(y - hero15.y) == int(bv.FOG_RADIUS):
has_boundary = true
if int(cells_f2[Vector2i(x, y)].fog_state) != 0:
_problems.append("case⑮ 边界圈内格被误遮(dist==R 应 fog_state=0)")
break
if has_boundary:
break
if not has_boundary:
_problems.append("case⑮ 板面无 dist==R 格(板面异常)")
# 移动跟随:把英雄挪到板角(move 动作逐格逼近成本高——直接白-box 校验公式换心后远格态翻转:
# 取一个对当前英雄 dist>R 的已开格(记忆态),以远角为新心重算应翻转/保持一致性由公式承载,
# 此处只验 set_fog_enabled(false) 全板归零与 true 恢复——开关两态契约主断言)
var digest_before: Array = []
for y in b15.height:
for x in b15.width:
var c16 := Vector2i(x, y)
digest_before.append(ms.is_revealed(c16))
bv.set_fog_enabled(false)
var all_clear := true
var cells_f3: Dictionary = bv.cells_probe()
for y in b15.height:
for x in b15.width:
if int(cells_f3[Vector2i(x, y)].fog_state) != 0:
all_clear = false
if not all_clear:
_problems.append("case⑮ 迷雾关未全板归零(off 态应 fog_state=0)")
var digest_off: Array = []
for y in b15.height:
for x in b15.width:
var c17 := Vector2i(x, y)
digest_off.append(ms.is_revealed(c17))
if digest_off != digest_before:
_problems.append("case⑮ 迷雾开关改写逻辑态(两态结算逐位一致 🔴 契约破)")
bv.set_fog_enabled(true)
if int(bv.cells_probe()[Vector2i(0, 0)].fog_state) == 0 \
and absi(0 - hero15.x) + absi(0 - hero15.y) > int(bv.FOG_RADIUS):
_problems.append("case⑮ 迷雾复开后远角未复原遮蔽(fog_state=0)")
# 视野加成接线断言(主语=阔视野带:参数注入 set_fog_glow_bonus(4) → 半径 8+4:
# dist==R+1 格由雾态转可见,卸装复原 bonus=0;纯接线零逻辑——半径算式归迷雾断言面)
var ring_cell := Vector2i(-99, -99)
for y in b15.height:
for x in b15.width:
if absi(x - hero15.x) + absi(y - hero15.y) == int(bv.FOG_RADIUS) + 1:
ring_cell = Vector2i(x, y)
break
if ring_cell.x != -99:
break
if ring_cell.x == -99:
_problems.append("case⑮ 板面无 dist==R+1 格(视野加成断言取数异常)")
else:
var pre_lantern: int = int(bv.cells_probe()[ring_cell].fog_state)
bv.set_fog_glow_bonus(4)
if int(bv.fog_params_probe()["lantern"]) != 4:
_problems.append("case⑮ 视野加成接雾参数未变(lantern 应=4)")
if int(bv.cells_probe()[ring_cell].fog_state) != 0:
_problems.append("case⑮ 视野 +4 后 dist==R+1 格仍被遮(应转可见 fog_state=0,改前=%d)" % pre_lantern)
bv.set_fog_glow_bonus(0)
if int(bv.fog_params_probe()["lantern"]) != 0:
_problems.append("case⑮ 视野加成清零后雾参数未复原(lantern 应=0)")
if int(bv.cells_probe()[ring_cell].fog_state) == 0:
_problems.append("case⑮ 视野加成清零后 dist==R+1 格未复原遮蔽")
# 装配链级断言(wide_scout_band 装配链贯通:①装配→开局参数面
# 〔build_adventure_params 统一构造〕②装配集→呈现桥〔_sync_treasure_glow 消费
# attune_param_sum 只读出口→set_fog_glow_bonus〕→视野 +4 生效与卸装复原。
# settlement.attunement 手动注入=模拟 begin_battle enc 注入后的运行态〔注入面本体归
# 冒烟+单测覆盖〕;不重启冒险——case⑯ 起既有板面继续用)
main.save_manager.set_attunement_equipped(["wide_scout_band"])
var p_lantern: Dictionary = main.build_adventure_params(main.save_manager, main.meta)
if not (p_lantern.get("attunement", []) as Array).has("wide_scout_band"):
_problems.append("case⑮ 装配未进开局参数面(attunement=%s 应含 wide_scout_band)" % str(p_lantern.get("attunement", [])))
main.run_scope.settlement.attunement = ["wide_scout_band"]
main._sync_treasure_glow()
if int(bv.fog_params_probe()["lantern"]) != 4:
_problems.append("case⑮ 装配后视野未扩(lantern 应=4——呈现桥未生效)")
if int(bv.cells_probe()[ring_cell].fog_state) != 0:
_problems.append("case⑮ 装配后 dist==R+1 格仍被遮(应转可见)")
main.run_scope.settlement.attunement = []
main._sync_treasure_glow()
if int(bv.fog_params_probe()["lantern"]) != 0:
_problems.append("case⑮ 卸装后视野未复原(lantern 应=0——契约复原语义显式重算)")
if int(bv.cells_probe()[ring_cell].fog_state) == 0:
_problems.append("case⑮ 卸装后 dist==R+1 格未复原遮蔽")
main.save_manager.set_attunement_equipped([])
# ---- case⑯ 状态面板输入锁语义(R3——开层锁棋盘、关层恢复无残留;真实 push_input
# 路径:入口角标本体 press+release 真点击 → 状态面板开;锁期挖掘点击零穿透;「继续」关层后
# 同格挖掘重新到达——锁语义与既有模态同款,无状态残留。置于全 case 之尾:取格若需前沿移位
#(真实点击 move 扩大可挖域),其板面扰动不再波及任何既有断言——域恰 2 枚时 seal_t 带封印+
# dig_t 揭开后域内零无封印格,正是首跑实测踩中的形态)----
var status_dig := Vector2i(-9, -9)
for c in bv.diggable_probe():
if not ms.has_seal(c):
status_dig = c
break
if status_dig.x == -9:
var mv16 := _pick_frontier(bv, ms)
if mv16.x == -9:
_problems.append("case⑯ 板面无可迫近边界(锁断言取格中断)")
else:
_push_click(_pos(bv, mv16), MOUSE_BUTTON_LEFT)
await get_tree().process_frame
await get_tree().process_frame
for c in bv.diggable_probe():
if not ms.has_seal(c):
status_dig = c
break
if status_dig.x == -9:
_problems.append("case⑯ 无可挖无封印格(板面异常——锁断言中断)")
else:
var pbtn16: Button = main.board_zone.status_badge
var bpos: Vector2 = pbtn16.get_global_rect().get_center()
_push_click(bpos, MOUSE_BUTTON_LEFT)
_push_release(bpos, MOUSE_BUTTON_LEFT) # Button pressed 信号走 release 沿——按压+释放成对
await get_tree().process_frame
if not main.status_panel.visible or not main._input_locked():
_problems.append("case⑯ 角标真点击未开状态面板(visible=%s locked=%s——入口可达性失守)"
% [str(main.status_panel.visible), str(main._input_locked())])
else:
_push_click(_pos(bv, status_dig), MOUSE_BUTTON_LEFT)
await get_tree().process_frame
await get_tree().process_frame
if _has_action(status_dig, "dig") or ms.is_revealed(status_dig):
_problems.append("case⑯ 状态面板开启时挖掘穿透(R3 输入锁破——目标=%s)" % status_dig)
var resume_btn: Button = ProbeUiUtil.find_button(main.status_panel, "继续", "prefix")
if resume_btn == null:
_problems.append("case⑯ 状态面板关闭钮缺失")
else:
resume_btn.pressed.emit()
await get_tree().process_frame
if main.status_panel.visible or main._input_locked():
_problems.append("case⑯ 关闭后未恢复(visible=%s locked=%s——R3 残留)"
% [str(main.status_panel.visible), str(main._input_locked())])
_push_click(_pos(bv, status_dig), MOUSE_BUTTON_LEFT)
await get_tree().process_frame
await get_tree().process_frame
if not _has_action(status_dig, "dig") or not ms.is_revealed(status_dig):
_problems.append("case⑯ 关闭后挖掘未到达(恢复无残留验证失守)")
# ---- case⑰ 未开格类型微光(活板面走查:作用集恒等/开格灭/移动复亮
# 与出圈灭/相位门控/开关两态零扰动。与冒烟段数据面断言分工:此处走真实点击路径) ----
if not bool(bv.type_glow_probe().get("enabled", false)):
_problems.append("case⑰ 微光默认态非开(生产默认开——Q4 丙案)")
if not (bv.type_glow_probe().get("errors", []) as Array).is_empty():
_problems.append("case⑰ 活板清单外登记非空(errors=%s——生产数据应全集合法)" % str(bv.type_glow_probe().get("errors", [])))
if bv.diggable_probe().is_empty():
var mv17a := _pick_frontier(bv, ms)
if mv17a.x == -9:
_problems.append("case⑰ 板面无 diggable 且无前沿可迫近(活板面前提异常)")
else:
_push_click(_pos(bv, mv17a), MOUSE_BUTTON_LEFT)
await get_tree().process_frame
await get_tree().process_frame
var digp17: Dictionary = bv.diggable_probe()
if digp17.is_empty():
_problems.append("case⑰ 板面仍无 diggable(活板面前提异常)")
else:
# ① 作用集恒等:cells 键集 == diggable 键集(生产身份回调面——未开格恒可查,F4)
var keys17: Array = (bv.type_glow_probe().get("cells", {}) as Dictionary).keys()
var eq17 := keys17.size() == digp17.size()
if eq17:
for k in keys17:
if not digp17.has(k):
eq17 = false
break
if not eq17:
_problems.append("case⑰ 作用集与可挖域不等(cells=%d diggable=%d)" % [keys17.size(), digp17.size()])
# ② 开格灭:真实点击挖开一格 → 该格退出作用集(S3 生命周期)
var dig17 := Vector2i(-9, -9)
for c in digp17:
if not ms.has_seal(c):
dig17 = c
break
if dig17.x == -9:
_problems.append("case⑰ 无无封印可挖格(板面异常)")
else:
if not (bv.type_glow_probe().get("cells", {}) as Dictionary).has(dig17):
_problems.append("case⑰ 待挖格不在作用集(%s)" % str(dig17))
_push_click(_pos(bv, dig17), MOUSE_BUTTON_LEFT)
await get_tree().process_frame
await get_tree().process_frame
if not ms.is_revealed(dig17):
_problems.append("case⑰ 真实点击未挖开目标格(%s)" % str(dig17))
if (bv.type_glow_probe().get("cells", {}) as Dictionary).has(dig17):
_problems.append("case⑰ 开格未灭(%s 仍在作用集——S3 生命周期破)" % str(dig17))
# ③ 移动复亮/出圈灭:点击前沿已开格移英雄 → 作用集随新 diggable 重算(恒等面承载两者)
var mv17 := _pick_frontier(bv, ms)
if mv17.x == -9:
_problems.append("case⑰ 无前沿可迫近(移动复亮断言取格中断)")
else:
_push_click(_pos(bv, mv17), MOUSE_BUTTON_LEFT)
await get_tree().process_frame
await get_tree().process_frame
var glowp2: Dictionary = bv.type_glow_probe()
var digp18: Dictionary = bv.diggable_probe()
var keys18: Array = (glowp2.get("cells", {}) as Dictionary).keys()
var eq18 := keys18.size() == digp18.size()
if eq18:
for k in keys18:
if not digp18.has(k):
eq18 = false
break
if not eq18:
_problems.append("case⑰ 移动后作用集与新可挖域不等(cells=%d diggable=%d——复亮/出圈灭破)" % [keys18.size(), digp18.size()])
# ④ 相位门控:结算相位(_dig_glow off)作用集空、微光开关读数不变;回挖掘相位逐位复位
var before_phase: Dictionary = (bv.type_glow_probe().get("cells", {}) as Dictionary).duplicate()
bv.set_dig_glow_enabled(false)
var glowp4: Dictionary = bv.type_glow_probe()
if not (glowp4.get("cells", {}) as Dictionary).is_empty():
_problems.append("case⑰ 结算相位未灭(作用集 %d 格——相位门控破)" % (glowp4.get("cells", {}) as Dictionary).size())
if not bool(glowp4.get("enabled", false)):
_problems.append("case⑰ 相位门控误改微光开关态(enabled 应保持 true)")
bv.set_dig_glow_enabled(true)
var glowp5: Dictionary = bv.type_glow_probe()
var after_phase: Dictionary = glowp5.get("cells", {})
var eq_phase := after_phase.size() == before_phase.size()
if eq_phase:
for k in before_phase:
if int(after_phase.get(k, -1)) != int(before_phase[k]):
eq_phase = false
break
if not eq_phase:
_problems.append("case⑰ 回挖掘相位作用集未逐位复位(%d→%d)" % [before_phase.size(), after_phase.size()])
# ⑤ 开关两态零扰动(case⑮ 同款升级到全板 revealed 摘要):off 前后 ms 逻辑态逐位一致
var b17 = ms.current_board()
var digest_before17: Array = []
for y in b17.height:
for x in b17.width:
digest_before17.append(ms.is_revealed(Vector2i(x, y)))
bv.set_type_glow_enabled(false)
var digest_off17: Array = []
for y in b17.height:
for x in b17.width:
digest_off17.append(ms.is_revealed(Vector2i(x, y)))
if digest_off17 != digest_before17:
_problems.append("case⑰ 微光开关改写逻辑态(两态结算逐位一致 🔴 契约破)")
bv.set_type_glow_enabled(true)
if not bool(bv.type_glow_probe().get("enabled", false)):
_problems.append("case⑰ 微光开关未恢复开态")
if _problems.is_empty():
print("[ui_input_probe] PASS — 悬停金框/禁入信号/路径预览/seal_place/chord/dig/英雄落位/影子构成/朝向/缩格重排/缺位回退/变异角标/视野迷雾/状态面板输入锁/未开格类型微光 十八断言全绿(真实 push_input 路径——⑫图上命中⑬画布拟合 2026-09-28 增,⑭变异角标 2026-09-29 增,④b 封印纹理槽回归 2026-10-02 增,⑮视野迷雾 2026-10-03 增〔+提灯接线断言 2026-10-05 迭代14⑤〕,⑯状态面板输入锁 2026-10-06 迭代17卡③增,⑰未开格类型微光 2026-10-17 迭代30卡①增,⑱信息迷雾绘制接线(display_probe 读实际绘制记录)增)")
get_tree().quit(0)
else:
for p in _problems:
push_error("[ui_input_probe] FAIL — " + p)
print("[ui_input_probe] FAIL — " + "; ".join(_problems))
get_tree().quit(1)
func _has_action(cell: Vector2i, kind: String) -> bool:
for a in _actions:
if a[0] == cell and a[1] == kind:
return true
return false
func _pick_forbidden(bv, ms) -> Vector2i:
var b = ms.current_board()
var digp: Dictionary = bv.diggable_probe()
for y in b.height:
for x in b.width:
var c := Vector2i(x, y)
if not ms.is_revealed(c) and not digp.has(c):
return c
return Vector2i(-9, -9)
func _pick_revealed(ms) -> Vector2i:
var b = ms.current_board()
var hero: Vector2i = ms.hero_pos()
for y in b.height:
for x in b.width:
var c := Vector2i(x, y)
if ms.is_revealed(c) and c != hero:
return c
return Vector2i(-9, -9)
# 前沿已开格兜底选取(主角邻域无前沿时全板扫描)
func _pick_frontier(bv, ms) -> Vector2i:
var b = ms.current_board()
for y in b.height:
for x in b.width:
var c := Vector2i(x, y)
if ms.is_revealed(c) and _has_hidden_neighbor(bv, ms, c):
return c
return Vector2i(-9, -9)
func _has_hidden_neighbor(bv, ms, c: Vector2i) -> bool:
for n in bv._board.neighbors8(c):
if not ms.is_revealed(n):
return true
return false
# 合成事件落点 = BoardView 画布原点 + 格中心(cell_center_local 与 _cell_at 同一公式族)
func _pos(bv, c: Vector2i) -> Vector2:
return bv.global_position + bv.cell_center_local(c)
func _push_motion(pos: Vector2) -> void:
var ev := InputEventMouseMotion.new()
ev.position = pos
ev.global_position = pos
main.get_viewport().push_input(ev, true)
func _push_click(pos: Vector2, btn: int) -> void:
var ev := InputEventMouseButton.new()
ev.position = pos
ev.global_position = pos
ev.button_index = btn
ev.pressed = true
main.get_viewport().push_input(ev, true)
# 释放沿(case⑯——BaseButton pressed 信号默认 release 触发:角标真点击须按压+释放成对)
func _push_release(pos: Vector2, btn: int) -> void:
var ev := InputEventMouseButton.new()
ev.position = pos
ev.global_position = pos
ev.button_index = btn
ev.pressed = false
main.get_viewport().push_input(ev, true)
func _pre_fail(why: String) -> void:
push_error("[ui_input_probe] FAIL — " + why)
print("[ui_input_probe] FAIL — " + why)
get_tree().quit(1)
+1
View File
@@ -0,0 +1 @@
uid://6qb420weehdu
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://probe/ui_input_probe.gd" id="1"]
[node name="UIInputProbe" type="Node"]
script = ExtResource("1")
+242
View File
@@ -0,0 +1,242 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# 实现 GDD:design/ux/battle.md(场景级 headless 探针——四区布局实例化断言;entry 17②③)。
# 项目运行模式(autoload 在位)实例化 main.tscn,直接驱动 _start_adventure_from_menu()(完整冒险——唯一试玩入口),
# 断言真实构建(棋盘/主角站桩/敌方站桩/AP 点阵/明细带/数据框;无 A 区进度条断言与 E 区道具面板;
# 英雄/敌人 = 站桩断言)。
# 运行:Godot_console.exe --headless --path . res://probe/ui_probe.tscn
extends Node
const ProbeUiUtil := preload("res://probe/probe_ui_util.gd") # T6 收口:树寻址/隔离样板单点
func _ready() -> void:
await get_tree().process_frame # autoload 就绪
var main: Node = load("res://presentation/main.tscn").instantiate()
add_child(main)
await get_tree().process_frame
var problems: Array = []
# ---- 放弃入口(台账 119):默认隐藏(非冒险模式无冒险可放弃——开始层/调试两层)----
if main.level_screen.abandon_btn == null or main.level_screen.abandon_btn.visible:
print("[ui_probe] FAIL — 放弃按钮默认应隐藏(非冒险模式关)")
get_tree().quit(1)
return
# ---- 营地覆盖层(主菜单入口/开合/余额文案/购买禁用态;
# 隔离存档换注 main.meta 保确定性(禁碰真实档 user://save.json——SaveManager 路径注入纪律;
# 营地先行段只换 meta 不换 save_manager——冒险段换注零档在前)----
ProbeUiUtil.isolate_save(main, "smoke_ui_probe_camp", false, false, false)
if not ProbeUiUtil.open_camp_via_savesel(main, "smoke_ui_probe_camp"):
problems.append("营地入口缺失(选档层新档钮——mn9 新路径驱动中断)")
else:
await get_tree().process_frame
if not main.camp_overlay.visible:
problems.append("营地层未打开(进档开层新路径)")
elif main._start_overlay.visible:
problems.append("营地层打开时开始层未隐藏(规范 2.3 遮挡纪律)")
else:
if not String(main.camp_overlay._balance_label.text).begins_with("钥石"):
problems.append("营地余额文案缺失(%s)" % main.camp_overlay._balance_label.text)
if main.camp_overlay._tree_buy_btns.is_empty():
problems.append("树淬炼柜台购买钮缺失(四柜台重排后树=主界面)")
else:
var btn_by_id := {}
for card2 in main.camp_overlay._tree_buy_btns:
btn_by_id[String(card2.node_id)] = card2.btn
if not btn_by_id.has("sp_hp") or bool(btn_by_id["sp_hp"].disabled):
problems.append("脊柱生命 L1 白板可购钮未启用(无门槛——can_buy 不含余额面语义)")
if not btn_by_id.has("att_socket2") or not bool(btn_by_id["att_socket2"].disabled):
problems.append("同调位 2 前置未亮应禁用(点亮前置语义)")
if main.camp_overlay._workshop_rows.size() != 12:
problems.append("秘宝匣柜台行数错误(%d ≠ 12——迭代 22 秘宝批换代)" % main.camp_overlay._workshop_rows.size())
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("营地关闭后未恢复开始层(开合往返失守)")
# 换回全新零档(营地段购买态不泄入后续冒险段——确定性;先删后建防陈旧档泄入)
ProbeUiUtil.isolate_save(main, "smoke_ui_probe_camp", true, false, true)
main.debug_overlay.seed_edit.text = "42" # 种子钉死(种子框默认为空=非首局随机;本探针 start_single 第五路同吃此框,确定性禁依赖默认分支)
main._start_adventure_from_menu()
await get_tree().process_frame
await get_tree().process_frame
# ---- 入口选路停点(关卡开始即出节点图——level_started 后、首战前弹选路层;
# press_candidate 图上命中路径 pick 入口单候选进首战)----
# ---- 独立性断言(独立屏激活期战斗屏宿主整体隐藏,零透出)----
if not main.level_screen.visible:
problems.append("关卡开始未弹入口选路停点(level_screen 应可见)")
elif main._battle_root.visible:
problems.append("独立屏激活期战斗屏宿主未隐藏(零透出契约失守)")
elif main.run_scope.runner == null or not main.run_scope.runner.awaiting_choice() \
or main.run_scope.runner.next_candidate_ids().size() != 1:
problems.append("入口停点状态失守(选路态 + 入口单候选)")
else:
main.level_screen.press_candidate(0)
await get_tree().process_frame
await get_tree().process_frame
if not main._battle_root.visible or main.level_screen.visible:
problems.append("选路提交战斗节点后未切回战斗屏(屏级互斥恢复失守)")
# ---- 放弃入口·冒险模式可见 + 战斗中按下被拒(真实按钮路径——入口挂选路停点,战斗中拒绝为防御分支)----
if main.level_screen.abandon_btn == null or not main.level_screen.abandon_btn.visible:
problems.append("冒险模式放弃按钮未开放(选路停点入口在案)")
else:
main.level_screen.abandon_btn.pressed.emit()
await get_tree().process_frame
if main.run_scope.adventure == null or String(main.run_scope.adventure.state) != "running":
problems.append("首战中放弃未被拒(战斗域单事务在飞——state 应保持 running)")
# ---- 既有断言(回归基线;A 区无进度条——节点链进度由关卡画面独立屏承载)----
if main.board_view.cells_probe().is_empty():
problems.append("棋盘格未生成(cells=0)")
if main.board_view.board() == null:
problems.append("board 未装载")
if main.info_panel.attack_text().contains("—") or main.info_panel.armor_text().contains("—"):
problems.append("C 区攻击/护甲未显示(%s | %s)" % [main.info_panel.attack_text(), main.info_panel.armor_text()])
if main.run_scope.settlement == null or main.run_scope.settlement.battle_result != "":
problems.append("首战未处于进行中")
if not main.log_view.parsed_text().contains("节点 1/"):
problems.append("节点头日志缺失")
# ---- 四区布局断言(battle.md §Layout)----
if not main.stage.round_label.text.contains("第 1 轮"):
problems.append("B 区轮次·阶段指示缺失(%s)" % main.stage.round_label.text)
if main.stage.enemy_box.get_child_count() < 1:
problems.append("B 区敌方站桩未构建(%d 个)" % main.stage.enemy_box.get_child_count())
if main.ap_bar._dots.get_child_count() != int(main.db.action_economy.ap_per_round):
problems.append("D 区 AP 点阵数量错误(%d)" % main.ap_bar._dots.get_child_count())
if main.ap_bar.refresh_remaining_text() == "":
problems.append("D 区弃图刷新按钮文案缺失")
if main.board_zone.toast == null:
problems.append("D 区 toast 槽缺失")
if main.info_panel.item_slots == null or main.info_panel.item_slots.get_child_count() != 4:
problems.append("C 区秘宝槽(预留)缺失")
if main.stage.hero_standee.slot_row == null:
problems.append("B 区站桩状态行缺失(状态效果唯一展示面——2026-09-26 C 区精简批)")
# B 区明细带挖掘期收起断言(T17 视觉扩展批适配):结算演播期 settling 视觉延伸=mode=settle
# 属新语义(演播放完回 dig——main._is_settling 排播项,判据=纯队列),本断言仅在排播空闲态判定。
if main._row_queue.is_empty() \
and (main.log_view.mode() != "dig" or main.log_view.custom_minimum_size.y != 24.0):
problems.append("B 区明细带挖掘期应收起 24px(mode=%s h=%s)" % [main.log_view.mode(), str(main.log_view.custom_minimum_size.y)])
# E 区无道具面板——四区构建由其余断言覆盖
# ---- 敌方站桩投影(顺位徽标/意图三档动作动画占位——敌人系统×4,全站桩)----
# B 区站桩子件伴随件化(壳层 tooltip 载体)——断言口径见台账 171:
# 伴随件化(壳层 tooltip 载体),穿透明(子件 _name_label/_armor_icon/_intent_badge)改本体
# 探针出口 stage.enemy_body_at(i).name_line_probe()/intent_probe()——呈现口径逐字保持
var standee = main.stage.enemy_body_at(0)
if standee == null:
problems.append("敌立绘本体缺失(enemy_body_at(0) 无世界站桩)")
else:
var name_line: Dictionary = standee.name_line_probe()
if String(name_line.name) == "" or String(name_line.armor_icon) == "":
problems.append("敌立绘名字行缺失(名字或护甲类型图标占位为空——2026-09-26 终裁:AC 不展示、类型图标化)")
# 三档动作动画占位契约(不显示技能名——图标档位 + 一次性动画)
standee.set_pending("damage", "")
var intent_on: Dictionary = standee.intent_probe()
if not bool(intent_on.visible) or String(intent_on.text) != "⚔":
problems.append("攻击系意图未呈现(visible=%s text=%s)" % [str(intent_on.visible), str(intent_on.text)])
standee.set_pending("basic", "")
if bool(standee.intent_probe().visible):
problems.append("普攻应零表示(意图指示应隐藏)")
# 站桩纹理非空断言(八敌真图落位;缺位回退链保持的对照面)
if not bool(standee.portrait_probe().get("has_tex", false)):
problems.append("敌立绘纹理未落位(素材管线 48px 档——迭代 6 T2;缺位回退链不应在此态触发)")
if main.stage.hero_standee == null or not main.stage.hero_standee.has_world_hp_bar:
problems.append("B 区主角站桩缺失(世界血条面缺失)")
elif not bool(main.stage.hero_standee.portrait_probe().get("has_tex", false)):
problems.append("主角立绘纹理未落位(48px 档——迭代 6 T2/T3)")
# ---- BUG-0001 回归(tooltip 入树后 set_effects 槽位不错位——结算软锁修复沉淀)----
var slots_row = main.stage.hero_standee.slot_row # BUG-0001 回归随组件迁入站桩(tooltip/槽位错位同源)
var probe_eff: Array = [{"effect_id": "attack_up", "display_name": "增伤", "layers": 1,
"remaining_settlements": 1, "source_class": "safe_cell", "round_index": 1, "effective_value": 50}]
slots_row.set_effects(probe_eff)
if slots_row._slots.size() != 1:
problems.append("BUG-0001 回归:set_effects 未建槽(%d)" % slots_row._slots.size())
slots_row._on_slot_hover(slots_row._slots[0]) # 悬停 → tooltip _tip 入子节点表(野外崩溃前置态)
slots_row.set_effects(probe_eff) # 修复前:_tip 被按位序当作槽 0 → _fill_slot 崩溃 → 结算协程中断软锁
if not (slots_row._tip in slots_row.get_children()):
problems.append("BUG-0001 回归:悬停后 tooltip 未入树(回归前置失真)")
elif slots_row._slots.size() != 1 or slots_row._slots[0] == slots_row._tip:
problems.append("BUG-0001 回归:tooltip 入树后槽引用错位")
else:
var icon_rect: TextureRect = slots_row._slots[0].get_child(0).get_child(0)
if icon_rect.texture == null:
problems.append("BUG-0001 回归:悬停后重刷槽未填充(texture=null)")
slots_row.set_effects([probe_eff[0], probe_eff[0], probe_eff[0], probe_eff[0], probe_eff[0], probe_eff[0]])
if slots_row._slots.size() != 4:
problems.append("BUG-0001 回归:超上限效果未截断至 4 槽(%d)" % slots_row._slots.size())
slots_row.set_effects([])
if not slots_row._slots.is_empty():
problems.append("BUG-0001 回归:空态未清槽(%d)" % slots_row._slots.size())
# ---- 图标键回归(HUD框架 图标键契约:状态表九行全映射〔定稿 PNG 纹理〕、
# 格面资源 cell_type 九型全映射〔含 gp 键〕;查不到的键 fail-fast + ? 占位
# ——此处钉死映射不残缺,未知键断言由 _icon_tex/_badge_glyph 承担)----
for eid in ["attack_up", "ac_up", "alert", "weaken", "enrage", "off_guard", "sapped", "empower", "fortify"]:
if not StatusSlotRow.EFFECT_ICON.has(eid) or StatusSlotRow.EFFECT_ICON[eid] == null:
problems.append("状态图标九键纹理映射缺失(%s)" % eid)
for cat in ["attack", "defense", "heal", "blast", "weaken", "pierce", "magic", "action", "gp"]:
if not BoardView.REWARD_BADGE.has(cat):
problems.append("奖励图标表缺键(%s)" % cat)
var probe_kb: Array = [
{"effect_id": "enrage", "display_name": "暴走", "layers": 1, "remaining_settlements": 1, "source_class": "seal_miss", "round_index": 1, "effective_value": 25},
{"effect_id": "off_guard", "display_name": "失衡", "layers": 1, "remaining_settlements": 1, "source_class": "seal_miss", "round_index": 1, "effective_value": 25},
]
slots_row.set_effects(probe_kb)
if slots_row._slots.size() != 2:
problems.append("图标键回归:enrage/off_guard 未建槽(%d)" % slots_row._slots.size())
else:
var ic_enrage: TextureRect = slots_row._slots[0].get_child(0).get_child(0)
var ic_off: TextureRect = slots_row._slots[1].get_child(0).get_child(0)
if ic_enrage.texture == null:
problems.append("暴走图标键未映射(texture=null)")
if ic_off.texture == null:
problems.append("失衡图标键未映射(texture=null)")
slots_row.set_effects([])
# ---- L2 单场段(debug_session 单场原语 + 去饱和接口可用性;雾格机制已整体移除,不再断言变体呈现)----
# 单场无用户入口——探针直调 debug_session 原语(battle_created/ready 仍经组合根接线;
# _reset_run_scope 复现 _start_single 的提示去重复位)
main._begin_session(false)
main._debug.start_single(2)
await get_tree().process_frame
await get_tree().process_frame
if main.board_view.board() == null:
problems.append("L2 单场未启动(board=null)")
else:
# 地形变异:L2 板允许带变异(岩浆/烬覆)——布置域断言 =
# 变异格全在初始展开区外(closed_only 结构保障)
var b0 = main.board_view.board()
var r0p := {}
for c in b0.compute_r0():
r0p[c] = true
for c in b0.variants.keys():
if r0p.has(c):
problems.append("L2 板变异格落在初始展开区(地形变异 布置域违例)")
break
main.board_view.set_desaturated(true)
main.board_view.set_desaturated(false)
# ---- GP 终局行:summary gp_final 键取数+胜利面板 GP 行呈现(C5:GP 行只上冒险终局面板;
# 断言面 = summary 键,无影子账清零转存)----
# (置于战斗态断言之后——合成收束会灰化站桩并清 settlement 引用,具污染性)
var meta_saved = main.meta.save
for p2 in ["user://smoke_ui_probe_settle_scratch.json", "user://smoke_ui_probe_settle_scratch.json.tmp"]:
if FileAccess.file_exists(p2):
DirAccess.remove_absolute(p2)
main.meta.save = load("res://core/save_manager.gd").new("user://smoke_ui_probe_settle_scratch.json")
# 合成摘要不落隔离档(settle_adventure 写盘改道 scratch——跨跑自污染断根),GP 行只验呈现
main._on_adventure_finished({"result": "completed", "cleared": true, "levels_cleared": 3,
"stopped_at": 0, "keystone_total": 30, "master_seed": 42, "gp_final": 57})
main.meta.save = meta_saved
if not main._end_overlay.is_open():
problems.append("冒险终局面板未开(合成摘要路径)")
elif not main._end_overlay._gp_row.visible or not main._end_overlay._gp_val.text.contains("57"):
problems.append("终局面板 GP 行缺失或取数不对(%s)" % main._end_overlay._gp_val.text)
main._end_overlay.close()
main._close_modal(main._end_overlay)
if problems.is_empty():
print("[ui_probe] PASS — cells=%d board=%dx%d hp='%s' enemies=%d ap_dots=%d" % [
main.board_view.cells_probe().size(), main.board_view.board().width, main.board_view.board().height,
main.info_panel.attack_text(),
main.stage.enemy_box.get_child_count(), main.ap_bar._dots.get_child_count()])
get_tree().quit(0)
else:
for p in problems:
push_error("[ui_probe] FAIL — " + p)
print("[ui_probe] FAIL — " + "; ".join(problems))
get_tree().quit(1)
+1
View File
@@ -0,0 +1 @@
uid://c63fe0ea78baw
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://probe/ui_probe.gd" id="1"]
[node name="UIProbe" type="Node"]
script = ExtResource("1")
+715
View File
@@ -0,0 +1,715 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# UI 结构探针:全 UI 屏结构断言一次跑完:逐屏实例化(菜单簇真驱动 + 冒险簇真流推进 + 兑付屏真实 open 面合成喂入),
# 断言族:
# ①overlap 同屏内可见未禁用按钮两两 global_rect 不相交(互压=红;层间模态盖底层系设计内——各屏采样以屏根为界天然排除)
# ②equal_width 同一直接父容器内 ≥2 可见按钮宽度差 >1px=红(容器纪律:组内等宽;钮高族
# equal_height——同容器高度差 >1px=红,纪律=ui_kit BUTTON_MIN_H)
# ③offscreen 可见件 global_rect 完全落在视口外=红
# ④zero_size 可见件宽或高 ≤0=红
# ⑤start 专项 标题屏左列(start_box)与右列(start_right)矩形不相交(双列契约——叠放缺陷回归闸)
# 豁免:WL 常量表按「屏名/规则名/件名」登记合法例外(登记须注理由于行尾注释)。
# 运行:Godot_console.exe --headless --path . res://probe/ui_struct_probe.tscn
extends Node
const ProbeUiUtil := preload("res://probe/probe_ui_util.gd")
# 豁免清单(空 = 零豁免起步;红灯逐条核对后合法者入表并注理由)
const WL := {}
var problems: Array = []
func _ready() -> void:
await get_tree().process_frame # autoload 就绪
var main: Node = load("res://presentation/main.tscn").instantiate()
add_child(main)
await get_tree().process_frame
# ---- 菜单簇(开始层可见态;逐屏开→采样→关,模态恢复走组合根真实路径)----
ProbeUiUtil.isolate_save(main, "smoke_ui_struct_probe_menu", true, false, true)
_settle()
await _audit("start", [main._start_overlay])
_start_columns_contract(main)
_modal_registry_contract(main)
main._open_settings()
_settle()
await _audit("settings", [main.settings_overlay])
main._on_settings_closed()
_settle()
if not ProbeUiUtil.open_camp_via_savesel(main, "smoke_ui_struct_probe_menu"):
problems.append("[camp] 营地入口缺失(选档层新档钮——mn9 新路径驱动中断)")
else:
_settle()
await _audit("camp", [main.camp_overlay])
main._on_camp_closed()
_settle()
main._on_save_select_closed() # 关闭被栈还原的选档层(动线序中间态——卡④ 换代点)
_settle()
# ---- 生涯页:入口点击链(合成点击真路径=pressed.emit→组合根 _open_career)→
# 结构断言族→只读巡检(契约 3)+零动效契约+零跳转契约(提纲 P4 甲)→组合根真实路径关闭。
#(登记序/树序契约统一在全模态 _modal_registry_contract——boot 段跑)----
# 生涯页驱动=函数直调中间态(卡② mn9/mn10——careerbtn 已撤,卡④ 营地笔记本改挂前无 UI 路径)
main._open_career()
_settle()
if not main.career_overlay.visible or main._start_overlay.visible:
problems.append("[career] 生涯页未打开或开始层未隐藏(函数直调中间态断裂)")
await _audit("career", [main.career_overlay])
_career_readonly_contract(main)
_career_zero_motion_contract(main)
_career_nojump_contract(main)
main._on_career_closed()
_settle()
if main.career_overlay.visible or not main._start_overlay.visible:
problems.append("[career] 关闭后页未隐藏或开始层未恢复(模态栈单口失守)")
# ---- 选档界面(迭代 33 卡③):函数直调开层(「进入存档」钮归卡② mn3——点击链在卡② 承接),
# sl1 三槽呈现/sl2 摘要三项逐槽读数/sl3 摘要禁项/sl4 操作集二分/sl9 重启链不预选。
# 隔离:attach_slot_template 注入命名空间(卡① 注入口);夹具按槽直写——槽1 有档异值、
# 槽2 空槽、槽3 坏档(Q2 甲=按空槽呈现→操作集与空槽同形)。----
var ssel_tpl := "user://smoke_ui_struct_probe_savesel_slot_%d.json"
for i in range(1, 4):
for suf in ["", ".tmp", ".corrupt.bak"]:
var spp: String = ssel_tpl % i + suf
if FileAccess.file_exists(spp):
DirAccess.remove_absolute(spp)
ProbeUiUtil.isolate_save(main, "smoke_ui_struct_probe_savesel", true, false, true)
main.save_manager.attach_slot_template(ssel_tpl) # 注入口(激活态回零——sl9 重启链同构态)
var f_s1 := FileAccess.open(ssel_tpl % 1, FileAccess.WRITE)
f_s1.store_string(JSON.stringify({"version": 7, "meta": {"keystone_balance": 555,
"adventure_total": 7, "adventure_cleared": 2, "tree_nodes": {}, "fork_sides": {}, "tree_struct": 2}}))
f_s1.close() # 槽1 有档异值
var f_s3 := FileAccess.open(ssel_tpl % 3, FileAccess.WRITE)
f_s3.store_string("broken json not a save{{{") # 槽3 坏档(窥视 readable=false→按空呈现)
f_s3.close()
if main.save_manager.active_slot() != 0:
problems.append("[savesel] 开层前激活态非零(sl9 重启链——boot/注入面后应无激活)")
main._open_save_select()
_settle()
if not main.save_select_overlay.visible or main._start_overlay.visible:
problems.append("[savesel] 选档层未打开或开始层未隐藏(开层函数断裂)")
await _audit("savesel", [main.save_select_overlay])
_save_select_contract(main, ssel_tpl)
main._on_save_select_closed()
_settle()
if main.save_select_overlay.visible or not main._start_overlay.visible:
problems.append("[savesel] 关闭后层未隐藏或开始层未恢复(模态栈单口失守)")
main.confirm_dialog.open("结构探针采样", "确认")
_settle()
await _audit("confirm", [main.confirm_dialog])
main.confirm_dialog.close()
_settle()
main._show_debug_overlay()
_settle()
await _audit("debug", [main.debug_overlay])
main._close_debug_overlay()
_settle()
# ---- 冒险簇(真流推进:开冒险→地图选路态→选战斗节点→战斗 HUD)----
ProbeUiUtil.isolate_save(main, "smoke_ui_struct_probe_adv", true, false, true)
main.debug_overlay.seed_edit.text = "42" # 种子钉死(种子框默认为空=非首局随机,结构采样确定性禁依赖默认分支)
main._start_adventure_from_menu()
_settle(4)
await _audit("level_map", [main.level_screen])
var battle_id := _pick_battle_node(main)
if battle_id >= 0:
main.level_screen.route_chosen.emit(battle_id)
_settle(6)
_audit("battle_hud", [main.board_zone, main.info_panel, main.stage])
_standeefit_contract(main)
# ---- 状态面板:组合根真实路径开层→结构断言→关闭。附只读性契约
#(R4——子树信号连接白名单巡检)与零动效契约(R5/P4 甲——零 AnimationPlayer/零 tween)。
# 巡检信号名清单=子树全控件的 get_signal_list() 全集(不放行任何未列名信号——新连接
# 一律红,须先入册审查);白名单={视图自身 _emit_closed, 组合根 _on_status_closed}。
main._open_status_panel()
_settle()
await _audit("status", [main.status_panel])
_status_readonly_contract(main)
_status_zero_motion_contract(main)
main._on_status_closed()
_settle()
if main.status_panel.visible:
problems.append("[status] 关闭后层未隐藏(模态栈单口失守)")
# ---- 图例层(旧模态、首过结构族):ap_bar legend_toggled
# 信号真路径开(board_zone 信号→组合根 _on_legend_toggled→_open_modal)→结构断言族→
# closed 信号真路径关(close()→组合根 _on_modal_closed)。
main.board_zone.legend_toggled.emit()
_settle()
if not main.legend_overlay.visible:
problems.append("[legend] 图例层未打开(legend_toggled 路径中断)")
await _audit("legend", [main.legend_overlay])
main.legend_overlay.close()
_settle()
if main.legend_overlay.visible:
problems.append("[legend] 关闭后层未隐藏(模态栈单口失守)")
else:
problems.append("[battle_hud] 候选中无战斗节点(驱动中断——候选=%s)" % str(main.level_screen._cand_ids))
# ---- 兑付屏(真实 open 面 + 合成最小数据——结构断言只看矩形/宽度/可见性)----
main._end_overlay.open_victory("结构探针·胜利演算", "返回", Rect2(200, 200, 400, 300), "GP 0")
_settle()
await _audit("battle_end", [main._end_overlay])
main._end_overlay.close()
_settle()
# ---- 节点结算层(旧模态、首过结构族):rest 兑付真路径开
#(组合根 _open_modal 登记册+show_rest 合成喂入)→结构断言族→continue_pressed 信号真路径关
#(press_continue→组合根 _on_node_continue→_close_modal)。
main._open_modal(main.node_overlay)
main.node_overlay.show_rest(3, 15, 20)
_settle()
if not main.node_overlay.visible:
problems.append("[node] 节点结算层未打开(rest 兑付路径中断)")
await _audit("node_overlay", [main.node_overlay])
main.node_overlay.press_continue()
_settle()
if main.node_overlay.visible:
problems.append("[node] 关闭后层未隐藏(continue_pressed 路径中断)")
main.curio_overlay.open_pick("结构探针·三选一", [
{"id": "probe_heal", "text": "回血\n恢复 3 点"},
{"id": "probe_magic", "text": "爆裂轰击\n下一击 +5"},
{"id": "probe_pierce", "text": "穿甲\n无视护甲"},
])
_settle()
await _audit("curio_pick", [main.curio_overlay])
main.curio_overlay.visible = false
_settle()
main.shop_overlay.open_shop([
{"product_id": "probe_p1", "display_name": "探针商品甲", "attr": "atk", "flat_value": 1, "price": 10, "purchased": 0},
{"product_id": "probe_p2", "display_name": "探针商品乙", "attr": "hp", "flat_value": 2, "price": 20, "purchased": 0},
], 100)
_settle()
await _audit("shop", [main.shop_overlay])
# ---- 术语传导:货架卡与三选一卡片的玩家面文案(物品名=专长获取、分类=专长凭证)
main.shop_overlay.open_shelf([
{"kind_id": "feat_ticket", "display_name": "专长获取", "value": 0, "price": 5},
{"kind_id": "heal", "display_name": "应急回复", "value": 6, "price": 5},
], 20)
_settle()
var ticket_row: Dictionary = main.shop_overlay._shelf_rows[0]
if String(main.db.shop_pool_row("feat_ticket").get("display_name", "")) != "专长获取":
problems.append("[shop_term] shop.tres 专长凭证件名未换代(现=%s)" % String(main.db.shop_pool_row("feat_ticket").get("display_name", "")))
if ticket_row.card.name_l.text != "专长获取":
problems.append("[shop_term] 专长凭证件名未用物品名「专长获取」(现=%s)" % ticket_row.card.name_l.text)
if ticket_row.btn.get_parent() != ticket_row.card.body or ticket_row.btn.get_index() >= ticket_row.card.foot_l.get_index():
problems.append("[shop_term] 购买钮未落在底注之前(add_footer 落位失败)")
if ticket_row.card.desc_l.text != "开一次专长三选一":
problems.append("[shop_term] 专长凭证件描述未用规范流程句(现=%s)" % ticket_row.card.desc_l.text)
if ticket_row.card.foot_l.text != "专长凭证":
problems.append("[shop_term] 专长凭证件分类标注未用「专长凭证」(现=%s)" % ticket_row.card.foot_l.text)
var heal_row: Dictionary = main.shop_overlay._shelf_rows[1]
if heal_row.card.desc_l.text != "回复 +6(本冒险)":
problems.append("[shop_term] 回复件描述漏了 heal 词表(现=%s)" % heal_row.card.desc_l.text)
if heal_row.card.foot_l.text != "回复":
problems.append("[shop_term] 回复件分类标注落到英文 kind(现=%s)" % heal_row.card.foot_l.text)
var ticket_texts: Array = main._bonus._curio_card_texts([{"kind": "feat_ticket", "tier": "excellent"}])
var ticket_text := String((ticket_texts[0] as Dictionary).get("text", ""))
var ticket_lines := ticket_text.replace("\r\n", "\n").split("\n")
if ticket_lines.size() < 2 or ticket_lines[0] != "专长获取" or not ticket_lines[1].contains("专长三选一"):
problems.append("[curio_term] 专长凭证三选一卡未用物品名/规范流程句(现=%s)" % ticket_text)
if ticket_text.contains("门票") or ticket_text.contains("T5") or ticket_text.contains("持有集") or ticket_text.contains("奇物三选一"):
problems.append("[curio_term] 专长凭证三选一卡残留退役词/内部任务号(现=%s)" % ticket_text)
main.shop_overlay.visible = false
_settle()
# ---- ⑦resume 专项:续玩停点恢复(main.gd:717 报错回归闸)----
# 三选一停点续玩分支在 level_started 之前同步 curio_choice_presented.emit(adventure_runner
# resume 提前 return)——GP 总结 lambda 求值时 runner 尚未赋值,修复前 717 炸 null。
ProbeUiUtil.isolate_save(main, "smoke_ui_struct_probe_resume", true)
main.save_manager.set_breakpoint(_resume_bp_fixture())
main.run_scope.runner = null # 全新启动等价态(真机=菜单直进续玩,无前置冒险——runner 从未赋值;探针此前推流会残留旧 runner)
main._resume_from_menu()
_settle(8)
if not main._end_overlay.visible:
problems.append("[resume] 续玩恢复后胜利总结面未开(三选一停点分支中断)")
else:
var found := {"ok": false}
_find_label_text(main._end_overlay, "合计 24", found)
if not found.ok:
problems.append("[resume] 胜利总结面 GP 行未见快照合计「合计 24」(runner 缺位守卫缺位——717 回归)")
var s: String = main._gp_summary_text({"gp_dropped": 0})
if not s.contains("合计 24"):
problems.append("[resume] _gp_summary_text runner=null 守卫未取快照 gp(%s)" % s)
# 恢复现场(resume 改写了 main 的 adventure/save 态——后续无屏依赖,仅为整洁)
main.run_scope.level_active = false
# ---- 判定 ----
if problems.is_empty():
print("[ui_struct_probe] PASS — 全屏结构断言零红")
get_tree().quit(0)
else:
for p in problems:
print("[ui_struct_probe] FAIL — %s" % p)
get_tree().quit(1)
# ⑦resume 夹具:最小合法断点快照——bonus_pending 非空=三选一停点分支(合计 gp=24 供守卫兜底断言)
func _resume_bp_fixture() -> Dictionary:
return {
"master_seed": 42, "rng_counter": 0, "hero_hp": 20, "gp": 24, "keystone": 0,
"level_index": 1, "bonus": {}, "shop_purchased": {}, "relics": [],
"keystone_drops": [], "bonus_taken": {},
"bonus_pending": {"candidates": [{"kind": "heal", "tier": "common"}], "payload": {"gp_dropped": 0}},
}
# 递归找含目标子串的 Label(⑦resume 断言读数面)
func _find_label_text(node: Node, needle: String, found: Dictionary) -> void:
for ch in node.get_children():
if ch is Label and String(ch.text).contains(needle):
found.ok = true
return
_find_label_text(ch, needle, found)
func _pick_battle_node(main: Node) -> int:
# 入口/兑付停点走 level_screen.open_choices 直传(不写 _pending_candidates)——
# 候选从视图态取:_cand_ids × runner.nodes() 的 type(编号解析=node_id_of 单一出口)
var ls: Node = main.level_screen
if ls._runner == null:
return -1
for nd in ls._runner.nodes():
if String(nd.get("type", "")) in ["battle", "boss"]:
var nid: int = ls._runner.node_id_of(nd)
if nid in ls._cand_ids:
return nid
return -1
# ⑧status 只读性契约(R4):状态面板子树全控件 × 游戏语义信号连接巡检——清单外零连接。
# 巡检信号名清单(需求卡片 v1.1「巡检信号名清单落码时列全进探针」)= 玩家交互可携带写意图的通道
# 全集(按钮按下/拨杆/文本/滑值/选项/键盘焦点)+ 本层外发信号 closed;引擎内部布局生命线信号
# 不计(child_order_changed/item_rect_changed/visibility_changed/size_flags_changed/
# minimum/maximum_size_changed 等系 Godot 容器系统对每控件自动建立的脏标记连接,与写通路无关
#——首跑实测踩中:全集巡检把布局生命线误判为非法连接)。清单外任何连接(尤其指向
# 购买/解锁/写入路径的)一律红,须先入册审查再改清单。
const _PAUSE_READONLY_SIGNALS: Array[String] = [
"pressed", "button_down", "button_up", "toggled",
"text_changed", "text_submitted", "value_changed", "item_selected",
"gui_input", "focus_entered", "focus_exited", "closed",
]
func _status_readonly_contract(main: Node) -> void:
var overlay: Control = main.status_panel
var stack: Array = [overlay]
var conn_count := 0
while not stack.is_empty():
var n: Node = stack.pop_back()
for sig_name in _PAUSE_READONLY_SIGNALS:
for conn in n.get_signal_connection_list(sig_name):
conn_count += 1
var cb: Callable = conn["callable"]
var host: Object = cb.get_object()
var m := String(cb.get_method())
if host == overlay and m == "_emit_closed":
continue
if host == main and m == "_on_status_closed":
continue
problems.append("[status][readonly] 非法信号连接 %s.%s → %s.%s(零写通路巡检——白名单=视图 _emit_closed/组合根 _on_status_closed)"
% [n.name, sig_name, host, m])
for ch in n.get_children():
stack.append(ch)
if conn_count != 2:
problems.append("[status][readonly] 信号连接数=%d 应=2(关闭钮 pressed+closed 外发——多连接=写通路嫌疑)" % conn_count)
# ⑨status 零动效契约(R5/P4 甲——零动效直开直关路径存在断言):
# 子树零 AnimationPlayer + 视图源码零 tween/动效包装调用(源码 token 扫描=ui_contrast 静态段同手法)
func _status_zero_motion_contract(main: Node) -> void:
var overlay: Control = main.status_panel
var stack: Array = [overlay]
while not stack.is_empty():
var n: Node = stack.pop_back()
if n is AnimationPlayer:
problems.append("[status][motion] 子树含 AnimationPlayer(R5 零动效破)")
for ch in n.get_children():
stack.append(ch)
var src := FileAccess.get_file_as_string("res://presentation/views/status_panel.gd")
for token in ["create_tween", "UiKit.motion"]:
if src.contains(token):
problems.append("[status][motion] 视图源码含「%s」(R5 零动效直开直关破——P4 甲)" % token)
# ---- 生涯页契约族(需求卡片契约 3/4+提纲 P3/P4 甲)----
# ⑩career 只读性契约(契约 3——状态面板 ⑧ 同形制):生涯页子树全控件 × 游戏语义信号连接巡检——
# 清单外零连接。白名单={视图自身 _emit_closed〔返回钮〕, 组合根 _on_career_closed};页内任何
# 购买/导航/写入方向的新连接一律红,须先入册审查。
func _career_readonly_contract(main: Node) -> void:
var overlay: Control = main.career_overlay
var stack: Array = [overlay]
var conn_count := 0
while not stack.is_empty():
var n: Node = stack.pop_back()
for sig_name in _PAUSE_READONLY_SIGNALS:
for conn in n.get_signal_connection_list(sig_name):
conn_count += 1
var cb: Callable = conn["callable"]
var host: Object = cb.get_object()
var m := String(cb.get_method())
if host == overlay and m == "_emit_closed":
continue
if host == main and m == "_on_career_closed":
continue
problems.append("[career][readonly] 非法信号连接 %s.%s → %s.%s(零写通路巡检——白名单=视图 _emit_closed/组合根 _on_career_closed)"
% [n.name, sig_name, host, m])
for ch in n.get_children():
stack.append(ch)
if conn_count != 2:
problems.append("[career][readonly] 信号连接数=%d 应=2(返回钮 pressed+closed 外发——多连接=写通路嫌疑)" % conn_count)
# ⑪career 零动效契约(状态面板 ⑨ 同形制——P4 甲直开直关)
func _career_zero_motion_contract(main: Node) -> void:
var overlay: Control = main.career_overlay
var stack: Array = [overlay]
while not stack.is_empty():
var n: Node = stack.pop_back()
if n is AnimationPlayer:
problems.append("[career][motion] 子树含 AnimationPlayer(零动效破)")
for ch in n.get_children():
stack.append(ch)
var src := FileAccess.get_file_as_string("res://presentation/views/career_overlay.gd")
for token in ["create_tween", "UiKit.motion"]:
if src.contains(token):
problems.append("[career][motion] 视图源码含「%s」(零动效直开直关破)" % token)
# ⑫模态登记/树序全量合同(V1——career 专属登记序契约推广到全部模态+屏壳;
# 判据=main.LAYER_CONTRACT 单一事实源:登记册序==合同模态序、直接子层树序==合同序逐对相等。
# 两特例按合同数据表达:level_screen 宿住冒险屏壳(树序随宿主=全模态之下,遮挡语义归模态栈
# screen_state 登记)、camp_overlay 成员与 camp_screen 屏壳同槽(屏壳树序代言成员登记位)。
# _career_registry_contract 的断言(career=状态面板后/确认弹窗前、树序同步)由本全量合同覆盖)
# ⑭选档界面契约(卡③ sl1/sl2/sl3/sl4/sl9——呈现面全部来自喂入数据,视图零自判)
func _save_select_contract(main: Node, tpl: String) -> void:
var sso: Node = main.save_select_overlay
# sl1 三槽呈现:槽卡计数==3+零滚动容器(禁滚动长列表形态——契约 1)
var rows: VBoxContainer = sso._rows_box
var live_rows := 0
for c in rows.get_children():
if c is Control and not c.is_queued_for_deletion():
live_rows += 1
if live_rows != 3:
problems.append("[savesel] 槽卡行数=%d 应=3(sl1 三槽呈现)" % live_rows)
var scroll_count := _count_scroll(sso)
if scroll_count > 0:
problems.append("[savesel] 子树出现滚动容器 ×%d(sl1 禁滚动长列表)" % scroll_count)
# sl4 操作集二分(渲染面实测——扫真实按钮文本,不走簿记缝):槽1 有档=进档+删档零新档;
# 槽2 空=新档零进档删档;槽3 坏档=按空槽呈现(Q2 甲)
var occ: Dictionary = sso._occupied_flags
if not (bool(occ.get(1, false)) and not bool(occ.get(2, false)) and not bool(occ.get(3, false))):
problems.append("[savesel] 槽态判定失守(occ=%s 应=1有/2空/3坏按空)〔sl4〕" % str(occ))
var row_idx := 0
for row_c in rows.get_children():
if not (row_c is Control) or row_c.is_queued_for_deletion():
continue
row_idx += 1
var btn_texts: Array[String] = []
_collect_button_texts(row_c, btn_texts)
var has_load := btn_texts.has("进档")
var has_del := btn_texts.has("删档")
var has_new := btn_texts.has("新档")
if row_idx == 1:
if not (has_load and has_del and not has_new):
problems.append("[savesel] 行1 操作集失守(有档槽应=进档+删档、零新档——渲染面实测)〔sl4〕")
else:
if not (has_new and not has_load and not has_del):
problems.append("[savesel] 行%d 操作集失守(空/坏槽应=仅新档、零进档删档含置灰形态)〔sl4〕" % row_idx)
# sl2 摘要三项逐槽读数:槽1 喂入值逐字(钥石 555/通关 2 局/冒险 7 局)
var sum1: Label = sso._summary_labels.get(1)
if sum1 == null or sum1.text != "通关 2 局 · 冒险 7 局":
problems.append("[savesel] 槽1 摘要读数失守(got=%s 应=通关 2 局 · 冒险 7 局)〔sl2〕" % (sum1.text if sum1 else "null"))
var all_texts: Array[String] = []
_collect_labels(sso, all_texts)
var has_keystone := false
for t in all_texts:
if t == "钥石 555":
has_keystone = true
if not has_keystone:
problems.append("[savesel] 槽1 钥石徽读数缺失(应=钥石 555)〔sl2〕")
# sl3 摘要禁项:断点/种子/「继续」/停点/「 关」(关数形——「通关」无空格不误伤)零命中
#(裁决 4——零未完冒险读数;词形覆盖=对照表 sl3 变异「断点关数拼进槽卡」的拦截面)
for needle in ["断点", "种子", "继续", "停点", " 关"]:
for t in all_texts:
if t.contains(needle):
problems.append("[savesel] 摘要禁项命中「%s」(label=「%s」)〔sl3〕" % [needle, t])
# sl9 重启链不预选:视图零选中态字段+喂入数据重建(无「已选」标记文本)
for t in all_texts:
if t.contains("已选"):
problems.append("[savesel] 出现预选标记文本「%s」(sl9 Q1 甲不预选)" % t)
func _count_scroll(node: Node) -> int:
var count := 1 if node is ScrollContainer else 0
for c in node.get_children():
count += _count_scroll(c)
return count
func _collect_button_texts(node: Node, out: Array[String]) -> void:
if node is Button and node.visible:
out.append(String(node.text))
for c in node.get_children():
_collect_button_texts(c, out)
func _collect_labels(node: Node, out: Array[String]) -> void:
if node is Label:
out.append(String(node.text))
for c in node.get_children():
_collect_labels(c, out)
func _modal_registry_contract(main: Node) -> void:
var list: Array = main._modals._list
# ① 登记册序==合同模态序(逐位点名——合同是登记序唯一事实源)
var contract_modals: Array = []
for entry: Dictionary in main.LAYER_CONTRACT:
if String(entry["kind"]) != "modal":
continue
var n: Node = main._layer_node(String(entry["key"]))
if n == null:
problems.append("[registry] 合同层缺失: %s〔装配生命批 V1〕" % String(entry["key"]))
continue
contract_modals.append(n)
if list.size() != contract_modals.size():
problems.append("[registry] 登记册层数=%d 应=%d(合同模态数)〔装配生命批 V1〕" % [list.size(), contract_modals.size()])
for i in contract_modals.size():
if i >= list.size():
break
if list[i] != contract_modals[i]:
problems.append("[registry] 登记序第 %d 位与合同不一致(登记=%s 合同=%s)〔装配生命批 V1〕" % [
i, list[i].name, contract_modals[i].name])
# ② 直接子层树序==合同序逐对相等(屏壳/模态/全屏态/非视觉节点全链;宿住层随宿主不参与)
var prev: Node = null
for entry: Dictionary in main.LAYER_CONTRACT:
if entry.has("host"):
continue
var node: Node = main._layer_node(String(entry["shell"]) if entry.has("shell") else String(entry["key"]))
if node == null:
problems.append("[registry] 合同层缺失: %s〔装配生命批 V1〕" % String(entry["key"]))
continue
if prev != null and node.get_index() <= prev.get_index():
problems.append("[registry] 树序违约(合同邻接对 %s→%s:%d≤%d)〔装配生命批 V1〕" % [
prev.name, node.name, node.get_index(), prev.get_index()])
prev = node
# ③ 宿住层特例:level_screen 宿主=冒险屏壳且在全直接子模态之下(遮挡语义归模态栈 screen_state)
var lv: Node = main.level_screen
if lv.get_parent() != main._adventure_screen:
problems.append("[registry] level_screen 宿主应=冒险屏壳(实测 %s)〔装配生命批 V1〕" % lv.get_parent().name)
elif lv.get_parent().get_index() >= main.legend_overlay.get_index():
problems.append("[registry] 冒险屏壳应在全模态之下(宿主 idx=%d legend idx=%d)〔装配生命批 V1〕" % [
lv.get_parent().get_index(), main.legend_overlay.get_index()])
# ④ 营地同槽特例:camp_screen 屏壳树序夹商店层与设置层之间(成员 camp_overlay 登记位代言)
var cs: Node = main._camp_screen
if cs.get_index() <= main.shop_overlay.get_index() or cs.get_index() >= main.settings_overlay.get_index():
problems.append("[registry] camp_screen 屏壳树序应夹商店与设置层之间(idx=%d shop=%d settings=%d)〔装配生命批 V1〕" % [
cs.get_index(), main.shop_overlay.get_index(), main.settings_overlay.get_index()])
# ⑬career 零跳转契约(提纲 P4 甲——页内零「前往他处」入口:除「返回」外任何导航钮=红)
func _career_nojump_contract(main: Node) -> void:
for nav in ["营地", "商店", "设置", "冒险", "继续上局", "退出", "前往"]:
if ProbeUiUtil.find_button(main.career_overlay, nav, "contains") != null:
problems.append("[career][nojump] 页内出现导航入口「%s」(零跳转违约——生涯纯只读)" % nav)
# ⑥standee 专项:B 区站桩内容矩形 ⊆ 252px 舞台矩形(内缩 8px)且不被裁切
# (名行基线离盒底 1px 同型高危写法返修回归闸;
# 内容矩形=StandeeBody.content_rect(),最低绘制元素=底座下缘)
func _standeefit_contract(main: Node) -> void:
var st: Node = main.stage
if st == null or st.hero_standee == null:
problems.append("[standee] 舞台/主角站桩缺失(断言中断)")
return
var stage_gr: Rect2 = st.get_global_rect()
var bodies: Array = [st.hero_standee]
for c in st._cards:
bodies.append(c)
for body in bodies:
if body == null or not is_instance_valid(body):
continue
var content := Rect2(st.world_to_shell(body.position), body.content_rect().size)
if not stage_gr.encloses(content):
problems.append("[standee] %s 内容矩形 %s 越出舞台矩形 %s(N2 站桩出框回归)"
% [body.name if body.name != "" else "standee", content, stage_gr])
elif content.end.y > stage_gr.end.y - 8.0:
problems.append("[standee] %s 内容底缘 %s 距舞台底 <8px(N2 纵向余量回归,舞台底=%s)"
% [body.name if body.name != "" else "standee", content.end.y, stage_gr.end.y])
func _settle(frames := 4) -> void:
for i in range(frames):
await get_tree().process_frame
await get_tree().process_frame # 布局排序系帧尾 call_deferred——末帧后再保一帧
# ---- 采样与断言 ----
func _visible_children(root: Control) -> Array:
var out: Array = []
var stack: Array = [root]
while not stack.is_empty():
var c: Control = stack.pop_back()
if c == null:
continue
if c.is_visible_in_tree():
out.append(c)
for ch in c.get_children():
if ch is Control:
stack.append(ch)
return out
func _buttons(root: Control) -> Array:
var out: Array = []
for c in _visible_children(root):
if c is Button and not c.disabled:
out.append(c)
return out
func _wl(screen: String, rule: String, ctl: Control) -> bool:
var s: Dictionary = WL.get(screen, {})
if s.is_empty():
return false
var names: Array = s.get(rule, [])
if names.is_empty():
return false
return String(ctl.name) in names
func _force_layout(root: Control) -> void:
# modal 屏首次 visible 后容器排序不自动传播(size 已算而 position 停 0——4.7 行为)——
# 采样前对子树全部 Container 强制 queue_sort
var stack: Array = [root]
while not stack.is_empty():
var c: Control = stack.pop_back()
if c is Container:
c.queue_sort()
for ch in c.get_children():
if ch is Control:
stack.append(ch)
func _in_scroll(c: Control) -> bool:
var p := c.get_parent()
while p is Control:
if p is ScrollContainer:
return true
p = p.get_parent()
return false
func _audit(screen: String, roots: Array) -> void:
for r in roots:
if r != null and is_instance_valid(r):
_force_layout(r)
await _settle(1)
if OS.get_environment("STRUCT_PROBE_DEBUG") == "1":
for r in roots:
if r != null and is_instance_valid(r):
_dump(r, 0)
var all_ctls: Array = []
var all_buttons: Array = []
for r in roots:
if r == null or not is_instance_valid(r):
problems.append("[%s] 采样根缺失" % screen)
return
all_ctls += _visible_children(r)
all_buttons += _buttons(r)
var vp := get_viewport().get_visible_rect()
# ①overlap:操作钮两两不相交
for i in range(all_buttons.size()):
for j in range(i + 1, all_buttons.size()):
var a: Control = all_buttons[i]
var b: Control = all_buttons[j]
if _wl(screen, "overlap", a) or _wl(screen, "overlap", b):
continue
# 滚动区跨界豁免(实证:当局数据区块加高列表后,恰好一枚在 ScrollContainer
# 内时,其未剪裁内容矩形与滚区外控件的全局矩形交叠是滚动态样貌非布局缺陷;
# 同滚区两钮仍查——同一容器 stacking 真重叠照红)
if _in_scroll(a) != _in_scroll(b):
continue
if a.get_global_rect().intersects(b.get_global_rect()):
problems.append("[%s][overlap] %s 与 %s 矩形相交(%s ∩ %s)" % [
screen, a.name, b.name, a.get_global_rect(), b.get_global_rect()])
# ②equal_width + equal_height:同一直接父容器内 ≥2 可见按钮宽度/高度差 >1px
#(钮高族判据「对齐好、不要细」——主钮 26px 对常规 38px 不齐回归闸;纪律=ui_kit BUTTON_MIN_H)
var groups := {}
for b in all_buttons:
var k: Node = b.get_parent()
if not groups.has(k):
groups[k] = []
groups[k].append(b)
for k in groups:
var g: Array = groups[k]
if g.size() < 2:
continue
var w0: float = (g[0] as Control).size.x
var h0: float = (g[0] as Control).size.y
var diff := false
var diff_h := false
for b in g:
if _wl(screen, "equal_width", b):
diff = false
diff_h = false
break
if absf((b as Control).size.x - w0) > 1.0:
diff = true
if absf((b as Control).size.y - h0) > 1.0:
diff_h = true
if diff:
var names: Array = []
var widths: Array = []
for x in g:
names.append(String((x as Control).name))
widths.append((x as Control).size.x)
problems.append("[%s][equal_width] 同容器按钮宽度不一(%s——%s)" % [screen, str(names), str(widths)])
if diff_h:
var names_h: Array = []
var heights: Array = []
for x in g:
names_h.append(String((x as Control).name))
heights.append((x as Control).size.y)
problems.append("[%s][equal_height] 同容器按钮高度不一(%s——%s)" % [screen, str(names_h), str(heights)])
# ③offscreen + ④zero_size(ScrollContainer 内容坐标系超出=可滚动设计内——豁免)
for c in all_ctls:
if _in_scroll(c):
continue
var gr: Rect2 = c.get_global_rect()
if not _wl(screen, "offscreen", c) and not gr.intersects(vp):
problems.append("[%s][offscreen] %s 完全出视口(%s)" % [screen, c.name, gr])
if not _wl(screen, "zero_size", c) and (c.size.x <= 0.0 or c.size.y <= 0.0):
problems.append("[%s][zero_size] %s 零尺寸(%s)" % [screen, c.name, c.size])
func _dump(c: Control, depth: int) -> void:
var ind := " ".repeat(depth)
print("[dump] %s%s(%s) pos=%s gpos=%s size=%s vis=%s" % [ind, c.name, c.get_class(),
c.position, c.get_global_position(), c.size, c.is_visible_in_tree()])
for ch in c.get_children():
if ch is Control:
_dump(ch, depth + 1)
# ⑤start 专项:左列/右列矩形不相交(M2 双列契约——装配件命名 start_box/start_right 为探针锚点)
func _start_columns_contract(main: Node) -> void:
var box: Control = main._start_overlay.find_child("start_box", true, false)
var right: Control = main._start_overlay.find_child("start_right", true, false)
if box == null or right == null:
problems.append("[start][columns] 左列/右列节点缺失(start_box/start_right——装配命名失守)")
return
if box.get_global_rect().intersects(right.get_global_rect()):
problems.append("[start][columns] 左列 %s 与右列 %s 相交(双列叠放回归——M2 缺陷闸)" % [
box.get_global_rect(), right.get_global_rect()])
+1
View File
@@ -0,0 +1 @@
uid://dqaphrds5ncet
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://probe/ui_struct_probe.gd" id="1"]
[node name="UIStructProbe" type="Node"]
script = ExtResource("1")
+237
View File
@@ -0,0 +1,237 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# 加成应用契约 = 组合根单一应用路径
# 调试浮窗场景探针(原数值调整板收进 debug_overlay;
# 角标挂宿主 / 启动即开 / 当局数据只读区块 / 输入锁防护配套):
# 驱动真实 UI 路径——①全局浮层角标开窗(UI 最顶层「调试」钮点击路径)+悬浮菜单之上+登记册末位树序
# ②滑条联动 ③应用 ④重置 ⑤批量模拟 N=10 ⑥关闭 ⑦hero 表运行期不可变
# ⑧单次加成生效(经 _start_adventure_from_menu 真实路径+显式种子钉死)+开冒险路径显式关浮窗
# ⑨对局中可开(战斗屏角标真实信号路径)+棋盘输入不锁+状态角标不置灰(停机点回归闸)
# +对局中两钮禁用+当局数据六项值对(与只读访问器逐项一致)+只读巡检(白名单外零连接)+零动效契约
# ⑩种子三分单点断言(显式输入优先/首局恒 42/非首局空框连抽 4 次不全等 42)
# 运行:Godot_console.exe --headless --path . res://probe/ui_tune_probe.tscn
extends Node
const ProbeUiUtil := preload("res://probe/probe_ui_util.gd") # T6 收口:隔离样板单点
const SIM_GUARD_FRAMES := 1800 # ⑤ N=10 headless 模拟防挂死帧预算上限(探针内停滞检测第一道——坏态不等满预算才报,超时即断言失败)
# 只读巡检信号名清单(17卡③「白名单外零连接」形制沿用——玩家交互可携带写意图的通道全集
# +本层外发 closed;引擎内部布局生命线信号不计)
const _OVERLAY_READONLY_SIGNALS: Array[String] = [
"pressed", "button_down", "button_up", "toggled",
"text_changed", "text_submitted", "value_changed", "item_selected",
"gui_input", "focus_entered", "focus_exited", "closed",
]
# 视图内白名单方法(fx 切换/应用/重置/模拟/两层调试/诊断转储/关闭/滑条联动——全部视图自身信号外发前置,
# 零跨对象写;清单外任何新连接一律红须入册)
const _OVERLAY_VIEW_METHODS: Array[String] = ["_on_fx_toggle", "_apply_btn_pressed", "_reset_btn_pressed",
"_emit_simulate_requested", "_emit_debug_levels", "_emit_diagnose_requested", "_emit_closed",
"_on_slider_value_changed"]
func _ready() -> void:
await get_tree().process_frame
var main: Node = load("res://presentation/main.tscn").instantiate()
add_child(main)
await get_tree().process_frame
var problems: Array = []
# 基线读表(表值驱动——不写字面量)
var base_atk := int(main.db.hero.atk)
var base_hp := int(main.db.hero.max_hp)
var boosted_atk := base_atk * 2 # 攻击滑条 100 → ×2
var boosted_hp := int(round(base_hp * 1.5)) # 生命滑条 50 → +50%
# ① 全局浮层角标开窗(调试入口=UI 最顶层浮层,任何 UI 不遮挡——
# main 直接子层、LAYER_CONTRACT 末位 float 类;boot 菜单优先不开浮窗,角标真实点击路径开窗;
# 悬浮于菜单之上——开始层不隐藏;树序新基准=登记册末位(确认弹窗之后=绘制序最上))
main.debug_badge.pressed.emit()
await get_tree().process_frame
if not main.debug_overlay.visible:
problems.append("调试浮层角标点击后浮窗未开(全局浮层入口失守)")
if not main._start_overlay.visible:
problems.append("开始覆盖层被隐藏——浮窗应悬浮于菜单之上(14.5 形态失守)")
if main.debug_overlay.get_index() < main.confirm_dialog.get_index():
problems.append("调试浮窗树序不在确认弹窗之后——登记册末位失守")
# ② 滑条 → 生效预览联动
main.debug_overlay.set_sliders(100, 50)
if str(boosted_atk) not in main.debug_overlay.effect_text() or str(boosted_hp) not in main.debug_overlay.effect_text():
problems.append("生效预览未联动(%s)" % main.debug_overlay.effect_text())
# ③ 应用(真实信号路径:apply_requested → 组合根)——E4 后加成零表写,生效断言移至 ⑧ 战斗快照
main.debug_overlay.press_apply_for_probe()
# ④ 重置(重开浮窗 → 重置按钮真实路径;面板百分比归零——下一场战斗生效值回到基线)
main._show_debug_overlay()
main.debug_overlay.press_reset_for_probe()
# ⑤ 批量模拟全链路(N=10——走 level_autoplay 与 headless 扫描同口径;表基线由组合根复位;
# 菜单态上下文未锁定=按钮可点,is_running 语义=模拟在跑〔18卡⑦ 与对局禁用态拆分〕)
main.debug_overlay.set_sliders(100, 0)
main.debug_overlay.set_sim_count(10)
main.debug_overlay.press_simulate_for_probe()
var guard := SIM_GUARD_FRAMES
while main.debug_overlay.is_running() and guard > 0:
guard -= 1
await get_tree().process_frame
if main.debug_overlay.is_running():
problems.append("模拟未在预算帧内完成(疑似挂死)")
var out: String = main.debug_overlay.output_text()
if not out.contains("通关率"):
problems.append("模拟输出缺通关率")
if not out.contains("败北节点"):
problems.append("模拟输出缺败北节点")
if "N=10" not in out:
problems.append("模拟输出 N 标注错误(%s)" % out.substr(0, 40))
# ⑥ 关闭 → 浮窗收起、开始层全程在屏
main._close_debug_overlay()
if main.debug_overlay.visible or not main._start_overlay.visible:
problems.append("关闭后层级状态错误")
# ⑦ hero 表运行期不可变(E4 收口回归断言:任何代码 reintroduce 表写在此被抓获——原"表值残留"断言语义升级)
if int(main.db.hero.atk) != base_atk or int(main.db.hero.max_hp) != base_hp:
problems.append("hero 表被运行时改写(E4 违例:atk=%s hp=%s,期望恒基线 %d/%d)" % [str(main.db.hero.atk), str(main.db.hero.max_hp), base_atk, base_hp])
# ⑧ 单次加成生效回归断言:应用加成 + 启动单场战斗 → 呈现快照 hero 必须是"基线 × 单次加成"
# (攻击 ×2 / 生命 +50%)——旧缺陷(残留值被当基线再乘)在 encounter 参数链上同样被抓获
# 存档隔离(停点断点写盘随 main.save_manager;探针禁碰真实档,同族探针隔离纪律;
# isolate_save 先删后建——上次运行的残留断点会触发互斥确认早退)
ProbeUiUtil.isolate_save(main, "smoke_tune_probe_save", true, false, true) # 末参=swap_save(卡②签名换代——断点写盘经 autoload 落隔离档)
main.debug_overlay.set_sliders(100, 50)
main.debug_overlay.press_apply_for_probe()
main.debug_overlay.seed_edit.text = "42" # 种子钉死(18卡⑦审计🔴——空框=非首局随机,显式优先经真实路径断言 master_seed)
main._start_adventure_from_menu() # 完整冒险路径(唯一试玩入口;_start_single 按语义清零滑条 = 恒基线,不承载加成断言)
await get_tree().process_frame
await get_tree().process_frame
# ⑧b 开冒险路径显式关浮窗(18卡⑦ T2 防护——调试会话默认开态下开局不带窗,对局中经角标再开)
if main.debug_overlay.visible:
problems.append("开冒险路径未显式关浮窗(T2 防护失守)")
if int(main.run_scope.adventure.master_seed) != 42:
problems.append("显式种子未达冒险(master_seed=%s ≠ 42——真实路径显式优先失守)" % str(main.run_scope.adventure.master_seed))
# 关卡开始 = 入口选路停点——真实按钮路径选入口候选进首战再读呈现快照
if main.level_screen.visible:
main.level_screen.press_candidate(0)
await get_tree().process_frame
await get_tree().process_frame
var hero: Dictionary = main.run_scope.settlement.presentation_snapshot().hero
if int(hero.get("atk", -1)) != boosted_atk or int(hero.get("max_hp", -1)) != boosted_hp:
problems.append("战斗加成单次生效违例(atk=%s max_hp=%s,期望 %d/%d)" % [
str(hero.get("atk", "?")), str(hero.get("max_hp", "?")), boosted_atk, boosted_hp])
# ⑨ 对局中可开(18卡⑦ P2 甲/T2 防护——S1+ 停机点回归闸):战斗屏角标真实信号路径开窗;
# 棋盘输入不锁、状态角标不置灰(「默认开×遮挡复活」下必锁死的破约回归面);
# 对局中批量模拟/两层调试两钮禁用;当局数据六项与只读访问器逐项一致;关闭钮真实路径收窗恢复
var dbg_badge: Button = main.debug_badge
if dbg_badge == null or not dbg_badge.visible:
problems.append("对局中调试浮层角标未点亮(全局浮层最顶层失守)")
else:
if dbg_badge.disabled:
problems.append("战斗屏调试角标被置灰(T2 排除式判据失守)")
dbg_badge.pressed.emit() # 真实按钮信号路径 → 组合根 _on_debug_badge_pressed → C6 单口
if not main.debug_overlay.visible:
problems.append("对局中角标开浮窗失败(P2 甲对局中可达失守)")
if main._input_locked():
problems.append("对局中开浮窗后棋盘输入锁死(T2 防护失守——S1+ 停机点)")
if main.board_zone.status_badge.disabled:
problems.append("对局中开浮窗后状态角标置灰(T2 防护失守——S1+ 停机点)")
if main.debug_overlay.is_running():
problems.append("对局中上下文锁定误判为模拟在跑(is_running 语义互扰)")
if not main.debug_overlay._sim_btn.disabled or not main.debug_overlay._dbg_btn.disabled:
problems.append("对局中批量模拟/两层调试按钮未禁用(T2 并存态不开放失守)")
# 当局数据六项值对(T3——数值与只读访问器逐项一致;种子=adventure.master_seed 现值非 _seed_value 重算)
var rd: Dictionary = main.debug_overlay.run_data_probe()
var r = main.run_scope.adventure.runner()
if String(rd.get("seed", "")) != "42":
problems.append("当局数据种子 ≠ master_seed 现值(%s)" % str(rd.get("seed")))
if int(rd.get("level", -1)) != int(main.run_scope.adventure.level_index):
problems.append("当局数据关卡失对(%s ≠ %s)" % [str(rd.get("level")), str(main.run_scope.adventure.level_index)])
if int(rd.get("node", -999)) != int(r.node_index):
problems.append("当局数据节点失对(%s ≠ %s)" % [str(rd.get("node")), str(r.node_index)])
if String(rd.get("hp", "")) != "%d/%d" % [int(r.hero_hp), int(r.effective_max_hp())]:
problems.append("当局数据 HP 失对(%s)" % str(rd.get("hp")))
if int(rd.get("gp", -1)) != int(r.gp):
problems.append("当局数据 GP 失对(%s ≠ %s)" % [str(rd.get("gp")), str(r.gp)])
if int(rd.get("keystone", -1)) != int(main.run_scope.adventure.keystone):
problems.append("当局数据钥石失对(%s ≠ %s)" % [str(rd.get("keystone")), str(main.run_scope.adventure.keystone)])
var close_btn: Button = ProbeUiUtil.find_button(main.debug_overlay, "关闭", "prefix")
if close_btn == null:
problems.append("浮窗关闭钮缺失(真实收窗路径中断)")
else:
close_btn.pressed.emit()
if main.debug_overlay.visible or main._input_locked():
problems.append("对局中关闭浮窗后态失守(visible=%s locked=%s)"
% [str(main.debug_overlay.visible), str(main._input_locked())])
# ⑨b 只读巡检(17卡③形制)+零动效契约(17卡③ R5——零 AnimationPlayer+源码零 tween)
_overlay_readonly_contract(main, problems)
_overlay_zero_motion_contract(main, problems)
# ⑩ 种子三分单点断言(18卡⑦ C1——判定只落 _seed_value() 单点;对局态不影响本判定面):
# 显式合法整数全场景优先(含首局)→ 首局(存档布尔 false)恒 42 → 非首局空框引擎熵连抽不全等 42
main.debug_overlay.seed_edit.text = "777"
if main._seed_value() != 777:
problems.append("显式种子输入未优先(_seed_value != 777——C1 ①失守)")
# SaveManager 是 autoload 单例——换注走原地 attach(首局态挂载+恢复隔离档);
# --script 模式无裸 autoload 标识符——经 main 节点路径解析
var sm = main.get_node("/root/SaveManager")
sm.attach_path("user://smoke_tune_probe_seed.json") # 全新空档——first_adventure_done=false(首局态;新档缺省面)
main.debug_overlay.seed_edit.text = ""
if main._seed_value() != 42:
problems.append("首局空框种子 != 42(P5 甲失守——教学态可复现破约)")
sm.attach_path("user://smoke_tune_probe_save.json") # 恢复隔离档(非首局态——isolate_save 已建档置位)
var draws: Array[int] = []
for i in range(4):
draws.append(main._seed_value())
var all42 := true
for d in draws:
if d == 42:
all42 = all42 and true
else:
all42 = false
if all42:
problems.append("非首局空框连抽 4 次全等 42(随机化失守——C1 ③)")
main.debug_overlay.seed_edit.text = "42" # 钉回显式值(现场整洁)
for f0 in ["user://smoke_tune_probe_seed.json", "user://smoke_tune_probe_seed.json.tmp"]:
if FileAccess.file_exists(f0):
DirAccess.remove_absolute(f0)
if problems.is_empty():
print("[tune_probe] PASS — 启动即开/联动/应用/重置/模拟/关闭/基线无残留/单次加成/对局中可开+当局数据+只读巡检/种子三分 全链路通过(模拟 N=10 输出 %.30s…)" % out)
get_tree().quit(0)
else:
for p in problems:
push_error("[tune_probe] FAIL — " + p)
print("[tune_probe] FAIL — " + "; ".join(problems))
get_tree().quit(1)
# 只读巡检(18卡⑦ T3——17卡③「白名单外零连接」形制沿用):浮窗子树全控件 × 游戏语义信号名清单
# 全连接巡检——白名单={视图自身八方法 + 组合根 _on_modal_closed(closed 外发消费)};
# 当局数据区块=纯标签零连接在此被一并约束(新增任何连接=红须入册审查)。
func _overlay_readonly_contract(main: Node, problems: Array) -> void:
var overlay: Control = main.debug_overlay
var stack: Array = [overlay]
var conn_count := 0
while not stack.is_empty():
var n: Node = stack.pop_back()
for sig_name in _OVERLAY_READONLY_SIGNALS:
for conn in n.get_signal_connection_list(sig_name):
conn_count += 1
var cb: Callable = conn["callable"]
var host: Object = cb.get_object()
var m := String(cb.get_method())
var ok := (host == overlay and m in _OVERLAY_VIEW_METHODS) \
or (host == main and m == "_on_modal_closed") \
or (host is VScrollBar or host is HScrollBar) # 引擎滚动机构内部 drag 接线(RichTextLabel 自动滚动条——17卡③布局生命线同类豁免,零游戏写通路)
if not ok:
problems.append("[readonly] 非法信号连接 %s.%s → %s.%s(白名单外零连接巡检——18卡⑦ T3)"
% [n.name, sig_name, host, m])
for ch in n.get_children():
stack.append(ch)
if conn_count != 11:
problems.append("[readonly] 信号连接数=%d 应=11(fx切换/应用/重置/模拟/两层调试/诊断转储/关闭七钮+滑条2+closed 外发+引擎滚动条 drag 1——多连接=写通路嫌疑)" % conn_count)
# 零动效契约(17卡③ R5 形制——子树零 AnimationPlayer + 视图源码零 tween/动效包装调用)
func _overlay_zero_motion_contract(main: Node, problems: Array) -> void:
var overlay: Control = main.debug_overlay
var stack: Array = [overlay]
while not stack.is_empty():
var n: Node = stack.pop_back()
if n is AnimationPlayer:
problems.append("[motion] 浮窗子树含 AnimationPlayer(零动效契约破)")
for ch in n.get_children():
stack.append(ch)
var src := FileAccess.get_file_as_string("res://presentation/views/debug_overlay.gd")
for token in ["create_tween", "UiKit.motion"]:
if src.contains(token):
problems.append("[motion] 浮窗视图源码含「%s」(零动效直开直关破——17卡③契约)" % token)
+1
View File
@@ -0,0 +1 @@
uid://s8mepyuvkyl8
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://probe/ui_tune_probe.gd" id="1"]
[node name="UITuneProbe" type="Node"]
script = ExtResource("1")