import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; import { join, sep } from 'node:path'; import { createServer } from 'vite'; const battleMapDir = join('src', 'assets', 'images', 'battle'); const storyDir = join('src', 'assets', 'images', 'story'); const titleImagePath = join('src', 'assets', 'images', 'taoyuan-oath-title.png'); const reportPath = join('docs', 'visual-asset-quality-audit.md'); const targetViewport = { width: 1280, height: 720 }; const expectedStorySize = { width: 1672, height: 941 }; const server = await createServer({ logLevel: 'error', server: { middlewareMode: true }, appType: 'custom' }); try { const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts'); const { battleMapAssets } = await server.ssrLoadModule('/src/game/data/battleMapAssets.ts'); const { storyBackgroundAssets, storyBackgroundKeysFor } = await server.ssrLoadModule('/src/game/data/storyAssets.ts'); const scenarioModule = await server.ssrLoadModule('/src/game/data/scenario.ts'); const battleRows = collectBattleRows(battleScenarios, battleMapAssets); const storyUsage = collectStoryUsage(scenarioModule, storyBackgroundAssets, storyBackgroundKeysFor); const storyRows = collectStoryRows(storyBackgroundAssets, storyUsage); const report = renderReport({ battleRows, storyRows, storyUsage }); writeFileSync(reportPath, report, 'utf8'); console.log(`Wrote ${reportPath}`); } finally { await server.close(); } function collectBattleRows(battleScenarios, battleMapAssets) { return Object.values(battleScenarios) .map((scenario) => { const assetUrl = battleMapAssets[scenario.mapTextureKey]; const path = assetUrlToPath(assetUrl, 'src/assets/images/battle/'); const image = path ? readImageMetadata(path) : undefined; const widthPxPerTile = image ? image.width / scenario.map.width : 0; const heightPxPerTile = image ? image.height / scenario.map.height : 0; const pxPerTile = Math.min(widthPxPerTile, heightPxPerTile); const expectedAspect = scenario.map.width / scenario.map.height; const actualAspect = image ? image.width / image.height : 0; const aspectDelta = image ? Math.abs(actualAspect - expectedAspect) / expectedAspect : 1; const concerns = battleConcerns({ image, pxPerTile, aspectDelta }); const score = concerns.reduce((sum, concern) => sum + concern.weight, 0); return { id: scenario.id, title: scenario.title, mapKey: scenario.mapTextureKey, path, mapColumns: scenario.map.width, mapRows: scenario.map.height, width: image?.width ?? 0, height: image?.height ?? 0, bytes: image?.bytes ?? 0, bytesPerPixel: image ? image.bytes / (image.width * image.height) : 0, pxPerTile, aspectDelta, concerns: concerns.map((concern) => concern.label), score }; }) .sort((left, right) => battleOrder(left, right)); } function battleConcerns({ image, pxPerTile, aspectDelta }) { const concerns = []; if (!image) { concerns.push({ label: 'missing readable image metadata', weight: 8 }); return concerns; } if (pxPerTile < 128) { concerns.push({ label: 'low tile pixel density', weight: 6 }); } else if (pxPerTile < 144) { concerns.push({ label: 'reduced tile pixel density', weight: 4 }); } else if (pxPerTile < 160) { concerns.push({ label: 'watch tile pixel density', weight: 2 }); } if (image.bytes / (image.width * image.height) < 0.035) { concerns.push({ label: 'very low WebP byte density', weight: 4 }); } else if (image.bytes / (image.width * image.height) < 0.045) { concerns.push({ label: 'low WebP byte density', weight: 2 }); } if (aspectDelta > 0.005) { concerns.push({ label: 'map/image aspect mismatch', weight: 5 }); } return concerns; } function battleOrder(left, right) { return ( right.score - left.score || left.pxPerTile - right.pxPerTile || left.bytesPerPixel - right.bytesPerPixel || left.id.localeCompare(right.id) ); } function collectStoryUsage(scenarioModule, storyBackgroundAssets, storyBackgroundKeysFor) { const usageByPath = new Map(); const pageReferences = []; Object.entries(scenarioModule).forEach(([exportName, value]) => { if (!Array.isArray(value)) { return; } value .filter((entry) => entry && typeof entry === 'object' && typeof entry.background === 'string') .forEach((page) => { const candidateKeys = storyBackgroundKeysFor(page.background); const resolvedKey = candidateKeys.find((key) => storyBackgroundAssets[key]) ?? page.background; const path = assetUrlToPath(storyBackgroundAssets[resolvedKey], 'src/assets/images/'); const usage = usageByPath.get(path) ?? { path, keys: new Set(), baseBackgrounds: new Set(), pageCount: 0, pageExamples: [] }; usage.keys.add(resolvedKey); usage.baseBackgrounds.add(page.background); usage.pageCount += 1; if (usage.pageExamples.length < 3) { usage.pageExamples.push(`${exportName}/${page.id ?? 'unnamed'}`); } usageByPath.set(path, usage); pageReferences.push({ exportName, pageId: page.id, background: page.background, resolvedKey, path }); }); }); return { pageReferences, byPath: usageByPath }; } function collectStoryRows(storyBackgroundAssets, storyUsage) { const sourcePaths = new Set([ ...imageFiles(storyDir).map((fileName) => join(storyDir, fileName)), titleImagePath ]); Object.values(storyBackgroundAssets).forEach((url) => { const path = assetUrlToPath(url, 'src/assets/images/'); if (path) { sourcePaths.add(path); } }); return [...sourcePaths] .sort() .map((path) => { const image = readImageMetadata(path); const usage = storyUsage.byPath.get(path); const concerns = storyConcerns({ image, usage }); const score = concerns.reduce((sum, concern) => sum + concern.weight, 0); return { path, keySummary: usage ? [...usage.keys].sort().join(', ') : storyKeyFromPath(path), width: image?.width ?? 0, height: image?.height ?? 0, bytes: image?.bytes ?? 0, bytesPerPixel: image ? image.bytes / (image.width * image.height) : 0, pageCount: usage?.pageCount ?? 0, pageExamples: usage?.pageExamples ?? [], concerns: concerns.map((concern) => concern.label), score }; }) .sort((left, right) => storyOrder(left, right)); } function storyConcerns({ image, usage }) { const concerns = []; if (!image) { concerns.push({ label: 'missing readable image metadata', weight: 8 }); return concerns; } if (image.width !== expectedStorySize.width || image.height !== expectedStorySize.height) { concerns.push({ label: 'non-standard story dimensions', weight: 6 }); } if ((usage?.pageCount ?? 0) >= 12) { concerns.push({ label: 'high-repeat story background', weight: 4 }); } else if ((usage?.pageCount ?? 0) >= 8) { concerns.push({ label: 'repeat story background', weight: 2 }); } if (image.bytes / (image.width * image.height) < 1.2) { concerns.push({ label: 'low PNG byte density', weight: 2 }); } if ((usage?.pageCount ?? 0) === 0) { concerns.push({ label: 'unused source background', weight: 1 }); } return concerns; } function storyOrder(left, right) { return ( right.score - left.score || right.pageCount - left.pageCount || left.bytesPerPixel - right.bytesPerPixel || left.path.localeCompare(right.path) ); } function renderReport({ battleRows, storyRows, storyUsage }) { const flaggedBattleRows = battleRows.filter((row) => row.concerns.length > 0); const flaggedStoryRows = storyRows.filter((row) => row.concerns.length > 0); const topBattleRows = flaggedBattleRows.slice(0, 12); const topStoryRows = flaggedStoryRows.slice(0, 12); const nextBattle = topBattleRows[0]; const nextStory = topStoryRows[0]; const battlePxPerTileValues = battleRows.map((row) => row.pxPerTile); const storyPageReferenceCount = storyUsage.pageReferences.length; return `${[ '# Visual Asset Quality Audit', '', 'Generated by `scripts/audit-visual-asset-quality.mjs`.', '', '## Summary', '', `- Target desktop viewport: ${targetViewport.width}x${targetViewport.height}`, `- Battle map assets: ${battleRows.length}`, `- Flagged battle map review rows: ${flaggedBattleRows.length}`, `- Battle map source pixel density range: ${formatNumber(Math.min(...battlePxPerTileValues))}-${formatNumber(Math.max(...battlePxPerTileValues))} px/tile`, `- Story source backgrounds: ${storyRows.length}`, `- Story page background references: ${storyPageReferenceCount}`, `- Flagged story background review rows: ${flaggedStoryRows.length}`, '', '## Main Findings', '', '- Existing verification already gates missing files and minimum dimensions; this audit ranks visual-review risk instead of failing the build.', '- Battle maps are scored by source pixels per scenario tile, source/image aspect match, and WebP byte density.', '- Story backgrounds are scored by standard 1672x941 dimensions, repeat usage, and PNG byte density.', '- The audit cannot judge composition or painterly quality by itself; top rows are candidates for desktop browser screenshot review.', '', '## Recommended Next Batch', '', `- Battle map candidate: ${nextBattle ? `\`${nextBattle.id}\` / \`${nextBattle.mapKey}\`` : 'none flagged'}`, `- Battle reason: ${nextBattle ? nextBattle.concerns.join(', ') : 'no battle map review rows are currently flagged'}`, `- Story background candidate: ${nextStory ? `\`${shortPath(nextStory.path)}\`` : 'none flagged'}`, `- Story reason: ${nextStory ? nextStory.concerns.join(', ') : 'no story background review rows are currently flagged'}`, '- Suggested workflow: capture the top battle candidate in a 1280x720 battle scene, inspect terrain readability under units/grid, then regenerate or repaint only that asset if the screenshot confirms the issue.', '', '## Top Battle Map Review Queue', '', '| score | battle id | map key | grid | image | px/tile | bytes/px | concerns |', '| ---: | --- | --- | ---: | ---: | ---: | ---: | --- |', ...topBattleRows.map( (row) => `| ${row.score} | \`${row.id}\` | \`${row.mapKey}\` | ${row.mapColumns}x${row.mapRows} | ${row.width}x${row.height} | ${formatNumber(row.pxPerTile)} | ${formatNumber(row.bytesPerPixel, 4)} | ${row.concerns.join('
')} |` ), '', '## Top Story Background Review Queue', '', '| score | source | page refs | image | bytes/px | concerns | examples |', '| ---: | --- | ---: | ---: | ---: | --- | --- |', ...topStoryRows.map( (row) => `| ${row.score} | \`${shortPath(row.path)}\` | ${row.pageCount} | ${row.width}x${row.height} | ${formatNumber(row.bytesPerPixel, 4)} | ${row.concerns.join('
')} | ${row.pageExamples.map((example) => `\`${example}\``).join('
') || 'none'} |` ), '', '## Battle Map Inventory', '', '| battle id | map key | grid | image | px/tile | MiB | concerns |', '| --- | --- | ---: | ---: | ---: | ---: | --- |', ...battleRows .slice() .sort((left, right) => left.id.localeCompare(right.id)) .map( (row) => `| \`${row.id}\` | \`${row.mapKey}\` | ${row.mapColumns}x${row.mapRows} | ${row.width}x${row.height} | ${formatNumber(row.pxPerTile)} | ${formatNumber(row.bytes / 1024 / 1024, 2)} | ${row.concerns.join('
') || 'none'} |` ), '', '## Story Background Inventory', '', '| source | page refs | image | MiB | concerns |', '| --- | ---: | ---: | ---: | --- |', ...storyRows .slice() .sort((left, right) => left.path.localeCompare(right.path)) .map( (row) => `| \`${shortPath(row.path)}\` | ${row.pageCount} | ${row.width}x${row.height} | ${formatNumber(row.bytes / 1024 / 1024, 2)} | ${row.concerns.join('
') || 'none'} |` ), '' ].join('\n')}`; } function readImageMetadata(path) { let bytes; let stats; try { bytes = readFileSync(path); stats = statSync(path); } catch { return undefined; } const lowerPath = path.toLowerCase(); if (lowerPath.endsWith('.png')) { const dimensions = readPngDimensions(bytes); return dimensions ? { ...dimensions, bytes: stats.size } : undefined; } if (lowerPath.endsWith('.webp')) { const dimensions = readWebpDimensions(bytes); return dimensions ? { ...dimensions, bytes: stats.size } : undefined; } return undefined; } function readPngDimensions(bytes) { const pngSignature = '89504e470d0a1a0a'; if (bytes.subarray(0, 8).toString('hex') !== pngSignature || bytes.subarray(12, 16).toString('ascii') !== 'IHDR') { return undefined; } return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) }; } function readWebpDimensions(bytes) { if (bytes.subarray(0, 4).toString('ascii') !== 'RIFF' || bytes.subarray(8, 12).toString('ascii') !== 'WEBP') { return undefined; } let offset = 12; while (offset + 8 <= bytes.length) { const chunkType = bytes.subarray(offset, offset + 4).toString('ascii'); const chunkSize = bytes.readUInt32LE(offset + 4); const dataOffset = offset + 8; if (dataOffset + chunkSize > bytes.length) { return undefined; } if (chunkType === 'VP8X' && chunkSize >= 10) { return { width: readUInt24LE(bytes, dataOffset + 4) + 1, height: readUInt24LE(bytes, dataOffset + 7) + 1 }; } if (chunkType === 'VP8L' && chunkSize >= 5 && bytes[dataOffset] === 0x2f) { const bits = bytes[dataOffset + 1] | (bytes[dataOffset + 2] << 8) | (bytes[dataOffset + 3] << 16) | (bytes[dataOffset + 4] << 24); return { width: (bits & 0x3fff) + 1, height: ((bits >>> 14) & 0x3fff) + 1 }; } if ( chunkType === 'VP8 ' && chunkSize >= 10 && bytes[dataOffset + 3] === 0x9d && bytes[dataOffset + 4] === 0x01 && bytes[dataOffset + 5] === 0x2a ) { return { width: bytes.readUInt16LE(dataOffset + 6) & 0x3fff, height: bytes.readUInt16LE(dataOffset + 8) & 0x3fff }; } offset = dataOffset + chunkSize + (chunkSize % 2); } return undefined; } function readUInt24LE(bytes, offset) { return bytes[offset] | (bytes[offset + 1] << 8) | (bytes[offset + 2] << 16); } function assetUrlToPath(url, marker) { if (typeof url !== 'string') { return undefined; } const index = url.indexOf(marker); if (index < 0) { return undefined; } return url.slice(index).replaceAll('/', sep); } function imageFiles(dir) { return readdirSync(dir, { withFileTypes: true }) .filter((entry) => entry.isFile()) .map((entry) => entry.name) .sort() .filter((fileName) => /\.(png|webp)$/i.test(fileName)); } function storyKeyFromPath(path) { const fileName = path.split(/[\\/]/).pop() ?? path; const slug = fileName.replace(/\.png$/i, '').replace(/^\d+-/, ''); return `story-${slug}`; } function shortPath(path) { return path.replaceAll('\\', '/'); } function formatNumber(value, digits = 1) { return Number.isFinite(value) ? value.toFixed(digits) : '0'; }