275 lines
9.0 KiB
JavaScript
275 lines
9.0 KiB
JavaScript
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
import { join, sep } from 'node:path';
|
|
import { createServer } from 'vite';
|
|
|
|
const battleMapDir = join('src', 'assets', 'images', 'battle');
|
|
const battleMapAssetsSourcePath = join('src', 'game', 'data', 'battleMapAssets.ts');
|
|
const minimumRasterMapWidth = 1024;
|
|
const minimumRasterMapHeight = 768;
|
|
|
|
const server = await createServer({
|
|
logLevel: 'error',
|
|
server: { middlewareMode: true },
|
|
appType: 'custom'
|
|
});
|
|
|
|
try {
|
|
const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts');
|
|
const { battleMapAssets } = await server.ssrLoadModule('/src/game/data/battleMapAssets.ts');
|
|
const errors = [];
|
|
const staticBattleMapAssets = parseBattleMapAssetSource(readFileSync(battleMapAssetsSourcePath, 'utf8'), errors);
|
|
|
|
validateRuntimeMatchesSource(errors, battleMapAssets, staticBattleMapAssets);
|
|
validateScenarioMapKeys(errors, battleScenarios, battleMapAssets);
|
|
validateMapAssetFiles(errors, battleMapAssets, staticBattleMapAssets);
|
|
validateBattleDirectoryCoverage(errors, staticBattleMapAssets);
|
|
|
|
if (errors.length) {
|
|
console.error(`Battle map 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 ${Object.keys(battleScenarios).length} scenario map keys and ` +
|
|
`${Object.keys(battleMapAssets).length} battle map assets.`
|
|
);
|
|
}
|
|
} finally {
|
|
await server.close();
|
|
}
|
|
|
|
function validateScenarioMapKeys(errors, battleScenarios, battleMapAssets) {
|
|
const scenarioMapKeys = new Set();
|
|
Object.entries(battleScenarios)
|
|
.sort(([left], [right]) => left.localeCompare(right))
|
|
.forEach(([scenarioId, scenario]) => {
|
|
assertNonEmptyString(errors, scenario.mapTextureKey, `${scenarioId}.mapTextureKey`);
|
|
scenarioMapKeys.add(scenario.mapTextureKey);
|
|
if (!battleMapAssets[scenario.mapTextureKey]) {
|
|
errors.push(`${scenarioId}: mapTextureKey "${scenario.mapTextureKey}" is not registered in battleMapAssets`);
|
|
}
|
|
});
|
|
|
|
Object.keys(battleMapAssets)
|
|
.sort()
|
|
.forEach((mapKey) => {
|
|
if (!scenarioMapKeys.has(mapKey)) {
|
|
errors.push(`battleMapAssets contains unused map key "${mapKey}"`);
|
|
}
|
|
});
|
|
}
|
|
|
|
function validateRuntimeMatchesSource(errors, battleMapAssets, staticBattleMapAssets) {
|
|
Object.keys(battleMapAssets)
|
|
.sort()
|
|
.forEach((mapKey) => {
|
|
if (!staticBattleMapAssets[mapKey]) {
|
|
errors.push(`runtime battleMapAssets contains "${mapKey}" but the source manifest has no matching import`);
|
|
}
|
|
});
|
|
|
|
Object.keys(staticBattleMapAssets)
|
|
.sort()
|
|
.forEach((mapKey) => {
|
|
if (!battleMapAssets[mapKey]) {
|
|
errors.push(`source battleMapAssets contains "${mapKey}" but runtime export is missing it`);
|
|
}
|
|
});
|
|
}
|
|
|
|
function validateMapAssetFiles(errors, battleMapAssets, staticBattleMapAssets) {
|
|
Object.entries(staticBattleMapAssets)
|
|
.sort(([left], [right]) => left.localeCompare(right))
|
|
.forEach(([mapKey, path]) => {
|
|
validateMapFile(errors, mapKey, path);
|
|
|
|
const url = battleMapAssets[mapKey];
|
|
assertNonEmptyString(errors, url, `${mapKey} runtime asset URL`);
|
|
if (typeof url !== 'string') {
|
|
return;
|
|
}
|
|
|
|
if (url.startsWith('data:')) {
|
|
if (!path.toLowerCase().endsWith('.svg') || !url.startsWith('data:image/svg+xml')) {
|
|
errors.push(`${mapKey}: runtime data URL does not match source asset type for "${path}"`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const runtimePath = assetUrlToPath(url);
|
|
if (!runtimePath) {
|
|
errors.push(`${mapKey}: URL "${url}" does not point at src/assets/images/battle`);
|
|
} else if (runtimePath !== path) {
|
|
errors.push(`${mapKey}: runtime URL points at "${runtimePath}" but source imports "${path}"`);
|
|
}
|
|
});
|
|
}
|
|
|
|
function validateBattleDirectoryCoverage(errors, staticBattleMapAssets) {
|
|
const registeredPaths = new Set(Object.values(staticBattleMapAssets));
|
|
readdirSync(battleMapDir, { withFileTypes: true })
|
|
.filter((entry) => entry.isFile() && /\.(svg|webp)$/i.test(entry.name))
|
|
.map((entry) => join(battleMapDir, entry.name))
|
|
.sort()
|
|
.forEach((path) => {
|
|
if (!registeredPaths.has(path)) {
|
|
errors.push(`battle map file "${path}" exists but is not registered in battleMapAssets`);
|
|
}
|
|
});
|
|
}
|
|
|
|
function parseBattleMapAssetSource(source, errors) {
|
|
const importsByName = new Map();
|
|
const importPattern = /import\s+(\w+)\s+from\s+['"]\.\.\/\.\.\/assets\/images\/battle\/([^'"]+)['"];/g;
|
|
for (const match of source.matchAll(importPattern)) {
|
|
importsByName.set(match[1], join(battleMapDir, match[2]));
|
|
}
|
|
|
|
const manifestMatch = source.match(/export\s+const\s+battleMapAssets:\s*Record<string,\s*string>\s*=\s*{([\s\S]*?)\n};/);
|
|
if (!manifestMatch) {
|
|
errors.push(`${battleMapAssetsSourcePath}: cannot find battleMapAssets manifest object`);
|
|
return {};
|
|
}
|
|
|
|
const assets = {};
|
|
const entryPattern = /['"]([^'"]+)['"]\s*:\s*(\w+)/g;
|
|
for (const match of manifestMatch[1].matchAll(entryPattern)) {
|
|
const [, mapKey, importName] = match;
|
|
const path = importsByName.get(importName);
|
|
if (!path) {
|
|
errors.push(`${mapKey}: source manifest references "${importName}" without a matching battle map import`);
|
|
continue;
|
|
}
|
|
assets[mapKey] = path;
|
|
}
|
|
|
|
importsByName.forEach((path, importName) => {
|
|
if (!Object.values(assets).includes(path)) {
|
|
errors.push(`${battleMapAssetsSourcePath}: import "${importName}" from "${path}" is not used in battleMapAssets`);
|
|
}
|
|
});
|
|
|
|
return assets;
|
|
}
|
|
|
|
function validateMapFile(errors, context, path) {
|
|
let stats;
|
|
let bytes;
|
|
try {
|
|
stats = statSync(path);
|
|
bytes = readFileSync(path);
|
|
} catch (error) {
|
|
errors.push(`${context}: cannot read "${path}": ${error.message}`);
|
|
return;
|
|
}
|
|
|
|
if (stats.size < 1024) {
|
|
errors.push(`${context}: "${path}" is too small to be a complete battle map asset`);
|
|
}
|
|
|
|
if (path.toLowerCase().endsWith('.svg')) {
|
|
const text = bytes.toString('utf8', 0, Math.min(bytes.length, 2048));
|
|
if (!text.includes('<svg')) {
|
|
errors.push(`${context}: "${path}" does not look like an SVG`);
|
|
}
|
|
if (!/viewBox=["'][^"']+["']/.test(text) && !/width=["'][^"']+["']/.test(text)) {
|
|
errors.push(`${context}: "${path}" should declare SVG dimensions or a viewBox`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (path.toLowerCase().endsWith('.webp')) {
|
|
if (
|
|
bytes.subarray(0, 4).toString('ascii') !== 'RIFF' ||
|
|
bytes.subarray(8, 12).toString('ascii') !== 'WEBP'
|
|
) {
|
|
errors.push(`${context}: "${path}" does not have a WebP RIFF header`);
|
|
return;
|
|
}
|
|
|
|
const dimensions = readWebpDimensions(bytes);
|
|
if (!dimensions) {
|
|
errors.push(`${context}: "${path}" does not expose readable WebP dimensions`);
|
|
return;
|
|
}
|
|
if (dimensions.width < minimumRasterMapWidth || dimensions.height < minimumRasterMapHeight) {
|
|
errors.push(
|
|
`${context}: "${path}" is ${dimensions.width}x${dimensions.height}, expected at least ${minimumRasterMapWidth}x${minimumRasterMapHeight}`
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
errors.push(`${context}: "${path}" uses an unsupported map asset extension`);
|
|
}
|
|
|
|
function readWebpDimensions(bytes) {
|
|
let offset = 12;
|
|
while (offset + 8 <= bytes.length) {
|
|
const chunkType = bytes.subarray(offset, offset + 4).toString('ascii');
|
|
const chunkSize = bytes.readUInt32LE(offset + 4);
|
|
const dataOffset = offset + 8;
|
|
if (dataOffset + chunkSize > bytes.length) {
|
|
return undefined;
|
|
}
|
|
|
|
if (chunkType === 'VP8X' && chunkSize >= 10) {
|
|
return {
|
|
width: readUInt24LE(bytes, dataOffset + 4) + 1,
|
|
height: readUInt24LE(bytes, dataOffset + 7) + 1
|
|
};
|
|
}
|
|
|
|
if (chunkType === 'VP8L' && chunkSize >= 5 && bytes[dataOffset] === 0x2f) {
|
|
const bits =
|
|
bytes[dataOffset + 1] |
|
|
(bytes[dataOffset + 2] << 8) |
|
|
(bytes[dataOffset + 3] << 16) |
|
|
(bytes[dataOffset + 4] << 24);
|
|
return {
|
|
width: (bits & 0x3fff) + 1,
|
|
height: ((bits >>> 14) & 0x3fff) + 1
|
|
};
|
|
}
|
|
|
|
if (
|
|
chunkType === 'VP8 ' &&
|
|
chunkSize >= 10 &&
|
|
bytes[dataOffset + 3] === 0x9d &&
|
|
bytes[dataOffset + 4] === 0x01 &&
|
|
bytes[dataOffset + 5] === 0x2a
|
|
) {
|
|
return {
|
|
width: bytes.readUInt16LE(dataOffset + 6) & 0x3fff,
|
|
height: bytes.readUInt16LE(dataOffset + 8) & 0x3fff
|
|
};
|
|
}
|
|
|
|
offset = dataOffset + chunkSize + (chunkSize % 2);
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
function readUInt24LE(bytes, offset) {
|
|
return bytes[offset] | (bytes[offset + 1] << 8) | (bytes[offset + 2] << 16);
|
|
}
|
|
|
|
function assetUrlToPath(url) {
|
|
const marker = 'src/assets/images/battle/';
|
|
const index = url.indexOf(marker);
|
|
if (index < 0) {
|
|
return undefined;
|
|
}
|
|
return url.slice(index).replaceAll('/', sep);
|
|
}
|
|
|
|
function assertNonEmptyString(errors, value, context) {
|
|
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
errors.push(`${context} must be a non-empty string`);
|
|
}
|
|
}
|