import { readFileSync, writeFileSync } from 'node:fs'; import { createServer } from 'vite'; const bootScenePath = 'src/game/scenes/BootScene.ts'; const campScenePath = 'src/game/scenes/CampScene.ts'; const reportPath = 'docs/equipment-icon-quality-audit.md'; const server = await createServer({ logLevel: 'error', server: { middlewareMode: true }, appType: 'custom' }); try { const { itemCatalog, equipmentSlots } = await server.ssrLoadModule('/src/game/data/battleItems.ts'); const bootSceneSource = readFileSync(bootScenePath, 'utf8'); const campSceneSource = readFileSync(campScenePath, 'utf8'); const iconSourceSize = numberConstant(bootSceneSource, 'itemIconSourceSize'); const iconTextureSize = numberConstant(bootSceneSource, 'itemIconTextureSize'); const drawFunctions = parseDrawFunctions(bootSceneSource); const iconDefinitions = parseIconDefinitions(bootSceneSource, drawFunctions); const sceneUsage = parseSceneUsage(campSceneSource); const itemRows = collectItemRows(itemCatalog, equipmentSlots, iconDefinitions); const groupedRows = groupBySignature(itemRows); const recommendations = buildRecommendations(itemRows, groupedRows); const report = renderReport({ iconSourceSize, iconTextureSize, itemRows, groupedRows, recommendations, sceneUsage }); writeFileSync(reportPath, report, 'utf8'); console.log(`Wrote ${reportPath}`); } finally { await server.close(); } function numberConstant(source, name) { const match = source.match(new RegExp(`const\\s+${name}\\s*=\\s*(\\d+)`)); if (!match) { throw new Error(`Could not find ${name}`); } return Number(match[1]); } function parseIconDefinitions(source, drawFunctions) { const definitions = new Map(); const matcher = /this\.createItemIcon\('item-([^']+)'/g; let match; while ((match = matcher.exec(source))) { const itemId = match[1]; const callText = expressionSpan(source, match.index); const drawCalls = [...callText.matchAll(/this\.(draw[A-Za-z]+Icon)\(([^)]*)\)/g)].map((drawMatch) => ({ functionName: drawMatch[1], colors: [...drawMatch[2].matchAll(/0x[0-9a-fA-F]{6}/g)].map((colorMatch) => colorMatch[0].toLowerCase()) })); const colors = [ ...new Set( drawCalls.flatMap((call) => [...call.colors, ...(drawFunctions.get(call.functionName)?.colors ?? [])]) ) ].sort(); const primitiveCount = drawCalls.reduce((sum, call) => sum + (drawFunctions.get(call.functionName)?.primitiveCount ?? 0), 0); const lineNumber = source.slice(0, match.index).split(/\r?\n/).length; definitions.set(itemId, { itemId, lineNumber, drawCalls, drawFunctionNames: drawCalls.map((call) => call.functionName), colors, primitiveCount, signature: drawCalls.map((call) => call.functionName).sort().join('+') }); } return definitions; } function expressionSpan(source, startIndex) { const openIndex = source.indexOf('(', startIndex); if (openIndex < 0) { throw new Error(`Could not find call expression at ${startIndex}`); } let depth = 0; let quote = ''; let escaped = false; for (let index = openIndex; index < source.length; index += 1) { const char = source[index]; if (quote) { if (escaped) { escaped = false; } else if (char === '\\') { escaped = true; } else if (char === quote) { quote = ''; } continue; } if (char === "'" || char === '"' || char === '`') { quote = char; continue; } if (char === '(') { depth += 1; } else if (char === ')') { depth -= 1; if (depth === 0) { return source.slice(startIndex, index + 1); } } } throw new Error(`Unclosed call expression at ${startIndex}`); } function parseDrawFunctions(source) { const functions = new Map(); const matcher = /private\s+(draw[A-Za-z]+Icon)\([^)]*\)\s*\{/g; let match; while ((match = matcher.exec(source))) { const functionName = match[1]; const body = blockSpan(source, source.indexOf('{', match.index)); functions.set(functionName, { functionName, primitiveCount: countGraphicsPrimitives(body), colors: [...new Set([...body.matchAll(/0x[0-9a-fA-F]{6}/g)].map((colorMatch) => colorMatch[0].toLowerCase()))].sort() }); } return functions; } function blockSpan(source, openIndex) { let depth = 0; let quote = ''; let escaped = false; for (let index = openIndex; index < source.length; index += 1) { const char = source[index]; if (quote) { if (escaped) { escaped = false; } else if (char === '\\') { escaped = true; } else if (char === quote) { quote = ''; } continue; } if (char === "'" || char === '"' || char === '`') { quote = char; continue; } if (char === '{') { depth += 1; } else if (char === '}') { depth -= 1; if (depth === 0) { return source.slice(openIndex + 1, index); } } } throw new Error(`Unclosed block at ${openIndex}`); } function countGraphicsPrimitives(body) { return [ ...body.matchAll( /graphics\.(fillTriangle|fillRect|fillRoundedRect|fillEllipse|fillCircle|lineStyle|beginPath|moveTo|lineTo|strokePath|strokeRect|strokeRoundedRect|strokeCircle)/g ) ].length; } function parseSceneUsage(source) { const sizes = new Set(); const usageBlocks = [...source.matchAll(/add\.image\([^`]+`item-\$\{[^`]+`[\s\S]{0,240}?setDisplaySize\((\d+),\s*(\d+)\)/g)]; const sortieSignature = source.match(/renderSortieEquipmentIcons\([^)]*iconSize\s*=\s*(\d+)/); usageBlocks.forEach((match) => { if (match[1] === match[2]) { sizes.add(Number(match[1])); } else { sizes.add(`${match[1]}x${match[2]}`); } }); if (sortieSignature) { sizes.add(Number(sortieSignature[1])); } const directItemImageCount = [...source.matchAll(/add\.image\([^`]+`item-\$\{/g)].length; return { scene: campScenePath, displaySizes: [...sizes].sort((left, right) => Number(left) - Number(right)), directItemImageCount }; } function collectItemRows(itemCatalog, equipmentSlots, iconDefinitions) { const slotOrder = new Map(equipmentSlots.map((slot, index) => [slot, index])); const rows = Object.entries(itemCatalog) .map(([itemId, item]) => { const icon = iconDefinitions.get(itemId); const concerns = []; if (!icon) { concerns.push('missing generated icon'); } else { if (icon.primitiveCount < 18) { concerns.push('low primitive detail'); } } return { itemId, slot: item.slot, rank: item.rank, attackBonus: item.attackBonus ?? 0, defenseBonus: item.defenseBonus ?? 0, strategyBonus: item.strategyBonus ?? 0, icon, signature: icon?.signature ?? 'missing', drawFunctions: icon?.drawFunctionNames ?? [], drawCalls: icon?.drawCalls.length ?? 0, primitiveCount: icon?.primitiveCount ?? 0, colorCount: icon?.colors.length ?? 0, concerns }; }); const signatureCounts = countBy(rows.filter((row) => row.icon), (row) => row.signature); rows.forEach((row) => { if (row.icon && row.drawCalls <= 1 && (signatureCounts.get(row.signature) ?? 0) > 1) { row.concerns.unshift('shared helper silhouette'); } }); return rows.sort((left, right) => { const slotDiff = (slotOrder.get(left.slot) ?? 99) - (slotOrder.get(right.slot) ?? 99); if (slotDiff !== 0) { return slotDiff; } if (left.rank !== right.rank) { return left.rank === 'treasure' ? -1 : 1; } return left.itemId.localeCompare(right.itemId); }); } function groupBySignature(itemRows) { const groups = new Map(); itemRows.forEach((row) => { const group = groups.get(row.signature) ?? []; group.push(row); groups.set(row.signature, group); }); return [...groups.entries()] .map(([signature, rows]) => ({ signature, rows, slots: [...new Set(rows.map((row) => row.slot))].sort(), ranks: [...new Set(rows.map((row) => row.rank))].sort(), itemIds: rows.map((row) => row.itemId), score: groupRiskScore(rows) })) .sort((left, right) => right.score - left.score || right.rows.length - left.rows.length || left.signature.localeCompare(right.signature)); } function groupRiskScore(rows) { const slots = new Set(rows.map((row) => row.slot)); const ranks = new Set(rows.map((row) => row.rank)); let score = rows.length; if (rows.length >= 3) { score += 3; } if (slots.has('weapon')) { score += 3; } if (ranks.has('treasure') && ranks.has('common')) { score += 3; } if (rows.some((row) => row.primitiveCount < 18)) { score += 2; } if (rows.some((row) => row.rank === 'treasure')) { score += 1; } return score; } function buildRecommendations(itemRows, groupedRows) { const weaponPolearmTargetIds = [ 'green-dragon-glaive', 'serpent-spear', 'iron-spear', 'sky-piercer-halberd', 'western-cavalry-spear' ]; const weaponPolearmRows = itemRows.filter((row) => weaponPolearmTargetIds.includes(row.itemId)); const armorIds = itemRows.filter((row) => row.slot === 'armor').map((row) => row.itemId); const accessoryIds = itemRows.filter((row) => row.slot === 'accessory').map((row) => row.itemId); const weaponPolearmComplete = weaponPolearmRows.length === weaponPolearmTargetIds.length && weaponPolearmRows.every((row) => row.icon && row.primitiveCount >= 18) && new Set(weaponPolearmRows.map((row) => row.signature)).size === weaponPolearmRows.length; const completedTargets = weaponPolearmComplete ? [ { title: 'weapon spear/polearm family', itemIds: weaponPolearmRows.map((row) => row.itemId), reason: 'The previous shared spear/polearm silhouettes now use item-specific helper shapes with enough primitive detail for 20px and 30px equipment UI reads.' } ] : []; return { completedTargets, firstTargetTitle: weaponPolearmComplete ? 'armor silhouettes' : 'weapon spear/polearm family', firstTargetIds: weaponPolearmComplete ? armorIds : weaponPolearmRows.map((row) => row.itemId), firstTargetReason: weaponPolearmComplete ? 'Armor and robes are readable but still depend on simple triangular torsos and small color accents; this is now the largest remaining shared-equipment silhouette group.' : 'These are high-frequency equipment surfaces and several treasure/common weapons share a thin diagonal shaft silhouette; upgrading this group gives the clearest identity gain in sortie prep and equipment swap screens.', firstTargetSuccessCriteria: weaponPolearmComplete ? 'Each armor item should keep its slot identity while gaining a distinct outer silhouette and material cue at 20px and 30px.' : 'Each item should read as a distinct weapon silhouette at 20px and 30px, with treasure weapons visibly richer than common weapons.', followUps: weaponPolearmComplete ? [ { title: 'accessory charms and tokens', itemIds: accessoryIds, reason: 'Accessories are the smallest UI read and several use compact badge-like shapes.' } ] : [ { title: 'armor silhouettes', itemIds: armorIds, reason: 'Armor and robes are readable but depend on simple triangular torsos and small color accents.' }, { title: 'accessory charms and tokens', itemIds: accessoryIds, reason: 'Accessories are the smallest UI read and several use compact badge-like shapes.' } ], topRiskGroups: groupedRows.slice(0, 5) }; } function renderReport({ iconSourceSize, iconTextureSize, itemRows, groupedRows, recommendations, sceneUsage }) { const slotCounts = countBy(itemRows, (row) => row.slot); const rankCounts = countBy(itemRows, (row) => row.rank); const generatedCount = itemRows.filter((row) => row.icon).length; const scale = (iconTextureSize / iconSourceSize).toFixed(2); return `${[ '# Equipment Icon Quality Audit', '', 'Generated by `scripts/audit-equipment-icon-quality.mjs`.', '', '## Summary', '', `- Catalog items: ${itemRows.length}`, `- Generated procedural item textures: ${generatedCount}`, `- Slots: ${formatCounts(slotCounts)}`, `- Ranks: ${formatCounts(rankCounts)}`, `- Texture size: ${iconTextureSize}px`, `- Drawing grid: ${iconSourceSize} units scaled ${scale}x into the texture`, `- Runtime item icon scene: \`${sceneUsage.scene}\``, `- Observed runtime display sizes: ${sceneUsage.displaySizes.map((size) => `\`${size}px\``).join(', ') || 'none found'}`, `- Direct item texture render sites found: ${sceneUsage.directItemImageCount}`, '', '## Main Findings', '', '- Equipment item icons are generated at runtime in `BootScene`, not stored as individual raster images.', '- The 64px textures are drawn from a compact 28-unit source grid, so they are crisp but intentionally simple.', '- Battle combat preview mostly uses the high-resolution `battle-ui-icons` slot frames and item names; per-item textures appear primarily in camp/sortie equipment UI.', '- Treasure/common distinction often relies on UI frame color plus a small palette change, not a strong unique silhouette.', '- Reused helper silhouettes are the strongest quality risk because several different items collapse to the same shape at 14-22px.', '', ...renderCompletedTargets(recommendations.completedTargets), '## Next Improvement Target', '', `- Target: ${recommendations.firstTargetTitle}`, `- Item ids: ${recommendations.firstTargetIds.map((itemId) => `\`${itemId}\``).join(', ')}`, `- Reason: ${recommendations.firstTargetReason}`, '', '## Follow-Up Targets', '', ...recommendations.followUps.flatMap((target) => [ `- ${target.title}: ${target.itemIds.map((itemId) => `\`${itemId}\``).join(', ')}`, ` - ${target.reason}` ]), '', '## Highest-Risk Shared Silhouette Groups', '', '| score | signature | slots | ranks | items |', '| ---: | --- | --- | --- | --- |', ...recommendations.topRiskGroups.map( (group) => `| ${group.score} | \`${group.signature}\` | ${group.slots.join(', ')} | ${group.ranks.join(', ')} | ${group.itemIds.map((itemId) => `\`${itemId}\``).join(', ')} |` ), '', '## Full Item Inventory', '', '| item id | slot | rank | draw helpers | calls | primitives | colors | concerns |', '| --- | --- | --- | --- | ---: | ---: | ---: | --- |', ...itemRows.map((row) => [ `\`${row.itemId}\``, row.slot, row.rank, row.drawFunctions.map((name) => `\`${name}\``).join('
') || 'missing', row.drawCalls, row.primitiveCount, row.colorCount, row.concerns.join('
') || 'none' ].join(' | ') ).map((line) => `| ${line} |`), '', '## Recommended Next Batch Definition', '', `- Scope: improve ${recommendations.firstTargetTitle} while keeping \`item-\${itemId}\` texture keys stable.`, `- Item ids: ${recommendations.firstTargetIds.map((itemId) => `\`${itemId}\``).join(', ')}`, `- Success criteria: ${recommendations.firstTargetSuccessCriteria}`, '- Keep the existing 64px texture size unless a UI-wide equipment icon pass is planned.', '- Add before/after contact sheets and verify the sortie equipment list and equipment swap panel in a desktop browser.', '' ].join('\n')}`; } function renderCompletedTargets(completedTargets) { if (!completedTargets.length) { return []; } return [ '## Completed Improvement Passes', '', ...completedTargets.flatMap((target) => [ `- ${target.title}: ${target.itemIds.map((itemId) => `\`${itemId}\``).join(', ')}`, ` - ${target.reason}` ]), '' ]; } function countBy(rows, keySelector) { return rows.reduce((counts, row) => { const key = keySelector(row); counts.set(key, (counts.get(key) ?? 0) + 1); return counts; }, new Map()); } function formatCounts(counts) { return [...counts.entries()] .sort(([left], [right]) => String(left).localeCompare(String(right))) .map(([key, count]) => `${key} ${count}`) .join(', '); }