# tests/system_test.gd — 系统测试宿主薄壳(SceneTree+构造注入) # 系统测试 = 启动真实工程走真实玩法路径的断言面(标准四级测试层之一,闸 01)。 # 本件为系统测试宿主(标准四级测试层之一,闸 01)。(原过渡期自述作废——, # 本批只落宿主薄壳,跑通即绿)。迁移完成后 tools/run_system_test.py 缺省 --script 改指本件。 # 协议照单元侧惯例:extends SceneTree 获得 quit(code);--script 模式不加载 autoload, # 运行形态=--script 直跑+autoload 分派闸(ConfigDB/SaveManager 在座才进主断言面——闸 01 实跑 65 项 0 失败实证在案);摘要行由 tools/run_system_test.py 三闸判定 # (退出码+摘要失败数+脚本错误扫描)。 # 运行:python tools/run_system_test.py --script res://tests/system_test.gd extends SceneTree const AutoPlayer := preload("res://sim/auto_player.gd") # 自动玩家策略单一实现(lifecycle 活战斗驱动;smoke 库件迁移期按现路径 preload) var _failures: int = 0 var _checks: int = 0 func check(cond: bool, label: String) -> void: _checks += 1 if cond: print(" [PASS] " + label) else: _failures += 1 print(" [FAIL] " + label) func _initialize() -> void: print("=== Dungeon Sweeper 系统测试 ===") _split_tail() # ---- 场景拆分/会话物化异步尾段(autoload 挂载需过 _initialize 后首帧〔run_all.gd 头注时序〕, # 挂 main.tscn 的断言在帧后执行;摘要打印与 quit 在本尾段)---- # (自 game/smoke/smoke_all.gd 剪切迁入——断言 label 逐字原样;G12 两套调用不随迁, # 归 source_guard_test.gd,断言迁移批 B G1) func _split_tail() -> void: await process_frame await process_frame var autoload_ok: bool = get_root().get_node_or_null("/root/ConfigDB") != null and get_root().get_node_or_null("/root/SaveManager") != null if autoload_ok: await _smoke_scene_split() # 协程函数必须 await(无 await=发射后不管,首个 await 后即被 quit 截断) await _smoke_boot_chain() await _smoke_run_lifecycle() await _smoke_lifecycle_matrix() else: check(false, "V2/V3 前置:autoload 链未就绪(ConfigDB/SaveManager)") print("[system_test] 摘要:%d 项检查,%d 项失败" % [_checks, _failures]) quit(1 if _failures > 0 else 0) # ---- 三屏场景化断言(V2——独立实例化+关键节点在位+组合根树序/z 序保序)---- # (自 game/smoke/smoke_all.gd 剪切迁入——label 与 cond 逐字原样,断言迁移批 B G1) func _smoke_scene_split() -> void: print("--- 迭代27 T1 三屏场景化(V2:屏壳独立实例化+组合根装配保序)---") # ① 三屏壳独立实例化(F6 等价——headless 直挂;视图代码构建在屏壳 _ready) var menu: Node = load("res://presentation/scenes/menu_screen.tscn").instantiate() root.add_child(menu) await process_frame check(menu.start_overlay != null and menu.entersavebtn != null and menu.settingsbtn != null and menu.quitbtn != null, "V2 菜单屏壳独立实例化(start_overlay+操作组三钮在位——主菜单三入口化)〔卡② mn1〕") check(menu.start_overlay.visible, "V2 菜单层默认可见(非模态全屏态)") check(menu.mouse_filter == Control.MOUSE_FILTER_IGNORE, "V2 屏壳根零鼠标截获(IGNORE——全屏 STOP 壳吃输入病灶回归闸)") menu.queue_free() var camp: Node = load("res://presentation/scenes/camp_screen.tscn").instantiate() root.add_child(camp) await process_frame check(camp.camp_overlay != null, "V2 营地屏壳独立实例化(camp_overlay 在位)") check(not camp.camp_overlay.visible, "V2 营地层默认隐藏(模态纪律)") camp.queue_free() var adv: Node = load("res://presentation/scenes/adventure_screen.tscn").instantiate() root.add_child(adv) await process_frame check(adv.battle_root != null and adv.stage != null and adv.board_zone != null and adv.board_view != null and adv.ap_bar != null and adv.level_screen != null, "V2 冒险屏壳独立实例化(战斗宿主+四区句柄+关卡地图屏在位)") adv.queue_free() await process_frame # ② 组合根装配(合同单点化——LAYER_CONTRACT 唯一事实源:树序=登记序=z 序同源派生) var main: Node = load("res://presentation/main.tscn").instantiate() root.add_child(main) await process_frame # V1a 启动态顶层断言:boot 后自树顶向下第一个可见内容层=start_overlay(headless 可判) #(内容层遍历——屏壳是容器不当候选:camp 壳 boot 态 vis=true 但成员恒隐,裸树遍历会误命中) var top: Node = _topmost_visible_layer(main) check(top == main._start_overlay, "V1a 启动态顶层可见层=start_overlay(实测顶层=%s——菜单被冒险屏盖住即红)〔装配生命批 V1〕" % (top.name if top != null else "null")) # V1b 语义关系:start_overlay 在冒险屏内容之上(菜单屏壳在冒险屏壳之上——装配序修复判据) check(main._menu_screen.get_index() > main._adventure_screen.get_index(), "V1b 菜单屏壳在冒险屏壳之上(start_overlay 盖住战斗内容)〔装配生命批 V1〕") # V1c 合同树序链逐对保序(直接子层全链=屏壳/模态/全屏态/非视觉节点;宿住层树序随宿主不参与) var chain_detail := "" var chain_prev: Node = null var chain_ok := true 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: chain_ok = false chain_detail += " 缺失:%s" % String(entry["key"]) continue if chain_prev != null and node.get_index() <= chain_prev.get_index(): chain_ok = false chain_detail += " %s(%d)≤%s(%d)" % [chain_prev.name, chain_prev.get_index(), node.name, node.get_index()] chain_prev = node check(chain_ok, "V1c 合同树序链逐对保序(运行期树序==LAYER_CONTRACT 序;违例:%s)〔装配生命批 V1〕" % chain_detail) # ③ 探针挂载假设句柄在座(main.X 一级路径——探针迁移面收口证明) check(main._start_overlay == main._menu_screen.start_overlay and main.camp_overlay == main._camp_screen.camp_overlay and main.level_screen == main._adventure_screen.level_screen and main._battle_root == main._adventure_screen.battle_root and main.board_view == main._adventure_screen.board_view, "V2 组合根句柄指向屏壳成员(探针挂载假设 main.X 零迁移)") check(main.run_scope != null and not main.run_scope.is_adventure, "V2 闲时会话自 _ready 即在(永非 null;迭代 27 T3——gp 影子账退役无账本成员)") main.queue_free() await process_frame # 顶层可见内容层(V1a 用——候选=合同模态/全屏态+两屏壳的 boot 内容成员;屏壳本身是容器不当候选: # camp 壳 boot 态 vis=true 但成员恒隐,裸树遍历自顶向下会先命中透明壳) func _topmost_visible_layer(main: Node) -> Node: var candidates: Array[Node] = [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] var best: Node = null var best_x := -1 var best_y := -1 for c: Node in candidates: 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 best == null or rx > best_x or (rx == best_x and ry > best_y): best = c best_x = rx best_y = ry return best # ---- boot 链路时序后置条件(启动态六断言;只立行为可判断言,不造假时序)---- # (自 game/smoke/smoke_all.gd 剪切迁入——label 与 cond 逐字原样,断言迁移批 B G1) func _count_visible_buttons(node: Node) -> int: var n := 1 if (node is Button and node.visible) else 0 for c in node.get_children(): n += _count_visible_buttons(c) return n # ---- boot 链路时序后置条件(启动态六断言;只立行为可判断言,不造假时序)---- func _smoke_boot_chain() -> void: print("--- 迭代28 T2 boot 链路时序后置条件(V2:六断言)---") var main: Node = load("res://presentation/main.tscn").instantiate() root.add_child(main) await process_frame await process_frame # ① ConfigDB 就绪且表已载 var db = main.get_node("/root/ConfigDB") check(db.is_ready and db.hero != null, "V2a ConfigDB 就绪且表已载(is_ready+hero 表在座)〔装配生命批 V2〕") # ② 设置已应用(引擎态=settings_store 现值——真实设置文件照读,一致性判据与文件内容无关) var ss = main.settings_store var master := AudioServer.get_bus_index("Master") check(AudioServer.is_bus_mute(master) == (ss.volume_linear <= 0.001) and is_equal_approx(AudioServer.get_bus_volume_db(master), linear_to_db(maxf(ss.volume_linear, 0.001))), "V2b 设置已应用·音量(Master mute/volume_db=settings_store 现值 linear=%f)〔装配生命批 V2〕" % ss.volume_linear) var ui_kit = load("res://presentation/views/ui_kit.gd") check(ui_kit.motion_scale == (0.5 if ss.reduced_motion else 1.0), "V2b 设置已应用·减动效(UiKit.motion_scale=%s=settings_store.reduced_motion=%s)〔装配生命批 V2〕" % [ str(ui_kit.motion_scale), str(ss.reduced_motion)]) # ③ 启动态菜单可见 check(main._start_overlay.visible, "V2c 启动态 _start_overlay.visible=true(boot 后玩家见菜单)〔装配生命批 V2〕") # ④ 续玩钮可见态==SaveManager 断点在档态 var sm = main.get_node("/root/SaveManager") # ④-1 boot 路径:启动态钉死(卡① sv2)——boot 无激活态、断点在档恒否,续玩钮恒隐 #(原「按钮态==真实档断点态」关系判据随启动态换代改写:test-conventions §9.5 申报在案,卡② mn8 再承接) check(not sm.has_breakpoint(), "V2d boot 无断点在档(启动态不隐式激活——卡① sv2 钉死)〔装配生命批 V2·卡② mn8 改写〕") # ④-1b 启动态三断言(卡① sv2——真实 autoload boot 链) check(sm.active_slot() == 0 and not sm.has_write_target(), "V2-s1 boot 后无激活态(active_slot=0、写盘目标空)〔卡① sv2〕") check(sm.keystone_balance() == 0 and not sm.has_breakpoint() and sm.adventure_total() == 0, "V2-s2 boot 内存=默认合法空态(钥石/断点/总局数全零值)〔卡① sv2〕") check(not sm.write_to_disk(), "V2-s3 boot 无激活写盘拒绝(write_to_disk=false 生产入口)〔卡① sv3〕") # ④-2 三钮承接(卡② mn8——V2d 续玩钮族随钮退役废止:钮可见性联动面由卡① V2-s 启动态断言与 # sv11 attach 语义段承接;attach 原地重置语义保持——绝不碰真实档 user://save.json) for p in ["user://smoke_boot_chain.json", "user://smoke_boot_chain.json.tmp"]: if FileAccess.file_exists(p): DirAccess.remove_absolute(p) sm.attach_path("user://smoke_boot_chain.json") check(main._menu_screen.entersavebtn != null and main._menu_screen.settingsbtn != null and main._menu_screen.quitbtn != null, "V2d 三钮在位·进入存档/设置/退出游戏(续玩钮族退役后主菜单操作组承接)〔卡② mn1/mn8〕") for old_btn in ["lvbtn", "resumebtn", "campbtn", "careerbtn"]: check(not (old_btn in main._menu_screen), "V2d 四旧钮句柄零命中·%s(彻底撤出——禁隐藏态禁置灰态残留)〔卡② mn2〕" % old_btn) check(_count_visible_buttons(main._start_overlay) == 3, "V2d 渲染面可见按钮计数==3(旧钮接回即红——渲染面锚,句柄缝盲区闭合)〔卡② mn1/mn2〕") # ⑤ 闲时会话(_ready 即建、永非 null、在树) check(main.run_scope != null and not main.run_scope.is_adventure and main.run_scope.is_inside_tree(), "V2e run_scope 非空且为闲时会话(is_adventure=false+is_inside_tree)〔装配生命批 V2〕") # ⑥ 调试浮窗 boot 不开+菜单角标在(菜单优先——boot 不自动开浮窗, # 入口=开始屏右下角「调试」角标+对局中两屏角标) check(not main.debug_overlay.visible, "V2f 调试浮窗 boot 不自动开(菜单优先)〔2026-10-16 用户令〕") check(main.debug_badge != null and main.debug_badge.visible, "V2f 调试浮层角标 boot 可见(全局浮层入口在座)〔2026-10-16 用户令〕") main.queue_free() await process_frame for p in ["user://smoke_boot_chain.json", "user://smoke_boot_chain.json.tmp"]: if FileAccess.file_exists(p): DirAccess.remove_absolute(p) func _smoke_run_lifecycle() -> void: print("--- 迭代27 T1 会话物化(V3:两连局复位+脏状态不残留)---") var main: Node = load("res://presentation/main.tscn").instantiate() root.add_child(main) await process_frame # 存档隔离(autoload attach 原地重置——绝不碰真实档 user://save.json) var sm = main.get_node("/root/SaveManager") for p in ["user://smoke_run_lifecycle.json", "user://smoke_run_lifecycle.json.tmp"]: if FileAccess.file_exists(p): DirAccess.remove_absolute(p) sm.attach_path("user://smoke_run_lifecycle.json") sm.mark_first_adventure_done() # ---- 第一局:开局→注脏→放弃→终局出口→会话收束 ---- main.debug_overlay.seed_edit.text = "42" main._start_adventure_from_menu() await process_frame check(main.run_scope != null and main.run_scope.is_adventure and main.run_scope.adventure != null, "V3 开局建会话(is_adventure+adventure 在座)") check(main.run_scope.hints_shown.is_empty(), "V3 新会话局态零值") # 注入脏状态(典型泄漏源——新会话必须整体吞掉;刷新计数单源化在编排器,不属会话脏面) main.run_scope.hints_shown["chord"] = true main.run_scope.boss_active = true main.run_scope.last_gain_amount = 77 var finished := {"done": false} main.run_scope.adventure.adventure_finished.connect(func(_s: Dictionary) -> void: finished.done = true, CONNECT_ONE_SHOT) check(main.run_scope.adventure.abandon(), "V3 放弃受理(入口停点天然非战斗期)") for i in range(8): if finished.done: break await process_frame check(finished.done, "V3 冒险终局到达(abandon→adventure_finished 同步链)") check(main._end_overlay.is_open(), "V3 终局面板弹起") main._on_battle_end_continue() # 终局出口(回菜单+会话收束——手工置空清单已消亡) await process_frame check(not main.run_scope.is_adventure and main.run_scope.adventure == null and main.run_scope.runner == null, "V3 会话收束(新闲时会话零冒险/关卡对象)") check(main.run_scope.hints_shown.is_empty() and not main.run_scope.boss_active and main.run_scope.last_gain_amount == 0, "V3 局态封印/账本全复位(queue_free 物化——注入脏状态零残留)") check(main._start_overlay.visible, "V3 回开始菜单(终局出口路径)") # ---- 第二局:放弃终局无断点残留→直进开局→全新会话 ---- check(not sm.has_breakpoint(), "V3 放弃终局清断点(无残留断点)") main.debug_overlay.seed_edit.text = "43" main._start_adventure_from_menu() await process_frame check(main.run_scope.is_adventure and main.run_scope.adventure != null and main.run_scope.hints_shown.is_empty(), "V3 第二连局全新会话(两连局零泄漏)") check(not main._start_overlay.visible, "V3 直进入局(菜单隐)") main.queue_free() await process_frame for p in ["user://smoke_run_lifecycle.json", "user://smoke_run_lifecycle.json.tmp"]: if FileAccess.file_exists(p): DirAccess.remove_absolute(p) # 引用面快照(五承载物的会话相关面+恒活面——逐个调用现值回调,与 run_scope 成员并采) # (自 game/smoke/smoke_all.gd 剪切迁入——label 与 cond 逐字原样,断言迁移批 B G1) func _face_snapshot(main: Node) -> Dictionary: var tu = main._treasure_ui var bo = main._bonus var cp = main._camp_pres var mf = main._modal_flow var rp = main._row_pacer return { "treasure_settlement": tu._settlement_get.call(), "treasure_runner": tu._runner_get.call(), "bonus_meta": bo._meta_get.call(), "bonus_save": bo._save_get.call(), "bonus_adventure": bo._adventure_get.call(), "bonus_runner": bo._runner_get.call(), "bonus_mode": bo._adventure_mode_get.call(), "camp_meta": cp._meta_get.call(), "camp_save": cp._save_get.call(), "camp_runner": cp._runner_get.call(), "mf_start": mf.start_visible_get.call(), "mf_inter": mf.inter_visible_get.call(), "mf_settlement": mf.settlement_get.call(), "mf_rows_empty": mf.rows_empty_get.call(), "rp_log": rp.log_view, "rp_stage": rp.stage, "rp_board_zone": rp.board_zone, "rp_fx": rp.settlement_fx, "rp_board": rp.board_view, "rp_refresh": rp.refresh_request, "rp_pump": rp.pump_ended, "rs_adventure": main.run_scope.adventure, "rs_runner": main.run_scope.runner, "rs_settlement": main.run_scope.settlement, "rs_is_adventure": main.run_scope.is_adventure, } # 阶段面断言:adv/run/set=alive|null|both(会话三面存活态);恒活面每阶段必查; # 现值回调与 run_scope 成员必须同源(回调链无悬挂铁证);extra=阶段附加判据。 func _stage_faces(main: Node, tag: String, adv: String, run: String, set: String, extra: Dictionary = {}) -> void: var snap := _face_snapshot(main) var bad := "" for k in ["bonus_meta", "bonus_save", "camp_meta", "camp_save", "rp_log", "rp_stage", "rp_board_zone", "rp_fx", "rp_board"]: if not is_instance_valid(snap[k]): bad += " %s=失效" % k for k in ["rp_refresh", "rp_pump"]: var cb: Variant = snap[k] if not (cb is Callable) or not (cb as Callable).is_valid(): bad += " %s=非有效Callable" % k if not main.run_scope.is_inside_tree(): bad += " run_scope=不在树" bad += _face_liveness("adventure", snap["rs_adventure"], adv) bad += _face_liveness("runner", snap["rs_runner"], run) bad += _face_liveness("settlement", snap["rs_settlement"], set) if snap["treasure_settlement"] != snap["rs_settlement"]: bad += " treasure._settlement_get≠run_scope.settlement(回调悬挂)" if snap["treasure_runner"] != snap["rs_runner"]: bad += " treasure._runner_get≠run_scope.runner(回调悬挂)" if snap["bonus_adventure"] != snap["rs_adventure"]: bad += " bonus._adventure_get≠run_scope.adventure(回调悬挂)" if snap["bonus_runner"] != snap["rs_runner"]: bad += " bonus._runner_get≠run_scope.runner(回调悬挂)" if snap["camp_runner"] != snap["rs_runner"]: bad += " camp._runner_get≠run_scope.runner(回调悬挂)" if snap["mf_settlement"] != snap["rs_settlement"]: bad += " modal_flow.settlement_get≠run_scope.settlement(回调悬挂)" if bool(snap["bonus_mode"]) != bool(snap["rs_is_adventure"]): bad += " bonus._adventure_mode_get≠run_scope.is_adventure(回调悬挂)" for k in extra: match String(k): "mf_start": if bool(snap["mf_start"]) != bool(extra[k]): bad += " modal_flow.start_visible_get 应=%s" % str(extra[k]) "mf_inter": if bool(snap["mf_inter"]) != bool(extra[k]): bad += " modal_flow.inter_visible_get 应=%s" % str(extra[k]) "mf_rows_empty": if bool(snap["mf_rows_empty"]) != bool(extra[k]): bad += " modal_flow.rows_empty_get 应=%s" % str(extra[k]) "level_vis": if main.level_screen.visible != bool(extra[k]): bad += " level_screen.visible 应=%s" % str(extra[k]) "end_open": if main._end_overlay.is_open() != bool(extra[k]): bad += " _end_overlay.is_open 应=%s" % str(extra[k]) "start_vis": if main._start_overlay.visible != bool(extra[k]): bad += " _start_overlay.visible 应=%s" % str(extra[k]) check(bad.is_empty(), "%s(引用面违例:%s)〔装配生命批 V3〕" % [tag, bad]) # 单面存活态判据:want=null=必须缺位/alive=必须有效非空/both=两可但非空必有效 func _face_liveness(name: String, v, want: String) -> String: if want == "null": return "" if v == null else " %s 应缺位(实测非空)" % name if want == "alive" and v == null: return " %s 应在座(实测 null)" % name if v != null and not is_instance_valid(v): return " %s 悬挂(失效对象)" % name return "" # ---- 生命周期存活性矩阵(九阶段×五引用面;驱动复用 V3 debug seed 开局路径+ # _auto_play_round 活战斗模式+ui_struct resume 夹具模式,不另起点击链)---- # 阶段映射说明:计划书十阶段中「开局停点」与「地图选路」在本现行流程同为关卡画面停点态 #(level_started→choices_presented 同步到达,无可观测中间态)——合并为 V3b 一处断言。 # (自 game/smoke/smoke_all.gd 剪切迁入——label 与 cond 逐字原样,断言迁移批 B G1) func _smoke_lifecycle_matrix() -> void: print("--- 迭代28 T3 生命周期存活性矩阵(V3:九阶段×五引用面)---") var main: Node = load("res://presentation/main.tscn").instantiate() root.add_child(main) await process_frame await process_frame var sm = main.get_node("/root/SaveManager") for p in ["user://smoke_lifecycle.json", "user://smoke_lifecycle.json.tmp"]: if FileAccess.file_exists(p): DirAccess.remove_absolute(p) sm.attach_path("user://smoke_lifecycle.json") main.debug_overlay.seed_edit.text = "42" # 阶段①闲时 await _stage_faces(main, "V3a 闲时", "null", "null", "null", {"mf_start": true, "mf_inter": false, "mf_rows_empty": true, "start_vis": true}) # 阶段②开局停点(地图选路停点态) main._start_adventure_from_menu() for i in range(6): await process_frame await _stage_faces(main, "V3b 开局停点", "alive", "alive", "null", {"mf_start": false, "level_vis": true}) # 阶段③地图选路→战斗中(真实选路提交 press_candidate) main.level_screen.press_candidate(0) for i in range(10): await process_frame await _stage_faces(main, "V3c 战斗中", "alive", "alive", "alive", {"mf_start": false}) # 阶段④结算后/终局面(AutoPlayer 活战斗驱动到终局——_auto_play_round 同款 phase/round 守卫) var finished := {"done": false} main.run_scope.adventure.adventure_finished.connect(func(_s: Dictionary) -> void: finished.done = true, CONNECT_ONE_SHOT) var guard := 0 while main.run_scope.settlement != null and main.run_scope.settlement.is_battle_live() \ and not finished.done and guard < 600: var st = main.run_scope.settlement var ms = st.minesweeper var ae = st.action_economy var start_round: int = st.round_index var round_guard := 400 while ae.phase == "Active" and st.round_index == start_round and round_guard > 0 \ and main.run_scope.settlement != null: round_guard -= 1 var plan: Dictionary = AutoPlayer.choose_dig(ms) if plan.is_empty(): break if plan.hero_cell != ms.hero_pos(): ms.settle_batch([{"action": "move", "cell": plan.hero_cell}]) if ae.phase != "Active" or st.round_index != start_round: break ms.settle_batch([{"action": "dig", "cell": plan.dig_cell}]) if main.run_scope.settlement == null or not main.run_scope.settlement.is_battle_live(): break if ae.phase == "Active" and st.round_index == start_round: ms.settle_batch([{"action": "stop"}]) guard += 1 check(main.run_scope.settlement == null or not main.run_scope.settlement.is_battle_live(), "V3d 活战斗驱动完毕(战斗已收场——%d 轮,finished=%s)〔装配生命批 V3〕" % [guard, str(finished.done)]) main._flush_row_queue() for i in range(12): if main._end_overlay.is_open(): break await process_frame # 分支甲:战斗胜利停点(结算后)→确认进选路→放弃→终局面 if not finished.done: await _stage_faces(main, "V3d 结算后(胜利停点)", "alive", "alive", "both", {"mf_start": false, "end_open": true}) main._on_battle_end_continue() for i in range(8): await process_frame await _stage_faces(main, "V3d2 选路页(停点后)", "alive", "alive", "both", {"mf_start": false, "level_vis": true}) check(main.run_scope.adventure.abandon(), "V3d2 放弃受理(选路停点天然非战斗期)〔装配生命批 V3〕") for i in range(12): if finished.done: break await process_frame main._flush_row_queue() for i in range(8): if main._end_overlay.is_open(): break await process_frame # 阶段⑤终局面(败北/放弃皆达) check(finished.done and main._end_overlay.is_open(), "V3e 终局面到达(adventure_finished+终局面板开)〔装配生命批 V3〕") await _stage_faces(main, "V3e 终局面", "alive", "alive", "null", {"mf_start": false, "end_open": true}) # 阶段⑥回菜单收束(旧局消亡+新会话即驻+无真空窗) var old_scope = main.run_scope main._on_battle_end_continue() check(main.run_scope != old_scope and main.run_scope.is_inside_tree(), "V3f 收束换会话无真空窗(新闲时会话即刻在树)〔装配生命批 V3〕") await process_frame check(not is_instance_valid(old_scope), "V3f 收束后旧局经 queue_free 消亡(is_instance_valid=false)〔装配生命批 V3〕") await _stage_faces(main, "V3f 回菜单收束", "null", "null", "null", {"mf_start": true, "start_vis": true, "end_open": false}) # 阶段⑦各模态开合后(菜单域四模态真实路径开合——会话面零扰动) for modal_name in ["settings", "career", "camp", "debug"]: if modal_name == "settings": main._open_settings() elif modal_name == "career": main._open_career() elif modal_name == "camp": # 营地经进档开层(卡② mn9 新路径——campbtn 已撤);槽模板注入隔离命名空间 #(防碰真实槽 user://save_slot_N.json——真实档红线),营地闭合后恢复生命周期 attach 写盘目标 #(卡① sv3 无激活拒写——后续阶段断点写盘依赖) sm.attach_slot_template("user://smoke_lifecycle_slot_%d.json") main._open_save_select() for i in range(2): await process_frame var ssel_new: Button = main.save_select_overlay._new_btns.get(1) if ssel_new == null: check(false, "V3g 营地入口缺失(选档层新档钮驱动中断)〔装配生命批 V3〕") continue ssel_new.pressed.emit() # 创建=激活=暂行开营地(卡③ Q3 乙落点) else: main._show_debug_overlay() for i in range(4): await process_frame var open_extra: Dictionary = {"mf_start": false} if modal_name == "debug": open_extra = {"mf_start": true} # 调试浮窗=浮于菜单之上的 Dialog(不隐藏开始层) await _stage_faces(main, "V3g 模态开·%s" % modal_name, "null", "null", "null", open_extra) if modal_name == "settings": main._on_settings_closed() elif modal_name == "career": main._on_career_closed() elif modal_name == "camp": main._on_camp_closed() else: main._close_debug_overlay() for i in range(4): await process_frame await _stage_faces(main, "V3g 模态合·%s" % modal_name, "null", "null", "null", {"mf_start": true, "start_vis": true}) if modal_name == "camp": main._on_save_select_closed() # 关闭被栈还原的选档层(动线序中间态——卡④ 换代点) for i in range(2): await process_frame sm.attach_path("user://smoke_lifecycle.json") # 恢复生命周期写盘目标 for suf in ["", ".tmp"]: var slot_p: String = "user://smoke_lifecycle_slot_1.json" + suf if FileAccess.file_exists(slot_p): DirAccess.remove_absolute(slot_p) # 阶段⑧两连局(第二局全新会话——旧局对象不复用、脏态零残留) var first_adv = main.run_scope.adventure main.debug_overlay.seed_edit.text = "43" main._start_adventure_from_menu() for i in range(6): await process_frame check(main.run_scope.adventure != null and main.run_scope.adventure != first_adv, "V3h 两连局第二会话在座(开局停点——新冒险会话非空)〔装配生命批 V3〕") await _stage_faces(main, "V3h 两连局·开局停点", "alive", "alive", "null", {"mf_start": false, "level_vis": true}) check(main.run_scope.adventure.abandon(), "V3h 放弃受理(第二局)〔装配生命批 V3〕") for i in range(12): if main._end_overlay.is_open(): break await process_frame main._flush_row_queue() for i in range(8): if main._end_overlay.is_open(): break await process_frame await _stage_faces(main, "V3h 两连局·终局面", "alive", "alive", "null", {"mf_start": false, "end_open": true}) main._on_battle_end_continue() await process_frame await _stage_faces(main, "V3h 两连局·收束", "null", "null", "null", {"mf_start": true, "start_vis": true}) # 阶段⑨续玩断点(fresh 隔离档+最小合法断点夹具——ui_struct resume 夹具同构;换会话无真空窗) for p in ["user://smoke_lifecycle_resume.json", "user://smoke_lifecycle_resume.json.tmp"]: if FileAccess.file_exists(p): DirAccess.remove_absolute(p) sm.attach_path("user://smoke_lifecycle_resume.json") sm.set_breakpoint({ "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}}, }) var idle_scope = main.run_scope main._resume_from_menu() check(main.run_scope != idle_scope and main.run_scope.is_inside_tree(), "V3i 续玩换会话无真空窗(新冒险会话即刻在树)〔装配生命批 V3〕") for i in range(10): await process_frame await _stage_faces(main, "V3i 续玩断点", "alive", "both", "both", {"mf_start": false}) main.queue_free() await process_frame for p in ["user://smoke_lifecycle.json", "user://smoke_lifecycle.json.tmp", "user://smoke_lifecycle_resume.json", "user://smoke_lifecycle_resume.json.tmp"]: if FileAccess.file_exists(p): DirAccess.remove_absolute(p)