feat(playable): 首个 Mac 联网内测版 STB-01 soak 工具与发布状态文档

- 新增 script/playable_soak.sh:先做 soak 配置/资源校验,无 --allow-gameplay
  只校验不启动客户端;N(>=10) 次独立正常退出运行 + 墙钟 soak,失败即停止后续
  运行并记 BLOCKED;写本批次 release-manifest.json 后聚合
- run_client_gate.sh:确认的故障代理对所有 suite 启动(共享场景的退出运行也经代理);
  soak 超时下限只约束 soak 客户端(validate_soak 增加 soak_client 参数)
- 新增本地 127.0.0.1 故障代理、RSS 采样/内存判定、窗口/指标/流程模块及其测试
- forest_mob_render_test 输出 PASS/FAIL 标记,供 rendering_batch_test.sh 识别
- 新增 docs/FIRST-MAC-PLAYABLE-STATUS.md:如实记录 PASS/BLOCKED、已知问题与环境需求
- .gitignore 排除本地场景配置、凭据文件与运行输出

离线回归:rendering_batch_test.sh failures=0,playable_gate_test.sh PASS,
node 夹具测试 PASS。未联网运行,未重建候选包。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ft3kXpNdLbKEfg5uDLfqVF
This commit is contained in:
shenlei
2026-09-11 22:06:48 +09:00
co-authored by Claude Opus 5
parent 374d4165d8
commit f39a55fdd5
66 changed files with 9189 additions and 951 deletions
+202
View File
@@ -0,0 +1,202 @@
#!/usr/bin/env bash
# MAP-02: run the packaged client in MT_TEST_MODE=forest_render and seal its report.
#
# - Offline: no server, no credentials. MT_ACCOUNT/MT_PASSWORD are removed from the
# child environment; nothing here reads or writes them. Do not add `set -x`.
# - Owns exactly one child PID; TERM, 10 s grace, KILL, then wait. Never pkills.
# - Maps come only from --maps or the config map_key (filled from the
# audit_playable_maps result); no map path is guessed here.
# - The client writes client-report.json/events/screenshots; this script writes
# report.json only after the real process exit and the full-log gate.
# - Metal screenshot sign-off stays a separate manual release item.
#
# exit: 0 PASS, 1 assert/exit-gate FAIL, 2 config/precondition BLOCKED, 124 wall-clock timeout.
set -euo pipefail
set +x
cd "$(dirname "$0")/.."
repo="$PWD"
app_path=""
maps=""
config_path=""
viewpoints=""
races=""
output_dir=""
timeout_seconds=900
required_arch="${MT_PLAYABLE_REQUIRED_ARCH:-arm64}"
kill_grace_seconds=10
suite="forest_render"
usage() {
cat <<'EOF'
用法: script/forest_map_render_test.sh --app APP (--maps KEYS | --config CONFIG) [选项]
选项:
--app APP 已签名的 arm64 包(build/export/mtgodot-poc.app);新测试模式必须重新构建后运行
--maps K1[,K2] map-assets.json 里的 map_key,逗号分隔(优先于 --config 的 map_key
--config FILE playable 配置;只读取 map_key/scenario_id
--viewpoints FILE 机位夹具(test/playable/forest-viewpoints.<name>.local.json);不给则只有自动候选
--races R1[,R2] 覆盖夹具里的怪物 vnum;不给且夹具为空时放 12 个候选树怪
--output DIR 本次运行目录,必须不存在或为空(默认 build/forest/run-<时间>-<pid>
--timeout-seconds N 墙钟超时,默认 900;超时 TERM10 秒后 KILL
--help 显示本帮助
说明:
自动候选机位、未确认机位、缺失传送点都会使对应用例 BLOCKED;
即使自动用例 PASS,Metal 截图仍需人工签核(发布清单单独的 manual 项)。
环境变量:
MT_PLAYABLE_REQUIRED_ARCH 包必须包含的架构,默认 arm64
退出码: 0 PASS / 1 FAIL / 2 BLOCKED(配置或前置条件)/ 124 墙钟超时
EOF
}
while [ "$#" -gt 0 ]; do
case "$1" in
--app) app_path="${2:-}"; shift 2 ;;
--maps) maps="${2:-}"; shift 2 ;;
--config) config_path="${2:-}"; shift 2 ;;
--viewpoints) viewpoints="${2:-}"; shift 2 ;;
--races) races="${2:-}"; shift 2 ;;
--output) output_dir="${2:-}"; shift 2 ;;
--timeout-seconds) timeout_seconds="${2:-}"; shift 2 ;;
--help) usage; exit 0 ;;
*) echo "未知选项: $1" >&2; usage >&2; exit 2 ;;
esac
done
output_ready=0
blocked() {
echo "FOREST RENDER GATE: BLOCKED $*" >&2
if [ "$output_ready" -eq 1 ]; then echo "BLOCKED $*" >>"$output_dir/gate.log"; fi
exit 2
}
abs_file() { echo "$(cd "$(dirname "$1")" && pwd)/$(basename "$1")"; }
[ -n "$app_path" ] || blocked "需要 --app"
[[ "$timeout_seconds" =~ ^[1-9][0-9]*$ ]] || blocked "--timeout-seconds 必须是正整数"
engine="$app_path/Contents/MacOS/mtgodot-poc"
[ -x "$engine" ] || blocked "找不到可执行包:$app_path"
if [ -n "$config_path" ]; then
[ -f "$config_path" ] || blocked "配置不存在:$config_path"
config_path="$(abs_file "$config_path")"
fi
if [ -n "$viewpoints" ]; then
[ -f "$viewpoints" ] || blocked "机位夹具不存在:$viewpoints"
viewpoints="$(abs_file "$viewpoints")"
fi
if [ -n "$races" ] && ! [[ "$races" =~ ^[1-9][0-9]*(,[1-9][0-9]*)*$ ]]; then
blocked "--races 必须是逗号分隔的正整数 vnum"
fi
# Resolve the map list exactly as the client does (MT_FOREST_MAPS, else config map_key).
map_list="$(node -e '
const fs = require("node:fs");
const [maps, config] = process.argv.slice(1);
let keys = maps.split(",").map((s) => s.trim()).filter(Boolean);
if (keys.length === 0 && config) {
try { const key = String(JSON.parse(fs.readFileSync(config, "utf8")).map_key ?? "").trim(); if (key) keys = [key]; } catch {}
}
process.stdout.write(keys.join(","));
' "$maps" "$config_path")"
[ -n "$map_list" ] || blocked "没有地图:给 --maps,或在配置里填 map-assets.json 审计出的 map_key"
if [ -z "$output_dir" ]; then
output_dir="$repo/build/forest/run-$(date +%Y%m%d-%H%M%S)-$$"
fi
if [ -e "$output_dir" ] && [ -n "$(find "$output_dir" -mindepth 1 -maxdepth 1 -print -quit)" ]; then
blocked "输出目录已有内容,拒绝复用历史报告:$output_dir"
fi
mkdir -p "$output_dir"
output_dir="$(cd "$output_dir" && pwd)"
output_ready=1
if ! codesign --verify --strict "$app_path" >/dev/null 2>&1; then
blocked "签名校验失败:codesign --verify --strict $app_path"
fi
archs="$(lipo -archs "$engine" 2>/dev/null || true)"
case " $archs " in
*" $required_arch "*) ;;
*) blocked "包架构 [$archs] 不含 $required_arch" ;;
esac
run_id="$(basename "$output_dir")-$(od -An -N4 -tx4 /dev/urandom | tr -d ' ')"
log="$output_dir/client.log"
client_report="$output_dir/client-report.json"
final_report="$output_dir/report.json"
events="$output_dir/events.jsonl"
# Same formula as forest_map_render_test.gd required_case_ids(); the validator
# fails the run if the client declares a different list.
node -e '
const [file, suite, maps] = process.argv.slice(1);
const cases = ["MAP-FOREST-CONFIG"];
for (const key of maps.split(",")) {
const id = key.split("/").pop();
cases.push(`MAP-FOREST-LOAD-${id}`);
for (const kind of ["flat", "slope", "dense", "warp"]) cases.push(`MAP-FOREST-VP-${id}-${kind}`);
cases.push(`MAP-FOREST-MOTION-${id}`);
}
require("node:fs").writeFileSync(file, JSON.stringify({ schema_version: 1, suite, cases }, null, 2) + "\n");
' "$output_dir/required-cases.json" "$suite" "$map_list"
bash script/playable_build_info.sh "$app_path" >"$output_dir/build.json"
build_field() { node -e 'process.stdout.write(String(JSON.parse(require("node:fs").readFileSync(process.argv[1],"utf8"))[process.argv[2]]))' "$output_dir/build.json" "$1"; }
started_ms="$(($(date +%s) * 1000))"
set +e
# No credentials reach this child, so its log needs no redactor.
env -u MT_ASSETS -u MT_ACCOUNT -u MT_PASSWORD \
MT_TEST_MODE=forest_render \
MT_FOREST_MAPS="$map_list" \
MT_FOREST_VIEWPOINTS="$viewpoints" \
MT_FOREST_RACES="$races" \
MT_FOREST_TIMEOUT_SECONDS="$timeout_seconds" \
MT_PLAYABLE_CONFIG="$config_path" \
MT_PLAYABLE_RUN_ID="$run_id" \
MT_TEST_REPORT="$client_report" \
MT_TEST_EVENTS="$events" \
MT_TEST_OUTPUT="$output_dir" \
MT_BUILD_ENGINE_SHA256="$(build_field engine_sha256)" \
MT_BUILD_EXTENSION_SHA256="$(build_field extension_sha256)" \
MT_BUILD_PCK_SHA256="$(build_field pck_sha256)" \
MT_BUILD_ARCH="$(build_field arch)" \
"$engine" >"$log" 2>&1 </dev/null &
child_pid=$!
SECONDS=0
timed_out=0
while kill -0 "$child_pid" 2>/dev/null; do
if [ "$SECONDS" -ge "$timeout_seconds" ]; then
timed_out=1
kill -TERM "$child_pid" 2>/dev/null
grace=0
while kill -0 "$child_pid" 2>/dev/null && [ "$grace" -lt "$kill_grace_seconds" ]; do
sleep 1
grace=$((grace + 1))
done
killed=0
if kill -0 "$child_pid" 2>/dev/null; then kill -KILL "$child_pid" 2>/dev/null; killed=1; fi
echo "TIMEOUT term_grace_s=$grace killed=$killed" >>"$output_dir/gate.log"
break
fi
sleep 1
done
wait "$child_pid"
process_code=$?
node script/validate_playable_report.mjs --report "$client_report" --output "$final_report" \
--log "$log" --process-code "$process_code" --timed-out "$timed_out" --run-id "$run_id" \
--suite "$suite" --required-cases "$output_dir/required-cases.json" --build "$output_dir/build.json" \
--events "$events" --started-ms "$started_ms" --require-pass
gate_status=$?
set -e
echo "FOREST RENDER GATE: Metal 截图人工签核仍是单独的 manual 项($output_dir/forest-*.png + forest-map-evidence.json"
if [ "$timed_out" -eq 1 ]; then
echo "FOREST RENDER GATE: TIMEOUT raw_exit=$process_code report=$final_report log=$log" >&2
exit 124
fi
case "$gate_status" in
0) echo "FOREST RENDER GATE: PASS run_id=$run_id report=$final_report" ;;
2) echo "FOREST RENDER GATE: BLOCKED raw_exit=$process_code report=$final_report" >&2 ;;
*) echo "FOREST RENDER GATE: FAIL raw_exit=$process_code report=$final_report log=$log" >&2; gate_status=1 ;;
esac
exit "$gate_status"