409 lines
16 KiB
JavaScript
409 lines
16 KiB
JavaScript
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('<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 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';
|
|
}
|