diff --git a/game/core/battle_orchestrator.gd b/game/core/battle_orchestrator.gd index 747b33e4..ac804514 100644 --- a/game/core/battle_orchestrator.gd +++ b/game/core/battle_orchestrator.gd @@ -39,8 +39,10 @@ signal settlement_snapshot(snap: Dictionary) # 每结算末尾的全量状态 const SOURCE_EXEMPT_TYPES := ["decay", "verdict"] # 行来源断言豁免集合(来源类别层)——事实来源是单位 / 清图而非棋盘格; # fx_reveal=专长揭示掷格(落空行无真实格〔迭代 21 存量红修复——a57489e9 落地件三处空来源 -# 触发行来源断言,51 行/冒烟,两轮确定性复现〕) -const SOURCE_EXEMPT_CLASSES := ["hero_attack", "enemy_action", "clear_reward", "fx_reveal"] +# 触发行来源断言,51 行/冒烟,两轮确定性复现〕); +# fx_grant_gp=专长·GP 入账(钱包面而非棋盘面,行无格子可指——与 fx_reveal 同款的漏网豁免, +# 未豁免时每局触发行来源断言失败 7 次,因脚本错误不改退出码而被闸长期漏过) +const SOURCE_EXEMPT_CLASSES := ["hero_attack", "enemy_action", "clear_reward", "fx_reveal", "fx_grant_gp"] var db: ConfigDb var rng: RngServiceCore diff --git a/game/tests/run_all.gd b/game/tests/run_all.gd index af00ae60..4c91021e 100644 --- a/game/tests/run_all.gd +++ b/game/tests/run_all.gd @@ -7,14 +7,25 @@ # 场景探针(.tscn 载体)由 CI/轻闸单独步骤调用,不经本入口。 extends SceneTree +# 脚本错误标记与 tools/run_smoke.py 的 ERROR_MARKERS 同源(GDScript 无脚本错误陷阱 API, +# 错误不改进程退出码、不计失败数,只能由调用方扫输出——三硬闸之二)。 +const ERROR_MARKERS: Array[String] = ["SCRIPT ERROR", "Parse Error"] + func _init() -> void: print("[run_all] unit/contract suite entry — %d suites" % _suites().size()) if DisplayServer.get_name() != "headless": push_warning("[run_all] 建议以 --headless 运行(D2 冒烟协议)") var failures := 0 + var ran := 0 for suite in _suites(): - failures += _run_suite(suite) + failures += _run_suite(suite, ran) + ran += 1 + # 实跑套件数校验:manifest 有条目却被静默跳过(早退/continue)必须判红,不能只凭 failures。 + if ran != _suites().size(): + push_error("[run_all] 实跑套件数 %d ≠ manifest 条目数 %d——有套件未跑" % [ran, _suites().size()]) + quit(1) + return if failures > 0: push_error("[run_all] %d 个套件失败" % failures) quit(1) @@ -23,15 +34,18 @@ func _init() -> void: quit(0) -# 套件清单:{ "path": String, "expect": int };致命类(期望非 0)独立进程断言退出码。 +# 套件清单:{ "path": String, "expect": int, "negative"?: bool };致命类(期望非 0)独立进程断言退出码。 func _suites() -> Array: return [ - {"path": "res://tests/unit/example_test.gd", "expect": 0}, + # example_test.gd 是协议活样板(tests/unit/README 明示),不进生产 manifest—— + # 样板件占位断言跑绿没有信息量,且占据「27 套件」计数。文件保留作模板(同类先例见下 fog_lantern 摘除行)。 {"path": "res://tests/unit/data_config/config_db_test.gd", "expect": 0}, {"path": "res://tests/unit/data_config/forge_table_test.gd", "expect": 0}, # 迭代 23 锻造工坊批 T1——六路线 schema 正反(V1)+特效留空(V11)+几何价格梯(V2 半边) {"path": "res://tests/unit/data_config/config_db_fatal_oob_test.gd", "expect": 1}, {"path": "res://tests/unit/data_config/treasure_target_violation_test.gd", "expect": 1}, # 迭代 7 T1——真视窄域护栏 - {"path": "res://tests/unit/data_config/reward_dangling_treasure_test.gd", "expect": 1}, # 迭代 7 T1——秘宝双表校验 + # negative=true=负面套件:坏表/违规输入是它的测试目标,输出里必然带 Parse Error, + # 脚本错误扫描对它属误伤——豁免只放行这一件,且仅限「错误扫描」一闸(退出码与断言下限照判)。 + {"path": "res://tests/unit/data_config/reward_dangling_treasure_test.gd", "expect": 1, "negative": true}, # 迭代 7 T1——秘宝双表校验 {"path": "res://tests/unit/data_config/rng_service_test.gd", "expect": 0}, {"path": "res://tests/unit/core/treasure_items_test.gd", "expect": 0}, # 迭代 7 T7——秘宝结构冻结断言面 {"path": "res://tests/unit/core/breakpoint_resume_test.gd", "expect": 0}, # 迭代 8 卡①/T9——断点往返/续玩确定性/幂等/退出清断点 @@ -57,19 +71,60 @@ func _suites() -> Array: ] -func _run_suite(suite: Dictionary) -> int: +func _run_suite(suite: Dictionary, idx: int) -> int: var path: String = suite.get("path", "") var expect: int = int(suite.get("expect", 0)) if path.is_empty(): push_error("[run_all] suite missing path") return 1 - var args := PackedStringArray(["--headless", "--path", ProjectSettings.globalize_path("res://"), "--script", path]) + var bin := _engine_bin() + if bin == "": + push_error("[run_all] 未找到 console 版引擎二进制(设 GODOT_BIN 指向 *_console.exe)——" + + "非 console 版是 GUI 子系统,子进程 stdout/stderr 句柄无效,脚本错误扫描无从落地") + return 1 + # 子进程输出只能落盘再读:OS.execute 的 output 管道在本机 Godot/Windows 组合下恒为空 + #(实测 0 行),故整条命令交给 cmd /c 并重定向到日志,退出码由 cmd 原样回传。 + var proj := ProjectSettings.globalize_path("res://") + var log := proj + "/tests/.gates/run_all_%02d.log" % idx + DirAccess.make_dir_recursive_absolute(proj + "/tests/.gates") + var cmd := '"%s" --headless --path "%s" --script "%s" > "%s" 2>&1' % [bin, proj, path, log] var out := PackedStringArray() - var code: int = OS.execute(OS.get_executable_path(), args, out, true, false) - var text := "\n".join(out) + var code: int = OS.execute("cmd.exe", PackedStringArray(["/c", cmd]), out, true, false) + var text := FileAccess.get_file_as_string(log) if FileAccess.file_exists(log) else "" print(text) + if not bool(suite.get("negative", false)) and _has_script_error(text): + push_error("[run_all] FAIL %s — 输出含脚本错误标记:%s" % [path, _script_error_line(text)]) + return 1 if code != expect: push_error("[run_all] FAIL %s — 退出码 %d ≠ 期望 %d" % [path, code, expect]) return 1 print("[run_all] PASS %s (exit=%d)" % [path, code]) return 0 + + +# 引擎二进制必须取 console 版:OS.get_executable_path() 返回的是 GUI 子系统 exe +#(Godot_v4.7.2-stable_win64.exe),它起的子进程 stdout/stderr 句柄无效——输出全丢, +# 脚本错误扫描就成了扫空气。GODOT_BIN 优先,其次按命名推导 *_console.exe。 +func _engine_bin() -> String: + var env := OS.get_environment("GODOT_BIN") + if env != "" and FileAccess.file_exists(env): + return env + var derived := OS.get_executable_path().replace(".exe", "_console.exe") + if FileAccess.file_exists(derived): + return derived + return "" + + +func _has_script_error(text: String) -> bool: + return _script_error_line(text) != "" + + +func _script_error_line(text: String) -> String: + for ln in text.split("\n"): + var s := String(ln).strip_edges() + if s.is_empty(): + continue + for marker in ERROR_MARKERS: + if s.contains(marker): + return s.substr(0, mini(s.length(), 160)) + return "" diff --git a/game/tests/unit/contracts/system_contracts_test.gd b/game/tests/unit/contracts/system_contracts_test.gd index 36901a8d..047222f7 100644 --- a/game/tests/unit/contracts/system_contracts_test.gd +++ b/game/tests/unit/contracts/system_contracts_test.gd @@ -11,6 +11,7 @@ extends SceneTree 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 UnitContainerScript := preload("res://core/unit_container.gd") const DamageControllerScript := preload("res://core/damage_controller.gd") const Kit := preload("res://tests/unit/test_kit.gd") # T6 收口:_check/汇总退出样板单点 @@ -86,23 +87,13 @@ func _test_ticket_nine_columns() -> void: func _bare_controller(): var db = ConfigDBScript.new() - # 只取 open_ticket 纯构造面——容器/依赖传桩(构造期不消费) - var units = UnitContainerStub.new() - var effects = StatusContainerStub.new() - return DamageControllerScript.new(db, RngStub.new(), units, effects) - - -# 桩容器:构造期零消费(DamageController._init 只 connect unit_died 信号——桩需同名信号) -class UnitContainerStub: - signal unit_died(u: Dictionary) - - -class StatusContainerStub: - pass - - -class RngStub: - pass + # 只取 open_ticket 纯构造面——四个依赖一律传真实实例,不用空桩: + # DamageController 的 db/rng/units/effects 全是定型标注,传无类型的空桩会触发 + # 「Trying to assign value of type '' to a variable of type '…'」脚本错误——该错误不改退出码, + # 却让赋值静默失败(成员留 null),脚本错误扫描落地后必红。三者均可零消费构造。 + var units = UnitContainerScript.new(db) + var effects = StatusContainerScript.new(db) + return DamageControllerScript.new(db, RngServiceScript.new(), units, effects) # ---- B4→B3:增益授予布尔受理口径(受理/拒绝二元面 + 投影一致性) ---- diff --git a/game/tests/unit/data_config/config_db_test.gd b/game/tests/unit/data_config/config_db_test.gd index 9e91d816..1837847c 100644 --- a/game/tests/unit/data_config/config_db_test.gd +++ b/game/tests/unit/data_config/config_db_test.gd @@ -193,23 +193,29 @@ func _test_manifest_dir_sync(db) -> void: # (rowset_rule——行序即契约,R6 加载期兜底)③行集实例总数 ≤4(骰后值运行面,spawn 守卫——冒烟侧验证)。 func _test_rowset_discipline() -> void: + # 行集用 rows.assign([...]) 而非直接赋值:rows 是 Array[Dictionary] 定型属性,直接赋 untyped + # 数组字面量会触发「Invalid assignment」脚本错误——该错误不改退出码,却会**中断本函数剩余断言** + #(本函数五条纪律断言曾因此全部静默不跑,套件仍报 all green,是零覆盖假象)。 const EnemyTableScript := preload("res://data/schemas/enemy_table.gd") var mk_row := func(l: int, d: int, eid: String) -> Dictionary: return {"level_index": l, "node_depth": d, "enemy_id": eid, "display_name": eid + "名", "unit_count": 1, "hp": 10, "atk": 2, "pool": [{"skill_id": "attack", "weight": 9}]} # 正向:同坐标两行不同敌种(相邻)= 混编合法,validate 零错 var t_mix = EnemyTableScript.new() - t_mix.rows = [mk_row.call(1, 5, "goblin"), mk_row.call(1, 5, "bugbear")] + t_mix.rows.assign([mk_row.call(1, 5, "goblin"), mk_row.call(1, 5, "bugbear"), + mk_row.call(1, 0, "goblin")]) # 末行=depth-0 锚行(validate 的 anchor 规则:每 level 必须有) var errs: Array = t_mix.validate() Kit.check("混编行集加载通过(同坐标两行不同敌种相邻——行集恰一敌种校验退役的正向面)", errs.is_empty()) # 正向:Boss 坐标两行同构合法(P4 乙——Boss 行集校验同样放开,加载层无 Boss 单行硬闸) var t_boss = EnemyTableScript.new() - t_boss.rows = [mk_row.call(2, 9, "drider"), mk_row.call(2, 9, "bugbear")] + t_boss.rows.assign([mk_row.call(2, 9, "drider"), mk_row.call(2, 9, "bugbear"), + mk_row.call(2, 0, "drider")]) # 末行=depth-0 锚行 errs = t_boss.validate() Kit.check("Boss 坐标混编行集加载通过(P4 乙——Boss 多行合法,无硬闸)", errs.is_empty()) # 负向:同坐标行不相邻 = rowset_rule 且报错定位到坐标(R6 行序纪律 + R3 错误类别可定位) var t_gap = EnemyTableScript.new() - t_gap.rows = [mk_row.call(1, 5, "goblin"), mk_row.call(1, 9, "hobgoblin"), mk_row.call(1, 5, "bugbear")] + t_gap.rows.assign([mk_row.call(1, 5, "goblin"), mk_row.call(1, 9, "hobgoblin"), mk_row.call(1, 5, "bugbear"), + mk_row.call(1, 0, "goblin")]) # 末行=depth-0 锚行(隔断关系不受影响) errs = t_gap.validate() var gap_hit := false for e in errs: @@ -218,7 +224,8 @@ func _test_rowset_discipline() -> void: Kit.check("行序相邻校验:同坐标行被隔断报 rowset_rule 且定位坐标(R6——行序即契约加载期兜底)", gap_hit) # 负向:同敌种第二行仍被 dup_key 拦(键含 enemy_id——纪律①既有通道不回归) var t_dup = EnemyTableScript.new() - t_dup.rows = [mk_row.call(1, 5, "goblin"), mk_row.call(1, 5, "goblin")] + t_dup.rows.assign([mk_row.call(1, 5, "goblin"), mk_row.call(1, 5, "goblin"), + mk_row.call(1, 0, "goblin")]) # 末行=depth-0 锚行 errs = t_dup.validate() var dup_hit := false for e in errs: @@ -227,7 +234,7 @@ func _test_rowset_discipline() -> void: Kit.check("行集内同敌种第二行仍被 dup_key 拦(纪律①——enemy_id 唯一)", dup_hit) # 负向:域校验不回归(单行 unit_count 越域仍拦——UNIT_COUNT_MAX 单种数语义下的同款域闸) var t_dom = EnemyTableScript.new() - t_dom.rows = [mk_row.call(1, 5, "goblin")] + t_dom.rows.assign([mk_row.call(1, 5, "goblin"), mk_row.call(1, 0, "goblin")]) # 末行=depth-0 锚行 t_dom.rows[0]["unit_count"] = 4 errs = t_dom.validate() var dom_hit := false diff --git a/game/tests/unit/test_kit.gd b/game/tests/unit/test_kit.gd index c78f0221..7cbd9b4a 100644 --- a/game/tests/unit/test_kit.gd +++ b/game/tests/unit/test_kit.gd @@ -5,7 +5,7 @@ # 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 有败) +# quit(Kit.finish("suite_name")) # 汇总输出 + 返回退出码(0 全绿 / 1 有败 / 2 零断言) # 计数器为 static(套件 = 独立 Godot 子进程恰一份生命周期,见 run_all.gd 执行形态); # 单进程跑多套件须先 Kit.reset()。 extends RefCounted @@ -39,6 +39,12 @@ static func check_quiet(ok: bool, desc: String) -> void: #(保留原套件 PASS 摘要文案;缺省 = "all green (N checks)" 计数口径)。 # 失败路径:push_error 计数汇总 + problems 逐条 FAIL 行(print + push_error 双通道——原两风格并档)。 static func finish(suite: String, pass_line: String = "") -> int: + # 断言数下限(三硬闸之三):零断言的套件必须判红——「跑了一遍没崩」不等于测过。 + # 退出码用 2(而非 1)与「断言失败」区分:致命类套件 manifest 期望 1, + # 零断言若也返回 1 会被 expect 协议当成预期结果放过。 + if checks == 0: + push_error("[%s] 零断言——套件未执行任何断言(疑似脚本错误中断或误挂空壳),判红" % suite) + return 2 if failures > 0: push_error("[%s] %d/%d 项失败" % [suite, failures, checks]) for p in problems: diff --git a/tools/error_scan.py b/tools/error_scan.py new file mode 100644 index 00000000..1cb5cff0 --- /dev/null +++ b/tools/error_scan.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +r"""error_scan.py — 脚本错误扫描闸件(判定层通用,供本地闸各段复用) + +用法: python tools/error_scan.py <日志文件> [...] +背景: GDScript 运行时脚本错误(SCRIPT ERROR / Parse Error)不改进程退出码、不计失败数, +故闸只能设在外部——本件把 run_smoke.py 的同一套判定抽出来给探针段与套件段共用。 +错误标记与 tools/run_smoke.py 的 ERROR_MARKERS 同源(直接 import,禁复制第二份清单)。 +退出码:0=无命中;1=命中脚本错误标记;2=用法错或日志不可读。 +""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from run_smoke import ERROR_MARKERS # noqa: E402 单一事实源,禁在本件另写一份 + + +def main() -> int: + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + args = sys.argv[1:] + if not args: + print("[error_scan] 用法: python tools/error_scan.py <日志文件> [...]") + return 2 + hits = [] + for a in args: + p = Path(a) + if not p.is_file(): + print("[error_scan] FAIL — 日志不可读: %s" % a) + return 2 + for ln in p.read_text(encoding="utf-8", errors="replace").splitlines(): + s = ln.strip() + if s and any(k in s for k in ERROR_MARKERS): + hits.append((p.name, s)) + if hits: + print("[error_scan] FAIL — 脚本错误标记命中 %d 行(此类错误不改退出码," + "且会中断所在函数的剩余断言)" % len(hits)) + for name, s in hits[:5]: + print(" - [%s] %s" % (name, s[:160])) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/probe_gate.sh b/tools/probe_gate.sh index 56fae644..484904e7 100644 --- a/tools/probe_gate.sh +++ b/tools/probe_gate.sh @@ -10,6 +10,8 @@ # 增删档位只改表内一行;用 content_scale_size 改逻辑视口,headless 下真生效—— # 旧 ui_layout_probe 用 get_window().size 在 headless 下不改变视口,其「1920 档恒定」 # 断言是永远为真的假断言,随该探针退役)。 +# 判定口径(迭代 29 T1 三硬闸):每段走 verdict 函数——①退出码为唯一权威判据(PASS 串降级为日志) +# ②脚本错误扫描命中即红(tools/error_scan.py,标记与 run_smoke.py 同源)③PASS 串保留为附加判据。 # --full 段(迭代 19 卡① T3 收编 2026-10-08——重闸件):ui_probe/ui_tune_probe/t17_defer_probe/fx_probe # 四件带断言探针收进(此前游离在一切闸外只能手敲);轻闸默认段断言面零删减零改动(--full 只增不改)。 # 渲染面归 render_gate.sh 正主(窗口化:shot_probe 七屏+fx_shot/board_shot 取证+棋盘 golden 逐像素)—— @@ -33,6 +35,39 @@ TMP="$(mktemp -d /tmp/probegate_XXXXXX)" trap 'rm -rf "$TMP"' EXIT _rc=0 +# 错误扫描闸件用纯标准库 python(不依赖 PIL),优先带 PIL 的 gamedev venv 以保持与重闸同解释器。 +PY="" +for cand in "/d/dev/python/envs/gamedev/Scripts/python.exe" "python" "python3"; do + if "$cand" -c "import sys" >/dev/null 2>&1; then PY="$cand"; break; fi +done +if [ -z "$PY" ]; then + echo "[probe_gate] 警告:未找到 python,脚本错误扫描闸不生效(三硬闸缺一)" +fi + +# 判定三硬闸——各探针段统一走本函数,禁再手拼判定: +# ①退出码为唯一权威判据(PASS 串降级为日志,不得再当判据) +# ②脚本错误扫描命中即红(SCRIPT ERROR/Parse Error 不改退出码,只能外部扫) +# ③签名标记保留为附加判据(退出码 0 却无该探针的签名行=静默退出,仍判红)。 +# 标记默认 PASS;ui_click_probe 本就走退出码语义、日志不写 PASS,传它自己的摘要标记。 +verdict() { # verdict <探针名> <日志路径> <退出码> [签名标记] + local name="$1" log="$2" st="$3" mark="${4:-PASS}" + if [ "$st" -eq 124 ] || [ "$st" -eq 137 ]; then + echo "[probe_gate] FAIL — $name 超时挂死(exit=$st),孤儿已清"; return 1 + fi + if [ "$st" -ne 0 ]; then + echo "[probe_gate] FAIL — $name 退出码=$st(退出码为唯一权威判据,PASS 串不抵)"; return 1 + fi + if [ -n "$PY" ] && ! "$PY" "$REPO/tools/error_scan.py" "$log" >"$TMP/err.txt" 2>&1; then + echo "[probe_gate] FAIL — $name 脚本错误扫描命中:" + sed -n '1,6p' "$TMP/err.txt" | sed 's/^/ /' + return 1 + fi + if ! grep -q "$mark" "$log"; then + echo "[probe_gate] FAIL — $name 未见签名标记「$mark」(exit=$st——退出码 0 但零输出亦判红)"; return 1 + fi + return 0 +} + orphans() { # 列出命令行含本 game 路径的 Godot 进程 PID(console wrapper+真引擎都算) printf "Get-CimInstance Win32_Process -Filter \"Name like 'Godot%%'\" | Where-Object { \$_.CommandLine -like '*%s*' } | ForEach-Object { \$_.ProcessId }\n" "$GAME_WIN" > "$TMP/k.ps1" powershell -NoProfile -ExecutionPolicy Bypass -File "$(cygpath -w "$TMP/k.ps1")" 2>/dev/null | tr -d '\r' @@ -55,52 +90,32 @@ echo "======== ui_input_probe ========" timeout 120 "$G" --headless --path "$GAME_WIN" res://smoke/ui_input_probe.tscn >"$TMP/uip.log" 2>&1 st=$?; kill_orphans grep -E "PASS|FAIL|问题" "$TMP/uip.log" | head -12 -if [ "$st" -eq 124 ] || [ "$st" -eq 137 ]; then - echo "[probe_gate] FAIL — ui_input_probe 超时挂死(exit=$st),孤儿已清"; _rc=1 -elif ! grep -q "PASS" "$TMP/uip.log"; then - echo "[probe_gate] FAIL — ui_input_probe 未见 PASS(exit=$st)"; _rc=1 -fi +verdict "ui_input_probe" "$TMP/uip.log" "$st" || _rc=1 echo "======== ui_struct_probe ========" timeout 120 "$G" --headless --path "$GAME_WIN" res://smoke/ui_struct_probe.tscn >"$TMP/stp.log" 2>&1 st=$?; kill_orphans grep -E "PASS|FAIL" "$TMP/stp.log" | head -12 -if [ "$st" -eq 124 ] || [ "$st" -eq 137 ]; then - echo "[probe_gate] FAIL — ui_struct_probe 超时挂死(exit=$st),孤儿已清"; _rc=1 -elif ! grep -q "PASS" "$TMP/stp.log"; then - echo "[probe_gate] FAIL — ui_struct_probe 未见 PASS(exit=$st)"; _rc=1 -fi +verdict "ui_struct_probe" "$TMP/stp.log" "$st" || _rc=1 echo "======== layout_probe(分辨率几何·两档) ========" timeout 120 "$G" --headless --path "$GAME_WIN" res://smoke/layout_probe.tscn >"$TMP/lay.log" 2>&1 st=$?; kill_orphans grep -E "PASS|FAIL" "$TMP/lay.log" | head -12 -if [ "$st" -eq 124 ] || [ "$st" -eq 137 ]; then - echo "[probe_gate] FAIL — layout_probe 超时挂死(exit=$st),孤儿已清"; _rc=1 -elif ! grep -q "PASS" "$TMP/lay.log"; then - echo "[probe_gate] FAIL — layout_probe 未见 PASS(exit=$st)"; _rc=1 -fi +verdict "layout_probe" "$TMP/lay.log" "$st" || _rc=1 echo "======== ui_contrast_probe ========" timeout 120 "$G" --headless --path "$GAME_WIN" res://smoke/ui_contrast_probe.tscn >"$TMP/ctp.log" 2>&1 st=$?; kill_orphans grep -E "PASS|FAIL" "$TMP/ctp.log" | head -12 -if [ "$st" -eq 124 ] || [ "$st" -eq 137 ]; then - echo "[probe_gate] FAIL — ui_contrast_probe 超时挂死(exit=$st),孤儿已清"; _rc=1 -elif ! grep -q "PASS" "$TMP/ctp.log"; then - echo "[probe_gate] FAIL — ui_contrast_probe 未见 PASS(exit=$st)"; _rc=1 -fi +verdict "ui_contrast_probe" "$TMP/ctp.log" "$st" || _rc=1 if [ "$WITH_CLICK" -eq 1 ]; then echo "======== ui_click_probe 行为走查 ========" timeout 240 "$G" --headless --path "$GAME_WIN" res://smoke/ui_click_probe.tscn >"$TMP/clk.log" 2>&1 st=$?; kill_orphans grep -E "click_probe" "$TMP/clk.log" | tail -4 - if [ "$st" -eq 124 ] || [ "$st" -eq 137 ]; then - echo "[probe_gate] FAIL — ui_click_probe 超时(exit=$st):挂死或探针未适配新停点层(归 probe适配批),孤儿已清"; _rc=1 - elif [ "$st" -ne 0 ]; then - echo "[probe_gate] FAIL — ui_click_probe 退出码=$st(done=false 或 问题>0)"; _rc=1 - fi + verdict "ui_click_probe" "$TMP/clk.log" "$st" "click_probe" || _rc=1 fi # ── --full 收编段(迭代 19 卡① T3 2026-10-08——四件带断言探针入闸;实测时长 ui_probe 17.4s/ @@ -110,41 +125,25 @@ if [ "$WITH_FULL" -eq 1 ]; then timeout 120 "$G" --headless --path "$GAME_WIN" res://smoke/ui_probe.tscn >"$TMP/flp.log" 2>&1 st=$?; kill_orphans grep -E "\[ui_probe\]" "$TMP/flp.log" | tail -2 - if [ "$st" -eq 124 ] || [ "$st" -eq 137 ]; then - echo "[probe_gate] FAIL — ui_probe 超时挂死(exit=$st),孤儿已清"; _rc=1 - elif ! grep -q "PASS" "$TMP/flp.log"; then - echo "[probe_gate] FAIL — ui_probe 未见 PASS(exit=$st)"; _rc=1 - fi + verdict "ui_probe" "$TMP/flp.log" "$st" || _rc=1 echo "======== ui_tune_probe(--full 收编) ========" timeout 120 "$G" --headless --path "$GAME_WIN" res://smoke/ui_tune_probe.tscn >"$TMP/flt.log" 2>&1 st=$?; kill_orphans grep -E "\[tune_probe\]" "$TMP/flt.log" | tail -2 - if [ "$st" -eq 124 ] || [ "$st" -eq 137 ]; then - echo "[probe_gate] FAIL — ui_tune_probe 超时挂死(exit=$st),孤儿已清"; _rc=1 - elif ! grep -q "PASS" "$TMP/flt.log"; then - echo "[probe_gate] FAIL — ui_tune_probe 未见 PASS(exit=$st)"; _rc=1 - fi + verdict "ui_tune_probe" "$TMP/flt.log" "$st" || _rc=1 echo "======== t17_defer_probe(--full 收编) ========" timeout 120 "$G" --headless --path "$GAME_WIN" res://smoke/t17_defer_probe.tscn >"$TMP/fld.log" 2>&1 st=$?; kill_orphans grep -E "\[t17_defer\]" "$TMP/fld.log" | tail -2 - if [ "$st" -eq 124 ] || [ "$st" -eq 137 ]; then - echo "[probe_gate] FAIL — t17_defer_probe 超时挂死(exit=$st),孤儿已清"; _rc=1 - elif ! grep -q "PASS" "$TMP/fld.log"; then - echo "[probe_gate] FAIL — t17_defer_probe 未见 PASS(exit=$st)"; _rc=1 - fi + verdict "t17_defer_probe" "$TMP/fld.log" "$st" || _rc=1 echo "======== fx_probe(--full 收编) ========" timeout 120 "$G" --headless --path "$GAME_WIN" res://smoke/fx_probe.tscn >"$TMP/flx.log" 2>&1 st=$?; kill_orphans grep -E "\[fx_probe\]" "$TMP/flx.log" | tail -2 - if [ "$st" -eq 124 ] || [ "$st" -eq 137 ]; then - echo "[probe_gate] FAIL — fx_probe 超时挂死(exit=$st),孤儿已清"; _rc=1 - elif ! grep -q "PASS" "$TMP/flx.log"; then - echo "[probe_gate] FAIL — fx_probe 未见 PASS(exit=$st)"; _rc=1 - fi + verdict "fx_probe" "$TMP/flx.log" "$st" || _rc=1 fi echo "======== 残留检查(批末闸) ========" diff --git a/tools/run_all_gates.sh b/tools/run_all_gates.sh index 83b3d30c..5868bd6b 100644 --- a/tools/run_all_gates.sh +++ b/tools/run_all_gates.sh @@ -87,6 +87,12 @@ gate_unit() { # run_all 是 --script 模式,退出码为唯一判据 ( cd "$REPO" && "$GODOT" --headless --path "$(cygpath -m "$GAME")" --script res://tests/run_all.gd ) \ >"$STAMP_DIR/unit.log" 2>&1 local rc=$? + # 三硬闸之二(脚本错误扫描):子套件的 SCRIPT ERROR/Parse Error 只写 stderr,入口内收不到, + # 只能在编排层扫本闸日志。命中即红——此类错误不改退出码、且会中断所在函数的剩余断言。 + if [ "$rc" -eq 0 ] && ! "$PY" "$REPO/tools/error_scan.py" "$STAMP_DIR/unit.log" >>"$STAMP_DIR/unit.log" 2>&1; then + rc=1 + tail -12 "$STAMP_DIR/unit.log" | sed 's/^/ | /' + fi [ $rc -ne 0 ] && tail -12 "$STAMP_DIR/unit.log" | sed 's/^/ | /' NAMES+=("$name"); EXITS+=("$rc"); LOGS+=("unit.log") return 0