feat: improve first-session combat and visual assets
This commit is contained in:
@@ -1,9 +1,13 @@
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
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({
|
||||
@@ -12,543 +16,393 @@ const server = await createServer({
|
||||
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
|
||||
];
|
||||
|
||||
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}`);
|
||||
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();
|
||||
}
|
||||
|
||||
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]);
|
||||
if (issueCount > 0) {
|
||||
process.exitCode = 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) {
|
||||
function collectCatalogRows(itemCatalog, equipmentSlots) {
|
||||
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)
|
||||
return Object.entries(itemCatalog)
|
||||
.map(([catalogKey, item]) => ({
|
||||
catalogKey,
|
||||
itemId: item.id,
|
||||
slot: item.slot,
|
||||
rank: item.rank
|
||||
}))
|
||||
.sort((left, right) => right.score - left.score || right.rows.length - left.rows.length || left.signature.localeCompare(right.signature));
|
||||
.sort((left, right) => {
|
||||
const slotDifference = (slotOrder.get(left.slot) ?? 99) - (slotOrder.get(right.slot) ?? 99);
|
||||
return slotDifference || left.itemId.localeCompare(right.itemId);
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
function collectCatalogIssues(catalogRows) {
|
||||
const issues = [];
|
||||
if (catalogRows.length !== expectedCatalogCount) {
|
||||
issues.push(`itemCatalog count is ${catalogRows.length}; expected ${expectedCatalogCount}`);
|
||||
}
|
||||
|
||||
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.'
|
||||
const seenIds = new Set();
|
||||
catalogRows.forEach((row) => {
|
||||
if (row.catalogKey !== row.itemId) {
|
||||
issues.push(`itemCatalog key/id mismatch: ${row.catalogKey} != ${row.itemId}`);
|
||||
}
|
||||
].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
|
||||
}));
|
||||
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 {
|
||||
completedTargets,
|
||||
firstTargetTitle: nextTarget.title,
|
||||
firstTargetIds: nextTarget.rows.map((row) => row.itemId),
|
||||
firstTargetReason: nextTarget.openReason,
|
||||
firstTargetSuccessCriteria: nextTarget.successCriteria,
|
||||
followUps,
|
||||
topRiskGroups: groupedRows.slice(0, 5)
|
||||
directory,
|
||||
expectedSize,
|
||||
files,
|
||||
assets,
|
||||
nonPngFiles,
|
||||
extraFiles: [],
|
||||
directoryIssues: nonPngFiles.map((name) => `${directory} contains a non-PNG file: ${name}`)
|
||||
};
|
||||
}
|
||||
|
||||
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 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 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);
|
||||
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`.',
|
||||
'Generated by `scripts/audit-equipment-icon-quality.mjs` from the current catalog, PNG headers, and runtime source.',
|
||||
'',
|
||||
'## Summary',
|
||||
'## Result',
|
||||
'',
|
||||
`- 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}`,
|
||||
`- 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`,
|
||||
'',
|
||||
'## Main Findings',
|
||||
'## Asset Coverage',
|
||||
'',
|
||||
'- 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.',
|
||||
`- 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.',
|
||||
'',
|
||||
...renderCompletedTargets(recommendations.completedTargets),
|
||||
'## Next Improvement Target',
|
||||
'## Boot Loading Contract',
|
||||
'',
|
||||
`- 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(', ')} |`
|
||||
'| check | result | contract |',
|
||||
'| --- | --- | --- |',
|
||||
...bootAudit.checks.map(
|
||||
(check) => `| ${check.name} | ${check.pass ? 'PASS' : 'FAIL'} | ${check.detail} |`
|
||||
),
|
||||
'',
|
||||
'## Full Item Inventory',
|
||||
'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.',
|
||||
'',
|
||||
'| 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('<br>') || 'missing',
|
||||
row.drawCalls,
|
||||
row.primitiveCount,
|
||||
row.colorCount,
|
||||
row.concerns.join('<br>') || 'none'
|
||||
].join(' | ')
|
||||
).map((line) => `| ${line} |`),
|
||||
'## Camp Render Usage',
|
||||
'',
|
||||
...renderRecommendedNextBatch(recommendations)
|
||||
`- 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('<br>') || '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 formatItemIds(itemIds) {
|
||||
return itemIds.length ? itemIds.map((itemId) => `\`${itemId}\``).join(', ') : 'none currently flagged';
|
||||
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 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 at the ${desktopBrowserViewportLabel} CSS viewport and 100% zoom.`,
|
||||
''
|
||||
];
|
||||
function formatPng(asset) {
|
||||
return asset ? `${asset.width}x${asset.height} ${pngColorLabel(asset.colorType, asset.bitDepth)}` : 'missing';
|
||||
}
|
||||
|
||||
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 formatAssetBytes(asset) {
|
||||
return asset ? asset.bytes.toLocaleString('en-US') : '-';
|
||||
}
|
||||
|
||||
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 formatDisplaySize(size) {
|
||||
return /^\d+$/.test(size) ? `\`${size}px\`` : `\`${size}\``;
|
||||
}
|
||||
|
||||
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 sumBytes(assets) {
|
||||
return assets.reduce((total, asset) => total + (asset?.bytes ?? 0), 0);
|
||||
}
|
||||
|
||||
function formatCounts(counts) {
|
||||
return [...counts.entries()]
|
||||
.sort(([left], [right]) => String(left).localeCompare(String(right)))
|
||||
.map(([key, count]) => `${key} ${count}`)
|
||||
.join(', ');
|
||||
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';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user