Files
gplan-2/game/tests/system_test.gd
T

559 lines
30 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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/sv12——真实 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〕")
check(not (FileAccess.file_exists("user://save.json") and not FileAccess.file_exists(sm.slot_path(1))),
"V2-s4 旧单档迁移不变式(旧档在则槽 1 必在——Q1 甲一次性迁移已发生;旧档在盘面罕见,迁移逻辑本体由 save_manager_domain v0 迁移链断言覆盖)〔卡① sv12·核代码增补注〕")
# ④-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)