From 315e46038c8aad04d04412f5af3534585d91dbc3 Mon Sep 17 00:00:00 2001 From: DKChan <524912269@qq.com> Date: Fri, 2 Oct 2026 08:46:43 +0800 Subject: [PATCH] =?UTF-8?q?T6=20=E6=8E=A2=E9=92=88=E5=9F=BA=E5=BB=BA?= =?UTF-8?q?=E6=94=B6=E5=8F=A3=E6=AE=B5:=20=E4=B8=89=E6=97=8F=E6=A0=B7?= =?UTF-8?q?=E6=9D=BF=E6=94=B6=E5=8D=95=E7=82=B9=EF=BC=88probe=5Fui=5Futil?= =?UTF-8?q?=E6=A0=91=E5=AF=BB=E5=9D=80contains/begins=5Fwith=E5=8F=82?= =?UTF-8?q?=E6=95=B0=E5=8C=96+isolate=5Fsave=E4=B8=83=E4=BB=BD=E9=9A=94?= =?UTF-8?q?=E7=A6=BB=E6=A0=B7=E6=9D=BF/battle=5Fdriver=E4=BA=94=E4=BB=BD?= =?UTF-8?q?=E6=88=98=E6=96=97=E9=A9=B1=E5=8A=A8=EF=BC=89+level=5Fautoplay?= =?UTF-8?q?=E5=8F=8C=E6=88=98=E6=96=97=E5=BE=AA=E7=8E=AF=E5=90=88=E5=B9=B6?= =?UTF-8?q?=5Fdecide=5Fact+test=5Fkit=E6=94=B613=E5=A5=97=E4=BB=B6=5Fcheck?= =?UTF-8?q?=E6=A0=B7=E6=9D=BF=EF=BC=88=E6=96=AD=E8=A8=80=E6=95=B0=E9=80=90?= =?UTF-8?q?=E5=A5=97=E4=BB=B6=E9=9B=B6=E4=B8=A2=E5=A4=B1=E5=AF=B9=E7=85=A7?= =?UTF-8?q?=E8=A1=A8=E5=9C=A8=E6=8A=A5=E5=91=8A=EF=BC=89+BAD=5FDIR?= =?UTF-8?q?=E5=A4=B9=E5=85=B7=E5=85=B1=E4=BA=AB+smoke=5Fall=E4=BA=94?= =?UTF-8?q?=E4=BB=B6=EF=BC=88:953=E5=85=AC=E6=9C=89=E9=9D=A2clamped=5Fpool?= =?UTF-8?q?/=5Fbattle=5Fst=E4=B8=83=E8=A2=8B/check=5Fsilent=E5=B9=B6?= =?UTF-8?q?=E5=85=A5quiet=E5=8F=82/=5Fpct=E8=BD=AC=E5=8F=91=E5=88=A0/load?= =?UTF-8?q?=E6=94=B9preload=EF=BC=89=EF=BC=9B=E6=8E=A2=E9=92=88=E5=B0=8F?= =?UTF-8?q?=E4=BB=B6=3Dmain=E5=8F=A5=E6=9F=84Node=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E6=B3=A8/=E9=AD=94=E6=B3=95=E7=AD=89=E5=BE=85=E5=B8=B8?= =?UTF-8?q?=E9=87=8F=E5=8C=96/=E7=94=BB=E5=B8=83=E5=B8=B8=E6=95=B0?= =?UTF-8?q?=E5=90=8C=E6=BA=90=E9=94=9A=E6=B3=A8/=E6=AD=BB=E5=8F=82verbose?= =?UTF-8?q?=E5=88=A0=EF=BC=9B=E7=BA=A2=E7=BA=BF=E5=AE=88=E4=BD=8F=3Dui=5Fi?= =?UTF-8?q?nput=20case=E2=91=A3b=E4=B8=8Eclick=E5=BE=AA=E7=8E=AF=E4=BD=93?= =?UTF-8?q?=E9=9B=B6=E8=A7=A6=E7=A2=B0/=E6=8B=8D=E6=9D=BF=E7=82=B9?= =?UTF-8?q?=E4=B8=89=E4=BB=B6=E6=9C=AA=E9=80=80=E5=BD=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- game/smoke/battle_driver.gd | 37 ++++++ game/smoke/battle_driver.gd.uid | 1 + game/smoke/damage_probe.gd | 42 ++----- game/smoke/dump_battle.gd | 18 +-- game/smoke/fx_shot_probe.gd | 40 +++---- game/smoke/level_autoplay.gd | 67 +++-------- game/smoke/monte_carlo.gd | 24 +--- game/smoke/probe_ui_util.gd | 112 ++++++++++++++++++ game/smoke/probe_ui_util.gd.uid | 1 + game/smoke/shot_probe.gd | 85 +++---------- game/smoke/skill_rhythm_probe.gd | 19 +-- game/smoke/smoke_all.gd | 40 ++++--- game/smoke/ui_click_probe.gd | 81 ++++--------- game/smoke/ui_input_probe.gd | 19 ++- game/smoke/ui_layout_probe.gd | 17 +-- game/smoke/ui_probe.gd | 34 ++---- game/smoke/ui_tune_probe.gd | 20 ++-- .../damage_controller_test.gd | 88 ++++++-------- .../battle_settlement/damage_math_test.gd | 61 ++++------ .../unit/contracts/system_contracts_test.gd | 50 +++----- game/tests/unit/core/aux_items_test.gd | 42 +++---- .../tests/unit/core/breakpoint_resume_test.gd | 96 +++++++-------- .../data_config/aux_target_violation_test.gd | 19 +-- game/tests/unit/data_config/config_db_test.gd | 72 +++++------ .../data_config/reward_dangling_aux_test.gd | 18 +-- .../unit/data_config/rng_service_test.gd | 32 ++--- game/tests/unit/example_test.gd | 21 +--- .../unit/presentation/reward_overlay_test.gd | 48 ++++---- .../presentation/terrain_tile_sets_test.gd | 44 +++---- .../unit/presentation/ui_font_asset_test.gd | 30 ++--- game/tests/unit/test_kit.gd | 72 +++++++++++ game/tests/unit/test_kit.gd.uid | 1 + 32 files changed, 587 insertions(+), 764 deletions(-) create mode 100644 game/smoke/battle_driver.gd create mode 100644 game/smoke/battle_driver.gd.uid create mode 100644 game/smoke/probe_ui_util.gd create mode 100644 game/smoke/probe_ui_util.gd.uid create mode 100644 game/tests/unit/test_kit.gd create mode 100644 game/tests/unit/test_kit.gd.uid diff --git a/game/smoke/battle_driver.gd b/game/smoke/battle_driver.gd new file mode 100644 index 00000000..250098c0 --- /dev/null +++ b/game/smoke/battle_driver.gd @@ -0,0 +1,37 @@ +# VERTICAL SLICE - NOT FOR PRODUCTION +# 战斗驱动循环单点(T6 样板收口批 2026-10-02)——smoke 族探针共用的「挖掘至停手」整轮驱动。 +# 策略 = AutoPlayer.choose_dig(O4 单一实现):挖最近可挖格(异格先移位)/ 无可挖或相位离 Active 停手。 +# 语义锚(monte_carlo 原型逐位保持):guard 400 步防挂死;移位后相位/轮次离场即 break; +# 轮末仍 Active 且未换轮 → settle_batch stop。smoke_all._run_battle/_auto_play_round 为冒烟本体 +# 不经本面(含回归断言——2026-10-02 修复批加,禁并入);fx_shot_probe 为带墙钟抓帧的异步变体亦不在列。 +extends RefCounted + +const AutoPlayer := preload("res://smoke/auto_player.gd") + +const GUARD_ACTIONS := 400 # 单轮动作步防挂死上限(被收口探针原值逐一同值) + + +# 驱动一整轮挖掘。still_running = 调用方战斗活性谓词(缺省恒真): +# monte_carlo / skill_rhythm 传 `func(): return out.result == ""`(battle_finished 已发的轮内不再动手), +# damage_probe 关卡驱动传 runner 绑定 + battle_result 谓词,battle 级探针传空即可。 +static func drive_round(settlement, still_running: Callable = Callable()) -> void: + var start_round: int = settlement.round_index + var minesweeper = settlement.minesweeper + var guard := GUARD_ACTIONS + while _alive(still_running) and settlement.action_economy.phase == "Active" \ + and settlement.round_index == start_round and guard > 0: + guard -= 1 + var plan := AutoPlayer.choose_dig(minesweeper) + if plan.is_empty(): + break + if plan.hero_cell != minesweeper.hero_pos(): + minesweeper.settle_batch([{"action": "move", "cell": plan.hero_cell}]) + if settlement.action_economy.phase != "Active" or settlement.round_index != start_round: + break + minesweeper.settle_batch([{"action": "dig", "cell": plan.dig_cell}]) + if _alive(still_running) and settlement.action_economy.phase == "Active" and settlement.round_index == start_round: + minesweeper.settle_batch([{"action": "stop"}]) + + +static func _alive(still_running: Callable) -> bool: + return still_running.is_null() or bool(still_running.call()) diff --git a/game/smoke/battle_driver.gd.uid b/game/smoke/battle_driver.gd.uid new file mode 100644 index 00000000..a40674d7 --- /dev/null +++ b/game/smoke/battle_driver.gd.uid @@ -0,0 +1 @@ +uid://dbattledrv13r diff --git a/game/smoke/damage_probe.gd b/game/smoke/damage_probe.gd index 6d86aa07..8bf58a11 100644 --- a/game/smoke/damage_probe.gd +++ b/game/smoke/damage_probe.gd @@ -12,7 +12,7 @@ # Godot_console.exe --headless --path . --script res://smoke/damage_probe.gd -- level 42 extends SceneTree -const AutoPlayer := preload("res://smoke/auto_player.gd") +const BattleDriver := preload("res://smoke/battle_driver.gd") # T6 收口:挖掘驱动循环单点(策略仍归 AutoPlayer 单一实现) var _checks := 0 var _fails := 0 @@ -67,7 +67,7 @@ func _probe_boss(db, seed_val: int) -> void: print("--- Run A:fresh 池(零挖掘停手轮——无授予,攻击链裸验)---") var run_a := _run_boss(db, seed_val, false) - _print_rounds(db, math, run_a, true) + _print_rounds(db, math, run_a) var a_ok := _assert_rounds(db, math, run_a, true) check(a_ok.fresh_and_full, "A1 攻击链健康(fresh 池·掷点口径):非蓄力拍 d_tick = max(下限, 掷点档位基线+附加)(×%d 拍)——H2 攻击链缺陷否证" % a_ok.n_attack) check(a_ok.arith_ok, "A2 池算术完整:全部非蓄力拍 d_tick == 掷点口径期望(×%d 拍)" % a_ok.n_attack) @@ -77,7 +77,7 @@ func _probe_boss(db, seed_val: int) -> void: print("--- Run B:自动挖掘(授予照常发生——当前数据承伤行为实测)---") var run_b := _run_boss(db, seed_val, true) - _print_rounds(db, math, run_b, true) + _print_rounds(db, math, run_b) var b_ok := _assert_rounds(db, math, run_b, false) check(b_ok.arith_ok, "B1 池算术完整(含授予轮):全部非蓄力拍 d_tick == 掷点口径期望(×%d 拍)" % b_ok.n_attack) check(b_ok.wall_tick_positive, "B2 负池轮不归零(伤害下限):hero_def<0 的轮 d_tick 恒 ≥ damage_floor(×%d 轮,最深实测池 %d)" % [b_ok.n_wall, b_ok.deepest]) @@ -98,28 +98,16 @@ func _run_boss(db, seed_val: int, dig: bool) -> Dictionary: var rounds := 0 while settlement.battle_result == "" and rounds < 40: rounds += 1 - var start_round: int = settlement.round_index if dig: - var minesweeper = settlement.minesweeper - var guard := 400 - while settlement.action_economy.phase == "Active" and settlement.round_index == start_round and guard > 0: - guard -= 1 - var plan := AutoPlayer.choose_dig(minesweeper) - if plan.is_empty(): - break - if plan.hero_cell != minesweeper.hero_pos(): - minesweeper.settle_batch([{"action": "move", "cell": plan.hero_cell}]) - if settlement.action_economy.phase == "Active": - minesweeper.settle_batch([{"action": "dig", "cell": plan.dig_cell}]) - if settlement.action_economy.phase == "Active" and settlement.round_index == start_round: - minesweeper.settle_batch([{"action": "stop"}]) + BattleDriver.drive_round(settlement) else: settlement.minesweeper.settle_batch([{"action": "stop"}]) return cap # 逐轮:授予行汇总 → 重建战斗段时点池值(effect_pool_map 表驱动归属)→ 敌行动行逐行打印 -func _print_rounds(db, math, run: Dictionary, verbose: bool) -> void: +#(T6 收口批:原形参 verbose 为死参——打印行为无档位差异,随批删除) +func _print_rounds(db, math, run: Dictionary) -> void: var groups := _round_groups(run.rows) var beats := _played_beats(run, db) var rounds: Array = groups.keys() @@ -356,22 +344,8 @@ func _drive_battle(runner, settlement, cap: Dictionary) -> void: var round_guard := 60 while cap["result"] == "" and runner.current_battle() == settlement and settlement.battle_result == "" and round_guard > 0: round_guard -= 1 - var start_round: int = settlement.round_index - var minesweeper = settlement.minesweeper - var action_guard := 400 - while runner.current_battle() == settlement and settlement.battle_result == "" and settlement.action_economy.phase == "Active" \ - and settlement.round_index == start_round and action_guard > 0: - action_guard -= 1 - var plan := AutoPlayer.choose_dig(minesweeper) - if plan.is_empty(): - break - if plan.hero_cell != minesweeper.hero_pos(): - minesweeper.settle_batch([{"action": "move", "cell": plan.hero_cell}]) - if settlement.action_economy.phase != "Active" or settlement.round_index != start_round: - break - minesweeper.settle_batch([{"action": "dig", "cell": plan.dig_cell}]) - if runner.current_battle() == settlement and settlement.battle_result == "" and settlement.action_economy.phase == "Active" and settlement.round_index == start_round: - minesweeper.settle_batch([{"action": "stop"}]) + BattleDriver.drive_round(settlement, func() -> bool: + return runner.current_battle() == settlement and settlement.battle_result == "") # 战斗判定摘要:round-1 victory 的战斗必须能从结算行分辨「首轮全灭」vs「空场」—— diff --git a/game/smoke/dump_battle.gd b/game/smoke/dump_battle.gd index ad352a5b..b411ef41 100644 --- a/game/smoke/dump_battle.gd +++ b/game/smoke/dump_battle.gd @@ -5,7 +5,7 @@ # 运行:Godot_console.exe --headless --path . --script res://smoke/dump_battle.gd extends SceneTree -const AutoPlayer := preload("res://smoke/auto_player.gd") # O4 收口:自动玩家策略单一实现 +const BattleDriver := preload("res://smoke/battle_driver.gd") # T6 收口:挖掘驱动循环单点(策略仍归 AutoPlayer 单一实现) func _initialize() -> void: var db = load("res://core/config_db.gd").new() @@ -28,21 +28,7 @@ func _initialize() -> void: var rounds := 0 while settlement.battle_result == "" and rounds < 40: rounds += 1 - var start_round: int = settlement.round_index - var minesweeper = settlement.minesweeper - var action_economy = settlement.action_economy - var guard := 400 - while action_economy.phase == "Active" and settlement.round_index == start_round and guard > 0: - guard -= 1 - var plan := AutoPlayer.choose_dig(minesweeper) - if plan.is_empty(): - break - if plan.hero_cell != minesweeper.hero_pos(): - minesweeper.settle_batch([{"action": "move", "cell": plan.hero_cell}]) - if action_economy.phase == "Active": - minesweeper.settle_batch([{"action": "dig", "cell": plan.dig_cell}]) - if action_economy.phase == "Active" and settlement.round_index == start_round: - minesweeper.settle_batch([{"action": "stop"}]) + BattleDriver.drive_round(settlement) # T6 收口:原内联循环语义同型(移动期免 AP——移动不换轮,移位后重查 break 分支为防御性等价) print("战斗结果: %s(%d 轮)" % [settlement.battle_result, rounds]) print("=== 结算步骤流(%d 行)===" % stream.size()) for row in stream: diff --git a/game/smoke/fx_shot_probe.gd b/game/smoke/fx_shot_probe.gd index b9ce1ef9..4c346b91 100644 --- a/game/smoke/fx_shot_probe.gd +++ b/game/smoke/fx_shot_probe.gd @@ -6,7 +6,16 @@ # 渲染在(本探针 PNG)」双证的第二半边。PNG 落输出目录(artwork/qa-fx-*/,素材纪律不入 git)。 extends Node -var main +const ProbeUiUtil := preload("res://smoke/probe_ui_util.gd") # T6 收口:树寻址/隔离样板单点 + +# ── 演出采样窗口口径(拍板点:headless/窗口化帧率不定,帧时刻以墙钟近似锚定;改窗口 = 改抓帧 +# 时刻观测面,非断言语义。窗口值与原字面量逐一同值——T6 收口批仅提具名。) ── +const T_DIG_FLIGHT := 0.12 # 挖格行到达/弹道在飞窗口(settle_batch 回执后停留) +const T_SETTLE := 0.35 # 结算排播窗口(行到达后二连抓) +const T_ROUND_TICK := 0.25 # 停手轮末(敌方攻击弹道/受击闪白 0.2-1.6s 流过)连抓 6 帧的采样步距 +const T_FINAL := 0.6 # 收尾帧前静置 + +var main: Node var out_dir := "" var _problems: Array = [] @@ -19,17 +28,10 @@ func _ready() -> void: main = load("res://presentation/main.tscn").instantiate() add_child(main) await get_tree().process_frame - for p0 in ["user://fx_shot_probe.json", "user://fx_shot_probe.json.tmp", "user://fx_shot_probe_settings.cfg"]: - if FileAccess.file_exists(p0): - DirAccess.remove_absolute(p0) - var iso_c = load("res://core/save_manager.gd").new("user://fx_shot_probe.json") - iso_c.load_from_disk() - main.save_manager = iso_c - main.meta = load("res://core/meta_progression.gd").new(main.db, iso_c) - main.settings_store = load("res://presentation/settings_store.gd").new("user://fx_shot_probe_settings.cfg") + ProbeUiUtil.isolate_save(main, "fx_shot_probe", false, true, false) for i in 8: await get_tree().process_frame # UI 装配完成等足帧(canvas_item 空引用窗口) - var lvbtn: Button = _find_button_by_sub(main._start_overlay, "开始冒险") + var lvbtn: Button = ProbeUiUtil.find_button(main._start_overlay, "开始冒险", "contains") if lvbtn == null: _fail("开始冒险按钮缺失") return @@ -68,9 +70,9 @@ func _ready() -> void: if out.is_empty() or not bool(out[0].get("accepted", false)): continue dug += 1 - await get_tree().create_timer(0.12).timeout + await get_tree().create_timer(T_DIG_FLIGHT).timeout await _snap("fx_dig%d" % dug) # 行到达/弹道在飞窗口 - await get_tree().create_timer(0.35).timeout + await get_tree().create_timer(T_SETTLE).timeout await _snap("fx_settle%d" % dug) # 结算排播窗口 continue # 无相邻可挖 → 走向有未开安全格邻居的已揭开格(挖掘期移动免费) @@ -90,12 +92,12 @@ func _ready() -> void: # 停手 → 轮末结算(敌方攻击弹道/受击闪白/结算排播在 0.2-1.6s 窗口流过)——连抓 6 帧 rules.settle_batch([{"action": "stop"}]) for i in 6: - await get_tree().create_timer(0.25).timeout + await get_tree().create_timer(T_ROUND_TICK).timeout await _snap("fx_round%d" % (i + 1)) if dug == 0: _fail("无安全格可挖(首板全雷不可信)") return - await get_tree().create_timer(0.6).timeout + await get_tree().create_timer(T_FINAL).timeout await _snap("fx_final") if _problems.is_empty(): print("[fx_shot_probe] PASS — 演出帧 %d 张落 %s(挖格 %d)" % [dug * 2 + 1, out_dir, dug]) @@ -115,16 +117,6 @@ func _snap(tag: String) -> void: img.save_png("%s/%s.png" % [out_dir, tag]) -func _find_button_by_sub(root: Node, needle: String) -> Button: - if root is Button and String(root.text).contains(needle): - return root - for c in root.get_children(): - var r := _find_button_by_sub(c, needle) - if r != null: - return r - return null - - func _fail(msg: String) -> void: _problems.append(msg) push_error("[fx_shot_probe] FAIL — " + msg) diff --git a/game/smoke/level_autoplay.gd b/game/smoke/level_autoplay.gd index 501342c8..aca66b9d 100644 --- a/game/smoke/level_autoplay.gd +++ b/game/smoke/level_autoplay.gd @@ -396,13 +396,17 @@ static func _on_level_finished(p: Dictionary, st: Dictionary, logger) -> void: static func _autoplay_battle(runner, settlement, st: Dictionary, logger) -> void: - if String(st.get("policy", "normal")) == "perfect": - _autoplay_battle_perfect(runner, settlement, st, logger) - return + # 策略档经 _decide_act 单点分派(T6 样板收口批 2026-10-02——原 _autoplay_battle/_autoplay_battle_perfect + # 双循环逐行相同仅差 act 来源行,合并单循环;三档决策语义逐位不变,见 _decide_act)。 # 普通策略档 v3(2026-10-01 用户重定义):完美策略 + 受控失误注入——v2「新手级单步推理+盲挖 # 兜底」画像被真人实证推翻(零加成真人首通三关踩 23 雷 vs v2 普通档 L2 条件通关率 10.6%)。 # v3 语义 = 会玩的普通玩家:推理与完美档同源(subset 解算/标旗/卡死刷新),失误只注入两手—— # 标雷误挖(NORMAL_MINE_SLIP_P)+ 安全挖跳过(NORMAL_SKIP_DIG_P)。连续拒绝 ≥3 = 编排违约防御。 + # 完美策略档(2026-09-14):单步决策循环——move/dig/flag 经 排雷玩法 settle_batch 提交, + # refresh 经 settlement.player_request_refresh(战斗结算 核心编排:行动经济 扣 RAP + 退役 + 当场换新板), + # stop 出内环走统一停手提交。连续拒绝 ≥3 = 编排违约防御(fail-fast 语义,停手防死循环)。 + # 2026-10-01 上界修正:选路改目标价值评分(_route_pick)+ 商店贪心买齐(_autoplay_shop)—— + # 「完美」须是元游戏意义上的上界(会购物会补给),不再只是扫雷子游戏上界。 var round_guard := 60 while st.result == "" and runner.current_battle() == settlement and settlement.battle_result == "" and round_guard > 0: round_guard -= 1 @@ -413,11 +417,7 @@ static func _autoplay_battle(runner, settlement, st: Dictionary, logger) -> void while runner.current_battle() == settlement and settlement.battle_result == "" and settlement.action_economy.phase == "Active" \ and settlement.round_index == start_round and action_guard > 0 and rejects < 3: action_guard -= 1 - var act: Dictionary - if bool(st.get("adv", false)): - act = _apply_normal_slips(AutoPlayer.perfect_action(settlement.minesweeper, settlement), settlement, st) # v3:完美+失误 - else: - act = AutoPlayer.novice_action(settlement.minesweeper) # 单关机制夹具:v2 新手画像保持 + var act: Dictionary = _decide_act(st, settlement) var kind := String(act.get("kind", "stop")) if kind == "stop": break @@ -487,47 +487,16 @@ static func _gamble_action(plan: Dictionary, settlement) -> Dictionary: return {"kind": "move", "cell": plan.hero_cell} -# 完美策略档战斗驱动(2026-09-14):单步决策循环——move/dig/flag 经 排雷玩法 settle_batch 提交, -# refresh 经 settlement.player_request_refresh(战斗结算 核心编排:行动经济 扣 RAP + 退役 + 当场换新板), -# stop 出内环走统一停手提交。连续拒绝 ≥3 = 编排违约防御(fail-fast 语义,停手防死循环)。 -# 2026-10-01 上界修正:选路改目标价值评分(_route_pick)+ 商店贪心买齐(_autoplay_shop)—— -# 「完美」须是元游戏意义上的上界(会购物会补给),不再只是扫雷子游戏上界。 -static func _autoplay_battle_perfect(runner, settlement, st: Dictionary, logger) -> void: - var round_guard := 60 - while st.result == "" and runner.current_battle() == settlement and settlement.battle_result == "" and round_guard > 0: - round_guard -= 1 - st.rounds += 1 - var start_round: int = settlement.round_index - var action_guard := 400 - var rejects := 0 - while runner.current_battle() == settlement and settlement.battle_result == "" and settlement.action_economy.phase == "Active" \ - and settlement.round_index == start_round and action_guard > 0 and rejects < 3: - action_guard -= 1 - var act := AutoPlayer.perfect_action(settlement.minesweeper, settlement) - var kind := String(act.get("kind", "stop")) - if kind == "stop": - break - var ok := false - if kind == "move": - ok = _submit_intent(settlement, "move", act.get("cell"), logger, start_round) - elif kind == "dig": - ok = _submit_intent(settlement, "dig", act.get("cell"), logger, start_round) - elif kind == "flag": - ok = _submit_intent(settlement, "flag_place", act.get("cell"), logger, start_round) - elif kind == "refresh": - ok = bool(settlement.player_request_refresh()) - if logger != null: - logger.log_action("refresh", Vector2i(-1, -1), ok, {"round": start_round}) - if ok: - if kind == "refresh": - st.refreshes = int(st.refreshes) + 1 - rejects = 0 - else: - rejects += 1 - if runner.current_battle() == settlement and settlement.battle_result == "" and settlement.action_economy.phase == "Active" and settlement.round_index == start_round: - var out_s: Array = settlement.minesweeper.settle_batch([{"action": "stop"}]) - if logger != null: - logger.log_action("stop", Vector2i(-1, -1), _accepted(out_s), {"round": start_round}) +# 战斗决策单行分派(T6 双循环合并抽取——原两循环唯一差异行): +# perfect 档 = 完美策略单步决策(上界口径); +# normal 档 v3 = 完美+受控失误注入(冒险测量面 adv=true);单关机制夹具保持 v2 新手画像(adv 缺省)。 +# 决策函数纯读板面/编排面(零 RNG 消费——失误流独走 st.prng,见 _apply_normal_slips)。 +static func _decide_act(st: Dictionary, settlement) -> Dictionary: + if String(st.get("policy", "normal")) == "perfect": + return AutoPlayer.perfect_action(settlement.minesweeper, settlement) + if bool(st.get("adv", false)): + return _apply_normal_slips(AutoPlayer.perfect_action(settlement.minesweeper, settlement), settlement, st) # v3:完美+失误 + return AutoPlayer.novice_action(settlement.minesweeper) # 单关机制夹具:v2 新手画像保持 static func _submit_intent(settlement, action: String, cell, logger, round_index: int) -> bool: diff --git a/game/smoke/monte_carlo.gd b/game/smoke/monte_carlo.gd index 41c9eeef..e7da2121 100644 --- a/game/smoke/monte_carlo.gd +++ b/game/smoke/monte_carlo.gd @@ -5,7 +5,7 @@ # 运行:Godot_console.exe --headless --path . --script res://smoke/monte_carlo.gd extends SceneTree -const AutoPlayer := preload("res://smoke/auto_player.gd") # O4 收口(批注 2026-09-15:原文件缺失 preload——HEAD 起即解析失败,本批顺手修复) +const BattleDriver := preload("res://smoke/battle_driver.gd") # T6 收口:挖掘驱动循环单点(策略仍归 AutoPlayer 单一实现) const N := 30 const SEED0 := 42 @@ -43,7 +43,7 @@ func _run_encounter(db, L: int) -> void: else: timeouts += 1 print("=== L%d · %d 种子(败 %d / 超时 %d)===" % [L, N, losses, timeouts]) - print(" 胜利轮数 均值 %.2f 中位 %d p10 %.1f p90 %.1f 分布 %s" % [_mean(rounds_v), _median(rounds_v), _pct(rounds_v, 0.1), _pct(rounds_v, 0.9), _hist(rounds_v)]) + print(" 胜利轮数 均值 %.2f 中位 %d p10 %.1f p90 %.1f 分布 %s" % [_mean(rounds_v), _median(rounds_v), _pct_f(rounds_v, 0.1), _pct_f(rounds_v, 0.9), _hist(rounds_v)]) print(" 每轮承伤 均值 %.2f 中位 %.1f p90 %.1f" % [_mean(dmg_per_round), _median_f(dmg_per_round), _pct_f(dmg_per_round, 0.9)]) print(" 整场承伤 均值 %.1f 中位 %.0f(主角 max_hp=%d)" % [_mean(dmg_total), _median_f(dmg_total), int(db.hero.max_hp)]) print(" 开安全格 均值 %.1f 中位 %d" % [_mean(safe_opened), _median(safe_opened)]) @@ -72,21 +72,7 @@ func _run_battle(db, seed_val: int, L: int) -> Dictionary: while out.result == "" and guard_rounds > 0: guard_rounds -= 1 out.rounds += 1 - var start_round: int = settlement.round_index - var minesweeper = settlement.minesweeper - var guard := 400 - while settlement.action_economy.phase == "Active" and settlement.round_index == start_round and guard > 0 and out.result == "": - guard -= 1 - var plan := AutoPlayer.choose_dig(minesweeper) - if plan.is_empty(): - break - if plan.hero_cell != minesweeper.hero_pos(): - minesweeper.settle_batch([{"action": "move", "cell": plan.hero_cell}]) - if settlement.action_economy.phase != "Active" or settlement.round_index != start_round: - break - minesweeper.settle_batch([{"action": "dig", "cell": plan.dig_cell}]) - if settlement.action_economy.phase == "Active" and settlement.round_index == start_round and out.result == "": - minesweeper.settle_batch([{"action": "stop"}]) + BattleDriver.drive_round(settlement, func() -> bool: return out.result == "") if out.result == "": out.result = "timeout" return out @@ -114,10 +100,6 @@ func _median_f(a: Array) -> float: return float(b[b.size() / 2]) -func _pct(a: Array, p: float) -> float: - return _pct_f(a, p) - - func _pct_f(a: Array, p: float) -> float: if a.is_empty(): return 0.0 diff --git a/game/smoke/probe_ui_util.gd b/game/smoke/probe_ui_util.gd new file mode 100644 index 00000000..fbcc6ce1 --- /dev/null +++ b/game/smoke/probe_ui_util.gd @@ -0,0 +1,112 @@ +# VERTICAL SLICE - NOT FOR PRODUCTION +# 探针共用面:树寻址助手 + 存档/设置/元进度隔离样板单点(T6 样板收口批 2026-10-02)。 +# 收口范围:ui_probe / ui_click_probe / shot_probe / fx_shot_probe 四探针的 7 处树寻址样板 +# 与 7 处隔离样板(各探针隔离文件名后缀 tag 保持不变)。 +# 用法:探针侧 `const ProbeUiUtil := preload("res://smoke/probe_ui_util.gd")` 后静态调用 +#(探针载体含 --script 场景两种,均无全局标识符——禁 class_name 裸引用)。 +extends RefCounted + +const SaveManagerScript := preload("res://core/save_manager.gd") +const MetaProgressionScript := preload("res://core/meta_progression.gd") +const SettingsStoreScript := preload("res://presentation/settings_store.gd") + +# ── 树寻址(递归只读——按钮/标签句柄未存组合根成员的探针专用面) ────────────── +# 匹配模式参数化(历史两种语义,禁混用禁静默改变): +# mode="prefix" = text.begins_with(needle)(ui_probe / ui_click_probe 历史语义) +# mode="contains" = text.contains(needle)(shot_probe / fx_shot_probe 历史语义) +# 调用点替换时逐一核对原语义后选参。 + +static func find_button(root: Node, needle: String, mode: String = "prefix") -> Button: + if root == null: + return null + if root is Button and _text_match(String(root.text), needle, mode): + return root + for child in root.get_children(): + var found := find_button(child, needle, mode) + if found != null: + return found + return null + + +# CheckButton 同款寻址(ui_click_probe 历史语义 = contains——设置面旧入口退役断言用) +static func find_check_button(root: Node, needle: String) -> CheckButton: + if root == null: + return null + if root is CheckButton and String(root.text).contains(needle): + return root + for child in root.get_children(): + var found := find_check_button(child, needle) + if found != null: + return found + return null + + +# Label 按文案包含寻址(shot_probe GP 徽章读数锚) +static func find_label(root: Node, text_fragment: String) -> Label: + if root == null: + return null + if root is Label and String(root.text).contains(text_fragment): + return root + for child in root.get_children(): + var found := find_label(child, text_fragment) + if found != null: + return found + return null + + +# 控件实测几何(shot_probe checker 文字框取样坐标源——禁硬编码像素,分辨率档位自适应) +static func label_rect(root: Node, text_fragment: String) -> Dictionary: + if root == null: + return {} + if root is Label and String(root.text).contains(text_fragment): + return {"x": root.global_position.x, "y": root.global_position.y, + "w": root.size.x, "h": root.size.y} + for child in root.get_children(): + var r := label_rect(child, text_fragment) + if not r.is_empty(): + return r + return {} + + +# 按脚本名寻址(resource_path contains——shot_probe hero 面板锚) +static func find_by_script(root: Node, script_name: String) -> Node: + if root == null: + return null + var s: Script = root.get_script() + if s != null and String(s.resource_path).contains(script_name): + return root + for child in root.get_children(): + var r := find_by_script(child, script_name) + if r != null: + return r + return null + + +static func _text_match(text: String, needle: String, mode: String) -> bool: + return text.begins_with(needle) if mode == "prefix" else text.contains(needle) + + +# ── 存档/设置/元进度三面隔离(探针禁碰真实档 user://save.json——SaveManager 路径注入纪律) ── +# tag = 隔离档名(不带扩展名,如 "smoke_input_probe_save"——各探针后缀保持不变);先删后建防陈旧档泄入。 +# mark_first_done = 对齐真实档游玩史口径(模板抽签路径不变);with_settings = 连设置面换注 +#(隔离 cfg 名 = _settings.cfg,与被收口探针原文件名逐一同名);refresh_resume = 组合根 +# 「继续上局」按钮重刷;swap_save = 是否换注 main.save_manager(营地先行段只换 meta 不换 save_manager)。 +# 返回隔离 SaveManager 实例(调用方常需后续直读余额/重读档)。 +static func isolate_save(main, tag: String, mark_first_done: bool = false, with_settings: bool = false, + refresh_resume: bool = false, swap_save: bool = true): + var save_path := "user://%s.json" % tag + for p in [save_path, save_path + ".tmp", "user://%s_settings.cfg" % tag]: + if FileAccess.file_exists(p): + DirAccess.remove_absolute(p) + var iso = SaveManagerScript.new(save_path) + iso.load_from_disk() + if mark_first_done: + iso.mark_first_adventure_done() + if swap_save: + main.save_manager = iso + main.meta = MetaProgressionScript.new(main.db, iso) + if with_settings: + main.settings_store = SettingsStoreScript.new("user://%s_settings.cfg" % tag) + if refresh_resume: + main._refresh_resume_button() + return iso diff --git a/game/smoke/probe_ui_util.gd.uid b/game/smoke/probe_ui_util.gd.uid new file mode 100644 index 00000000..4e4f43f1 --- /dev/null +++ b/game/smoke/probe_ui_util.gd.uid @@ -0,0 +1 @@ +uid://c1probeuiutil diff --git a/game/smoke/shot_probe.gd b/game/smoke/shot_probe.gd index edda5c6f..fb1b48a0 100644 --- a/game/smoke/shot_probe.gd +++ b/game/smoke/shot_probe.gd @@ -9,7 +9,9 @@ # 最小自检 = 四件存在且非零字节(像素方差断言归 tools/shot_checker.py——B 档骨架分工)。 extends Node -var main +const ProbeUiUtil := preload("res://smoke/probe_ui_util.gd") # T6 收口:树寻址/隔离样板单点 + +var main: Node var out_dir := "" var _problems: Array = [] var _meta := {} # 控件实测几何(全局 rect——shot_checker 文字框/明度带取样坐标源,禁硬编码像素) @@ -23,36 +25,28 @@ func _ready() -> void: main = load("res://presentation/main.tscn").instantiate() add_child(main) await get_tree().process_frame - # ── 存档/元进度/设置三面隔离(ui_click_probe 同款——真档零污染) ── - for p0 in ["user://smoke_shot_probe.json", "user://smoke_shot_probe.json.tmp", - "user://smoke_shot_probe_settings.cfg"]: - if FileAccess.file_exists(p0): - DirAccess.remove_absolute(p0) - var iso_c = load("res://core/save_manager.gd").new("user://smoke_shot_probe.json") - iso_c.load_from_disk() - main.save_manager = iso_c - main.meta = load("res://core/meta_progression.gd").new(main.db, iso_c) - main.settings_store = load("res://presentation/settings_store.gd").new("user://smoke_shot_probe_settings.cfg") + # ── 存档/元进度/设置三面隔离(ui_click_probe 同款——真档零污染;历史语义 = contains 寻址) ── + ProbeUiUtil.isolate_save(main, "smoke_shot_probe", false, true, false) await get_tree().process_frame # ① 菜单(启动态) - _meta["menu_sub"] = _find_label_rect(main._start_overlay, "改造批") - _meta["menu_help"] = _find_label_rect(main._start_overlay, "玩法:") - _meta["menu_title"] = _find_label_rect(main._start_overlay, "Dungeon Sweeper") + _meta["menu_sub"] = ProbeUiUtil.label_rect(main._start_overlay, "改造批") + _meta["menu_help"] = ProbeUiUtil.label_rect(main._start_overlay, "玩法:") + _meta["menu_title"] = ProbeUiUtil.label_rect(main._start_overlay, "Dungeon Sweeper") await _snap("menu") # ② 营地 - var campbtn: Button = _find_button_by_prefix(main._start_overlay, "营地") + var campbtn: Button = ProbeUiUtil.find_button(main._start_overlay, "营地", "contains") if campbtn == null: _problems.append("营地入口缺失(开始层 campbtn)") else: campbtn.pressed.emit() await get_tree().process_frame await _snap("camp") - var close_btn: Button = _find_button_by_prefix(main.camp_overlay, "关闭营地") + var close_btn: Button = ProbeUiUtil.find_button(main.camp_overlay, "关闭营地", "contains") if close_btn != null: close_btn.pressed.emit() await get_tree().process_frame # ③ 关卡画面(开始冒险→停点出图——冒险开始即出图,停点前移批语义) - var lvbtn: Button = _find_button_by_prefix(main._start_overlay, "开始冒险") + var lvbtn: Button = ProbeUiUtil.find_button(main._start_overlay, "开始冒险", "contains") if lvbtn == null: _problems.append("开始冒险按钮缺失(开始层 lvbtn)") else: @@ -61,7 +55,7 @@ func _ready() -> void: await get_tree().process_frame await _snap("level") # GP 常驻徽章断言(迭代 9 卡⑪核验——读数=runner.gp 现值,刷新=写路径回执后重查〔open_choices〕) - var gp_label: Label = _find_label_by_text(main.level_screen, "◈ ") + var gp_label: Label = ProbeUiUtil.find_label(main.level_screen, "◈ ") if gp_label == null or not gp_label.is_visible_in_tree(): _problems.append("GP 徽章不可见(关卡画面顶栏)") elif main.level_screen.get("_runner") != null: @@ -75,8 +69,8 @@ func _ready() -> void: await get_tree().process_frame await _snap("battle") # 战斗屏文字框/明度带锚点(全局 rect——checker 取样坐标源) - _meta["battle_log_hint"] = _find_label_rect(main.stage, "结算明细") - _meta["hero_panel"] = _node_rect(_find_by_script(main, "hero_info_panel_view")) + _meta["battle_log_hint"] = ProbeUiUtil.label_rect(main.stage, "结算明细") + _meta["hero_panel"] = _node_rect(ProbeUiUtil.find_by_script(main, "hero_info_panel_view")) _meta["stage_view"] = _node_rect(main.stage) _meta["board_zone"] = _node_rect(main.board_zone) # draw calls 真机测量(迭代 9 卡⑦——基线 270→目标 <200;窗口化渲染帧读数, @@ -116,59 +110,8 @@ func _snap(key: String) -> void: print("[shot_probe] saved %s (%dx%d)" % [key, img.get_width(), img.get_height()]) -func _find_button_by_prefix(root: Node, prefix: String) -> Button: - if root == null: - return null - if root is Button and String(root.text).contains(prefix): - return root - for c in root.get_children(): - var r := _find_button_by_prefix(c, prefix) - if r != null: - return r - return null - - -# 控件实测几何采集(checker 断言坐标源——禁硬编码像素,分辨率档位自适应) -func _find_label_rect(root: Node, text_fragment: String) -> Dictionary: - if root == null: - return {} - if root is Label and String(root.text).contains(text_fragment): - return {"x": root.global_position.x, "y": root.global_position.y, - "w": root.size.x, "h": root.size.y} - for c in root.get_children(): - var r := _find_label_rect(c, text_fragment) - if not r.is_empty(): - return r - return {} - - -func _find_label_by_text(root: Node, text_fragment: String) -> Label: - if root == null: - return null - if root is Label and String(root.text).contains(text_fragment): - return root - for c in root.get_children(): - var r := _find_label_by_text(c, text_fragment) - if r != null: - return r - return null - - func _node_rect(n: Node) -> Dictionary: if n == null or not (n is CanvasItem): return {} return {"x": n.global_position.x, "y": n.global_position.y, "w": n.size.x, "h": n.size.y} - - -func _find_by_script(root: Node, script_name: String) -> Node: - if root == null: - return null - var s: Script = root.get_script() - if s != null and String(s.resource_path).contains(script_name): - return root - for c in root.get_children(): - var r := _find_by_script(c, script_name) - if r != null: - return r - return null diff --git a/game/smoke/skill_rhythm_probe.gd b/game/smoke/skill_rhythm_probe.gd index f607d1d1..fab457c1 100644 --- a/game/smoke/skill_rhythm_probe.gd +++ b/game/smoke/skill_rhythm_probe.gd @@ -7,6 +7,8 @@ # 运行:Godot_console.exe --headless --path . --script res://smoke/skill_rhythm_probe.gd extends SceneTree +const BattleDriver := preload("res://smoke/battle_driver.gd") # T6 收口:挖掘驱动循环单点(策略仍归 AutoPlayer 单一实现) + func _initialize() -> void: var db = load("res://core/config_db.gd").new() db.load_tables("res://data/tables", false) @@ -50,23 +52,8 @@ func _run_battle(db, seed_val: int, L: int, depth: int, casts: Dictionary) -> Di casts[sid] = casts.get(sid, 0) + 1) if not settlement.begin_battle({"level_index": L, "node_depth": depth, "hero_hp": int(db.hero.max_hp)}): return out - var AutoPlayer = load("res://smoke/auto_player.gd") var guard_rounds := 40 while out.result == "" and guard_rounds > 0: guard_rounds -= 1 - var start_round: int = settlement.round_index - var minesweeper = settlement.minesweeper - var guard := 400 - while settlement.action_economy.phase == "Active" and settlement.round_index == start_round and guard > 0 and out.result == "": - guard -= 1 - var plan = AutoPlayer.choose_dig(minesweeper) - if plan.is_empty(): - break - if plan.hero_cell != minesweeper.hero_pos(): - minesweeper.settle_batch([{"action": "move", "cell": plan.hero_cell}]) - if settlement.action_economy.phase != "Active" or settlement.round_index != start_round: - break - minesweeper.settle_batch([{"action": "dig", "cell": plan.dig_cell}]) - if settlement.action_economy.phase == "Active" and settlement.round_index == start_round and out.result == "": - minesweeper.settle_batch([{"action": "stop"}]) + BattleDriver.drive_round(settlement, func() -> bool: return out.result == "") return out diff --git a/game/smoke/smoke_all.gd b/game/smoke/smoke_all.gd index a45b5e7e..46181d99 100644 --- a/game/smoke/smoke_all.gd +++ b/game/smoke/smoke_all.gd @@ -6,15 +6,20 @@ extends SceneTree const AutoPlayer := preload("res://smoke/auto_player.gd") # O4 收口:自动玩家策略单一实现 +const ConfigDBScript := preload("res://core/config_db.gd") # T6 收口:表宿主 preload 常量(原 _initialize 运行期 load) var _failures := 0 var _checks := 0 -func check(cond: bool, label: String) -> void: +func check(cond: bool, label: String, quiet: bool = false) -> void: _checks += 1 if cond: - print(" [PASS] " + label) + if not quiet: + print(" [PASS] " + label) + elif quiet: + _failures += 1 + print(" [FAIL] (silent check)") # 原 check_silent 输出逐位保持 else: _failures += 1 print(" [FAIL] " + label) @@ -22,7 +27,7 @@ func check(cond: bool, label: String) -> void: func _initialize() -> void: print("=== Dungeon Sweeper 垂直切片冒烟 ===") - var db = load("res://core/config_db.gd").new() + var db = ConfigDBScript.new() var load_ok: bool = db.load_tables("res://data/tables", true) check(load_ok, "全部表加载 + 校验通过(含跨表护栏)") if not load_ok: @@ -238,7 +243,7 @@ func _smoke_adventure_api() -> void: # 驱动首战到胜利 → 选路态(非战斗期)→ abandon 受理 var autoplay = load("res://smoke/level_autoplay.gd") var battle = adr.runner().current_battle() - var st := {"result": "", "rounds": 0, "refreshes": 0, "mine_hits": 0, "hero_dmg": 0, "policy": "normal"} + var st := _battle_st("normal") var guard := 30 while adr.runner().current_battle() == battle and guard > 0: guard -= 1 @@ -950,7 +955,8 @@ func _weaken_refresh_probe() -> Dictionary: settlement._status_effects.apply_effect("weaken", src) # 续时:remaining ← 2,层数不动 for eff in settlement._status_effects.query_active("enemy"): if String(eff.effect_id) == "weaken": - return {"layers": int(eff.layers), "remaining": int(eff.remaining_settlements), "pool": int(settlement._clamped_pool("enemy_output"))} + # T6 收口批:改走伤害控制器公有算术面 clamped_pool(原编排器 _clamped_pool 薄委托的直呼退役) + return {"layers": int(eff.layers), "remaining": int(eff.remaining_settlements), "pool": int(settlement._damage.clamped_pool("enemy_output"))} return {} @@ -1760,7 +1766,7 @@ func _smoke_encounter_roll() -> void: var rng = load("res://core/rng_service.gd").new() rng.set_master_seed(30000 + i) var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng) - check_silent(settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp)})) + check(settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp)}), "遭遇扫描战斗启动(quiet 计数档——计数入总账,成败由后续 spawn 断言归因)", true) counts.append(settlement.presentation_snapshot().enemies.size()) # 区间生效:20 个种子应出现 1 和 2(D0 [1,2]) check(counts.has(1) and counts.has(2), "D0 区间 [1,2] 两值均出现(分布 %s)" % str(counts)) @@ -2417,11 +2423,11 @@ func _has_effect_id(rows: Array, effect_id: String) -> bool: return true return false -func check_silent(cond: bool) -> void: - _checks += 1 - if not cond: - _failures += 1 - print(" [FAIL] (silent check)") + +# 手搓战斗状态袋单点(T6 收口批——7 处字面量收编;_autoplay_battle 直接消费的最小字段族。 +# 大字段族同构袋仍走 _adv_st()——_drive_level/_autoplay_battle 全字段消费面) +func _battle_st(policy: String) -> Dictionary: + return {"result": "", "rounds": 0, "refreshes": 0, "mine_hits": 0, "hero_dmg": 0, "policy": policy} # ==================== 敌人技能池批(2026-09-16 用户拍板——开轮摇号出招系统) ==================== @@ -2883,7 +2889,7 @@ func _smoke_level_graph() -> void: var choice_sizes: Array = [] runner.choices_presented.connect(func(c): choice_sizes.append(c.size())) check(runner.start_level(2, 42, {}, 0), "手动驱动:L2 启动(入口战斗直接开打——入口无前置兑付;template_pick=0 固定现行图——精确选路断言)") - var st = {"result": "", "rounds": 0, "refreshes": 0, "mine_hits": 0, "hero_dmg": 0, "policy": "normal"} + var st = _battle_st("normal") var first = runner.current_battle() var res_box := {} # 信号写回容器(lambda 按值捕获局部变量——Dictionary 引用可变) first.battle_finished.connect(func(p): res_box["result"] = String(p.get("result", ""))) @@ -2908,7 +2914,7 @@ func _smoke_level_graph() -> void: for i in range(12): var g_runner = load("res://core/level_runner.gd").new(_db, _fresh_rng(9100 + i)) g_runner.start_level(2, 9100 + i) - var g_st = {"result": "", "rounds": 0, "refreshes": 0, "mine_hits": 0, "hero_dmg": 0, "policy": "perfect"} + var g_st = _battle_st("perfect") var g_first = g_runner.current_battle() var g_box := {} g_first.battle_finished.connect(func(p): @@ -2989,7 +2995,7 @@ func _smoke_level_graph() -> void: var g3_runner = load("res://core/level_runner.gd").new(_db, _fresh_rng(9100 + i)) g3_runner.set_hero_override(200, 100) g3_runner.start_level(3, 9100 + i) - var g3_st = {"result": "", "rounds": 0, "refreshes": 0, "mine_hits": 0, "hero_dmg": 0, "policy": "perfect"} + var g3_st = _battle_st("perfect") var g3_first = g3_runner.current_battle() var g3_box := {} g3_first.battle_finished.connect(func(p): @@ -3035,7 +3041,7 @@ func _smoke_gp_wallet() -> void: var rows: Array = [] battle.step_row.connect(func(row): rows.append(row)) battle.battle_finished.connect(func(p): fin["p"] = p) - var st := {"result": "", "rounds": 0, "refreshes": 0, "mine_hits": 0, "hero_dmg": 0, "policy": "normal"} + var st := _battle_st("normal") var guard := 30 while runner.current_battle() == battle and not fin.has("p") and guard > 0: guard -= 1 @@ -3199,7 +3205,7 @@ func _smoke_shop_room() -> void: var runner = load("res://core/level_runner.gd").new(_db, _fresh_rng(s)) runner.set_hero_override(900, 400) # 行进段快速通关档(购买断言与百分比轴正交——加算区单独断言) runner.start_level(2, s, {}, 0) # 固定模板 0——商店经济断言按现行图 GP 账本 - var st := {"result": "", "rounds": 0, "refreshes": 0, "mine_hits": 0, "hero_dmg": 0, "policy": "normal"} + var st := _battle_st("normal") var guard := 40 while String(runner.level_result) == "" and int(runner.current_node_id()) != 8 and guard > 0: guard -= 1 @@ -3294,7 +3300,7 @@ func _smoke_shop_room() -> void: var b_end = runner.current_battle() if b_end == null: break - autoplay._autoplay_battle(runner, b_end, {"result": "", "rounds": 0, "refreshes": 0, "mine_hits": 0, "hero_dmg": 0, "policy": "normal"}, null) + autoplay._autoplay_battle(runner, b_end, _battle_st("normal"), null) check(fin_end.has("p") and String(fin_end["p"].get("result", "")) in ["victory", "defeat"], "冒险收束(Boss 战终局:%s)" % String(fin_end.get("p", {}).get("result", ""))) check(runner.gp == 0, "冒险收束钱包清零(局内货币生命周期终点——不入存档不入摘要)") diff --git a/game/smoke/ui_click_probe.gd b/game/smoke/ui_click_probe.gd index 3a7d9584..44a7a535 100644 --- a/game/smoke/ui_click_probe.gd +++ b/game/smoke/ui_click_probe.gd @@ -11,8 +11,9 @@ extends Node const AutoPlayer := preload("res://smoke/auto_player.gd") # O4:自动玩家策略单一实现 +const ProbeUiUtil := preload("res://smoke/probe_ui_util.gd") # T6 收口:树寻址/隔离样板单点 -var main +var main: Node var _last_state := {} var _stall_frames := 0 var _done := false @@ -41,14 +42,11 @@ func _ready() -> void: var t_ui_ready := Time.get_ticks_msec() - t0 # ---- 营地购买合成点击路径(元进度实现批——campbtn/购买/关闭全走真实按钮信号路径; # 隔离存档换注 main.meta 保确定性(禁碰真实档 user://save.json);陈旧档段首清理)---- - for p0 in ["user://smoke_click_probe_camp.json", "user://smoke_click_probe_camp.json.tmp", - "user://smoke_click_probe_reset.json", "user://smoke_click_probe_reset.json.tmp"]: + for p0 in ["user://smoke_click_probe_reset.json", "user://smoke_click_probe_reset.json.tmp"]: if FileAccess.file_exists(p0): - DirAccess.remove_absolute(p0) - var iso_c = load("res://core/save_manager.gd").new("user://smoke_click_probe_camp.json") - iso_c.load_from_disk() - main.meta = load("res://core/meta_progression.gd").new(main.db, iso_c) - var campbtn: Button = _find_button_by_prefix(main._start_overlay, "营地") + DirAccess.remove_absolute(p0) # 重置档段前清场兜底(营地/设置段不触该档——防陈旧断点泄入后续段) + var iso_c = ProbeUiUtil.isolate_save(main, "smoke_click_probe_camp", false, false, false, false) + var campbtn: Button = ProbeUiUtil.find_button(main._start_overlay, "营地", "prefix") if campbtn == null: _problems.append("营地入口按钮缺失(开始层 campbtn)") else: @@ -90,7 +88,7 @@ func _ready() -> void: iso_s.load_settings() main.settings_store = iso_s main.settings_overlay.retarget(iso_s) - var settings_btn: Button = _find_button_by_prefix(main._start_overlay, "设置") + var settings_btn: Button = ProbeUiUtil.find_button(main._start_overlay, "设置", "prefix") if settings_btn == null: _problems.append("设置入口按钮缺失(开始层 settingsbtn)") else: @@ -100,7 +98,7 @@ func _ready() -> void: _problems.append("设置面未打开(点击路径)") else: # 减动效转正双态(卡④ AC③):开=0.5 引擎态即时生效+落盘持久;关=1.0 回常态 - var motion_btn: Button = _find_button_by_text(main.settings_overlay, "减动效") # 二轮 D2——开关已换 UiKit toggle 按钮形态 + var motion_btn: Button = ProbeUiUtil.find_button(main.settings_overlay, "减动效", "contains") # 二轮 D2——开关已换 UiKit toggle 按钮形态 if motion_btn == null: _problems.append("设置面减动效开关缺失") else: @@ -127,10 +125,10 @@ func _ready() -> void: if absf(iso_s.volume_linear - 0.3) > 0.001: _problems.append("音量未入 store(%f ≠ 0.3)" % iso_s.volume_linear) # 调试入口默认隐藏门控(卡④ AC④——探针环境 DS_DEBUG 未设) - var dbg_btn: Button = _find_button_by_prefix(main._start_overlay, "调试") + var dbg_btn: Button = ProbeUiUtil.find_button(main._start_overlay, "调试", "prefix") if dbg_btn != null and dbg_btn.visible: _problems.append("调试入口未隐藏(DS_DEBUG 门控失守——卡④ AC④)") - var settings_close: Button = _find_button_by_prefix(main.settings_overlay, "返回") + var settings_close: Button = ProbeUiUtil.find_button(main.settings_overlay, "返回", "prefix") if settings_close == null: _problems.append("设置面返回按钮缺失") else: @@ -139,25 +137,17 @@ func _ready() -> void: if main.settings_overlay.visible or not main._start_overlay.visible: _problems.append("设置面关闭后未恢复开始层") # 旧入口退役断言(卡④ AC③——测试调整板不再含减动效开关) - var legacy_motion = _find_checkbutton_by_text(main.tune_panel, "减动效") + var legacy_motion = ProbeUiUtil.find_check_button(main.tune_panel, "减动效") if legacy_motion != null: _problems.append("调整板减动效旧入口未退役(设置面唯一入口失守——卡④ AC③)") # D1 断言(二轮):退出游戏只在一级菜单——开始菜单有、设置面无 - if _find_button_by_prefix(main._start_overlay, "退出游戏") == null: + if ProbeUiUtil.find_button(main._start_overlay, "退出游戏", "prefix") == null: _problems.append("开始菜单缺退出游戏按钮(二轮 D1)") - if _find_button_by_prefix(main.settings_overlay, "退出游戏") != null: + if ProbeUiUtil.find_button(main.settings_overlay, "退出游戏", "prefix") != null: _problems.append("设置面仍含退出游戏按钮(二轮 D1——只在一级菜单出现)") # 换回全新零档(营地购买态不泄入后续冒险段——确定性;档案残留下次段首清理兜底) - for p0 in ["user://smoke_click_probe_reset.json", "user://smoke_click_probe_reset.json.tmp"]: - if FileAccess.file_exists(p0): - DirAccess.remove_absolute(p0) # 段首清场——上次运行的残留断点会触发互斥确认早退 - var iso_reset = load("res://core/save_manager.gd").new("user://smoke_click_probe_reset.json") - iso_reset.load_from_disk() - iso_reset.mark_first_adventure_done() # 对齐基线(真实档已有游玩史)——非首次脚本模式=逐关抽签模板 - main.save_manager = iso_reset # 迭代 8 卡①——冒险断点写盘同步隔离(禁碰真实档 user://save.json) - main.meta = load("res://core/meta_progression.gd").new(main.db, iso_reset) - main._refresh_resume_button() - var resume_btn0: Button = _find_button_by_prefix(main._start_overlay, "▶ 继续上局") + ProbeUiUtil.isolate_save(main, "smoke_click_probe_reset", true, false, true) # 零档换注 + 继续上局按钮重刷(原三行样板收口) + var resume_btn0: Button = ProbeUiUtil.find_button(main._start_overlay, "▶ 继续上局", "prefix") if resume_btn0 == null: _problems.append("继续上局按钮缺失(开始层 resumebtn——装配器)") elif resume_btn0.visible: @@ -227,7 +217,7 @@ func _ready() -> void: _problems.append("大奖励屏打开时输入未锁定") _inter_confirms += 1 if _inter_confirms == 1: - var next_btn: Button = _find_button_by_prefix(main._inter_overlay, "进入下一层") + var next_btn: Button = ProbeUiUtil.find_button(main._inter_overlay, "进入下一层", "prefix") if next_btn == null: _problems.append("大奖励屏确认按钮缺失") else: @@ -309,11 +299,11 @@ func _ready() -> void: await get_tree().process_frame if not main._start_overlay.visible: _problems.append("断点段:终局返回未回开始菜单") - var resume_btn: Button = _find_button_by_prefix(main._start_overlay, "▶ 继续上局") + var resume_btn: Button = ProbeUiUtil.find_button(main._start_overlay, "▶ 继续上局", "prefix") if resume_btn != null and resume_btn.visible: _problems.append("断点段:退出清断点后继续上局按钮未隐藏(卡②/T3 AC③)") # ② 中断→续玩:新冒险达入口停点=断点在档 → 合成回菜单(模拟关游戏重开)→ 按钮点亮 → 点击恢复至停点 - var lv_btn: Button = _find_button_by_prefix(main._start_overlay, "▶ 开始冒险") + var lv_btn: Button = ProbeUiUtil.find_button(main._start_overlay, "▶ 开始冒险", "prefix") if lv_btn == null or resume_btn == null: _problems.append("断点段:开始冒险/继续上局按钮缺失") else: @@ -356,7 +346,7 @@ func _ready() -> void: await get_tree().process_frame if not main.confirm_dialog.is_open() or main.adventure != null: _problems.append("断点段:有断点点开始冒险未弹确认框(二轮 A 失守)") - var cancel_btn: Button = _find_button_by_text(main.confirm_dialog, "取消") + var cancel_btn: Button = ProbeUiUtil.find_button(main.confirm_dialog, "取消", "contains") if cancel_btn == null: _problems.append("断点段:确认框取消按钮缺失") else: @@ -366,7 +356,7 @@ func _ready() -> void: _problems.append("断点段:取消未回开始菜单(二轮 A)") lv_btn.pressed.emit() await get_tree().process_frame - var confirm_btn: Button = _find_button_by_text(main.confirm_dialog, "开新冒险") + var confirm_btn: Button = ProbeUiUtil.find_button(main.confirm_dialog, "开新冒险", "contains") if confirm_btn == null: _problems.append("断点段:确认框开新冒险按钮缺失") else: @@ -561,34 +551,3 @@ func _dump_state() -> void: String(main.runner.current_node().get("type", "")), str(main.bonus_overlay.visible), str(main._inter_overlay.visible), str(main.shop_overlay.visible), str(main.runner.next_candidate_ids())]) - - -# 开始层按钮按文案前缀寻址(campbtn 句柄未存组合根成员——树遍历只读;与 ui_probe 同款助手) -func _find_button_by_prefix(root: Node, prefix: String) -> Button: - if root is Button and String(root.text).begins_with(prefix): - return root - for child in root.get_children(): - var found := _find_button_by_prefix(child, prefix) - if found != null: - return found - return null - - -func _find_button_by_text(root: Node, needle: String) -> Button: - if root is Button and String(root.text).contains(needle): - return root - for child in root.get_children(): - var found := _find_button_by_text(child, needle) - if found != null: - return found - return null - - -func _find_checkbutton_by_text(root: Node, needle: String) -> CheckButton: - if root is CheckButton and String(root.text).contains(needle): - return root - for child in root.get_children(): - var found := _find_checkbutton_by_text(child, needle) - if found != null: - return found - return null diff --git a/game/smoke/ui_input_probe.gd b/game/smoke/ui_input_probe.gd index e7615c2a..28cc3088 100644 --- a/game/smoke/ui_input_probe.gd +++ b/game/smoke/ui_input_probe.gd @@ -13,7 +13,9 @@ # 运行:Godot_console.exe --headless --path . res://smoke/ui_input_probe.tscn extends Node -var main +const ProbeUiUtil := preload("res://smoke/probe_ui_util.gd") # T6 收口:隔离样板单点 + +var main: Node var _problems: Array = [] var _actions: Array = [] # action_requested 收集([cell, kind]) var _chords: Array = [] # chord_attempted 收集 @@ -26,16 +28,9 @@ func _ready() -> void: add_child(main) await get_tree().process_frame # 存档隔离(迭代 8 卡①②——断点写盘面随冒险挂 main.save_manager;探针禁碰真实档 user://save.json, - # 同 ui_click_probe 隔离纪律。首次冒险布尔位置位=对齐真实档游玩史口径,模板抽签路径不变) - for p0 in ["user://smoke_input_probe_save.json", "user://smoke_input_probe_save.json.tmp"]: - if FileAccess.file_exists(p0): - DirAccess.remove_absolute(p0) # 段首清场——上次运行的残留断点会触发互斥确认早退 - var iso = load("res://core/save_manager.gd").new("user://smoke_input_probe_save.json") - iso.load_from_disk() - iso.mark_first_adventure_done() - main.save_manager = iso - main.meta = load("res://core/meta_progression.gd").new(main.db, iso) - main._refresh_resume_button() + # 同 ui_click_probe 隔离纪律。首次冒险布尔位置位=对齐真实档游玩史口径,模板抽签路径不变; + # isolate_save 先删后建——上次运行的残留断点会触发互斥确认早退) + ProbeUiUtil.isolate_save(main, "smoke_input_probe_save", true, false, true) main._start_adventure_from_menu() await get_tree().process_frame # ---- ⑫ 图上命中真实输入(关卡画面实装批 2026-09-28 回归闸):入口停点候选节点中心 @@ -99,6 +94,8 @@ func _ready() -> void: var maxl2 := 0 for k2 in layer2.keys(): maxl2 = maxi(maxl2, int(layer2[k2])) + # 常数锚(画布拟合探针):70/122/26/8 与 presentation/views/level_map_screen.gd 的 _content_size + # 公式(70 + maxl*122 + NODE_HALF.x + 8)同源复刻——改常数须两侧同步(本探针只读断言层,真源在屏) var need_x2: float = 70.0 + maxl2 * 122.0 + 26.0 + 8.0 if float(ro2._canvas.custom_minimum_size.x) < need_x2 - 0.5: _pre_fail("画布拟合不足(min.x=%.0f < 最深内容 %.0f——深层节点不可点回归)" % [ro2._canvas.custom_minimum_size.x, need_x2]) diff --git a/game/smoke/ui_layout_probe.gd b/game/smoke/ui_layout_probe.gd index d0dfb026..cbdc2538 100644 --- a/game/smoke/ui_layout_probe.gd +++ b/game/smoke/ui_layout_probe.gd @@ -4,22 +4,17 @@ # 运行:Godot_console.exe --headless --path . res://smoke/ui_layout_probe.tscn extends Node +const ProbeUiUtil := preload("res://smoke/probe_ui_util.gd") # T6 收口:隔离样板单点 + func _ready() -> void: await get_tree().process_frame # autoload 就绪 - var main = load("res://presentation/main.tscn").instantiate() + var main: Node = load("res://presentation/main.tscn").instantiate() add_child(main) await get_tree().process_frame - # 存档隔离(迭代 8 卡①②——停点断点写盘随 main.save_manager;探针禁碰真实档,同族探针隔离纪律) - for p0 in ["user://smoke_layout_probe_save.json", "user://smoke_layout_probe_save.json.tmp"]: - if FileAccess.file_exists(p0): - DirAccess.remove_absolute(p0) # 段首清场——上次运行的残留断点会触发互斥确认早退 - var iso = load("res://core/save_manager.gd").new("user://smoke_layout_probe_save.json") - iso.load_from_disk() - iso.mark_first_adventure_done() - main.save_manager = iso - main.meta = load("res://core/meta_progression.gd").new(main.db, iso) - main._refresh_resume_button() + # 存档隔离(迭代 8 卡①②——停点断点写盘随 main.save_manager;探针禁碰真实档,同族探针隔离纪律; + # isolate_save 先删后建——上次运行的残留断点会触发互斥确认早退) + ProbeUiUtil.isolate_save(main, "smoke_layout_probe_save", true, false, true) main._start_adventure_from_menu() for i in range(3): await get_tree().process_frame diff --git a/game/smoke/ui_probe.gd b/game/smoke/ui_probe.gd index 2c2506da..d4ab4c06 100644 --- a/game/smoke/ui_probe.gd +++ b/game/smoke/ui_probe.gd @@ -7,10 +7,12 @@ # 运行:Godot_console.exe --headless --path . res://smoke/ui_probe.tscn extends Node +const ProbeUiUtil := preload("res://smoke/probe_ui_util.gd") # T6 收口:树寻址/隔离样板单点 + func _ready() -> void: await get_tree().process_frame # autoload 就绪 - var main = load("res://presentation/main.tscn").instantiate() + var main: Node = load("res://presentation/main.tscn").instantiate() add_child(main) await get_tree().process_frame var problems: Array = [] @@ -20,11 +22,10 @@ func _ready() -> void: get_tree().quit(1) return # ---- 营地覆盖层(元进度实现批——主菜单入口/开合/余额文案/购买禁用态; - # 隔离存档换注 main.meta 保确定性(禁碰真实档 user://save.json——SaveManager 路径注入纪律)---- - var iso = load("res://core/save_manager.gd").new("user://smoke_ui_probe_camp.json") - iso.load_from_disk() - main.meta = load("res://core/meta_progression.gd").new(main.db, iso) - var campbtn: Button = _find_button_by_prefix(main._start_overlay, "营地") + # 隔离存档换注 main.meta 保确定性(禁碰真实档 user://save.json——SaveManager 路径注入纪律; + # 营地先行段只换 meta 不换 save_manager——冒险段换注零档在前)---- + ProbeUiUtil.isolate_save(main, "smoke_ui_probe_camp", false, false, false, false) + var campbtn: Button = ProbeUiUtil.find_button(main._start_overlay, "营地", "prefix") if campbtn == null: problems.append("营地入口按钮缺失(开始层 campbtn)") else: @@ -52,15 +53,7 @@ func _ready() -> void: if main.camp_overlay.visible or not main._start_overlay.visible: problems.append("营地关闭后未恢复开始层(开合往返失守)") # 换回全新零档(营地段购买态不泄入后续冒险段——确定性;先删后建防陈旧档泄入) - for p in ["user://smoke_ui_probe_camp.json", "user://smoke_ui_probe_camp.json.tmp"]: - if FileAccess.file_exists(p): - DirAccess.remove_absolute(p) - var iso_zero = load("res://core/save_manager.gd").new("user://smoke_ui_probe_camp.json") - iso_zero.load_from_disk() - iso_zero.mark_first_adventure_done() # 对齐真实档游玩史口径(模板抽签路径不变) - main.save_manager = iso_zero # 迭代 8 卡①——断点写盘面同步隔离(禁碰真实档) - main.meta = load("res://core/meta_progression.gd").new(main.db, iso_zero) - main._refresh_resume_button() + ProbeUiUtil.isolate_save(main, "smoke_ui_probe_camp", true, false, true) main._start_adventure_from_menu() await get_tree().process_frame await get_tree().process_frame @@ -244,14 +237,3 @@ func _ready() -> void: push_error("[ui_probe] FAIL — " + p) print("[ui_probe] FAIL — " + "; ".join(problems)) get_tree().quit(1) - - -# 开始层按钮按文案前缀寻址(campbtn 句柄未存组合根成员——树遍历只读;text 前缀匹配首枚按钮) -func _find_button_by_prefix(root: Node, prefix: String) -> Button: - if root is Button and String(root.text).begins_with(prefix): - return root - for child in root.get_children(): - var found := _find_button_by_prefix(child, prefix) - if found != null: - return found - return null diff --git a/game/smoke/ui_tune_probe.gd b/game/smoke/ui_tune_probe.gd index 87d7314f..43c48be8 100644 --- a/game/smoke/ui_tune_probe.gd +++ b/game/smoke/ui_tune_probe.gd @@ -6,10 +6,14 @@ # 运行:Godot_console.exe --headless --path . res://smoke/ui_tune_probe.tscn extends Node +const ProbeUiUtil := preload("res://smoke/probe_ui_util.gd") # T6 收口:隔离样板单点 + +const SIM_GUARD_FRAMES := 1800 # ⑤ N=10 headless 模拟防挂死帧预算上限(ADR-0004:探针内停滞检测第一道——坏态不等满预算才报,超时即断言失败) + func _ready() -> void: await get_tree().process_frame - var main = load("res://presentation/main.tscn").instantiate() + var main: Node = load("res://presentation/main.tscn").instantiate() add_child(main) await get_tree().process_frame var problems: Array = [] @@ -37,7 +41,7 @@ func _ready() -> void: main.tune_panel.set_sliders(100, 0) main.tune_panel.set_sim_count(10) main.tune_panel.press_simulate_for_probe() - var guard := 1800 # N=10 headless 模拟防挂死上限(ADR-0004:探针内停滞检测第一道) + var guard := SIM_GUARD_FRAMES while main.tune_panel.is_running() and guard > 0: guard -= 1 await get_tree().process_frame @@ -59,15 +63,9 @@ func _ready() -> void: problems.append("hero 表被运行时改写(E4 违例:atk=%s hp=%s,期望恒基线 %d/%d)" % [str(main.db.hero.atk), str(main.db.hero.max_hp), base_atk, base_hp]) # ⑧ 单次加成生效回归断言:应用加成 + 启动单场战斗 → 呈现快照 hero 必须是"基线 × 单次加成" # (攻击 ×2 / 生命 +50%)——旧缺陷(残留值被当基线再乘)在 encounter 参数链上同样被抓获 - # 存档隔离(迭代 8 卡①——停点断点写盘随 main.save_manager;探针禁碰真实档,同族探针隔离纪律) - for p0 in ["user://smoke_tune_probe_save.json", "user://smoke_tune_probe_save.json.tmp"]: - if FileAccess.file_exists(p0): - DirAccess.remove_absolute(p0) # 段首清场——上次运行的残留断点会触发互斥确认早退 - var iso_tune = load("res://core/save_manager.gd").new("user://smoke_tune_probe_save.json") - iso_tune.load_from_disk() - iso_tune.mark_first_adventure_done() - main.save_manager = iso_tune - main.meta = load("res://core/meta_progression.gd").new(main.db, iso_tune) + # 存档隔离(迭代 8 卡①——停点断点写盘随 main.save_manager;探针禁碰真实档,同族探针隔离纪律; + # isolate_save 先删后建——上次运行的残留断点会触发互斥确认早退) + ProbeUiUtil.isolate_save(main, "smoke_tune_probe_save", true, false, false) main.tune_panel.set_sliders(100, 50) main.tune_panel.press_apply_for_probe() main._start_adventure_from_menu() # 完整冒险路径(唯一试玩入口;_start_single 按语义清零滑条 = 恒基线,不承载加成断言) diff --git a/game/tests/unit/battle_settlement/damage_controller_test.gd b/game/tests/unit/battle_settlement/damage_controller_test.gd index 25513331..143d5a69 100644 --- a/game/tests/unit/battle_settlement/damage_controller_test.gd +++ b/game/tests/unit/battle_settlement/damage_controller_test.gd @@ -16,15 +16,15 @@ const DamageControllerScript := preload("res://core/damage_controller.gd") const TEST_SEED := 20260928 # 夹具种子(基建值非玩法数值——回放确定性契约的输入) -var _failures := 0 -var _checks := 0 +const Kit := preload("res://tests/unit/test_kit.gd") # T6 收口:_check/汇总退出样板单点 + var _rows: Array = [] # 当前 run 的行协议收集(_on_row 绑定法——避 lambda 多行控制流坑) func _init() -> void: var db = ConfigDBScript.new() var load_ok: bool = db.load_tables("res://data/tables", true) - _check("前置:表加载零 error", load_ok and db.diagnostics_count("error") == 0) + Kit.check("前置:表加载零 error", load_ok and db.diagnostics_count("error") == 0) if load_ok: _test_five_recipes_and_replay(db) _test_policy_immediate_mode(db) @@ -33,21 +33,7 @@ func _init() -> void: _test_hero_death_halt(db) _test_ac_merge(db) _test_pool_clamp(db) - if _failures > 0: - push_error("[damage_controller_test] %d/%d 项失败" % [_failures, _checks]) - quit(1) - return - print("[damage_controller_test] all green (%d checks)" % _checks) - quit(0) - - -func _check(desc: String, ok: bool) -> void: - _checks += 1 - if ok: - print(" [PASS] " + desc) - else: - _failures += 1 - push_error(" [FAIL] " + desc) + quit(Kit.finish("damage_controller_test")) # ---- 夹具 ---- @@ -84,7 +70,7 @@ func _run_recipe_sequence(db) -> Array: var units = h.units var ctrl = h.ctrl if not h.spawn_ok: - _check("前置:units.spawn(1,0) 成功(遭遇行在案)", false) + Kit.check("前置:units.spawn(1,0) 成功(遭遇行在案)", false) return [] var hero_before: int = int(units.hero_unit().hp) var enemy_hp_before: Array = [] @@ -122,27 +108,27 @@ func _run_recipe_sequence(db) -> Array: for id in [t1, t2, t3, t4, t5]: if ctrl.outcome(int(id)) != "redeemed": outcomes_redeemed = false - _check("五配方单据 flush 后全部 redeemed(B 模式段界结账)", outcomes_redeemed) + Kit.check("五配方单据 flush 后全部 redeemed(B 模式段界结账)", outcomes_redeemed) var hero_after: int = int(units.hero_unit().hp) var r2: Dictionary = _row_by_display_prefix("T2敌方技") var r5: Dictionary = _row_by_display_prefix("T5反噬") - _check("主角扣血 = 敌方技行值 + 反噬行值(逐行归因闭合)", + Kit.check("主角扣血 = 敌方技行值 + 反噬行值(逐行归因闭合)", hero_before - hero_after == int(r2.get("value", -1)) + int(r5.get("value", -1))) - _check("敌方技行值 ≥ 下限 + rider(rider 后置不进乘区)", int(r2.get("value", 0)) >= floor_v + floor_v) - _check("反噬行值 = 落地下限(中性无类型表值)", int(r5.get("value", 0)) == floor_v) + Kit.check("敌方技行值 ≥ 下限 + rider(rider 后置不进乘区)", int(r2.get("value", 0)) >= floor_v + floor_v) + Kit.check("反噬行值 = 落地下限(中性无类型表值)", int(r5.get("value", 0)) == floor_v) var r1: Dictionary = _row_by_display_prefix("T1主角攻击") var ex1: Dictionary = r1.get("extra", {}) - _check("主角攻击行 AC = 防守方 AC + 攻击加成(开单前快照一致)", int(ex1.get("ac", -1)) == threshold_expected) - _check("掷点 ∈ [1, 骰面]", int(ex1.get("roll", -1)) >= 1 and int(ex1.get("roll", -1)) <= int(h.db.settlement.roll_faces)) + Kit.check("主角攻击行 AC = 防守方 AC + 攻击加成(开单前快照一致)", int(ex1.get("ac", -1)) == threshold_expected) + Kit.check("掷点 ∈ [1, 骰面]", int(ex1.get("roll", -1)) >= 1 and int(ex1.get("roll", -1)) <= int(h.db.settlement.roll_faces)) var tier_ok := false for dv in h.db.settlement.roll_tier_discounts: if int(dv) == int(ex1.get("tier", -1)): tier_ok = true if int(ex1.get("tier", -1)) == 100: tier_ok = true - _check("掷点档位 ∈ 表档位值 ∪ 满额", tier_ok) + Kit.check("掷点档位 ∈ 表档位值 ∪ 满额", tier_ok) var r4: Dictionary = _row_by_display_prefix("T4群伤") var alive_at_blast: int = int(r4.get("extra", {}).get("count", -1)) @@ -158,10 +144,10 @@ func _run_recipe_sequence(db) -> Array: var attack_landed: int = int(r1.get("value", 0)) var typed_landed: int = int(_row_by_display_prefix("T3克制直伤").get("value", 0)) if any_enemy_dead: - _check("敌方总扣血 ≥ 攻击+克制+群伤(死亡溢出合法,下界归因)", + Kit.check("敌方总扣血 ≥ 攻击+克制+群伤(死亡溢出合法,下界归因)", enemy_drop_total >= attack_landed + typed_landed + int(r4.get("value", 0)) * maxi(0, alive_at_blast)) else: - _check("敌方总扣血 = 攻击 + 克制直伤 + 群伤×存活数(逐行归因闭合)", + Kit.check("敌方总扣血 = 攻击 + 克制直伤 + 群伤×存活数(逐行归因闭合)", enemy_drop_total == attack_landed + typed_landed + int(r4.get("value", 0)) * alive_at_blast) var protocol_ok := true @@ -174,7 +160,7 @@ func _run_recipe_sequence(db) -> Array: protocol_ok = false if not (r.extra is Dictionary) or not r.extra.has("target"): protocol_ok = false - _check("行协议公共字段封闭(type/source_class/display/value≥下限/extra.target)", protocol_ok) + Kit.check("行协议公共字段封闭(type/source_class/display/value≥下限/extra.target)", protocol_ok) # 复现序列(供双跑对拍):逐行 (display, value) var seq: Array = [] @@ -185,9 +171,9 @@ func _run_recipe_sequence(db) -> Array: func _test_five_recipes_and_replay(db) -> void: var run1: Array = _run_recipe_sequence(db) - _check("五配方序列产出 5 行伤害行", run1.size() == 5) + Kit.check("五配方序列产出 5 行伤害行", run1.size() == 5) var run2: Array = _run_recipe_sequence(db) - _check("同种子双跑行序列逐位一致(回放确定性契约)", run1 == run2) + Kit.check("同种子双跑行序列逐位一致(回放确定性契约)", run1 == run2) # ---- A 模式(开单即结——回切位) ---- @@ -201,8 +187,8 @@ func _test_policy_immediate_mode(db) -> void: var floor_v: int = int(h.db.settlement.damage_floor) var t: int = h.ctrl.submit(h.ctrl.open_ticket(DamageControllerScript.KIND_BOARD_FLAT_HERO, "mine_hit", "A模式反噬", "棋盘兑付", floor_v, DamageControllerScript.TARGET_HERO, 1)) - _check("A 模式开单即结(无 flush 即 redeemed)", h.ctrl.outcome(t) == "redeemed") - _check("A 模式主角即时扣血", int(h.units.hero_unit().hp) == int(h.db.hero.max_hp) - floor_v) + Kit.check("A 模式开单即结(无 flush 即 redeemed)", h.ctrl.outcome(t) == "redeemed") + Kit.check("A 模式主角即时扣血", int(h.units.hero_unit().hp) == int(h.db.hero.max_hp) - floor_v) # ---- 来源敌死亡:队列中未结单据作废(死亡即不动手) ---- @@ -221,8 +207,8 @@ func _test_source_death_void(db) -> void: var t: int = h.ctrl.submit(ticket) h.units.apply_damage(src, int(src.max_hp)) # 敌当场倒下 → unit_died → 该敌未结单作废 h.ctrl.flush() - _check("来源敌死亡后其未结单据 voided(死亡即不动手)", h.ctrl.outcome(t) == "voided") - _check("作废单零行零扣血", int(h.units.hero_unit().hp) == hero_before and _rows.is_empty()) + Kit.check("来源敌死亡后其未结单据 voided(死亡即不动手)", h.ctrl.outcome(t) == "voided") + Kit.check("作废单零行零扣血", int(h.units.hero_unit().hp) == hero_before and _rows.is_empty()) # ---- requires 前置门:前置单未实际有效兑付 → 后继单静默作废;无存活敌 blast = 合法零兑付 ---- @@ -238,7 +224,7 @@ func _test_requires_gate_and_zero(db) -> void: var big: int = front_hp * 10 var t_clear: int = h.ctrl.flush_submit(h.ctrl.open_ticket(DamageControllerScript.KIND_BOARD_BLAST, "clear_reward", "清场群伤", "棋盘兑付", big, DamageControllerScript.TARGET_ALL_ALIVE, 1)) - _check("flush_submit 同步开单即结(清图批契约)", h.ctrl.outcome(t_clear) == "redeemed" and h.units.query_alive("enemy").is_empty()) + Kit.check("flush_submit 同步开单即结(清图批契约)", h.ctrl.outcome(t_clear) == "redeemed" and h.units.query_alive("enemy").is_empty()) var t_zero: int = h.ctrl.submit(h.ctrl.open_ticket(DamageControllerScript.KIND_BOARD_BLAST, "clear_reward", "空场群伤", "棋盘兑付", floor_v, DamageControllerScript.TARGET_ALL_ALIVE, 1)) var gated_ticket: Dictionary = h.ctrl.open_ticket(DamageControllerScript.KIND_HERO_ATTACK, "hero_attack", @@ -246,8 +232,8 @@ func _test_requires_gate_and_zero(db) -> void: gated_ticket["requires"] = t_zero var t_gated: int = h.ctrl.submit(gated_ticket) h.ctrl.flush() - _check("无存活敌 blast = 合法零兑付(zero 非 voided)", h.ctrl.outcome(t_zero) == "zero") - _check("前置单未实际有效 → 后继单 voided(requires 门)", h.ctrl.outcome(t_gated) == "voided") + Kit.check("无存活敌 blast = 合法零兑付(zero 非 voided)", h.ctrl.outcome(t_zero) == "zero") + Kit.check("前置单未实际有效 → 后继单 voided(requires 门)", h.ctrl.outcome(t_gated) == "voided") # ---- 主角倒下:flush 余单全部作废(_halted 终止) ---- @@ -263,8 +249,8 @@ func _test_hero_death_halt(db) -> void: var t_after: int = h.ctrl.submit(h.ctrl.open_ticket(DamageControllerScript.KIND_BOARD_FLAT_HERO, "mine_hit", "余单", "棋盘兑付", 1, DamageControllerScript.TARGET_HERO, 2)) h.ctrl.flush() - _check("致死单 redeemed 且主角 hp 归零", h.ctrl.outcome(t_kill) == "redeemed" and int(h.units.hero_unit().hp) == 0) - _check("主角倒下后余单 voided(flush 终止)", h.ctrl.outcome(t_after) == "voided") + Kit.check("致死单 redeemed 且主角 hp 归零", h.ctrl.outcome(t_kill) == "redeemed" and int(h.units.hero_unit().hp) == 0) + Kit.check("主角倒下后余单 voided(flush 终止)", h.ctrl.outcome(t_after) == "voided") # ---- AC 合流:override 入参 / 表值兜底 / ac 型状态叠层(不入池) ---- @@ -275,10 +261,10 @@ func _test_ac_merge(db) -> void: if not h.spawn_ok: return var base_ac: int = int(h.db.hero.armor_class) - _check("缺省兜底:defender_ac(hero) = hero 表值", h.ctrl.defender_ac(h.units.hero_unit()) == base_ac) + Kit.check("缺省兜底:defender_ac(hero) = hero 表值", h.ctrl.defender_ac(h.units.hero_unit()) == base_ac) var override_v: int = base_ac + int(h.db.settlement.damage_floor) h.ctrl.set_hero_ac_override(override_v) - _check("override 合流入参生效", h.ctrl.defender_ac(h.units.hero_unit()) == override_v) + Kit.check("override 合流入参生效", h.ctrl.defender_ac(h.units.hero_unit()) == override_v) # ac 型状态效果叠其上(找 hero 侧 unit=ac 行) var ac_eff := {} for row in h.db.status_effects.rows: @@ -286,12 +272,12 @@ func _test_ac_merge(db) -> void: ac_eff = row break if ac_eff.is_empty(): - _check("前置:状态表存在 hero 侧 ac 型行", false) + Kit.check("前置:状态表存在 hero 侧 ac 型行", false) return var src := {"source_class": "safe_cell", "origin_cell": Vector2i(-1, -1), "round_index": 0} h.effects.apply_effect(String(ac_eff.effect_id), src) var expected: int = override_v + int(ac_eff.magnitude) - _check("ac 型效果叠层入 AC(层数×幅度,不入池)", h.ctrl.defender_ac(h.units.hero_unit()) == expected) + Kit.check("ac 型效果叠层入 AC(层数×幅度,不入池)", h.ctrl.defender_ac(h.units.hero_unit()) == expected) # ---- 池乘区收口:池内加算 → clamp;ac 型不入池 ---- @@ -303,7 +289,7 @@ func _test_pool_clamp(db) -> void: return var lo: int = int(h.db.settlement.pool_clamp_lo) var hi: int = int(h.db.settlement.pool_clamp_hi) - _check("空效果池读 0(表 clamp 域含 0 前提)", h.ctrl.clamped_pool("hero_offense") == 0 and lo <= 0 and hi >= 0) + Kit.check("空效果池读 0(表 clamp 域含 0 前提)", h.ctrl.clamped_pool("hero_offense") == 0 and lo <= 0 and hi >= 0) # 找 hero_offense 池的 percent 型 hero 侧效果行 var pool_eff := {} for row in h.db.status_effects.rows: @@ -313,14 +299,14 @@ func _test_pool_clamp(db) -> void: pool_eff = row break if pool_eff.is_empty(): - _check("前置:hero_offense 池存在 percent 型 hero 侧行", false) + Kit.check("前置:hero_offense 池存在 percent 型 hero 侧行", false) return var src := {"source_class": "safe_cell", "origin_cell": Vector2i(-1, -1), "round_index": 0} var first_ok: bool = h.effects.apply_effect(String(pool_eff.effect_id), src) var expected1: int = clampi(int(pool_eff.magnitude), lo, hi) - _check("池值 = 单实例 L×m 加算后 clamp", first_ok and h.ctrl.clamped_pool("hero_offense") == expected1) + Kit.check("池值 = 单实例 L×m 加算后 clamp", first_ok and h.ctrl.clamped_pool("hero_offense") == expected1) var second_ok: bool = h.effects.apply_effect(String(pool_eff.effect_id), src) - _check("refresh 政策重复授予受理且层数恒 1(叠层改续时裁决)——池值不变", + Kit.check("refresh 政策重复授予受理且层数恒 1(叠层改续时裁决)——池值不变", second_ok and h.ctrl.clamped_pool("hero_offense") == expected1) # ac 型效果不入 percent 池 var ac_eff2 := {} @@ -331,7 +317,7 @@ func _test_pool_clamp(db) -> void: if not ac_eff2.is_empty(): var before: int = h.ctrl.clamped_pool("hero_offense") h.effects.apply_effect(String(ac_eff2.effect_id), src) - _check("ac 型效果不入池(percent 池隔离)", h.ctrl.clamped_pool("hero_offense") == before) + Kit.check("ac 型效果不入池(percent 池隔离)", h.ctrl.clamped_pool("hero_offense") == before) # stack 政策效果:全新容器授予至满层 → 再授拒绝(满层拒绝原子——容器零写入) var stack_eff := {} for row3 in h.db.status_effects.rows: @@ -339,7 +325,7 @@ func _test_pool_clamp(db) -> void: stack_eff = row3 break if stack_eff.is_empty(): - _check("前置:状态表存在 stack 政策行", false) + Kit.check("前置:状态表存在 stack 政策行", false) return var effects2 = StatusContainerScript.new(h.db) effects2.notify_battle_start() @@ -348,4 +334,4 @@ func _test_pool_clamp(db) -> void: for i in range(max_n + 1): if effects2.apply_effect(String(stack_eff.effect_id), src): granted += 1 - _check("stack 政策恰授予 max_stack 层后拒绝(满层拒绝原子)", granted == max_n) + Kit.check("stack 政策恰授予 max_stack 层后拒绝(满层拒绝原子)", granted == max_n) diff --git a/game/tests/unit/battle_settlement/damage_math_test.gd b/game/tests/unit/battle_settlement/damage_math_test.gd index e1d5a294..6d103ce3 100644 --- a/game/tests/unit/battle_settlement/damage_math_test.gd +++ b/game/tests/unit/battle_settlement/damage_math_test.gd @@ -6,36 +6,21 @@ extends SceneTree const DamageMathScript := preload("res://core/damage_math.gd") const ConfigDBScript := preload("res://core/config_db.gd") +const Kit := preload("res://tests/unit/test_kit.gd") # T6 收口:_check/汇总退出样板单点 -var _failures := 0 -var _checks := 0 var db func _init() -> void: db = ConfigDBScript.new() var load_ok: bool = db.load_tables("res://data/tables", true) - _check("前置:settlement 表加载零 error", load_ok and db.diagnostics_count("error") == 0) + Kit.check("前置:settlement 表加载零 error", load_ok and db.diagnostics_count("error") == 0) if load_ok: _test_pool_damage() _test_counter_component() _test_roll_discount_percent() _test_rolled_component() - if _failures > 0: - push_error("[damage_math_test] %d/%d 项失败" % [_failures, _checks]) - quit(1) - return - print("[damage_math_test] all green (%d checks)" % _checks) - quit(0) - - -func _check(desc: String, ok: bool) -> void: - _checks += 1 - if ok: - print(" [PASS] " + desc) - else: - _failures += 1 - push_error(" [FAIL] " + desc) + quit(Kit.finish("damage_math_test")) func _floor_val() -> int: @@ -46,23 +31,23 @@ func _floor_val() -> int: func _test_pool_damage() -> void: var d := _floor_val() + 9 # 表值推导的任意正探针量(非玩法行值) - _check("中性双池 (0,0) 原样透传", DamageMathScript.pool_damage(d, 0, 0) == d) - _check("负敌方池压低不增高", DamageMathScript.pool_damage(d, 0, int(db.settlement.pool_clamp_lo)) <= d) - _check("正主角池抬升不低于原值", DamageMathScript.pool_damage(d, int(db.settlement.pool_clamp_hi), 0) >= d) - _check("主角池单调不减(+50% vs +49% 探针档)", DamageMathScript.pool_damage(d, 50, 0) >= DamageMathScript.pool_damage(d, 49, 0)) - _check("对称性:池值互换结果不变(乘算交换)", DamageMathScript.pool_damage(d, 30, -20) == DamageMathScript.pool_damage(d, -20, 30)) - _check("输出非负(maxi(0,·) 封底)", DamageMathScript.pool_damage(d, int(db.settlement.pool_clamp_lo), int(db.settlement.pool_clamp_lo)) >= 0) + Kit.check("中性双池 (0,0) 原样透传", DamageMathScript.pool_damage(d, 0, 0) == d) + Kit.check("负敌方池压低不增高", DamageMathScript.pool_damage(d, 0, int(db.settlement.pool_clamp_lo)) <= d) + Kit.check("正主角池抬升不低于原值", DamageMathScript.pool_damage(d, int(db.settlement.pool_clamp_hi), 0) >= d) + Kit.check("主角池单调不减(+50% vs +49% 探针档)", DamageMathScript.pool_damage(d, 50, 0) >= DamageMathScript.pool_damage(d, 49, 0)) + Kit.check("对称性:池值互换结果不变(乘算交换)", DamageMathScript.pool_damage(d, 30, -20) == DamageMathScript.pool_damage(d, -20, 30)) + Kit.check("输出非负(maxi(0,·) 封底)", DamageMathScript.pool_damage(d, int(db.settlement.pool_clamp_lo), int(db.settlement.pool_clamp_lo)) >= 0) # ---- 克制分量:空桶不吃修正恒 0;落地下限保底([1,+∞) 全来源)---- func _test_counter_component() -> void: var floor_v := _floor_val() - _check("空桶(点数≤0)恒 0 不吃修正", DamageMathScript.counter_component(0, floor_v, floor_v) == 0 + Kit.check("空桶(点数≤0)恒 0 不吃修正", DamageMathScript.counter_component(0, floor_v, floor_v) == 0 and DamageMathScript.counter_component(-1, 5, floor_v) == 0) - _check("正向修正直加", DamageMathScript.counter_component(floor_v, floor_v, floor_v) == floor_v * 2) - _check("负修正触底保落地伤害下限", DamageMathScript.counter_component(floor_v, -floor_v * 10, floor_v) == floor_v) - _check("下限参数缺省 = 0(无下限语义面)", DamageMathScript.counter_component(floor_v, -floor_v * 10) == 0) + Kit.check("正向修正直加", DamageMathScript.counter_component(floor_v, floor_v, floor_v) == floor_v * 2) + Kit.check("负修正触底保落地伤害下限", DamageMathScript.counter_component(floor_v, -floor_v * 10, floor_v) == floor_v) + Kit.check("下限参数缺省 = 0(无下限语义面)", DamageMathScript.counter_component(floor_v, -floor_v * 10) == 0) # ---- 掷点折扣查档(纯函数):满阈值恒全额;档位边界/末档语义与表数组一致 ---- @@ -71,22 +56,22 @@ func _test_roll_discount_percent() -> void: var threshold := 10 # 查档探针阈值(夹具值——档位参数本身全部来自表) var bounds: Array = db.settlement.roll_tier_bounds var discounts: Array = db.settlement.roll_tier_discounts - _check("前置:档位数 = 边界数 + 1(查档契约——档数 = 边界数 + 1)", discounts.size() == bounds.size() + 1 and discounts.size() > 0) - _check("掷点 ≥ 阈值恒全额 100", DamageMathScript.roll_discount_percent(threshold, threshold, bounds, discounts) == 100 + Kit.check("前置:档位数 = 边界数 + 1(查档契约——档数 = 边界数 + 1)", discounts.size() == bounds.size() + 1 and discounts.size() > 0) + Kit.check("掷点 ≥ 阈值恒全额 100", DamageMathScript.roll_discount_percent(threshold, threshold, bounds, discounts) == 100 and DamageMathScript.roll_discount_percent(threshold + 5, threshold, bounds, discounts) == 100) # 缺口 = 1:落首档(缺口 ∈ [1, bounds[0]] → discounts[0]) var tier0_expected: int = int(discounts[0]) if int(bounds[0]) >= 1: - _check("缺口 1 落首档", DamageMathScript.roll_discount_percent(threshold - 1, threshold, bounds, discounts) == tier0_expected) + Kit.check("缺口 1 落首档", DamageMathScript.roll_discount_percent(threshold - 1, threshold, bounds, discounts) == tier0_expected) # 缺口超末边界:落末档 var last_expected: int = int(discounts[-1]) - _check("缺口超末边界落末档", DamageMathScript.roll_discount_percent(threshold - int(bounds[-1]) - 1, threshold, bounds, discounts) == last_expected) + Kit.check("缺口超末边界落末档", DamageMathScript.roll_discount_percent(threshold - int(bounds[-1]) - 1, threshold, bounds, discounts) == last_expected) # 档位值降序(重档折扣更深)——表自检 var descending := true for i in range(discounts.size() - 1): if int(discounts[i]) < int(discounts[i + 1]): descending = false - _check("档位值随档位加深不升(折扣单调)", descending) + Kit.check("档位值随档位加深不升(折扣单调)", descending) # ---- 吃掷点分量算术:折扣→(暴击)→floor→+克制修正(单一取整链)---- @@ -94,9 +79,9 @@ func _test_roll_discount_percent() -> void: func _test_rolled_component() -> void: var base := float(_floor_val() + 7) # 表值推导探针基线 var crit_mult: int = int(db.settlement.crit_multiplier) - _check("满额折扣无暴击 = floor(基线) + 修正 0", DamageMathScript.rolled_component(base, 100, false, crit_mult, 0) == int(floor(base))) - _check("满额折扣暴击按表倍率放大", DamageMathScript.rolled_component(base, 100, true, crit_mult, 0) == int(floor(base * float(crit_mult) / 100.0))) - _check("折扣降低不高于满额", DamageMathScript.rolled_component(base, 50, false, crit_mult, 0) <= DamageMathScript.rolled_component(base, 100, false, crit_mult, 0)) - _check("克制修正加在 floor 之后(加法核)", DamageMathScript.rolled_component(base, 100, false, crit_mult, _floor_val()) == int(floor(base)) + _floor_val()) - _check("复合序 = 基线×折扣%×暴击%(floor 只在链末)", + Kit.check("满额折扣无暴击 = floor(基线) + 修正 0", DamageMathScript.rolled_component(base, 100, false, crit_mult, 0) == int(floor(base))) + Kit.check("满额折扣暴击按表倍率放大", DamageMathScript.rolled_component(base, 100, true, crit_mult, 0) == int(floor(base * float(crit_mult) / 100.0))) + Kit.check("折扣降低不高于满额", DamageMathScript.rolled_component(base, 50, false, crit_mult, 0) <= DamageMathScript.rolled_component(base, 100, false, crit_mult, 0)) + Kit.check("克制修正加在 floor 之后(加法核)", DamageMathScript.rolled_component(base, 100, false, crit_mult, _floor_val()) == int(floor(base)) + _floor_val()) + Kit.check("复合序 = 基线×折扣%×暴击%(floor 只在链末)", DamageMathScript.rolled_component(base, 50, true, crit_mult, 0) == int(floor(base * 50.0 / 100.0 * float(crit_mult) / 100.0))) diff --git a/game/tests/unit/contracts/system_contracts_test.gd b/game/tests/unit/contracts/system_contracts_test.gd index 6f332bf1..36901a8d 100644 --- a/game/tests/unit/contracts/system_contracts_test.gd +++ b/game/tests/unit/contracts/system_contracts_test.gd @@ -12,35 +12,19 @@ const ConfigDBScript := preload("res://core/config_db.gd") const RngServiceScript := preload("res://core/rng_service.gd") const StatusContainerScript := preload("res://core/status_container.gd") const DamageControllerScript := preload("res://core/damage_controller.gd") - -var _failures := 0 -var _checks := 0 +const Kit := preload("res://tests/unit/test_kit.gd") # T6 收口:_check/汇总退出样板单点 func _init() -> void: var db = ConfigDBScript.new() var load_ok: bool = db.load_tables("res://data/tables", true) - _check("前置:表加载零 error", load_ok and db.diagnostics_count("error") == 0) + Kit.check("前置:表加载零 error", load_ok and db.diagnostics_count("error") == 0) if load_ok: _test_damage_controller_vocabularies() _test_rng_tag_closure(db) _test_ticket_nine_columns() _test_status_boolean_acceptance(db) - if _failures > 0: - push_error("[system_contracts_test] %d/%d 项失败" % [_failures, _checks]) - quit(1) - return - print("[system_contracts_test] all green (%d checks)" % _checks) - quit(0) - - -func _check(desc: String, ok: bool) -> void: - _checks += 1 - if ok: - print(" [PASS] " + desc) - else: - _failures += 1 - push_error(" [FAIL] " + desc) + quit(Kit.finish("system_contracts_test")) # ---- B1 兑付配方词表:五配方/三目标规则/双结账政策——常量非空且互异(封闭枚举负空间) ---- @@ -55,11 +39,11 @@ func _test_damage_controller_vocabularies() -> void: for k in kinds: if String(k).is_empty(): nonempty = false - _check("五配方常量非空(开单面唯一分派键)", nonempty) + Kit.check("五配方常量非空(开单面唯一分派键)", nonempty) var unique_kinds: Dictionary = {} for k in kinds: unique_kinds[String(k)] = true - _check("五配方常量互异(词表封闭无别名)", unique_kinds.size() == kinds.size()) + Kit.check("五配方常量互异(词表封闭无别名)", unique_kinds.size() == kinds.size()) var targets: Array = [ DamageControllerScript.TARGET_FRONT_ALIVE, DamageControllerScript.TARGET_HERO, DamageControllerScript.TARGET_ALL_ALIVE, @@ -67,8 +51,8 @@ func _test_damage_controller_vocabularies() -> void: var unique_targets: Dictionary = {} for t in targets: unique_targets[String(t)] = true - _check("三目标规则常量互异(结账时刻现场解析词表)", unique_targets.size() == targets.size()) - _check("双结账政策常量互异(A 即结 / B 攒批回切位)", + Kit.check("三目标规则常量互异(结账时刻现场解析词表)", unique_targets.size() == targets.size()) + Kit.check("双结账政策常量互异(A 即结 / B 攒批回切位)", String(DamageControllerScript.POLICY_IMMEDIATE) != String(DamageControllerScript.POLICY_SEGMENT)) @@ -76,9 +60,9 @@ func _test_damage_controller_vocabularies() -> void: func _test_rng_tag_closure(db) -> void: var stream_label: String = String(db.settlement.armor_roll_stream) - _check("settlement.armor_roll_stream ∈ RngService 封闭标签词表(EC12)", + Kit.check("settlement.armor_roll_stream ∈ RngService 封闭标签词表(EC12)", RngServiceScript.PURPOSE_TAGS.has(stream_label)) - _check("RngService 用途标签词表非空且互异(封闭语义由流改标签)", + Kit.check("RngService 用途标签词表非空且互异(封闭语义由流改标签)", RngServiceScript.PURPOSE_TAGS.size() > 0) @@ -95,9 +79,9 @@ func _test_ticket_nine_columns() -> void: for c in columns: if not t.has(c): all_present = false - _check("单据九栏 + 可选两栏缺省齐备(开单面只写差异栏前提)", all_present) - _check("缺省无类型轴(attack_type 空 = 恒不查克制矩阵)", String(t.attack_type) == "") - _check("缺省无前置依赖(requires = -1)", int(t.requires) == -1) + Kit.check("单据九栏 + 可选两栏缺省齐备(开单面只写差异栏前提)", all_present) + Kit.check("缺省无类型轴(attack_type 空 = 恒不查克制矩阵)", String(t.attack_type) == "") + Kit.check("缺省无前置依赖(requires = -1)", int(t.requires) == -1) func _bare_controller(): @@ -129,18 +113,18 @@ func _test_status_boolean_acceptance(db) -> void: var any_row: Dictionary = {} if db.status_effects.rows.size() > 0: any_row = db.status_effects.rows[0] - _check("前置:状态表非空", not any_row.is_empty()) + Kit.check("前置:状态表非空", not any_row.is_empty()) if any_row.is_empty(): return var probe_id: String = String(any_row.effect_id) var src := {"source_class": "safe_cell", "origin_cell": Vector2i(-1, -1), "round_index": 0} - _check("域闭授予拒绝(EC1——战斗域外 apply_effect = false)", effects.apply_effect(probe_id, src) == false) - _check("未知 effect_id 拒绝(EC2)", effects.apply_effect("no_such_effect_id__", src) == false) + Kit.check("域闭授予拒绝(EC1——战斗域外 apply_effect = false)", effects.apply_effect(probe_id, src) == false) + Kit.check("未知 effect_id 拒绝(EC2)", effects.apply_effect("no_such_effect_id__", src) == false) effects.notify_battle_start() - _check("开域后受理已知 id(布尔受理口径 true 通道)", effects.apply_effect(probe_id, src) == true) + Kit.check("开域后受理已知 id(布尔受理口径 true 通道)", effects.apply_effect(probe_id, src) == true) var active: Array = effects.query_active(String(any_row.target_side)) var projected := false for a in active: if String(a.effect_id) == probe_id: projected = int(a.effective_value) == int(a.layers) * int(a.snap_magnitude) - _check("受理后投影 effective_value = 层数×授予时快照(抗热重载 EC9 面)", projected) + Kit.check("受理后投影 effective_value = 层数×授予时快照(抗热重载 EC9 面)", projected) diff --git a/game/tests/unit/core/aux_items_test.gd b/game/tests/unit/core/aux_items_test.gd index 2dff7fea..f78c1626 100644 --- a/game/tests/unit/core/aux_items_test.gd +++ b/game/tests/unit/core/aux_items_test.gd @@ -6,13 +6,7 @@ extends SceneTree const AuxItemsScript := preload("res://core/aux_items.gd") - -var _problems: Array = [] - - -func check(cond: bool, msg: String) -> void: - if not cond: - _problems.append(msg) +const Kit := preload("res://tests/unit/test_kit.gd") # T6 收口:check/汇总退出样板单点(静默收集通道) class StubTable: @@ -59,39 +53,39 @@ func _init() -> void: var inv = _new_inv() var r1: Dictionary = inv.add_item("sapper_order", "safe_cell", Vector2i(3, 4), 2) var r2: Dictionary = inv.add_item("nest_pendulum", "flag_hit", Vector2i(5, 6), 3) - check(int(r1.handle) == 1 and int(r2.handle) == 2, "句柄受理序单调(1→2——零随机)") - check(String(r1.source_class) == "safe_cell" and r1.origin_cell == Vector2i(3, 4) and int(r1.round_index) == 2, + Kit.check_quiet(int(r1.handle) == 1 and int(r2.handle) == 2, "句柄受理序单调(1→2——零随机)") + Kit.check_quiet(String(r1.source_class) == "safe_cell" and r1.origin_cell == Vector2i(3, 4) and int(r1.round_index) == 2, "授予来源可归因(source/cell/round)") var inv_proj: Array = inv.query_inventory(Callable()) - check(inv_proj.size() == 2 and not bool(inv_proj[0].used), "库存投影两件未用") - check(bool(inv.consume(int(r1.handle)).get("used", false)), "consume 返回已用快照回执") - check(inv.consume(int(r1.handle)).is_empty(), "Used 不可逆——二次 consume 拒绝(空回执)") - check(int(inv.inventory_count()) == 2, "Used 保留只读(库存计数不变)") + Kit.check_quiet(inv_proj.size() == 2 and not bool(inv_proj[0].used), "库存投影两件未用") + Kit.check_quiet(bool(inv.consume(int(r1.handle)).get("used", false)), "consume 返回已用快照回执") + Kit.check_quiet(inv.consume(int(r1.handle)).is_empty(), "Used 不可逆——二次 consume 拒绝(空回执)") + Kit.check_quiet(int(inv.inventory_count()) == 2, "Used 保留只读(库存计数不变)") # ② 真视恒可用+边缘窗口∩板(3×3 中心贴角——角 4 格) var v_ok: Dictionary = inv.validate_use(int(r2.handle), Vector2i(0, 0), Vector2i(10, 10), Callable()) - check(bool(v_ok.ok) and v_ok.resolved.size() == 4, + Kit.check_quiet(bool(v_ok.ok) and v_ok.resolved.size() == 4, "真视边缘窗口=窗口∩板(角 4 格——护栏只缩不扩)") # 预读点击级零信息:中心板内但回调恒 false(全已开)→拒绝零消耗 var inv2 = _new_inv() var rp: Dictionary = inv2.add_item("loot_compass", "safe_cell", Vector2i(1, 1), 1) var v_zero: Dictionary = inv2.validate_use(int(rp.handle), Vector2i(5, 5), Vector2i(10, 10), Callable()) - check(not bool(v_zero.ok) and int(inv2.inventory_count()) == 1, + Kit.check_quiet(not bool(v_zero.ok) and int(inv2.inventory_count()) == 1, "预读点击级零信息拒绝(不消耗——库存不变)") # 目标越板 var v_oob: Dictionary = inv2.validate_use(int(rp.handle), Vector2i(12, 5), Vector2i(10, 10), Callable()) - check(not bool(v_zero.ok) and not bool(v_oob.ok), "越板目标拒绝") + Kit.check_quiet(not bool(v_zero.ok) and not bool(v_oob.ok), "越板目标拒绝") # ③ 叠加层+三清(retire 不动库存;reset 全清) inv2.apply_overlay("mine_reveal", v_ok.resolved) inv2.apply_overlay("reward_preview", [Vector2i(5, 5)]) var ov: Dictionary = inv2.query_overlays() - check(ov.mine_reveal.size() == 4 and ov.reward_preview.size() == 1, "叠加层两集落位(真视 4/预读 1)") + Kit.check_quiet(ov.mine_reveal.size() == 4 and ov.reward_preview.size() == 1, "叠加层两集落位(真视 4/预读 1)") inv2.retire_overlays() - check(inv2.query_overlays().mine_reveal.is_empty() and int(inv2.inventory_count()) == 1, + Kit.check_quiet(inv2.query_overlays().mine_reveal.is_empty() and int(inv2.inventory_count()) == 1, "换图三清:叠加层消亡+库存不动(关卡域)") inv2.reset() - check(inv2.inventory_count() == 0 and inv2.probe().used == 0, "关卡末 reset 全清(幂等)") + Kit.check_quiet(inv2.inventory_count() == 0 and inv2.probe().used == 0, "关卡末 reset 全清(幂等)") # ④ 零随机重放:同操作序列两遍,投影逐位相同 var replay_a: Array = [] @@ -106,13 +100,7 @@ func _init() -> void: replay_a = [inv3.query_inventory(Callable()), inv3.query_overlays(), inv3.probe()] else: replay_b = [inv3.query_inventory(Callable()), inv3.query_overlays(), inv3.probe()] - check(str(replay_a) == str(replay_b), "零随机重放:库存+叠加层+探针投影逐位相同") + Kit.check_quiet(str(replay_a) == str(replay_b), "零随机重放:库存+叠加层+探针投影逐位相同") # ---- 汇总 ---- - if _problems.is_empty(): - print("[aux_items_test] PASS — 授予归因/Used 不可逆/真视恒可用/零信息拒绝/边缘窗口/三清/零随机重放 全过") - quit(0) - else: - for p in _problems: - push_error("[aux_items_test] FAIL — " + p) - quit(1) + quit(Kit.finish("aux_items_test", "PASS — 授予归因/Used 不可逆/真视恒可用/零信息拒绝/边缘窗口/三清/零随机重放 全过")) diff --git a/game/tests/unit/core/breakpoint_resume_test.gd b/game/tests/unit/core/breakpoint_resume_test.gd index 4270efb1..5edf6cdd 100644 --- a/game/tests/unit/core/breakpoint_resume_test.gd +++ b/game/tests/unit/core/breakpoint_resume_test.gd @@ -8,16 +8,11 @@ extends SceneTree const SaveManagerScript := preload("res://core/save_manager.gd") const AdventureRunnerScript := preload("res://core/adventure_runner.gd") +const Kit := preload("res://tests/unit/test_kit.gd") # T6 收口:check/汇总退出样板单点(静默收集通道) -var _problems: Array = [] var _bp_path := "user://test_breakpoint_resume.json" -func check(cond: bool, msg: String) -> void: - if not cond: - _problems.append(msg) - - func _init() -> void: var db = load("res://core/config_db.gd").new() if not db.load_tables("res://data/tables", false): @@ -32,64 +27,64 @@ func _init() -> void: var adr_a = AdventureRunnerScript.new(db, rng_a) adr_a.set_save_manager(sm_a) var params := {"hero_override": {}, "aux_unlocked": {}} - check(adr_a.start_adventure(1234, params), "冒险启动(入口停点)") - check(sm_a.has_breakpoint(), "入口停点断点在档") - check(int((sm_a.breakpoint_snapshot() as Dictionary).get("level_index", -1)) == 1, "断点关卡序号 = 1") - check(adr_a.abandon(), "入口停点放弃受理(非战斗期)") - check(not sm_a.has_breakpoint() and sm_a.write_to_disk(), "放弃清断点+写盘(退出=清除断点)") + Kit.check_quiet(adr_a.start_adventure(1234, params), "冒险启动(入口停点)") + Kit.check_quiet(sm_a.has_breakpoint(), "入口停点断点在档") + Kit.check_quiet(int((sm_a.breakpoint_snapshot() as Dictionary).get("level_index", -1)) == 1, "断点关卡序号 = 1") + Kit.check_quiet(adr_a.abandon(), "入口停点放弃受理(非战斗期)") + Kit.check_quiet(not sm_a.has_breakpoint() and sm_a.write_to_disk(), "放弃清断点+写盘(退出=清除断点)") var sm_reload = SaveManagerScript.new(_bp_path) sm_reload.load_from_disk() - check(not sm_reload.has_breakpoint(), "放弃后重读档无断点(「继续上局」隐藏口径)") + Kit.check_quiet(not sm_reload.has_breakpoint(), "放弃后重读档无断点(「继续上局」隐藏口径)") # ---- Part 2:中断基线——入口停点后选路进战,记板指纹与计数器 ---- var rng_b = load("res://core/rng_service.gd").new() var sm_b = SaveManagerScript.new(_bp_path) var adr_b = AdventureRunnerScript.new(db, rng_b) adr_b.set_save_manager(sm_b) - check(adr_b.start_adventure(1234, params), "基线冒险启动(同种子 1234)") + Kit.check_quiet(adr_b.start_adventure(1234, params), "基线冒险启动(同种子 1234)") var runner_b = adr_b.runner() var c0: int = rng_b.counter() var cand_b: Array = runner_b.next_candidate_ids() var hp0: int = runner_b.hero_hp var bp_disk: Dictionary = sm_b.breakpoint_snapshot() - check(int(bp_disk.get("rng_counter", -1)) == c0, "断点计数器 = 当前取数号位(快照契约)") - check(runner_b.pick_next_node(int(cand_b[0])), "基线选路进首战") + Kit.check_quiet(int(bp_disk.get("rng_counter", -1)) == c0, "断点计数器 = 当前取数号位(快照契约)") + Kit.check_quiet(runner_b.pick_next_node(int(cand_b[0])), "基线选路进首战") var battle_b = runner_b.current_battle() - check(battle_b != null, "基线首战开打") + Kit.check_quiet(battle_b != null, "基线首战开打") var fp_b := _board_fingerprint(battle_b.minesweeper.current_board()) var c1: int = rng_b.counter() # ---- Part 3:续玩确定性——新进程对象自盘上断点重建,同种子续玩板逐位一致 ---- var sm_c = SaveManagerScript.new(_bp_path) - check(sm_c.load_from_disk() and sm_c.has_breakpoint(), "断点自盘读回在档") + Kit.check_quiet(sm_c.load_from_disk() and sm_c.has_breakpoint(), "断点自盘读回在档") var bp_c: Dictionary = sm_c.breakpoint_snapshot() var rng_c = load("res://core/rng_service.gd").new() var adr_c = AdventureRunnerScript.new(db, rng_c) - check(adr_c.resume_adventure(bp_c, params), "续玩受理") + Kit.check_quiet(adr_c.resume_adventure(bp_c, params), "续玩受理") var runner_c = adr_c.runner() - check(int(adr_c.master_seed) == 1234, "续玩主种子回填") - check(int(rng_c.counter()) == c0, "续玩取数号位 = 断点号位(零重耗零跳号)") - check(runner_c.hero_hp == hp0, "续玩生命账本回填") + Kit.check_quiet(int(adr_c.master_seed) == 1234, "续玩主种子回填") + Kit.check_quiet(int(rng_c.counter()) == c0, "续玩取数号位 = 断点号位(零重耗零跳号)") + Kit.check_quiet(runner_c.hero_hp == hp0, "续玩生命账本回填") var cand_c: Array = runner_c.next_candidate_ids() - check(cand_c.size() == cand_b.size() and int(cand_c[0]) == int(cand_b[0]), "续玩候选一致(停点重建)") - check(runner_c.awaiting_choice(), "续玩处选路停点态") - check(runner_c.pick_next_node(int(cand_c[0])), "续玩选路进首战") + Kit.check_quiet(cand_c.size() == cand_b.size() and int(cand_c[0]) == int(cand_b[0]), "续玩候选一致(停点重建)") + Kit.check_quiet(runner_c.awaiting_choice(), "续玩处选路停点态") + Kit.check_quiet(runner_c.pick_next_node(int(cand_c[0])), "续玩选路进首战") var battle_c = runner_c.current_battle() var fp_c := _board_fingerprint(battle_c.minesweeper.current_board()) - check(fp_c == fp_b, "续玩首战棋盘与中断前时间线逐位一致(同种子同板)") - check(int(rng_c.counter()) == c1, "续玩开板后号位与基线一致") + Kit.check_quiet(fp_c == fp_b, "续玩首战棋盘与中断前时间线逐位一致(同种子同板)") + Kit.check_quiet(int(rng_c.counter()) == c1, "续玩开板后号位与基线一致") # ---- Part 4:续玩幂等——同一断点二次续玩(新进程对象)结果逐位同 ---- var sm_d = SaveManagerScript.new(_bp_path) sm_d.load_from_disk() var rng_d = load("res://core/rng_service.gd").new() var adr_d = AdventureRunnerScript.new(db, rng_d) - check(adr_d.resume_adventure(sm_d.breakpoint_snapshot(), params), "二次续玩受理") + Kit.check_quiet(adr_d.resume_adventure(sm_d.breakpoint_snapshot(), params), "二次续玩受理") var runner_d = adr_d.runner() - check(int(rng_d.counter()) == c0 and runner_d.hero_hp == hp0, "二次续玩状态逐位同(幂等)") - check(runner_d.pick_next_node(int(runner_d.next_candidate_ids()[0])), "二次续玩选路") + Kit.check_quiet(int(rng_d.counter()) == c0 and runner_d.hero_hp == hp0, "二次续玩状态逐位同(幂等)") + Kit.check_quiet(runner_d.pick_next_node(int(runner_d.next_candidate_ids()[0])), "二次续玩选路") var battle_d = runner_d.current_battle() - check(_board_fingerprint(battle_d.minesweeper.current_board()) == fp_b, "二次续玩板逐位同(幂等)") + Kit.check_quiet(_board_fingerprint(battle_d.minesweeper.current_board()) == fp_b, "二次续玩板逐位同(幂等)") # ---- Part 5:三选一停点中断→续玩(迭代 10 奖励重构——候选不重抽、已兑付不重付、次数计数一致)---- var rng_e = load("res://core/rng_service.gd").new() @@ -97,50 +92,43 @@ func _init() -> void: var adr_e = AdventureRunnerScript.new(db, rng_e) adr_e.set_save_manager(sm_e) var params_e := {"hero_override": {}, "aux_unlocked": {"blast_kit": true}} - check(adr_e.start_adventure(4321, params_e), "P5 冒险启动(seed 4321)") + Kit.check_quiet(adr_e.start_adventure(4321, params_e), "P5 冒险启动(seed 4321)") adr_e._bonus_taken["attack"] = 1 # 白盒预置计数(断点附随件往返的判据面——计数不可由加算区反推) adr_e._on_level_finished({"result": "victory", "hp": 30, "node_index": 1, "node_id": 1, "node_type": "shop"}) var cands_e: Array = (adr_e._bonus_pending.get("candidates", []) as Array).duplicate() - check(not cands_e.is_empty(), "P5 非末关通关三选一停点在案(直调 victory——L1 非末关)") + Kit.check_quiet(not cands_e.is_empty(), "P5 非末关通关三选一停点在案(直调 victory——L1 非末关)") var sm_f = SaveManagerScript.new(_bp_path) - check(sm_f.load_from_disk() and sm_f.has_breakpoint(), "P5 选择停点断点在档(选择停点可中断契约)") + Kit.check_quiet(sm_f.load_from_disk() and sm_f.has_breakpoint(), "P5 选择停点断点在档(选择停点可中断契约)") var bp_f: Dictionary = sm_f.breakpoint_snapshot() var bp_f_cands: Array = (bp_f.get("bonus_pending", {}) as Dictionary).get("candidates", []) as Array - check(bp_f_cands == cands_e, "P5 断点附随件含候选(bonus_pending 往返)") - check(int((bp_f.get("bonus_taken", {}) as Dictionary).get("attack", -1)) == 1, "P5 断点附随件含次数计数(bonus_taken 往返)") + Kit.check_quiet(bp_f_cands == cands_e, "P5 断点附随件含候选(bonus_pending 往返)") + Kit.check_quiet(int((bp_f.get("bonus_taken", {}) as Dictionary).get("attack", -1)) == 1, "P5 断点附随件含次数计数(bonus_taken 往返)") var got_cands := {"cands": [] as Array} var adr_f = AdventureRunnerScript.new(db, load("res://core/rng_service.gd").new()) adr_f.set_save_manager(sm_f) # 兑付后停点快照落盘承载(与真机组合根同构) adr_f.bonus_choice_presented.connect(func(p): got_cands["cands"] = (p.get("candidates", []) as Array).duplicate()) - check(adr_f.resume_adventure(bp_f, params_e), "P5 续玩受理(选择态分支)") - check(adr_f.runner() == null, "P5 续玩不重跑关卡(三选一停点直回,_start_current_level 未走)") - check(got_cands["cands"] == cands_e, "P5 续玩候选不重抽(逐位一致)") - check(int(adr_f._bonus_taken["attack"]) == 1, "P5 次数计数恢复一致(牌库剔除态续接)") - check(not adr_f.continue_to_next_level(), "P5 未兑付推进拒绝(续玩态零自动推进同契约)") - check(adr_f.pick_bonus(String(cands_e[0])), "P5 点选兑付受理") + Kit.check_quiet(adr_f.resume_adventure(bp_f, params_e), "P5 续玩受理(选择态分支)") + Kit.check_quiet(adr_f.runner() == null, "P5 续玩不重跑关卡(三选一停点直回,_start_current_level 未走)") + Kit.check_quiet(got_cands["cands"] == cands_e, "P5 续玩候选不重抽(逐位一致)") + Kit.check_quiet(int(adr_f._bonus_taken["attack"]) == 1, "P5 次数计数恢复一致(牌库剔除态续接)") + Kit.check_quiet(not adr_f.continue_to_next_level(), "P5 未兑付推进拒绝(续玩态零自动推进同契约)") + Kit.check_quiet(adr_f.pick_bonus(String(cands_e[0])), "P5 点选兑付受理") var picked_e: String = String(adr_f._bonus_result.get("picked", "")) if adr_f._bonus_taken.has(picked_e): - check(int(adr_f._bonus_taken[picked_e]) == (2 if picked_e == "attack" else 1), + Kit.check_quiet(int(adr_f._bonus_taken[picked_e]) == (2 if picked_e == "attack" else 1), "P5 兑付计数一致(%s——预置 1 + 本次兑付)" % picked_e) - check(sm_f.has_breakpoint(), "P5 兑付后停点快照在档(已兑付待推进态可中断)") + Kit.check_quiet(sm_f.has_breakpoint(), "P5 兑付后停点快照在档(已兑付待推进态可中断)") var bp_g: Dictionary = sm_f.breakpoint_snapshot() var adr_g = AdventureRunnerScript.new(db, load("res://core/rng_service.gd").new()) adr_g.set_save_manager(sm_f) # 已兑付态续玩的后续快照同承载 var got_result := {"r": {}} adr_g.bonus_resolved.connect(func(r): got_result["r"] = r.duplicate()) - check(adr_g.resume_adventure(bp_g, params_e), "P5 已兑付态续玩受理") - check(String(got_result["r"].get("picked", "")) == picked_e, "P5 已兑付不重付(resolved 重发同件)") - check(adr_g.continue_to_next_level(), "P5 已兑付续玩后推进受理(下一关开始)") + Kit.check_quiet(adr_g.resume_adventure(bp_g, params_e), "P5 已兑付态续玩受理") + Kit.check_quiet(String(got_result["r"].get("picked", "")) == picked_e, "P5 已兑付不重付(resolved 重发同件)") + Kit.check_quiet(adr_g.continue_to_next_level(), "P5 已兑付续玩后推进受理(下一关开始)") _clear_disk() - if _problems.is_empty(): - print("[breakpoint_resume_test] PASS — 断点往返/续玩确定性/幂等/退出清断点 全绿") - quit(0) - else: - for p in _problems: - push_error("[breakpoint_resume_test] FAIL — " + p) - print("[breakpoint_resume_test] FAIL — " + p) - quit(1) + quit(Kit.finish("breakpoint_resume_test", "PASS — 断点往返/续玩确定性/幂等/退出清断点 全绿")) func _board_fingerprint(b) -> String: diff --git a/game/tests/unit/data_config/aux_target_violation_test.gd b/game/tests/unit/data_config/aux_target_violation_test.gd index f06c0251..4f4fa08d 100644 --- a/game/tests/unit/data_config/aux_target_violation_test.gd +++ b/game/tests/unit/data_config/aux_target_violation_test.gd @@ -7,27 +7,12 @@ extends SceneTree const ConfigDBScript := preload("res://core/config_db.gd") +const Kit := preload("res://tests/unit/test_kit.gd") # T6 收口:BAD 表目录夹具共享(copy_tables) const SRC_DIR := "res://data/tables" const BAD_DIR := "user://aux_fixture_target_violation" -func _copy_tables() -> bool: - DirAccess.make_dir_recursive_absolute(BAD_DIR) - var dir := DirAccess.open(SRC_DIR) - if dir == null: - return false - dir.list_dir_begin() - var f := dir.get_next() - while f != "": - if not dir.current_is_dir(): - var err := dir.copy(SRC_DIR + "/" + f, BAD_DIR + "/" + f) - if err != OK: - return false - f = dir.get_next() - return true - - func _inject_violation() -> bool: var path := BAD_DIR + "/aux_items.tres" var f := FileAccess.open(path, FileAccess.READ) @@ -58,7 +43,7 @@ func _inject_violation() -> bool: func _init() -> void: - if not _copy_tables() or not _inject_violation(): + if not Kit.copy_tables(SRC_DIR, BAD_DIR) or not _inject_violation(): push_error("[aux_target_violation_test] 夹具目录准备失败") quit(2) return diff --git a/game/tests/unit/data_config/config_db_test.gd b/game/tests/unit/data_config/config_db_test.gd index 5362bd42..bdf4c2bc 100644 --- a/game/tests/unit/data_config/config_db_test.gd +++ b/game/tests/unit/data_config/config_db_test.gd @@ -5,16 +5,14 @@ extends SceneTree const ConfigDBScript := preload("res://core/config_db.gd") - -var _failures := 0 -var _checks := 0 +const Kit := preload("res://tests/unit/test_kit.gd") # T6 收口:_check/汇总退出样板单点 func _init() -> void: var db = ConfigDBScript.new() var load_ok: bool = db.load_tables("res://data/tables", true) - _check("全部表加载 + 校验通过(含跨表护栏零 error 诊断)", load_ok and db.diagnostics_count("error") == 0) - _check("is_ready 通道随加载置位", db.is_ready) + Kit.check("全部表加载 + 校验通过(含跨表护栏零 error 诊断)", load_ok and db.diagnostics_count("error") == 0) + Kit.check("is_ready 通道随加载置位", db.is_ready) if not load_ok: for d in db.diagnostics(): if d.severity == "error": @@ -25,21 +23,7 @@ func _init() -> void: _test_shop_accessors(db) _test_meta_accessors(db) _test_manifest_dir_sync(db) - if _failures > 0: - push_error("[config_db_test] %d/%d 项失败" % [_failures, _checks]) - quit(1) - return - print("[config_db_test] all green (%d checks)" % _checks) - quit(0) - - -func _check(desc: String, ok: bool) -> void: - _checks += 1 - if ok: - print(" [PASS] " + desc) - else: - _failures += 1 - push_error(" [FAIL] " + desc) + quit(Kit.finish("config_db_test")) # ---- 进度坐标 ceil 解析(R1:向上下取整保证偏难;越最大行 clamp 取最大值 + 警告去重)---- @@ -67,34 +51,34 @@ func _test_progress_resolution(db) -> void: # 精确命中:请求 = 表内现存 (L,D) → 原行返回(字段逐位相等) var probe_row: Dictionary = bg_rows[0] var got: Dictionary = db.resolve_board_gen(int(probe_row.level_index), int(probe_row.node_depth)) - _check("进度解析精确命中返回原行(board_gen 首行)", + Kit.check("进度解析精确命中返回原行(board_gen 首行)", int(got.get("level_index", -99)) == int(probe_row.level_index) and int(got.get("node_depth", -99)) == int(probe_row.node_depth) and String(got.get("board_type", "")) == String(probe_row.get("board_type", ""))) # enemy 行集同通道:同 (L,D) 精确行存在(关卡节点地图 前置——battle 节点双表精确行契约) var en_got: Dictionary = db.resolve_enemy_rowset(int(probe_row.level_index), int(probe_row.node_depth)) - _check("enemy 行集同坐标解析非空(双表精确行契约)", not en_got.is_empty()) + Kit.check("enemy 行集同坐标解析非空(双表精确行契约)", not en_got.is_empty()) # 深度 clamp:请求深度超最大行 → 取该关最大深度行 + 警告诊断一次(去重) db.reset_diagnostics() var deep: Dictionary = db.resolve_board_gen(min_l, int(min_depths[-1]) + 1) - _check("深度越最大行 clamp 取该关最大深度行", + Kit.check("深度越最大行 clamp 取该关最大深度行", int(deep.get("level_index", -99)) == min_l and int(deep.get("node_depth", -99)) == int(min_depths[-1])) - _check("clamp 解析发警告诊断(resolve_depth_clamp)", db.diagnostics_count("warning") >= 1) + Kit.check("clamp 解析发警告诊断(resolve_depth_clamp)", db.diagnostics_count("warning") >= 1) var found_clamp := false for d in db.diagnostics(): if d.category == "resolve_depth_clamp": found_clamp = true - _check("警告类别 = resolve_depth_clamp", found_clamp) + Kit.check("警告类别 = resolve_depth_clamp", found_clamp) var warn_before: int = db.diagnostics_count("warning") db.resolve_board_gen(min_l, int(min_depths[-1]) + 1) - _check("同键重复请求警告去重(四元组注册表)", db.diagnostics_count("warning") == warn_before) + Kit.check("同键重复请求警告去重(四元组注册表)", db.diagnostics_count("warning") == warn_before) # 关卡 clamp:请求关卡超最大行 → 取最大关卡行 db.reset_diagnostics() var high: Dictionary = db.resolve_board_gen(max_l + 1, 0) - _check("关卡越最大行 clamp 取最大关卡行", int(high.get("level_index", -99)) == max_l) + Kit.check("关卡越最大行 clamp 取最大关卡行", int(high.get("level_index", -99)) == max_l) # ---- 变异池门控(unlock_level 闸 + ∅ board_types 通配)---- @@ -114,12 +98,12 @@ func _test_variant_pool_gate(db) -> void: var tag: String = String(gated_row.board_types[0]) var before: Array = db.variant_pool(u - 1, tag) var after: Array = db.variant_pool(u, tag) - _check("变异池 unlock 前不含未解锁行(level=unlock−1)", not _rows_contain(before, gated_row)) - _check("变异池 unlock 当级含该行", _rows_contain(after, gated_row)) + Kit.check("变异池 unlock 前不含未解锁行(level=unlock−1)", not _rows_contain(before, gated_row)) + Kit.check("变异池 unlock 当级含该行", _rows_contain(after, gated_row)) if not wildcard_row.is_empty(): var u2: int = int(wildcard_row.unlock_level) var any_pool: Array = db.variant_pool(u2, "nonexistent_board_type_tag") - _check("∅ board_types 行通配(任一 board_type 命中)", _rows_contain(any_pool, wildcard_row)) + Kit.check("∅ board_types 行通配(任一 board_type 命中)", _rows_contain(any_pool, wildcard_row)) func _rows_contain(pool: Array, row: Dictionary) -> bool: @@ -134,26 +118,26 @@ func _rows_contain(pool: Array, row: Dictionary) -> bool: func _test_shop_accessors(db) -> void: var products: Array = db.shop_products() if products.is_empty(): - _check("ShopTable products 行族非空(表已落籍前提)", false) + Kit.check("ShopTable products 行族非空(表已落籍前提)", false) return - _check("ShopTable products 行族非空", true) + Kit.check("ShopTable products 行族非空", true) var first: Dictionary = products[0] var got: Dictionary = db.shop_product_row(String(first.product_id)) - _check("shop_product_row 按 product_id 精确命中首行", got == first) - _check("shop_product_row 未知 id 返回空字典(容错通道)", db.shop_product_row("no_such_product_id__").is_empty()) + Kit.check("shop_product_row 按 product_id 精确命中首行", got == first) + Kit.check("shop_product_row 未知 id 返回空字典(容错通道)", db.shop_product_row("no_such_product_id__").is_empty()) var tier_rows: Array = db._shop_rows("gp_drops") if not tier_rows.is_empty(): var tier0: String = String(tier_rows[0].tier) - _check("shop_gp_drop_row 按 tier 命中", not db.shop_gp_drop_row(tier0).is_empty()) + Kit.check("shop_gp_drop_row 按 tier 命中", not db.shop_gp_drop_row(tier0).is_empty()) var bonus_rows: Array = db._shop_rows("level_gp_bonus") if not bonus_rows.is_empty(): var blv: int = int(bonus_rows[0].level_index) - _check("shop_level_gp_bonus 按关卡序号命中行值", db.shop_level_gp_bonus(blv) == int(bonus_rows[0].bonus)) - _check("shop_level_gp_bonus 未登记关卡返回 0(兜底通道)", db.shop_level_gp_bonus(-1) == 0) + Kit.check("shop_level_gp_bonus 按关卡序号命中行值", db.shop_level_gp_bonus(blv) == int(bonus_rows[0].bonus)) + Kit.check("shop_level_gp_bonus 未登记关卡返回 0(兜底通道)", db.shop_level_gp_bonus(-1) == 0) var price_rows: Array = db._shop_rows("price_level_bonus") if not price_rows.is_empty(): var plv: int = int(price_rows[0].level_index) - _check("shop_price_level_bonus 按关卡序号命中行值", db.shop_price_level_bonus(plv) == int(price_rows[0].bonus)) + Kit.check("shop_price_level_bonus 按关卡序号命中行值", db.shop_price_level_bonus(plv) == int(price_rows[0].bonus)) # ---- MetaTable 消费面 ---- @@ -161,19 +145,19 @@ func _test_shop_accessors(db) -> void: func _test_meta_accessors(db) -> void: var forge_rows: Array = db._meta_rows("forge_products") if forge_rows.is_empty(): - _check("MetaTable forge_products 行族非空", false) + Kit.check("MetaTable forge_products 行族非空", false) else: var attr0: String = String(forge_rows[0].attr) - _check("meta_forge_row 按 attr 命中首行", db.meta_forge_row(attr0) == forge_rows[0]) - _check("meta_forge_row 未知 attr 返回空字典", db.meta_forge_row("no_such_attr__").is_empty()) + Kit.check("meta_forge_row 按 attr 命中首行", db.meta_forge_row(attr0) == forge_rows[0]) + Kit.check("meta_forge_row 未知 attr 返回空字典", db.meta_forge_row("no_such_attr__").is_empty()) var key_rows: Array = db._meta_rows("boss_keystone") if not key_rows.is_empty(): var klv: int = int(key_rows[0].level_index) - _check("meta_boss_keystone_row 按关卡序号命中", db.meta_boss_keystone_row(klv) == key_rows[0]) + Kit.check("meta_boss_keystone_row 按关卡序号命中", db.meta_boss_keystone_row(klv) == key_rows[0]) var conv_rows: Array = db._meta_rows("conversion") if not conv_rows.is_empty(): var cattr: String = String(conv_rows[0].attr) - _check("meta_conversion_row 按 attr 命中", db.meta_conversion_row(cattr) == conv_rows[0]) + Kit.check("meta_conversion_row 按 attr 命中", db.meta_conversion_row(cattr) == conv_rows[0]) # ---- TABLE_MANIFEST ↔ data/tables 目录同步(各系统→D2 单点契约的登记面)---- @@ -195,4 +179,4 @@ func _test_manifest_dir_sync(db) -> void: for key in manifest.keys(): if not disk_files.has(String(manifest[key])): synced = false - _check("TABLE_MANIFEST 与 data/tables 目录逐文件同步(无缺登/无悬登)", synced) + Kit.check("TABLE_MANIFEST 与 data/tables 目录逐文件同步(无缺登/无悬登)", synced) diff --git a/game/tests/unit/data_config/reward_dangling_aux_test.gd b/game/tests/unit/data_config/reward_dangling_aux_test.gd index 7490d229..7d110512 100644 --- a/game/tests/unit/data_config/reward_dangling_aux_test.gd +++ b/game/tests/unit/data_config/reward_dangling_aux_test.gd @@ -7,26 +7,12 @@ extends SceneTree const ConfigDBScript := preload("res://core/config_db.gd") +const Kit := preload("res://tests/unit/test_kit.gd") # T6 收口:BAD 表目录夹具共享(copy_tables) const SRC_DIR := "res://data/tables" const BAD_DIR := "user://aux_fixture_dangling_aux" -func _copy_tables() -> bool: - DirAccess.make_dir_recursive_absolute(BAD_DIR) - var dir := DirAccess.open(SRC_DIR) - if dir == null: - return false - dir.list_dir_begin() - var f := dir.get_next() - while f != "": - if not dir.current_is_dir(): - if dir.copy(SRC_DIR + "/" + f, BAD_DIR + "/" + f) != OK: - return false - f = dir.get_next() - return true - - func _inject_violation() -> bool: var path := BAD_DIR + "/reward.tres" var f := FileAccess.open(path, FileAccess.READ) @@ -50,7 +36,7 @@ func _inject_violation() -> bool: func _init() -> void: - if not _copy_tables() or not _inject_violation(): + if not Kit.copy_tables(SRC_DIR, BAD_DIR) or not _inject_violation(): push_error("[reward_dangling_aux_test] 夹具目录准备失败") quit(2) return diff --git a/game/tests/unit/data_config/rng_service_test.gd b/game/tests/unit/data_config/rng_service_test.gd index 14861c97..f04939a5 100644 --- a/game/tests/unit/data_config/rng_service_test.gd +++ b/game/tests/unit/data_config/rng_service_test.gd @@ -6,33 +6,17 @@ extends SceneTree const RngServiceScript := preload("res://core/rng_service.gd") +const Kit := preload("res://tests/unit/test_kit.gd") # T6 收口:_check/汇总退出样板单点 const TEST_SEED := 20260928 # 夹具种子(任意固定值;语义 = 可复现性,非玩法数值) -var _failures := 0 -var _checks := 0 - func _init() -> void: _test_derive_determinism() _test_counter_ledger() _test_stream_draw_determinism() _test_negative_seed_normalization() - if _failures > 0: - push_error("[rng_service_test] %d/%d 项失败" % [_failures, _checks]) - quit(1) - return - print("[rng_service_test] all green (%d checks)" % _checks) - quit(0) - - -func _check(desc: String, ok: bool) -> void: - _checks += 1 - if ok: - print(" [PASS] " + desc) - else: - _failures += 1 - push_error(" [FAIL] " + desc) + quit(Kit.finish("rng_service_test")) func _fresh_service(): @@ -49,11 +33,11 @@ func _test_derive_determinism() -> void: for scope in range(8): if a.derive_at(scope) != b.derive_at(scope): same = false - _check("同种子两实例 derive_at(0..7) 逐位一致", same) + Kit.check("同种子两实例 derive_at(0..7) 逐位一致", same) var a2 = _fresh_service() var before: int = a2.counter() a2.derive_at(0) - _check("derive_at 为纯函数不推计数器", a2.counter() == before) + Kit.check("derive_at 为纯函数不推计数器", a2.counter() == before) # 全局取数计数器:唯一账本——open_stream 逐次 +1(scope 单调) @@ -64,13 +48,13 @@ func _test_counter_ledger() -> void: var c1: int = rng.counter() rng.open_stream("armor_roll") var c2: int = rng.counter() - _check("open_stream 逐次推进全局计数器(+1/次)", c1 == c0 + 1 and c2 == c0 + 2) + Kit.check("open_stream 逐次推进全局计数器(+1/次)", c1 == c0 + 1 and c2 == c0 + 2) # 用途标签只进事件日志不参与派生——同 scope 同派生种子(ADR-0007 裁丙口径) var rng_x = _fresh_service() var rng_y = _fresh_service() var s1: int = int(rng_x.open_stream("board_gen").seed) var s2: int = int(rng_y.open_stream("reward_roll").seed) - _check("同 scope 异用途标签派生种子一致(标签不参与派生)", s1 == s2) + Kit.check("同 scope 异用途标签派生种子一致(标签不参与派生)", s1 == s2) # 同种子两实例同标签流的实际取数序列逐位一致(回放确定性契约——存档快照=主种子+计数器的前提) @@ -84,7 +68,7 @@ func _test_stream_draw_determinism() -> void: var rb: int = b.open_stream("armor_roll").randi_range(1, faces) if ra != rb: same = false - _check("同种子两实例同标签流取数 12 次逐位一致", same) + Kit.check("同种子两实例同标签流取数 12 次逐位一致", same) # 负输入 abs 归一化(警告通道)——|−s| 与 s 同派生 @@ -92,4 +76,4 @@ func _test_negative_seed_normalization() -> void: var pos = _fresh_service() var neg = RngServiceScript.new() neg.set_master_seed(-TEST_SEED) - _check("负种子 abs 归一化后派生与正种子一致", pos.derive_at(0) == neg.derive_at(0)) + Kit.check("负种子 abs 归一化后派生与正种子一致", pos.derive_at(0) == neg.derive_at(0)) diff --git a/game/tests/unit/example_test.gd b/game/tests/unit/example_test.gd index df553db7..455d059a 100644 --- a/game/tests/unit/example_test.gd +++ b/game/tests/unit/example_test.gd @@ -7,23 +7,12 @@ # 第一个真实测试:复制本文件为 tests/unit//__test.gd 并替换占位断言 extends SceneTree -func _init() -> void: - var failures := 0 - failures += _check("headless SceneTree 协议可用(quit(code) 通道)", _test_headless_protocol()) - failures += _check("命名规范可执行:test_[scenario]_[expected]", _test_naming_convention_followed()) - if failures > 0: - push_error("[example_test] %d 项失败" % failures) - quit(1) - return - print("[example_test] all green") - quit(0) +const Kit := preload("res://tests/unit/test_kit.gd") # T6 收口:check/汇总退出样板单点 -func _check(desc: String, ok: bool) -> int: - if not ok: - push_error("[example_test] FAIL: " + desc) - return 1 - print("[example_test] PASS: " + desc) - return 0 +func _init() -> void: + Kit.check("headless SceneTree 协议可用(quit(code) 通道)", _test_headless_protocol()) + Kit.check("命名规范可执行:test_[scenario]_[expected]", _test_naming_convention_followed()) + quit(Kit.finish("example_test")) func _test_headless_protocol() -> bool: # 占位断言:验证脚本本身在 SceneTree 上下文可执行。 diff --git a/game/tests/unit/presentation/reward_overlay_test.gd b/game/tests/unit/presentation/reward_overlay_test.gd index e4fc85f7..a9960934 100644 --- a/game/tests/unit/presentation/reward_overlay_test.gd +++ b/game/tests/unit/presentation/reward_overlay_test.gd @@ -3,6 +3,17 @@ extends SceneTree # 原守 node_overlay.show_reward_items;奖励节点三选一与通关三选一合并共用 BonusPickOverlay 后, # 本套件改守共用面的「功能存在 ≠ 功能可达」盲区:选择外发/强制确认两段式/替换子面板两路/结果态。 # 运行:godot --headless --path game --script res://tests/unit/presentation/reward_overlay_test.gd(expect 0) +# T6 收口批:断言走 test_kit 计数(Kit.check)+ 失败即停早退协议保留(顺序状态机——坏态下后继断言无效)。 +const Kit := preload("res://tests/unit/test_kit.gd") + +# 断言 + 失败即停(原直写 print FAIL + quit(1) + return 样板单点化——早退语义逐位保持) +func _req(desc: String, ok: bool) -> bool: + Kit.check(desc, ok) + if not ok: + quit(1) + return false + return true + func _initialize() -> void: call_deferred("_run") @@ -25,17 +36,15 @@ func _run() -> void: for c in ov._choice_box.get_children(): if c is Button: btns += 1 - if ov.visible != true or btns != 3 or ov._confirm_btn.visible or ov._continue_btn.visible: - print("[reward_verify] FAIL — 选择态应三卡呈现且确认/继续隐藏(btns=%d)" % btns) - quit(1) + if not _req("选择态应三卡呈现且确认/继续隐藏(btns=%d)" % btns, + ov.visible and btns == 3 and not ov._confirm_btn.visible and not ov._continue_btn.visible): return # ② 点选→高亮→确认外发(真实按钮路径);确认前改选以最后点选为准 ov.press_card(0) ov.press_card(1) ov.press_confirm() - if ov._confirm_btn.visible != true or picked.size() != 1 or String(picked[0]) != "gp": - print("[reward_verify] FAIL — 点选确认未外发 pick_confirmed(gp)(%s)" % str(picked)) - quit(1) + if not _req("点选确认未外发 pick_confirmed(gp)(%s)" % str(picked), + ov._confirm_btn.visible and picked.size() == 1 and String(picked[0]) == "gp"): return # ③ 替换子面板:丢旧收新与放弃新件两路外发(通关渠道=携带序号 / 节点渠道=库存句柄——tag 直通) ov.open_replace([{"index": 0, "item_id": "probe_dust", "display_name": "显影尘"}], "爆破工兵包", 1, 1) @@ -43,33 +52,26 @@ func _run() -> void: for c in ov._replace_box.get_children(): if c is Button: replace_btns += 1 - if replace_btns != 2 or ov._choice_box.visible: - print("[reward_verify] FAIL — 替换面板应 2 钮(N+1 选 N+放弃)且三卡隐藏(btns=%d)" % replace_btns) - quit(1) + if not _req("替换面板应 2 钮(N+1 选 N+放弃)且三卡隐藏(btns=%d)" % replace_btns, + replace_btns == 2 and not ov._choice_box.visible): return + var replaced_first := false for c in ov._replace_box.get_children(): if c is Button: c.pressed.emit() + replaced_first = true break - if replaced.size() != 1 or int(replaced[0]) != 0: - print("[reward_verify] FAIL — 丢旧收新未外发 replace_chosen(0)(%s)" % str(replaced)) - quit(1) + if not _req("丢旧收新未外发 replace_chosen(0)(%s)" % str(replaced), + replaced_first and replaced.size() == 1 and int(replaced[0]) == 0): return # ④ 结果态:继续钮点亮、唯一出口 ov.show_result("已选择:攻击力提升 · 攻击 +1") - if ov._continue_btn.visible != true or ov._replace_box.visible: - print("[reward_verify] FAIL — 结果态应点亮继续且收替换面板") - quit(1) + if not _req("结果态应点亮继续且收替换面板", ov._continue_btn.visible and not ov._replace_box.visible): return ov.press_continue() - if continued.size() != 1: - print("[reward_verify] FAIL — 继续未外发 continue_pressed") - quit(1) + if not _req("继续未外发 continue_pressed", continued.size() == 1): return ov.close_pick() - if ov.visible: - print("[reward_verify] FAIL — close_pick 应收屏") - quit(1) + if not _req("close_pick 应收屏", not ov.visible): return - print("[reward_verify] PASS — 三卡两段式/确认外发/替换两路/结果态/收屏 全过") - quit(0) + quit(Kit.finish("reward_verify", "PASS — 三卡两段式/确认外发/替换两路/结果态/收屏 全过")) diff --git a/game/tests/unit/presentation/terrain_tile_sets_test.gd b/game/tests/unit/presentation/terrain_tile_sets_test.gd index ba6f4c81..e4ac400b 100644 --- a/game/tests/unit/presentation/terrain_tile_sets_test.gd +++ b/game/tests/unit/presentation/terrain_tile_sets_test.gd @@ -9,9 +9,7 @@ extends SceneTree const ConfigDBScript := preload("res://core/config_db.gd") const BoardViewScript := preload("res://presentation/views/board_view.gd") - -var _failures := 0 -var _checks := 0 +const Kit := preload("res://tests/unit/test_kit.gd") # T6 收口:_check/汇总退出样板单点 func _init() -> void: @@ -23,7 +21,7 @@ func _init() -> void: return var db = ConfigDBScript.new() var load_ok: bool = db.load_tables("res://data/tables", true) - _check("前置:表加载零 error", load_ok and db.diagnostics_count("error") == 0) + Kit.check("前置:表加载零 error", load_ok and db.diagnostics_count("error") == 0) if not load_ok: quit(1) return @@ -33,25 +31,11 @@ func _init() -> void: var bt := String(row.get("board_type", "")) if bt != "" and not types.has(bt): types.append(bt) - _check("前置:board_gen 词表非空(%d 型)" % types.size(), types.size() > 0) + Kit.check("前置:board_gen 词表非空(%d 型)" % types.size(), types.size() > 0) _test_set_completeness(board_dir, types) _test_hue_domains(board_dir) _test_digit_ink_modes(board_dir, types) - if _failures > 0: - push_error("[terrain_tile_sets_test] %d/%d 项失败" % [_failures, _checks]) - quit(1) - return - print("[terrain_tile_sets_test] all green (%d checks)" % _checks) - quit(0) - - -func _check(desc: String, ok: bool) -> void: - _checks += 1 - if ok: - print(" [PASS] " + desc) - else: - _failures += 1 - push_error(" [FAIL] " + desc) + quit(Kit.finish("terrain_tile_sets_test")) # 套完整性:词表每个 board_type 的 16 墙 + 5 地面变体全部在盘(缺位 → BoardView 整批回落 dungeon 的 @@ -69,7 +53,7 @@ func _test_set_completeness(board_dir: String, types: Array) -> void: var pf := board_dir.path_join("terrain_floor_%s_%d.png" % [String(bt), i]) if not FileAccess.file_exists(pf): missing.append("floor_%d" % i) - _check("图型 %s 瓦片套完整(16 墙 + %d 地面,缺=%s)" % [String(bt), variants, str(missing)], missing.is_empty()) + Kit.check("图型 %s 瓦片套完整(16 墙 + %d 地面,缺=%s)" % [String(bt), variants, str(missing)], missing.is_empty()) # 色相域抽检:wall_center 上半幅(墙顶面)与 floor_1 全幅的均值色相/明度 @@ -85,17 +69,17 @@ func _test_hue_domains(board_dir: String) -> void: var wall := _load_stats(board_dir, "terrain_wall_center_%s.png" % String(bt), true) var floor_img := _load_stats(board_dir, "terrain_floor_%s_1.png" % String(bt), false) if wall.is_empty() or floor_img.is_empty(): - _check("图型 %s 抽检样本可读" % String(bt), false) + Kit.check("图型 %s 抽检样本可读" % String(bt), false) continue wall_v[bt] = float(wall["v"]) floor_v[bt] = float(floor_img["v"]) var band: Array = bands[bt] - _check("图型 %s 墙顶均值色相在带 %s(实测 %.3f)" % [String(bt), str(band), float(wall["h"])], + Kit.check("图型 %s 墙顶均值色相在带 %s(实测 %.3f)" % [String(bt), str(band), float(wall["h"])], float(wall["h"]) >= float(band[0]) and float(wall["h"]) <= float(band[1])) - _check("图型 %s 图底分离(墙顶明度 %.3f > 地面 %.3f)" % [String(bt), wall_v[bt], floor_v[bt]], + Kit.check("图型 %s 图底分离(墙顶明度 %.3f > 地面 %.3f)" % [String(bt), wall_v[bt], floor_v[bt]], wall_v[bt] > floor_v[bt]) if wall_v.has("underdark") and wall_v.has("deep_underdark"): - _check("deep_underdark 墙顶更暗于 underdark(更深蓝黑方向)", float(wall_v["deep_underdark"]) < float(wall_v["underdark"])) + Kit.check("deep_underdark 墙顶更暗于 underdark(更深蓝黑方向)", float(wall_v["deep_underdark"]) < float(wall_v["underdark"])) # 均值读数:hue 取饱和度 > 0.05 的像素(墨线/灰黑不影响色相均值);v 取全幅均值 @@ -150,26 +134,26 @@ func _test_digit_ink_modes(board_dir: String, types: Array) -> void: var tex: Texture2D = ImageTexture.create_from_image(Image.load_from_file(p)) BoardViewScript._normalize_floor_luma(tex, 0.10, lumas) if not readable: - _check("图型 %s 数字墨族断言样本可读" % String(bt), false) + Kit.check("图型 %s 数字墨族断言样本可读" % String(bt), false) continue var mode: Dictionary = BoardViewScript._digit_ink_mode(lumas) var plate: bool = bool(mode["plate"]) var dark: bool = bool(mode["dark"]) var mode_name := "色牌垫底" if plate else ("深墨族直画" if dark else "亮墨族直画") if plate: - _check("图型 %s 双峰纹理落牌(亮族 p90 %.2f/均值 %.2f,深族 p90 %.2f/均值 %.2f 均未过门)" % [ + Kit.check("图型 %s 双峰纹理落牌(亮族 p90 %.2f/均值 %.2f,深族 p90 %.2f/均值 %.2f 均未过门)" % [ String(bt), float(mode["cr_p90_bright"]), float(mode["cr_mean_bright"]), float(mode["cr_p90_dark"]), float(mode["cr_mean_dark"])], float(mode["cr_mean_dark"]) < float(BoardViewScript.DIGIT_INK_CR_MEAN_DARK)) else: var cr_p90 := float(mode["cr_p90_bright"]) if not dark else float(mode["cr_p90_dark"]) var cr_mean := float(mode["cr_mean_bright"]) if not dark else float(mode["cr_mean_dark"]) - _check("图型 %s 所选%s 对 p90 ≥%.1f(实测 %.2f)" % [String(bt), mode_name, + Kit.check("图型 %s 所选%s 对 p90 ≥%.1f(实测 %.2f)" % [String(bt), mode_name, float(BoardViewScript.DIGIT_INK_CR_P90), cr_p90], cr_p90 >= float(BoardViewScript.DIGIT_INK_CR_P90)) var mean_gate := float(BoardViewScript.DIGIT_INK_CR_MEAN_BRIGHT) if not dark else float(BoardViewScript.DIGIT_INK_CR_MEAN_DARK) - _check("图型 %s 所选%s 对均值 ≥%.1f(实测 %.2f)" % [String(bt), mode_name, mean_gate, cr_mean], + Kit.check("图型 %s 所选%s 对均值 ≥%.1f(实测 %.2f)" % [String(bt), mode_name, mean_gate, cr_mean], cr_mean >= mean_gate) if expect_plate.has(bt): - _check("图型 %s 呈现模式期望=%s(实测=%s)" % [String(bt), + Kit.check("图型 %s 呈现模式期望=%s(实测=%s)" % [String(bt), "色牌" if bool(expect_plate[bt]) else "直画", mode_name], plate == bool(expect_plate[bt])) diff --git a/game/tests/unit/presentation/ui_font_asset_test.gd b/game/tests/unit/presentation/ui_font_asset_test.gd index eba1ec4c..102cc88e 100644 --- a/game/tests/unit/presentation/ui_font_asset_test.gd +++ b/game/tests/unit/presentation/ui_font_asset_test.gd @@ -7,38 +7,22 @@ extends SceneTree const UiKitScript := preload("res://presentation/views/ui_kit.gd") - -var _failures := 0 -var _checks := 0 +const Kit := preload("res://tests/unit/test_kit.gd") # T6 收口:_check/汇总退出样板单点 func _init() -> void: var path: String = UiKitScript.FONT_UI var f: Font = UiKitScript.load_font(path) - _check("随包字体守卫装载非空(res://assets/fonts/ui_font_fusion_pixel_zh_hans.ttf)", f != null) + Kit.check("随包字体守卫装载非空(res://assets/fonts/ui_font_fusion_pixel_zh_hans.ttf)", f != null) if f != null: - _check("中文字形在集(攻/雷/旗/封)", f.has_char("攻".unicode_at(0)) and f.has_char("雷".unicode_at(0)) \ + Kit.check("中文字形在集(攻/雷/旗/封)", f.has_char("攻".unicode_at(0)) and f.has_char("雷".unicode_at(0)) \ and f.has_char("旗".unicode_at(0)) and f.has_char("封".unicode_at(0))) - _check("ASCII/数字字形在集(A/0/%)", f.has_char("A".unicode_at(0)) and f.has_char("0".unicode_at(0)) and f.has_char("%".unicode_at(0))) + Kit.check("ASCII/数字字形在集(A/0/%)", f.has_char("A".unicode_at(0)) and f.has_char("0".unicode_at(0)) and f.has_char("%".unicode_at(0))) var sz := 18 var w_cn: float = f.get_string_size("战斗胜利", HORIZONTAL_ALIGNMENT_LEFT, -1, sz).x var w_label: float = f.get_string_size("攻击 2", HORIZONTAL_ALIGNMENT_LEFT, -1, sz).x - _check("中文采样文本宽度 > 0(C6 headless 判据)", w_cn > 0.0 and w_label > 0.0) + Kit.check("中文采样文本宽度 > 0(C6 headless 判据)", w_cn > 0.0 and w_label > 0.0) # 注:不设「私用区 has_char=false」负探针——fusion pixel 实测覆盖 PUA 码位,负向覆盖非契约面(宁缺勿错) else: - _check("字体缺失时后续断言跳过(缺位=回归,此行仅占位计数)", false) - if _failures > 0: - push_error("[ui_font_asset_test] %d/%d 项失败" % [_failures, _checks]) - quit(1) - return - print("[ui_font_asset_test] all green (%d checks)" % _checks) - quit(0) - - -func _check(desc: String, ok: bool) -> void: - _checks += 1 - if ok: - print(" [PASS] " + desc) - else: - _failures += 1 - push_error(" [FAIL] " + desc) + Kit.check("字体缺失时后续断言跳过(缺位=回归,此行仅占位计数)", false) + quit(Kit.finish("ui_font_asset_test")) diff --git a/game/tests/unit/test_kit.gd b/game/tests/unit/test_kit.gd new file mode 100644 index 00000000..e8b81bb8 --- /dev/null +++ b/game/tests/unit/test_kit.gd @@ -0,0 +1,72 @@ +# tests/unit/test_kit.gd — 单元测试协议样板单点(T6 样板收口批 2026-10-02) +# 收口范围:11 份套件的 _check/_problems 样板(计数器 + PASS/FAIL 行 + 汇总退出)+ +# reward_dangling_aux/aux_target_violation 两致命类用例各自复制的 BAD 表目录夹具。 +# 用法(--script 模式无 autoload/全局标识符——禁 class_name 裸引用,使用方 preload): +# const Kit := preload("res://tests/unit/test_kit.gd") +# Kit.check("断言描述", cond) # 计数 + 即时 PASS/FAIL 行 +# Kit.check_quiet(cond, "描述") # 计数 + 失败收集(finish 时统一汇总——原 _problems 风格,原参序) +# quit(Kit.finish("suite_name")) # 汇总输出 + 返回退出码(0 全绿 / 1 有败) +# 计数器为 static(套件 = 独立 Godot 子进程恰一份生命周期,见 run_all.gd 执行形态); +# 单进程跑多套件须先 Kit.reset()。 +extends RefCounted + +static var checks: int = 0 +static var failures: int = 0 +static var problems: Array = [] + + +# 主通道:计数 + 即时 PASS/FAIL 行(config_db/rng_service/damage_math/system_contracts/ +# damage_controller/ui_font_asset/terrain_tile_sets 等套件原 _check 同款输出,描述在前) +static func check(desc: String, ok: bool) -> void: + checks += 1 + if ok: + print(" [PASS] " + desc) + else: + failures += 1 + push_error(" [FAIL] " + desc) + + +# 静默收集通道:失败只记入 problems(即时零刷屏),finish 统一汇总。 +# 参序沿原函数式套件 aux_items/breakpoint_resume 的 check(cond, msg)——条件在前,与主通道相反。 +static func check_quiet(ok: bool, desc: String) -> void: + checks += 1 + if not ok: + failures += 1 + problems.append(desc) + + +# 汇总退出:返回退出码(调用方 `quit(Kit.finish(...))`)。pass_line = 成功尾行覆盖 +#(保留原套件 PASS 摘要文案;缺省 = "all green (N checks)" 计数口径)。 +# 失败路径:push_error 计数汇总 + problems 逐条 FAIL 行(print + push_error 双通道——原两风格并档)。 +static func finish(suite: String, pass_line: String = "") -> int: + if failures > 0: + push_error("[%s] %d/%d 项失败" % [suite, failures, checks]) + for p in problems: + print("[%s] FAIL — %s" % [suite, p]) + push_error("[%s] FAIL — %s" % [suite, p]) + return 1 + print("[%s] %s" % [suite, pass_line if pass_line != "" else "all green (%d checks)" % checks]) + return 0 + + +# BAD 表夹具共享(原 reward_dangling_aux/aux_target_violation 各自复制的 _copy_tables): +# 整目录拷贝 src_dir → bad_dir(目录已建)。BAD_DIR 常量仍由各用例自持(名字互异——独立进程并行不互踩)。 +static func copy_tables(src_dir: String, bad_dir: String) -> bool: + DirAccess.make_dir_recursive_absolute(bad_dir) + var dir := DirAccess.open(src_dir) + if dir == null: + return false + dir.list_dir_begin() + var f := dir.get_next() + while f != "": + if not dir.current_is_dir(): + if dir.copy(src_dir + "/" + f, bad_dir + "/" + f) != OK: + return false + f = dir.get_next() + return true + + +static func reset() -> void: + checks = 0 + failures = 0 + problems = [] diff --git a/game/tests/unit/test_kit.gd.uid b/game/tests/unit/test_kit.gd.uid new file mode 100644 index 00000000..27e686e6 --- /dev/null +++ b/game/tests/unit/test_kit.gd.uid @@ -0,0 +1 @@ +uid://dtestkit123456