#!/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;