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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ft3kXpNdLbKEfg5uDLfqVF
This commit is contained in:
shenlei
2026-09-11 22:06:48 +09:00
co-authored by Claude Opus 5
parent 374d4165d8
commit f39a55fdd5
66 changed files with 9189 additions and 951 deletions
+103
View File
@@ -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;