230 lines
7.6 KiB
JavaScript
230 lines
7.6 KiB
JavaScript
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { createServer } from 'vite';
|
|
|
|
const unitDir = join('src', 'assets', 'images', 'units');
|
|
const frameSize = 313;
|
|
const rows = 4;
|
|
const baseFramesPerDirection = 16;
|
|
const actionFramesPerDirection = 36;
|
|
const expectedBaseSize = { width: frameSize * baseFramesPerDirection, height: frameSize * rows };
|
|
const expectedActionSize = { width: frameSize * actionFramesPerDirection, height: frameSize * rows };
|
|
const battleScenePath = 'src/game/scenes/BattleScene.ts';
|
|
const nonTextureUnitLiterals = new Set(['unit-defeated']);
|
|
const requiredDynamicBaseKeys = [
|
|
'unit-rebel',
|
|
'unit-rebel-archer',
|
|
'unit-rebel-cavalry',
|
|
'unit-rebel-leader',
|
|
'unit-shu-infantry',
|
|
'unit-shu-spearman',
|
|
'unit-shu-archer',
|
|
'unit-shu-cavalry',
|
|
'unit-shu-strategist',
|
|
'unit-shu-officer',
|
|
'unit-wei-infantry',
|
|
'unit-wei-archer',
|
|
'unit-wei-cavalry',
|
|
'unit-wei-strategist',
|
|
'unit-wei-officer',
|
|
'unit-wu-infantry',
|
|
'unit-wu-archer',
|
|
'unit-wu-cavalry',
|
|
'unit-wu-strategist',
|
|
'unit-wu-officer',
|
|
'unit-nanman-infantry',
|
|
'unit-nanman-shaman',
|
|
'unit-nanman-officer'
|
|
];
|
|
|
|
const server = await createServer({
|
|
logLevel: 'error',
|
|
server: { middlewareMode: true },
|
|
appType: 'custom'
|
|
});
|
|
|
|
try {
|
|
const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts');
|
|
const { hasUnitSheetAsset } = await server.ssrLoadModule('/src/game/data/unitAssets.ts');
|
|
const { storyCutsceneActorTextureKeys } = await server.ssrLoadModule('/src/game/data/storyCutscenes.ts');
|
|
const scenarioModule = await server.ssrLoadModule('/src/game/data/scenario.ts');
|
|
const errors = [];
|
|
|
|
const { baseKeys, actionKeys } = collectUnitSheetKeys(errors);
|
|
validateUnitSheetPairs(errors, baseKeys, actionKeys);
|
|
validateUnitSheetDimensions(errors, baseKeys, actionKeys);
|
|
validateRequiredDynamicBaseKeys(errors, baseKeys, hasUnitSheetAsset);
|
|
validateBattleSceneTextureLiterals(errors, baseKeys, actionKeys, hasUnitSheetAsset);
|
|
validateCutsceneTextureKeys(errors, scenarioModule, battleScenarios, storyCutsceneActorTextureKeys, baseKeys);
|
|
|
|
if (errors.length) {
|
|
console.error(`Unit sprite asset verification failed with ${errors.length} issue(s):`);
|
|
errors.slice(0, 100).forEach((error) => console.error(`- ${error}`));
|
|
if (errors.length > 100) {
|
|
console.error(`- ...and ${errors.length - 100} more`);
|
|
}
|
|
process.exitCode = 1;
|
|
} else {
|
|
console.log(
|
|
`Verified ${baseKeys.size} unit base sheets, ${actionKeys.size} action sheets, ` +
|
|
`${Object.keys(battleScenarios).length} battle scenario sprite dependencies, and cutscene texture keys.`
|
|
);
|
|
}
|
|
} finally {
|
|
await server.close();
|
|
}
|
|
|
|
function collectUnitSheetKeys(errors) {
|
|
const baseKeys = new Set();
|
|
const actionKeys = new Set();
|
|
readdirSync(unitDir, { withFileTypes: true })
|
|
.filter((entry) => entry.isFile() && /^unit-.+\.png$/i.test(entry.name))
|
|
.forEach((entry) => {
|
|
const key = entry.name.replace(/\.png$/i, '');
|
|
if (key.endsWith('-actions')) {
|
|
actionKeys.add(key);
|
|
} else {
|
|
baseKeys.add(key);
|
|
}
|
|
});
|
|
|
|
if (baseKeys.size === 0) {
|
|
errors.push(`No unit base sprite sheets found in ${unitDir}`);
|
|
}
|
|
if (actionKeys.size === 0) {
|
|
errors.push(`No unit action sprite sheets found in ${unitDir}`);
|
|
}
|
|
|
|
return { baseKeys, actionKeys };
|
|
}
|
|
|
|
function validateUnitSheetPairs(errors, baseKeys, actionKeys) {
|
|
Array.from(baseKeys)
|
|
.sort()
|
|
.forEach((key) => {
|
|
if (!actionKeys.has(`${key}-actions`)) {
|
|
errors.push(`${key}: missing paired action sheet ${key}-actions.png`);
|
|
}
|
|
});
|
|
Array.from(actionKeys)
|
|
.sort()
|
|
.forEach((key) => {
|
|
const baseKey = key.replace(/-actions$/, '');
|
|
if (!baseKeys.has(baseKey)) {
|
|
errors.push(`${key}: action sheet has no paired base sheet ${baseKey}.png`);
|
|
}
|
|
});
|
|
}
|
|
|
|
function validateUnitSheetDimensions(errors, baseKeys, actionKeys) {
|
|
Array.from(baseKeys)
|
|
.sort()
|
|
.forEach((key) => validatePngDimensions(errors, key, join(unitDir, `${key}.png`), expectedBaseSize));
|
|
Array.from(actionKeys)
|
|
.sort()
|
|
.forEach((key) => validatePngDimensions(errors, key, join(unitDir, `${key}.png`), expectedActionSize));
|
|
}
|
|
|
|
function validateRequiredDynamicBaseKeys(errors, baseKeys, hasUnitSheetAsset) {
|
|
requiredDynamicBaseKeys.forEach((key) => {
|
|
if (!baseKeys.has(key) || !hasUnitSheetAsset(key)) {
|
|
errors.push(`dynamic unit texture base "${key}" is required by BattleScene but missing from unit assets`);
|
|
}
|
|
});
|
|
}
|
|
|
|
function validateBattleSceneTextureLiterals(errors, baseKeys, actionKeys, hasUnitSheetAsset) {
|
|
const source = readFileSync(battleScenePath, 'utf8');
|
|
const literalKeys = new Set([...source.matchAll(/['"`](unit-[a-z0-9-]+)['"`]/g)].map((match) => match[1]));
|
|
literalKeys.forEach((key) => {
|
|
if (nonTextureUnitLiterals.has(key)) {
|
|
return;
|
|
}
|
|
if (key.endsWith('-actions')) {
|
|
if (!actionKeys.has(key)) {
|
|
errors.push(`${battleScenePath}: literal action texture "${key}" has no matching PNG`);
|
|
}
|
|
return;
|
|
}
|
|
if (!baseKeys.has(key) || !hasUnitSheetAsset(key)) {
|
|
errors.push(`${battleScenePath}: literal unit texture "${key}" has no matching paired unit sheet`);
|
|
}
|
|
});
|
|
}
|
|
|
|
function validateCutsceneTextureKeys(errors, scenarioModule, battleScenarios, storyCutsceneActorTextureKeys, baseKeys) {
|
|
collectStorySources(scenarioModule, battleScenarios).forEach(({ source, pages }) => {
|
|
pages.forEach((page, index) => {
|
|
storyCutsceneActorTextureKeys(page.cutscene).forEach((key) => {
|
|
if (!baseKeys.has(key)) {
|
|
errors.push(`${source}[${index}]/${page.id}: cutscene texture "${key}" has no matching base sheet`);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
function collectStorySources(scenarioModule, battleScenarios) {
|
|
const sources = [];
|
|
const seenArrays = new WeakSet();
|
|
|
|
Object.entries(scenarioModule)
|
|
.filter(([, value]) => isStoryPageList(value))
|
|
.sort(([left], [right]) => left.localeCompare(right))
|
|
.forEach(([name, pages]) => {
|
|
sources.push({ source: name, pages });
|
|
seenArrays.add(pages);
|
|
});
|
|
|
|
Object.entries(battleScenarios)
|
|
.sort(([left], [right]) => left.localeCompare(right))
|
|
.forEach(([battleId, scenario]) => {
|
|
if (!isStoryPageList(scenario.victoryPages) || seenArrays.has(scenario.victoryPages)) {
|
|
return;
|
|
}
|
|
sources.push({ source: `${battleId}.victoryPages`, pages: scenario.victoryPages });
|
|
seenArrays.add(scenario.victoryPages);
|
|
});
|
|
|
|
return sources;
|
|
}
|
|
|
|
function isStoryPageList(value) {
|
|
return Array.isArray(value) && value.length > 0 && value.every(isStoryPage);
|
|
}
|
|
|
|
function isStoryPage(page) {
|
|
return Boolean(page && typeof page === 'object' && typeof page.id === 'string' && typeof page.text === 'string');
|
|
}
|
|
|
|
function validatePngDimensions(errors, key, path, expected) {
|
|
let dimensions;
|
|
try {
|
|
dimensions = readPngDimensions(path);
|
|
} catch (error) {
|
|
errors.push(`${key}: cannot read "${path}": ${error.message}`);
|
|
return;
|
|
}
|
|
if (dimensions.width !== expected.width || dimensions.height !== expected.height) {
|
|
errors.push(`${key}: expected ${expected.width}x${expected.height}, got ${dimensions.width}x${dimensions.height}`);
|
|
}
|
|
}
|
|
|
|
function readPngDimensions(path) {
|
|
const stats = statSync(path);
|
|
if (stats.size < 24) {
|
|
throw new Error('file is too small to be a PNG');
|
|
}
|
|
|
|
const bytes = readFileSync(path);
|
|
const signature = '89504e470d0a1a0a';
|
|
if (bytes.subarray(0, 8).toString('hex') !== signature) {
|
|
throw new Error('file does not have a PNG signature');
|
|
}
|
|
|
|
return {
|
|
width: bytes.readUInt32BE(16),
|
|
height: bytes.readUInt32BE(20)
|
|
};
|
|
}
|