Files
heros_web/scripts/verify-battle-action-asset-budgets.mjs

377 lines
15 KiB
JavaScript

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;
}