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