Files
mtgodot-poc/script/audit_playable_maps.mjs
T

131 lines
6.9 KiB
JavaScript

#!/usr/bin/env node
/* Static prerequisite audit for the first playable-map fixture. It does not
* connect to a server and cannot prove which monsters a live server spawned. */
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
function parseArgs(argv) {
const out = { assets: 'assets', output: '', maps: ['outdoortrent/metin2_map_trent', 'outdoortrent02/metin2_map_trent02'] };
for (let i = 0; i < argv.length; i += 1) {
const a = argv[i];
if (a === '--help') { console.log('usage: audit_playable_maps.mjs [--assets DIR] [--maps DIR[,DIR...]] [--output FILE]'); process.exit(0); }
if (!a.startsWith('--') || i + 1 >= argv.length) throw new Error(`invalid argument: ${a}`);
const k = a.slice(2).replaceAll('-', '_');
out[k] = argv[++i];
}
if (typeof out.maps === 'string') out.maps = out.maps.split(',').map(s => s.trim()).filter(Boolean);
return out;
}
function filesUnder(root) {
if (!fs.existsSync(root)) return [];
const result = [];
const visit = dir => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) visit(full); else result.push(full);
}
};
visit(root);
return result;
}
const norm = value => value.replaceAll('\\', '/').toLowerCase().replace(/^.*?ymir work\//, 'ymir work/');
const read = file => fs.readFileSync(file, 'utf8');
const sha256 = file => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
function parseProperties(allFiles) {
const byId = new Map();
for (const file of allFiles.filter(f => f.toLowerCase().endsWith('.prt'))) {
const lines = read(file).split(/\r?\n/);
const id = lines[1]?.trim();
if (!/^\d+$/.test(id || '')) continue;
const text = lines.join('\n');
const tree = text.match(/^\s*treefile\s+"([^"]+)"/im)?.[1] || '';
const type = text.match(/^\s*propertytype\s+"([^"]+)"/im)?.[1] || '';
byId.set(id, { id: Number(id), file, type, treefile: tree });
}
return byId;
}
function parseArea(file) {
const text = read(file);
const objects = [];
const blockRe = /Start Object\d+\r?\n([^]*?)\r?\nEnd Object/g;
for (const match of text.matchAll(blockRe)) {
const lines = match[1].split(/\r?\n/).map(s => s.trim()).filter(Boolean);
if (lines.length < 2) continue;
const id = Number(lines[1]);
if (Number.isInteger(id)) objects.push({ property_id: id, position: lines[0] });
}
return objects;
}
function parseNpcList(file) {
const result = new Map();
if (!fs.existsSync(file)) return result;
for (const line of read(file).split(/\r?\n/)) {
const parts = line.trim().split(/\s+/);
if (parts.length >= 2 && /^\d+$/.test(parts[0])) result.set(Number(parts[0]), parts[1]);
}
return result;
}
function mobRecord(assets, code, race, allFiles) {
const candidates = allFiles.filter(file => file.toLowerCase().endsWith('.msm') && path.basename(file, '.msm').toLowerCase() === code.toLowerCase())
.filter(file => file.toLowerCase().includes(`/monster2/`) || file.toLowerCase().includes(`/monster/`));
const msm = candidates[0] || '';
const dir = msm ? path.dirname(msm) : '';
const sibling = name => dir && fs.existsSync(path.join(dir, name)) ? path.join(dir, name) : '';
const motlist = sibling('motlist.txt');
const actions = motlist ? [...read(motlist).matchAll(/^\s*\S+\s+([A-Z0-9_]+)\s+([^\s]+\.msa)/gmi)].map(m => ({ name: m[1], file: m[2], exists: fs.existsSync(path.join(dir, m[2])) })) : [];
return {
race, code, directory: dir ? path.relative(assets, dir).replaceAll('\\', '/') : '',
files: { msm: Boolean(msm), gr2: Boolean(sibling(`${code}.gr2`)), dds: Boolean(sibling(`${code}.dds`)), motlist: Boolean(motlist) },
action_count: actions.length, actions,
required_states: Object.fromEntries(['WAIT', 'RUN', 'NORMAL_ATTACK', 'FRONT_DAMAGE', 'FRONT_DEAD'].map(state => [state, actions.some(a => a.name === state)])),
source_sha256: msm ? sha256(msm) : null,
};
}
function main() {
const options = parseArgs(process.argv.slice(2));
const assets = path.resolve(options.assets);
const allFiles = filesUnder(assets);
const manifestPath = path.join(assets, 'TreeGeometry', 'manifest.json');
const manifest = fs.existsSync(manifestPath) ? JSON.parse(read(manifestPath)) : { trees: {} };
const sourceByName = new Map(Object.values(manifest.trees || {}).map(item => [norm(item.source), item]));
const properties = parseProperties(allFiles);
const maps = options.maps.map(key => {
const root = path.join(assets, key);
const areaFiles = filesUnder(root).filter(f => path.basename(f).toLowerCase() === 'areadata.txt');
const objects = areaFiles.flatMap(file => parseArea(file).map(item => ({ ...item, file: path.relative(assets, file).replaceAll('\\', '/') })));
const propertyIds = [...new Set(objects.map(item => item.property_id))];
const resolved = propertyIds.map(id => {
const prop = properties.get(String(id));
if (!prop) return { property_id: id, resolved: false };
const record = sourceByName.get(norm(prop.treefile));
return { property_id: id, resolved: true, property_type: prop.type, property_file: path.relative(assets, prop.file).replaceAll('\\', '/'),
treefile: prop.treefile, native_tree: Boolean(record), source_sha256: record?.source_sha256 || null };
});
const treeObjects = objects.filter(item => resolved.find(r => r.property_id === item.property_id)?.native_tree);
return { key, exists: fs.existsSync(root), setting: fs.existsSync(path.join(root, 'setting.txt')),
area_files: areaFiles.length, object_count: objects.length, property_count: propertyIds.length,
resolved_properties: resolved.length, unresolved_property_ids: resolved.filter(r => !r.resolved).map(r => r.property_id),
tree_object_count: treeObjects.length, native_tree_object_count: treeObjects.length,
properties: resolved };
});
const npc = parseNpcList(path.join(assets, 'root/npclist.txt'));
const treeMobRaces = [...Array(15)].map((_, i) => 2301 + i).filter(race => npc.has(race));
const mobs = treeMobRaces.map(race => mobRecord(assets, npc.get(race), race, allFiles));
const report = { schema_version: 1, suite: 'playable-map-assets', assets_root: assets,
maps, tree_monster_candidates: mobs,
caveat: 'Static map AreaData and local resources only; server spawn membership and visual correctness require live/map-render tests.' };
if (options.output) { fs.mkdirSync(path.dirname(path.resolve(options.output)), { recursive: true }); fs.writeFileSync(options.output, `${JSON.stringify(report, null, 2)}\n`); }
console.log(JSON.stringify({ maps: maps.map(m => ({ key: m.key, exists: m.exists, objects: m.object_count, native_tree_objects: m.native_tree_object_count, unresolved: m.unresolved_property_ids.length })), tree_monsters: mobs.length }));
if (maps.some(m => !m.exists || !m.setting || m.area_files === 0) || mobs.some(m => !m.files.msm || !m.files.gr2 || !m.files.dds || !m.files.motlist)) process.exitCode = 1;
}
main();