131 lines
6.9 KiB
JavaScript
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();
|