From d7d4bc87d309c5fc7895fc5f643c7de08703594e Mon Sep 17 00:00:00 2001 From: DKChan <524912269@qq.com> Date: Fri, 6 Nov 2026 05:23:29 +0800 Subject: [PATCH] =?UTF-8?q?=E6=89=B93(QA=20harness=C2=B7C=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E8=AF=95=E7=8E=A9=E6=89=A9=E5=B1=95):=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9Egame/sim/journey=5Fplayer.gd+.tscn=E5=85=A8=E5=8A=A8?= =?UTF-8?q?=E7=BA=BF=E6=9C=BA=E5=99=A8=E8=87=AA=E7=8E=A9=E9=A9=B1=E5=8A=A8?= =?UTF-8?q?=E4=BB=B6(=E5=BC=80=E6=9C=BA=E2=86=92=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E2=86=92=E5=AD=98=E6=A1=A3=E4=B8=89=E6=93=8D=E4=BD=9C=E6=96=B0?= =?UTF-8?q?=E6=A1=A3/=E5=88=A0=E6=A1=A3/=E8=BF=9B=E6=A1=A3=E2=86=92?= =?UTF-8?q?=E8=90=A5=E5=9C=B0=E4=BA=94=E7=AA=97=E9=80=90=E7=AA=97=E2=86=92?= =?UTF-8?q?Esc=E5=9B=9E=E9=80=80=E2=86=92=E5=A4=A7=E9=97=A8=E5=BC=80?= =?UTF-8?q?=E5=86=92=E9=99=A9=E2=86=92=E5=85=B3=E5=8D=A1=E6=8E=A8=E8=BF=9B?= =?UTF-8?q?=E2=86=92=E6=88=98=E6=96=97=E2=86=92=E5=81=9C=E7=82=B9=E5=88=86?= =?UTF-8?q?=E5=8F=91=E2=86=92=E7=BB=88=E5=B1=80=E6=94=B6=E6=9D=9F=E2=86=92?= =?UTF-8?q?=E5=9B=9E=E8=8F=9C=E5=8D=95);=20=E6=88=98=E6=96=97=E7=AD=96?= =?UTF-8?q?=E7=95=A5=E5=A7=94=E6=89=98=E6=97=A2=E6=9C=89auto=5Fplayer,?= =?UTF-8?q?=E6=8C=89=E9=92=AE/=E7=89=A9=E4=BB=B6=E5=85=A8=E7=9C=9F?= =?UTF-8?q?=E8=BE=93=E5=85=A5=E7=AE=A1=E7=BA=BF(ProbeUiUtil=E5=8D=95?= =?UTF-8?q?=E7=82=B9),=E4=B8=A4=E6=96=B9=E6=B3=95=E5=8F=A3=E5=A6=82?= =?UTF-8?q?=E5=AE=9E=E6=A0=87=E9=80=9A=E9=81=93(=E5=9C=B0=E5=9B=BE?= =?UTF-8?q?=E5=80=99=E9=80=89=3Dpress=5Fcandidate=E8=87=AA=E7=BB=98?= =?UTF-8?q?=E5=B8=83=E5=B1=80=E5=91=BD=E4=B8=AD,=E6=A3=8B=E7=9B=98?= =?UTF-8?q?=E6=A0=BC=3Dclick=5Fcell=20BoardView=E5=85=AC=E5=85=B1=E5=8F=A3?= =?UTF-8?q?=E2=80=94=E2=80=94=E6=A3=8B=E7=9B=98=E7=AE=A1=E7=BA=BF=E9=9D=A2?= =?UTF-8?q?=E5=BD=92ui=5Finput=5Fprobe);=20=E5=BC=82=E5=B8=B8=E4=B8=89?= =?UTF-8?q?=E7=B1=BB=E4=B8=8A=E6=8A=A5=E5=90=AB=E6=9C=BA=E5=99=A8=E8=87=AA?= =?UTF-8?q?=E5=A1=AB=E5=BD=92=E5=9B=A0=E5=88=9D=E5=88=A4+=E9=80=9A?= =?UTF-8?q?=E9=81=93=E6=A0=87=E7=AD=BE,fatal=3D=E8=AF=8A=E6=96=AD=E6=94=B6?= =?UTF-8?q?=E9=9B=86=E5=99=A8=E7=BA=A7(battle=5Fresult=3Dfatal=5Ferror),?= =?UTF-8?q?=E6=AE=B5=E9=97=B4=E8=B6=85=E6=97=B630s+=E6=88=98=E6=96=97?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E6=8C=82=E6=AD=BB90=E5=B8=A7,=E5=B1=8F?= =?UTF-8?q?=E4=BA=92=E6=96=A5=E5=85=AD=E5=AF=B9=E6=A3=80=E6=9F=A5;=20?= =?UTF-8?q?=E8=AF=AF=E6=8A=A5=E6=8C=87=E7=BA=B9ledger=E8=B7=A8=E8=B7=91?= =?UTF-8?q?=E6=8C=81=E4=B9=85(=E5=90=8C=E6=BA=90=E7=AC=AC2=E6=AC=A1?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E9=9D=99=E9=BB=98);=20=E6=8A=A5=E5=91=8Ajour?= =?UTF-8?q?ney=5Freport.js+=E5=91=BD=E4=B8=AD30=E6=8E=A7=E4=BB=B6=E5=B9=B6?= =?UTF-8?q?=E5=85=A5=E8=A6=86=E7=9B=96=E7=99=BB=E8=AE=B0=E3=80=82=E8=AF=BB?= =?UTF-8?q?=E6=95=B0:=20=E9=A6=96=E8=B7=9116=E7=B1=BB=E5=B1=8F=E5=88=B0?= =?UTF-8?q?=E8=BE=BE/=E4=B8=8A=E6=8A=A52=3D=E6=9C=BA=E5=99=A8=E9=A6=96?= =?UTF-8?q?=E8=B7=91=E5=8D=B3=E5=A4=8D=E7=8E=B0=E5=9C=A8=E6=A1=88=E7=BC=BA?= =?UTF-8?q?=E9=99=B7=E3=80=8C=E5=BC=80=E5=B1=80=E8=A2=AB=E5=BC=80=E5=A7=8B?= =?UTF-8?q?=E5=B1=82=E7=9B=96=E4=BD=8F=E3=80=8D(=E8=90=A5=E5=9C=B0?= =?UTF-8?q?=E4=BA=A4=E4=BA=92=E4=BF=AE=E5=A4=8D=E6=89=B9=E6=89=BF=E6=8E=A5?= =?UTF-8?q?=E4=B8=AD=E2=80=94=E2=80=94=E5=BC=80=E5=A7=8B=E5=B1=82=E5=8F=AF?= =?UTF-8?q?=E8=A7=81=E7=9B=B4=E5=85=A5locks=5Finput=E8=B0=93=E8=AF=8D?= =?UTF-8?q?=E9=94=81=E6=AD=BB=E6=88=98=E6=96=97,C=E4=B8=8A=E6=8A=A5?= =?UTF-8?q?=E5=90=8E=E5=A4=B9=E5=85=B7=E9=9D=A2=E9=9A=90=E8=97=8F=E7=BB=A7?= =?UTF-8?q?=E7=BB=AD=E8=B5=B0=E6=9F=A5,=E7=BC=BA=E9=99=B7=E6=9C=AC?= =?UTF-8?q?=E4=BD=93=E4=B8=8D=E6=8E=A9=E7=9B=96);=20=E4=BA=8C=E8=B7=91?= =?UTF-8?q?=E4=B8=8A=E6=8A=A50/=E9=9D=99=E9=BB=982/PASS=20exit=3D0(?= =?UTF-8?q?=E6=94=B6=E6=95=9B=E6=9C=BA=E5=88=B6=E5=AE=9E=E6=B5=8B=E7=94=9F?= =?UTF-8?q?=E6=95=88);=20=E5=86=92=E9=99=A9=E6=AE=B50=20fatal=E3=80=82?= =?UTF-8?q?=E5=AE=9E=E6=96=BD=E6=9C=9F=E8=87=AA=E4=BF=AE=E4=B8=A4=E5=9D=91?= =?UTF-8?q?:=20=E9=99=88=E6=97=A7=E6=A7=BD=E6=96=87=E4=BB=B6=E5=A4=B9?= =?UTF-8?q?=E5=85=B7=E5=8D=AB=E7=94=9F(=E6=AE=B5=E9=A6=96=E6=B8=85?= =?UTF-8?q?=E6=A7=BD)+=E5=BC=80=E5=B1=82=E6=8E=92=E7=89=88=E5=B8=A7(?= =?UTF-8?q?=E9=80=89=E6=A1=A3=E9=92=AE/=E9=9D=A2=E6=9D=BF=E7=BB=A7?= =?UTF-8?q?=E7=BB=AD=E9=92=AE=E5=90=8C=E5=B8=A7=E7=82=B9=E5=87=BB=E8=90=BD?= =?UTF-8?q?=E7=A9=BA=E2=80=94=E2=80=94=E9=80=89=E6=A1=A3=E7=A9=BA=E8=BD=AC?= =?UTF-8?q?=E5=81=87=E7=BA=A2=E4=B8=8E=E8=BE=93=E5=85=A5=E9=94=81=E6=BB=9E?= =?UTF-8?q?=E7=95=99=E4=B8=A4=E5=BD=A2=E6=80=81)=E3=80=82=E6=96=B9?= =?UTF-8?q?=E6=A1=88=E5=81=8F=E5=B7=AE6=E6=9D=A1=E5=9B=9E=E5=86=99qa-harne?= =?UTF-8?q?ss=E8=AE=A8=E8=AE=BA=E6=96=87=E6=A1=A3=C2=A712?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- game/sim/journey_player.gd | 587 +++++++++++++++++++++++++++++++++++ game/sim/journey_player.tscn | 6 + 待办清单.md | 2 +- 3 files changed, 594 insertions(+), 1 deletion(-) create mode 100644 game/sim/journey_player.gd create mode 100644 game/sim/journey_player.tscn diff --git a/game/sim/journey_player.gd b/game/sim/journey_player.gd new file mode 100644 index 00000000..5e64ae82 --- /dev/null +++ b/game/sim/journey_player.gd @@ -0,0 +1,587 @@ +# journey_player.gd — 全动线机器自玩驱动(C·发现环节去人化) +# 是什么:headless 场景驱动件——实例化 main、隔离存档,按旅程段驱动全动线(开机→设置→存档三操作 +# 新档/删档/进档→营地功能窗逐窗开合→Esc 回退→大门开冒险→关卡推进→战斗→各停点→终局收束→回菜单), +# 每屏到达记账+段间异常上报,收尾落 JSONP 报告并入探针覆盖登记(真输入事件同源记账)。 +# 判据(契约 §5):每个屏到达过+段间零 fatal。 +# 异常上报三类(其余静默):①诊断收集器 fatal 级(settlement battle_result=fatal_error/fatal 屏) +# ②段间超时(单段 >30s 未达预期停点;战斗段另设状态无变化挂死阈值)③屏状态与预期不符。 +# RID 泄漏等已知日志噪音静默。 +# 归因字段:每条上报机器自填——段名/期望屏/实测屏/上一动作/通道标签(真实输入-按钮/ +# 真实输入-场景热点/真实输入-键盘/方法-地图命中口/方法-棋盘口)。原归因制度残骸=本结构内字段。 +# 误报收敛:误报指纹(类+段+归因摘要)落 .gates/coverage/journey_misfire_ledger.json——同一指纹 +# 第 2 次起自动登记静默(计数照记、不再进上报清单);连 3 批零真缺陷判据收紧归用户复核轮。 +# 驱动纪律:按钮/场景物件全走真输入(push_input 管线,同探针判据——ProbeUiUtil 单点通道)。 +# 两处方法口如实标注通道:地图候选=press_candidate(自绘布局命中路径,探针族既定入口); +# 棋盘格=click_cell(BoardView 公共点击口,ui_input_probe 先例——棋盘管线面由该探针承载)。 +# 停点分发的视图内部读数(_continue_btn/_node_pending 等)沿用探针族直读受控例外(iter53 反哺点 9 同款在册)。 +# 运行:Godot_console.exe --headless --path . res://sim/journey_player.tscn +extends Node + +const ProbeUiUtil := preload("res://probe/probe_ui_util.gd") +const ProbeCoverageRecorder := preload("res://probe/probe_coverage_recorder.gd") +const AutoPlayer := preload("res://sim/auto_player.gd") + +const SEGMENT_TIMEOUT_MS := 30000 # 段间超时判据(契约 §5——单段 30s 未达预期停点) +const BATTLE_STALL_FRAMES := 90 # 战斗段状态无变化挂死阈值(演播期豁免同探针口径) +const ADVENTURE_BUDGET_FRAMES := 60000 # 冒险段帧预算(全停点循环兜底) +const MISFIRE_PATH := "res://tests/.gates/coverage/journey_misfire_ledger.json" +const REPORT_PATH := "res://tests/.gates/coverage/journey_report.js" + +var main: Node +var _recorder: RefCounted = null +var _problems: Array = [] # 异常上报(三类;指纹二次起静默不入此列) +var _silent_count := 0 # 误报静默笔数(回执可见) +var _screens_seen: Dictionary = {} # 屏到达记账(判据面) +var _screen_trace: Array = [] # 屏到达变迁轨迹(诊断面——新屏首现时点+上一动作) +var _misfire: Dictionary = {} # 指纹 → 次数(跨跑落盘) +var _last_state := {} # 战斗段状态哈希(挂死检测) +var _stall := 0 +var _last_action := "(无)" # 归因初判:上一动作描述 +var _map_status_done := false # 地图屏状态角标一次性 +var _legend_done := false # 图例开合一次性(每战斗面一次) +var _status_done := false # 战斗屏状态面板一次性 +var _refresh_done := false # 刷新确认链一次性 + + +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 + _load_misfire() + _recorder = ProbeCoverageRecorder.new() + _recorder.attach(main, "journey") + ProbeUiUtil.isolate_save(main, "journey_player", false, false, true) + main.save_manager.slots.attach_slot_template("user://journey_player_slot_%d.json") + for i in range(1, 4): # 夹具清场——陈旧槽文件会翻有档态、新档钮缺席(open_camp_via_savesel 同款坑注) + for suf in ["", ".tmp", ".corrupt.bak"]: + var sp: String = main.save_manager.slots.slot_path(i) + suf + if FileAccess.file_exists(sp): + DirAccess.remove_absolute(sp) + main.ui.modals.menu.debug_overlay.set_sliders(400, 200) # 高加成保 L1 可完成(探针先例——夹具注) + main.ui.modals.menu.debug_overlay.seed_edit.text = "42" + + await _phase_expect("开机落菜单", ["菜单"]) + await _phase_menu_settings() + await _phase_new_slot(1) + await _phase_camp_windows() + await _phase_camp_esc_return() + await _phase_new_and_delete(2) + await _phase_load_slot(1) + await _phase_gate_start() + await _phase_adventure() + await _phase_end_back_menu() + + _recorder.dump_js() + _dump_report() + _save_misfire() + var fatal := _fatal_count() + if _problems.is_empty() and fatal == 0 and _screens_seen.has("菜单") and _screens_seen.has("终局"): + print("[journey] PASS — 全动线走查完成(屏到达 %d 类,异常上报 0,fatal 0,误报静默 %d)" % [ + _screens_seen.size(), _silent_count]) + get_tree().quit(0) + return + print("[journey] FAIL — 异常 %d(静默 %d)/ fatal %d / 屏到达 %d 类" % [ + _problems.size(), _silent_count, fatal, _screens_seen.size()]) + for p in _problems: + print("[journey] - " + JSON.stringify(p, " ")) + get_tree().quit(1) + + +# ---- 屏状态读取与记账 ---- + +func _screen_state() -> Dictionary: + var st := { + "菜单": main.ui.screens.start_overlay != null and main.ui.screens.start_overlay.visible, + "选档": main.ui.modals.menu.save_select_overlay.visible, + "设置": main.ui.modals.menu.settings_overlay.visible, + "生涯": main.ui.modals.menu.career_overlay.visible, + "确认弹窗": main.ui.modals.menu.confirm_dialog.is_open(), + "营地": main.ui.modals.camp.scene.visible, + "锻造窗": main.ui.modals.camp.forge.visible, + "树窗": main.ui.modals.camp.tree.visible, + "秘宝窗": main.ui.modals.camp.treasure.visible, + "图鉴窗": main.ui.modals.camp.codex.visible, + "Esc浮窗": main.ui.modals.camp.esc_overlay.visible, + "冒险": main.run_scope != null and main.run_scope.is_adventure, + "关卡屏": main.ui.modals.run.level_screen != null and main.ui.modals.run.level_screen.visible, + "三选一": main.ui.modals.run.curio_overlay.visible, + "商店": main.ui.modals.menu.shop_overlay.visible, + "大奖励": main.ui.screens.inter_overlay != null and main.ui.screens.inter_overlay.visible, + "节点": main.ui.modals.run.node_overlay.visible, + "终局": main.ui.modals.run.end_overlay != null and main.ui.modals.run.end_overlay.is_open(), + } + var in_battle: bool = bool(st["冒险"]) and not bool(st["关卡屏"]) and not bool(st["终局"]) \ + and main.run_scope != null and main.run_scope.settlement != null \ + and String(main.run_scope.settlement.battle_result) == "" + st["战斗"] = in_battle + return st + + +func _record_screens() -> void: + var st := _screen_state() + for k in st: + if bool(st[k]) and not _screens_seen.has(k): + _screens_seen[k] = true + _screen_trace.append({"t": Time.get_datetime_string_from_system(), "screen": k, "last_action": _last_action}) + _check_exclusion(st) + + +var _excl_reported: Dictionary = {} # 屏互斥违约对(本跑一次性上报——跨跑由误报指纹静默) + +# 屏级互斥(探针同款判据对):合法叠加仅「终局盖冒险」;违约=③屏状态与预期不符 +func _check_exclusion(st: Dictionary) -> void: + var pairs := [["菜单", "营地"], ["菜单", "冒险"], ["营地", "冒险"], ["终局", "营地"], ["终局", "菜单"], ["菜单", "战斗"]] + for pair in pairs: + if bool(st.get(pair[0], false)) and bool(st.get(pair[1], false)): + var fk: String = str(pair[0]) + "×" + str(pair[1]) + if not _excl_reported.has(fk): + _excl_reported[fk] = true + _report("screen_mismatch", "屏级互斥", "%s 与 %s 不应同场" % [pair[0], pair[1]]) + + +func _active_screens() -> Array: + var on: Array = [] + var st := _screen_state() + for k in st: + if bool(st[k]): + on.append(k) + return on + + +func _fatal_count() -> int: + var n := 0 + if main.run_scope != null and main.run_scope.settlement != null \ + and String(main.run_scope.settlement.battle_result) == "fatal_error": + n += 1 + return n + + +# ---- 异常上报(三类+归因+误报收敛) ---- + +func _report(kind: String, seg: String, expect: String) -> void: + var finger_src := "%s|%s|%s|%s" % [kind, seg, expect, ",".join(_active_screens())] + var finger := str(finger_src.hash()) + _misfire[finger] = int(_misfire.get(finger, 0)) + 1 + if int(_misfire[finger]) >= 2: + _silent_count += 1 # 同一误报源第 2 次自动登记静默(契约 §5 收敛口径) + return + _problems.append({ + "kind": kind, "segment": seg, "expect": expect, + "actual_screens": _active_screens(), "last_action": _last_action, + "fatal": _fatal_count(), "at": Time.get_datetime_string_from_system(), + }) + + +func _load_misfire() -> void: + if FileAccess.file_exists(MISFIRE_PATH): + var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(MISFIRE_PATH)) + if parsed is Dictionary: + _misfire = parsed + + +func _save_misfire() -> void: + var f := FileAccess.open(MISFIRE_PATH, FileAccess.WRITE) + if f != null: + f.store_string(JSON.stringify(_misfire, " ")) + f.close() + + +# ---- 段间等待(超时判据承载) ---- + +func _frames(n: int = 2) -> void: + for _i in n: + await get_tree().process_frame + _record_screens() + + +# 期望屏集合在超时内逐帧到达;到达即记账返回。超时=异常②超时+③屏状态不符双记(同源指纹静默收敛)。 +func _phase_expect(seg: String, expect_keys: Array) -> void: + _last_action = "等待段「%s」落点(期望 %s)" % [seg, ",".join(expect_keys)] + var t0 := Time.get_ticks_msec() + while Time.get_ticks_msec() - t0 < SEGMENT_TIMEOUT_MS: + await get_tree().process_frame + _record_screens() + var st := _screen_state() + var all_on := true + for k in expect_keys: + all_on = all_on and bool(st.get(k, false)) + if all_on: + await _frames(2) # 开层排版帧——当帧可见时容器未排,紧随的控件真点击会落空(探针件同款坑) + return + _report("timeout", seg, "屏 %s 应在场" % ",".join(expect_keys)) + _report("screen_mismatch", seg, "屏 %s 应在场" % ",".join(expect_keys)) + _save_misfire() + + +# ---- 旅程段 ---- + +func _phase_menu_settings() -> void: + # 设置开合(开始层真实入口——功能界面全覆盖义务) + var btn: Button = ProbeUiUtil.find_button(main.ui.screens.start_overlay, "设置", "prefix") + if btn == null: + _report("screen_mismatch", "设置入口", "开始层「设置」钮在场") + return + _last_action = "真点「设置」钮" + ProbeUiUtil.push_click(main, btn) + await _phase_expect("设置开", ["设置"]) + var back: Button = ProbeUiUtil.find_button(main.ui.modals.menu.settings_overlay, "返回", "prefix") + if back != null: + _last_action = "真点设置「返回」钮" + ProbeUiUtil.push_click(main, back) + await _phase_expect("设置收", ["菜单"]) + + +func _phase_new_slot(slot: int) -> void: + # 开机→存档·新档(选档层真实入口:进入存档→槽位新档→落营地) + var esb: Button = ProbeUiUtil.find_button(main.ui.screens.start_overlay, "进入存档", "prefix") + if esb == null: + _report("screen_mismatch", "选档入口", "开始层「进入存档」钮在场") + return + _last_action = "真点「进入存档」钮" + ProbeUiUtil.push_click(main, esb) + await _phase_expect("选档层开", ["选档"]) + var nb: Button = main.ui.modals.menu.save_select_overlay._new_btns.get(slot) + if nb == null: + _report("screen_mismatch", "新档槽%d" % slot, "槽%d 新档钮在场" % slot) + return + _last_action = "真点槽%d「新档」钮" % slot + ProbeUiUtil.push_click(main, nb) + await _phase_expect("新档落营地", ["营地"]) + + +func _phase_load_slot(slot: int) -> void: + var esb: Button = ProbeUiUtil.find_button(main.ui.screens.start_overlay, "进入存档", "prefix") + if esb == null: + _report("screen_mismatch", "选档入口", "开始层「进入存档」钮在场") + return + _last_action = "真点「进入存档」钮(进档链)" + ProbeUiUtil.push_click(main, esb) + await _phase_expect("选档层开", ["选档"]) + var lb: Button = main.ui.modals.menu.save_select_overlay._load_btns.get(slot) + if lb == null: + _report("screen_mismatch", "进档槽%d" % slot, "槽%d 进档钮在场" % slot) + return + _last_action = "真点槽%d「进档」钮" % slot + ProbeUiUtil.push_click(main, lb) + await _phase_expect("进档落营地", ["营地"]) + + +func _phase_new_and_delete(slot: int) -> void: + # 新档→篝火回菜单→选档删档(二次确认双路径真点击)——存档三操作收口 + await _phase_new_slot(slot) + var camp: Control = main.ui.modals.camp.scene + _last_action = "真点篝火热点(回退动线)" + ProbeUiUtil.push_click_at(main, camp.get_hotspot_rect(&"campfire").get_center()) + await _phase_expect("篝火确认弹窗", ["确认弹窗"]) + var ok: Button = main.ui.modals.menu.confirm_dialog._confirm_btn + if ok != null: + _last_action = "真点篝火确认「确认」钮" + ProbeUiUtil.push_click(main, ok) + await _phase_expect("回菜单", ["菜单"]) + # 删档:进选档→槽位删档一击开窗→真点取消→再删→真点确认(文件三态走查归探针既有套件——C 只验到达) + var esb: Button = ProbeUiUtil.find_button(main.ui.screens.start_overlay, "进入存档", "prefix") + if esb != null: + _last_action = "真点「进入存档」钮(删档链)" + ProbeUiUtil.push_click(main, esb) + await _phase_expect("选档层开", ["选档"]) + var db: Button = main.ui.modals.menu.save_select_overlay._delete_btns.get(slot) + if db != null: + _last_action = "真点槽%d「删档」钮一击" % slot + ProbeUiUtil.push_click(main, db) + await _phase_expect("删档确认窗", ["确认弹窗"]) + var cc: Button = main.ui.modals.menu.confirm_dialog._cancel_btn + if cc != null: + _last_action = "真点删档「取消」钮" + ProbeUiUtil.push_click(main, cc) + await _frames(2) + _last_action = "真点槽%d「删档」钮二击" % slot + ProbeUiUtil.push_click(main, db) + await _phase_expect("删档确认窗", ["确认弹窗"]) + var ok2: Button = main.ui.modals.menu.confirm_dialog._confirm_btn + if ok2 != null: + _last_action = "真点删档「确认」钮" + ProbeUiUtil.push_click(main, ok2) + await _frames(3) + var back: Button = ProbeUiUtil.find_button(main.ui.modals.menu.save_select_overlay, "返回", "prefix") + if back != null: + _last_action = "真点选档「返回」钮" + ProbeUiUtil.push_click(main, back) + await _phase_expect("选档收", ["菜单"]) + + +func _phase_camp_windows() -> void: + # 营地功能窗逐窗真点开合(锻造/秘宝/树/图鉴/生涯——campfire/gate 归动线段) + var views := { + &"forge": ["锻造窗", main.ui.modals.camp.forge], + &"chest": ["秘宝窗", main.ui.modals.camp.treasure], + &"dummy": ["树窗", main.ui.modals.camp.tree], + &"board": ["图鉴窗", main.ui.modals.camp.codex], + &"notebook": ["生涯", main.ui.modals.menu.career_overlay], + } + for key: StringName in [&"forge", &"chest", &"dummy", &"board", &"notebook"]: + var camp: Control = main.ui.modals.camp.scene + _last_action = "真点营地物件热点 %s" % String(key) + ProbeUiUtil.push_click_at(main, camp.get_hotspot_rect(key).get_center()) + await _phase_expect("%s窗开" % String(key), [views[key][0]]) + var back: Button = ProbeUiUtil.find_button(views[key][1], "返回营地") + if back == null: + back = ProbeUiUtil.find_button(views[key][1], "返回", "prefix") + if back != null: + _last_action = "真点 %s 窗返回钮" % String(key) + ProbeUiUtil.push_click(main, back) + await _frames(3) + + +func _phase_camp_esc_return() -> void: + # 营地 Esc 浮窗→「返回主菜单」真点(回退动线+浮窗功能面到达) + _push_esc() + await _phase_expect("Esc浮窗开", ["Esc浮窗"]) + var rb: Button = main.ui.modals.camp.esc_overlay._return_btn + if rb != null: + _last_action = "真点浮窗「返回主菜单」钮" + ProbeUiUtil.push_click(main, rb) + await _phase_expect("浮窗回菜单", ["菜单"]) + + +func _phase_gate_start() -> void: + # 大门确认→开冒险(动线收口——确认后落点=冒险,非回菜单) + var camp: Control = main.ui.modals.camp.scene + _last_action = "真点大门热点" + ProbeUiUtil.push_click_at(main, camp.get_hotspot_rect(&"gate").get_center()) + await _phase_expect("大门确认弹窗", ["确认弹窗"]) + var ok: Button = main.ui.modals.menu.confirm_dialog._confirm_btn + if ok != null: + _last_action = "真点大门「确认」钮" + ProbeUiUtil.push_click(main, ok) + await _phase_expect("冒险发起", ["冒险"]) + # 已知缺陷现场(发现环义务已尽):大门开局后开始层滞留盖屏=「开局被开始层盖住」 + # (营地交互修复批承接中、合回前 master 在案)——开始层可见直入 locks_input 谓词,战斗全程锁死。 + # C 上报后以夹具面隐藏开始层继续走查(通道=夹具修整,非真输入;缺陷本体不因此掩盖)。 + await _frames(2) + if main.ui.screens.start_overlay != null and main.ui.screens.start_overlay.visible: + _report("screen_mismatch", "大门开局", "开始层滞留盖屏(已知缺陷——营地交互修复批承接中)") + _last_action = "夹具修整:隐藏滞留开始层以解锁战斗走查(缺陷已上报在案)" + main.ui.screens.start_overlay.visible = false + await _frames(2) + + +func _phase_adventure() -> void: + # 关卡推进段:入口选路→战斗→各停点分发→真终局收口(帧预算兜底) + var budget := ADVENTURE_BUDGET_FRAMES + while budget > 0: + budget -= 1 + await get_tree().process_frame + _record_screens() + var st := _screen_state() + if bool(st["节点"]): + _last_action = "节点层「继续」(方法-视图口)" + main.ui.modals.run.node_overlay.press_continue() + continue + if bool(st["三选一"]): + _drive_curio() + continue + if bool(st["商店"]): + var lv: Button = ProbeUiUtil.find_button(main.ui.modals.menu.shop_overlay, "离开商店") + if lv != null: + _last_action = "真点「离开商店」钮" + ProbeUiUtil.push_click(main, lv) + continue + if bool(st["大奖励"]): + var nb: Button = ProbeUiUtil.find_button(main.ui.screens.inter_overlay, "进入下一层", "prefix") + if nb != null: + _last_action = "真点大奖励「进入下一层」钮" + ProbeUiUtil.push_click(main, nb) + await _frames(2) + continue + if bool(st["关卡屏"]): + _drive_map_once() + continue + if bool(st["战斗"]): + await _drive_battle_once() + continue + var endo: Control = main.ui.modals.run.end_overlay + if endo != null and endo.is_open(): + var armed: bool = main.run_scope.settlement_stop_timer.is_armed() + var running: bool = main.run_scope.is_adventure and main.run_scope.adventure != null \ + and String(main.run_scope.adventure.state) == "running" + if armed or running: + _last_action = "真点结算面「继续/下一节点」钮" + ProbeUiUtil.push_click(main, main.ui.modals.run.end_overlay._btn) + await _frames(2) + continue + break # 真终局(失败/放弃/打穿)——收口段交棒 + if budget <= 0: + _last_action = "冒险段超时(诊断:%s)" % JSON.stringify({ + "pacer_pending": main.ui.screens.row_pacer.pending_rows().size(), + "locked": main.ui.flow.locks_input(), + "battle_result": String(main.run_scope.settlement.battle_result) if main.run_scope.settlement != null else "?", + "level": int(main.run_scope.runner.level_row.get("level_index", -1)) if main.run_scope.runner != null else -1, + "round": int(main.run_scope.settlement.round_index) if main.run_scope.settlement != null else -1, + "ap": int(main.run_scope.settlement.action_economy.ap) if main.run_scope.settlement != null else -1, + }) + _report("timeout", "冒险段", "帧预算耗尽未达真终局") + _save_misfire() + + +func _drive_map_once() -> void: + # 地图屏:状态角标真点开合一次+候选命中(方法-地图命中口——自绘布局路径,探针族同款入口) + if not _map_status_done and main.ui.modals.run.level_screen.status_badge != null \ + and not main.ui.modals.run.level_screen.status_badge.disabled: + _map_status_done = true + var badge: Button = main.ui.modals.run.level_screen.status_badge + _last_action = "真点地图屏状态角标" + ProbeUiUtil.push_click(main, badge) + await _frames(3) # 面板开层排版帧——同帧点「继续」会落空、输入锁滞留全战役(实证) + var resumed: Button = ProbeUiUtil.find_button(main.ui.modals.run.status_panel, "继续", "prefix") + if resumed != null and main.ui.modals.run.status_panel.visible: + ProbeUiUtil.push_click(main, resumed) + _last_action = "地图候选命中(方法-地图命中口)" + main.ui.modals.run.level_screen.press_candidate(0) + + +func _drive_curio() -> void: + # 三选一停点:待选态点首件+确认;兑付/已消费态点继续(方法-视图口——curio 停点既定驱动面) + var co: Control = main.ui.modals.run.curio_overlay + _last_action = "三选一停点分发(方法-视图口)" + if co._continue_btn.visible: + co.press_continue() + elif main.run_scope.adventure != null and not main.run_scope.adventure.stop()._node_pending.is_empty(): + co.press_card(0) + co.press_confirm() + elif main.run_scope.adventure != null and not main.run_scope.adventure.stop()._pending.is_empty(): + co.press_card(0) + co.press_confirm() + else: + co.press_continue() + + +func _drive_battle_once() -> void: + # 战斗单步:挂死检测+功能钮真点(图例/状态/刷新确认链/停手)+棋盘挖格(方法-棋盘口) + var settlement = main.run_scope.settlement + var diag: Dictionary = settlement.diagnostic_snapshot() + var st := { + "round": diag.round, "phase": diag.action_economy.phase, "ap": diag.action_economy.ap, + "revealed": diag.minesweeper_revealed, + } + var pacer_idle: bool = main.ui.screens.row_pacer.pending_rows().is_empty() + if JSON.stringify(st) == JSON.stringify(_last_state) and pacer_idle \ + and not main.ui.flow.locks_input(): + _stall += 1 + if _stall >= BATTLE_STALL_FRAMES: + _report("timeout", "战斗段", "状态无变化达 %d 帧" % BATTLE_STALL_FRAMES) + _stall = 0 + _save_misfire() + _last_state = {} # 重置基线防同帧连报 + else: + _stall = 0 + _last_state = st + if not pacer_idle or main.ui.flow.locks_input(): + return + var ap_bar: Control = main.ui.battle.board_zone.ap_bar + # 功能钮真点(一次性——图例开合/状态面板开合/刷新确认链) + if not _legend_done: + _legend_done = true + _last_action = "真点图例钮" + ProbeUiUtil.push_click(main, ap_bar._btn_legend) + await _frames(2) + var lclose: Button = ProbeUiUtil.find_button(main.ui.modals.run.legend_overlay, "关闭") + if lclose != null and main.ui.modals.run.legend_overlay.is_open(): + ProbeUiUtil.push_click(main, lclose) + await _frames(2) + return + if not _status_done and not main.ui.battle.board_zone.status_badge.disabled: + _status_done = true + _last_action = "真点战斗屏状态角标" + ProbeUiUtil.push_click(main, main.ui.battle.board_zone.status_badge) + await _frames(3) # 面板开层排版帧(同地图屏实证坑) + var resumed: Button = ProbeUiUtil.find_button(main.ui.modals.run.status_panel, "继续", "prefix") + if resumed != null and main.ui.modals.run.status_panel.visible: + ProbeUiUtil.push_click(main, resumed) + await _frames(2) + return + if not _refresh_done and ap_bar.is_refresh_ready(): + _refresh_done = true + _last_action = "真点「弃图刷新」钮+确认链" + ProbeUiUtil.push_click(main, ap_bar._btn_refresh) + await _frames(2) + var cbtn: Button = ProbeUiUtil.find_button(ap_bar, "确认弃图") + if cbtn != null: + ProbeUiUtil.push_click(main, cbtn) + await _frames(2) + return + # 棋盘动作(方法-棋盘口——BoardView 公共点击口,ui_input_probe 先例;C 的按钮面全走真输入) + var minesweeper = settlement.minesweeper + var plan: Dictionary = AutoPlayer.choose_dig(minesweeper) + if plan.is_empty(): + plan = AutoPlayer.gamble_pick(minesweeper) + if plan.is_empty(): + _last_action = "真点「停手」钮(无计划)" + ProbeUiUtil.push_click(main, ap_bar._btn_stop) + await _frames(2) + return + _last_action = "棋盘挖格 %s(方法-棋盘口)" % str(plan.dig_cell) + if plan.hero_cell != minesweeper.hero_pos(): + main.ui.battle.board_zone.board_view.click_cell(plan.hero_cell, MOUSE_BUTTON_LEFT) + main.ui.battle.board_zone.board_view.click_cell(plan.dig_cell, MOUSE_BUTTON_LEFT) + await _frames(1) + + +func _phase_end_back_menu() -> void: + # 终局收束:真点「返回」→ 回菜单;结算框/中途面已在冒险段分发 + var t0 := Time.get_ticks_msec() + while Time.get_ticks_msec() - t0 < SEGMENT_TIMEOUT_MS: + await get_tree().process_frame + _record_screens() + var endo: Control = main.ui.modals.run.end_overlay + if endo != null and endo.is_open() and not main.run_scope.settlement_stop_timer.is_armed(): + break + if main.ui.modals.run.end_overlay == null or not main.ui.modals.run.end_overlay.is_open(): + _report("timeout", "终局收束", "真终局面未在超时内到达") + _save_misfire() + return + var btn: Button = main.ui.modals.run.end_overlay._btn + if btn != null: + _last_action = "真点终局「返回」钮" + ProbeUiUtil.push_click(main, btn) + await _phase_expect("终局回菜单", ["菜单"]) + + +func _push_esc() -> void: + var ev := InputEventKey.new() + ev.keycode = KEY_ESCAPE + ev.physical_keycode = KEY_ESCAPE + ev.pressed = true + main.get_viewport().push_input(ev, true) # 键盘真输入(通道=真实输入-键盘) + + +# ---- 报告落盘(JSONP) ---- + +func _dump_report() -> void: + var dir := DirAccess.open("res://") + if dir != null: + dir.make_dir_recursive("tests/.gates/coverage") + var payload := { + "meta": { + "generated_at": Time.get_datetime_string_from_system(), + "seed": OS.get_environment("PROBE_SEED"), + "screens_seen_total": _screens_seen.size(), + "anomalies_reported": _problems.size(), + "anomalies_silent": _silent_count, + "fatal_count": _fatal_count(), + "anomaly_classes": "fatal/timeout/screen_mismatch(其余静默;误报指纹二次起静默)", + }, + "screens_seen": _screens_seen.keys(), + "screen_trace": _screen_trace, + "anomalies": _problems, + } + var f := FileAccess.open(REPORT_PATH, FileAccess.WRITE) + if f != null: + var js := "/* 全动线自玩报告——journey_player.gd 随跑覆写;误报核销归用户复核轮。 */\n" + js += "window.__QA_COVERAGE__ = window.__QA_COVERAGE__ || {};\n" + js += "window.__QA_COVERAGE__.journey = %s;\n" % JSON.stringify(payload, " ") + f.store_string(js) + f.close() + print("[journey] 报告已落盘 %s(屏到达 %d 类 / 上报 %d / 静默 %d)" % [ + REPORT_PATH, _screens_seen.size(), _problems.size(), _silent_count]) diff --git a/game/sim/journey_player.tscn b/game/sim/journey_player.tscn new file mode 100644 index 00000000..40b5bf23 --- /dev/null +++ b/game/sim/journey_player.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3 uid="uid://cjourneyplayer1"] + +[ext_resource type="Script" path="res://sim/journey_player.gd" id="1"] + +[node name="JourneyPlayer" type="Node"] +script = ExtResource("1") diff --git a/待办清单.md b/待办清单.md index 1ead02eb..6937f7d8 100644 --- a/待办清单.md +++ b/待办清单.md @@ -253,7 +253,7 @@ #### QA 覆盖总览面板与自动试玩扩展——自动发现测试验证缺漏(2026-11-05 登记,来源=会话提及) -**实况**:方案全文=`D:\code\qa-harness-discussion\QA-Harness讨论结果-中间报告.md`(三批定案;批1实施偏差八条回写其 §10);证据=能力行「营地七物件命中区点选与分界面可达」残险 game/tests/README.md:158+test-conventions §8.3/§2 条文。进度=①②已落地(worktree qa-harness:批1=两探针 51 处真输入化+1 接线断言保留+覆盖登记件+§2 条文;批2=B 面板四节+义务层提取器+六源回执明细化闸壳挂点+本体枚举器 865 实体对账,工具四件入 tools/,提交 bb9ce8a1)③待批 2 验收后开工(C 自动试玩扩展)。裁决在案勿重提:A 执行覆盖矩阵不做、D 缺口闸不做、变异测试禁入、归因登记不立独立制度。 +**实况**:方案全文=`D:\code\qa-harness-discussion\QA-Harness讨论结果-中间报告.md`(三批定案;批1实施偏差八条回写其 §10);证据=能力行「营地七物件命中区点选与分界面可达」残险 game/tests/README.md:158+test-conventions §8.3/§2 条文。进度=①②已落地(worktree qa-harness:批1=两探针 51 处真输入化+1 接线断言保留+覆盖登记件+§2 条文;批2=B 面板四节+义务层提取器+六源回执明细化闸壳挂点+本体枚举器 865 实体对账,工具四件入 tools/,提交 bb9ce8a1)③已落地(journey_player 场景驱动件:全动线 16 类屏到达+异常三类上报+误报指纹跨跑静默收敛实测生效+真输入命中并入覆盖登记 30 控件;首跑机器复现在案缺陷「开局被开始层盖住」)。裁决在案勿重提:A 执行覆盖矩阵不做、D 缺口闸不做、变异测试禁入、归因登记不立独立制度。 **改动面**:游戏代码+工具代码+文档