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 armorTargetIds = ['oath-robe', 'reinforced-lamellar', 'cloth-armor', 'lamellar-armor', 'rebel-vest'];
const accessoryIds = itemRows.filter((row) => row.slot === 'accessory').map((row) => row.itemId);
const lowDetailWeaponTargetIds = ['leader-axe', 'training-sword', 'yellow-turban-saber'];
const targets = [
{
title: 'weapon spear/polearm family',
itemIds: weaponPolearmTargetIds,
completeReason:
'The previous shared spear/polearm silhouettes now use item-specific helper shapes with enough primitive detail for 20px and 30px equipment UI reads.',
openReason:
'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.',
successCriteria:
'Each item should read as a distinct weapon silhouette at 20px and 30px, with treasure weapons visibly richer than common weapons.'
},
{
title: 'armor silhouettes',
itemIds: armorTargetIds,
completeReason:
'The previous shared armor and robe silhouettes now use item-specific helper shapes with stronger outer contours and material cues.',
openReason:
'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.',
successCriteria:
'Each armor item should keep its slot identity while gaining a distinct outer silhouette and material cue at 20px and 30px.'
},
{
title: 'accessory charms and tokens',
itemIds: accessoryIds,
completeReason:
'Accessory icons now use distinct badge, scroll, charm, pouch, manual, and quiver silhouettes at small camp UI sizes.',
openReason: 'Accessories are the smallest UI read and several use compact badge-like shapes.',
successCriteria:
'Each accessory should keep a compact footprint while gaining a distinct readable prop shape at 20px and 30px.'
},
{
title: 'remaining low-detail weapon icons',
itemIds: lowDetailWeaponTargetIds,
completeReason:
'The final low-detail common weapons now use dedicated axe, training sword, and Yellow Turban saber silhouettes.',
openReason:
'These are the last generated equipment icons still below the primitive-detail threshold after the larger weapon, armor, and accessory passes.',
successCriteria: 'Clear the low primitive detail concerns without changing stable item texture keys.'
}
].map((target) => ({
...target,
rows: itemRows.filter((row) => target.itemIds.includes(row.itemId))
}));
const completedTargets = targets
.filter((target) => improvementTargetComplete(target))
.map((target) => ({
title: target.title,
itemIds: target.rows.map((row) => row.itemId),
reason: target.completeReason
}));
const remainingConcernRows = itemRows.filter((row) => row.concerns.length > 0);
const nextTarget =
targets.find((target) => !improvementTargetComplete(target)) ?? {
title: remainingConcernRows.length ? 'remaining low-detail equipment icons' : 'no flagged equipment icon batch',
rows: remainingConcernRows,
openReason: remainingConcernRows.length
? 'All named batches are complete; remaining work should be selected from the low-detail inventory rows.'
: 'No generated equipment icon currently has a low-detail or shared-helper audit concern; choose the next visual asset pass from in-game QA.',
successCriteria: remainingConcernRows.length
? 'Clear remaining low-detail concerns without changing stable texture keys.'
: 'Keep the generated equipment icon audit green while selecting the next art target from runtime screenshots.'
};
const nextTargetIndex = targets.findIndex((target) => target.title === nextTarget.title);
const followUps = targets
.filter((target, index) => index > nextTargetIndex && !improvementTargetComplete(target))
.map((target) => ({
title: target.title,
itemIds: target.rows.map((row) => row.itemId),
reason: target.openReason
}));
return {
completedTargets,
firstTargetTitle: nextTarget.title,
firstTargetIds: nextTarget.rows.map((row) => row.itemId),
firstTargetReason: nextTarget.openReason,
firstTargetSuccessCriteria: nextTarget.successCriteria,
followUps,
topRiskGroups: groupedRows.slice(0, 5)
};
}
function improvementTargetComplete(target) {
return (
target.rows.length === target.itemIds.length &&
target.rows.every((row) => row.icon && row.primitiveCount >= 18) &&
new Set(target.rows.map((row) => row.signature)).size === target.rows.length
);
}
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: ${formatItemIds(recommendations.firstTargetIds)}`,
`- Reason: ${recommendations.firstTargetReason}`,
'',
...renderFollowUpTargets(recommendations.followUps),
'## 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} |`),
'',
...renderRecommendedNextBatch(recommendations)
].join('\n')}`;
}
function formatItemIds(itemIds) {
return itemIds.length ? itemIds.map((itemId) => `\`${itemId}\``).join(', ') : 'none currently flagged';
}
function renderRecommendedNextBatch(recommendations) {
if (!recommendations.firstTargetIds.length) {
return [
'## Recommended Next Batch Definition',
'',
'- Scope: no generated equipment icon is currently below the audit threshold.',
'- Item ids: none currently flagged',
`- Success criteria: ${recommendations.firstTargetSuccessCriteria}`,
'- Suggested next area: map/background or unit sprite assets that still look low-resolution in gameplay.',
''
];
}
return [
'## Recommended Next Batch Definition',
'',
`- Scope: improve ${recommendations.firstTargetTitle} while keeping \`item-\${itemId}\` texture keys stable.`,
`- Item ids: ${formatItemIds(recommendations.firstTargetIds)}`,
`- 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.',
''
];
}
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 renderFollowUpTargets(followUps) {
if (!followUps.length) {
return [];
}
return [
'## Follow-Up Targets',
'',
...followUps.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(', ');
}