import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; import { join } from 'node:path'; const root = process.cwd(); const unitDir = join(root, 'src', 'assets', 'images', 'units'); const distAssetsDir = join(root, 'dist', 'assets'); const policy = JSON.parse(readFileSync(join(root, 'src', 'game', 'data', 'unitActionAssetPolicy.json'), 'utf8')); const MiB = 1024 * 1024; const verifyDist = process.argv.includes('--dist'); if (policy.optimizeAll !== true) { throw new Error('Unit asset policy v3 must optimize every base and action sheet.'); } const optimizedActionKeyList = readdirSync(unitDir) .filter((file) => /^unit-.*-actions\.png$/i.test(file)) .map((file) => file.replace(/-actions\.png$/i, '')) .sort(); const optimizedBaseKeyList = readdirSync(unitDir) .filter((file) => /^unit-.*\.png$/i.test(file) && !/-actions\.png$/i.test(file)) .map((file) => file.replace(/\.png$/i, '')) .sort(); const optimizedActionKeys = new Set(optimizedActionKeyList); const optimizedBaseKeys = new Set(optimizedBaseKeyList); if (optimizedActionKeyList.length !== policy.expectedActionAssetCount) { throw new Error(`Expected ${policy.expectedActionAssetCount} optimized action assets, found ${optimizedActionKeyList.length}.`); } if (optimizedBaseKeyList.length !== policy.expectedBaseAssetCount) { throw new Error(`Expected ${policy.expectedBaseAssetCount} optimized base assets, found ${optimizedBaseKeyList.length}.`); } if (optimizedActionKeyList.some((key, index) => key !== optimizedBaseKeyList[index])) { throw new Error('Base and action sprite key sets do not match.'); } const representativeBattles = [ { id: 'first-battle-zhuo-commandery', phase: 'early', keys: [ 'unit-liu-bei', 'unit-guan-yu', 'unit-zhang-fei', 'unit-rebel', 'unit-rebel-archer', 'unit-rebel-cavalry', 'unit-rebel-leader' ], budget: { actionEncodedMiB: 18, actionDecodedMiB: 150, combinedEncodedMiB: 25, combinedDecodedMiB: 210 } }, { id: 'twenty-second-battle-red-cliffs-fire', phase: 'middle', keys: [ 'unit-liu-bei', 'unit-guan-yu', 'unit-zhang-fei', 'unit-shu-strategist-white', 'unit-zhao-yun', 'unit-zhuge-liang', 'unit-wei-infantry-shield', 'unit-wei-infantry-veteran', 'unit-wei-infantry', 'unit-wei-archer', 'unit-wei-archer-crossbow', 'unit-wei-archer-veteran', 'unit-wei-cavalry', 'unit-wei-cavalry-elite', 'unit-wei-cavalry-iron', 'unit-wei-officer-iron' ], budget: { actionEncodedMiB: 30, actionDecodedMiB: 330, combinedEncodedMiB: 40, combinedDecodedMiB: 475 } }, { id: 'forty-sixth-battle-yiling-fire', phase: 'late', keys: [ 'unit-liu-bei', 'unit-zhao-yun', 'unit-zhuge-liang', 'unit-ma-liang', 'unit-huang-quan', 'unit-ma-chao', 'unit-wang-ping', 'unit-wu-infantry', 'unit-wu-infantry-marine', 'unit-wu-infantry-river', 'unit-wu-archer-river', 'unit-wu-archer', 'unit-wu-archer-naval', 'unit-wu-cavalry-elite', 'unit-wu-cavalry-river', 'unit-wu-cavalry', 'unit-wu-strategist', 'unit-wu-strategist-fire', 'unit-wu-strategist-river', 'unit-wu-officer', 'unit-wu-officer-harbor', 'unit-wu-officer-river' ], budget: { actionEncodedMiB: 35, actionDecodedMiB: 450, combinedEncodedMiB: 50, combinedDecodedMiB: 650 } }, { id: 'sixty-sixth-battle-wuzhang-final', phase: 'final', keys: [ 'unit-zhao-yun', 'unit-zhuge-liang', 'unit-li-yan', 'unit-huang-quan', 'unit-ma-chao', 'unit-ma-dai', 'unit-wang-ping', 'unit-jiang-wei', 'unit-wei-infantry', 'unit-wei-infantry-shield', 'unit-wei-infantry-veteran', 'unit-wei-archer-veteran', 'unit-wei-archer', 'unit-wei-archer-crossbow', 'unit-wei-cavalry-elite', 'unit-wei-cavalry-iron', 'unit-wei-cavalry', 'unit-wei-strategist-blue', 'unit-wei-strategist', 'unit-wei-strategist-black', 'unit-wei-officer', 'unit-sima-yi' ], budget: { actionEncodedMiB: 35, actionDecodedMiB: 450, combinedEncodedMiB: 50, combinedDecodedMiB: 650 } } ]; function imageDimensions(path) { const buffer = readFileSync(path); if (buffer.subarray(1, 4).toString('ascii') === 'PNG') { return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) }; } if (buffer.subarray(0, 4).toString('ascii') !== 'RIFF' || buffer.subarray(8, 12).toString('ascii') !== 'WEBP') { throw new Error(`Unsupported image format: ${path}`); } let offset = 12; while (offset + 8 <= buffer.length) { const chunk = buffer.subarray(offset, offset + 4).toString('ascii'); const size = buffer.readUInt32LE(offset + 4); const payload = offset + 8; if (chunk === 'VP8X') { return { width: 1 + buffer.readUIntLE(payload + 4, 3), height: 1 + buffer.readUIntLE(payload + 7, 3) }; } if (chunk === 'VP8L') { const bits = buffer.readUInt32LE(payload + 1); return { width: 1 + (bits & 0x3fff), height: 1 + ((bits >>> 14) & 0x3fff) }; } if (chunk === 'VP8 ') { return { width: buffer.readUInt16LE(payload + 6) & 0x3fff, height: buffer.readUInt16LE(payload + 8) & 0x3fff }; } offset = payload + size + (size % 2); } throw new Error(`Could not read WebP dimensions: ${path}`); } function sheetAssetForKey(key, group, preferOptimized = true) { const suffix = group === 'action' ? '-actions' : ''; const optimizedSet = group === 'action' ? optimizedActionKeys : optimizedBaseKeys; const useOptimized = preferOptimized && optimizedSet.has(key); const extension = useOptimized ? policy.format : 'png'; const file = `${key}${suffix}.${extension}`; const path = join(unitDir, file); if (!existsSync(path)) { throw new Error(`Missing ${group} asset: ${path}`); } const dimensions = imageDimensions(path); return { key, group, extension, file, encodedBytes: statSync(path).size, decodedBytes: dimensions.width * dimensions.height * 4, ...dimensions }; } function byteTotals(assets) { return { encodedBytes: assets.reduce((sum, asset) => sum + asset.encodedBytes, 0), decodedBytes: assets.reduce((sum, asset) => sum + asset.decodedBytes, 0) }; } function summarizeBattle(battle) { const currentActionAssets = battle.keys.map((key) => sheetAssetForKey(key, 'action')); const originalActionAssets = battle.keys.map((key) => sheetAssetForKey(key, 'action', false)); const currentBaseAssets = battle.keys.map((key) => sheetAssetForKey(key, 'base')); const originalBaseAssets = battle.keys.map((key) => sheetAssetForKey(key, 'base', false)); const currentAction = byteTotals(currentActionAssets); const originalAction = byteTotals(originalActionAssets); const currentBase = byteTotals(currentBaseAssets); const originalBase = byteTotals(originalBaseAssets); const combinedEncodedBytes = currentAction.encodedBytes + currentBase.encodedBytes; const combinedDecodedBytes = currentAction.decodedBytes + currentBase.decodedBytes; const originalCombinedEncodedBytes = originalAction.encodedBytes + originalBase.encodedBytes; const originalCombinedDecodedBytes = originalAction.decodedBytes + originalBase.decodedBytes; const actionEncodedMiB = currentAction.encodedBytes / MiB; const actionDecodedMiB = currentAction.decodedBytes / MiB; const combinedEncodedMiB = combinedEncodedBytes / MiB; const combinedDecodedMiB = combinedDecodedBytes / MiB; const currentAssets = [...currentBaseAssets, ...currentActionAssets]; const maxTextureEdge = Math.max(...currentAssets.flatMap((asset) => [asset.width, asset.height])); const over8kTextureCount = currentAssets.filter((asset) => Math.max(asset.width, asset.height) > 8192).length; if ( actionEncodedMiB > battle.budget.actionEncodedMiB || actionDecodedMiB > battle.budget.actionDecodedMiB || combinedEncodedMiB > battle.budget.combinedEncodedMiB || combinedDecodedMiB > battle.budget.combinedDecodedMiB ) { throw new Error( `${battle.id} unit sheet budget failed: action=${actionEncodedMiB.toFixed(1)}MiB encoded/` + `${actionDecodedMiB.toFixed(1)}MiB decoded, combined=${combinedEncodedMiB.toFixed(1)}MiB encoded/` + `${combinedDecodedMiB.toFixed(1)}MiB decoded` ); } return { id: battle.id, phase: battle.phase, unitKeyCount: battle.keys.length, textureCount: currentAssets.length, optimizedTextureCount: currentAssets.filter((asset) => asset.extension === policy.format).length, action: { encodedMiB: round(actionEncodedMiB), encodedReductionPercent: round((1 - currentAction.encodedBytes / originalAction.encodedBytes) * 100), decodedMiB: round(actionDecodedMiB), decodedReductionPercent: round((1 - currentAction.decodedBytes / originalAction.decodedBytes) * 100) }, base: { encodedMiB: round(currentBase.encodedBytes / MiB), encodedReductionPercent: round((1 - currentBase.encodedBytes / originalBase.encodedBytes) * 100), decodedMiB: round(currentBase.decodedBytes / MiB), decodedReductionPercent: round((1 - currentBase.decodedBytes / originalBase.decodedBytes) * 100) }, combined: { encodedMiB: round(combinedEncodedMiB), encodedReductionPercent: round((1 - combinedEncodedBytes / originalCombinedEncodedBytes) * 100), decodedMiB: round(combinedDecodedMiB), decodedReductionPercent: round((1 - combinedDecodedBytes / originalCombinedDecodedBytes) * 100) }, maxTextureEdge, over8kTextureCount, budget: battle.budget }; } function deployedSheetKey(file, group, extension) { if (!file.toLowerCase().endsWith(`.${extension}`)) { return undefined; } const stem = file.slice(0, -(extension.length + 1)); if (group === 'base' && /-actions-/i.test(stem)) { return undefined; } const keys = group === 'action' ? optimizedActionKeyList : optimizedBaseKeyList; const suffix = group === 'action' ? '-actions-' : '-'; return keys .filter((key) => stem.startsWith(`${key}${suffix}`) && stem.length > key.length + suffix.length) .sort((left, right) => right.length - left.length)[0]; } function deployedAssetForKey(files, key, group) { const webps = files.filter((file) => deployedSheetKey(file, group, 'webp') === key); const pngs = files.filter((file) => deployedSheetKey(file, group, 'png') === key); if (webps.length !== 1 || pngs.length !== 0) { throw new Error( `${key} ${group}: expected one deployed WebP and no deployed PNG, got webp=${webps.length}, png=${pngs.length}` ); } return { key, group, file: webps[0], bytes: statSync(join(distAssetsDir, webps[0])).size }; } function deploymentSummary() { if (!existsSync(distAssetsDir)) { throw new Error(`Missing deployment assets directory: ${distAssetsDir}`); } const files = readdirSync(distAssetsDir); const base = optimizedBaseKeyList.map((key) => deployedAssetForKey(files, key, 'base')); const action = optimizedActionKeyList.map((key) => deployedAssetForKey(files, key, 'action')); const deployedUnitPngs = files.filter((file) => /^unit-.*\.png$/i.test(file)); if (deployedUnitPngs.length !== 0) { throw new Error(`Expected no deployed unit sheet PNG files, found: ${deployedUnitPngs.join(', ')}`); } return { checked: true, baseWebpFiles: base.length, actionWebpFiles: action.length, totalWebpFiles: base.length + action.length, baseMiB: round(base.reduce((sum, asset) => sum + asset.bytes, 0) / MiB), actionMiB: round(action.reduce((sum, asset) => sum + asset.bytes, 0) / MiB), combinedMiB: round([...base, ...action].reduce((sum, asset) => sum + asset.bytes, 0) / MiB), originalPngEmitted: false }; } function optimizedAssetDetails(group, keys, columns, rows) { const expectedWidth = columns * policy.optimizedFrameSize; const expectedHeight = rows * policy.optimizedFrameSize; return keys.map((key) => { const current = sheetAssetForKey(key, group); const original = sheetAssetForKey(key, group, false); if (current.width !== expectedWidth || current.height !== expectedHeight) { throw new Error(`${current.file}: expected ${expectedWidth}x${expectedHeight}, got ${current.width}x${current.height}`); } return { key, sourceEncodedBytes: original.encodedBytes, optimizedEncodedBytes: current.encodedBytes, sourceDecodedBytes: original.decodedBytes, optimizedDecodedBytes: current.decodedBytes }; }); } function summarizeOptimizedAssets(details) { return { count: details.length, sourceEncodedMiB: round(details.reduce((sum, asset) => sum + asset.sourceEncodedBytes, 0) / MiB), optimizedEncodedMiB: round(details.reduce((sum, asset) => sum + asset.optimizedEncodedBytes, 0) / MiB), encodedReductionPercent: round( (1 - details.reduce((sum, asset) => sum + asset.optimizedEncodedBytes, 0) / details.reduce((sum, asset) => sum + asset.sourceEncodedBytes, 0)) * 100 ), sourceDecodedMiB: round(details.reduce((sum, asset) => sum + asset.sourceDecodedBytes, 0) / MiB), optimizedDecodedMiB: round(details.reduce((sum, asset) => sum + asset.optimizedDecodedBytes, 0) / MiB), decodedReductionPercent: round( (1 - details.reduce((sum, asset) => sum + asset.optimizedDecodedBytes, 0) / details.reduce((sum, asset) => sum + asset.sourceDecodedBytes, 0)) * 100 ) }; } const optimizedBaseAssets = optimizedAssetDetails('base', optimizedBaseKeyList, policy.baseColumns, policy.baseRows); const optimizedActionAssets = optimizedAssetDetails('action', optimizedActionKeyList, policy.actionColumns, policy.actionRows); const report = { viewportBaseline: { width: 1920, height: 1080, deviceScaleFactor: 1, zoomPercent: 100 }, policy: { version: policy.version, baseFrameCount: policy.baseColumns * policy.baseRows, actionFrameCount: policy.actionColumns * policy.actionRows, sourceFrameSize: policy.sourceFrameSize, optimizedFrameSize: policy.optimizedFrameSize, format: policy.lossless ? 'lossless-webp' : policy.format, optimizedBaseAssetCount: optimizedBaseKeyList.length, optimizedActionAssetCount: optimizedActionKeyList.length }, optimizedAssets: { base: summarizeOptimizedAssets(optimizedBaseAssets), action: summarizeOptimizedAssets(optimizedActionAssets) }, representativeBattles: representativeBattles.map(summarizeBattle), deployment: verifyDist ? deploymentSummary() : { checked: false, hint: 'Run with --dist after a production build.' } }; console.log(JSON.stringify(report, null, 2)); function round(value) { return Math.round(value * 10) / 10; }