From 1ee65d854bc3939849325fd95479bad4da2cf39b Mon Sep 17 00:00:00 2001 From: DKChan <524912269@qq.com> Date: Fri, 30 Oct 2026 02:17:05 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B7=A5=E7=A8=8B(=E9=97=B8=E6=9C=BA=E6=8F=90?= =?UTF-8?q?=E9=80=9F=E6=89=B9):=2002=E5=88=86=E7=89=87=E5=B9=B6=E8=A1=8C?= =?UTF-8?q?=EF=BC=8B=E4=BB=A3=E7=A0=81=E5=9F=9F=E8=B5=B7=E6=89=8B=E8=87=AA?= =?UTF-8?q?=E6=A3=80=EF=BC=8Bworktree=E7=B4=A0=E6=9D=90=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E2=80=94=E2=80=94run=5Fall.gd=E5=8A=A0--shard/--shard-count?= =?UTF-8?q?=E5=88=86=E7=89=87=EF=BC=88=E4=B8=B2=E8=A1=8C=E9=81=93=3DRNG?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E4=BC=9A=E8=AF=9D=E5=85=AD=E5=A5=97=E4=BB=B6?= =?UTF-8?q?=E9=92=89=E7=AC=AC1=E7=89=87=E9=98=B2=E5=90=8C=E7=A7=8D?= =?UTF-8?q?=E5=AD=90=E6=97=A5=E5=BF=97=E4=BA=92=E8=B8=A9=EF=BC=9B=E7=A9=BA?= =?UTF-8?q?=E7=89=87=E6=94=BE=E8=A1=8C=EF=BC=9B=E9=80=90=E5=A5=97=E4=BB=B6?= =?UTF-8?q?=E6=97=A5=E5=BF=97=5Fs=E6=AE=B5=E9=98=B2=E8=B7=A8=E7=89=87?= =?UTF-8?q?=E8=A6=86=E5=86=99=EF=BC=89=EF=BC=8Bgate=5Funit=E5=A4=9A?= =?UTF-8?q?=E8=BF=9B=E7=A8=8B=E5=B9=B6=E8=A1=8C=EF=BC=88GATE=5FSHARDS?= =?UTF-8?q?=E9=BB=98=E8=AE=A44=EF=BC=8C=3D1=E5=9B=9E=E8=90=BD=E5=8D=95?= =?UTF-8?q?=E8=BF=9B=E7=A8=8B=E5=8E=9F=E8=B7=AF=E5=BE=84=EF=BC=9B=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E6=8C=89=E7=89=87=E5=BA=8F=E5=90=88=E5=B9=B6=E4=BE=9B?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E6=89=AB=E6=8F=8F=E4=B8=8E=E6=8C=87=E7=BA=B9?= =?UTF-8?q?=E5=8F=96=E6=9D=90=E2=80=94=E2=80=94=E6=8C=87=E7=BA=B9=E7=BB=BF?= =?UTF-8?q?=E9=97=B8=E8=AE=B0green=E7=BA=A2=E9=97=B8=E5=8F=96FAIL=E8=A1=8C?= =?UTF-8?q?=E6=8E=92=E5=BA=8F=E9=9B=86=E5=90=88=EF=BC=8C=E4=B8=8D=E5=8F=97?= =?UTF-8?q?=E7=89=87=E5=BA=8F=E5=BD=B1=E5=93=8D=EF=BC=89=EF=BC=8Brun=5Fall?= =?UTF-8?q?=5Fgates.sh=20code=E5=9F=9F=E8=B5=B7=E6=89=8B=E8=87=AA=E6=A3=80?= =?UTF-8?q?=EF=BC=88=E7=86=B5=E5=A2=9E=E9=A2=84=E6=A3=80=E7=BA=A2=E7=81=AF?= =?UTF-8?q?=E5=8D=B3=E6=AD=A2/master=E6=8E=A8=E8=BF=9B=E9=BB=84=E7=89=8C/w?= =?UTF-8?q?orktree=E7=B4=A0=E6=9D=90=E8=87=AA=E5=8A=A8=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=EF=BC=89=EF=BC=8Btools/sync=5Fworktree=5Fassets.sh=EF=BC=88?= =?UTF-8?q?=E4=B8=BB=E5=8C=BA=E2=86=92worktree=E5=A2=9E=E9=87=8F=E6=8B=B7a?= =?UTF-8?q?ssets=20art=E4=B8=8E.import=E8=BE=B9=E8=BD=A6=E3=80=81=E7=BC=BA?= =?UTF-8?q?=E4=BB=B6=E6=88=96=E7=BC=BA.godot=E6=97=B6=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E5=99=A8=E5=AF=BC=E5=85=A5=E5=88=B7=E6=96=B0=EF=BC=9B=E7=9B=B8?= =?UTF-8?q?=E5=AF=B9=E8=B7=AF=E5=BE=84=E6=8B=B7=E8=B4=9D=E9=98=B2=E6=9D=82?= =?UTF-8?q?=E6=95=A3=E9=A1=B6=E5=B1=82=E7=9B=AE=E5=BD=95=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- game/tests/run_all.gd | 79 ++++++++++++++++++++++++++++++++++- tools/run_all_gates.sh | 62 ++++++++++++++++++++++++--- tools/sync_worktree_assets.sh | 54 ++++++++++++++++++++++++ 3 files changed, 188 insertions(+), 7 deletions(-) create mode 100644 tools/sync_worktree_assets.sh diff --git a/game/tests/run_all.gd b/game/tests/run_all.gd index 489f219b..93e05bd1 100644 --- a/game/tests/run_all.gd +++ b/game/tests/run_all.gd @@ -4,6 +4,14 @@ # 零命中/空值=用法错 quit(2)。两个来源分开定性——开发期直跑(默认)仅供最窄确认,不产闸回执、 # 不作验收凭据;闸机关联档选跑(--only-source=gates,由 tools/run_all_gates.sh 在 02 闸传入) # 是收口验收跑批本体,回执 scope=related。无任何选跑参数=全量态,行为与全量口径完全一致。 +# 分片态:追加 `-- --shard=k --shard-count=n`(须成对,1≤k≤n,n≥2 才有意义)——把选套集 +# (--only 过滤后)切成 n 片互斥子集,本进程只跑第 k 片;闸机据此多进程并行缩短 02 闸墙钟。 +# 分片分配走「串行道优先」:开 RNG 事件日志会话的套件(SERIAL_PATTERNS,new_adventure 调用方) +# 全部钉第 1 片——事件日志按种子命名同文件追加(user://logs/rng_log_.jsonl),跨片并行 +# 写同一文件互踩内容,断言日志纯净序的套件(cross_system_flow)必读错行。新增日志会话套件必须 +# 同步登记 SERIAL_PATTERNS,漏登记=并行期日志互踩假红。空片合法(--only 小集 × 多片):空片 +# 直接 quit(0) 放行,判定职责由其余片承担。各片独立退出码,由闸机聚合;片内三硬闸与 +# 快速失败语义不变,逐套件日志名加 _s 段防跨片覆写。 # 协议:套件清单逐条"套件 → 期望退出码";致命类独立进程(expect=1)。 # 执行形态:逐套件起独立 Godot 子进程实跑 #(OS.get_executable_path() 解析本引擎二进制——--script 模式无 autoload/全局标识符, @@ -30,6 +38,23 @@ const SUITE_TIMEOUT_DEFAULT_SEC := 120 # 快速失败阈值(可调常量,初值 3):连续 N 个套件超时即中止整轮。 const CONSECUTIVE_TIMEOUT_ABORT := 3 +# 串行道套件(路径子串匹配):开 RNG 事件日志会话(new_adventure)的调用方——事件日志按种子 +# 命名同文件追加(user://logs/rng_log_.jsonl),跨分片并行写同一文件互踩;断言日志内容 +# 的套件(cross_system_flow)必须读到本会话纯净追加序。分片分配时全部钉第 1 片(见头注分片态)。 +# 新增日志会话套件时同步登记此处,漏登记=并行期假红。 +const SERIAL_PATTERNS: Array[String] = [ + "board_generator_test", + "node_map_generator_test", + "minesweeper_rules_test", + "rng_stream_boot_test", + "cross_system_flow_test", + "node_map_determinism_test", +] + +# 分片态成员:0=未分片(单进程原路径);>0=第 _shard_k/_shard_n 片(_run_suite 取名用)。 +var _shard_k := 0 +var _shard_n := 0 + func _init() -> void: # 选跑态解析:--only=<词[,词…]>(经 -- 分隔的 user args)——对套件 res:// 路径做子串匹配; @@ -46,6 +71,22 @@ func _init() -> void: only_terms.append(term) elif a == "--only-source=gates": only_from_gates = true + elif a.begins_with("--shard="): + var sv := a.trim_prefix("--shard=").strip_edges() + if sv.is_valid_int(): + _shard_k = int(sv) + elif a.begins_with("--shard-count="): + var sn := a.trim_prefix("--shard-count=").strip_edges() + if sn.is_valid_int(): + _shard_n = int(sn) + if (_shard_k == 0) != (_shard_n == 0): + push_error("[run_all] --shard 与 --shard-count 必须成对出现") + quit(2) + return + if _shard_n < 0 or (_shard_n > 0 and (_shard_k < 1 or _shard_k > _shard_n)): + push_error("[run_all] 分片参数非法(须 1≤k≤n,n≥1):k=%d n=%d" % [_shard_k, _shard_n]) + quit(2) + return if has_only and only_terms.is_empty(): push_error("[run_all] --only= 空值——用法错(选跑禁把零套件当全量)") quit(2) @@ -64,6 +105,14 @@ func _init() -> void: print("[run_all] GATE FILTERED RUN — 闸机关联档选跑 %d/%d 套件(--only=%s)" % [suites.size(), manifest.size(), ",".join(only_terms)]) else: print("[run_all] FILTERED RUN — 选跑 %d/%d 套件(--only=%s)——仅供开发期最窄确认,不产闸回执、不作验收凭据" % [suites.size(), manifest.size(), ",".join(only_terms)]) + var sharded := _shard_n > 1 + if sharded: + suites = _shard_suites(suites, _shard_k, _shard_n) + if suites.is_empty(): + print("[run_all] SHARD %d/%d — 本片零套件,空片放行(判定职责由其余片承担)" % [_shard_k, _shard_n]) + quit(0) + return + print("[run_all] SHARD %d/%d — 本片 %d 套件" % [_shard_k, _shard_n, suites.size()]) print("[run_all] unit/contract suite entry — %d suites" % suites.size()) if DisplayServer.get_name() != "headless": push_warning("[run_all] 建议以 --headless 运行(D2 冒烟协议)") @@ -101,7 +150,10 @@ func _init() -> void: push_error("[run_all] %d 个套件失败" % failures) quit(1) return - print("[run_all] all green (%d suites%s)" % [suites.size(), ",filtered" if filtered else ""]) + var tag := ",filtered" if filtered else "" + if sharded: + tag += ",shard %d/%d" % [_shard_k, _shard_n] + print("[run_all] all green (%d suites%s)" % [suites.size(), tag]) quit(0) @@ -203,7 +255,8 @@ func _run_suite(suite: Dictionary, idx: int, filtered: bool = false) -> Dictiona # 后又没有退出码读取 API,故由 cmd /V:ON 在套件退出后把 !ERRORLEVEL! 回显落盘, # 值与阻塞式 execute 经 cmd 回传的同一。 var proj := ProjectSettings.globalize_path("res://") - var log := proj + "/tests/.gates/run_all%s_%02d.log" % ["_filter" if filtered else "", idx] + var shard_tag := "" if _shard_n == 0 else "_s%d" % _shard_k + var log := proj + "/tests/.gates/run_all%s%s_%02d.log" % ["_filter" if filtered else "", shard_tag, idx] var codefile := log + ".code" if FileAccess.file_exists(codefile): DirAccess.remove_absolute(codefile) # 陈值防护:上轮临界残件不许冒充本轮退出码 @@ -293,6 +346,28 @@ func _filter_suites(manifest: Array, terms: PackedStringArray) -> Array: return out +# 分片分配:串行道套件(SERIAL_PATTERNS 命中)全钉第 1 片;其余按片轮转(片内相对序 j%n+1)。 +# 分配对每个 k 确定性、互斥且完备——各片并起来=传入全集零重复零遗漏。 +func _shard_suites(suites: Array, k: int, n: int) -> Array: + var out: Array = [] + var j := 0 + for suite in suites: + var path: String = suite.get("path", "") + var is_serial := false + for pat in SERIAL_PATTERNS: + if path.contains(pat): + is_serial = true + break + if is_serial: + if k == 1: + out.append(suite) + else: + if j % n + 1 == k: + out.append(suite) + j += 1 + return out + + # 引擎二进制必须取 console 版:OS.get_executable_path() 返回的是 GUI 子系统 exe #(Godot_v4.7.2-stable_win64.exe),它起的子进程 stdout/stderr 句柄无效——输出全丢, # 脚本错误扫描就成了扫空气。GODOT_BIN 优先,其次按命名推导 *_console.exe。 diff --git a/tools/run_all_gates.sh b/tools/run_all_gates.sh index 943d227a..d70f7fa5 100644 --- a/tools/run_all_gates.sh +++ b/tools/run_all_gates.sh @@ -300,6 +300,24 @@ if in_domain code; then fi fi +# ── 代码域起手自检(闸承载:只省烧闸,不做合回判定——真判定在 00–11 与合回 guard)── +if in_domain code; then + # worktree 素材同步:素材走 gitignore 不入 git,worktree 冷检出缺件=呈现断言红的环境成因; + # 主工作区直跑=脚本内自动无操作。 + bash "$REPO/tools/sync_worktree_assets.sh" + # 熵增预检:垃圾目录/越界文件先于长跑批暴露(熵增闸在批末,撞上才红=整轮白烧)。 + if ! "$PY" "$REPO/tools/repo_structure_audit.py" >"$STAMP_DIR/entropy_preflight.log" 2>&1; then + echo "[gates] 起手自检红:熵增预检不过——顶层目录越界/根文件异动,先清理再跑闸(日志 $STAMP_DIR/entropy_preflight.log)" + grep -aE "越界|✗|违例" "$STAMP_DIR/entropy_preflight.log" | tail -5 | sed 's/^/ | /' + exit 1 + fi + # master 推进预警(worktree 才会触发):master 已越过本分支=合回 guard 第 6 况必拦, + # 先并 master 再出闸,省一轮白烧;本闸回执照出(开发期跑批不受阻)。 + if [ -f "$RECEIPT" ] && ! git -C "$REPO" merge-base --is-ancestor master HEAD 2>/dev/null; then + echo "[gates] 警告:master 已推进至本分支之外——本闸回执照出,但合回前须并 master 重出闸。" + fi +fi + echo "闸机跑批 · 域=$DOMAIN · 仓库 $REPO" echo "起跑 commit: $(git -C "$REPO" rev-parse --short HEAD 2>/dev/null || echo '非git目录')" echo "python: $PY" @@ -322,24 +340,58 @@ gate() { # gate <名称> <日志名> <命令...> return 0 } -gate_unit() { # run_all 是 --script 模式,退出码为唯一判据 +gate_unit() { # run_all 是 --script 模式,退出码为唯一判据;分片态=多 Godot 进程各跑一片 local name="02 单元与集成测试" # 关联档选跑:把套件词列表经 --only= 传给 run_all(闸机来源标记 --only-source=gates, # 入口据此打关联档横幅、不落「仅供开发期」免责句);全量态不传任何选跑参数。 local -a _user_args=() if [ "$SCOPE" = related ] && [ -n "$ONLY_TERMS" ]; then - _user_args=(-- "--only=${ONLY_TERMS// /,}" --only-source=gates) + _user_args+=("--only=${ONLY_TERMS// /,}" "--only-source=gates") fi + local _shards="${GATE_SHARDS:-4}" echo "" echo "════════════════════════════════════════════" echo " $name(--script 模式,退出码为唯一判据)" if [ "$SCOPE" = related ] && [ -n "$ONLY_TERMS" ]; then echo " 关联档选跑:--only=$ONLY_TERMS" fi + if [ "$_shards" -gt 1 ]; then + echo " 分片并行:$_shards 进程(GATE_SHARDS 可调,=1 回落单进程原路径)" + fi echo "════════════════════════════════════════════" - ( cd "$REPO" && "$GODOT" --headless --path "$(cygpath -m "$GAME")" --script res://tests/run_all.gd "${_user_args[@]}" ) \ - >"$STAMP_DIR/unit.log" 2>&1 - local rc=$? + local rc=0 + if [ "$_shards" -le 1 ]; then + local -a _cmd=(--headless --path "$(cygpath -m "$GAME")" --script res://tests/run_all.gd) + [ ${#_user_args[@]} -gt 0 ] && _cmd+=(-- "${_user_args[@]}") + ( cd "$REPO" && "$GODOT" "${_cmd[@]}" ) \ + >"$STAMP_DIR/unit.log" 2>&1 + rc=$? + else + # 分片并行:串行道套件由 run_all 内部分配钉第 1 片(RNG 日志会话跨片互踩,见其头注); + # 各片独立日志,退出码聚合(任一片非 0=闸红);日志按片序合并供错误扫描与指纹取材 + #(指纹只取 FAIL 行排序集合、绿闸记 green,片序与合并不改变指纹)。 + local -a _pids=() + local k + for k in $(seq 1 "$_shards"); do + ( cd "$REPO" && "$GODOT" --headless --path "$(cygpath -m "$GAME")" --script res://tests/run_all.gd \ + -- "${_user_args[@]}" "--shard=$k" "--shard-count=$_shards" ) \ + >"$STAMP_DIR/unit_shard_$k.log" 2>&1 & + _pids+=($!) + done + local _shard_rc _any=0 + for k in $(seq 1 "$_shards"); do + wait "${_pids[$((k - 1))]}" + _shard_rc=$? + echo "[run_all] 分片 $k/$_shards 退出码 $_shard_rc" + [ "$_shard_rc" -ne 0 ] && _any=1 + done + [ "$_any" -ne 0 ] && rc=1 + : >"$STAMP_DIR/unit.log" + for k in $(seq 1 "$_shards"); do + echo "── unit 分片 $k/$_shards ──" >>"$STAMP_DIR/unit.log" + cat "$STAMP_DIR/unit_shard_$k.log" >>"$STAMP_DIR/unit.log" + done + fi # 三硬闸之二(脚本错误扫描):子套件的 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 diff --git a/tools/sync_worktree_assets.sh b/tools/sync_worktree_assets.sh new file mode 100644 index 00000000..0e82c10e --- /dev/null +++ b/tools/sync_worktree_assets.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# worktree 素材同步:主工作区 → 本 worktree 增量拷贝 gitignore 面(game/assets/art/ 终稿与 .import 边车)。 +# 素材不入 git,worktree 冷检出与主区新收素材之间必然缺件——呈现/样张断言红的环境成因。 +# 只在 worktree 生效(主工作区直跑=无操作);有新拷件或缺 .godot 时跑一次编辑器导入刷新 +#(headless --script 模式不触发 import,素材在盘而 .ctex 缺位照样加载失败)。 +# 用法:tools/sync_worktree_assets.sh(闸机 code 域起手自动调用;可单独手跑) +# 纪律:拷贝一律相对路径定位(在源根 cd 后按相对路径落盘)——绝对路径 --parents 会在 +# 目标仓顶层误产盘符目录树(杂散目录即此误产,熵增闸判红)。 +set -u +REPO="$(cd "$(dirname "$0")/.." && pwd)" +GITDIR="$(git -C "$REPO" rev-parse --git-dir 2>/dev/null)" +COMMON="$(git -C "$REPO" rev-parse --git-common-dir 2>/dev/null)" +if [ -z "$GITDIR" ] || [ -z "$COMMON" ] || [ "$GITDIR" = "$COMMON" ]; then + echo "[assets] 主工作区:无同步面" + exit 0 +fi +MAIN="$(git -C "$REPO" worktree list --porcelain 2>/dev/null | sed -n 's/^worktree //p' | head -1)" +if [ -z "$MAIN" ]; then + echo "[assets] 取不到主工作区路径(git worktree list 为空),跳过同步" + exit 0 +fi +MAIN="$(cd "$MAIN" && pwd)" +[ "$MAIN" = "$REPO" ] && exit 0 + +_copied=0 +if [ -d "$MAIN/game/assets/art" ]; then + _new="$(comm -13 <(cd "$REPO/game/assets/art" 2>/dev/null && find . -type f | sort) \ + <(cd "$MAIN/game/assets/art" && find . -type f | sort) | wc -l)" + if [ "$_new" -gt 0 ]; then + mkdir -p "$REPO/game/assets/art" + cp -ru "$MAIN/game/assets/art/." "$REPO/game/assets/art/" + _copied=$((_copied + _new)) + fi +fi +_imports="$(comm -13 <(cd "$REPO/game" 2>/dev/null && find . -name '*.import' -type f | sort) \ + <(cd "$MAIN/game" && find . -name '*.import' -type f | sort) | wc -l)" +if [ "$_imports" -gt 0 ]; then + (cd "$MAIN/game" && find . -name '*.import' -type f -print0) | while IFS= read -r -d '' f; do + if [ ! -f "$REPO/game/$f" ]; then + mkdir -p "$REPO/game/$(dirname "$f")" + cp "$MAIN/game/$f" "$REPO/game/$f" + fi + done + _copied=$((_copied + _imports)) +fi +if [ "$_copied" -gt 0 ] || [ ! -d "$REPO/game/.godot" ]; then + echo "[assets] worktree 素材同步:补拷 $_copied 件(含 .import 边车),刷新导入缓存" + "${GODOT_BIN:-/d/code/godot/Godot_v4.7.2-stable_win64_console.exe}" \ + --headless --editor --quit --path "$REPO/game" >/dev/null 2>&1 + echo "[assets] 导入刷新完成" +else + echo "[assets] worktree 素材同步:无缺件" +fi +exit 0