#!/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" source script/playable_process_cleanup.sh 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..local.json);不给则只有自动候选 --races R1[,R2] 覆盖夹具里的怪物 vnum;不给且夹具为空时放 12 个候选树怪 --output DIR 本次运行目录,必须不存在或为空(默认 build/forest/run-<时间>-) --timeout-seconds N 墙钟超时,默认 900;超时 TERM,10 秒后 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; 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=$? child_pid="" 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"