Implement playable Mac client and rendering validation
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import {execFileSync} from 'node:child_process';
|
||||
const assets = process.argv[2] ?? 'assets';
|
||||
const output = process.argv[3];
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(assets, 'TreeGeometry/manifest.json')));
|
||||
const normalize = s => s.replaceAll('\\', '/').toLowerCase().replace(/^.*?ymir work\//, 'ymir work/');
|
||||
const converted = new Set(Object.values(manifest.trees).map(r => normalize(r.source)));
|
||||
const list = pattern => execFileSync('rg', ['--files', assets, '-g', pattern], {encoding: 'utf8', maxBuffer: 8 * 1024 * 1024}).trim().split('\n').filter(Boolean);
|
||||
const references = new Set();
|
||||
const propertyReferences = new Map();
|
||||
for (const file of list('*.prt')) {
|
||||
const source = fs.readFileSync(file, 'utf8');
|
||||
for (const match of source.matchAll(/^\s*treefile\s+"([^"]+)"/gmi)) {
|
||||
const reference = normalize(match[1]);
|
||||
references.add(reference);
|
||||
propertyReferences.set(source.split(/\r?\n/)[1].trim(), reference);
|
||||
}
|
||||
}
|
||||
const placedReferences = new Set();
|
||||
for (const file of list('areadata.txt')) {
|
||||
const source = fs.readFileSync(file, 'utf8');
|
||||
for (const match of source.matchAll(/^Start Object\d+\r?\n[^\n]*\n\s*(\d+)/gm)) {
|
||||
const reference = propertyReferences.get(match[1]);
|
||||
if (reference) placedReferences.add(reference);
|
||||
}
|
||||
}
|
||||
const unmatched = [];
|
||||
let matched = 0;
|
||||
for (const file of list('*.spt')) {
|
||||
const hash = crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
||||
if (manifest.trees[hash]) matched++;
|
||||
else unmatched.push({file, source_sha256: hash, referenced_by_property: references.has(normalize(file))});
|
||||
}
|
||||
const missing_referenced = [...references].filter(r => !converted.has(r)).sort();
|
||||
const missing_placed = [...placedReferences].filter(r => !converted.has(r)).sort();
|
||||
const report = {matched_spt_files: matched, converted_species: converted.size, referenced_species: references.size,
|
||||
placed_species: placedReferences.size, missing_placed,
|
||||
missing_referenced, unconverted_spt: unmatched, scope: 'SPT inventory and text PRT/AreaData references; six-map runtime validation is separate'};
|
||||
if (output) fs.writeFileSync(output, JSON.stringify(report, null, 2) + '\n');
|
||||
console.log(JSON.stringify({matched, converted: converted.size, referenced: references.size, placed: placedReferences.size, missing_placed, missing_referenced, unconverted: unmatched.length}));
|
||||
if (!placedReferences.size || missing_placed.length) process.exitCode = 1;
|
||||
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env node
|
||||
/* Static prerequisite audit for the first playable-map fixture. It does not
|
||||
* connect to a server and cannot prove which monsters a live server spawned. */
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = { assets: 'assets', output: '', maps: ['outdoortrent/metin2_map_trent', 'outdoortrent02/metin2_map_trent02'] };
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const a = argv[i];
|
||||
if (a === '--help') { console.log('usage: audit_playable_maps.mjs [--assets DIR] [--maps DIR[,DIR...]] [--output FILE]'); process.exit(0); }
|
||||
if (!a.startsWith('--') || i + 1 >= argv.length) throw new Error(`invalid argument: ${a}`);
|
||||
const k = a.slice(2).replaceAll('-', '_');
|
||||
out[k] = argv[++i];
|
||||
}
|
||||
if (typeof out.maps === 'string') out.maps = out.maps.split(',').map(s => s.trim()).filter(Boolean);
|
||||
return out;
|
||||
}
|
||||
|
||||
function filesUnder(root) {
|
||||
if (!fs.existsSync(root)) return [];
|
||||
const result = [];
|
||||
const visit = dir => {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) visit(full); else result.push(full);
|
||||
}
|
||||
};
|
||||
visit(root);
|
||||
return result;
|
||||
}
|
||||
|
||||
const norm = value => value.replaceAll('\\', '/').toLowerCase().replace(/^.*?ymir work\//, 'ymir work/');
|
||||
const read = file => fs.readFileSync(file, 'utf8');
|
||||
const sha256 = file => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
||||
|
||||
function parseProperties(allFiles) {
|
||||
const byId = new Map();
|
||||
for (const file of allFiles.filter(f => f.toLowerCase().endsWith('.prt'))) {
|
||||
const lines = read(file).split(/\r?\n/);
|
||||
const id = lines[1]?.trim();
|
||||
if (!/^\d+$/.test(id || '')) continue;
|
||||
const text = lines.join('\n');
|
||||
const tree = text.match(/^\s*treefile\s+"([^"]+)"/im)?.[1] || '';
|
||||
const type = text.match(/^\s*propertytype\s+"([^"]+)"/im)?.[1] || '';
|
||||
byId.set(id, { id: Number(id), file, type, treefile: tree });
|
||||
}
|
||||
return byId;
|
||||
}
|
||||
|
||||
function parseArea(file) {
|
||||
const text = read(file);
|
||||
const objects = [];
|
||||
const blockRe = /Start Object\d+\r?\n([^]*?)\r?\nEnd Object/g;
|
||||
for (const match of text.matchAll(blockRe)) {
|
||||
const lines = match[1].split(/\r?\n/).map(s => s.trim()).filter(Boolean);
|
||||
if (lines.length < 2) continue;
|
||||
const id = Number(lines[1]);
|
||||
if (Number.isInteger(id)) objects.push({ property_id: id, position: lines[0] });
|
||||
}
|
||||
return objects;
|
||||
}
|
||||
|
||||
function parseNpcList(file) {
|
||||
const result = new Map();
|
||||
if (!fs.existsSync(file)) return result;
|
||||
for (const line of read(file).split(/\r?\n/)) {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts.length >= 2 && /^\d+$/.test(parts[0])) result.set(Number(parts[0]), parts[1]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function mobRecord(assets, code, race, allFiles) {
|
||||
const candidates = allFiles.filter(file => file.toLowerCase().endsWith('.msm') && path.basename(file, '.msm').toLowerCase() === code.toLowerCase())
|
||||
.filter(file => file.toLowerCase().includes(`/monster2/`) || file.toLowerCase().includes(`/monster/`));
|
||||
const msm = candidates[0] || '';
|
||||
const dir = msm ? path.dirname(msm) : '';
|
||||
const sibling = name => dir && fs.existsSync(path.join(dir, name)) ? path.join(dir, name) : '';
|
||||
const motlist = sibling('motlist.txt');
|
||||
const actions = motlist ? [...read(motlist).matchAll(/^\s*\S+\s+([A-Z0-9_]+)\s+([^\s]+\.msa)/gmi)].map(m => ({ name: m[1], file: m[2], exists: fs.existsSync(path.join(dir, m[2])) })) : [];
|
||||
return {
|
||||
race, code, directory: dir ? path.relative(assets, dir).replaceAll('\\', '/') : '',
|
||||
files: { msm: Boolean(msm), gr2: Boolean(sibling(`${code}.gr2`)), dds: Boolean(sibling(`${code}.dds`)), motlist: Boolean(motlist) },
|
||||
action_count: actions.length, actions,
|
||||
required_states: Object.fromEntries(['WAIT', 'RUN', 'NORMAL_ATTACK', 'FRONT_DAMAGE', 'FRONT_DEAD'].map(state => [state, actions.some(a => a.name === state)])),
|
||||
source_sha256: msm ? sha256(msm) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const assets = path.resolve(options.assets);
|
||||
const allFiles = filesUnder(assets);
|
||||
const manifestPath = path.join(assets, 'TreeGeometry', 'manifest.json');
|
||||
const manifest = fs.existsSync(manifestPath) ? JSON.parse(read(manifestPath)) : { trees: {} };
|
||||
const sourceByName = new Map(Object.values(manifest.trees || {}).map(item => [norm(item.source), item]));
|
||||
const properties = parseProperties(allFiles);
|
||||
const maps = options.maps.map(key => {
|
||||
const root = path.join(assets, key);
|
||||
const areaFiles = filesUnder(root).filter(f => path.basename(f).toLowerCase() === 'areadata.txt');
|
||||
const objects = areaFiles.flatMap(file => parseArea(file).map(item => ({ ...item, file: path.relative(assets, file).replaceAll('\\', '/') })));
|
||||
const propertyIds = [...new Set(objects.map(item => item.property_id))];
|
||||
const resolved = propertyIds.map(id => {
|
||||
const prop = properties.get(String(id));
|
||||
if (!prop) return { property_id: id, resolved: false };
|
||||
const record = sourceByName.get(norm(prop.treefile));
|
||||
return { property_id: id, resolved: true, property_type: prop.type, property_file: path.relative(assets, prop.file).replaceAll('\\', '/'),
|
||||
treefile: prop.treefile, native_tree: Boolean(record), source_sha256: record?.source_sha256 || null };
|
||||
});
|
||||
const treeObjects = objects.filter(item => resolved.find(r => r.property_id === item.property_id)?.native_tree);
|
||||
return { key, exists: fs.existsSync(root), setting: fs.existsSync(path.join(root, 'setting.txt')),
|
||||
area_files: areaFiles.length, object_count: objects.length, property_count: propertyIds.length,
|
||||
resolved_properties: resolved.length, unresolved_property_ids: resolved.filter(r => !r.resolved).map(r => r.property_id),
|
||||
tree_object_count: treeObjects.length, native_tree_object_count: treeObjects.length,
|
||||
properties: resolved };
|
||||
});
|
||||
const npc = parseNpcList(path.join(assets, 'root/npclist.txt'));
|
||||
const treeMobRaces = [...Array(15)].map((_, i) => 2301 + i).filter(race => npc.has(race));
|
||||
const mobs = treeMobRaces.map(race => mobRecord(assets, npc.get(race), race, allFiles));
|
||||
const report = { schema_version: 1, suite: 'playable-map-assets', assets_root: assets,
|
||||
maps, tree_monster_candidates: mobs,
|
||||
caveat: 'Static map AreaData and local resources only; server spawn membership and visual correctness require live/map-render tests.' };
|
||||
if (options.output) { fs.mkdirSync(path.dirname(path.resolve(options.output)), { recursive: true }); fs.writeFileSync(options.output, `${JSON.stringify(report, null, 2)}\n`); }
|
||||
console.log(JSON.stringify({ maps: maps.map(m => ({ key: m.key, exists: m.exists, objects: m.object_count, native_tree_objects: m.native_tree_object_count, unresolved: m.unresolved_property_ids.length })), tree_monsters: mobs.length }));
|
||||
if (maps.some(m => !m.exists || !m.setting || m.area_files === 0) || mobs.some(m => !m.files.msm || !m.files.gr2 || !m.files.dds || !m.files.motlist)) process.exitCode = 1;
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env bash
|
||||
# Reproducible Metal-only release template used by the particle exit checks.
|
||||
# Does not replace Godot's installed templates. Pass the resulting executable
|
||||
# to build-macos-client.sh using MT_MAC_ENGINE and the matching MT_MAC_ARCHES.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
repo_dir="$PWD"
|
||||
source_dir="$repo_dir/build/godot-particle-diagnostic"
|
||||
revision="a13da4feb8d8aefc283c3763d33a2f170a18d541"
|
||||
engine_arch="${1:-arm64}"
|
||||
case "$engine_arch" in arm64|x86_64) ;; *) echo "Expected arm64 or x86_64" >&2; exit 1 ;; esac
|
||||
if [ ! -d "$source_dir" ]; then
|
||||
git clone --depth 1 --branch 4.7.1-stable https://github.com/godotengine/godot.git "$source_dir"
|
||||
fi
|
||||
test "$(git -C "$source_dir" rev-parse HEAD)" = "$revision"
|
||||
git -C "$source_dir" diff --exit-code
|
||||
git -C "$source_dir" diff --cached --exit-code
|
||||
cd "$source_dir"
|
||||
scons platform=macos arch="$engine_arch" target=template_release \
|
||||
debug_symbols=yes optimize=speed lto=none vulkan=no -j8
|
||||
echo "Engine: $source_dir/bin/godot.macos.template_release.$engine_arch"
|
||||
shasum -a 256 "bin/godot.macos.template_release.$engine_arch"
|
||||
@@ -12,9 +12,9 @@ set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
REPO="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
APP="$REPO/build/export/mtgodot-poc.app"
|
||||
APP="${MT_SMOKE_APP:-$REPO/build/export/mtgodot-poc.app}"
|
||||
EXECUTABLE="$APP/Contents/MacOS/mtgodot-poc"
|
||||
ARTIFACT_DIR="$REPO/build/live-smoke"
|
||||
ARTIFACT_DIR="${MT_SMOKE_OUTPUT:-$REPO/build/live-smoke}"
|
||||
LOG="$ARTIFACT_DIR/live-smoke.log"
|
||||
REPORT="$ARTIFACT_DIR/live-smoke-report.json"
|
||||
CONFIG_FILE="${MT_SMOKE_CONFIG:-$REPO/.env.live-smoke.local}"
|
||||
@@ -31,6 +31,7 @@ usage() {
|
||||
--no-build 使用现有 APP,不重新打包
|
||||
--no-mutations 只做登录、进游戏和数据读取,不发送移动/聊天/重连包
|
||||
--no-reconnect 做移动和聊天,但跳过重连
|
||||
--reconnect-only 只测试登录/主动重连,不发送移动、聊天或施法
|
||||
--help 显示帮助
|
||||
|
||||
环境变量:
|
||||
@@ -42,8 +43,9 @@ EOF
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--no-build) BUILD=0 ;;
|
||||
--no-mutations) MUTATIONS=0 ;;
|
||||
--no-mutations) MUTATIONS=0; RECONNECT=0 ;;
|
||||
--no-reconnect) RECONNECT=0 ;;
|
||||
--reconnect-only) MUTATIONS=0; RECONNECT=1 ;;
|
||||
--help) usage; exit 0 ;;
|
||||
*) echo "未知选项: $1" >&2; usage >&2; exit 2 ;;
|
||||
esac
|
||||
@@ -90,7 +92,10 @@ for port in 11000 13002; do
|
||||
fi
|
||||
done
|
||||
|
||||
pkill -x mtgodot-poc 2>/dev/null || true
|
||||
if pgrep -x mtgodot-poc >/dev/null; then
|
||||
echo "已有客户端运行,请先关闭后再执行独立登录测试。" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
export MT_PROTOCOL=classic
|
||||
export MT_AUTOLOGIN=1
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
# A JSON PASS is insufficient: shutdown happens after the report is written.
|
||||
# Keep the exported client's complete log and fail on renderer/RID leaks too.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
app_path="${MT_RENDER_APP:-$PWD/build/export/mtgodot-poc.app}"
|
||||
output_dir="${MT_RENDER_OUTPUT:-$PWD/build/rendering/package-gate-$(date +%Y%m%d-%H%M%S)-$$}"
|
||||
test -x "$app_path/Contents/MacOS/mtgodot-poc"
|
||||
mkdir -p "$output_dir"
|
||||
status=0
|
||||
env -u MT_ASSETS MT_TEST_MODE=render MT_RENDER_OUTPUT="$output_dir" \
|
||||
"$app_path/Contents/MacOS/mtgodot-poc" --quit-after 1800 >"$output_dir/client.log" 2>&1 || status=$?
|
||||
if [ "$status" -ne 0 ] || ! rg -q '^PKGRENDER: PASS$' "$output_dir/client.log" || \
|
||||
rg -q 'SCRIPT ERROR|^ERROR:|leaked at exit|shaders of type .* were never freed' "$output_dir/client.log"; then
|
||||
echo "FAIL: package load/exit gate (exit=$status): $output_dir/client.log"
|
||||
exit 1
|
||||
fi
|
||||
echo "PASS: package load and clean exit: $output_dir/client.log"
|
||||
@@ -0,0 +1,14 @@
|
||||
extends SceneTree
|
||||
|
||||
const Config = preload("res://testing/playable_config.gd")
|
||||
|
||||
func _init() -> void:
|
||||
var path := OS.get_environment("MT_PLAYABLE_VALIDATE_CONFIG")
|
||||
var result := Config.load_file(path)
|
||||
if not result.ok:
|
||||
for error in result.errors:
|
||||
printerr("CONFIG: " + String(error))
|
||||
quit(2)
|
||||
return
|
||||
print("PLAYABLE CONFIG: PASS " + path)
|
||||
quit(0)
|
||||
Executable
+44
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
# INF-02 negative/positive checks. Uses only static fixtures and a temporary
|
||||
# output directory; it never starts the game or contacts the server.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/mt-playable-gate.XXXXXX")"
|
||||
trap 'rm -R "$tmp_dir"' EXIT
|
||||
|
||||
node script/validate_playable_report.mjs \
|
||||
--report test/playable/report.valid.json \
|
||||
--output "$tmp_dir/valid.json" \
|
||||
--log test/playable/clean.log --process-code 0 --require-pass
|
||||
|
||||
if node script/validate_playable_report.mjs \
|
||||
--report test/playable/report.blocked.json \
|
||||
--output "$tmp_dir/blocked.json" \
|
||||
--log test/playable/clean.log --process-code 0 --require-pass; then
|
||||
echo "FAIL: BLOCKED fixture was accepted" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if node script/validate_playable_report.mjs \
|
||||
--report test/playable/report.valid.json \
|
||||
--output "$tmp_dir/exit.json" \
|
||||
--log test/playable/clean.log --process-code 1 --require-pass; then
|
||||
echo "FAIL: non-zero child exit was accepted" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if node script/validate_playable_report.mjs \
|
||||
--report test/playable/report.valid.json \
|
||||
--output "$tmp_dir/rid.json" \
|
||||
--log test/playable/rid-warning.log --process-code 0 --require-pass; then
|
||||
echo "FAIL: renderer warning was accepted" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if MT_PLAYABLE_VALIDATE_CONFIG="$PWD/test/playable/scenario.example.json" \
|
||||
godot --headless --path project --script playable_config_gate.gd; then
|
||||
echo "FAIL: empty scenario configuration was accepted" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "playable_gate_test: PASS"
|
||||
Executable
+56
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
# Public entry for the first playable client gate. It delegates process
|
||||
# ownership and post-exit validation to run_client_gate.sh.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
app="${MT_PLAYABLE_APP:-$PWD/build/export-native-trees-20260911/mtgodot-poc.app}"
|
||||
config=""
|
||||
output=""
|
||||
mode="playable"
|
||||
timeout="180"
|
||||
allow=0
|
||||
repeat=1
|
||||
suite="full"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
用法: script/playable_test.sh --config CONFIG [选项]
|
||||
--app APP 导出包路径
|
||||
--output DIR 单次运行输出目录(repeat=1)
|
||||
--suite full 当前固定为 full/playable
|
||||
--allow-gameplay 允许发送游戏状态变更请求
|
||||
--repeat N 独立进程运行次数,默认 1
|
||||
--timeout-seconds N 每次墙钟超时,默认 180
|
||||
EOF
|
||||
}
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--app) app="$2"; shift 2 ;;
|
||||
--config) config="$2"; shift 2 ;;
|
||||
--output) output="$2"; shift 2 ;;
|
||||
--suite) suite="$2"; shift 2 ;;
|
||||
--allow-gameplay) allow=1; shift ;;
|
||||
--repeat) repeat="$2"; shift 2 ;;
|
||||
--timeout-seconds) timeout="$2"; shift 2 ;;
|
||||
--help) usage; exit 0 ;;
|
||||
*) echo "未知选项: $1" >&2; usage >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
if [ -z "$config" ] || [ ! -f "$config" ]; then echo "需要 --config CONFIG" >&2; exit 2; fi
|
||||
if [ "$suite" != "full" ] && [ "$suite" != "playable" ]; then echo "--suite 只支持 full/playable" >&2; exit 2; fi
|
||||
if ! [[ "$repeat" =~ ^[1-9][0-9]*$ ]]; then echo "--repeat 必须是正整数" >&2; exit 2; fi
|
||||
if [ "$repeat" -gt 1 ] && [ -n "$output" ]; then echo "repeat>1 时不能指定单一 --output" >&2; exit 2; fi
|
||||
|
||||
root="$output"
|
||||
if [ -z "$root" ]; then root="${MT_PLAYABLE_OUTPUT:-$PWD/build/playable/suite-$(date +%Y%m%d-%H%M%S)-$$}"; fi
|
||||
mkdir -p "$root"
|
||||
failed=0
|
||||
for index in $(seq 1 "$repeat"); do
|
||||
run_dir="$root"
|
||||
if [ "$repeat" -gt 1 ]; then run_dir="$root/run-$index"; fi
|
||||
args=(--app "$app" --config "$config" --output "$run_dir" --mode "$mode" --timeout-seconds "$timeout")
|
||||
if [ "$allow" -eq 1 ]; then args+=(--allow-gameplay); fi
|
||||
if ! bash script/run_client_gate.sh "${args[@]}"; then failed=$((failed + 1)); fi
|
||||
done
|
||||
if [ "$failed" -ne 0 ]; then echo "PLAYABLE SUITE: FAIL runs=$failed/$repeat" >&2; exit 1; fi
|
||||
echo "PLAYABLE SUITE: PASS runs=$repeat root=$root"
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
# Repeatable, non-network regression sweep; logs retain failures and warnings.
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
run_dir="$(pwd)/build/rendering/batch-$(date +%Y%m%d-%H%M%S)-$$"
|
||||
mkdir -p "$run_dir"
|
||||
failed=0
|
||||
run_test() {
|
||||
local mode="$1" test_script="$2" log="$run_dir/$1-${2%.gd}.log"
|
||||
local options=(--path project)
|
||||
if [ "$mode" = headless ]; then options+=(--headless); fi
|
||||
"${GODOT:-godot}" "${options[@]}" --script "$test_script" --quit-after 1800 >"$log" 2>&1
|
||||
local status=$?
|
||||
if [ "$status" -ne 0 ] || rg -q 'SCRIPT ERROR|Parse Error|^FAIL:|check\(s\) failed' "$log" || ! rg -q 'PASS|failures=0' "$log"; then
|
||||
echo "FAIL $mode $test_script (exit=$status): $log"
|
||||
failed=$((failed + 1))
|
||||
else
|
||||
echo "PASS $mode $test_script"
|
||||
fi
|
||||
if rg -q 'leaked at exit' "$log"; then echo "WARN resource leak: $log"; fi
|
||||
}
|
||||
for test_script in animation_cache_test.gd target_effect_test.gd effect_space_test.gd effect_faces_test.gd motion_effect_anchor_test.gd mob_winding_test.gd water_reference_test.gd effect_texture_animation_test.gd dds_lifecycle_test.gd effect_color_operation_test.gd effect_color_test.gd effect_scale_test.gd effect_rotation_test.gd effect_surface_test.gd effect_emission_test.gd effect_render_regression_test.gd effect_playback_test.gd fx_test.gd skill_fx_test.gd player_skill_test.gd skill_test.gd damage_effect_test.gd combat_fx_test.gd app_flow_lifecycle_test.gd playable_harness_test.gd playable_adapter_test.gd gamescene_test.gd equip_model_test.gd equip_rules_test.gd race_motion_assembly_test.gd character_winding_test.gd; do
|
||||
run_test headless "$test_script"
|
||||
done
|
||||
for test_script in effect_faces_test.gd effect_lie_test.gd effect_texture_animation_test.gd effect_color_operation_test.gd effect_surface_test.gd effect_emission_test.gd effect_rotation_test.gd model_render_test.gd gpu_lod_attachment_test.gd gpu_pose_bounds_test.gd forest_mob_render_test.gd; do
|
||||
run_test metal "$test_script"
|
||||
done
|
||||
for mode in headless metal; do
|
||||
MT_TREE_OUTPUT="$run_dir/native-trees-$mode" run_test "$mode" native_tree_test.gd
|
||||
run_test "$mode" effect_random_rotation_test.gd
|
||||
run_test "$mode" fly_target_bounds_test.gd
|
||||
done
|
||||
echo "BATCH failures=$failed logs=$run_dir"
|
||||
test "$failed" -eq 0
|
||||
Executable
+125
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run one exported client process and seal its report after exit.
|
||||
# This script owns exactly one child PID; it never pkills other clients.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
app_path="${MT_PLAYABLE_APP:-$PWD/build/export-native-trees-20260911/mtgodot-poc.app}"
|
||||
mode="playable"
|
||||
config_path=""
|
||||
output_dir="${MT_PLAYABLE_OUTPUT:-$PWD/build/playable/run-$(date +%Y%m%d-%H%M%S)-$$}"
|
||||
timeout_seconds="${MT_PLAYABLE_TIMEOUT_SECONDS:-180}"
|
||||
allow_gameplay=0
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
用法: script/run_client_gate.sh --app APP --config CONFIG [选项]
|
||||
选项:
|
||||
--output DIR 本次运行目录,必须不存在或为空
|
||||
--mode MODE 默认 playable
|
||||
--timeout-seconds N 墙钟超时,默认 180
|
||||
--allow-gameplay 允许专用测试账号发送移动/战斗/拾取请求
|
||||
EOF
|
||||
}
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--app) app_path="$2"; shift 2 ;;
|
||||
--config) config_path="$2"; shift 2 ;;
|
||||
--output) output_dir="$2"; shift 2 ;;
|
||||
--mode) mode="$2"; shift 2 ;;
|
||||
--timeout-seconds) timeout_seconds="$2"; shift 2 ;;
|
||||
--allow-gameplay) allow_gameplay=1; shift ;;
|
||||
--help) usage; exit 0 ;;
|
||||
*) echo "未知选项: $1" >&2; usage >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -z "$config_path" ] || [ ! -f "$config_path" ]; then
|
||||
echo "配置不存在;不会启动客户端:$config_path" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [ ! -x "$app_path/Contents/MacOS/mtgodot-poc" ]; then
|
||||
echo "找不到可执行包:$app_path" >&2
|
||||
exit 2
|
||||
fi
|
||||
if ! [[ "$timeout_seconds" =~ ^[1-9][0-9]*$ ]]; then
|
||||
echo "timeout-seconds 必须是正整数" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [ -e "$output_dir" ] && [ "$(find "$output_dir" -mindepth 1 -maxdepth 1 -print -quit)" ]; then
|
||||
echo "输出目录已有内容,拒绝复用历史报告:$output_dir" >&2
|
||||
exit 2
|
||||
fi
|
||||
mkdir -p "$output_dir"
|
||||
log="$output_dir/client.log"
|
||||
client_report="$output_dir/client-report.json"
|
||||
final_report="$output_dir/report.json"
|
||||
events="$output_dir/events.jsonl"
|
||||
|
||||
if ! MT_PLAYABLE_VALIDATE_CONFIG="$config_path" godot --headless --path project --script playable_config_gate.gd >"$output_dir/config.log" 2>&1; then
|
||||
echo "配置校验失败;不会启动客户端:$output_dir/config.log" >&2
|
||||
exit 2
|
||||
fi
|
||||
char_slot="$(node -e 'const fs=require("node:fs"); const p=process.argv[1]; const d=JSON.parse(fs.readFileSync(p,"utf8")); process.stdout.write(String(d.character_slot));' "$config_path")"
|
||||
|
||||
sha256_of() {
|
||||
if [ -f "$1" ]; then shasum -a 256 "$1" | awk '{print $1}'; else echo ""; fi
|
||||
}
|
||||
engine="$app_path/Contents/MacOS/mtgodot-poc"
|
||||
pck="$app_path/Contents/Resources/mtgodot-poc.pck"
|
||||
dylib="$(find "$app_path/Contents/Frameworks" -maxdepth 1 -type f -name '*.dylib' -print -quit)"
|
||||
export MT_TEST_MODE="$mode"
|
||||
export MT_PLAYABLE_CONFIG="$config_path"
|
||||
export MT_TEST_REPORT="$client_report"
|
||||
export MT_TEST_EVENTS="$events"
|
||||
export MT_TEST_OUTPUT="$output_dir"
|
||||
export MT_PROTOCOL="classic"
|
||||
export MT_AUTOLOGIN=1
|
||||
export MT_CHAR_SLOT="$char_slot"
|
||||
export MT_PLAYABLE_RUN_ID="$(basename "$output_dir")"
|
||||
export MT_BUILD_ENGINE_SHA256="$(sha256_of "$engine")"
|
||||
export MT_BUILD_EXTENSION_SHA256="$(sha256_of "$dylib")"
|
||||
export MT_BUILD_PCK_SHA256="$(sha256_of "$pck")"
|
||||
file_description="$(file "$engine")"
|
||||
case "$file_description" in
|
||||
*arm64*) export MT_BUILD_ARCH="arm64" ;;
|
||||
*x86_64*) export MT_BUILD_ARCH="x86_64" ;;
|
||||
*) export MT_BUILD_ARCH="unknown" ;;
|
||||
esac
|
||||
if [ "$allow_gameplay" -eq 1 ]; then export MT_PLAYABLE_ALLOW_GAMEPLAY=1; else export MT_PLAYABLE_ALLOW_GAMEPLAY=0; fi
|
||||
|
||||
start_epoch="$(date +%s)"
|
||||
set +e
|
||||
env -u MT_ASSETS "$engine" >"$log" 2>&1 &
|
||||
child_pid=$!
|
||||
timed_out=0
|
||||
while kill -0 "$child_pid" 2>/dev/null; do
|
||||
now="$(date +%s)"
|
||||
if [ $((now - start_epoch)) -ge "$timeout_seconds" ]; then
|
||||
timed_out=1
|
||||
kill -TERM "$child_pid" 2>/dev/null || true
|
||||
for _ in 1 2 3 4 5 6 7 8 9 10; do
|
||||
kill -0 "$child_pid" 2>/dev/null || break
|
||||
sleep 1
|
||||
done
|
||||
kill -KILL "$child_pid" 2>/dev/null || true
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
wait "$child_pid"
|
||||
app_status=$?
|
||||
set -e
|
||||
if [ "$timed_out" -eq 1 ]; then app_status=124; fi
|
||||
|
||||
set +e
|
||||
node script/validate_playable_report.mjs --report "$client_report" --output "$final_report" \
|
||||
--log "$log" --process-code "$app_status" --require-pass
|
||||
gate_status=$?
|
||||
set -e
|
||||
if [ "$app_status" -ne 0 ] || [ "$gate_status" -ne 0 ]; then
|
||||
echo "PLAYABLE GATE: FAIL exit=$app_status report=$final_report log=$log" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "PLAYABLE GATE: PASS report=$final_report log=$log"
|
||||
@@ -0,0 +1,44 @@
|
||||
// Stage already-extracted, hash-matched native trees. Never manufacture geometry.
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
const [source, assets] = process.argv.slice(2);
|
||||
if (!source || !assets) throw Error('Usage: node script/stage_tree_geometry.mjs CONVERTED_ROOT ASSETS_ROOT');
|
||||
const hash = b => crypto.createHash('sha256').update(b).digest('hex');
|
||||
const index = JSON.parse(fs.readFileSync(path.join(source, 'asset-index.json')));
|
||||
const records = [];
|
||||
for (const item of Object.values(index.assets)) {
|
||||
if (item.kind !== 'tree') continue;
|
||||
if (item.status !== 'converted' || item.build.native_extractor !== 'spt_extract.exe' ||
|
||||
item.build.coordinate_policy !== 'raw_speedtree_z_up_centimeters_to_godot_x_z_neg_y_meters') throw Error(`Unverified tree: ${item.id}`);
|
||||
const raw = item.source.raw;
|
||||
const spt = fs.readFileSync(path.join(assets, raw.source_physical));
|
||||
const bytes = fs.readFileSync(path.join(source, item.output));
|
||||
if (hash(spt) !== raw.source_sha256 || hash(bytes) !== item.output_sha256) throw Error(`Hash mismatch: ${item.id}`);
|
||||
if (bytes.readUInt32LE(0) !== 0x46546c67 || bytes.readUInt32LE(4) !== 2 || bytes.readUInt32LE(8) !== bytes.length) throw Error('Invalid GLB');
|
||||
const gltf = JSON.parse(bytes.subarray(20, 20 + bytes.readUInt32LE(12)).toString());
|
||||
// Runtime loads mesh zero directly: reject transforms, external data and scenes
|
||||
// that would need flattening instead of silently dropping their transforms.
|
||||
if (gltf.nodes.length !== 1 || gltf.nodes[0].mesh !== 0 || gltf.meshes.length !== 1 ||
|
||||
['matrix', 'translation', 'rotation', 'scale', 'children'].some(k => k in gltf.nodes[0]) ||
|
||||
gltf.buffers.some(b => b.uri) || (gltf.images ?? []).some(i => i.uri)) throw Error(`Unsupported scene: ${item.id}`);
|
||||
records.push({bytes, key: raw.source_sha256, record: {
|
||||
source: raw.source_physical, source_sha256: raw.source_sha256,
|
||||
glb: `${raw.source_sha256}.glb`, glb_sha256: item.output_sha256,
|
||||
native_extractor: item.build.native_extractor, extractor_runs: item.build.extractor_runs,
|
||||
primitives: gltf.meshes[0].primitives.length, leaves: 'static_extracted_cards', wind: 'not_baked'
|
||||
}});
|
||||
}
|
||||
if (!records.length) throw Error('No native trees found');
|
||||
const dest = path.join(assets, 'TreeGeometry');
|
||||
// Validate the whole set before staging. Do not overwrite unrelated asset files.
|
||||
fs.mkdirSync(dest, {recursive: true});
|
||||
const trees = {};
|
||||
for (const {bytes, key, record} of records) {
|
||||
const target = path.join(dest, record.glb);
|
||||
if (fs.existsSync(target) && hash(fs.readFileSync(target)) !== record.glb_sha256) throw Error(`Conflicting existing GLB: ${target}`);
|
||||
if (!fs.existsSync(target)) fs.writeFileSync(target, bytes);
|
||||
trees[key] = record;
|
||||
}
|
||||
fs.writeFileSync(path.join(dest, 'manifest.json'), JSON.stringify({schema_version: 1, coordinates: 'godot_y_up_meters', trees}, null, 2) + '\n');
|
||||
console.log(`Staged ${records.length} verified native tree species into ${dest}`);
|
||||
Executable
+113
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env node
|
||||
/* Validate the machine-readable playable report and, when requested, seal it
|
||||
* with the parent process' exit/log gate. The client cannot mark a run PASS by
|
||||
* itself: the final report is written only after the child has exited. */
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const VALID = new Set(['PASS', 'FAIL', 'BLOCKED', 'SKIP']);
|
||||
const ERROR_RE = /SCRIPT ERROR|Parse Error|^ERROR:|leaked at exit|shaders of type .* were never freed/m;
|
||||
|
||||
function usage() {
|
||||
console.error('usage: validate_playable_report.mjs --report PATH [--output PATH] [--log PATH] [--process-code N] [--run-id ID] [--require-pass]');
|
||||
}
|
||||
|
||||
function argsOf(argv) {
|
||||
const out = { require_pass: false };
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
if (arg === '--require-pass') out.require_pass = true;
|
||||
else if (arg.startsWith('--')) {
|
||||
const key = arg.slice(2).replaceAll('-', '_');
|
||||
if (i + 1 >= argv.length) throw new Error(`missing value for ${arg}`);
|
||||
out[key] = argv[++i];
|
||||
} else throw new Error(`unknown argument: ${arg}`);
|
||||
}
|
||||
if (!out.report) throw new Error('--report is required');
|
||||
return out;
|
||||
}
|
||||
|
||||
function readJson(file) {
|
||||
try { return JSON.parse(fs.readFileSync(file, 'utf8')); }
|
||||
catch (error) { return { __read_error: `${file}: ${error.message}` }; }
|
||||
}
|
||||
|
||||
function validate(report, options) {
|
||||
const errors = [];
|
||||
const readError = report?.__read_error;
|
||||
if (readError) errors.push(readError);
|
||||
if (!report || typeof report !== 'object' || Array.isArray(report)) {
|
||||
errors.push('report root must be an object');
|
||||
return errors;
|
||||
}
|
||||
if (report.schema_version !== 1) errors.push('schema_version must be 1');
|
||||
if (typeof report.run_id !== 'string' || report.run_id.trim() === '') errors.push('run_id is missing');
|
||||
if (options.run_id && report.run_id !== options.run_id) errors.push('run_id does not match requested run');
|
||||
if (report.suite !== 'playable') errors.push('suite must be playable');
|
||||
if (!VALID.has(report.status)) errors.push(`invalid report status: ${report.status}`);
|
||||
for (const key of ['engine_sha256', 'extension_sha256', 'pck_sha256', 'arch']) {
|
||||
if (typeof report.build?.[key] !== 'string' || report.build[key].trim() === '') errors.push(`build.${key} is missing`);
|
||||
}
|
||||
if (!Array.isArray(report.cases) || report.cases.length === 0) errors.push('cases must be non-empty');
|
||||
const cases = Array.isArray(report.cases) ? report.cases : [];
|
||||
let passed = 0;
|
||||
for (const item of cases) {
|
||||
if (!item || typeof item !== 'object' || typeof item.id !== 'string' || item.id.trim() === '') {
|
||||
errors.push('case id is missing');
|
||||
continue;
|
||||
}
|
||||
if (!VALID.has(item.status)) errors.push(`${item.id}: invalid status`);
|
||||
if (item.status === 'PASS') passed += 1;
|
||||
if (item.status === 'BLOCKED' || item.status === 'SKIP') errors.push(`${item.id}: ${item.status} is not releasable`);
|
||||
if (!Array.isArray(item.evidence)) errors.push(`${item.id}: evidence must be an array`);
|
||||
for (const evidence of (Array.isArray(item.evidence) ? item.evidence : [])) {
|
||||
if (typeof evidence !== 'string' || evidence.trim() === '') errors.push(`${item.id}: empty evidence path`);
|
||||
else if (!fs.existsSync(path.resolve(path.dirname(options.report), evidence))) errors.push(`${item.id}: missing evidence ${evidence}`);
|
||||
}
|
||||
}
|
||||
const coverage = report.coverage;
|
||||
if (!coverage || coverage.required !== cases.length || coverage.passed !== passed) errors.push('coverage does not match cases');
|
||||
if (!Array.isArray(report.failures) || !Array.isArray(report.blocked)) errors.push('failures and blocked must be arrays');
|
||||
if (!report.exit_gate || report.exit_gate.checked !== true) errors.push('exit_gate.checked must be true in final report');
|
||||
if (options.process_code !== undefined && Number(report.exit_gate?.process_code) !== Number(options.process_code)) errors.push('exit_gate.process_code does not match child process');
|
||||
if (Number(report.exit_gate?.process_code) !== 0) errors.push('child process did not exit with code 0');
|
||||
if (options.log) {
|
||||
if (!fs.existsSync(options.log)) errors.push(`log does not exist: ${options.log}`);
|
||||
else {
|
||||
const lines = fs.readFileSync(options.log, 'utf8').split(/\r?\n/);
|
||||
lines.forEach((line, index) => { if (ERROR_RE.test(line)) errors.push(`log:${index + 1}: ${line}`); });
|
||||
}
|
||||
}
|
||||
if (options.require_pass && report.status !== 'PASS') errors.push(`report status is ${report.status}, expected PASS`);
|
||||
if (report.status === 'FAIL') errors.push('client report already contains failures');
|
||||
return [...new Set(errors)];
|
||||
}
|
||||
|
||||
function main() {
|
||||
let options;
|
||||
try { options = argsOf(process.argv.slice(2)); }
|
||||
catch (error) { usage(); console.error(error.message); return 2; }
|
||||
const input = readJson(options.report);
|
||||
const report = input.__read_error ? {
|
||||
schema_version: 1, run_id: options.run_id || 'missing-report', suite: 'playable', status: 'FAIL',
|
||||
build: { engine_sha256: '', extension_sha256: '', pck_sha256: '', arch: '' }, cases: [],
|
||||
failures: [input.__read_error], blocked: [], coverage: { required: 0, passed: 0 },
|
||||
} : input;
|
||||
const exitCode = options.process_code === undefined ? null : Number(options.process_code);
|
||||
report.exit_gate = { checked: true, process_code: exitCode, errors: [] };
|
||||
const errors = validate(report, options);
|
||||
report.exit_gate.errors = errors;
|
||||
if (errors.length > 0) report.status = 'FAIL';
|
||||
const output = options.output || options.report;
|
||||
fs.mkdirSync(path.dirname(path.resolve(output)), { recursive: true });
|
||||
fs.writeFileSync(output, `${JSON.stringify(report, null, 2)}\n`);
|
||||
if (errors.length) {
|
||||
console.error(`PLAYABLE REPORT: FAIL (${errors.length} errors)`);
|
||||
errors.forEach((error) => console.error(`- ${error}`));
|
||||
return 1;
|
||||
}
|
||||
console.log(`PLAYABLE REPORT: PASS ${output}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
process.exitCode = main();
|
||||
Reference in New Issue
Block a user