import { readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { createServer } from 'vite'; import { desktopBrowserViewportLabel } from './desktop-browser-viewport.mjs'; const expectedCatalogCount = 23; const bootScenePath = 'src/game/scenes/BootScene.ts'; const campScenePath = 'src/game/scenes/CampScene.ts'; const icon128Directory = 'src/assets/images/ui/equipment-icons/128'; const icon32Directory = 'src/assets/images/ui/equipment-icons/32'; const reportPath = 'docs/equipment-icon-quality-audit.md'; const server = await createServer({ logLevel: 'error', server: { middlewareMode: true }, appType: 'custom' }); let issueCount = 0; try { const { itemCatalog, equipmentSlots } = await server.ssrLoadModule('/src/game/data/battleItems.ts'); const bootSceneSource = readFileSync(bootScenePath, 'utf8'); const campSceneSource = readFileSync(campScenePath, 'utf8'); const catalogRows = collectCatalogRows(itemCatalog, equipmentSlots); const directory128 = inspectDirectory(icon128Directory, 128); const directory32 = inspectDirectory(icon32Directory, 32); const assetRows = collectAssetRows(catalogRows, directory128, directory32); const bootAudit = inspectBootContract(bootSceneSource, catalogRows.map((row) => row.itemId)); const campAudit = inspectCampUsage(campSceneSource); const catalogIssues = collectCatalogIssues(catalogRows); const issues = [ ...catalogIssues, ...directory128.directoryIssues, ...directory32.directoryIssues, ...assetRows.flatMap((row) => row.issues.map((issue) => `${row.itemId}: ${issue}`)), ...directory128.extraFiles.map((file) => `128px directory has an uncataloged file: ${file}`), ...directory32.extraFiles.map((file) => `32px directory has an uncataloged file: ${file}`), ...bootAudit.issues, ...campAudit.issues ]; issueCount = issues.length; writeFileSync( reportPath, renderReport({ catalogRows, assetRows, directory128, directory32, bootAudit, campAudit, issues }), 'utf8' ); console.log(`Wrote ${reportPath}: ${issues.length === 0 ? 'PASS' : `FAIL (${issues.length} issues)`}`); } finally { await server.close(); } if (issueCount > 0) { process.exitCode = 1; } function collectCatalogRows(itemCatalog, equipmentSlots) { const slotOrder = new Map(equipmentSlots.map((slot, index) => [slot, index])); return Object.entries(itemCatalog) .map(([catalogKey, item]) => ({ catalogKey, itemId: item.id, slot: item.slot, rank: item.rank })) .sort((left, right) => { const slotDifference = (slotOrder.get(left.slot) ?? 99) - (slotOrder.get(right.slot) ?? 99); return slotDifference || left.itemId.localeCompare(right.itemId); }); } function collectCatalogIssues(catalogRows) { const issues = []; if (catalogRows.length !== expectedCatalogCount) { issues.push(`itemCatalog count is ${catalogRows.length}; expected ${expectedCatalogCount}`); } const seenIds = new Set(); catalogRows.forEach((row) => { if (row.catalogKey !== row.itemId) { issues.push(`itemCatalog key/id mismatch: ${row.catalogKey} != ${row.itemId}`); } if (seenIds.has(row.itemId)) { issues.push(`itemCatalog has duplicate id: ${row.itemId}`); } seenIds.add(row.itemId); }); return issues; } function inspectDirectory(directory, expectedSize) { const entries = readdirSync(directory, { withFileTypes: true }); const files = entries.filter((entry) => entry.isFile()).map((entry) => entry.name).sort(); const pngFiles = files.filter((name) => name.toLowerCase().endsWith('.png')); const nonPngFiles = files.filter((name) => !name.toLowerCase().endsWith('.png')); const assets = new Map( pngFiles.map((name) => { const itemId = name.slice(0, -4); return [itemId, inspectPng(join(directory, name), expectedSize)]; }) ); return { directory, expectedSize, files, assets, nonPngFiles, extraFiles: [], directoryIssues: nonPngFiles.map((name) => `${directory} contains a non-PNG file: ${name}`) }; } function inspectPng(path, expectedSize) { const bytes = statSync(path).size; const buffer = readFileSync(path); const signature = buffer.subarray(0, 8).toString('hex'); const validSignature = signature === '89504e470d0a1a0a'; const enoughHeader = buffer.length >= 26; const width = enoughHeader ? buffer.readUInt32BE(16) : 0; const height = enoughHeader ? buffer.readUInt32BE(20) : 0; const bitDepth = enoughHeader ? buffer[24] : 0; const colorType = enoughHeader ? buffer[25] : -1; const issues = []; if (!validSignature || !enoughHeader) { issues.push('invalid PNG header'); } else { if (width !== expectedSize || height !== expectedSize) { issues.push(`expected ${expectedSize}x${expectedSize}, got ${width}x${height}`); } if (bitDepth !== 8 || colorType !== 6) { issues.push(`expected RGBA8, got ${pngColorLabel(colorType, bitDepth)}`); } } if (bytes <= 0) { issues.push('empty file'); } return { path, bytes, width, height, bitDepth, colorType, issues }; } function collectAssetRows(catalogRows, directory128, directory32) { const catalogIds = new Set(catalogRows.map((row) => row.itemId)); directory128.extraFiles = directory128.files.filter((name) => { const itemId = name.toLowerCase().endsWith('.png') ? name.slice(0, -4) : name; return !catalogIds.has(itemId); }); directory32.extraFiles = directory32.files.filter((name) => { const itemId = name.toLowerCase().endsWith('.png') ? name.slice(0, -4) : name; return !catalogIds.has(itemId); }); return catalogRows.map((catalogRow) => { const icon128 = directory128.assets.get(catalogRow.itemId); const icon32 = directory32.assets.get(catalogRow.itemId); const issues = []; if (!icon128) { issues.push('missing 128px PNG'); } else { issues.push(...icon128.issues.map((issue) => `128px ${issue}`)); } if (!icon32) { issues.push('missing 32px PNG'); } else { issues.push(...icon32.issues.map((issue) => `32px ${issue}`)); } if (!icon128 || !icon32) { issues.push('incomplete 128/32 pair'); } return { ...catalogRow, icon128, icon32, issues }; }); } function inspectBootContract(source, catalogIds) { const fallbackIds = [...source.matchAll(/this\.createItemIcon\('item-([^']+)'/g)].map((match) => match[1]); const missingFallbackIds = catalogIds.filter((itemId) => !fallbackIds.includes(itemId)); const extraFallbackIds = fallbackIds.filter((itemId) => !catalogIds.includes(itemId)); const checks = [ { name: '128px source glob', pass: /import\.meta\.glob\('\.\.\/\.\.\/assets\/images\/ui\/equipment-icons\/128\/\*\.png'/.test(source), detail: '`equipment-icons/128/*.png` is imported eagerly' }, { name: '32px source glob', pass: /import\.meta\.glob\('\.\.\/\.\.\/assets\/images\/ui\/equipment-icons\/32\/\*\.png'/.test(source), detail: '`equipment-icons/32/*.png` is imported eagerly' }, { name: 'full texture preload', pass: /preloadEquipmentIcons\(equipmentIcon128Modules,\s*''\)/.test(source), detail: '128px files load as `item-${id}`' }, { name: 'micro texture preload', pass: /preloadEquipmentIcons\(equipmentIcon32Modules,\s*'-micro'\)/.test(source), detail: '32px files load as `item-${id}-micro`' }, { name: 'catalog-derived texture key', pass: /this\.load\.image\(`item-\$\{itemId\}\$\{suffix\}`,\s*url\)/.test(source), detail: 'file stem becomes the stable item texture id' }, { name: 'procedural fallback guard', pass: /private\s+createItemIcon\([\s\S]{0,260}?if\s*\(this\.textures\.exists\(key\)\)\s*\{\s*return;\s*\}/.test(source), detail: 'procedural full icon is created only when the loaded texture key is absent' }, { name: 'procedural fallback coverage', pass: missingFallbackIds.length === 0 && extraFallbackIds.length === 0, detail: `${fallbackIds.length}/${catalogIds.length} catalog ids have a base-key fallback` } ]; return { fallbackIds, missingFallbackIds, extraFallbackIds, checks, issues: [ ...checks.filter((check) => !check.pass).map((check) => `Boot preload contract failed: ${check.name}`), ...missingFallbackIds.map((itemId) => `Boot procedural fallback missing: ${itemId}`), ...extraFallbackIds.map((itemId) => `Boot procedural fallback is uncataloged: ${itemId}`) ] }; } function inspectCampUsage(source) { const lines = source.split(/\r?\n/); const usages = []; let currentMethod = '(scene body)'; lines.forEach((line, index) => { const methodMatch = line.match(/^\s*(?:private|public|protected)\s+([A-Za-z][A-Za-z0-9_]*)\s*\(/); if (methodMatch) { currentMethod = methodMatch[1]; } const imageMatch = line.match(/this\.add\.image\(.+?`item-\$\{([^}]+)\}(-micro)?`\)/); if (!imageMatch) { return; } const nearbySource = lines.slice(index, index + 7).join(' '); const displayMatch = nearbySource.match( /setDisplaySize\(this\.campUiLength\(([^)]+)\),\s*this\.campUiLength\(([^)]+)\)\)/ ); usages.push({ line: index + 1, method: currentMethod, itemExpression: imageMatch[1], tier: imageMatch[2] ? 'micro' : 'full', displaySize: displayMatch ? displayMatch[1].trim() === displayMatch[2].trim() ? displayMatch[1].trim() : `${displayMatch[1].trim()} x ${displayMatch[2].trim()}` : 'not detected' }); }); const microUsages = usages.filter((usage) => usage.tier === 'micro'); const fullUsages = usages.filter((usage) => usage.tier === 'full'); const issues = []; if (microUsages.length === 0) { issues.push('CampScene has no `item-${id}-micro` render usage'); } if (fullUsages.length === 0) { issues.push('CampScene has no `item-${id}` full render usage'); } usages.forEach((usage) => { const numericSize = /^\d+$/.test(usage.displaySize) ? Number(usage.displaySize) : undefined; if (usage.tier === 'micro' && numericSize !== undefined && numericSize > 32) { issues.push(`CampScene line ${usage.line} uses a micro texture at ${numericSize}px`); } if (usage.displaySize === 'not detected') { issues.push(`CampScene line ${usage.line} has no detected square display size`); } }); return { usages, microUsages, fullUsages, issues }; } function renderReport({ catalogRows, assetRows, directory128, directory32, bootAudit, campAudit, issues }) { const pairedCount = assetRows.filter((row) => row.icon128 && row.icon32).length; const validCount = assetRows.filter((row) => row.issues.length === 0).length; const total128Bytes = sumBytes(assetRows.map((row) => row.icon128)); const total32Bytes = sumBytes(assetRows.map((row) => row.icon32)); const missing128 = assetRows.filter((row) => !row.icon128).map((row) => row.itemId); const missing32 = assetRows.filter((row) => !row.icon32).map((row) => row.itemId); return `${[ '# Equipment Icon Quality Audit', '', 'Generated by `scripts/audit-equipment-icon-quality.mjs` from the current catalog, PNG headers, and runtime source.', '', '## Result', '', `- Status: **${issues.length === 0 ? 'PASS' : 'FAIL'}**`, `- Catalog contract: ${catalogRows.length}/${expectedCatalogCount} items`, `- Complete 128/32 pairs: ${pairedCount}/${catalogRows.length}`, `- Fully valid catalog rows: ${validCount}/${catalogRows.length}`, `- 128px payload: ${formatBytes(total128Bytes)} across ${directory128.assets.size} PNG files`, `- 32px payload: ${formatBytes(total32Bytes)} across ${directory32.assets.size} PNG files`, `- Browser visual QA baseline: \`${desktopBrowserViewportLabel}\` CSS viewport at 100% zoom`, '', '## Asset Coverage', '', `- 128px missing: ${formatIds(missing128)}`, `- 32px missing: ${formatIds(missing32)}`, `- 128px uncataloged/extra: ${formatFileNames(directory128.extraFiles)}`, `- 32px uncataloged/extra: ${formatFileNames(directory32.extraFiles)}`, '- Required PNG format: exact square dimensions, 8-bit RGBA (PNG color type 6), non-empty file.', '', '## Boot Loading Contract', '', '| check | result | contract |', '| --- | --- | --- |', ...bootAudit.checks.map( (check) => `| ${check.name} | ${check.pass ? 'PASS' : 'FAIL'} | ${check.detail} |` ), '', 'The authored 128px files are the normal `item-${id}` textures and the authored 32px files are the `item-${id}-micro` textures. The existing procedural 64px drawing path is retained only as a **loaded-base-texture-missing fallback**: its guard leaves a successfully loaded authored base texture untouched. Micro textures are static assets, so their completeness is enforced by this audit rather than by the procedural fallback.', '', '## Camp Render Usage', '', `- Micro render sites: ${campAudit.microUsages.length}`, `- Full render sites: ${campAudit.fullUsages.length}`, '', '| tier | source location | method | key expression | requested display size |', '| --- | --- | --- | --- | --- |', ...campAudit.usages.map( (usage) => `| ${usage.tier} | \`${campScenePath}:${usage.line}\` | \`${usage.method}\` | \`item-\${${usage.itemExpression}}${usage.tier === 'micro' ? '-micro' : ''}\` | ${formatDisplaySize(usage.displaySize)} |` ), '', 'Micro textures are used for compact sortie, inventory, and equipped-item rows. Full textures are used for larger equipment cards and the compare/confirm panel.', '', '## Full Catalog Inventory', '', '| item id | slot | rank | 128px PNG | bytes | 32px PNG | bytes | pair | concerns |', '| --- | --- | --- | --- | ---: | --- | ---: | --- | --- |', ...assetRows.map( (row) => `| \`${row.itemId}\` | ${row.slot} | ${row.rank} | ${formatPng(row.icon128)} | ${formatAssetBytes(row.icon128)} | ${formatPng(row.icon32)} | ${formatAssetBytes(row.icon32)} | ${row.icon128 && row.icon32 ? 'yes' : 'no'} | ${row.issues.join('
') || 'none'} |` ), '', '## Findings', '', ...(issues.length === 0 ? [ '- All 23 catalog items have valid, paired 128px and 32px RGBA8 PNG assets.', '- File stems map to stable Boot texture keys for both full and micro tiers.', '- Camp compact rows use micro textures while larger cards and comparison panels use full textures.', '- Procedural icons remain a load-failure fallback for base item keys, not the normal rendered asset path.' ] : issues.map((issue) => `- FAIL: ${issue}`)), '' ].join('\n')}`; } function pngColorLabel(colorType, bitDepth) { const labels = new Map([ [0, 'grayscale'], [2, 'RGB'], [3, 'indexed'], [4, 'grayscale+alpha'], [6, 'RGBA'] ]); return `${labels.get(colorType) ?? `color-type-${colorType}`}${bitDepth}`; } function formatPng(asset) { return asset ? `${asset.width}x${asset.height} ${pngColorLabel(asset.colorType, asset.bitDepth)}` : 'missing'; } function formatAssetBytes(asset) { return asset ? asset.bytes.toLocaleString('en-US') : '-'; } function formatDisplaySize(size) { return /^\d+$/.test(size) ? `\`${size}px\`` : `\`${size}\``; } function sumBytes(assets) { return assets.reduce((total, asset) => total + (asset?.bytes ?? 0), 0); } function formatBytes(bytes) { return `${bytes.toLocaleString('en-US')} B (${(bytes / 1024).toFixed(1)} KiB)`; } function formatIds(ids) { return ids.length ? ids.map((id) => `\`${id}\``).join(', ') : 'none'; } function formatFileNames(names) { return names.length ? names.map((name) => `\`${name}\``).join(', ') : 'none'; }