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:
+410
-95
@@ -1,130 +1,445 @@
|
||||
#!/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. */
|
||||
/* Static prerequisite audit for the first playable-map fixture (FIRST-MAC-PLAYABLE §8.1).
|
||||
* It reads the real map setting, TextureSet, AreaData/Property, npclist, MSM and motlist,
|
||||
* resolves every referenced file the way the runtime does, and lists missing items.
|
||||
* It does not connect to a server and cannot prove which monsters a live server spawned:
|
||||
* tree monsters are reported as candidates only.
|
||||
*
|
||||
* exit: 0 = no unexplained gaps, 1 = missing resources, 2 = config/precondition blocked. */
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
const USAGE = `usage: audit_playable_maps.mjs [--config SCENARIO.json | --maps DIR[,DIR...]] [--assets DIR] [--races N[,N...]] [--output FILE]
|
||||
--config scenario JSON; its map_key is audited (credentials are never read from it)
|
||||
--maps explicit map directories relative to the asset root
|
||||
--assets asset root (default: assets)
|
||||
--races candidate monster races (default: npclist entries 2301-2315)
|
||||
--output report path (default: build/playable/map-assets.json)`;
|
||||
|
||||
const DEFAULT_MAPS = ['outdoortrent/metin2_map_trent', 'outdoortrent02/metin2_map_trent02'];
|
||||
const PROPERTY_EXTENSIONS = ['.prb', '.prt', '.pre', '.prd', '.pra'];
|
||||
// Keep in sync with project/ui/mob_view.gd STATE_MOTIONS (first name = primary motion).
|
||||
const STATE_MOTIONS = {
|
||||
wait: ['WAIT', 'WAIT1'],
|
||||
run: ['RUN', 'WALK'],
|
||||
attack: ['NORMAL_ATTACK', 'NORMAL_ATTACK1', 'SPECIAL_1'],
|
||||
damage: ['FRONT_DAMAGE', 'DAMAGE', 'BACK_DAMAGE'],
|
||||
dead: ['FRONT_DEAD', 'DEAD', 'BACK_DEAD'],
|
||||
};
|
||||
// Reference CActorInstance::Move -> SetLoopMotion(RUN/WALK): a GetMotionKey miss returns early
|
||||
// and the current loop keeps playing. Only these states may be explained by that rule.
|
||||
const REFERENCE_KEEP_CURRENT_STATES = ['run'];
|
||||
const TILE_FILES = ['areadata.txt', 'height.raw', 'tile.raw', 'attr.atr'];
|
||||
|
||||
class Blocked extends Error {}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = { assets: 'assets', output: '', maps: ['outdoortrent/metin2_map_trent', 'outdoortrent02/metin2_map_trent02'] };
|
||||
const out = { assets: 'assets', output: 'build/playable/map-assets.json', maps: '', config: '', races: '' };
|
||||
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}`);
|
||||
if (a === '--help' || a === '-h') { console.log(USAGE); process.exit(0); }
|
||||
if (!a.startsWith('--') || i + 1 >= argv.length) throw new Blocked(`invalid argument: ${a}`);
|
||||
const k = a.slice(2).replaceAll('-', '_');
|
||||
if (!(k in out)) throw new Blocked(`unknown option: ${a}`);
|
||||
out[k] = argv[++i];
|
||||
}
|
||||
if (typeof out.maps === 'string') out.maps = out.maps.split(',').map(s => s.trim()).filter(Boolean);
|
||||
if (out.config && out.maps) throw new Blocked('use either --config or --maps, not both');
|
||||
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 posix = value => value.replaceAll('\\', '/');
|
||||
const read = file => fs.readFileSync(file, 'latin1');
|
||||
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 });
|
||||
// Mirrors fmt::AssetResolver: files are indexed by their asset-root relative path and by the
|
||||
// suffix after "ymir work/", with the pack priority deciding which real file wins.
|
||||
class AssetIndex {
|
||||
constructor(root) {
|
||||
this.root = root;
|
||||
this.byRel = new Map();
|
||||
this.byYmir = new Map();
|
||||
const priority = ['zone', 'terrain', 'etc', 'pc', 'tree', 'property', 'effect', 'monster', 'npc', 'season2', 'season3_eu'];
|
||||
const rank = rel => {
|
||||
const top = rel.split('/')[0].toLowerCase();
|
||||
const i = priority.indexOf(top);
|
||||
if (i >= 0) return i;
|
||||
return top.startsWith('metin2_patch') ? 2000 : 1000;
|
||||
};
|
||||
const files = [];
|
||||
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 files.push(posix(path.relative(root, full)));
|
||||
}
|
||||
};
|
||||
if (fs.existsSync(root)) visit(root);
|
||||
files.sort((a, b) => rank(a) - rank(b) || (a < b ? -1 : a > b ? 1 : 0));
|
||||
for (const rel of files) {
|
||||
const lower = rel.toLowerCase();
|
||||
if (!this.byRel.has(lower)) this.byRel.set(lower, rel);
|
||||
const at = lower.indexOf('ymir work/');
|
||||
if (at >= 0) {
|
||||
const key = lower.slice(at + 'ymir work/'.length);
|
||||
if (!this.byYmir.has(key)) this.byYmir.set(key, rel);
|
||||
}
|
||||
}
|
||||
this.files = files;
|
||||
this.dirs = new Set();
|
||||
for (const rel of files) {
|
||||
const parts = rel.toLowerCase().split('/');
|
||||
for (let i = 1; i < parts.length; i += 1) this.dirs.add(parts.slice(0, i).join('/'));
|
||||
}
|
||||
this.topDirs = [...new Set(files.filter(rel => rel.includes('/')).map(rel => rel.split('/')[0]))].sort();
|
||||
}
|
||||
return byId;
|
||||
|
||||
hasDir(relDir) { return this.dirs.has(AssetIndex.normalize(relDir)); }
|
||||
|
||||
static normalize(virtualPath) {
|
||||
let s = posix(String(virtualPath || '')).toLowerCase();
|
||||
if (/^[a-z]:/.test(s)) s = s.slice(2);
|
||||
return s.replace(/^\/+/, '').replace(/\/{2,}/g, '/');
|
||||
}
|
||||
|
||||
// Returns the asset-root relative real path, or ''.
|
||||
resolve(virtualPath) {
|
||||
const s = AssetIndex.normalize(virtualPath);
|
||||
if (!s) return '';
|
||||
const at = s.indexOf('ymir work/');
|
||||
if (at >= 0) return this.byYmir.get(s.slice(at + 'ymir work/'.length)) || '';
|
||||
return this.byRel.get(s) || '';
|
||||
}
|
||||
|
||||
rel(relPath) { return this.byRel.get(AssetIndex.normalize(relPath)) || ''; }
|
||||
abs(rel) { return path.join(this.root, rel); }
|
||||
}
|
||||
|
||||
function parseArea(file) {
|
||||
const text = read(file);
|
||||
function parseKeyValues(text) {
|
||||
const out = {};
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const m = line.trim().match(/^([A-Za-z_][\w]*)\s+(.*)$/);
|
||||
if (m && !(m[1].toLowerCase() in out)) out[m[1].toLowerCase()] = m[2].trim();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Property file: line 1 "YPRT", line 2 CRC, then key "value" pairs (formats/property.h).
|
||||
function parseProperty(index, rel) {
|
||||
const lines = read(index.abs(rel)).split(/\r?\n/);
|
||||
const crc = lines[1]?.trim();
|
||||
if (!/^\d+$/.test(crc || '')) return null;
|
||||
const kv = {};
|
||||
for (const line of lines.slice(2)) {
|
||||
const m = line.trim().match(/^(\S+)\s+"([^"]*)"/);
|
||||
if (m) kv[m[1].toLowerCase()] = m[2];
|
||||
}
|
||||
return { crc: Number(crc), file: rel, type: kv.propertytype || '', name: kv.propertyname || '', kv };
|
||||
}
|
||||
|
||||
function propertyRegistries(index) {
|
||||
const runtime = new Map();
|
||||
const outside = new Map();
|
||||
let collisions = 0;
|
||||
for (const rel of index.files) {
|
||||
if (!PROPERTY_EXTENSIONS.includes(path.extname(rel).toLowerCase())) continue;
|
||||
const prop = parseProperty(index, rel);
|
||||
if (!prop) continue;
|
||||
// PropertyRegistry::scan_list only accepts the top-level Property/ directory.
|
||||
const target = rel.toLowerCase().startsWith('property/') ? runtime : outside;
|
||||
if (target.has(prop.crc)) { if (target === runtime) collisions += 1; continue; }
|
||||
target.set(prop.crc, prop);
|
||||
}
|
||||
return { runtime, outside, collisions };
|
||||
}
|
||||
|
||||
function parseObjects(text) {
|
||||
const objects = [];
|
||||
const blockRe = /Start Object\d+\r?\n([^]*?)\r?\nEnd Object/g;
|
||||
for (const match of text.matchAll(blockRe)) {
|
||||
for (const match of text.matchAll(/Start Object\d+\r?\n([^]*?)\r?\nEnd Object/g)) {
|
||||
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] });
|
||||
if (lines.length < 2 || !/^\d+$/.test(lines[1])) continue;
|
||||
objects.push({ crc: Number(lines[1]), 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]);
|
||||
function parseTextureSet(text) {
|
||||
const entries = [];
|
||||
for (const match of text.matchAll(/Start Texture(\d+)\r?\n([^]*?)\r?\nEnd Texture/g)) {
|
||||
const file = match[2].match(/"([^"]*)"/)?.[1] ?? '';
|
||||
entries.push({ index: Number(match[1]), file });
|
||||
}
|
||||
return result;
|
||||
return entries;
|
||||
}
|
||||
|
||||
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 parseNpcList(index) {
|
||||
const result = new Map();
|
||||
for (const candidate of ['root/npclist.txt', 'npclist.txt', 'locale/npclist.txt']) {
|
||||
const rel = index.rel(candidate);
|
||||
if (!rel) continue;
|
||||
for (const line of read(index.abs(rel)).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 { file: rel, entries: result };
|
||||
}
|
||||
return { file: '', entries: result };
|
||||
}
|
||||
|
||||
function auditMap(index, registries, key, source, missing) {
|
||||
const note = (kind, ref, reason, extra = {}) => missing.push({ scope: `map:${key}`, kind, ref, reason, ...extra });
|
||||
const rootRel = index.rel(path.posix.join(key, 'setting.txt'));
|
||||
const map = { key, source, exists: false, setting: '', setting_values: {}, tiles: [], texture_set: null,
|
||||
environment: null, regen: null, object_count: 0, static_spt: [], static_gr2: [], effects: [], ambience: [],
|
||||
unresolved_properties: [], property_count: 0 };
|
||||
if (!rootRel) {
|
||||
note('map_setting', `${key}/Setting.txt`, 'map directory or Setting.txt not found under the asset root');
|
||||
return map;
|
||||
}
|
||||
map.exists = true;
|
||||
map.setting = rootRel;
|
||||
const dirRel = path.posix.dirname(rootRel);
|
||||
const setting = parseKeyValues(read(index.abs(rootRel)));
|
||||
const [sizeX, sizeY] = (setting.mapsize || '').split(/\s+/).map(Number);
|
||||
map.setting_values = { map_size: [sizeX || 0, sizeY || 0], base_position: (setting.baseposition || '').split(/\s+/).map(Number),
|
||||
cell_scale: Number(setting.cellscale || 0), texture_set: setting.textureset || '', environment: setting.environment || '' };
|
||||
if (!(sizeX > 0 && sizeY > 0)) note('map_setting', rootRel, 'MapSize is missing or invalid');
|
||||
|
||||
// Tiles: the runtime loads <tx*1000+ty padded to 6>/ for every MapSize cell (m2coord::tile_dir).
|
||||
const objects = [];
|
||||
const ambienceObjects = [];
|
||||
for (let tx = 0; tx < (sizeX || 0); tx += 1) {
|
||||
for (let ty = 0; ty < (sizeY || 0); ty += 1) {
|
||||
const tile = String(tx * 1000 + ty).padStart(6, '0');
|
||||
const files = Object.fromEntries(TILE_FILES.map(name => [name, Boolean(index.rel(`${dirRel}/${tile}/${name}`))]));
|
||||
map.tiles.push({ tile, files });
|
||||
for (const [name, present] of Object.entries(files)) if (!present) note('tile_file', `${dirRel}/${tile}/${name}`, 'tile file missing');
|
||||
const area = index.rel(`${dirRel}/${tile}/areadata.txt`);
|
||||
if (area) objects.push(...parseObjects(read(index.abs(area))).map(o => ({ ...o, tile })));
|
||||
const ambience = index.rel(`${dirRel}/${tile}/areaambiencedata.txt`);
|
||||
if (ambience) ambienceObjects.push(...parseObjects(read(index.abs(ambience))).map(o => ({ ...o, tile })));
|
||||
}
|
||||
}
|
||||
map.object_count = objects.length;
|
||||
map.ambience_object_count = ambienceObjects.length;
|
||||
|
||||
// TextureSet: runtime reads <assets>/textureset/<TextureSet lowercased>.
|
||||
if (setting.textureset) {
|
||||
const tsRel = index.rel(`textureset/${posix(setting.textureset).toLowerCase()}`);
|
||||
map.texture_set = { ref: setting.textureset, file: tsRel, textures: [] };
|
||||
if (!tsRel) note('texture_set', setting.textureset, 'TextureSet file not found under textureset/');
|
||||
else {
|
||||
map.texture_set.textures = parseTextureSet(read(index.abs(tsRel))).map(entry => {
|
||||
const real = entry.file ? index.resolve(entry.file) : '';
|
||||
if (entry.file && !real) note('terrain_texture', entry.file, 'TextureSet entry does not resolve');
|
||||
return { ...entry, resolved: real };
|
||||
});
|
||||
if (map.texture_set.textures.length === 0) note('texture_set', tsRel, 'TextureSet has no texture entries');
|
||||
}
|
||||
} else note('texture_set', rootRel, 'Setting.txt has no TextureSet');
|
||||
|
||||
if (setting.environment) {
|
||||
const real = index.resolve(`d:/ymir work/environment/${setting.environment}`);
|
||||
map.environment = { ref: setting.environment, resolved: real };
|
||||
if (!real) note('environment', setting.environment, 'environment .msenv does not resolve');
|
||||
}
|
||||
|
||||
const regenRel = index.rel(`${dirRel}/regen.txt`);
|
||||
const regenEntries = regenRel ? read(index.abs(regenRel)).split(/\r?\n/).filter(l => l.trim() && !l.trim().startsWith('//')).length : 0;
|
||||
map.regen = { file: regenRel, entries: regenEntries,
|
||||
note: regenEntries === 0 ? 'no client-side spawn data; server spawn membership is unknown' : 'client regen.txt is not proof of live server spawns' };
|
||||
|
||||
const manifestRel = index.rel('TreeGeometry/manifest.json');
|
||||
const manifest = manifestRel ? JSON.parse(fs.readFileSync(index.abs(manifestRel), 'utf8')) : { trees: {} };
|
||||
const nativeBySource = new Map(Object.values(manifest.trees || {}).map(item => [index.resolve(item.source) || AssetIndex.normalize(item.source), item]));
|
||||
|
||||
const groups = new Map();
|
||||
const counts = new Map();
|
||||
for (const o of [...objects, ...ambienceObjects]) counts.set(o.crc, (counts.get(o.crc) || 0) + 1);
|
||||
map.property_count = counts.size;
|
||||
for (const [crc, instances] of counts) {
|
||||
const prop = registries.runtime.get(crc);
|
||||
if (!prop) {
|
||||
const elsewhere = registries.outside.get(crc);
|
||||
const item = { property_id: crc, instances, found_outside_runtime_scope: elsewhere ? elsewhere.file : '' };
|
||||
map.unresolved_properties.push(item);
|
||||
note('property', String(crc), elsewhere ? 'CRC only exists outside Property/, which the runtime registry does not scan'
|
||||
: 'CRC not found in any property file', { instances });
|
||||
continue;
|
||||
}
|
||||
const base = { property_id: crc, instances, property_file: prop.file, property_type: prop.type, name: prop.name };
|
||||
const type = prop.type.toLowerCase();
|
||||
if (type === 'tree') {
|
||||
const ref = prop.kv.treefile || '';
|
||||
const spt = ref ? index.resolve(ref) : '';
|
||||
const native = spt ? nativeBySource.get(spt) : undefined;
|
||||
const glb = native ? index.rel(`TreeGeometry/${native.glb}`) : '';
|
||||
const item = { ...base, treefile: ref, spt, native_glb: glb, source_sha256: native?.source_sha256 || null };
|
||||
map.static_spt.push(item);
|
||||
if (!spt) note('tree_spt', ref || prop.file, 'treefile does not resolve', { instances });
|
||||
else if (!glb) note('tree_native_geometry', spt, 'no extracted TreeGeometry entry for this SPT', { instances });
|
||||
} else if (type === 'building' || type === 'dungeonblock') {
|
||||
const ref = prop.kv[type === 'building' ? 'buildingfile' : 'dungeonblockfile'] || '';
|
||||
const gr2 = ref ? index.resolve(ref) : '';
|
||||
map.static_gr2.push({ ...base, model: ref, gr2, sha256: gr2 ? sha256(index.abs(gr2)) : null });
|
||||
if (!gr2) note('building_gr2', ref || prop.file, 'model file does not resolve', { instances });
|
||||
} else if (type === 'effect') {
|
||||
const ref = prop.kv.effectfile || '';
|
||||
const mse = ref ? index.resolve(ref) : '';
|
||||
// Metin2World skips Effect objects for static rendering; still audit the file.
|
||||
map.effects.push({ ...base, effectfile: ref, mse, rendered_by_world_loader: false });
|
||||
if (!mse) note('effect_mse', ref || prop.file, 'effect file does not resolve', { instances });
|
||||
} else if (type === 'ambience') {
|
||||
const refs = (prop.kv.ambiencesoundvector || '').split(/[,;]/).map(s => s.trim()).filter(Boolean);
|
||||
const sounds = refs.map(ref => ({ ref, resolved: index.resolve(ref) || index.rel(ref) || index.rel(`sound/${ref}`) }));
|
||||
map.ambience.push({ ...base, sounds });
|
||||
for (const s of sounds) if (!s.resolved) note('ambience_sound', s.ref, 'ambience sound does not resolve', { instances });
|
||||
} else {
|
||||
map.unresolved_properties.push({ ...base, unsupported_type: true });
|
||||
note('property_type', prop.file, `unsupported property type "${prop.type}"`, { instances });
|
||||
}
|
||||
}
|
||||
map.static_spt_instances = map.static_spt.reduce((n, item) => n + item.instances, 0);
|
||||
map.static_gr2_instances = map.static_gr2.reduce((n, item) => n + item.instances, 0);
|
||||
return map;
|
||||
}
|
||||
|
||||
// Mirrors MobView._find_dir + the npclist stem segment fallback (bear_brown -> bear).
|
||||
function findMobDir(index, code) {
|
||||
const segs = code.split('_');
|
||||
for (let cut = segs.length; cut > 0; cut -= 1) {
|
||||
const folder = segs.slice(0, cut).join('_');
|
||||
const rels = ['ymir work/monster', 'ymir work/monster2', 'ymir work/npc', 'ymir work/npc2',
|
||||
'Monster/ymir work/monster', 'Monster/ymir work/monster2', 'NPC/ymir work/npc', 'NPC/ymir work/npc2'].map(r => `${r}/${folder}`);
|
||||
for (const rel of rels) if (index.hasDir(rel)) return rel;
|
||||
for (const sub of index.topDirs) for (const rel of rels) if (index.hasDir(`${sub}/${rel}`)) return `${sub}/${rel}`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function auditMob(index, race, code, missing, explained) {
|
||||
const scope = `race:${race}`;
|
||||
const note = (kind, ref, reason) => missing.push({ scope, kind, ref, reason });
|
||||
const record = { race, code, candidate_only: true, directory: '', files: {}, msm: null, textures: [], motions: [], states: {} };
|
||||
if (!code) { note('npclist', String(race), 'race is not listed in npclist'); return record; }
|
||||
const dir = findMobDir(index, code);
|
||||
record.directory = dir;
|
||||
if (!dir) { note('mob_directory', code, 'no monster/npc directory for this code'); return record; }
|
||||
const inDir = name => index.rel(`${dir}/${name}`);
|
||||
const gr2 = inDir(`${code}.gr2`) || inDir(`${path.posix.basename(dir)}.gr2`);
|
||||
const msmRel = inDir(`${code}.msm`);
|
||||
record.files = { gr2, msm: msmRel, motlist: inDir('motlist.txt') };
|
||||
record.source_sha256 = gr2 ? sha256(index.abs(gr2)) : null;
|
||||
if (!gr2) note('mob_gr2', `${dir}/${code}.gr2`, 'base model GR2 missing (dynamic skinned model)');
|
||||
if (msmRel) {
|
||||
const base = read(index.abs(msmRel)).match(/BaseModelFileName\s+"([^"]+)"/i)?.[1] || '';
|
||||
const resolved = base ? index.resolve(base) : '';
|
||||
record.msm = { file: msmRel, base_model: base, resolved, matches_runtime_gr2: Boolean(resolved && gr2 && resolved.toLowerCase() === gr2.toLowerCase()) };
|
||||
if (base && !resolved) note('msm_base_model', base, 'MSM BaseModelFileName does not resolve');
|
||||
} else note('mob_msm', `${dir}/${code}.msm`, 'MSM missing');
|
||||
const dirPrefix = `${dir.toLowerCase()}/`;
|
||||
record.textures = index.files.filter(f => f.toLowerCase().startsWith(dirPrefix) && !f.slice(dirPrefix.length).includes('/') && f.toLowerCase().endsWith('.dds'));
|
||||
if (record.textures.length === 0) note('mob_texture', dir, 'no .dds texture next to the model');
|
||||
|
||||
const motions = new Map();
|
||||
if (record.files.motlist) {
|
||||
for (const line of read(index.abs(record.files.motlist)).split(/\r?\n/)) {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts.length < 3 || !parts[2].toLowerCase().endsWith('.msa')) continue;
|
||||
const file = inDir(parts[2]);
|
||||
record.motions.push({ group: parts[0], name: parts[1], file: parts[2], exists: Boolean(file), weight: Number(parts[3] || 0) });
|
||||
if (!file) note('motion_file', `${dir}/${parts[2]}`, `motlist ${parts[1]} references a missing .msa`);
|
||||
else if (!motions.has(parts[1])) motions.set(parts[1], file);
|
||||
}
|
||||
} else note('motlist', `${dir}/motlist.txt`, 'motlist missing');
|
||||
|
||||
for (const [state, names] of Object.entries(STATE_MOTIONS)) {
|
||||
let entry = null;
|
||||
for (const name of names) {
|
||||
if (motions.has(name)) { entry = { motion: name, file: motions.get(name), fallback_reason: name === names[0] ? '' : `client_alias:${name}` }; break; }
|
||||
}
|
||||
if (!entry) {
|
||||
for (const name of names) {
|
||||
const file = inDir(`${name.toLowerCase()}.msa`);
|
||||
if (file) { entry = { motion: name, file, fallback_reason: `motlist_missing:${path.posix.basename(file)}` }; break; }
|
||||
}
|
||||
}
|
||||
if (!entry) {
|
||||
entry = { motion: '', file: '', fallback_reason: 'reference_keep_current_motion' };
|
||||
if (REFERENCE_KEEP_CURRENT_STATES.includes(state)) {
|
||||
explained.push({ scope, kind: 'motion', ref: state, reason: `no ${names.join('/')} motion; reference SetLoopMotion keeps the current loop`,
|
||||
needs_live_confirmation: 'a moving server instance of this race keeps its idle loop while its position changes' });
|
||||
} else note('motion', `${code}:${state}`, `no ${names.join('/')} motion for a required state`);
|
||||
}
|
||||
record.states[state] = entry;
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
function mapsFromConfig(configPath) {
|
||||
if (!fs.existsSync(configPath)) throw new Blocked(`config not found: ${configPath}`);
|
||||
let config;
|
||||
try { config = JSON.parse(fs.readFileSync(configPath, 'utf8')); } catch (error) { throw new Blocked(`config is not valid JSON: ${error.message}`); }
|
||||
const mapKey = String(config.map_key || '').trim();
|
||||
if (!mapKey) throw new Blocked('config map_key is empty; resolve it from the server/map fixture');
|
||||
if (mapKey.startsWith('/') || mapKey.includes('\\') || mapKey.split('/').includes('..')) throw new Blocked('config map_key must be a relative asset directory');
|
||||
return { maps: [mapKey], scenario_id: String(config.scenario_id || '') };
|
||||
}
|
||||
|
||||
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;
|
||||
if (!fs.existsSync(assets)) throw new Blocked(`asset root not found: ${assets}`);
|
||||
let mapSource = 'default';
|
||||
let maps = DEFAULT_MAPS;
|
||||
let scenarioId = '';
|
||||
if (options.config) {
|
||||
const fromConfig = mapsFromConfig(options.config);
|
||||
maps = fromConfig.maps;
|
||||
scenarioId = fromConfig.scenario_id;
|
||||
mapSource = 'config';
|
||||
} else if (options.maps) {
|
||||
maps = options.maps.split(',').map(s => s.trim()).filter(Boolean);
|
||||
mapSource = 'argument';
|
||||
}
|
||||
const index = new AssetIndex(assets);
|
||||
const registries = propertyRegistries(index);
|
||||
const missing = [];
|
||||
const explained = [];
|
||||
const mapReports = maps.map(key => auditMap(index, registries, key, mapSource, missing));
|
||||
const npc = parseNpcList(index);
|
||||
if (!npc.file) missing.push({ scope: 'global', kind: 'npclist', ref: 'root/npclist.txt', reason: 'npclist not found' });
|
||||
const races = options.races ? options.races.split(',').map(Number).filter(Number.isInteger)
|
||||
: [...Array(15)].map((_, i) => 2301 + i).filter(race => npc.entries.has(race));
|
||||
if (races.length === 0) missing.push({ scope: 'global', kind: 'tree_monster_candidates', ref: '2301-2315', reason: 'no candidate races found' });
|
||||
const mobs = races.map(race => auditMob(index, race, npc.entries.get(race) || '', missing, explained));
|
||||
|
||||
const report = {
|
||||
schema_version: 2, suite: 'playable-map-assets', generated_at: new Date().toISOString(),
|
||||
assets_root: assets, map_source: mapSource, scenario_id: scenarioId, npclist: npc.file,
|
||||
property_registry: { runtime_properties: registries.runtime.size, outside_runtime_scope: registries.outside.size, crc_collisions: registries.collisions },
|
||||
maps: mapReports, tree_monster_candidates: mobs,
|
||||
missing, explained,
|
||||
status: missing.length === 0 ? 'PASS' : 'FAIL',
|
||||
caveat: 'Static map AreaData and local resources only; tree monsters are candidates, not proof of live server spawns. Visual correctness requires the render tests and manual sign-off.',
|
||||
};
|
||||
const output = path.resolve(options.output);
|
||||
fs.mkdirSync(path.dirname(output), { recursive: true });
|
||||
fs.writeFileSync(output, `${JSON.stringify(report, null, 2)}\n`);
|
||||
console.log(`MAP_ASSETS ${JSON.stringify({ status: report.status, output, maps: mapReports.map(m => ({ key: m.key, exists: m.exists, objects: m.object_count,
|
||||
spt: m.static_spt_instances || 0, gr2: m.static_gr2_instances || 0, unresolved: m.unresolved_properties.length })),
|
||||
tree_monsters: mobs.length, missing: missing.length, explained: explained.length })}`);
|
||||
for (const item of missing) console.error(`MISSING ${item.scope} ${item.kind} ${item.ref}: ${item.reason}`);
|
||||
process.exitCode = missing.length === 0 ? 0 : 1;
|
||||
}
|
||||
|
||||
main();
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
if (error instanceof Blocked) {
|
||||
console.error(`MAP_ASSETS BLOCKED: ${error.message}`);
|
||||
process.exitCode = 2;
|
||||
} else {
|
||||
console.error(`MAP_ASSETS ERROR: ${error.stack || error}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env node
|
||||
/* MAP-01 audit regression on a throw-away asset tree. Proves that unresolved
|
||||
* property CRCs, missing model/texture/motion files and an empty config map_key
|
||||
* are reported (exit 1 / 2) instead of passing, and that a resource-level
|
||||
* RUN/WALK gap is only "explained" by the reference keep-current-motion rule. */
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
const script = path.join(path.dirname(new URL(import.meta.url).pathname), 'audit_playable_maps.mjs');
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mt-map-audit.'));
|
||||
let failures = 0;
|
||||
const check = (ok, label) => { if (ok) console.log(`ok - ${label}`); else { failures += 1; console.error(`not ok - ${label}`); } };
|
||||
|
||||
const write = (rel, text) => { const file = path.join(root, rel); fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, text); };
|
||||
const object = (i, crc) => `Start Object${String(i).padStart(3, '0')}\n 100.0 -100.0 0.0\n ${crc}\n 0.0#0.0#0.0\n 0\nEnd Object\n`;
|
||||
const prop = (crc, type, key, value) => `YPRT\n${crc}\n${key}\t\t"${value}"\npropertyname\t\t"p${crc}"\npropertytype\t\t"${type}"\n`;
|
||||
const motlist = names => names.map(([name, file]) => `GENERAL ${name} ${file} 50`).join('\n') + '\n';
|
||||
|
||||
function buildFixture({ broken }) {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
write('mapa/setting.txt', 'ScriptType\tMapSetting\nCellScale\t200\nMapSize\t1\t1\nBasePosition\t0\t0\nTextureSet\ttextureset\\mapa.txt\nEnvironment\ta.msenv\n');
|
||||
write('mapa/000000/height.raw', 'x');
|
||||
write('mapa/000000/tile.raw', 'x');
|
||||
write('mapa/000000/attr.atr', 'x');
|
||||
const objects = [object(0, 11), object(1, 22), object(2, 33)];
|
||||
if (broken) objects.push(object(3, 44), object(4, 55));
|
||||
write('mapa/000000/areadata.txt', `AreaDataFile\n\nObjectCount ${objects.length}\n${objects.join('')}`);
|
||||
write('textureset/textureset/mapa.txt', 'TextureSet\n\nTextureCount 1\n\nStart Texture001\n "d:\\ymir work\\terrainmaps\\a\\field.dds"\n 5.0\nEnd Texture001\n');
|
||||
write('Terrain/ymir work/terrainmaps/a/field.dds', 'x');
|
||||
write('ETC/ymir work/environment/a.msenv', 'x');
|
||||
write('Property/property/t/tree.prt', prop(11, 'Tree', 'treefile', 'd:/ymir work/tree/a.spt'));
|
||||
write('Tree/ymir work/tree/a.spt', 'spt');
|
||||
write('TreeGeometry/manifest.json', JSON.stringify({ trees: { h: { source: 'Tree/ymir work/tree/a.spt', glb: 'h.glb', source_sha256: 'h' } } }));
|
||||
write('TreeGeometry/h.glb', 'glb');
|
||||
write('Property/property/b/house.prb', prop(22, 'Building', 'buildingfile', 'd:/ymir work/zone/house.gr2'));
|
||||
if (!broken) write('Zone/ymir work/zone/house.gr2', 'gr2');
|
||||
write('Property/property/e/fx.pre', prop(33, 'Effect', 'effectfile', 'd:/ymir work/effect/fx.mse'));
|
||||
write('Effect/ymir work/effect/fx.mse', 'mse');
|
||||
// 44 only exists outside Property/ (the runtime registry never sees it); 55 exists nowhere.
|
||||
write('Zone/property/stray.prb', prop(44, 'Building', 'buildingfile', 'd:/ymir work/zone/house.gr2'));
|
||||
write('root/npclist.txt', '2301\tent_a\n2302\tent_b\n');
|
||||
const mob = (code, names) => {
|
||||
const dir = `monster2/ymir work/monster2/${code}`;
|
||||
write(`${dir}/${code}.gr2`, 'gr2');
|
||||
write(`${dir}/${code}.dds`, 'dds');
|
||||
write(`${dir}/${code}.msm`, `BaseModelFileName "d:\\ymir work\\monster2\\${code}\\${code}.gr2"\n`);
|
||||
write(`${dir}/motlist.txt`, motlist(names));
|
||||
for (const [, file] of names) write(`${dir}/${file}`, 'msa');
|
||||
};
|
||||
mob('ent_a', [['WAIT', '00.msa'], ['NORMAL_ATTACK', '20.msa'], ['FRONT_DAMAGE', '30.msa'], ['FRONT_DEAD', '31.msa']]);
|
||||
const b = [['WAIT', '00.msa'], ['RUN', '10.msa'], ['NORMAL_ATTACK', '20.msa'], ['FRONT_DAMAGE', '30.msa']];
|
||||
if (!broken) b.push(['FRONT_DEAD', '31.msa']);
|
||||
mob('ent_b', b);
|
||||
}
|
||||
|
||||
function run(args) {
|
||||
const output = path.join(root, 'out', 'map-assets.json');
|
||||
const result = spawnSync(process.execPath, [script, '--assets', root, '--output', output, ...args], { encoding: 'utf8' });
|
||||
const report = fs.existsSync(output) ? JSON.parse(fs.readFileSync(output, 'utf8')) : null;
|
||||
return { code: result.status, report, stderr: result.stderr };
|
||||
}
|
||||
|
||||
try {
|
||||
buildFixture({ broken: false });
|
||||
let r = run(['--maps', 'mapa']);
|
||||
check(r.code === 0 && r.report?.status === 'PASS', `complete fixture passes (exit ${r.code}) ${r.stderr}`);
|
||||
check(r.report?.explained.some(e => e.scope === 'race:2301' && e.ref === 'run'), 'missing RUN/WALK is explained by the reference rule');
|
||||
const map = r.report?.maps[0];
|
||||
check(map?.static_spt[0]?.native_glb === 'TreeGeometry/h.glb' && map?.static_gr2[0]?.gr2 === 'Zone/ymir work/zone/house.gr2', 'tree SPT and building GR2 resolve');
|
||||
check(map?.texture_set?.textures[0]?.resolved === 'Terrain/ymir work/terrainmaps/a/field.dds', 'TextureSet entries resolve through ymir work');
|
||||
check(map?.effects[0]?.mse === 'Effect/ymir work/effect/fx.mse', 'effect property resolves');
|
||||
check(r.report?.tree_monster_candidates.every(m => m.candidate_only && m.msm?.matches_runtime_gr2), 'MSM base model matches runtime GR2 and races stay candidates');
|
||||
|
||||
buildFixture({ broken: true });
|
||||
r = run(['--maps', 'mapa']);
|
||||
const kinds = new Set((r.report?.missing || []).map(m => `${m.kind}:${m.ref}`));
|
||||
check(r.code === 1 && r.report?.status === 'FAIL', `broken fixture fails (exit ${r.code})`);
|
||||
check(kinds.has('building_gr2:d:/ymir work/zone/house.gr2'), 'missing building GR2 is listed');
|
||||
check(kinds.has('property:44') && r.report?.maps[0].unresolved_properties.some(p => p.property_id === 44 && p.found_outside_runtime_scope === 'Zone/property/stray.prb'), 'CRC outside Property/ is listed with its location');
|
||||
check(kinds.has('property:55'), 'unknown CRC is listed');
|
||||
check(kinds.has('motion:ent_b:dead'), 'missing death motion is not explained away');
|
||||
check(!kinds.has('motion:ent_a:run'), 'RUN/WALK gap is explained, not missing');
|
||||
|
||||
fs.rmSync(path.join(root, 'mapa', '000000', 'attr.atr'));
|
||||
r = run(['--maps', 'mapa']);
|
||||
check((r.report?.missing || []).some(m => m.kind === 'tile_file'), 'missing tile file is listed');
|
||||
|
||||
write('scenario.local.json', JSON.stringify({ scenario_id: 'x', map_key: '' }));
|
||||
r = run(['--config', path.join(root, 'scenario.local.json')]);
|
||||
check(r.code === 2 && /map_key is empty/.test(r.stderr), `empty config map_key is BLOCKED (exit ${r.code})`);
|
||||
write('scenario.local.json', JSON.stringify({ scenario_id: 'x', map_key: '../mapa' }));
|
||||
r = run(['--config', path.join(root, 'scenario.local.json')]);
|
||||
check(r.code === 2, 'map_key escaping the asset root is BLOCKED');
|
||||
write('scenario.local.json', JSON.stringify({ scenario_id: 'x', map_key: 'mapa' }));
|
||||
r = run(['--config', path.join(root, 'scenario.local.json')]);
|
||||
check(r.report?.map_source === 'config' && r.report?.maps[0].key === 'mapa', 'config map_key is the audited map');
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
console.log(`audit_playable_maps_test: failures=${failures}`);
|
||||
process.exitCode = failures ? 1 : 0;
|
||||
Executable
+202
@@ -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;超时 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 &
|
||||
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"
|
||||
@@ -8,7 +8,10 @@
|
||||
# ./script/live_smoke_test.sh --no-reconnect
|
||||
#
|
||||
# 账号和密码优先从环境变量读取;未提供时安全地交互输入,不写入脚本。
|
||||
# 服务器地址与客户端一样经 project/net/serverinfo.gd 解析,不在脚本里写死。
|
||||
# 不要在本脚本中启用 set -x。
|
||||
set -euo pipefail
|
||||
set +x
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
REPO="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
@@ -37,6 +40,7 @@ usage() {
|
||||
环境变量:
|
||||
MT_ACCOUNT / MT_PASSWORD 可预先提供账号密码,未提供时交互输入
|
||||
MT_CHAR_SLOT=N 指定角色槽位;不设置时自动选择第一个角色
|
||||
MT_GODOT 解析 serverinfo 用的 godot,默认 godot
|
||||
EOF
|
||||
}
|
||||
|
||||
@@ -61,7 +65,8 @@ if [ -f "$CONFIG_FILE" ]; then
|
||||
fi
|
||||
|
||||
if [ -z "${MT_ACCOUNT:-}" ]; then
|
||||
read -r -p "账号: " MT_ACCOUNT
|
||||
read -r -s -p "账号: " MT_ACCOUNT
|
||||
echo
|
||||
fi
|
||||
if [ -z "${MT_PASSWORD:-}" ]; then
|
||||
read -r -s -p "密码: " MT_PASSWORD
|
||||
@@ -85,9 +90,19 @@ if [ ! -x "$EXECUTABLE" ]; then
|
||||
fi
|
||||
|
||||
echo "== check server ports =="
|
||||
for port in 11000 13002; do
|
||||
if ! nc -G 3 -z 192.168.21.203 "$port" >/dev/null 2>&1; then
|
||||
echo "服务器端口不可达:192.168.21.203:$port" >&2
|
||||
# 登录页默认 server 0 / 第一个频道;与 AppFlow 同一 ServerInfo 解析。
|
||||
ADDRESS_LINE="$("${MT_GODOT:-godot}" --headless --path project --script print_server_address.gd 2>/dev/null | grep '^SERVER_ADDRESS ' || true)"
|
||||
if [ -z "$ADDRESS_LINE" ]; then
|
||||
echo "无法从 serverinfo 解析服务器地址" >&2
|
||||
exit 2
|
||||
fi
|
||||
read -r AUTH_HOST AUTH_PORT GAME_HOST GAME_PORT <<<"$(node -e '
|
||||
const a = JSON.parse(process.argv[1].slice("SERVER_ADDRESS ".length));
|
||||
process.stdout.write([a.auth_host, a.auth_port, a.game_host, a.game_port].join(" "));
|
||||
' "$ADDRESS_LINE")"
|
||||
for endpoint in "$AUTH_HOST:$AUTH_PORT" "$GAME_HOST:$GAME_PORT"; do
|
||||
if ! nc -G 3 -z "${endpoint%:*}" "${endpoint##*:}" >/dev/null 2>&1; then
|
||||
echo "服务器端口不可达:$endpoint" >&2
|
||||
exit 3
|
||||
fi
|
||||
done
|
||||
@@ -111,7 +126,8 @@ echo "== run live smoke =="
|
||||
echo "log: $LOG"
|
||||
echo "report: $REPORT"
|
||||
set +e
|
||||
"$EXECUTABLE" 2>&1 | tee "$LOG"
|
||||
# 日志落盘前脱敏;PIPESTATUS[0] 仍是 APP 的真实退出码。
|
||||
env -u MT_ASSETS "$EXECUTABLE" 2>&1 | node script/redact_stream.mjs | tee "$LOG"
|
||||
APP_STATUS=${PIPESTATUS[0]}
|
||||
set -e
|
||||
|
||||
@@ -140,6 +156,12 @@ if [ "$APP_STATUS" -ne 0 ]; then
|
||||
exit "$APP_STATUS"
|
||||
fi
|
||||
|
||||
# 与 package_render_test.sh / validate_playable_report.mjs 相同的完整日志门禁。
|
||||
if grep -nE 'SCRIPT ERROR|Parse Error|^ERROR:|leaked at exit|shaders of type .* were never freed' "$LOG"; then
|
||||
echo "LIVE_SMOKE RESULT: FAIL (log exit gate)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$REPORT_STATUS" != "PASS" ]; then
|
||||
echo "LIVE_SMOKE RESULT: FAIL (report status=$REPORT_STATUS)" >&2
|
||||
exit 1
|
||||
|
||||
@@ -10,8 +10,8 @@ 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
|
||||
if [ "$status" -ne 0 ] || ! grep -qE '^PKGRENDER: PASS$' "$output_dir/client.log" || \
|
||||
grep -qE 'SCRIPT ERROR|Parse 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
|
||||
|
||||
Executable
+26
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
# Print the candidate package identity as JSON (engine/extension/pck sha256 +
|
||||
# architectures). Shared by run_client_gate.sh and playable_test.sh so the
|
||||
# per-run build.json and the release manifest are computed the same way.
|
||||
set -euo pipefail
|
||||
|
||||
if [ "${1:-}" = "--help" ] || [ "$#" -ne 1 ]; then
|
||||
echo "用法: script/playable_build_info.sh APP" >&2
|
||||
[ "${1:-}" = "--help" ] && exit 0
|
||||
exit 2
|
||||
fi
|
||||
app="$1"
|
||||
engine="$app/Contents/MacOS/mtgodot-poc"
|
||||
pck="$app/Contents/Resources/mtgodot-poc.pck"
|
||||
dylib="$(find "$app/Contents/Frameworks" -maxdepth 1 -type f -name '*.dylib' 2>/dev/null | LC_ALL=C sort | head -n 1)"
|
||||
|
||||
sha256_of() {
|
||||
if [ -n "$1" ] && [ -f "$1" ]; then shasum -a 256 "$1" | awk '{print $1}'; else printf ''; fi
|
||||
}
|
||||
archs=""
|
||||
if [ -f "$engine" ]; then archs="$(lipo -archs "$engine" 2>/dev/null | tr ' ' '+' || true)"; fi
|
||||
|
||||
node -e '
|
||||
const [engine, extension, pck, arch] = process.argv.slice(1);
|
||||
process.stdout.write(JSON.stringify({ engine_sha256: engine, extension_sha256: extension, pck_sha256: pck, arch }) + "\n");
|
||||
' "$(sha256_of "$engine")" "$(sha256_of "$dylib")" "$(sha256_of "$pck")" "$archs"
|
||||
@@ -1,14 +0,0 @@
|
||||
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)
|
||||
@@ -0,0 +1,225 @@
|
||||
#!/usr/bin/env node
|
||||
/* STB-DISCONNECT-01 local transport-fault proxy for ONE test client connection.
|
||||
*
|
||||
* playable_fault_proxy.mjs --route LISTEN_PORT=UPSTREAM_HOST:UPSTREAM_PORT [--route ...]
|
||||
* --events events.jsonl --log fault-proxy.jsonl [--run-id RUN_ID] [--poll-ms 200]
|
||||
*
|
||||
* - Listens on 127.0.0.1 only and forwards bytes to the upstream test server. The
|
||||
* scenario's server.* points the client at these loopback ports; nothing else on
|
||||
* the machine is touched: no routes, firewall rules, other clients or production.
|
||||
* - Tails the client's events.jsonl and acts on each `fault_request` exactly once:
|
||||
* close destroy the proxied connections ("connection closed")
|
||||
* unreachable destroy them AND stop listening for payload.unreachable_seconds
|
||||
* (capped at 20 s) so reconnect attempts are refused, then listen again
|
||||
* - Every action is appended to --log as JSON (wall_ms, action, type, attempt, ...).
|
||||
* The client, not this proxy, decides whether recovery happened.
|
||||
* - Exits 0 on SIGTERM/SIGINT after closing its sockets. It never signals any process.
|
||||
*
|
||||
* Known limit: a server-side warp that hands out a direct game address bypasses the proxy.
|
||||
* exit: 0 stopped, 2 usage error or a listen port could not be opened. */
|
||||
import fs from 'node:fs';
|
||||
import net from 'node:net';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const LISTEN_HOST = '127.0.0.1';
|
||||
const MAX_UNREACHABLE_S = 20;
|
||||
|
||||
function usage(stream = process.stderr) {
|
||||
stream.write('usage: playable_fault_proxy.mjs --route LISTEN_PORT=HOST:PORT [--route ...] --events events.jsonl --log fault-proxy.jsonl [--run-id ID] [--poll-ms 200]\n'
|
||||
+ ' listens on 127.0.0.1 only; acts on fault_request events (close | unreachable)\n');
|
||||
}
|
||||
|
||||
const isPort = (value) => Number.isInteger(value) && value >= 1 && value <= 65535;
|
||||
|
||||
export function parseArgs(argv) {
|
||||
const options = { routes: [], poll_ms: 200 };
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
if (arg === '--help') { options.help = true; continue; }
|
||||
const value = argv[i + 1];
|
||||
if (value === undefined) throw new Error(`missing value for ${arg}`);
|
||||
i += 1;
|
||||
if (arg === '--route') {
|
||||
const match = /^(\d+)=([A-Za-z0-9.:-]+):(\d+)$/.exec(value);
|
||||
if (!match) throw new Error(`bad --route ${value}`);
|
||||
const route = { listen: Number(match[1]), host: match[2], port: Number(match[3]) };
|
||||
if (!isPort(route.listen) || !isPort(route.port)) throw new Error(`bad port in --route ${value}`);
|
||||
if (['127.0.0.1', 'localhost', '::1'].includes(route.host) && route.port === route.listen) throw new Error(`route ${value} loops back to itself`);
|
||||
if (options.routes.some((r) => r.listen === route.listen)) throw new Error(`duplicate listen port ${route.listen}`);
|
||||
options.routes.push(route);
|
||||
} else if (arg === '--events') options.events = value;
|
||||
else if (arg === '--log') options.log = value;
|
||||
else if (arg === '--run-id') options.run_id = value;
|
||||
else if (arg === '--poll-ms') options.poll_ms = Number(value);
|
||||
else throw new Error(`unknown option ${arg}`);
|
||||
}
|
||||
if (!options.help && (options.routes.length === 0 || !options.events || !options.log
|
||||
|| !Number.isInteger(options.poll_ms) || options.poll_ms < 20)) throw new Error('--route, --events and --log are required');
|
||||
return options;
|
||||
}
|
||||
|
||||
/** Incremental JSONL reader: returns complete new lines since the last call. */
|
||||
export function tailer(file) {
|
||||
let offset = 0;
|
||||
let partial = '';
|
||||
return () => {
|
||||
let size;
|
||||
try { size = fs.statSync(file).size; } catch { return []; }
|
||||
if (size < offset) { offset = 0; partial = ''; }
|
||||
if (size === offset) return [];
|
||||
const fd = fs.openSync(file, 'r');
|
||||
const buffer = Buffer.alloc(size - offset);
|
||||
fs.readSync(fd, buffer, 0, buffer.length, offset);
|
||||
fs.closeSync(fd);
|
||||
offset = size;
|
||||
const text = partial + buffer.toString('utf8');
|
||||
const lines = text.split('\n');
|
||||
partial = lines.pop();
|
||||
return lines.filter((line) => line.trim() !== '');
|
||||
};
|
||||
}
|
||||
|
||||
export class FaultProxy {
|
||||
constructor(options) {
|
||||
this.options = options;
|
||||
this.servers = new Map();
|
||||
this.pairs = new Set();
|
||||
this.seen = new Set();
|
||||
this.unreachableTimer = null;
|
||||
this.stopped = false;
|
||||
this.read = tailer(options.events);
|
||||
}
|
||||
|
||||
log(action, detail = {}) {
|
||||
fs.appendFileSync(this.options.log, `${JSON.stringify({ wall_ms: Date.now(), action, ...detail })}\n`);
|
||||
}
|
||||
|
||||
listen(route) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer((client) => this.accept(route, client));
|
||||
server.once('error', reject);
|
||||
server.listen(route.listen, LISTEN_HOST, () => {
|
||||
server.off('error', reject);
|
||||
this.servers.set(route.listen, server);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
accept(route, client) {
|
||||
const upstream = net.connect(route.port, route.host);
|
||||
const pair = { client, upstream };
|
||||
this.pairs.add(pair);
|
||||
const drop = () => {
|
||||
if (!this.pairs.delete(pair)) return;
|
||||
client.destroy();
|
||||
upstream.destroy();
|
||||
};
|
||||
client.on('error', drop).on('close', drop);
|
||||
upstream.on('error', drop).on('close', drop);
|
||||
client.pipe(upstream);
|
||||
upstream.pipe(client);
|
||||
this.log('accepted', { listen_port: route.listen, open: this.pairs.size });
|
||||
}
|
||||
|
||||
dropAll() {
|
||||
const count = this.pairs.size;
|
||||
for (const pair of [...this.pairs]) {
|
||||
this.pairs.delete(pair);
|
||||
pair.client.destroy();
|
||||
pair.upstream.destroy();
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/** Stops accepting, drops open connections (server.close waits for them), returns the drop count. */
|
||||
async closeListeners() {
|
||||
const closing = [...this.servers.values()].map((server) => new Promise((resolve) => server.close(resolve)));
|
||||
this.servers.clear();
|
||||
const dropped = this.dropAll();
|
||||
await Promise.all(closing);
|
||||
return dropped;
|
||||
}
|
||||
|
||||
async start() {
|
||||
for (const route of this.options.routes) await this.listen(route);
|
||||
this.log('listening', { listen_ports: this.options.routes.map((r) => r.listen) });
|
||||
this.poller = setInterval(() => { this.poll().catch((error) => this.log('error', { reason: String(error.message).slice(0, 120) })); }, this.options.poll_ms);
|
||||
}
|
||||
|
||||
async poll() {
|
||||
// setInterval does not wait for an async fault; never run two polls at once.
|
||||
if (this.polling) return;
|
||||
this.polling = true;
|
||||
try { await this.drain(); } finally { this.polling = false; }
|
||||
}
|
||||
|
||||
async drain() {
|
||||
for (const line of this.read()) {
|
||||
let event;
|
||||
try { event = JSON.parse(line); } catch { continue; }
|
||||
if (event?.kind !== 'fault_request') continue;
|
||||
if (this.options.run_id && event.run_id !== this.options.run_id) { this.log('ignored_foreign_run', {}); continue; }
|
||||
const payload = event.payload ?? {};
|
||||
const key = `${event.monotonic_us}:${payload.type}:${payload.attempt}`;
|
||||
if (this.seen.has(key)) continue;
|
||||
this.seen.add(key);
|
||||
await this.fault(payload);
|
||||
}
|
||||
}
|
||||
|
||||
async fault(payload) {
|
||||
const detail = { type: payload.type, attempt: payload.attempt, round: payload.round };
|
||||
if (payload.type === 'close') {
|
||||
this.log('close', { ...detail, closed: this.dropAll() });
|
||||
} else if (payload.type === 'unreachable') {
|
||||
if (this.unreachableTimer) { this.log('rejected', { ...detail, reason: 'unreachable window already active' }); return; }
|
||||
const seconds = Math.min(MAX_UNREACHABLE_S, Math.max(1, Number.parseInt(payload.unreachable_seconds, 10) || 1));
|
||||
const closed = await this.closeListeners();
|
||||
this.log('unreachable_start', { ...detail, closed, seconds });
|
||||
this.unreachableTimer = setTimeout(async () => {
|
||||
this.unreachableTimer = null;
|
||||
if (this.stopped) return;
|
||||
try {
|
||||
for (const route of this.options.routes) await this.listen(route);
|
||||
this.log('unreachable_end', detail);
|
||||
} catch (error) {
|
||||
this.log('error', { ...detail, reason: `relisten failed: ${error.code || error.message}` });
|
||||
}
|
||||
}, seconds * 1000);
|
||||
} else {
|
||||
this.log('rejected', { ...detail, reason: 'unknown fault type' });
|
||||
}
|
||||
}
|
||||
|
||||
async stop() {
|
||||
if (this.stopped) return;
|
||||
this.stopped = true;
|
||||
clearInterval(this.poller);
|
||||
if (this.unreachableTimer) clearTimeout(this.unreachableTimer);
|
||||
this.dropAll();
|
||||
await this.closeListeners();
|
||||
this.log('stopped', {});
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let options;
|
||||
try { options = parseArgs(process.argv.slice(2)); } catch (error) { usage(); console.error(error.message); return 2; }
|
||||
if (options.help) { usage(process.stdout); return 0; }
|
||||
const proxy = new FaultProxy(options);
|
||||
try { await proxy.start(); } catch (error) {
|
||||
console.error(`FAULT PROXY: cannot listen (${error.code || error.message})`);
|
||||
await proxy.stop();
|
||||
return 2;
|
||||
}
|
||||
await new Promise((resolve) => {
|
||||
process.once('SIGTERM', resolve);
|
||||
process.once('SIGINT', resolve);
|
||||
});
|
||||
await proxy.stop();
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) process.exitCode = await main();
|
||||
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env node
|
||||
/* STB-DISCONNECT-01 fault proxy regression. Uses only 127.0.0.1 ephemeral ports and a
|
||||
* local echo server as the "upstream"; never contacts the test server. Proves that the
|
||||
* proxy forwards bytes, closes the connection on `close`, refuses connections for the
|
||||
* `unreachable` window and listens again, acts on each request once, ignores foreign
|
||||
* run_ids, and stops cleanly on SIGTERM. */
|
||||
import fs from 'node:fs';
|
||||
import net from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { parseArgs } from './playable_fault_proxy.mjs';
|
||||
|
||||
const script = path.join(path.dirname(new URL(import.meta.url).pathname), 'playable_fault_proxy.mjs');
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mt-fault-proxy.'));
|
||||
let failures = 0;
|
||||
const check = (ok, label) => { if (ok) console.log(`ok - ${label}`); else { failures += 1; console.error(`not ok - ${label}`); } };
|
||||
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
async function until(predicate, timeoutMs, stepMs = 50) {
|
||||
const end = Date.now() + timeoutMs;
|
||||
while (Date.now() < end) {
|
||||
if (await predicate()) return true;
|
||||
await delay(stepMs);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function freePort() {
|
||||
return new Promise((resolve) => {
|
||||
const server = net.createServer();
|
||||
server.listen(0, '127.0.0.1', () => { const { port } = server.address(); server.close(() => resolve(port)); });
|
||||
});
|
||||
}
|
||||
|
||||
function connect(port) {
|
||||
return new Promise((resolve) => {
|
||||
const socket = net.connect(port, '127.0.0.1');
|
||||
const state = { socket, closed: false, data: '', error: null };
|
||||
socket.on('data', (chunk) => { state.data += chunk.toString(); });
|
||||
socket.on('close', () => { state.closed = true; });
|
||||
socket.once('connect', () => resolve(state));
|
||||
socket.once('error', (error) => { state.error = error.code; resolve(state); });
|
||||
});
|
||||
}
|
||||
|
||||
const logRows = (file) => (fs.existsSync(file) ? fs.readFileSync(file, 'utf8').trim().split('\n').filter(Boolean).map((l) => JSON.parse(l)) : []);
|
||||
const actions = (file, action) => logRows(file).filter((row) => row.action === action);
|
||||
let usClock = 1000;
|
||||
const request = (file, type, attempt, extra = {}) => fs.appendFileSync(file, `${JSON.stringify({ monotonic_us: (usClock += 1000), run_id: 'run-a',
|
||||
case_id: 'STB-DISCONNECT-01', connection_epoch: 2, stage: 'FAULT', kind: 'fault_request', actor_vid: 0, target_vid: 0,
|
||||
payload: { type, attempt, round: 0, timeout_ms: 30000, unreachable_seconds: type === 'unreachable' ? 2 : 0, ...extra } })}\n`);
|
||||
|
||||
// Argument contract.
|
||||
{
|
||||
const help = spawnSync(process.execPath, [script, '--help'], { encoding: 'utf8' });
|
||||
check(help.status === 0 && /127\.0\.0\.1/.test(help.stdout), '--help documents loopback-only listening');
|
||||
for (const [label, args] of [
|
||||
['no routes', ['--events', 'e', '--log', 'l']],
|
||||
['malformed route', ['--route', 'abc', '--events', 'e', '--log', 'l']],
|
||||
['route to itself', ['--route', '4000=127.0.0.1:4000', '--events', 'e', '--log', 'l']],
|
||||
['port out of range', ['--route', '70000=10.0.0.1:1', '--events', 'e', '--log', 'l']],
|
||||
['unknown option', ['--route', '4000=10.0.0.1:1', '--events', 'e', '--log', 'l', '--listen-host', '0.0.0.0']],
|
||||
]) {
|
||||
const run = spawnSync(process.execPath, [script, ...args], { encoding: 'utf8' });
|
||||
check(run.status === 2, `usage error exits 2: ${label}`);
|
||||
}
|
||||
check(parseArgs(['--route', '4000=192.0.2.1:11000', '--events', 'e', '--log', 'l']).routes[0].host === '192.0.2.1', 'route parses upstream host');
|
||||
}
|
||||
|
||||
// Live behaviour against a local echo upstream.
|
||||
const upstream = net.createServer((socket) => socket.pipe(socket));
|
||||
await new Promise((resolve) => upstream.listen(0, '127.0.0.1', resolve));
|
||||
const upstreamPort = upstream.address().port;
|
||||
const listenPort = await freePort();
|
||||
const events = path.join(root, 'events.jsonl');
|
||||
const log = path.join(root, 'fault-proxy.jsonl');
|
||||
const proxy = spawn(process.execPath, [script, '--route', `${listenPort}=127.0.0.1:${upstreamPort}`, '--events', events,
|
||||
'--log', log, '--run-id', 'run-a', '--poll-ms', '50'], { stdio: ['ignore', 'ignore', 'pipe'] });
|
||||
let exited = null;
|
||||
proxy.on('exit', (code) => { exited = code; });
|
||||
|
||||
check(await until(() => actions(log, 'listening').length === 1, 5000), 'proxy logs listening before any fault');
|
||||
let a = await connect(listenPort);
|
||||
a.socket.write('ping');
|
||||
check(await until(() => a.data === 'ping', 2000), 'bytes are forwarded to the upstream and back');
|
||||
|
||||
request(events, 'close', 1);
|
||||
check(await until(() => a.closed, 2000), 'close request drops the proxied connection');
|
||||
check(actions(log, 'close')[0]?.closed === 1, 'close action records the dropped connection count');
|
||||
let b = await connect(listenPort);
|
||||
b.socket.write('again');
|
||||
check(!b.error && await until(() => b.data === 'again', 2000), 'the listener stays open after close');
|
||||
|
||||
// The same line again (same monotonic_us/type/attempt) is not a second fault.
|
||||
const lines = fs.readFileSync(events, 'utf8').trim().split('\n');
|
||||
fs.appendFileSync(events, `${lines.at(-1)}\n`);
|
||||
await delay(300);
|
||||
check(!b.closed && actions(log, 'close').length === 1, 'a repeated request line is acted on once');
|
||||
|
||||
fs.appendFileSync(events, `${JSON.stringify({ monotonic_us: (usClock += 1000), run_id: 'other-run', kind: 'fault_request', payload: { type: 'close', attempt: 9 } })}\n`);
|
||||
await delay(300);
|
||||
check(!b.closed && actions(log, 'ignored_foreign_run').length === 1, 'fault requests from another run are ignored');
|
||||
|
||||
// Partial line: only acted on once the newline arrives.
|
||||
const partial = JSON.stringify({ monotonic_us: (usClock += 1000), run_id: 'run-a', kind: 'fault_request',
|
||||
payload: { type: 'unreachable', attempt: 1, unreachable_seconds: 2 } });
|
||||
fs.appendFileSync(events, partial.slice(0, 20));
|
||||
await delay(300);
|
||||
check(!b.closed, 'a half-written event line is not parsed early');
|
||||
const started = Date.now();
|
||||
fs.appendFileSync(events, `${partial.slice(20)}\n`);
|
||||
check(await until(() => b.closed, 2000), 'unreachable request drops the connection');
|
||||
await until(() => actions(log, 'unreachable_start').length === 1, 1000);
|
||||
const refused = await connect(listenPort);
|
||||
check(refused.error === 'ECONNREFUSED', `connections are refused during the unreachable window (${refused.error})`);
|
||||
check(await until(() => actions(log, 'unreachable_end').length === 1, 5000), 'listener comes back after unreachable_seconds');
|
||||
const elapsed = Date.now() - started;
|
||||
check(elapsed >= 1900 && elapsed < 4500, `unreachable window lasts about 2 s (${elapsed} ms)`);
|
||||
const c = await connect(listenPort);
|
||||
c.socket.write('back');
|
||||
check(!c.error && await until(() => c.data === 'back', 2000), 'forwarding works again after the window');
|
||||
c.socket.destroy();
|
||||
|
||||
proxy.kill('SIGTERM');
|
||||
check(await until(() => exited !== null, 5000) && exited === 0, 'SIGTERM stops the proxy with exit 0');
|
||||
check(actions(log, 'stopped').length === 1, 'stop is logged');
|
||||
const after = await connect(listenPort);
|
||||
check(after.error === 'ECONNREFUSED', 'no listener remains after stop');
|
||||
check(!fs.readFileSync(log, 'utf8').includes('ping'), 'the action log never contains payload bytes');
|
||||
|
||||
// A port already in use is a startup error, not a silent run.
|
||||
{
|
||||
const busy = net.createServer();
|
||||
await new Promise((resolve) => busy.listen(0, '127.0.0.1', resolve));
|
||||
const run = spawnSync(process.execPath, [script, '--route', `${busy.address().port}=127.0.0.1:${upstreamPort}`, '--events', events,
|
||||
'--log', path.join(root, 'busy.jsonl')], { encoding: 'utf8', timeout: 10000 });
|
||||
check(run.status === 2 && /cannot listen/.test(run.stderr), 'busy listen port exits 2');
|
||||
busy.close();
|
||||
}
|
||||
|
||||
upstream.close();
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
console.log(failures === 0 ? 'PASS: playable_fault_proxy_test' : `FAIL: playable_fault_proxy_test (${failures})`);
|
||||
process.exitCode = failures === 0 ? 0 : 1;
|
||||
+394
-29
@@ -1,44 +1,409 @@
|
||||
#!/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.
|
||||
# INF-02 runner/exit-gate regression. Builds a throw-away ad-hoc signed arm64
|
||||
# app whose executable execs test/playable/fake_client.sh, listens on a local
|
||||
# 127.0.0.1 port, and drives script/run_client_gate.sh through PASS and every
|
||||
# failure path. Never starts the real client and never contacts the test server.
|
||||
set -euo pipefail
|
||||
set +x
|
||||
cd "$(dirname "$0")/.."
|
||||
repo="$PWD"
|
||||
tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/mt-playable-gate.XXXXXX")"
|
||||
trap 'rm -R "$tmp_dir"' EXIT
|
||||
listener_pid=""
|
||||
cleanup() {
|
||||
if [ -n "$listener_pid" ]; then kill "$listener_pid" 2>/dev/null || true; fi
|
||||
if [ -z "${MT_GATE_TEST_KEEP:-}" ]; then rm -R "$tmp_dir"; else echo "kept $tmp_dir"; fi
|
||||
}
|
||||
trap cleanup 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
|
||||
failures=0
|
||||
pass() { echo "ok - $*"; }
|
||||
fail() { echo "not ok - $*" >&2; failures=$((failures + 1)); }
|
||||
expect_code() { # expected actual label
|
||||
if [ "$1" -eq "$2" ]; then pass "$3 (exit $2)"; else fail "$3: expected exit $1, got $2"; fi
|
||||
}
|
||||
|
||||
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
|
||||
# ---------- validator unit checks on static fixtures ----------
|
||||
fixture="$repo/test/playable"
|
||||
validate_fixture() { # report log process_code timed_out [extra...]
|
||||
local report="$1" log="$2" code="$3" timed="$4"
|
||||
shift 4
|
||||
node script/validate_playable_report.mjs --report "$report" --output "$tmp_dir/sealed.json" --log "$log" \
|
||||
--process-code "$code" --timed-out "$timed" --run-id fixture-run --suite playable \
|
||||
--required-cases "$tmp_dir/v/required-cases.json" --build "$tmp_dir/v/build.json" \
|
||||
--events "$tmp_dir/v/events.jsonl" --started-ms "$(($(date +%s) * 1000))" --require-pass "$@" >/dev/null 2>&1
|
||||
}
|
||||
reset_fixture() {
|
||||
rm -rf "$tmp_dir/v"
|
||||
mkdir -p "$tmp_dir/v"
|
||||
cp "$fixture/report.valid.json" "$tmp_dir/v/client-report.json"
|
||||
cp "$fixture/required-cases.fixture.json" "$tmp_dir/v/required-cases.json"
|
||||
cp "$fixture/build.fixture.json" "$tmp_dir/v/build.json"
|
||||
cp "$fixture/events.valid.jsonl" "$tmp_dir/v/events.jsonl"
|
||||
}
|
||||
mutate() { # js expression body operating on `r`
|
||||
node -e 'const fs=require("fs");const f=process.argv[1];const r=JSON.parse(fs.readFileSync(f,"utf8"));'"$1"';fs.writeFileSync(f,JSON.stringify(r));' "$tmp_dir/v/client-report.json"
|
||||
}
|
||||
code_of() { set +e; "$@"; local c=$?; set -e; echo "$c"; }
|
||||
quiet() { "$@" >/dev/null 2>&1; }
|
||||
|
||||
reset_fixture
|
||||
expect_code 0 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: valid fixture"
|
||||
sealed_checked="$(node -e 'const r=require(process.argv[1]);process.stdout.write(String(r.exit_gate.checked===true&&r.exit_gate.process_code===0))' "$tmp_dir/sealed.json")"
|
||||
[ "$sealed_checked" = "true" ] && pass "validator: exit_gate sealed" || fail "validator: exit_gate not sealed"
|
||||
cp "$fixture/report.blocked.json" "$tmp_dir/v/client-report.json"
|
||||
expect_code 2 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: BLOCKED case is not releasable"
|
||||
reset_fixture
|
||||
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 1 0)" "validator: nonzero child exit"
|
||||
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 1)" "validator: timed out"
|
||||
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/rid-warning.log" 0 0)" "validator: RID warning in log"
|
||||
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$tmp_dir/missing.log" 0 0)" "validator: missing log"
|
||||
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/missing.json" "$fixture/clean.log" 0 0)" "validator: missing client report"
|
||||
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0 --redactor-code 143)" "validator: redactor killed"
|
||||
mutate 'r.schema_version=2'
|
||||
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: unknown schema"
|
||||
reset_fixture; mutate 'r.suite="full"'
|
||||
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: suite mismatch"
|
||||
reset_fixture; mutate 'r.cases=[];r.required_cases=[];r.coverage={required:0,passed:0}'
|
||||
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: empty cases"
|
||||
reset_fixture; mutate 'r.cases.push(r.cases[0]);r.coverage.required=2;r.coverage.passed=2'
|
||||
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: duplicate case"
|
||||
reset_fixture; mutate 'r.build.pck_sha256=""'
|
||||
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: missing hash"
|
||||
reset_fixture; mutate 'r.build.engine_sha256="d".repeat(64)'
|
||||
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: build differs from runner build.json"
|
||||
reset_fixture; mutate 'r.cases[0].evidence=["shot.png"]'
|
||||
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: missing evidence file"
|
||||
reset_fixture; mutate 'r.cases[0].evidence=["../../etc/hosts"]'
|
||||
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: evidence path escape"
|
||||
reset_fixture; mutate 'r.event_count=5'
|
||||
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: event_count mismatch"
|
||||
reset_fixture; sed 's/fixture-run/other-run/' "$fixture/events.valid.jsonl" >"$tmp_dir/v/events.jsonl"
|
||||
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: foreign run_id in events"
|
||||
reset_fixture; node -e 'require("fs").writeFileSync(process.argv[1], JSON.stringify({schema_version:1,suite:"playable",cases:["FIXTURE-01","FIXTURE-02"]}))' "$tmp_dir/v/required-cases.json"
|
||||
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: required count differs from case list"
|
||||
reset_fixture; touch -t 200001010000 "$tmp_dir/v/client-report.json"
|
||||
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: stale report mtime"
|
||||
reset_fixture; mutate 'r.cases[0].reason="fixture-secret-value"'
|
||||
code="$(MT_PASSWORD=fixture-secret-value code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)"
|
||||
expect_code 1 "$code" "validator: credential literal in client report"
|
||||
if grep -q "fixture-secret-value" "$tmp_dir/sealed.json"; then fail "validator: secret copied into sealed report"; else pass "validator: secret not persisted"; fi
|
||||
|
||||
# ---------- log redactor ----------
|
||||
redacted="$(printf 'user fixture-acct pass fixture-acct-pw\n' | MT_ACCOUNT=fixture-acct MT_PASSWORD=fixture-acct-pw node script/redact_stream.mjs)"
|
||||
[ "$redacted" = "user [redacted] pass [redacted]" ] && pass "redactor: longest literal first" || fail "redactor output: $redacted"
|
||||
|
||||
# ---------- scripts never trace ----------
|
||||
if grep -nE '^[[:space:]]*set[[:space:]]+-[a-zA-Z]*x' script/run_client_gate.sh script/playable_test.sh script/playable_soak.sh script/live_smoke_test.sh script/forest_map_render_test.sh 2>/dev/null; then
|
||||
fail "a launch script enables set -x"
|
||||
else
|
||||
pass "launch scripts never enable set -x"
|
||||
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
|
||||
# ---------- example config is rejected ----------
|
||||
if MT_PLAYABLE_VALIDATE_CONFIG="$repo/test/playable/scenario.example.json" MT_PLAYABLE_ASSETS="$tmp_dir" \
|
||||
"${MT_GODOT:-godot}" --headless --path project --script playable_config_gate.gd >/dev/null 2>&1; then
|
||||
fail "empty example scenario accepted"
|
||||
else
|
||||
pass "empty example scenario rejected"
|
||||
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
|
||||
# ---------- fake signed arm64 app ----------
|
||||
app="$tmp_dir/Fake.app"
|
||||
mkdir -p "$app/Contents/MacOS" "$app/Contents/Frameworks" "$app/Contents/Resources"
|
||||
cat >"$tmp_dir/fake.c" <<'EOF'
|
||||
#include <stdlib.h>
|
||||
#include <unistd.h>
|
||||
int main(void) {
|
||||
const char *script = getenv("MT_FAKE_CHILD_SCRIPT");
|
||||
if (script == NULL) return 64;
|
||||
execl("/bin/sh", "sh", script, (char *)0);
|
||||
return 127;
|
||||
}
|
||||
EOF
|
||||
printf 'int fake_extension(void) { return 1; }\n' >"$tmp_dir/ext.c"
|
||||
cc -arch arm64 -o "$app/Contents/MacOS/mtgodot-poc" "$tmp_dir/fake.c"
|
||||
cc -arch arm64 -dynamiclib -o "$app/Contents/Frameworks/libmtgodot.macos.template_release.dylib" "$tmp_dir/ext.c"
|
||||
printf 'fake pck\n' >"$app/Contents/Resources/mtgodot-poc.pck"
|
||||
cat >"$app/Contents/Info.plist" <<'EOF'
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0"><dict>
|
||||
<key>CFBundleExecutable</key><string>mtgodot-poc</string>
|
||||
<key>CFBundleIdentifier</key><string>test.mt.playable-gate</string>
|
||||
<key>CFBundlePackageType</key><string>APPL</string>
|
||||
</dict></plist>
|
||||
EOF
|
||||
codesign --force -s - "$app/Contents/Frameworks/libmtgodot.macos.template_release.dylib" >/dev/null 2>&1
|
||||
codesign --force -s - "$app" >/dev/null 2>&1
|
||||
|
||||
# Local listener so the TCP precheck has a real endpoint; nothing reads from it.
|
||||
node -e '
|
||||
const server = require("node:net").createServer((socket) => socket.destroy());
|
||||
server.listen(0, "127.0.0.1", () => console.log(server.address().port));
|
||||
' >"$tmp_dir/port" &
|
||||
listener_pid=$!
|
||||
for _ in 1 2 3 4 5 6 7 8 9 10; do [ -s "$tmp_dir/port" ] && break; sleep 0.2; done
|
||||
port="$(tr -d '\n' <"$tmp_dir/port")"
|
||||
printf 'fixture\t127.0.0.1\t%s\t127.0.0.1\t%s\t1\t10\t0\n' "$port" "$port" >"$tmp_dir/serverlist.txt"
|
||||
map_key="outdoortrent/metin2_map_trent"
|
||||
mkdir -p "$tmp_dir/assets/$map_key"
|
||||
printf 'ScriptType\tMapSetting\nBasePosition\t0\t0\nMapSize\t2\t2\n' >"$tmp_dir/assets/$map_key/Setting.txt"
|
||||
node -e '
|
||||
const [file, port, key] = process.argv.slice(1);
|
||||
require("node:fs").writeFileSync(file, JSON.stringify({
|
||||
schema_version: 1, scenario_id: "gate-fixture", protocol: "classic",
|
||||
server: { server_index: 0, channel: 1, auth_host: "127.0.0.1", auth_port: Number(port), game_host: "127.0.0.1", game_port: Number(port) },
|
||||
character_slot: 0, map_key: key, waypoints_cm: [[1000, 2000], [1600, 2000]],
|
||||
allowed_mob_vnums: [101], allowed_drop_vnums: [19],
|
||||
skill_cases: [{ case_id: "strike", skill_id: 5, target: "enemy", repeats: 2, required_evidence: ["cast_started", "damage"] }],
|
||||
resolution: [1280, 720], loops: 1, timeout_seconds: 900,
|
||||
}, null, 2));
|
||||
' "$tmp_dir/scenario.local.json" "$port" "$map_key"
|
||||
|
||||
export MT_ACCOUNT="gate-fixture-account"
|
||||
export MT_PASSWORD="gate-fixture-password"
|
||||
export MT_FAKE_CHILD_SCRIPT="$repo/test/playable/fake_client.sh"
|
||||
run_gate() { # scenario output [extra args...]
|
||||
local scenario="$1" out="$2"
|
||||
shift 2
|
||||
MT_FAKE_SCENARIO="$scenario" bash script/run_client_gate.sh --app "$app" --config "$tmp_dir/scenario.local.json" \
|
||||
--output "$out" --assets "$tmp_dir/assets" --serverlist "$tmp_dir/serverlist.txt" "$@" >"$out.stdout" 2>&1
|
||||
}
|
||||
report_status() { node -e 'try{process.stdout.write(require(process.argv[1]).status)}catch{process.stdout.write("MISSING")}' "$1/report.json"; }
|
||||
|
||||
expect_code 0 "$(code_of run_gate pass "$tmp_dir/run-pass")" "runner: PASS"
|
||||
[ "$(report_status "$tmp_dir/run-pass")" = "PASS" ] && pass "runner: sealed report PASS" || fail "runner: sealed report not PASS"
|
||||
expect_code 0 "$(code_of run_gate pass "$tmp_dir/run-pass-2")" "runner: second independent PASS"
|
||||
expect_code 0 "$(code_of run_gate pass "$tmp_dir/run-full" --suite full)" "runner: full suite PASS"
|
||||
expect_code 1 "$(code_of run_gate exit3 "$tmp_dir/run-exit3")" "runner: exit 3 after client PASS"
|
||||
[ "$(report_status "$tmp_dir/run-exit3")" = "FAIL" ] && pass "runner: exit 3 sealed FAIL" || fail "runner: exit 3 not sealed FAIL"
|
||||
expect_code 1 "$(code_of run_gate signal "$tmp_dir/run-signal")" "runner: child killed by signal"
|
||||
expect_code 1 "$(code_of run_gate stale "$tmp_dir/run-stale")" "runner: stale run_id"
|
||||
expect_code 1 "$(code_of run_gate no-report "$tmp_dir/run-noreport")" "runner: missing client report"
|
||||
expect_code 1 "$(code_of run_gate rid "$tmp_dir/run-rid")" "runner: RID warning after PASS"
|
||||
expect_code 1 "$(code_of run_gate leak "$tmp_dir/run-leak")" "runner: leak warning after PASS"
|
||||
expect_code 1 "$(code_of run_gate missing-case "$tmp_dir/run-missing-case")" "runner: missing required case"
|
||||
expect_code 1 "$(code_of run_gate missing-evidence "$tmp_dir/run-missing-evidence")" "runner: missing evidence file"
|
||||
expect_code 2 "$(code_of run_gate blocked "$tmp_dir/run-blocked")" "runner: BLOCKED cases"
|
||||
expect_code 0 "$(code_of run_gate print-secret "$tmp_dir/run-secret")" "runner: printed credentials are redacted before disk"
|
||||
if grep -rqF -e "$MT_ACCOUNT" -e "$MT_PASSWORD" "$tmp_dir/run-secret" "$tmp_dir/run-secret.stdout"; then
|
||||
fail "runner: credential literal reached the run directory"
|
||||
else
|
||||
pass "runner: no credential literal in run directory"
|
||||
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
|
||||
expect_code 124 "$(code_of run_gate hang "$tmp_dir/run-hang" --timeout-seconds 2)" "runner: TERM-ignoring child is killed"
|
||||
if grep -qE '^TIMEOUT term_grace_s=1[0-9] killed=1$' "$tmp_dir/run-hang/gate.log"; then
|
||||
pass "runner: TERM grace honoured before KILL"
|
||||
else
|
||||
fail "runner: timeout sequence: $(tr '\n' ' ' <"$tmp_dir/run-hang/gate.log" 2>/dev/null)"
|
||||
fi
|
||||
[ "$(report_status "$tmp_dir/run-hang")" = "FAIL" ] && pass "runner: timeout sealed FAIL" || fail "runner: timeout not sealed FAIL"
|
||||
|
||||
expect_code 1 "$(code_of run_gate fifo-holder "$tmp_dir/run-fifo")" "runner: grandchild holding the log pipe"
|
||||
|
||||
set +e
|
||||
MT_FAKE_SCENARIO=exit3 bash script/run_client_gate.sh --app "$app" --config "$tmp_dir/scenario.local.json" \
|
||||
--output "$tmp_dir/run-pipe" --assets "$tmp_dir/assets" --serverlist "$tmp_dir/serverlist.txt" 2>&1 | cat >/dev/null
|
||||
piped=$?
|
||||
set -e
|
||||
expect_code 1 "$piped" "runner: exit code survives | cat under pipefail"
|
||||
|
||||
mkdir -p "$tmp_dir/run-nocreds"
|
||||
code="$(code_of quiet env -u MT_ACCOUNT -u MT_PASSWORD MT_FAKE_SCENARIO=pass bash script/run_client_gate.sh --app "$app" \
|
||||
--config "$tmp_dir/scenario.local.json" --output "$tmp_dir/run-nocreds" --assets "$tmp_dir/assets" \
|
||||
--serverlist "$tmp_dir/serverlist.txt" </dev/null)"
|
||||
expect_code 2 "$code" "runner: no credentials and no TTY"
|
||||
[ ! -e "$tmp_dir/run-nocreds/client.log" ] && pass "runner: client not started without credentials" || fail "runner: client started without credentials"
|
||||
|
||||
expect_code 2 "$(code_of run_gate pass "$tmp_dir/run-pass")" "runner: refuses a non-empty output directory"
|
||||
expect_code 2 "$(code_of env MT_PLAYABLE_REQUIRED_ARCH=x86_64 bash -c 'MT_FAKE_SCENARIO=pass bash script/run_client_gate.sh --app "$1" --config "$2" --output "$3" --assets "$4" --serverlist "$5" >/dev/null 2>&1' _ "$app" "$tmp_dir/scenario.local.json" "$tmp_dir/run-arch" "$tmp_dir/assets" "$tmp_dir/serverlist.txt")" "runner: missing required architecture"
|
||||
printf '0\t127.0.0.1\t1\t127.0.0.1\t1\t1\t10\t0\n' >"$tmp_dir/serverlist-drift.txt"
|
||||
expect_code 2 "$(code_of env bash -c 'MT_FAKE_SCENARIO=pass bash script/run_client_gate.sh --app "$1" --config "$2" --output "$3" --assets "$4" --serverlist "$5" >/dev/null 2>&1' _ "$app" "$tmp_dir/scenario.local.json" "$tmp_dir/run-drift" "$tmp_dir/assets" "$tmp_dir/serverlist-drift.txt")" "runner: config address differs from serverlist"
|
||||
[ ! -e "$tmp_dir/run-drift/client.log" ] && pass "runner: client not started on address drift" || fail "runner: client started on address drift"
|
||||
cp -R "$app" "$tmp_dir/Tampered.app"
|
||||
printf 'tampered\n' >>"$tmp_dir/Tampered.app/Contents/Resources/mtgodot-poc.pck"
|
||||
expect_code 2 "$(code_of env bash -c 'MT_FAKE_SCENARIO=pass bash script/run_client_gate.sh --app "$1" --config "$2" --output "$3" --assets "$4" --serverlist "$5" >/dev/null 2>&1' _ "$tmp_dir/Tampered.app" "$tmp_dir/scenario.local.json" "$tmp_dir/run-tampered" "$tmp_dir/assets" "$tmp_dir/serverlist.txt")" "runner: broken signature"
|
||||
|
||||
# ---------- release aggregation: explicit run list only ----------
|
||||
release="$tmp_dir/release"
|
||||
mkdir -p "$release"
|
||||
cp -R "$tmp_dir/run-pass" "$release/run-1"
|
||||
cp -R "$tmp_dir/run-pass-2" "$release/run-2"
|
||||
cp -R "$tmp_dir/run-exit3" "$release/run-bad"
|
||||
bash script/playable_build_info.sh "$app" >"$tmp_dir/candidate.json"
|
||||
write_manifest() { # required runs-json [candidate-override-js]
|
||||
node -e '
|
||||
const fs = require("node:fs");
|
||||
const [dir, required, runs, candidateFile, tweak] = process.argv.slice(1);
|
||||
const candidate = JSON.parse(fs.readFileSync(candidateFile, "utf8"));
|
||||
if (tweak) eval(tweak);
|
||||
const list = JSON.parse(runs).map((name) => ({ run_id: JSON.parse(fs.readFileSync(`${dir}/${name}/report.json`, "utf8")).run_id, suite: "playable", path: name }));
|
||||
fs.writeFileSync(`${dir}/release-manifest.json`, JSON.stringify({ schema_version: 1, candidate, required_runs: { playable: Number(required) }, runs: list }));
|
||||
' "$release" "$1" "$2" "$tmp_dir/candidate.json" "${3:-}"
|
||||
}
|
||||
release_code() { code_of quiet node script/validate_playable_report.mjs --release-dir "$release"; }
|
||||
write_manifest 2 '["run-1","run-2"]'
|
||||
expect_code 0 "$(release_code)" "release: two listed PASS runs"
|
||||
write_manifest 2 '["run-1"]'
|
||||
expect_code 1 "$(release_code)" "release: unlisted PASS runs are never counted"
|
||||
write_manifest 2 '["run-1","run-2","run-bad"]'
|
||||
expect_code 1 "$(release_code)" "release: a listed FAIL run fails the release"
|
||||
write_manifest 2 '["run-1","run-2"]' 'candidate.pck_sha256 = "e".repeat(64)'
|
||||
expect_code 1 "$(release_code)" "release: runs from another candidate"
|
||||
write_manifest 2 '["run-1","run-1"]'
|
||||
expect_code 1 "$(release_code)" "release: the same run listed twice"
|
||||
node -e 'const fs=require("fs");const f=process.argv[1];const m=JSON.parse(fs.readFileSync(f));m.runs[0].path="../run-pass";fs.writeFileSync(f,JSON.stringify(m));' "$release/release-manifest.json"
|
||||
expect_code 1 "$(release_code)" "release: run path escapes the release directory"
|
||||
|
||||
mkdir -p "$release/run-blocked"
|
||||
printf 'BLOCKED 缺少 MT_ACCOUNT/MT_PASSWORD,且不是交互终端\n' >"$release/run-blocked/gate.log"
|
||||
node -e 'const fs=require("fs");const f=process.argv[1];const m=JSON.parse(fs.readFileSync(f));m.runs=[m.runs[1],{run_id:"run-blocked-unsealed",suite:"playable",path:"run-blocked"}];m.required_runs={playable:2};fs.writeFileSync(f,JSON.stringify(m));' "$release/release-manifest.json"
|
||||
expect_code 2 "$(release_code)" "release: precondition-blocked run is BLOCKED, not PASS"
|
||||
|
||||
# ---------- batch entry: manifest written from the runs it just made ----------
|
||||
expect_code 0 "$(code_of quiet env MT_FAKE_SCENARIO=pass bash script/playable_test.sh --app "$app" --config "$tmp_dir/scenario.local.json" \
|
||||
--output "$tmp_dir/batch" --suite playable --repeat 2 --assets "$tmp_dir/assets" --serverlist "$tmp_dir/serverlist.txt")" "batch: two runs aggregate to PASS"
|
||||
batch_runs="$(node -e 'const m=require(process.argv[1]);process.stdout.write(m.runs.map((r)=>r.path).join(","))' "$tmp_dir/batch/release-manifest.json")"
|
||||
[ "$batch_runs" = "run-1,run-2" ] && pass "batch: manifest lists exactly its own runs" || fail "batch: manifest runs $batch_runs"
|
||||
expect_code 1 "$(code_of quiet env MT_FAKE_SCENARIO=rid bash script/playable_test.sh --app "$app" --config "$tmp_dir/scenario.local.json" \
|
||||
--output "$tmp_dir/batch-rid" --suite playable --assets "$tmp_dir/assets" --serverlist "$tmp_dir/serverlist.txt")" "batch: a failing run fails the batch"
|
||||
expect_code 2 "$(code_of quiet env -u MT_ACCOUNT -u MT_PASSWORD bash script/playable_test.sh --app "$app" --config "$tmp_dir/scenario.local.json" \
|
||||
--output "$tmp_dir/batch-nocreds" </dev/null)" "batch: no credentials and no TTY"
|
||||
|
||||
# ---------- MAP-02 forest render runner (offline, same exit gate) ----------
|
||||
run_forest() { # scenario output [extra args...]
|
||||
local scenario="$1" out="$2"
|
||||
shift 2
|
||||
MT_FAKE_SCENARIO="$scenario" bash script/forest_map_render_test.sh --app "$app" --maps "$map_key" --output "$out" "$@" >"$out.stdout" 2>&1
|
||||
}
|
||||
expect_code 0 "$(code_of run_forest no-creds "$tmp_dir/forest-pass")" "forest: PASS without credentials in the child"
|
||||
forest_cases="$(node -e 'process.stdout.write(require(process.argv[1]).cases.join(","))' "$tmp_dir/forest-pass/required-cases.json")"
|
||||
[ "$forest_cases" = "MAP-FOREST-CONFIG,MAP-FOREST-LOAD-metin2_map_trent,MAP-FOREST-VP-metin2_map_trent-flat,MAP-FOREST-VP-metin2_map_trent-slope,MAP-FOREST-VP-metin2_map_trent-dense,MAP-FOREST-VP-metin2_map_trent-warp,MAP-FOREST-MOTION-metin2_map_trent" ] \
|
||||
&& pass "forest: required cases follow required_case_ids()" || fail "forest: required cases $forest_cases"
|
||||
grep -q "人工签核" "$tmp_dir/forest-pass.stdout" && pass "forest: manual screenshot sign-off stays pending" || fail "forest: sign-off reminder missing"
|
||||
expect_code 2 "$(code_of run_forest blocked "$tmp_dir/forest-blocked")" "forest: BLOCKED viewpoints are not releasable"
|
||||
expect_code 1 "$(code_of run_forest missing-case "$tmp_dir/forest-missing-case")" "forest: missing required case"
|
||||
expect_code 1 "$(code_of run_forest leak "$tmp_dir/forest-leak")" "forest: leak warning after PASS"
|
||||
expect_code 124 "$(code_of run_forest hang "$tmp_dir/forest-hang" --timeout-seconds 2)" "forest: TERM-ignoring child is killed"
|
||||
mkdir -p "$tmp_dir/forest-nomap"
|
||||
expect_code 2 "$(code_of quiet env MT_FAKE_SCENARIO=pass bash script/forest_map_render_test.sh --app "$app" --output "$tmp_dir/forest-nomap")" "forest: no map key"
|
||||
[ ! -e "$tmp_dir/forest-nomap/client.log" ] && pass "forest: client not started without a map" || fail "forest: client started without a map"
|
||||
expect_code 2 "$(code_of run_forest pass "$tmp_dir/forest-races" --races 2301,abc)" "forest: invalid race list"
|
||||
expect_code 2 "$(code_of run_forest pass "$tmp_dir/forest-pass")" "forest: refuses a non-empty output directory"
|
||||
|
||||
# ---------- STB-01 soak runner (offline; fake client, no real 2 h run) ----------
|
||||
soak_config() { # output-file server-port [faults-js]
|
||||
node -e '
|
||||
const fs = require("node:fs");
|
||||
const [base, out, port, faults] = process.argv.slice(1);
|
||||
const c = JSON.parse(fs.readFileSync(base, "utf8"));
|
||||
c.timeout_seconds = 8100;
|
||||
c.server.auth_port = Number(port); c.server.game_port = Number(port);
|
||||
c.soak = { duration_seconds: 7200, rest_seconds: 30, warmup_rounds: 1, min_rounds: 10, reconnects: 10, exits: 10,
|
||||
resolutions: { sizes: [[1280, 720], [1440, 900], [1600, 1000]], switches_per_size: 10 },
|
||||
warp: { status: "unconfirmed" }, faults: { status: "unconfirmed" } };
|
||||
if (faults) c.soak.faults = JSON.parse(faults);
|
||||
fs.writeFileSync(out, JSON.stringify(c, null, 2));
|
||||
' "$tmp_dir/scenario.local.json" "$1" "$2" "${3:-}"
|
||||
}
|
||||
soak_config "$tmp_dir/soak.local.json" "$port"
|
||||
run_soak_gate() { # scenario output config serverlist [extra args...]
|
||||
local scenario="$1" out="$2" config="$3" list="$4"
|
||||
shift 4
|
||||
MT_FAKE_SCENARIO="$scenario" bash script/run_client_gate.sh --app "$app" --config "$config" --output "$out" \
|
||||
--assets "$tmp_dir/assets" --serverlist "$list" --suite soak --allow-gameplay "$@" >"$out.stdout" 2>&1
|
||||
}
|
||||
mkdir -p "$tmp_dir/soak-short"
|
||||
expect_code 2 "$(code_of run_soak_gate soak-pass "$tmp_dir/soak-short" "$tmp_dir/soak.local.json" "$tmp_dir/serverlist.txt" --timeout-seconds 900)" "soak runner: timeout below duration+900"
|
||||
[ ! -e "$tmp_dir/soak-short/client.log" ] && pass "soak runner: client not started with a short timeout" || fail "soak runner: client started with a short timeout"
|
||||
mkdir -p "$tmp_dir/soak-noblock"
|
||||
expect_code 2 "$(code_of run_soak_gate soak-pass "$tmp_dir/soak-noblock" "$tmp_dir/scenario.local.json" "$tmp_dir/serverlist.txt" --timeout-seconds 8100)" "soak runner: scenario without a soak block"
|
||||
[ ! -e "$tmp_dir/soak-noblock/client.log" ] && pass "soak runner: client not started without a soak block" || fail "soak runner: client started without a soak block"
|
||||
|
||||
expect_code 2 "$(code_of run_soak_gate soak-pass "$tmp_dir/soak-run" "$tmp_dir/soak.local.json" "$tmp_dir/serverlist.txt" --timeout-seconds 8100)" "soak runner: fake client without soak rounds is BLOCKED, not PASS"
|
||||
soak_seal="$(node -e '
|
||||
const fs = require("node:fs");
|
||||
const dir = process.argv[1];
|
||||
const r = JSON.parse(fs.readFileSync(`${dir}/report.json`, "utf8"));
|
||||
const memory = (r.runner_cases || []).find((c) => c.id === "STB-MEMORY-01");
|
||||
const rows = fs.readFileSync(`${dir}/rss.jsonl`, "utf8").trim().split("\n").filter(Boolean).length;
|
||||
process.stdout.write([r.status, memory ? memory.status : "none", rows > 0 ? "rss" : "no-rss", r.exit_gate.checked].join(","));
|
||||
' "$tmp_dir/soak-run" 2>/dev/null || echo unreadable)"
|
||||
[ "$soak_seal" = "BLOCKED,BLOCKED,rss,true" ] && pass "soak runner: RSS sampled for the child and STB-MEMORY-01 sealed" || fail "soak runner: seal $soak_seal"
|
||||
[ ! -e "$tmp_dir/soak-run/fault-proxy.jsonl" ] && pass "soak runner: no fault proxy while faults are unconfirmed" || fail "soak runner: proxy started for unconfirmed faults"
|
||||
|
||||
# Confirmed faults: client points at a loopback proxy port, proxy forwards to the local listener.
|
||||
proxy_port="$(node -e 'const s=require("node:net").createServer();s.listen(0,"127.0.0.1",()=>{const p=s.address().port;s.close(()=>process.stdout.write(String(p)));})')"
|
||||
printf 'fixture\t127.0.0.1\t%s\t127.0.0.1\t%s\t1\t10\t0\n' "$proxy_port" "$proxy_port" >"$tmp_dir/serverlist-proxy.txt"
|
||||
soak_config "$tmp_dir/soak-proxy.local.json" "$proxy_port" "{\"status\":\"confirmed\",\"mode\":\"local_proxy\",\"per_type\":1,\"unreachable_seconds\":5,\"upstream\":{\"auth_host\":\"127.0.0.1\",\"auth_port\":$port,\"game_host\":\"127.0.0.1\",\"game_port\":$port}}"
|
||||
expect_code 2 "$(code_of run_soak_gate soak-pass "$tmp_dir/soak-proxy" "$tmp_dir/soak-proxy.local.json" "$tmp_dir/serverlist-proxy.txt" --timeout-seconds 8100)" "soak runner: confirmed faults run through the loopback proxy"
|
||||
proxy_actions="$(node -e 'const rows=require("node:fs").readFileSync(process.argv[1],"utf8").trim().split("\n").map((l)=>JSON.parse(l).action);process.stdout.write(String(rows[0]==="listening"&&rows.at(-1)==="stopped"))' "$tmp_dir/soak-proxy/fault-proxy.jsonl" 2>/dev/null || echo false)"
|
||||
[ "$proxy_actions" = "true" ] && pass "soak runner: proxy listened before the client and stopped after it" || fail "soak runner: proxy log incomplete"
|
||||
proxy_pid="$(sed -n 's/^FAULT PROXY pid=\([0-9]*\) .*/\1/p' "$tmp_dir/soak-proxy/gate.log")"
|
||||
if [ -n "$proxy_pid" ] && ! kill -0 "$proxy_pid" 2>/dev/null; then pass "soak runner: proxy process is gone after the run"; else fail "soak runner: proxy pid [$proxy_pid] still alive or unknown"; fi
|
||||
proxy_playable() { # output: an exit run (suite playable) with the same confirmed-faults config still reaches the server
|
||||
MT_FAKE_SCENARIO=pass bash script/run_client_gate.sh --app "$app" --config "$tmp_dir/soak-proxy.local.json" --output "$1" \
|
||||
--assets "$tmp_dir/assets" --serverlist "$tmp_dir/serverlist-proxy.txt" --allow-gameplay >"$1.stdout" 2>&1
|
||||
}
|
||||
expect_code 0 "$(code_of proxy_playable "$tmp_dir/soak-proxy-exit")" "soak runner: playable exit run behind the confirmed proxy"
|
||||
grep -q '"action":"stopped"' "$tmp_dir/soak-proxy-exit/fault-proxy.jsonl" 2>/dev/null && [ ! -e "$tmp_dir/soak-proxy-exit/rss.jsonl" ] \
|
||||
&& pass "soak runner: exit run forwards through the proxy without soak sampling" || fail "soak runner: exit run proxy/sampler state"
|
||||
node -e 'const fs=require("fs");const c=JSON.parse(fs.readFileSync(process.argv[1]));c.timeout_seconds=900;fs.writeFileSync(process.argv[2],JSON.stringify(c));' "$tmp_dir/soak.local.json" "$tmp_dir/soak-short-timeout.local.json"
|
||||
expect_code 0 "$(code_of quiet env MT_FAKE_SCENARIO=pass bash script/run_client_gate.sh --app "$app" --config "$tmp_dir/soak-short-timeout.local.json" \
|
||||
--output "$tmp_dir/soak-block-playable" --assets "$tmp_dir/assets" --serverlist "$tmp_dir/serverlist.txt")" "soak runner: playable suite ignores the soak-only timeout margin"
|
||||
bad_soak="$tmp_dir/soak-bad-playable.local.json"
|
||||
node -e 'const fs=require("fs");const c=JSON.parse(fs.readFileSync(process.argv[1]));c.soak.faults.upstream.auth_port=70000;fs.writeFileSync(process.argv[2],JSON.stringify(c));' "$tmp_dir/soak-proxy.local.json" "$bad_soak"
|
||||
mkdir -p "$tmp_dir/soak-bad-playable"
|
||||
expect_code 2 "$(code_of quiet env MT_FAKE_SCENARIO=pass bash script/run_client_gate.sh --app "$app" --config "$bad_soak" --output "$tmp_dir/soak-bad-playable" \
|
||||
--assets "$tmp_dir/assets" --serverlist "$tmp_dir/serverlist-proxy.txt")" "soak runner: an invalid soak block is rejected for every suite"
|
||||
grep -q "soak.faults.upstream.auth_port" "$tmp_dir/soak-bad-playable/config.log" 2>/dev/null && [ ! -e "$tmp_dir/soak-bad-playable/client.log" ] \
|
||||
&& pass "soak runner: config gate names the invalid upstream before any client" || fail "soak runner: invalid soak block not caught by the config gate"
|
||||
mkdir -p "$tmp_dir/soak-proxy-down"
|
||||
soak_config "$tmp_dir/soak-proxy-down.local.json" "$proxy_port" "{\"status\":\"confirmed\",\"mode\":\"local_proxy\",\"per_type\":1,\"unreachable_seconds\":5,\"upstream\":{\"auth_host\":\"127.0.0.1\",\"auth_port\":1,\"game_host\":\"127.0.0.1\",\"game_port\":1}}"
|
||||
expect_code 2 "$(code_of run_soak_gate soak-pass "$tmp_dir/soak-proxy-down" "$tmp_dir/soak-proxy-down.local.json" "$tmp_dir/serverlist-proxy.txt" --timeout-seconds 8100)" "soak runner: unreachable fault-proxy upstream"
|
||||
[ ! -e "$tmp_dir/soak-proxy-down/client.log" ] && pass "soak runner: client not started when the upstream is down" || fail "soak runner: client started with upstream down"
|
||||
|
||||
# ---------- STB-01 public entry: script/playable_soak.sh ----------
|
||||
run_soak() { # scenario output [extra args...]
|
||||
local scenario="$1" out="$2"
|
||||
shift 2
|
||||
MT_FAKE_SCENARIO="$scenario" bash script/playable_soak.sh --app "$app" --config "$tmp_dir/soak.local.json" --output "$out" \
|
||||
--assets "$tmp_dir/assets" --serverlist "$tmp_dir/serverlist.txt" "$@" </dev/null >"$out.stdout" 2>&1
|
||||
}
|
||||
expect_code 0 "$(code_of quiet bash script/playable_soak.sh --help)" "soak: --help"
|
||||
expect_code 2 "$(code_of quiet bash script/playable_soak.sh --app "$app" </dev/null)" "soak: no config"
|
||||
expect_code 2 "$(code_of run_soak soak-pass "$tmp_dir/soak-nogameplay")" "soak: refuses to start without --allow-gameplay"
|
||||
expect_code 2 "$(code_of run_soak soak-pass "$tmp_dir/soak-duration" --allow-gameplay --duration-seconds 60)" "soak: --duration-seconds must match the config"
|
||||
expect_code 2 "$(code_of run_soak soak-pass "$tmp_dir/soak-exits" --allow-gameplay --exits 9)" "soak: fewer than 10 exit runs"
|
||||
expect_code 2 "$(code_of run_soak soak-pass "$tmp_dir/soak-timeout" --allow-gameplay --timeout-seconds 7300)" "soak: timeout below duration+900"
|
||||
expect_code 2 "$(code_of quiet env MT_FAKE_SCENARIO=soak-pass bash script/playable_soak.sh --app "$app" --config "$tmp_dir/scenario.local.json" \
|
||||
--output "$tmp_dir/soak-noblock-entry" --assets "$tmp_dir/assets" --serverlist "$tmp_dir/serverlist.txt" --allow-gameplay </dev/null)" "soak: config without a soak block"
|
||||
for dir in soak-nogameplay soak-duration soak-exits soak-timeout soak-noblock-entry; do
|
||||
if [ -n "$(find "$tmp_dir/$dir" -name client.log 2>/dev/null)" ]; then fail "soak: $dir started a client"; fi
|
||||
done
|
||||
expect_code 2 "$(code_of quiet env -u MT_ACCOUNT -u MT_PASSWORD MT_FAKE_SCENARIO=soak-pass bash script/playable_soak.sh --app "$app" \
|
||||
--config "$tmp_dir/soak.local.json" --output "$tmp_dir/soak-nocreds" --assets "$tmp_dir/assets" --serverlist "$tmp_dir/serverlist.txt" --allow-gameplay </dev/null)" "soak: no credentials and no TTY"
|
||||
|
||||
expect_code 1 "$(code_of run_soak rid "$tmp_dir/soak-exitfail" --allow-gameplay)" "soak: a failing exit run fails the release"
|
||||
[ ! -e "$tmp_dir/soak-exitfail/soak-1/client.log" ] && grep -q "^BLOCKED not started: exit-1" "$tmp_dir/soak-exitfail/soak-1/gate.log" 2>/dev/null \
|
||||
&& pass "soak: 2 h soak not started after a failed exit run" || fail "soak: soak run started after a failed exit run"
|
||||
|
||||
expect_code 2 "$(code_of run_soak soak-pass "$tmp_dir/soak-batch" --allow-gameplay)" "soak: fake batch stays BLOCKED without real soak rounds"
|
||||
soak_runs="$(node -e 'const m=require(process.argv[1]);process.stdout.write(m.runs.map((r)=>`${r.path}:${r.suite}`).join(",")+"|"+JSON.stringify(m.required_runs))' "$tmp_dir/soak-batch/release-manifest.json" 2>/dev/null || echo missing)"
|
||||
expected_runs="$(for i in 1 2 3 4 5 6 7 8 9 10; do printf 'exit-%s:playable,' "$i"; done)soak-1:soak|{\"playable\":10,\"soak\":1}"
|
||||
[ "$soak_runs" = "$expected_runs" ] && pass "soak: manifest lists 10 exit runs and one soak run" || fail "soak: manifest $soak_runs"
|
||||
exit_passes="$(node -e 'const r=require(process.argv[1]);process.stdout.write(String(r.counts.playable.passed)+"/"+r.status)' "$tmp_dir/soak-batch/release-report.json" 2>/dev/null || echo missing)"
|
||||
[ "$exit_passes" = "10/BLOCKED" ] && pass "soak: 10 independent exit runs PASS, release BLOCKED by memory evidence" || fail "soak: release $exit_passes"
|
||||
if grep -rqF -e "$MT_ACCOUNT" -e "$MT_PASSWORD" "$tmp_dir/soak-batch" "$tmp_dir/soak-batch.stdout"; then
|
||||
fail "soak: credential literal reached the batch directory"
|
||||
else
|
||||
pass "soak: no credential literal in the batch directory"
|
||||
fi
|
||||
expect_code 2 "$(code_of run_soak soak-pass "$tmp_dir/soak-batch" --allow-gameplay)" "soak: refuses a non-empty output directory"
|
||||
|
||||
if [ "$failures" -ne 0 ]; then
|
||||
echo "playable_gate_test: FAIL ($failures)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "playable_gate_test: PASS"
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
#!/usr/bin/env bash
|
||||
# STB-01 public entry: 2 h wall-clock soak plus independent normal-exit runs.
|
||||
#
|
||||
# Layout under --output (must be empty):
|
||||
# exit-<n>/ N independent client processes, suite playable, each through
|
||||
# run_client_gate.sh (normal quit, exit 0, no leak/RID warnings)
|
||||
# soak-1/ one client process, suite soak: move/combat/pickup rounds until the
|
||||
# wall-clock duration, active reconnects, resolution switches, and —
|
||||
# only when the scenario confirms them — portal warps and real
|
||||
# transport faults through the 127.0.0.1 fault proxy; RSS is sampled
|
||||
# for that child PID and sealed as STB-MEMORY-01
|
||||
# release-manifest.json listing exactly these runs; aggregated with
|
||||
# validate_playable_report.mjs --release-dir (no historical reports are scanned).
|
||||
#
|
||||
# Exit runs go first; if one is not PASS the remaining runs (including the 2 h soak)
|
||||
# are not started and are recorded as BLOCKED so the release cannot pass.
|
||||
# Credentials only from MT_ACCOUNT/MT_PASSWORD or one hidden TTY prompt.
|
||||
# Do not add `set -x` to this script. Never pkill; never touch routes/firewall/caches.
|
||||
#
|
||||
# exit: 0 PASS, 1 FAIL, 2 BLOCKED (config/precondition/unconfirmed environment), 124 timeout.
|
||||
set -euo pipefail
|
||||
set +x
|
||||
cd "$(dirname "$0")/.."
|
||||
repo="$PWD"
|
||||
app="${MT_PLAYABLE_APP:-}"
|
||||
config=""
|
||||
output=""
|
||||
duration=""
|
||||
exits=""
|
||||
timeout=""
|
||||
exit_timeout="900"
|
||||
allow=0
|
||||
assets=""
|
||||
serverlist="${MT_PLAYABLE_SERVERLIST:-}"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
用法: script/playable_soak.sh --app APP --config CONFIG --allow-gameplay [选项]
|
||||
--app APP 候选导出包(所有 run 必须同一哈希)
|
||||
--config CONFIG 场景 JSON(含 soak 块;不含凭据)
|
||||
--output DIR 本批次根目录,必须不存在或为空(默认 build/playable/soak-<时间>-<pid>)
|
||||
--duration-seconds N 墙钟时长,必须等于配置 soak.duration_seconds(默认取配置,>= 7200)
|
||||
--exits N 独立正常退出运行次数(默认配置 soak.exits,>= 10)
|
||||
--timeout-seconds N soak 进程墙钟超时(默认 duration+900,不得更小)
|
||||
--exit-timeout-seconds N 每个退出运行的墙钟超时,默认 900
|
||||
--assets DIR 资源预检根目录(透传 run_client_gate.sh)
|
||||
--serverlist FILE 测试模式 serverlist 覆盖(透传)
|
||||
--allow-gameplay 允许专用测试账号发送移动/战斗/拾取请求(不给则只校验配置,返回 2,不启动客户端)
|
||||
环境变量:
|
||||
MT_ACCOUNT / MT_PASSWORD 测试账号(未设置且 stdin 是终端时隐藏输入一次)
|
||||
说明:
|
||||
切图(soak.warp)和真实断网(soak.faults)须由环境负责人确认后在配置里标 confirmed;
|
||||
未确认时对应用例为 BLOCKED,不用主动重连或直接传送替代。
|
||||
退出码: 0 PASS / 1 FAIL / 2 BLOCKED / 124 超时
|
||||
EOF
|
||||
}
|
||||
|
||||
blocked() { echo "PLAYABLE SOAK: BLOCKED $*" >&2; exit 2; }
|
||||
positive() { [[ "$1" =~ ^[1-9][0-9]*$ ]]; }
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--app) app="${2:-}"; shift 2 ;;
|
||||
--config) config="${2:-}"; shift 2 ;;
|
||||
--output) output="${2:-}"; shift 2 ;;
|
||||
--duration-seconds) duration="${2:-}"; shift 2 ;;
|
||||
--exits) exits="${2:-}"; shift 2 ;;
|
||||
--timeout-seconds) timeout="${2:-}"; shift 2 ;;
|
||||
--exit-timeout-seconds) exit_timeout="${2:-}"; shift 2 ;;
|
||||
--assets) assets="${2:-}"; shift 2 ;;
|
||||
--serverlist) serverlist="${2:-}"; shift 2 ;;
|
||||
--allow-gameplay) allow=1; shift ;;
|
||||
--help) usage; exit 0 ;;
|
||||
*) echo "未知选项: $1" >&2; usage >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ -n "$app" ] && [ -d "$app" ] || blocked "需要 --app 指向候选导出包"
|
||||
[ -n "$config" ] && [ -f "$config" ] || blocked "需要 --config CONFIG"
|
||||
config="$(cd "$(dirname "$config")" && pwd)/$(basename "$config")"
|
||||
for pair in "duration-seconds:$duration" "exits:$exits" "timeout-seconds:$timeout"; do
|
||||
value="${pair#*:}"
|
||||
if [ -n "$value" ] && ! positive "$value"; then blocked "--${pair%%:*} 必须是正整数"; fi
|
||||
done
|
||||
positive "$exit_timeout" || blocked "--exit-timeout-seconds 必须是正整数"
|
||||
|
||||
# Config validation before any prompt or process: the same gate the runner uses.
|
||||
if [ -z "$assets" ]; then
|
||||
if [ -d "$app/Contents/Resources/assets" ]; then assets="$app/Contents/Resources/assets"; else assets="$repo/assets"; fi
|
||||
fi
|
||||
if ! config_log="$(env MT_PLAYABLE_VALIDATE_CONFIG="$config" MT_PLAYABLE_SUITE=soak MT_PLAYABLE_ASSETS="$assets" \
|
||||
MT_PLAYABLE_SERVERLIST="$serverlist" "${MT_GODOT:-godot}" --headless --path project --script playable_config_gate.gd 2>&1)"; then
|
||||
printf '%s\n' "$config_log" | grep '^CONFIG:' >&2 || true
|
||||
blocked "配置/资源预检失败(suite soak)"
|
||||
fi
|
||||
read -r cfg_duration cfg_exits <<<"$(node -e '
|
||||
const s = JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8")).soak;
|
||||
process.stdout.write(`${s.duration_seconds} ${s.exits}`);
|
||||
' "$config")"
|
||||
if [ -z "$duration" ]; then duration="$cfg_duration"; fi
|
||||
[ "$duration" -eq "$cfg_duration" ] || blocked "--duration-seconds $duration 与配置 soak.duration_seconds $cfg_duration 不一致;请改配置而不是覆盖"
|
||||
if [ -z "$exits" ]; then exits="$cfg_exits"; fi
|
||||
[ "$exits" -ge 10 ] || blocked "--exits 至少 10"
|
||||
minimum_timeout=$((duration + 900))
|
||||
if [ -z "$timeout" ]; then timeout="$minimum_timeout"; fi
|
||||
[ "$timeout" -ge "$minimum_timeout" ] || blocked "--timeout-seconds 不得小于 duration+900(${minimum_timeout})"
|
||||
# Without explicit consent only the configuration is checked; no client, no gameplay request.
|
||||
[ "$allow" -eq 1 ] || blocked "配置校验通过;soak 需要 --allow-gameplay(专用测试账号上的移动/战斗/拾取),未启动客户端"
|
||||
|
||||
root="$output"
|
||||
if [ -z "$root" ]; then root="$repo/build/playable/soak-$(date +%Y%m%d-%H%M%S)-$$"; fi
|
||||
if [ -e "$root" ] && [ -n "$(find "$root" -mindepth 1 -maxdepth 1 -print -quit)" ]; then
|
||||
blocked "输出目录已有内容,拒绝混入历史运行:$root"
|
||||
fi
|
||||
mkdir -p "$root"
|
||||
root="$(cd "$root" && pwd)"
|
||||
|
||||
if [ -z "${MT_ACCOUNT:-}" ] || [ -z "${MT_PASSWORD:-}" ]; then
|
||||
if [ -t 0 ]; then
|
||||
if [ -z "${MT_ACCOUNT:-}" ]; then read -r -s -p "测试账号: " MT_ACCOUNT; echo >&2; fi
|
||||
if [ -z "${MT_PASSWORD:-}" ]; then read -r -s -p "测试密码: " MT_PASSWORD; echo >&2; fi
|
||||
fi
|
||||
fi
|
||||
[ -n "${MT_ACCOUNT:-}" ] && [ -n "${MT_PASSWORD:-}" ] || blocked "缺少 MT_ACCOUNT/MT_PASSWORD,且不是交互终端"
|
||||
export MT_ACCOUNT MT_PASSWORD
|
||||
|
||||
bash script/playable_build_info.sh "$app" >"$root/candidate.json"
|
||||
|
||||
runs_json="[]"
|
||||
timed_out=0
|
||||
stop_reason=""
|
||||
list_run() { # name suite
|
||||
runs_json="$(node -e '
|
||||
const fs = require("node:fs");
|
||||
const [runs, dir, name, suite] = process.argv.slice(1);
|
||||
let runId = `${name}-unsealed`;
|
||||
try { runId = JSON.parse(fs.readFileSync(`${dir}/report.json`, "utf8")).run_id || runId; } catch {}
|
||||
const list = JSON.parse(runs);
|
||||
list.push({ run_id: runId, suite, path: name });
|
||||
process.stdout.write(JSON.stringify(list));
|
||||
' "$runs_json" "$root/$1" "$1" "$2")"
|
||||
}
|
||||
run_one() { # name suite timeout
|
||||
local name="$1" run_suite="$2" run_timeout="$3" code
|
||||
if [ -n "$stop_reason" ]; then
|
||||
mkdir -p "$root/$name"
|
||||
echo "BLOCKED not started: $stop_reason" >"$root/$name/gate.log"
|
||||
list_run "$name" "$run_suite"
|
||||
return
|
||||
fi
|
||||
local args=(--app "$app" --config "$config" --output "$root/$name" --suite "$run_suite" --timeout-seconds "$run_timeout" --allow-gameplay)
|
||||
if [ -n "$assets" ]; then args+=(--assets "$assets"); fi
|
||||
if [ -n "$serverlist" ]; then args+=(--serverlist "$serverlist"); fi
|
||||
set +e
|
||||
bash script/run_client_gate.sh "${args[@]}" </dev/null
|
||||
code=$?
|
||||
set -e
|
||||
if [ "$code" -eq 124 ]; then timed_out=1; fi
|
||||
if [ "$code" -ne 0 ] && [ "$run_suite" = "playable" ]; then stop_reason="$name exit $code"; fi
|
||||
list_run "$name" "$run_suite"
|
||||
}
|
||||
|
||||
for index in $(seq 1 "$exits"); do
|
||||
run_one "exit-$index" playable "$exit_timeout"
|
||||
done
|
||||
run_one soak-1 soak "$timeout"
|
||||
|
||||
node -e '
|
||||
const fs = require("node:fs");
|
||||
const [root, exits, runs] = process.argv.slice(1);
|
||||
const candidate = JSON.parse(fs.readFileSync(`${root}/candidate.json`, "utf8"));
|
||||
fs.writeFileSync(`${root}/release-manifest.json`, JSON.stringify({
|
||||
schema_version: 1, candidate, required_runs: { playable: Number(exits), soak: 1 }, runs: JSON.parse(runs),
|
||||
}, null, 2) + "\n");
|
||||
' "$root" "$exits" "$runs_json"
|
||||
|
||||
set +e
|
||||
node script/validate_playable_report.mjs --release-dir "$root"
|
||||
release_code=$?
|
||||
set -e
|
||||
if [ -f "$root/soak-1/report.json" ]; then
|
||||
node -e '
|
||||
const r = JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8"));
|
||||
for (const c of [...(r.cases || []).filter((c) => c.id.startsWith("STB-")), ...(r.runner_cases || [])]) console.log(` ${c.id} ${c.status} ${c.reason || ""}`);
|
||||
' "$root/soak-1/report.json" || true
|
||||
fi
|
||||
if [ "$timed_out" -eq 1 ]; then echo "PLAYABLE SOAK: TIMEOUT root=$root" >&2; exit 124; fi
|
||||
case "$release_code" in
|
||||
0) echo "PLAYABLE SOAK: PASS exits=$exits duration=${duration}s root=$root" ;;
|
||||
2) echo "PLAYABLE SOAK: BLOCKED root=$root" >&2 ;;
|
||||
*) echo "PLAYABLE SOAK: FAIL root=$root" >&2; release_code=1 ;;
|
||||
esac
|
||||
exit "$release_code"
|
||||
@@ -0,0 +1,242 @@
|
||||
#!/usr/bin/env node
|
||||
/* STB-01 §9.1 external memory probe and leak gate.
|
||||
*
|
||||
* playable_soak_metrics.mjs --sample-rss --pid PID --output rss.jsonl [--interval-ms 1000]
|
||||
* Appends {wall_ms, rss_kib} for exactly one PID (the runner's own child) via
|
||||
* `ps -o stat=,rss= -p PID` once per interval and exits when that PID is gone or a zombie. It
|
||||
* never signals the PID. A failed read is written as rss_kib null, not 0.
|
||||
*
|
||||
* playable_soak_metrics.mjs --seal --report report.json --rss rss.jsonl --events events.jsonl
|
||||
* [--warmup-rounds 1] [--min-rounds 10]
|
||||
* Reads the client's soak_rest start/end events (payload.round, payload.wall_ms),
|
||||
* takes the RSS low water inside each >=30 s rest window, applies the leak rule
|
||||
* and writes report.memory + runner_cases[STB-MEMORY-01] back into the sealed
|
||||
* report. The verdict can only downgrade the report status.
|
||||
*
|
||||
* Leak rule: after warm-up, FAIL when the low water grew in >=5 consecutive rounds
|
||||
* AND median(last 5) - median(first 5) > max(50 MiB, 5% of median(first 5)).
|
||||
* Below the threshold is not proof that there is no long-term leak.
|
||||
*
|
||||
* exit: 0 PASS, 1 FAIL, 2 BLOCKED or usage error. */
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const REST_MIN_MS = 30000;
|
||||
const STREAK = 5;
|
||||
const ABS_THRESHOLD_KIB = 50 * 1024;
|
||||
const PCT_THRESHOLD = 0.05;
|
||||
const NOTE = '阈值以下不能证明长期无泄漏;GPU 内存不可得(null/unavailable),不据此判断显存。';
|
||||
const CASE_ID = 'STB-MEMORY-01';
|
||||
const RANK = { PASS: 0, BLOCKED: 1, FAIL: 2 };
|
||||
|
||||
export function median(values) {
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
if (sorted.length === 0) return null;
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
||||
}
|
||||
|
||||
export function parseRss(text) {
|
||||
const samples = [];
|
||||
let badLines = 0;
|
||||
for (const line of String(text).split('\n')) {
|
||||
if (line.trim() === '') continue;
|
||||
try {
|
||||
const row = JSON.parse(line);
|
||||
if (!Number.isFinite(row.wall_ms) || !(row.rss_kib === null || Number.isInteger(row.rss_kib))) badLines += 1;
|
||||
else samples.push({ wall_ms: row.wall_ms, rss_kib: row.rss_kib });
|
||||
} catch { badLines += 1; }
|
||||
}
|
||||
return { samples, bad_lines: badLines };
|
||||
}
|
||||
|
||||
/** Pair soak_rest start/end events per round. Unpaired or reordered events are errors. */
|
||||
export function restWindows(eventsText) {
|
||||
const open = new Map();
|
||||
const windows = [];
|
||||
const errors = [];
|
||||
for (const line of String(eventsText).split('\n')) {
|
||||
if (line.trim() === '') continue;
|
||||
let event;
|
||||
try { event = JSON.parse(line); } catch { continue; }
|
||||
if (event?.kind !== 'soak_rest') continue;
|
||||
const { round, state, wall_ms: wallMs } = event.payload ?? {};
|
||||
if (!Number.isInteger(round) || !Number.isFinite(wallMs)) { errors.push('soak_rest event without integer round/wall_ms'); continue; }
|
||||
if (state === 'start') {
|
||||
if (open.has(round)) errors.push(`round ${round}: rest started twice`);
|
||||
open.set(round, wallMs);
|
||||
} else if (state === 'end') {
|
||||
if (!open.has(round)) { errors.push(`round ${round}: rest ended without a start`); continue; }
|
||||
const start = open.get(round);
|
||||
open.delete(round);
|
||||
if (wallMs < start) errors.push(`round ${round}: rest ends before it starts`);
|
||||
else windows.push({ round, start_ms: start, end_ms: wallMs, rest_ms: wallMs - start });
|
||||
}
|
||||
}
|
||||
for (const round of open.keys()) errors.push(`round ${round}: rest never ended`);
|
||||
windows.sort((a, b) => a.round - b.round);
|
||||
return { windows, errors };
|
||||
}
|
||||
|
||||
export function roundLowWaters(windows, samples) {
|
||||
return windows.map((window) => {
|
||||
const inside = samples.filter((s) => s.wall_ms >= window.start_ms && s.wall_ms <= window.end_ms && Number.isInteger(s.rss_kib));
|
||||
// At least one sample per two seconds of rest; a sparse window is not a measured low water.
|
||||
const needed = Math.max(1, Math.floor(window.rest_ms / 2000));
|
||||
return {
|
||||
round: window.round,
|
||||
rest_ms: window.rest_ms,
|
||||
samples: inside.length,
|
||||
low_water_kib: inside.length >= needed ? Math.min(...inside.map((s) => s.rss_kib)) : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function leakVerdict(lowWaters, { warmupRounds = 1, minRounds = 10 } = {}) {
|
||||
const verdict = {
|
||||
status: 'BLOCKED', reason: '', unit: 'KiB', warmup_rounds: warmupRounds, rounds_total: lowWaters.length, rounds_analysed: 0,
|
||||
first5_median_kib: null, last5_median_kib: null, growth_kib: null, threshold_kib: null, max_growth_streak: 0,
|
||||
rule: `FAIL iff >=${STREAK} consecutive growing rounds AND last5 median - first5 median > max(50MiB, 5%)`,
|
||||
warnings: [], note: NOTE,
|
||||
};
|
||||
const short = lowWaters.find((row) => row.rest_ms < REST_MIN_MS);
|
||||
if (short) return { ...verdict, status: 'FAIL', reason: `round ${short.round}: rest ${short.rest_ms} ms is shorter than 30 s` };
|
||||
const rows = lowWaters.slice(warmupRounds);
|
||||
verdict.rounds_analysed = rows.length;
|
||||
if (rows.length < minRounds) return { ...verdict, reason: `need at least ${minRounds} rounds after warm-up, got ${rows.length}` };
|
||||
const missing = rows.find((row) => row.low_water_kib === null);
|
||||
if (missing) return { ...verdict, reason: `round ${missing.round}: no RSS low water (sampler gap)` };
|
||||
const values = rows.map((row) => row.low_water_kib);
|
||||
let streak = 0;
|
||||
for (let i = 1; i < values.length; i += 1) {
|
||||
streak = values[i] > values[i - 1] ? streak + 1 : 0;
|
||||
verdict.max_growth_streak = Math.max(verdict.max_growth_streak, streak);
|
||||
}
|
||||
const first = Math.round(median(values.slice(0, STREAK)));
|
||||
const last = Math.round(median(values.slice(-STREAK)));
|
||||
verdict.first5_median_kib = first;
|
||||
verdict.last5_median_kib = last;
|
||||
verdict.growth_kib = last - first;
|
||||
verdict.threshold_kib = Math.max(ABS_THRESHOLD_KIB, Math.ceil(PCT_THRESHOLD * first));
|
||||
const overThreshold = verdict.growth_kib > verdict.threshold_kib;
|
||||
const streakHit = verdict.max_growth_streak >= STREAK;
|
||||
if (overThreshold && streakHit) {
|
||||
verdict.status = 'FAIL';
|
||||
verdict.reason = `low water grew ${verdict.max_growth_streak} rounds in a row and +${verdict.growth_kib} KiB > ${verdict.threshold_kib} KiB; analyse before release`;
|
||||
return verdict;
|
||||
}
|
||||
if (overThreshold) verdict.warnings.push(`median growth +${verdict.growth_kib} KiB exceeds the threshold without ${STREAK} consecutive growing rounds; needs analysis`);
|
||||
verdict.status = 'PASS';
|
||||
verdict.reason = 'no leak signal at the gate threshold';
|
||||
return verdict;
|
||||
}
|
||||
|
||||
export function sealMemory(report, { eventsText, rssText, warmupRounds = 1, minRounds = 10 }) {
|
||||
const rss = parseRss(rssText ?? '');
|
||||
const rest = restWindows(eventsText ?? '');
|
||||
const rounds = roundLowWaters(rest.windows, rss.samples);
|
||||
let verdict = leakVerdict(rounds, { warmupRounds, minRounds });
|
||||
if (rest.errors.length > 0) verdict = { ...verdict, status: 'FAIL', reason: `rest events: ${rest.errors.slice(0, 5).join('; ')}` };
|
||||
else if (rest.windows.length === 0) verdict = { ...verdict, status: 'BLOCKED', reason: 'no soak_rest windows in events' };
|
||||
// The client's own MEMORY_STATIC snapshots stay separate from the external RSS.
|
||||
const godot = report.memory?.godot ?? report.memory ?? null;
|
||||
report.memory = {
|
||||
sampler: 'external ps -o stat=,rss= on the runner-held child PID, 1 Hz',
|
||||
unit: 'KiB',
|
||||
sample_count: rss.samples.length,
|
||||
samples_null: rss.samples.filter((s) => s.rss_kib === null).length,
|
||||
bad_lines: rss.bad_lines,
|
||||
rounds,
|
||||
gpu_memory_kib: null,
|
||||
gpu_memory_status: 'unavailable',
|
||||
godot,
|
||||
verdict,
|
||||
};
|
||||
report.runner_cases = (Array.isArray(report.runner_cases) ? report.runner_cases : []).filter((c) => c?.id !== CASE_ID);
|
||||
report.runner_cases.push({ id: CASE_ID, status: verdict.status, reason: verdict.reason });
|
||||
if (!VALID_STATUS(report.status)) report.status = 'FAIL';
|
||||
if (RANK[verdict.status] > RANK[report.status]) report.status = verdict.status;
|
||||
report.exit_gate = report.exit_gate && typeof report.exit_gate === 'object' ? report.exit_gate : { checked: false, errors: [] };
|
||||
if (!Array.isArray(report.exit_gate.errors)) report.exit_gate.errors = [];
|
||||
if (verdict.status !== 'PASS') report.exit_gate.errors.push(`${CASE_ID}: ${verdict.status} ${verdict.reason}`);
|
||||
return { report, code: { PASS: 0, FAIL: 1, BLOCKED: 2 }[report.status] ?? 1 };
|
||||
}
|
||||
|
||||
function VALID_STATUS(status) { return Object.hasOwn(RANK, status); }
|
||||
|
||||
function usage(stream = process.stderr) {
|
||||
stream.write('usage: playable_soak_metrics.mjs --sample-rss --pid PID --output rss.jsonl [--interval-ms 1000]\n'
|
||||
+ ' playable_soak_metrics.mjs --seal --report report.json --rss rss.jsonl --events events.jsonl [--warmup-rounds 1] [--min-rounds 10]\n');
|
||||
}
|
||||
|
||||
function argsOf(argv) {
|
||||
const out = {};
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
if (['--sample-rss', '--seal', '--help'].includes(arg)) out[arg.slice(2).replace('-', '_')] = true;
|
||||
else if (arg.startsWith('--') && i + 1 < argv.length) out[arg.slice(2).replaceAll('-', '_')] = argv[++i];
|
||||
else throw new Error(`bad argument: ${arg}`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function readRss(pid) {
|
||||
return new Promise((resolve) => {
|
||||
execFile('ps', ['-o', 'stat=,rss=', '-p', String(pid)], { timeout: 5000 }, (error, stdout) => {
|
||||
if (error) { resolve({ alive: false }); return; }
|
||||
const [stat = '', rss = ''] = String(stdout).trim().split(/\s+/);
|
||||
// An exited child the runner has not reaped yet is a zombie: it is gone, not 0 KiB.
|
||||
if (stat.startsWith('Z')) { resolve({ alive: false }); return; }
|
||||
const value = Number.parseInt(rss, 10);
|
||||
resolve({ alive: true, rss_kib: Number.isInteger(value) && value > 0 ? value : null });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function sampleRss(options) {
|
||||
const pid = Number(options.pid);
|
||||
const interval = Number(options.interval_ms ?? 1000);
|
||||
if (!Number.isInteger(pid) || pid <= 0 || !options.output || !Number.isInteger(interval) || interval < 100) { usage(); return 2; }
|
||||
let stop = false;
|
||||
process.on('SIGTERM', () => { stop = true; });
|
||||
process.on('SIGINT', () => { stop = true; });
|
||||
while (!stop) {
|
||||
const tick = Date.now();
|
||||
const row = await readRss(pid);
|
||||
if (!row.alive) break;
|
||||
fs.appendFileSync(options.output, `${JSON.stringify({ wall_ms: tick, rss_kib: row.rss_kib })}\n`);
|
||||
await new Promise((resolve) => setTimeout(resolve, Math.max(0, interval - (Date.now() - tick))));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function seal(options) {
|
||||
for (const key of ['report', 'rss', 'events']) if (!options[key]) { usage(); return 2; }
|
||||
let report;
|
||||
try { report = JSON.parse(fs.readFileSync(options.report, 'utf8')); }
|
||||
catch { console.error(`PLAYABLE MEMORY: FAIL report unreadable ${path.basename(options.report)}`); return 1; }
|
||||
const read = (file) => (fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '');
|
||||
const { report: sealed, code } = sealMemory(report, {
|
||||
eventsText: read(options.events), rssText: read(options.rss),
|
||||
warmupRounds: Number(options.warmup_rounds ?? 1), minRounds: Number(options.min_rounds ?? 10),
|
||||
});
|
||||
fs.writeFileSync(options.report, `${JSON.stringify(sealed, null, 2)}\n`);
|
||||
const v = sealed.memory.verdict;
|
||||
const line = `PLAYABLE MEMORY: ${v.status} ${v.reason} (first5=${v.first5_median_kib} last5=${v.last5_median_kib} KiB)`;
|
||||
if (v.status === 'PASS') console.log(line); else console.error(line);
|
||||
return code;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let options;
|
||||
try { options = argsOf(process.argv.slice(2)); } catch (error) { usage(); console.error(error.message); return 2; }
|
||||
if (options.help) { usage(process.stdout); return 0; }
|
||||
if (options.sample_rss) return sampleRss(options);
|
||||
if (options.seal) return seal(options);
|
||||
usage();
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) process.exitCode = await main();
|
||||
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env node
|
||||
/* STB-01 §9.1 memory gate regression. Synthetic RSS/rest-window data proves the
|
||||
* leak rule (5 consecutive growing low waters AND last-5 median above
|
||||
* max(50MiB, 5%) of the first-5 median), that missing data is BLOCKED rather
|
||||
* than PASS, and that the external sampler records KiB for one PID only. */
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { parseRss, restWindows, roundLowWaters, leakVerdict, median, sealMemory } from './playable_soak_metrics.mjs';
|
||||
|
||||
const script = path.join(path.dirname(new URL(import.meta.url).pathname), 'playable_soak_metrics.mjs');
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mt-soak-metrics.'));
|
||||
let failures = 0;
|
||||
const check = (ok, label) => { if (ok) console.log(`ok - ${label}`); else { failures += 1; console.error(`not ok - ${label}`); } };
|
||||
|
||||
const MIB = 1024;
|
||||
const event = (round, state, wallMs, us) => JSON.stringify({ monotonic_us: us, run_id: 'r', case_id: 'STB-MEMORY-01', connection_epoch: 1,
|
||||
stage: 'REST', kind: 'soak_rest', actor_vid: 0, target_vid: 0, payload: { round, state, wall_ms: wallMs } });
|
||||
|
||||
/** Build rounds of rest windows (30 s each, 1 Hz samples) whose low water follows `lows` (KiB). */
|
||||
function fixture(lows, { restMs = 30000, gapMs = 60000, dropSamplesInRound = -1 } = {}) {
|
||||
const events = [];
|
||||
const rss = [];
|
||||
let t = 1_700_000_000_000;
|
||||
lows.forEach((low, round) => {
|
||||
for (let s = 1; s < gapMs / 1000; s += 1) rss.push({ wall_ms: t + s * 1000, rss_kib: low + 80 * MIB });
|
||||
t += gapMs;
|
||||
events.push(event(round, 'start', t, round * 10 + 1));
|
||||
for (let s = 0; s <= restMs / 1000; s += 1) {
|
||||
if (round !== dropSamplesInRound) rss.push({ wall_ms: t + s * 1000, rss_kib: low + (s === 20 ? 0 : 3 * MIB) });
|
||||
}
|
||||
t += restMs;
|
||||
events.push(event(round, 'end', t, round * 10 + 2));
|
||||
});
|
||||
return { eventsText: `${events.join('\n')}\n`, rssText: `${rss.map((r) => JSON.stringify(r)).join('\n')}\n` };
|
||||
}
|
||||
|
||||
const flat = Array.from({ length: 12 }, (_, i) => 900 * MIB + (i % 2) * MIB);
|
||||
const leaking = Array.from({ length: 12 }, (_, i) => 900 * MIB + (i < 6 ? 0 : (i - 5) * 20 * MIB));
|
||||
const sawtooth = Array.from({ length: 12 }, (_, i) => 900 * MIB + i * 30 * MIB - (i % 3 === 0 ? 40 * MIB : 0));
|
||||
const smallCreep = Array.from({ length: 12 }, (_, i) => 900 * MIB + i * 2 * MIB);
|
||||
|
||||
check(median([3, 1, 2]) === 2 && median([4, 1, 3, 2]) === 2.5, 'median of odd and even lists');
|
||||
{
|
||||
const rows = parseRss('{"wall_ms":1,"rss_kib":10}\nnot json\n{"wall_ms":2,"rss_kib":null}\n');
|
||||
check(rows.samples.length === 2 && rows.bad_lines === 1 && rows.samples[1].rss_kib === null, 'rss parser keeps null samples and counts bad lines');
|
||||
}
|
||||
{
|
||||
const { eventsText, rssText } = fixture(flat);
|
||||
const windows = restWindows(eventsText);
|
||||
check(windows.windows.length === 12 && windows.windows[0].round === 0 && windows.windows[0].rest_ms === 30000, 'rest windows come from soak_rest start/end events');
|
||||
const lows = roundLowWaters(windows.windows, parseRss(rssText).samples);
|
||||
check(lows[3].low_water_kib === flat[3] && lows[3].samples === 31, 'low water is the minimum RSS inside the rest window');
|
||||
const verdict = leakVerdict(lows, { warmupRounds: 1 });
|
||||
check(verdict.status === 'PASS' && verdict.rounds_analysed === 11, `flat memory passes (${verdict.status} ${verdict.reason})`);
|
||||
check(/不能证明长期无泄漏/.test(verdict.note), 'a pass still says it cannot prove no long-term leak');
|
||||
check(verdict.unit === 'KiB' && Number.isInteger(verdict.first5_median_kib) && Number.isInteger(verdict.last5_median_kib), 'first/last 5 medians are recorded in KiB');
|
||||
}
|
||||
{
|
||||
const { eventsText, rssText } = fixture(leaking);
|
||||
const verdict = leakVerdict(roundLowWaters(restWindows(eventsText).windows, parseRss(rssText).samples), { warmupRounds: 1 });
|
||||
check(verdict.status === 'FAIL' && verdict.max_growth_streak >= 5 && verdict.growth_kib > verdict.threshold_kib, `steady growth fails (${JSON.stringify(verdict)})`);
|
||||
check(verdict.threshold_kib === 50 * MIB, 'threshold is max(50MiB, 5%) — 50MiB for a 900MiB baseline');
|
||||
}
|
||||
{
|
||||
// Large growth without 5 consecutive rising rounds is reported but does not fail the gate.
|
||||
const verdict = leakVerdict(sawtooth.map((low, round) => ({ round, low_water_kib: low, samples: 31, rest_ms: 30000 })), { warmupRounds: 1 });
|
||||
check(verdict.status === 'PASS' && verdict.max_growth_streak < 5 && verdict.growth_kib > verdict.threshold_kib && verdict.warnings.length === 1,
|
||||
`median growth without a 5-round streak is a warning (${JSON.stringify(verdict)})`);
|
||||
}
|
||||
{
|
||||
const verdict = leakVerdict(smallCreep.map((low, round) => ({ round, low_water_kib: low, samples: 31, rest_ms: 30000 })), { warmupRounds: 1 });
|
||||
check(verdict.status === 'PASS' && verdict.max_growth_streak >= 5 && verdict.growth_kib <= verdict.threshold_kib, 'a monotonic creep below the threshold passes with the note');
|
||||
}
|
||||
{
|
||||
const big = Array.from({ length: 12 }, (_, i) => 4096 * MIB + i * 60 * MIB);
|
||||
const verdict = leakVerdict(big.map((low, round) => ({ round, low_water_kib: low, samples: 31, rest_ms: 30000 })), { warmupRounds: 1 });
|
||||
check(verdict.threshold_kib > 50 * MIB && verdict.threshold_kib === Math.ceil(0.05 * verdict.first5_median_kib), '5% wins for a large baseline');
|
||||
}
|
||||
{
|
||||
const verdict = leakVerdict(flat.slice(0, 10).map((low, round) => ({ round, low_water_kib: low, samples: 31, rest_ms: 30000 })), { warmupRounds: 1 });
|
||||
check(verdict.status === 'BLOCKED' && /10/.test(verdict.reason), 'fewer than 10 rounds after warm-up is BLOCKED');
|
||||
}
|
||||
{
|
||||
const { eventsText, rssText } = fixture(flat, { dropSamplesInRound: 4 });
|
||||
const verdict = leakVerdict(roundLowWaters(restWindows(eventsText).windows, parseRss(rssText).samples), { warmupRounds: 1 });
|
||||
check(verdict.status === 'BLOCKED' && /round 4/.test(verdict.reason), `a rest window without RSS samples is BLOCKED (${verdict.reason})`);
|
||||
}
|
||||
{
|
||||
const { eventsText, rssText } = fixture(flat, { restMs: 10000 });
|
||||
const verdict = leakVerdict(roundLowWaters(restWindows(eventsText).windows, parseRss(rssText).samples), { warmupRounds: 1 });
|
||||
check(verdict.status === 'FAIL' && /30/.test(verdict.reason), 'a rest window shorter than 30 s violates the contract');
|
||||
}
|
||||
{
|
||||
const unmatched = `${event(0, 'start', 1000, 1)}\n${event(1, 'start', 2000, 2)}\n`;
|
||||
const windows = restWindows(unmatched);
|
||||
check(windows.windows.length === 0 && windows.errors.length === 2, 'unmatched rest events are errors, not windows');
|
||||
}
|
||||
|
||||
// Seal: the memory verdict downgrades the sealed report and never upgrades it.
|
||||
{
|
||||
const { eventsText, rssText } = fixture(leaking);
|
||||
const base = { schema_version: 1, run_id: 'r', suite: 'soak', status: 'PASS', exit_gate: { checked: true, process_code: 0, timed_out: false, errors: [] } };
|
||||
const sealed = sealMemory(structuredClone(base), { eventsText, rssText, warmupRounds: 1 });
|
||||
check(sealed.report.status === 'FAIL' && sealed.code === 1, 'leak downgrades PASS to FAIL');
|
||||
check(sealed.report.runner_cases?.[0]?.id === 'STB-MEMORY-01' && sealed.report.runner_cases[0].status === 'FAIL', 'runner case STB-MEMORY-01 is recorded');
|
||||
check(sealed.report.memory.gpu_memory_kib === null && sealed.report.memory.gpu_memory_status === 'unavailable', 'GPU memory stays null/unavailable');
|
||||
check(sealed.report.exit_gate.errors.some((e) => e.startsWith('STB-MEMORY-01')), 'memory verdict reaches exit_gate.errors');
|
||||
const blocked = sealMemory(structuredClone(base), { eventsText: '', rssText, warmupRounds: 1 });
|
||||
check(blocked.report.status === 'BLOCKED' && blocked.code === 2, 'no rest windows downgrades PASS to BLOCKED');
|
||||
const failed = sealMemory({ ...structuredClone(base), status: 'FAIL' }, { ...fixture(flat), warmupRounds: 1 });
|
||||
check(failed.report.status === 'FAIL' && failed.code === 1 && failed.report.runner_cases[0].status === 'PASS', 'a clean memory verdict never upgrades a FAIL');
|
||||
}
|
||||
|
||||
// CLI seal writes in place; unknown input is a usage error.
|
||||
{
|
||||
const { eventsText, rssText } = fixture(flat);
|
||||
const dir = path.join(root, 'seal');
|
||||
fs.mkdirSync(dir);
|
||||
fs.writeFileSync(path.join(dir, 'events.jsonl'), eventsText);
|
||||
fs.writeFileSync(path.join(dir, 'rss.jsonl'), rssText);
|
||||
fs.writeFileSync(path.join(dir, 'report.json'), JSON.stringify({ schema_version: 1, run_id: 'r', suite: 'soak', status: 'PASS', exit_gate: { checked: true, errors: [] } }));
|
||||
const run = spawnSync(process.execPath, [script, '--seal', '--report', path.join(dir, 'report.json'), '--rss', path.join(dir, 'rss.jsonl'),
|
||||
'--events', path.join(dir, 'events.jsonl')], { encoding: 'utf8' });
|
||||
const out = JSON.parse(fs.readFileSync(path.join(dir, 'report.json'), 'utf8'));
|
||||
check(run.status === 0 && out.status === 'PASS' && out.memory.rounds.length === 12, `CLI seal PASS (${run.status} ${run.stderr})`);
|
||||
const bad = spawnSync(process.execPath, [script, '--seal', '--report', path.join(dir, 'report.json')], { encoding: 'utf8' });
|
||||
check(bad.status === 2, 'missing seal arguments exit 2');
|
||||
const help = spawnSync(process.execPath, [script, '--help'], { encoding: 'utf8' });
|
||||
check(help.status === 0 && /--sample-rss/.test(help.stdout + help.stderr), '--help documents the sampler');
|
||||
}
|
||||
|
||||
// Sampler: 1 Hz ps samples of one PID, KiB integers, stops by itself when that PID exits.
|
||||
{
|
||||
const target = spawn('/bin/sleep', ['3'], { stdio: 'ignore' });
|
||||
const output = path.join(root, 'sampler.jsonl');
|
||||
const started = Date.now();
|
||||
const sampler = spawnSync(process.execPath, [script, '--sample-rss', '--pid', String(target.pid), '--output', output, '--interval-ms', '500'],
|
||||
{ encoding: 'utf8', timeout: 15000 });
|
||||
const rows = parseRss(fs.readFileSync(output, 'utf8')).samples;
|
||||
check(sampler.status === 0 && Date.now() - started < 12000, `sampler exits after the PID is gone (${sampler.status} ${sampler.stderr})`);
|
||||
check(rows.length >= 3 && rows.every((r) => Number.isInteger(r.rss_kib) && r.rss_kib > 0 && Number.isInteger(r.wall_ms)), `sampler writes KiB rows (${rows.length})`);
|
||||
const refused = spawnSync(process.execPath, [script, '--sample-rss', '--pid', '0', '--output', output], { encoding: 'utf8' });
|
||||
check(refused.status === 2, 'sampler refuses a non-positive PID');
|
||||
}
|
||||
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
console.log(failures === 0 ? 'PASS: playable_soak_metrics_test' : `FAIL: playable_soak_metrics_test (${failures})`);
|
||||
process.exitCode = failures === 0 ? 0 : 1;
|
||||
+101
-27
@@ -1,56 +1,130 @@
|
||||
#!/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.
|
||||
# Public entry for the first playable client gate (INF-02 / REL-01).
|
||||
#
|
||||
# Runs N independent client processes through run_client_gate.sh into
|
||||
# ROOT/run-<n>, writes ROOT/release-manifest.json listing exactly those runs and
|
||||
# the candidate package hashes, then aggregates ONLY the listed runs. Historical
|
||||
# PASS reports elsewhere are never scanned. Credentials are asked once (hidden
|
||||
# TTY prompt) or taken from MT_ACCOUNT/MT_PASSWORD; never from arguments/files.
|
||||
# Do not add `set -x` to this script.
|
||||
#
|
||||
# exit: 0 PASS, 1 FAIL, 2 BLOCKED, 124 a run hit the wall-clock timeout.
|
||||
set -euo pipefail
|
||||
set +x
|
||||
cd "$(dirname "$0")/.."
|
||||
app="${MT_PLAYABLE_APP:-$PWD/build/export-native-trees-20260911/mtgodot-poc.app}"
|
||||
repo="$PWD"
|
||||
app="${MT_PLAYABLE_APP:-}"
|
||||
config=""
|
||||
output=""
|
||||
mode="playable"
|
||||
timeout="180"
|
||||
timeout="900"
|
||||
allow=0
|
||||
repeat=1
|
||||
suite="full"
|
||||
assets=""
|
||||
serverlist="${MT_PLAYABLE_SERVERLIST:-}"
|
||||
|
||||
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
|
||||
用法: script/playable_test.sh --app APP --config CONFIG [选项]
|
||||
--app APP 导出包路径(候选包;所有 run 必须是同一哈希)
|
||||
--output DIR 本批次根目录,必须不存在或为空(默认 build/playable/suite-<时间>)
|
||||
--suite playable|full 必测用例集合,默认 full
|
||||
--repeat N 独立进程运行次数,默认 1(发布要求见 docs/FIRST-MAC-PLAYABLE-IMPLEMENTATION.md)
|
||||
--timeout-seconds N 每次墙钟超时,默认 900
|
||||
--assets DIR 资源预检根目录(透传 run_client_gate.sh)
|
||||
--serverlist FILE 测试模式 serverlist 覆盖(透传)
|
||||
--allow-gameplay 允许专用测试账号发送移动/战斗/拾取/施法请求
|
||||
退出码: 0 PASS / 1 FAIL / 2 BLOCKED / 124 超时
|
||||
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 ;;
|
||||
--app) app="${2:-}"; shift 2 ;;
|
||||
--config) config="${2:-}"; shift 2 ;;
|
||||
--output) output="${2:-}"; shift 2 ;;
|
||||
--suite) suite="${2:-}"; shift 2 ;;
|
||||
--repeat) repeat="${2:-}"; shift 2 ;;
|
||||
--timeout-seconds) timeout="${2:-}"; shift 2 ;;
|
||||
--assets) assets="${2:-}"; shift 2 ;;
|
||||
--serverlist) serverlist="${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 [ -z "$app" ] || [ ! -d "$app" ]; then echo "PLAYABLE SUITE: BLOCKED 需要 --app 指向候选导出包" >&2; exit 2; fi
|
||||
if [ -z "$config" ] || [ ! -f "$config" ]; then echo "PLAYABLE SUITE: BLOCKED 需要 --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
|
||||
if [ -z "$root" ]; then root="$repo/build/playable/suite-$(date +%Y%m%d-%H%M%S)-$$"; fi
|
||||
if [ -e "$root" ] && [ -n "$(find "$root" -mindepth 1 -maxdepth 1 -print -quit)" ]; then
|
||||
echo "PLAYABLE SUITE: BLOCKED 输出目录已有内容,拒绝混入历史运行:$root" >&2
|
||||
exit 2
|
||||
fi
|
||||
mkdir -p "$root"
|
||||
failed=0
|
||||
root="$(cd "$root" && pwd)"
|
||||
|
||||
# Ask once for the whole batch; child runners inherit the environment only.
|
||||
if [ -z "${MT_ACCOUNT:-}" ] || [ -z "${MT_PASSWORD:-}" ]; then
|
||||
if [ -t 0 ]; then
|
||||
if [ -z "${MT_ACCOUNT:-}" ]; then read -r -s -p "测试账号: " MT_ACCOUNT; echo >&2; fi
|
||||
if [ -z "${MT_PASSWORD:-}" ]; then read -r -s -p "测试密码: " MT_PASSWORD; echo >&2; fi
|
||||
fi
|
||||
fi
|
||||
if [ -z "${MT_ACCOUNT:-}" ] || [ -z "${MT_PASSWORD:-}" ]; then
|
||||
echo "PLAYABLE SUITE: BLOCKED 缺少 MT_ACCOUNT/MT_PASSWORD,且不是交互终端" >&2
|
||||
exit 2
|
||||
fi
|
||||
export MT_ACCOUNT MT_PASSWORD
|
||||
|
||||
# The candidate identity is computed once, before any run, from the same package.
|
||||
bash script/playable_build_info.sh "$app" >"$root/candidate.json"
|
||||
|
||||
runs_json="[]"
|
||||
timed_out=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")
|
||||
run_dir="$root/run-$index"
|
||||
args=(--app "$app" --config "$config" --output "$run_dir" --suite "$suite" --mode "$mode" --timeout-seconds "$timeout")
|
||||
if [ -n "$assets" ]; then args+=(--assets "$assets"); fi
|
||||
if [ -n "$serverlist" ]; then args+=(--serverlist "$serverlist"); fi
|
||||
if [ "$allow" -eq 1 ]; then args+=(--allow-gameplay); fi
|
||||
if ! bash script/run_client_gate.sh "${args[@]}"; then failed=$((failed + 1)); fi
|
||||
set +e
|
||||
bash script/run_client_gate.sh "${args[@]}" </dev/null
|
||||
code=$?
|
||||
set -e
|
||||
if [ "$code" -eq 124 ]; then timed_out=1; fi
|
||||
# A run that never produced a sealed report is still listed so the release fails loudly.
|
||||
runs_json="$(node -e '
|
||||
const fs = require("node:fs");
|
||||
const [runs, dir, name, suite] = process.argv.slice(1);
|
||||
let runId = `${name}-unsealed`;
|
||||
try { runId = JSON.parse(fs.readFileSync(`${dir}/report.json`, "utf8")).run_id || runId; } catch {}
|
||||
const list = JSON.parse(runs);
|
||||
list.push({ run_id: runId, suite, path: name });
|
||||
process.stdout.write(JSON.stringify(list));
|
||||
' "$runs_json" "$run_dir" "run-$index" "$suite")"
|
||||
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"
|
||||
|
||||
node -e '
|
||||
const fs = require("node:fs");
|
||||
const [root, suite, repeat, runs] = process.argv.slice(1);
|
||||
const candidate = JSON.parse(fs.readFileSync(`${root}/candidate.json`, "utf8"));
|
||||
fs.writeFileSync(`${root}/release-manifest.json`, JSON.stringify({
|
||||
schema_version: 1, candidate, required_runs: { [suite]: Number(repeat) }, runs: JSON.parse(runs),
|
||||
}, null, 2) + "\n");
|
||||
' "$root" "$suite" "$repeat" "$runs_json"
|
||||
|
||||
set +e
|
||||
node script/validate_playable_report.mjs --release-dir "$root"
|
||||
release_code=$?
|
||||
set -e
|
||||
if [ "$timed_out" -eq 1 ]; then echo "PLAYABLE SUITE: TIMEOUT root=$root" >&2; exit 124; fi
|
||||
case "$release_code" in
|
||||
0) echo "PLAYABLE SUITE: PASS runs=$repeat root=$root" ;;
|
||||
2) echo "PLAYABLE SUITE: BLOCKED root=$root" >&2 ;;
|
||||
*) echo "PLAYABLE SUITE: FAIL root=$root" >&2; release_code=1 ;;
|
||||
esac
|
||||
exit "$release_code"
|
||||
|
||||
Executable
+63
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env node
|
||||
/* Copy stdin to stdout line by line, replacing the test credentials before any
|
||||
* byte reaches disk. Secrets come only from MT_ACCOUNT / MT_PASSWORD in the
|
||||
* environment and are never printed, logged or passed as arguments.
|
||||
*
|
||||
* node script/redact_stream.mjs <fifo >client.log
|
||||
*
|
||||
* Uses blocking reads on fd 0: async stdin on a macOS FIFO does not reliably
|
||||
* report EOF, which would leave the runner waiting for the redactor. */
|
||||
import fs from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const MIN_LENGTH = 2;
|
||||
|
||||
export function secretLiterals(env = process.env) {
|
||||
return ['MT_PASSWORD', 'MT_ACCOUNT']
|
||||
.map((name) => env[name] || '')
|
||||
.filter((value) => value.length >= MIN_LENGTH)
|
||||
// Longest first so an account contained in a password is not half-replaced.
|
||||
.sort((a, b) => b.length - a.length);
|
||||
}
|
||||
|
||||
export function redact(line, literals = secretLiterals()) {
|
||||
let out = line;
|
||||
for (const literal of literals) out = out.split(literal).join('[redacted]');
|
||||
return out;
|
||||
}
|
||||
|
||||
function pump() {
|
||||
const literals = secretLiterals();
|
||||
const buffer = Buffer.alloc(64 * 1024);
|
||||
let pending = '';
|
||||
const write = (text) => {
|
||||
const bytes = Buffer.from(text);
|
||||
let offset = 0;
|
||||
while (offset < bytes.length) offset += fs.writeSync(1, bytes, offset);
|
||||
};
|
||||
for (;;) {
|
||||
let count;
|
||||
try {
|
||||
count = fs.readSync(0, buffer, 0, buffer.length, null);
|
||||
} catch (error) {
|
||||
if (error.code === 'EAGAIN') continue;
|
||||
if (error.code === 'EOF') break;
|
||||
throw error;
|
||||
}
|
||||
if (count === 0) break;
|
||||
pending += buffer.toString('utf8', 0, count);
|
||||
const lines = pending.split('\n');
|
||||
pending = lines.pop();
|
||||
if (lines.length > 0) write(lines.map((line) => `${redact(line, literals)}\n`).join(''));
|
||||
}
|
||||
// A trailing partial line is still redacted as a whole.
|
||||
if (pending !== '') write(`${redact(pending, literals)}\n`);
|
||||
}
|
||||
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === fs.realpathSync(process.argv[1])) {
|
||||
if (process.argv.includes('--help')) {
|
||||
console.log('usage: node script/redact_stream.mjs < INPUT > OUTPUT (reads MT_ACCOUNT/MT_PASSWORD from env)');
|
||||
} else {
|
||||
pump();
|
||||
}
|
||||
}
|
||||
@@ -11,15 +11,15 @@ run_test() {
|
||||
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
|
||||
if [ "$status" -ne 0 ] || grep -qE 'SCRIPT ERROR|Parse Error|^FAIL:|check\(s\) failed' "$log" || ! grep -qE '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
|
||||
if grep -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
|
||||
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 playable_combat_test.gd gamescene_test.gd equip_model_test.gd equip_rules_test.gd race_motion_assembly_test.gd character_winding_test.gd mob_view_test.gd net_world_vis_test.gd forest_viewpoints_test.gd playable_metrics_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
|
||||
|
||||
+250
-67
@@ -1,125 +1,308 @@
|
||||
#!/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.
|
||||
# INF-02: run ONE exported client process and seal its report after it exits.
|
||||
#
|
||||
# - Owns exactly one child PID (plus its own log redactor); never pkills other
|
||||
# clients, never touches routes/firewall/system caches.
|
||||
# - Credentials only from MT_ACCOUNT/MT_PASSWORD or a hidden TTY prompt; never
|
||||
# from arguments or files. Do not add `set -x` to this script.
|
||||
# - The client writes client-report.json; this script writes the final
|
||||
# report.json only after the real process exit and the full-log gate.
|
||||
# - suite soak (STB-01): also samples RSS of the child PID once per second into
|
||||
# rss.jsonl, seals STB-MEMORY-01 into report.json (can only downgrade), and when
|
||||
# soak.faults is confirmed runs script/playable_fault_proxy.mjs on 127.0.0.1 for
|
||||
# this client's connection only (for every suite, since server.* then points at the
|
||||
# proxy). Sampler and proxy are this script's own children.
|
||||
#
|
||||
# exit: 0 PASS, 1 assert/exit-gate FAIL, 2 config/precondition BLOCKED, 124 wall-clock timeout.
|
||||
set -euo pipefail
|
||||
set +x
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
app_path="${MT_PLAYABLE_APP:-$PWD/build/export-native-trees-20260911/mtgodot-poc.app}"
|
||||
repo="$PWD"
|
||||
app_path="${MT_PLAYABLE_APP:-}"
|
||||
mode="playable"
|
||||
suite="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}"
|
||||
assets_root=""
|
||||
serverlist="${MT_PLAYABLE_SERVERLIST:-}"
|
||||
output_dir=""
|
||||
timeout_seconds="${MT_PLAYABLE_TIMEOUT_SECONDS:-900}"
|
||||
allow_gameplay=0
|
||||
required_arch="${MT_PLAYABLE_REQUIRED_ARCH:-arm64}"
|
||||
godot_bin="${MT_GODOT:-godot}"
|
||||
kill_grace_seconds=10
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
用法: script/run_client_gate.sh --app APP --config CONFIG [选项]
|
||||
选项:
|
||||
--output DIR 本次运行目录,必须不存在或为空
|
||||
--mode MODE 默认 playable
|
||||
--timeout-seconds N 墙钟超时,默认 180
|
||||
--allow-gameplay 允许专用测试账号发送移动/战斗/拾取请求
|
||||
--output DIR 本次运行目录,必须不存在或为空(默认 build/playable/run-<时间>-<pid>)
|
||||
--suite playable|full|soak 必测用例集合,默认 playable;soak 需 --timeout-seconds >= soak.duration_seconds+900
|
||||
--mode MODE 包内 MT_TEST_MODE,默认 playable
|
||||
--assets DIR 资源预检根目录(默认包内 Contents/Resources/assets)
|
||||
--serverlist FILE 测试模式 serverlist 覆盖;预检与客户端使用同一文件
|
||||
--timeout-seconds N 墙钟超时,默认 900;超时 TERM,10 秒后 KILL
|
||||
--allow-gameplay 允许专用测试账号发送移动/战斗/拾取/施法请求
|
||||
环境变量:
|
||||
MT_ACCOUNT / MT_PASSWORD 测试账号(未设置且 stdin 是终端时隐藏输入;否则返回 2)
|
||||
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 ;;
|
||||
--config) config_path="$2"; shift 2 ;;
|
||||
--output) output_dir="$2"; shift 2 ;;
|
||||
--mode) mode="$2"; shift 2 ;;
|
||||
--timeout-seconds) timeout_seconds="$2"; shift 2 ;;
|
||||
--app) app_path="${2:-}"; shift 2 ;;
|
||||
--config) config_path="${2:-}"; shift 2 ;;
|
||||
--output) output_dir="${2:-}"; shift 2 ;;
|
||||
--suite) suite="${2:-}"; shift 2 ;;
|
||||
--mode) mode="${2:-}"; shift 2 ;;
|
||||
--assets) assets_root="${2:-}"; shift 2 ;;
|
||||
--serverlist) serverlist="${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
|
||||
output_ready=0
|
||||
blocked() {
|
||||
echo "PLAYABLE GATE: BLOCKED $*" >&2
|
||||
if [ "$output_ready" -eq 1 ]; then echo "BLOCKED $*" >>"$output_dir/gate.log"; fi
|
||||
exit 2
|
||||
}
|
||||
|
||||
[ -n "$app_path" ] || blocked "需要 --app"
|
||||
[ -n "$config_path" ] && [ -f "$config_path" ] || blocked "配置不存在;不会启动客户端"
|
||||
[ "$suite" = "playable" ] || [ "$suite" = "full" ] || [ "$suite" = "soak" ] || blocked "--suite 只支持 playable/full/soak"
|
||||
[[ "$timeout_seconds" =~ ^[1-9][0-9]*$ ]] || blocked "--timeout-seconds 必须是正整数"
|
||||
config_path="$(cd "$(dirname "$config_path")" && pwd)/$(basename "$config_path")"
|
||||
engine="$app_path/Contents/MacOS/mtgodot-poc"
|
||||
[ -x "$engine" ] || blocked "找不到可执行包:$app_path"
|
||||
if [ -n "$serverlist" ]; then
|
||||
[ -f "$serverlist" ] || blocked "serverlist 不存在"
|
||||
serverlist="$(cd "$(dirname "$serverlist")" && pwd)/$(basename "$serverlist")"
|
||||
fi
|
||||
if [ ! -x "$app_path/Contents/MacOS/mtgodot-poc" ]; then
|
||||
echo "找不到可执行包:$app_path" >&2
|
||||
exit 2
|
||||
|
||||
if [ -z "$output_dir" ]; then
|
||||
output_dir="$repo/build/playable/run-$(date +%Y%m%d-%H%M%S)-$$"
|
||||
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
|
||||
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
|
||||
|
||||
# 2. package identity: signature and architecture before anything runs.
|
||||
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"
|
||||
|
||||
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
|
||||
if [ -z "$assets_root" ]; then
|
||||
if [ -d "$app_path/Contents/Resources/assets" ]; then
|
||||
assets_root="$app_path/Contents/Resources/assets"
|
||||
else
|
||||
assets_root="$repo/assets"
|
||||
fi
|
||||
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
|
||||
# 3. config + address + resource preconditions; resolves the address the client will use.
|
||||
if ! env MT_PLAYABLE_VALIDATE_CONFIG="$config_path" MT_PLAYABLE_SUITE="$suite" \
|
||||
MT_PLAYABLE_ASSETS="$assets_root" MT_PLAYABLE_SERVERLIST="$serverlist" \
|
||||
MT_PLAYABLE_GATE_OUTPUT="$output_dir" \
|
||||
"$godot_bin" --headless --path project --script playable_config_gate.gd >"$output_dir/config.log" 2>&1; then
|
||||
blocked "配置/资源预检失败;不会启动客户端:$output_dir/config.log"
|
||||
fi
|
||||
|
||||
# The config gate validated any soak block (for every suite); read only the fields the runner needs.
|
||||
read -r soak_duration soak_warmup soak_min_rounds fault_status up_auth_host up_auth_port up_game_host up_game_port <<<"$(node -e '
|
||||
const c = JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8"));
|
||||
const s = c.soak || {}; const f = s.faults || {}; const u = f.upstream || {};
|
||||
process.stdout.write([s.duration_seconds || 0, s.warmup_rounds ?? 1, s.min_rounds || 10, f.status || "unconfirmed", u.auth_host || "-", u.auth_port || 0, u.game_host || "-", u.game_port || 0].join(" "));
|
||||
' "$config_path")"
|
||||
if [ "$suite" = "soak" ]; then
|
||||
[ "$timeout_seconds" -ge $((soak_duration + 900)) ] || blocked "soak 需要 --timeout-seconds >= soak.duration_seconds + 900(当前 ${timeout_seconds})"
|
||||
fi
|
||||
|
||||
# Credentials: env or hidden prompt only.
|
||||
if [ -z "${MT_ACCOUNT:-}" ] || [ -z "${MT_PASSWORD:-}" ]; then
|
||||
if [ -t 0 ]; then
|
||||
if [ -z "${MT_ACCOUNT:-}" ]; then read -r -s -p "测试账号: " MT_ACCOUNT; echo >&2; fi
|
||||
if [ -z "${MT_PASSWORD:-}" ]; then read -r -s -p "测试密码: " MT_PASSWORD; echo >&2; fi
|
||||
fi
|
||||
fi
|
||||
[ -n "${MT_ACCOUNT:-}" ] && [ -n "${MT_PASSWORD:-}" ] || blocked "缺少 MT_ACCOUNT/MT_PASSWORD,且不是交互终端"
|
||||
export MT_ACCOUNT MT_PASSWORD
|
||||
|
||||
read_address() {
|
||||
node -e '
|
||||
const a = JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8"));
|
||||
process.stdout.write([a.auth_host, a.auth_port, a.game_host, a.game_port].join(" "));
|
||||
' "$output_dir/server-address.json"
|
||||
}
|
||||
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)"
|
||||
read -r auth_host auth_port game_host game_port <<<"$(read_address)"
|
||||
|
||||
proxy_pid=""
|
||||
stop_proxy() {
|
||||
if [ -n "$proxy_pid" ]; then
|
||||
kill -TERM "$proxy_pid" 2>/dev/null || true
|
||||
wait "$proxy_pid" 2>/dev/null || true
|
||||
proxy_pid=""
|
||||
fi
|
||||
}
|
||||
trap stop_proxy EXIT
|
||||
if [ "$fault_status" = "confirmed" ]; then
|
||||
# Loopback proxy in front of the test server for this client only. server.* points at it, so
|
||||
# playable exit runs sharing the scenario go through it too; only the soak client requests faults.
|
||||
for endpoint in "$up_auth_host:$up_auth_port" "$up_game_host:$up_game_port"; do
|
||||
if ! nc -z -G 3 "${endpoint%:*}" "${endpoint##*:}" >/dev/null 2>&1; then
|
||||
blocked "故障代理上游不可达:$endpoint"
|
||||
fi
|
||||
done
|
||||
routes=(--route "$auth_port=$up_auth_host:$up_auth_port")
|
||||
if [ "$game_port" != "$auth_port" ]; then
|
||||
routes+=(--route "$game_port=$up_game_host:$up_game_port")
|
||||
elif [ "$up_game_host:$up_game_port" != "$up_auth_host:$up_auth_port" ]; then
|
||||
blocked "auth/game 共用代理端口,但上游地址不同"
|
||||
fi
|
||||
node script/playable_fault_proxy.mjs "${routes[@]}" --events "$events" --log "$output_dir/fault-proxy.jsonl" \
|
||||
--run-id "$run_id" >"$output_dir/fault-proxy.log" 2>&1 </dev/null &
|
||||
proxy_pid=$!
|
||||
proxy_wait=0
|
||||
until grep -q '"action":"listening"' "$output_dir/fault-proxy.jsonl" 2>/dev/null; do
|
||||
if ! kill -0 "$proxy_pid" 2>/dev/null || [ "$proxy_wait" -ge 50 ]; then
|
||||
blocked "故障代理未能在 127.0.0.1 监听:$output_dir/fault-proxy.log"
|
||||
fi
|
||||
sleep 0.1
|
||||
proxy_wait=$((proxy_wait + 1))
|
||||
done
|
||||
echo "FAULT PROXY pid=$proxy_pid listen=127.0.0.1:$auth_port,$game_port" >>"$output_dir/gate.log"
|
||||
fi
|
||||
for endpoint in "$auth_host:$auth_port" "$game_host:$game_port"; do
|
||||
if ! nc -z -G 3 "${endpoint%:*}" "${endpoint##*:}" >/dev/null 2>&1; then
|
||||
blocked "服务器端口不可达:$endpoint"
|
||||
fi
|
||||
done
|
||||
char_slot="$(node -e 'process.stdout.write(String(JSON.parse(require("node:fs").readFileSync(process.argv[1],"utf8")).character_slot))' "$config_path")"
|
||||
|
||||
# 1. identity of this run.
|
||||
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"; }
|
||||
|
||||
export MT_TEST_MODE="$mode"
|
||||
export MT_PLAYABLE_SUITE="$suite"
|
||||
export MT_PLAYABLE_CONFIG="$config_path"
|
||||
export MT_PLAYABLE_RUN_ID="$run_id"
|
||||
export MT_PLAYABLE_SERVERLIST="$serverlist"
|
||||
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
|
||||
export MT_BUILD_ENGINE_SHA256="$(build_field engine_sha256)"
|
||||
export MT_BUILD_EXTENSION_SHA256="$(build_field extension_sha256)"
|
||||
export MT_BUILD_PCK_SHA256="$(build_field pck_sha256)"
|
||||
export MT_BUILD_ARCH="$(build_field arch)"
|
||||
export MT_PLAYABLE_ALLOW_GAMEPLAY="$allow_gameplay"
|
||||
|
||||
start_epoch="$(date +%s)"
|
||||
# 4/5. child stdout+stderr -> FIFO -> redactor -> client.log. The child PID is
|
||||
# the engine itself (redirections are applied before exec), so `wait` returns
|
||||
# its real status regardless of the log pipe.
|
||||
fifo="$output_dir/.client.fifo"
|
||||
mkfifo "$fifo"
|
||||
started_ms="$(($(date +%s) * 1000))"
|
||||
set +e
|
||||
env -u MT_ASSETS "$engine" >"$log" 2>&1 &
|
||||
node script/redact_stream.mjs <"$fifo" >"$log" &
|
||||
redactor_pid=$!
|
||||
env -u MT_ASSETS "$engine" >"$fifo" 2>&1 </dev/null &
|
||||
child_pid=$!
|
||||
sampler_pid=""
|
||||
if [ "$suite" = "soak" ]; then
|
||||
node script/playable_soak_metrics.mjs --sample-rss --pid "$child_pid" --output "$output_dir/rss.jsonl" \
|
||||
</dev/null >>"$output_dir/gate.log" 2>&1 &
|
||||
sampler_pid=$!
|
||||
fi
|
||||
SECONDS=0
|
||||
timed_out=0
|
||||
while kill -0 "$child_pid" 2>/dev/null; do
|
||||
now="$(date +%s)"
|
||||
if [ $((now - start_epoch)) -ge "$timeout_seconds" ]; then
|
||||
if [ "$SECONDS" -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
|
||||
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
|
||||
kill -KILL "$child_pid" 2>/dev/null || true
|
||||
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"
|
||||
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
|
||||
process_code=$?
|
||||
# The redactor ends at EOF; bound the wait in case a grandchild kept the FIFO open.
|
||||
redactor_wait=0
|
||||
while kill -0 "$redactor_pid" 2>/dev/null && [ "$redactor_wait" -lt 10 ]; do
|
||||
sleep 1
|
||||
redactor_wait=$((redactor_wait + 1))
|
||||
done
|
||||
if kill -0 "$redactor_pid" 2>/dev/null; then
|
||||
kill -TERM "$redactor_pid" 2>/dev/null
|
||||
echo "PLAYABLE GATE: log redactor did not reach EOF (FIFO held open)" >>"$output_dir/gate.log"
|
||||
fi
|
||||
echo "PLAYABLE GATE: PASS report=$final_report log=$log"
|
||||
wait "$redactor_pid"
|
||||
redactor_code=$?
|
||||
rm -f "$fifo"
|
||||
if [ -n "$sampler_pid" ]; then
|
||||
# The sampler stops by itself once the child is gone; bound the wait anyway.
|
||||
sampler_wait=0
|
||||
while kill -0 "$sampler_pid" 2>/dev/null && [ "$sampler_wait" -lt 10 ]; do
|
||||
sleep 1
|
||||
sampler_wait=$((sampler_wait + 1))
|
||||
done
|
||||
if kill -0 "$sampler_pid" 2>/dev/null; then kill -TERM "$sampler_pid" 2>/dev/null; fi
|
||||
wait "$sampler_pid"
|
||||
fi
|
||||
stop_proxy
|
||||
|
||||
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" --redactor-code "$redactor_code" --require-pass
|
||||
gate_status=$?
|
||||
if [ "$suite" = "soak" ]; then
|
||||
node script/playable_soak_metrics.mjs --seal --report "$final_report" --rss "$output_dir/rss.jsonl" --events "$events" \
|
||||
--warmup-rounds "$soak_warmup" --min-rounds "$soak_min_rounds"
|
||||
memory_status=$?
|
||||
if [ "$gate_status" -ne 0 ] && [ "$gate_status" -ne 2 ]; then gate_status=1
|
||||
elif [ "$memory_status" -ne 0 ] && [ "$memory_status" -ne 2 ]; then gate_status=1
|
||||
elif [ "$gate_status" -eq 2 ] || [ "$memory_status" -eq 2 ]; then gate_status=2
|
||||
fi
|
||||
fi
|
||||
set -e
|
||||
|
||||
if [ "$timed_out" -eq 1 ]; then
|
||||
echo "PLAYABLE GATE: TIMEOUT raw_exit=$process_code report=$final_report log=$log" >&2
|
||||
exit 124
|
||||
fi
|
||||
case "$gate_status" in
|
||||
0) echo "PLAYABLE GATE: PASS run_id=$run_id report=$final_report" ;;
|
||||
2) echo "PLAYABLE GATE: BLOCKED raw_exit=$process_code report=$final_report" >&2 ;;
|
||||
*) echo "PLAYABLE GATE: FAIL raw_exit=$process_code report=$final_report log=$log" >&2; gate_status=1 ;;
|
||||
esac
|
||||
exit "$gate_status"
|
||||
|
||||
@@ -1,15 +1,37 @@
|
||||
#!/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. */
|
||||
/* INF-02 / REL-01 report gate.
|
||||
*
|
||||
* Run mode (called by run_client_gate.sh after the child exited):
|
||||
* --report client-report.json --output report.json --log client.log
|
||||
* --process-code N --timed-out 0|1 --run-id ID --suite S
|
||||
* --required-cases required-cases.json --build build.json --events events.jsonl
|
||||
* --started-ms MS [--redactor-code N] [--require-pass]
|
||||
* Seals the final report. The client can never mark a run PASS by itself.
|
||||
*
|
||||
* Release mode:
|
||||
* --release-dir DIR reads DIR/release-manifest.json, checks every explicitly
|
||||
* listed sealed run (same candidate hashes, PASS, counts), writes
|
||||
* DIR/release-report.json. History is never scanned.
|
||||
*
|
||||
* exit: 0 PASS, 1 FAIL, 2 BLOCKED (only BLOCKED/SKIP findings, clean process+log).
|
||||
* Credential literals are read from MT_ACCOUNT/MT_PASSWORD and never printed. */
|
||||
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;
|
||||
const SUITES = new Set(['playable', 'full', 'soak', 'forest_render']);
|
||||
// Same rule as script/package_render_test.sh, plus parse errors. No RID allow-list.
|
||||
const ERROR_RE = /SCRIPT ERROR|Parse Error|^ERROR:|leaked at exit|shaders of type .* were never freed/;
|
||||
const EVENT_KEYS = ['monotonic_us', 'run_id', 'case_id', 'connection_epoch', 'stage', 'kind', 'actor_vid', 'target_vid', 'payload'];
|
||||
const HASH_KEYS = ['engine_sha256', 'extension_sha256', 'pck_sha256'];
|
||||
const HEX64 = /^[0-9a-f]{64}$/;
|
||||
const SECRET_MIN_SCAN = 4;
|
||||
const MTIME_SLACK_MS = 1000;
|
||||
|
||||
function usage() {
|
||||
console.error('usage: validate_playable_report.mjs --report PATH [--output PATH] [--log PATH] [--process-code N] [--run-id ID] [--require-pass]');
|
||||
console.error('usage: validate_playable_report.mjs --report PATH --output PATH --log PATH --process-code N --run-id ID '
|
||||
+ '--suite S --required-cases PATH --build PATH --events PATH --started-ms MS [--timed-out 0|1] [--require-pass]\n'
|
||||
+ ' validate_playable_report.mjs --release-dir DIR');
|
||||
}
|
||||
|
||||
function argsOf(argv) {
|
||||
@@ -17,97 +39,281 @@ function argsOf(argv) {
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
if (arg === '--require-pass') out.require_pass = true;
|
||||
else if (arg === '--help') out.help = 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');
|
||||
if (out.help) return out;
|
||||
if (!out.release_dir) {
|
||||
for (const key of ['report', 'output', 'log', 'process_code', 'run_id', 'suite', 'required_cases', 'build', 'events', 'started_ms']) {
|
||||
if (out[key] === undefined) throw new Error(`--${key.replaceAll('_', '-')} is required`);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function readJson(file) {
|
||||
try { return JSON.parse(fs.readFileSync(file, 'utf8')); }
|
||||
catch (error) { return { __read_error: `${file}: ${error.message}` }; }
|
||||
catch (error) { return { __read_error: `${path.basename(file)}: ${error.code || 'invalid JSON'}` }; }
|
||||
}
|
||||
|
||||
function validate(report, options) {
|
||||
const errors = [];
|
||||
const readError = report?.__read_error;
|
||||
if (readError) errors.push(readError);
|
||||
function secretLiterals() {
|
||||
return ['MT_ACCOUNT', 'MT_PASSWORD'].map((name) => process.env[name] || '').filter((v) => v.length >= SECRET_MIN_SCAN);
|
||||
}
|
||||
|
||||
function scanSecrets(label, text, errors) {
|
||||
for (const literal of secretLiterals()) {
|
||||
if (text.includes(literal)) errors.fail.push(`${label} contains a credential literal`);
|
||||
}
|
||||
}
|
||||
|
||||
class Findings {
|
||||
constructor() { this.fail = []; this.blocked = []; }
|
||||
get all() { return [...new Set([...this.fail, ...this.blocked])]; }
|
||||
}
|
||||
|
||||
function checkBuild(build, expected, prefix, errors) {
|
||||
for (const key of HASH_KEYS) {
|
||||
if (typeof build?.[key] !== 'string' || !HEX64.test(build[key])) errors.fail.push(`${prefix}build.${key} is missing or not sha256`);
|
||||
else if (expected && build[key] !== expected[key]) errors.fail.push(`${prefix}build.${key} differs from the candidate`);
|
||||
}
|
||||
if (typeof build?.arch !== 'string' || build.arch.trim() === '') errors.fail.push(`${prefix}build.arch is missing`);
|
||||
else if (expected && build.arch !== expected.arch) errors.fail.push(`${prefix}build.arch differs from the candidate`);
|
||||
}
|
||||
|
||||
function validateReport(report, options, errors) {
|
||||
if (report.__read_error) {
|
||||
errors.fail.push(`client report unreadable: ${report.__read_error}`);
|
||||
return;
|
||||
}
|
||||
if (!report || typeof report !== 'object' || Array.isArray(report)) {
|
||||
errors.push('report root must be an object');
|
||||
return errors;
|
||||
errors.fail.push('report root must be an object');
|
||||
return;
|
||||
}
|
||||
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');
|
||||
if (report.schema_version !== 1) errors.fail.push('schema_version must be 1');
|
||||
if (typeof report.run_id !== 'string' || report.run_id.trim() === '') errors.fail.push('run_id is missing');
|
||||
else if (report.run_id !== options.run_id) errors.fail.push('run_id does not match this run (stale or foreign report)');
|
||||
if (report.suite !== options.suite) errors.fail.push(`suite ${report.suite} does not match ${options.suite}`);
|
||||
if (!VALID.has(report.status)) errors.fail.push(`invalid report status: ${report.status}`);
|
||||
const expectedBuild = readJson(options.build);
|
||||
if (expectedBuild.__read_error) errors.fail.push(`build.json unreadable: ${expectedBuild.__read_error}`);
|
||||
checkBuild(report.build, expectedBuild.__read_error ? null : expectedBuild, '', errors);
|
||||
|
||||
const required = readJson(options.required_cases);
|
||||
const requiredIds = Array.isArray(required?.cases) ? required.cases : null;
|
||||
if (!requiredIds || requiredIds.length === 0) errors.fail.push('required-cases.json is missing or empty');
|
||||
else if (required.suite !== options.suite) errors.fail.push('required-cases.json suite mismatch');
|
||||
const cases = Array.isArray(report.cases) ? report.cases : [];
|
||||
if (cases.length === 0) errors.fail.push('cases must be non-empty');
|
||||
const seen = new Map();
|
||||
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');
|
||||
errors.fail.push('case id is missing');
|
||||
continue;
|
||||
}
|
||||
if (!VALID.has(item.status)) errors.push(`${item.id}: invalid status`);
|
||||
if (seen.has(item.id)) errors.fail.push(`${item.id}: duplicated case`);
|
||||
seen.set(item.id, item);
|
||||
if (!VALID.has(item.status)) errors.fail.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`);
|
||||
if (item.status === 'FAIL') errors.fail.push(`${item.id}: FAIL ${item.reason || ''}`.trim());
|
||||
if (item.status === 'BLOCKED' || item.status === 'SKIP') errors.blocked.push(`${item.id}: ${item.status} is not releasable (${item.reason || 'no reason'})`);
|
||||
if (!Array.isArray(item.evidence)) errors.fail.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 resolved = typeof evidence === 'string' ? path.resolve(path.dirname(options.report), evidence) : '';
|
||||
if (!resolved || !resolved.startsWith(path.resolve(path.dirname(options.report)) + path.sep)) errors.fail.push(`${item.id}: evidence path escapes the run directory`);
|
||||
else if (!fs.existsSync(resolved)) errors.fail.push(`${item.id}: missing evidence ${evidence}`);
|
||||
}
|
||||
}
|
||||
if (requiredIds) {
|
||||
for (const id of requiredIds) if (!seen.has(id)) errors.fail.push(`${id}: required case missing from report`);
|
||||
for (const id of seen.keys()) if (!requiredIds.includes(id)) errors.fail.push(`${id}: case is not in the required list`);
|
||||
if (JSON.stringify(report.required_cases) !== JSON.stringify(requiredIds)) errors.fail.push('report required_cases differs from the runner list');
|
||||
}
|
||||
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 (!coverage || coverage.required !== cases.length || coverage.passed !== passed
|
||||
|| (requiredIds && coverage.required !== requiredIds.length)) errors.fail.push('coverage does not match cases');
|
||||
if (!Array.isArray(report.failures) || !Array.isArray(report.blocked)) errors.fail.push('failures and blocked must be arrays');
|
||||
else if (report.failures.length > 0) errors.fail.push('client report contains failures');
|
||||
if (report.status === 'PASS' && (passed !== cases.length || report.blocked?.length)) errors.fail.push('client PASS contradicts its cases');
|
||||
|
||||
// Staleness: the client report must have been written during this run.
|
||||
try {
|
||||
const mtime = fs.statSync(options.report).mtimeMs;
|
||||
if (mtime + MTIME_SLACK_MS < Number(options.started_ms)) errors.fail.push('client report predates this run (stale)');
|
||||
} catch { /* unreadable already reported */ }
|
||||
|
||||
// Event contract.
|
||||
let lines = [];
|
||||
if (fs.existsSync(options.events)) {
|
||||
const text = fs.readFileSync(options.events, 'utf8');
|
||||
scanSecrets('events', text, errors);
|
||||
lines = text.split('\n').filter((line) => line.trim() !== '');
|
||||
}
|
||||
if (report.status === 'PASS' && lines.length === 0) errors.fail.push('PASS report has no events');
|
||||
if (Number(report.event_count ?? 0) !== lines.length) errors.fail.push(`event_count ${report.event_count} does not match events lines ${lines.length}`);
|
||||
let badEvents = 0;
|
||||
let lastUs = -Infinity;
|
||||
for (const line of lines) {
|
||||
let event;
|
||||
try { event = JSON.parse(line); } catch { badEvents += 1; continue; }
|
||||
if (!EVENT_KEYS.every((key) => Object.hasOwn(event, key)) || event.run_id !== options.run_id
|
||||
|| typeof event.payload !== 'object' || event.payload === null || Array.isArray(event.payload)
|
||||
|| !Number.isInteger(event.connection_epoch) || event.monotonic_us < lastUs) badEvents += 1;
|
||||
else lastUs = event.monotonic_us;
|
||||
}
|
||||
if (badEvents > 0) errors.fail.push(`${badEvents} events violate the contract (keys/run_id/order)`);
|
||||
}
|
||||
|
||||
function validateProcess(options, errors) {
|
||||
const code = Number(options.process_code);
|
||||
if (options.timed_out === '1') errors.fail.push(`wall-clock timeout (raw exit ${code})`);
|
||||
if (code !== 0) errors.fail.push(`child process exit code ${code}`);
|
||||
if (options.redactor_code !== undefined && Number(options.redactor_code) !== 0) errors.fail.push('log redactor did not finish cleanly');
|
||||
if (!fs.existsSync(options.log)) {
|
||||
errors.fail.push('client log does not exist');
|
||||
return;
|
||||
}
|
||||
const text = fs.readFileSync(options.log, 'utf8');
|
||||
scanSecrets('client log', text, errors);
|
||||
const hits = text.split(/\r?\n/).map((line, index) => [line, index]).filter(([line]) => ERROR_RE.test(line));
|
||||
// Report the line numbers and the matched rule, not arbitrary log text.
|
||||
for (const [line, index] of hits.slice(0, 50)) errors.fail.push(`log:${index + 1}: ${line.match(ERROR_RE)[0]}`);
|
||||
if (hits.length > 50) errors.fail.push(`log: ${hits.length - 50} more error lines`);
|
||||
}
|
||||
|
||||
function sealRun(options) {
|
||||
const errors = new Findings();
|
||||
const input = readJson(options.report);
|
||||
validateReport(input, options, errors);
|
||||
validateProcess(options, errors);
|
||||
const report = input.__read_error ? {
|
||||
schema_version: 1, run_id: options.run_id, suite: options.suite, status: 'FAIL',
|
||||
build: readJson(options.build), cases: [], failures: [input.__read_error], blocked: [],
|
||||
coverage: { required: 0, passed: 0 },
|
||||
} : input;
|
||||
let status = 'PASS';
|
||||
if (errors.fail.length > 0) status = 'FAIL';
|
||||
else if (errors.blocked.length > 0) status = 'BLOCKED';
|
||||
if (options.require_pass && status === 'PASS' && report.status !== 'PASS') { status = 'FAIL'; errors.fail.push(`client status ${report.status}`); }
|
||||
report.status = status;
|
||||
report.exit_gate = {
|
||||
checked: true,
|
||||
process_code: Number(options.process_code),
|
||||
timed_out: options.timed_out === '1',
|
||||
errors: errors.all,
|
||||
};
|
||||
const text = `${JSON.stringify(report, null, 2)}\n`;
|
||||
const leak = new Findings();
|
||||
scanSecrets('final report', text, leak);
|
||||
const output = options.output;
|
||||
fs.mkdirSync(path.dirname(path.resolve(output)), { recursive: true });
|
||||
if (leak.fail.length > 0) {
|
||||
// Never persist a report that would carry the secret; keep only the verdict.
|
||||
fs.writeFileSync(output, `${JSON.stringify({ schema_version: 1, run_id: options.run_id, suite: options.suite, status: 'FAIL',
|
||||
exit_gate: { checked: true, process_code: Number(options.process_code), timed_out: options.timed_out === '1', errors: leak.fail } }, null, 2)}\n`);
|
||||
console.error('PLAYABLE REPORT: FAIL (credential literal in report)');
|
||||
return 1;
|
||||
}
|
||||
fs.writeFileSync(output, text);
|
||||
if (status !== 'PASS') {
|
||||
console.error(`PLAYABLE REPORT: ${status} (${errors.all.length} findings)`);
|
||||
errors.all.slice(0, 40).forEach((error) => console.error(`- ${error}`));
|
||||
return status === 'BLOCKED' ? 2 : 1;
|
||||
}
|
||||
console.log(`PLAYABLE REPORT: PASS ${output}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
function sealRelease(dir) {
|
||||
const errors = new Findings();
|
||||
const root = path.resolve(dir);
|
||||
const manifest = readJson(path.join(root, 'release-manifest.json'));
|
||||
const summary = { schema_version: 1, status: 'FAIL', candidate: null, runs: [], counts: {}, errors: [] };
|
||||
if (manifest.__read_error) errors.fail.push(`release-manifest.json unreadable: ${manifest.__read_error}`);
|
||||
else {
|
||||
if (manifest.schema_version !== 1) errors.fail.push('manifest schema_version must be 1');
|
||||
checkBuild(manifest.candidate, null, 'manifest.', errors);
|
||||
summary.candidate = manifest.candidate;
|
||||
const runs = Array.isArray(manifest.runs) ? manifest.runs : [];
|
||||
if (runs.length === 0) errors.fail.push('manifest lists no runs');
|
||||
const ids = new Set();
|
||||
const passedBySuite = {};
|
||||
const blockedBySuite = {};
|
||||
for (const run of runs) {
|
||||
const label = `run ${run?.run_id ?? '?'}`;
|
||||
if (typeof run?.run_id !== 'string' || typeof run?.path !== 'string' || !SUITES.has(run?.suite)) {
|
||||
errors.fail.push(`${label}: run_id/path/suite missing`);
|
||||
continue;
|
||||
}
|
||||
if (ids.has(run.run_id)) errors.fail.push(`${label}: listed twice`);
|
||||
ids.add(run.run_id);
|
||||
const reportPath = path.resolve(root, run.path, 'report.json');
|
||||
if (!reportPath.startsWith(root + path.sep)) {
|
||||
errors.fail.push(`${label}: path escapes the release directory`);
|
||||
continue;
|
||||
}
|
||||
const report = readJson(reportPath);
|
||||
const entry = { run_id: run.run_id, suite: run.suite, path: run.path, status: report.status ?? 'MISSING' };
|
||||
summary.runs.push(entry);
|
||||
if (report.__read_error) {
|
||||
// run_client_gate.sh records precondition stops in gate.log before any client starts.
|
||||
const gateLog = path.resolve(root, run.path, 'gate.log');
|
||||
const preconditionBlocked = fs.existsSync(gateLog) && /^BLOCKED /m.test(fs.readFileSync(gateLog, 'utf8'));
|
||||
if (preconditionBlocked) {
|
||||
entry.status = 'BLOCKED';
|
||||
blockedBySuite[run.suite] = (blockedBySuite[run.suite] || 0) + 1;
|
||||
errors.blocked.push(`${label}: precondition BLOCKED before launch`);
|
||||
} else errors.fail.push(`${label}: sealed report missing`);
|
||||
continue;
|
||||
}
|
||||
if (report.run_id !== run.run_id) errors.fail.push(`${label}: report run_id mismatch`);
|
||||
if (report.suite !== run.suite) errors.fail.push(`${label}: report suite mismatch`);
|
||||
if (report.exit_gate?.checked !== true || report.exit_gate?.process_code !== 0 || report.exit_gate?.timed_out !== false) errors.fail.push(`${label}: report not sealed by a clean exit gate`);
|
||||
checkBuild(report.build, manifest.candidate, `${label}: `, errors);
|
||||
if (report.status === 'PASS') passedBySuite[run.suite] = (passedBySuite[run.suite] || 0) + 1;
|
||||
else if (report.status === 'BLOCKED') {
|
||||
blockedBySuite[run.suite] = (blockedBySuite[run.suite] || 0) + 1;
|
||||
errors.blocked.push(`${label}: BLOCKED`);
|
||||
}
|
||||
else errors.fail.push(`${label}: ${report.status}`);
|
||||
}
|
||||
const requiredRuns = manifest.required_runs && typeof manifest.required_runs === 'object' ? manifest.required_runs : {};
|
||||
if (Object.keys(requiredRuns).length === 0) errors.fail.push('manifest required_runs is empty');
|
||||
for (const [suite, count] of Object.entries(requiredRuns)) {
|
||||
const got = passedBySuite[suite] || 0;
|
||||
summary.counts[suite] = { required: count, passed: got };
|
||||
if (!Number.isInteger(count) || count < 1) errors.fail.push(`required_runs.${suite} must be a positive integer`);
|
||||
else if (got < count) {
|
||||
// Missing passes explained by BLOCKED runs stay BLOCKED; anything else is a FAIL.
|
||||
const message = `${suite}: ${got}/${count} passing runs`;
|
||||
if (got + (blockedBySuite[suite] || 0) >= count) errors.blocked.push(message);
|
||||
else errors.fail.push(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
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)];
|
||||
summary.status = errors.fail.length ? 'FAIL' : (errors.blocked.length ? 'BLOCKED' : 'PASS');
|
||||
summary.errors = errors.all;
|
||||
fs.writeFileSync(path.join(root, 'release-report.json'), `${JSON.stringify(summary, null, 2)}\n`);
|
||||
if (summary.status !== 'PASS') {
|
||||
console.error(`PLAYABLE RELEASE: ${summary.status}`);
|
||||
summary.errors.forEach((error) => console.error(`- ${error}`));
|
||||
return summary.status === 'BLOCKED' ? 2 : 1;
|
||||
}
|
||||
console.log(`PLAYABLE RELEASE: PASS ${path.join(root, 'release-report.json')}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
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;
|
||||
if (options.help) { usage(); return 0; }
|
||||
if (options.release_dir) return sealRelease(options.release_dir);
|
||||
return sealRun(options);
|
||||
}
|
||||
|
||||
process.exitCode = main();
|
||||
|
||||
Reference in New Issue
Block a user