From 43a7f5a1dd88e8919ec73c2c0865c2fe749f53cc Mon Sep 17 00:00:00 2001 From: DKChan <524912269@qq.com> Date: Fri, 23 Oct 2026 00:11:17 +0800 Subject: [PATCH] =?UTF-8?q?test(=E8=BF=81=E7=A7=BB):=20=E6=89=B9=20B=20?= =?UTF-8?q?=E6=B3=A2=201=20G1=20=E7=B3=BB=E7=BB=9F=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=E9=93=BE=E5=B0=BE=E6=AE=B5=E5=85=AD=E5=A5=97=2049=20=E6=9D=A1?= =?UTF-8?q?=E8=BF=81=E5=85=A5=20system=5Ftest.gd=E2=80=94=E2=80=94?= =?UTF-8?q?=E5=89=AA=E5=88=87=E6=8C=82=E8=BD=BD=E7=BB=BF=EF=BC=8B=E6=8C=89?= =?UTF-8?q?=E6=9D=A1=E5=8F=98=E5=BC=82=2047/49=20=E9=AA=8C=E7=BA=A2?= =?UTF-8?q?=EF=BC=88:279=20=E8=BF=81=E7=A7=BB=E4=B8=8D=E5=8F=98=E5=BC=8F?= =?UTF-8?q?=E4=B8=8E=20:421=20=E8=90=A5=E5=9C=B0=E6=8A=A4=E6=A0=8F?= =?UTF-8?q?=E8=AE=B0=E6=AD=BB=E6=96=AD=E8=A8=80=E5=80=99=E9=80=89=E7=85=A7?= =?UTF-8?q?=E8=BF=81=E7=A6=81=E5=88=A0=EF=BC=89=EF=BC=9Bsmoke=5Fall.gd=20?= =?UTF-8?q?=E5=90=8C=E6=89=B9=E5=88=A0=E5=85=AD=E5=87=BD=E6=95=B0=E4=B8=89?= =?UTF-8?q?=20helper=20=E7=98=A6=E5=A3=B3=E7=95=99=20G12=EF=BC=9B=E9=97=B8?= =?UTF-8?q?01=E5=89=A9=E4=BD=99=E9=9D=A21146=E7=BB=BF=EF=BC=8B=E6=96=B0?= =?UTF-8?q?=E5=AE=BF=E4=B8=BB65=E7=BB=BF=EF=BC=8B=E9=97=B802=E5=9B=9B?= =?UTF-8?q?=E5=8D=81=E5=A5=97=E4=BB=B6=E7=BB=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- game/smoke/smoke_all.gd | 500 ------------------------ game/tests/system_test.gd | 518 +++++++++++++++++++++++++ reports/批B-断言迁移账本-2026-10-22.md | 19 + 3 files changed, 537 insertions(+), 500 deletions(-) diff --git a/game/smoke/smoke_all.gd b/game/smoke/smoke_all.gd index 14963756..986f4570 100644 --- a/game/smoke/smoke_all.gd +++ b/game/smoke/smoke_all.gd @@ -80,16 +80,8 @@ func _initialize() -> void: 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 _smoke_type_gate() _smoke_hygiene_gates() - 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("=== 冒烟完成:%d 项检查,%d 项失败 ===" % [_checks, _failures]) quit(1 if _failures > 0 else 0) @@ -152,498 +144,6 @@ func _smoke_hygiene_gates() -> void: check(outside_calls == 0, "V1d 层序落位单口(_apply_layer_contract 之外 move_child 调用点=%d 应=0)〔装配生命批 V1〕" % outside_calls) -# ---- 三屏场景化断言(V2——独立实例化+关键节点在位+组合根树序/z 序保序)---- -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.lvbtn != null and menu.resumebtn != null and menu.campbtn != null and menu.careerbtn != null and menu.settingsbtn != null and menu.quitbtn != null, - "V2 菜单屏壳独立实例化(start_overlay+操作组六钮在位)") - 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 链路时序后置条件(启动态六断言;只立行为可判断言,不造假时序)---- -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() and not main._resume_btn.visible, - "V2d 续玩钮 boot 态恒隐(无激活态——启动态不隐式激活;断点在档=%s visible=%s)〔装配生命批 V2·卡① sv2 改写〕" % [ - str(sm.has_breakpoint()), str(main._resume_btn.visible)]) - # ④-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 甲一次性迁移已发生)〔卡① sv12〕") - # ④-2 隔离新档双向(attach 原地重置——绝不碰真实档 user://save.json;刷新单口与 boot 路径同源) - 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") - main._refresh_resume_button() - check(not sm.has_breakpoint() and not main._resume_btn.visible, - "V2d 续玩钮·无断点态隐藏(visible=%s has_breakpoint=%s)〔装配生命批 V2〕" % [ - str(main._resume_btn.visible), str(sm.has_breakpoint())]) - sm.set_breakpoint({"level_index": 2, "master_seed": 42}) - main._refresh_resume_button() - check(sm.has_breakpoint() and main._resume_btn.visible, - "V2d 续玩钮·有断点态可见(visible=%s has_breakpoint=%s)〔装配生命批 V2〕" % [ - str(main._resume_btn.visible), str(sm.has_breakpoint())]) - sm.clear_breakpoint() - main._refresh_resume_button() - check(not main._resume_btn.visible, - "V2d 续玩钮·清断点后回隐藏(visible=%s)〔装配生命批 V2〕" % str(main._resume_btn.visible)) - # ⑤ 闲时会话(_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) - - -# ---- 生命周期存活性矩阵(九阶段×五引用面;驱动复用 V3 debug seed 开局路径+ -# _auto_play_round 活战斗模式+ui_struct resume 夹具模式,不另起点击链)---- -# 阶段映射说明:计划书十阶段中「开局停点」与「地图选路」在本现行流程同为关卡画面停点态 -#(level_started→choices_presented 同步到达,无可观测中间态)——合并为 V3b 一处断言。 -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._refresh_resume_button() # 隔离后重同步(boot 时按钮态反映的是真实档——T2 已断 boot 关系,此处按隔离档定基) - 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, "resume_vis": false}) - # 阶段②开局停点(地图选路停点态) - 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": - var campbtn: Button = main._menu_screen.campbtn - if campbtn == null: - check(false, "V3g 营地入口缺失(驱动中断)〔装配生命批 V3〕") - continue - campbtn.pressed.emit() - 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}) - # 阶段⑧两连局(第二局全新会话——旧局对象不复用、脏态零残留) - 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}}, - }) - main._refresh_resume_button() - check(main._resume_btn.visible, "V3i 续玩钮·断点在档可见〔装配生命批 V3〕") - 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) - - -# 引用面快照(五承载物的会话相关面+恒活面——逐个调用现值回调,与 run_scope 成员并采) -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]) - "resume_vis": - if main._resume_btn.visible != bool(extra[k]): - bad += " _resume_btn.visible 应=%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 v != null and not is_instance_valid(v): - return " %s 悬挂(失效对象)" % name - return "" - - -# ---- 会话物化断言(V3——两连局复位/无脏状态残留)---- -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) - # ---- Core 集成冒烟(表加载成功才执行)---- var _db diff --git a/game/tests/system_test.gd b/game/tests/system_test.gd index 8846a55c..0030169d 100644 --- a/game/tests/system_test.gd +++ b/game/tests/system_test.gd @@ -8,6 +8,8 @@ # 运行:python tools/run_system_test.py --script res://tests/system_test.gd extends SceneTree +const AutoPlayer := preload("res://smoke/auto_player.gd") # 自动玩家策略单一实现(lifecycle 活战斗驱动;smoke 库件迁移期按现路径 preload) + var _failures: int = 0 var _checks: int = 0 @@ -23,5 +25,521 @@ func check(cond: bool, label: String) -> void: func _initialize() -> void: print("=== Dungeon Sweeper 系统测试(宿主薄壳——断言面待迁移批自 smoke_all.gd 迁入) ===") + _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.lvbtn != null and menu.resumebtn != null and menu.campbtn != null and menu.careerbtn != null and menu.settingsbtn != null and menu.quitbtn != null, + "V2 菜单屏壳独立实例化(start_overlay+操作组六钮在位)") + 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 _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() and not main._resume_btn.visible, + "V2d 续玩钮 boot 态恒隐(无激活态——启动态不隐式激活;断点在档=%s visible=%s)〔装配生命批 V2·卡① sv2 改写〕" % [ + str(sm.has_breakpoint()), str(main._resume_btn.visible)]) + # ④-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 甲一次性迁移已发生)〔卡① sv12〕") + # ④-2 隔离新档双向(attach 原地重置——绝不碰真实档 user://save.json;刷新单口与 boot 路径同源) + 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") + main._refresh_resume_button() + check(not sm.has_breakpoint() and not main._resume_btn.visible, + "V2d 续玩钮·无断点态隐藏(visible=%s has_breakpoint=%s)〔装配生命批 V2〕" % [ + str(main._resume_btn.visible), str(sm.has_breakpoint())]) + sm.set_breakpoint({"level_index": 2, "master_seed": 42}) + main._refresh_resume_button() + check(sm.has_breakpoint() and main._resume_btn.visible, + "V2d 续玩钮·有断点态可见(visible=%s has_breakpoint=%s)〔装配生命批 V2〕" % [ + str(main._resume_btn.visible), str(sm.has_breakpoint())]) + sm.clear_breakpoint() + main._refresh_resume_button() + check(not main._resume_btn.visible, + "V2d 续玩钮·清断点后回隐藏(visible=%s)〔装配生命批 V2〕" % str(main._resume_btn.visible)) + # ⑤ 闲时会话(_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) + + +# ---- 会话物化断言(V3——两连局复位/无脏状态残留)---- +# (自 game/smoke/smoke_all.gd 剪切迁入——label 与 cond 逐字原样,断言迁移批 B G1) +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]) + "resume_vis": + if main._resume_btn.visible != bool(extra[k]): + bad += " _resume_btn.visible 应=%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 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._refresh_resume_button() # 隔离后重同步(boot 时按钮态反映的是真实档——T2 已断 boot 关系,此处按隔离档定基) + 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, "resume_vis": false}) + # 阶段②开局停点(地图选路停点态) + 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": + var campbtn: Button = main._menu_screen.campbtn + if campbtn == null: + check(false, "V3g 营地入口缺失(驱动中断)〔装配生命批 V3〕") + continue + campbtn.pressed.emit() + 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}) + # 阶段⑧两连局(第二局全新会话——旧局对象不复用、脏态零残留) + 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}}, + }) + main._refresh_resume_button() + check(main._resume_btn.visible, "V3i 续玩钮·断点在档可见〔装配生命批 V3〕") + 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) diff --git a/reports/批B-断言迁移账本-2026-10-22.md b/reports/批B-断言迁移账本-2026-10-22.md index e68e5b0a..c9410766 100644 --- a/reports/批B-断言迁移账本-2026-10-22.md +++ b/reports/批B-断言迁移账本-2026-10-22.md @@ -225,3 +225,22 @@ 8. **_smoke_type_glow**:全量迁单元 presentation,与现役 type_glow_test 同域共存;重复面记「另批去重候选」,**本批禁删任何断言**(验证集不缩小为绝对约束)。 附(普查新发现落位,波 2 执行):`game/presentation/tune_sim_controller.gd:27` 与 `unit/pacing_queue_test.gd:53` 的 `res://smoke/` preload 指向 autoplay 驱动件——两类件随波 2 入 `game/sim/`,preload 同步改指;`体验验证清单:83` 旧函数名一并清扫;合同必改点修正——PARITY 触面实位 :209(合同写 :206 漂 3 行),`sim-data/analyze.py` 零命中从必改点除名。 + +## 九、波 1 施工记录(逐组追加;G1 —— 2026-10-22 波 1 主刀代理) + +**G1 系统·启动链尾段 → game/tests/system_test.gd:六套 49 条全部迁入,原子 commit 一笔。** +挂载终态=`_split_tail` 剪切形态(autoload 检查+四协程套依序 await+摘要 quit);G12 两套调用(_smoke_type_gate/_smoke_hygiene_gates)不随迁。helpers `_topmost_visible_layer/_face_snapshot/_face_liveness` 随用套迁入;`AutoPlayer` 按现路径 `res://smoke/auto_player.gd` preload(账本 §五夹具前提)。smoke_all.gd 同批删六函数+三 helper,`_split_tail` 瘦壳只留 G12 两套调用+摘要 quit。 + +逐套变异记账(每条独立一轮:改坏→跑新宿主→红→`git checkout` 还原→`git status` 清洁确认): + +- `_smoke_scene_split`(11 条):11/11 验红。变异点——:162 menu_screen.gd careerbtn 置 null(红,连带 V1c);:164 menu_screen.gd 尾加 start_overlay.visible=false(红唯一);:165 mouse_filter 改 STOP(红唯一);:170 camp_screen.gd camp_overlay 置 null(红,null 访问中断后续属预期形态);:171 camp_screen.gd 尾加 visible=true(红,连带 V1a);:176 adventure_screen.gd stage 置 null(红唯一);:187 main.gd:199 启动态 visible 翻 false(红唯一);:191 LAYER_CONTRACT 前两 shell 行对调(红,连带 V1a);:209 LAYER_CONTRACT legend_overlay key 改错名(红唯一);:211 main.gd:228 level_screen 句柄置 null(红;首试 board_view=null 因后续 set_identity_resolver 崩链过早、:211 未达,换点后成);:213 main.gd:197 闲时会话参数翻 true(红唯一)。 +- `_smoke_boot_chain`(15 条):14/15 验红。变异点——:250 config_db.gd:139 hero 置 null(红,连带 V1c);:255 settings_store.gd 音量 db 偏移 +1.0(红唯一);:259 motion_scale 两支对调(红唯一);:263 main.gd:199 翻 false(红,连带 V1a);:269 _refresh_resume_button has 恒 true(红,连带 :287/:297);:273 save_manager.gd:83 active_slot() 访问器恒返 1(红唯一);:275 keystone_balance() 偏移 +3(红唯一);:277 write_to_disk 无激活拒绝分支返 true(红唯一);:287 `_resume_btn.visible = not has`(红,连带 3 条同面);:292 恒 false(红唯一);:297 clear_breakpoint 改 pass(红唯一);:300 main.gd:197 翻 true(红,连带 :213);:304 _ready 尾插 debug_overlay.visible=true(红,连带 V1a);:306 badge 恒 false(红唯一)。 +- **:279 死断言候选(唯一一条,照迁禁删)**:「V2-s4 旧单档迁移不变式」——本机磁盘态已固化(save.json 与 save_slot_1.json 均在档=迁移已完成),cond 的可红面(旧档在而槽 1 缺)在生产码变异下不可达;三形态结论:迁移调用行注释=cond 恒真不红;slot_path 派生改坏会触发 _ready 真迁移副作用(碰真实档,红线禁止);迁移停摆+槽派生两处组合=save_manager 内部消费链崩、断言不可达。护栏语义在 fresh 环境有效(旧档存在且未迁时必红),本机环境无法复现该态。 +- `_smoke_run_lifecycle`(11 条):11/11 验红。变异点——:608/:638 main.gd:607(_start_adventure 内 `_begin_session(true)`)翻 false 两独立轮(各红);:610 run_scope.gd hints_shown 预置(红,连带 :630/:638);:619 adventure_runner.gd:830 abandon 返 false(红唯一);:624 :970 emit 注释(红,连带 :625);:625 battle_end_overlay.gd:165 is_open 恒 false(红唯一;首试注释 main.gd:700 else 体致 Parse Error 不算红,换点);:628 main.gd:727 收束会话翻 true(红唯一);:630 run_scope.gd boss_active 预置 true(红唯一);:632 main.gd:729 翻 false(红唯一);:634 adventure_runner.gd:967 clear_breakpoint 注释(红,连带 :638/:640);:640 main.gd:499 翻 true(红唯一)。 +- `_stage_faces`(1 条):1/1 验红。变异点——main.gd:383 `start_visible_get` 回调取反(红唯一,tag「V3a 闲时」)。 +- `_smoke_lifecycle_matrix`(10 条):9/10 验红。变异点——:374 battle_orchestrator.gd:1224 is_battle_live 恒真(驱动跑满 guard 600 红唯一);:388/:453 abandon 返 false 两独立轮(各红);:399 :970 emit 注释(红,连带 4 条);:405 main.gd:727 收束会话注释(红,连带多条);:408 _begin_session 的 queue_free 改 pass(红唯一;注释形态 Parse Error 不算红,换 pass);:421 **死断言候选(照迁禁删)**——menu_screen.gd campbtn 置 null 后 boot 的信号接线先崩(SCRIPT ERROR 中断 _build_ui),:421 护栏在执行序上被遮断不可达;其拦截语义由 boot 崩与前置断言代偿;:450 断点残留形态(:967 clear_breakpoint 注释,第二局走确认框分支不开局→adventure null)验红(首试 wire 断链因 run_lifecycle 先中断不可达,换形态);:479 `_resume_btn.visible` 恒 false(红,连带 :292);:482 main.gd:550 resume 不换会话(红唯一)。 +- `_split_tail`(1 条):1/1 验红。变异点——project.godot SaveManager autoload 行注释(走 else 分支,红唯一,1 项检查 1 失败)。 + +窄复验:新宿主面 65 项检查 0 失败 PASS(运行计数 65>静态 49,因 _stage_faces 每阶段调用各计一次——smoke 原生特性);闸 01 剩余面(smoke_all.gd)1146 项检查 0 失败 PASS;闸 02(run_all.gd)40 套件 all green。标签逐字抽查 6 条与冻结清单全对齐;新宿主 check( 调用计数 49=账本条数。 + +施工发现(供后续组参考):① Git Bash sed 的 `a\` 追加与含块结构注释变异易产 Parse Error(不算红),整行 `pass`/python 插入替代;② save_manager.gd `_init` 内对成员的赋值会被成员初始化器时序覆盖(probe 实证 active_slot 恒 0),变异请选访问器面;③ main.gd `_begin_session(true)` 有两处(_resume_from_menu:550、_start_adventure:607),awk 首匹配会错定位,变异前必核行号上下文。