449 lines
18 KiB
JavaScript
449 lines
18 KiB
JavaScript
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
import { createServer } from 'vite';
|
|
|
|
const server = await createServer({
|
|
logLevel: 'error',
|
|
server: { middlewareMode: true },
|
|
appType: 'custom'
|
|
});
|
|
|
|
const minimumReadableIconFrameSize = 96;
|
|
const expectedV2IconFrames = {
|
|
attack: 4,
|
|
hit: 16,
|
|
critical: 17,
|
|
counter: 19,
|
|
fire: 24,
|
|
bean: 27,
|
|
salve: 28,
|
|
wine: 29,
|
|
confusion: 30,
|
|
benevolentCommand: 31,
|
|
azureDragonStrike: 32,
|
|
changbanRoar: 33,
|
|
singleRiderRescue: 34,
|
|
eastWindFire: 35,
|
|
hundredPacePierce: 36,
|
|
westernCavalryCharge: 37,
|
|
qilinStratagem: 38,
|
|
burn: 39
|
|
};
|
|
|
|
const expectedDedicatedUsableIcons = {
|
|
benevolentCommand: 'benevolentCommand',
|
|
azureDragonStrike: 'azureDragonStrike',
|
|
changbanRoar: 'changbanRoar',
|
|
singleRiderRescue: 'singleRiderRescue',
|
|
eastWindFire: 'eastWindFire',
|
|
hundredPacePierce: 'hundredPacePierce',
|
|
westernCavalryCharge: 'westernCavalryCharge',
|
|
qilinStratagem: 'qilinStratagem'
|
|
};
|
|
|
|
try {
|
|
const { battleUiIconManifest, battleUiIconTextureForSize } = await server.ssrLoadModule('/src/game/data/battleUiIcons.ts');
|
|
const { usableCatalog } = await server.ssrLoadModule('/src/game/data/battleUsables.ts');
|
|
const { itemCatalog, equipmentSlots } = await server.ssrLoadModule('/src/game/data/battleItems.ts');
|
|
const errors = [];
|
|
|
|
validateBattleUiIconAtlas(errors, battleUiIconManifest);
|
|
validateBattleUiIconTextureRouting(errors, battleUiIconManifest, battleUiIconTextureForSize);
|
|
validateBattleUsableIcons(errors, usableCatalog, battleUiIconManifest.frames);
|
|
validateItemCatalog(errors, itemCatalog, equipmentSlots);
|
|
validateGeneratedEquipmentIcons(errors, itemCatalog);
|
|
|
|
if (errors.length) {
|
|
console.error(`Visual asset data 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 battle UI atlas ${battleUiIconManifest.columns}x${battleUiIconManifest.rows}, ` +
|
|
`${Object.keys(battleUiIconManifest.frames).length} icon keys, and ${Object.keys(itemCatalog).length} generated equipment icons.`
|
|
);
|
|
}
|
|
} finally {
|
|
await server.close();
|
|
}
|
|
|
|
function validateBattleUiIconAtlas(errors, manifest) {
|
|
assertNonEmptyString(errors, manifest.source, 'battleUiIconManifest.source');
|
|
assertNonEmptyString(errors, manifest.textureKey, 'battleUiIconManifest.textureKey');
|
|
assertPositiveInteger(errors, manifest.frameWidth, 'battleUiIconManifest.frameWidth');
|
|
assertPositiveInteger(errors, manifest.frameHeight, 'battleUiIconManifest.frameHeight');
|
|
assertIntegerAtLeast(
|
|
errors,
|
|
manifest.frameWidth,
|
|
minimumReadableIconFrameSize,
|
|
'battleUiIconManifest.frameWidth for desktop readability'
|
|
);
|
|
assertIntegerAtLeast(
|
|
errors,
|
|
manifest.frameHeight,
|
|
minimumReadableIconFrameSize,
|
|
'battleUiIconManifest.frameHeight for desktop readability'
|
|
);
|
|
assertPositiveInteger(errors, manifest.columns, 'battleUiIconManifest.columns');
|
|
assertPositiveInteger(errors, manifest.rows, 'battleUiIconManifest.rows');
|
|
assertNonEmptyString(errors, manifest.microSource, 'battleUiIconManifest.microSource');
|
|
assertNonEmptyString(errors, manifest.microTextureKey, 'battleUiIconManifest.microTextureKey');
|
|
assertPositiveInteger(errors, manifest.microFrameWidth, 'battleUiIconManifest.microFrameWidth');
|
|
assertPositiveInteger(errors, manifest.microFrameHeight, 'battleUiIconManifest.microFrameHeight');
|
|
assertPositiveInteger(errors, manifest.microMaxDisplaySize, 'battleUiIconManifest.microMaxDisplaySize');
|
|
|
|
let dimensions;
|
|
try {
|
|
dimensions = readPngDimensions(manifest.source);
|
|
} catch (error) {
|
|
errors.push(`battle UI icon atlas cannot be read at "${manifest.source}": ${error.message}`);
|
|
return;
|
|
}
|
|
|
|
const expectedWidth = manifest.columns * manifest.frameWidth;
|
|
const expectedHeight = manifest.rows * manifest.frameHeight;
|
|
if (dimensions.width !== expectedWidth || dimensions.height !== expectedHeight) {
|
|
errors.push(`battle UI icon atlas is ${dimensions.width}x${dimensions.height}, expected ${expectedWidth}x${expectedHeight}`);
|
|
}
|
|
if (dimensions.bitDepth !== 8 || dimensions.colorType !== 6) {
|
|
errors.push(`battle UI icon atlas must be 8-bit RGBA PNG color type 6, got bitDepth=${dimensions.bitDepth} colorType=${dimensions.colorType}`);
|
|
}
|
|
|
|
let microDimensions;
|
|
try {
|
|
microDimensions = readPngDimensions(manifest.microSource);
|
|
} catch (error) {
|
|
errors.push(`battle UI micro icon atlas cannot be read at "${manifest.microSource}": ${error.message}`);
|
|
return;
|
|
}
|
|
const expectedMicroWidth = manifest.columns * manifest.microFrameWidth;
|
|
const expectedMicroHeight = manifest.rows * manifest.microFrameHeight;
|
|
if (microDimensions.width !== expectedMicroWidth || microDimensions.height !== expectedMicroHeight) {
|
|
errors.push(
|
|
`battle UI micro icon atlas is ${microDimensions.width}x${microDimensions.height}, expected ${expectedMicroWidth}x${expectedMicroHeight}`
|
|
);
|
|
}
|
|
if (microDimensions.bitDepth !== 8 || microDimensions.colorType !== 6) {
|
|
errors.push(
|
|
`battle UI micro icon atlas must be 8-bit RGBA PNG color type 6, got bitDepth=${microDimensions.bitDepth} colorType=${microDimensions.colorType}`
|
|
);
|
|
}
|
|
|
|
const frameLimit = manifest.columns * manifest.rows;
|
|
if (frameLimit !== 40) {
|
|
errors.push(`battle UI icon atlas must contain 40 frames, got ${frameLimit}`);
|
|
}
|
|
Object.entries(manifest.frames).forEach(([key, frame]) => {
|
|
assertNonEmptyString(errors, key, 'battle UI icon key');
|
|
if (!Number.isInteger(frame) || frame < 0 || frame >= frameLimit) {
|
|
errors.push(`battle UI icon "${key}" frame ${frame} is outside atlas frame range 0..${frameLimit - 1}`);
|
|
}
|
|
});
|
|
const coveredFrames = new Set(Object.values(manifest.frames));
|
|
for (let frame = 0; frame < frameLimit; frame += 1) {
|
|
if (!coveredFrames.has(frame)) {
|
|
errors.push(`battle UI icon atlas frame ${frame} has no manifest key`);
|
|
}
|
|
}
|
|
|
|
validateBattleUiIconSources(errors, manifest);
|
|
}
|
|
|
|
function validateBattleUiIconSources(errors, manifest) {
|
|
const sources = manifest.v2Sources;
|
|
if (!sources || typeof sources !== 'object' || Array.isArray(sources)) {
|
|
errors.push('battleUiIconManifest.v2Sources must be an object');
|
|
return;
|
|
}
|
|
|
|
const entries = Object.entries(sources);
|
|
if (entries.length !== 18) {
|
|
errors.push(`battleUiIconManifest.v2Sources must contain 18 source icons, got ${entries.length}`);
|
|
}
|
|
|
|
const sourceFrames = new Set();
|
|
entries.forEach(([key, source]) => {
|
|
const context = `battleUiIconManifest.v2Sources.${key}`;
|
|
if (expectedV2IconFrames[key] !== source.frame) {
|
|
errors.push(`${context}.frame must remain ${expectedV2IconFrames[key]}, got ${source.frame}`);
|
|
}
|
|
if (manifest.frames[key] !== source.frame) {
|
|
errors.push(`${context}.frame ${source.frame} does not match battleUiIconManifest.frames.${key} ${manifest.frames[key]}`);
|
|
}
|
|
if (sourceFrames.has(source.frame)) {
|
|
errors.push(`${context}.frame duplicates frame ${source.frame}`);
|
|
}
|
|
sourceFrames.add(source.frame);
|
|
assertNonEmptyString(errors, source.source, `${context}.source`);
|
|
|
|
let dimensions;
|
|
try {
|
|
dimensions = readPngDimensions(source.source);
|
|
} catch (error) {
|
|
errors.push(`${context}.source cannot be read at "${source.source}": ${error.message}`);
|
|
return;
|
|
}
|
|
if (dimensions.width !== dimensions.height || dimensions.width < 512) {
|
|
errors.push(`${context}.source must be a square master of at least 512px, got ${dimensions.width}x${dimensions.height}`);
|
|
}
|
|
if (dimensions.bitDepth !== 8 || dimensions.colorType !== 6) {
|
|
errors.push(`${context}.source must be an 8-bit RGBA PNG, got bitDepth=${dimensions.bitDepth} colorType=${dimensions.colorType}`);
|
|
}
|
|
});
|
|
}
|
|
|
|
function validateBattleUiIconTextureRouting(errors, manifest, textureForSize) {
|
|
if (textureForSize(14) !== manifest.microTextureKey || textureForSize(manifest.microMaxDisplaySize) !== manifest.microTextureKey) {
|
|
errors.push('battle UI icons at or below microMaxDisplaySize must use the micro atlas');
|
|
}
|
|
if (textureForSize(manifest.microMaxDisplaySize + 1) !== manifest.textureKey || textureForSize(70) !== manifest.textureKey) {
|
|
errors.push('battle UI icons above microMaxDisplaySize must use the full atlas');
|
|
}
|
|
}
|
|
|
|
function validateBattleUsableIcons(errors, usableCatalog, frames) {
|
|
Object.entries(usableCatalog).forEach(([id, usable]) => {
|
|
const context = `usableCatalog.${id}.iconKey`;
|
|
assertNonEmptyString(errors, usable.iconKey, context);
|
|
if (!(usable.iconKey in frames)) {
|
|
errors.push(`${context} references unknown battle UI icon "${usable.iconKey}"`);
|
|
}
|
|
if (usable.command === 'item' && usable.iconKey !== id) {
|
|
errors.push(`${context} must use the item's dedicated icon key "${id}", got "${usable.iconKey}"`);
|
|
}
|
|
});
|
|
|
|
const dedicatedIconKeys = [];
|
|
Object.entries(expectedDedicatedUsableIcons).forEach(([usableId, expectedIconKey]) => {
|
|
const usable = usableCatalog[usableId];
|
|
if (!usable) {
|
|
errors.push(`usableCatalog.${usableId} must exist for its signature icon`);
|
|
return;
|
|
}
|
|
if (usable.iconKey !== expectedIconKey) {
|
|
errors.push(`usableCatalog.${usableId}.iconKey must use dedicated icon "${expectedIconKey}", got "${usable.iconKey}"`);
|
|
}
|
|
dedicatedIconKeys.push(usable.iconKey);
|
|
});
|
|
|
|
if (new Set(dedicatedIconKeys).size !== dedicatedIconKeys.length) {
|
|
errors.push('signature usable icon keys must not be duplicated');
|
|
}
|
|
|
|
const statusIconKeys = ['confusion', 'burn'];
|
|
const reservedGenericIconKeys = ['shout', 'fire'];
|
|
const premiumIconKeys = [...dedicatedIconKeys, ...statusIconKeys];
|
|
if (new Set(premiumIconKeys).size !== premiumIconKeys.length) {
|
|
errors.push('signature and status icon keys must be unique');
|
|
}
|
|
[...statusIconKeys, ...reservedGenericIconKeys].forEach((iconKey) => {
|
|
if (!(iconKey in frames)) {
|
|
errors.push(`battle UI icon manifest is missing dedicated status/generic icon "${iconKey}"`);
|
|
}
|
|
});
|
|
if (frames.confusion === frames.shout) {
|
|
errors.push('confusion status icon frame must be distinct from shout');
|
|
}
|
|
if (frames.burn === frames.fire) {
|
|
errors.push('burn status icon frame must be distinct from fire tactic');
|
|
}
|
|
|
|
const premiumFrames = premiumIconKeys.map((iconKey) => frames[iconKey]).filter(Number.isInteger);
|
|
if (new Set(premiumFrames).size !== premiumFrames.length) {
|
|
errors.push('signature and status icon frames must not be duplicated');
|
|
}
|
|
}
|
|
|
|
function validateItemCatalog(errors, itemCatalog, equipmentSlots) {
|
|
const validSlots = new Set(equipmentSlots);
|
|
const validRanks = new Set(['common', 'treasure']);
|
|
|
|
Object.entries(itemCatalog).forEach(([id, item]) => {
|
|
const context = `itemCatalog.${id}`;
|
|
if (item.id !== id) {
|
|
errors.push(`${context}: item id does not match catalog key`);
|
|
}
|
|
assertNonEmptyString(errors, item.name, `${context}.name`);
|
|
assertNonEmptyString(errors, item.description, `${context}.description`);
|
|
if (!validSlots.has(item.slot)) {
|
|
errors.push(`${context}: unknown equipment slot "${item.slot}"`);
|
|
}
|
|
if (!validRanks.has(item.rank)) {
|
|
errors.push(`${context}: unknown equipment rank "${item.rank}"`);
|
|
}
|
|
if (!Array.isArray(item.effects) || item.effects.length === 0) {
|
|
errors.push(`${context}: effects must not be empty`);
|
|
} else {
|
|
item.effects.forEach((effect, index) => assertNonEmptyString(errors, effect, `${context}.effects[${index}]`));
|
|
}
|
|
['attackBonus', 'defenseBonus', 'strategyBonus'].forEach((field) => {
|
|
if (item[field] !== undefined && (!Number.isInteger(item[field]) || item[field] < 0)) {
|
|
errors.push(`${context}.${field} must be a non-negative integer`);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function validateGeneratedEquipmentIcons(errors, itemCatalog) {
|
|
const bootScene = readFileSync('src/game/scenes/BootScene.ts', 'utf8');
|
|
const campScene = readFileSync('src/game/scenes/CampScene.ts', 'utf8');
|
|
const generatedItemIds = new Set([...bootScene.matchAll(/createItemIcon\('item-([^']+)'/g)].map((match) => match[1]));
|
|
const catalogItemIds = new Set(Object.keys(itemCatalog));
|
|
|
|
Array.from(catalogItemIds)
|
|
.sort()
|
|
.forEach((itemId) => {
|
|
if (!generatedItemIds.has(itemId)) {
|
|
errors.push(`BootScene does not generate an equipment icon texture for item-${itemId}`);
|
|
}
|
|
});
|
|
Array.from(generatedItemIds)
|
|
.sort()
|
|
.forEach((itemId) => {
|
|
if (!catalogItemIds.has(itemId)) {
|
|
errors.push(`BootScene generates item-${itemId}, but itemCatalog has no matching item`);
|
|
}
|
|
});
|
|
|
|
validateEquipmentIconDirectory(errors, catalogItemIds, 'src/assets/images/ui/equipment-icons/128', 128);
|
|
validateEquipmentIconDirectory(errors, catalogItemIds, 'src/assets/images/ui/equipment-icons/32', 32);
|
|
|
|
const bootPreloadChecks = [
|
|
{
|
|
pattern: /import\.meta\.glob\('\.\.\/\.\.\/assets\/images\/ui\/equipment-icons\/128\/\*\.png'/,
|
|
message: 'BootScene must import the 128px equipment icon glob'
|
|
},
|
|
{
|
|
pattern: /import\.meta\.glob\('\.\.\/\.\.\/assets\/images\/ui\/equipment-icons\/32\/\*\.png'/,
|
|
message: 'BootScene must import the 32px equipment icon glob'
|
|
},
|
|
{
|
|
pattern: /this\.preloadEquipmentIcons\(equipmentIcon128Modules,\s*''\)/,
|
|
message: 'BootScene must preload 128px equipment icons as item-${id}'
|
|
},
|
|
{
|
|
pattern: /this\.preloadEquipmentIcons\(equipmentIcon32Modules,\s*'-micro'\)/,
|
|
message: 'BootScene must preload 32px equipment icons as item-${id}-micro'
|
|
},
|
|
{
|
|
pattern: /this\.load\.image\(`item-\$\{itemId\}\$\{suffix\}`,\s*url\)/,
|
|
message: 'BootScene equipment icon loader must preserve item-${id}${suffix} texture keys'
|
|
}
|
|
];
|
|
bootPreloadChecks.forEach(({ pattern, message }) => {
|
|
if (!pattern.test(bootScene)) {
|
|
errors.push(message);
|
|
}
|
|
});
|
|
|
|
const smallCampEquipmentIcons = [
|
|
...campScene.matchAll(
|
|
/const icon = [^\n]*this\.add\.image\([^\n]*, `item-\$\{[^`]+?\}(-micro)?`\)\);[\s\S]{0,180}?icon\.setDisplaySize\(this\.campUiLength\((\d+)\)/g
|
|
)
|
|
];
|
|
smallCampEquipmentIcons.forEach((match) => {
|
|
const suffix = match[1] ?? '';
|
|
const displaySize = Number(match[2]);
|
|
if (displaySize <= 22 && suffix !== '-micro') {
|
|
errors.push(`CampScene equipment icon displayed at ${displaySize}px must use the -micro texture`);
|
|
}
|
|
});
|
|
if (smallCampEquipmentIcons.filter((match) => Number(match[2]) <= 22 && match[1] === '-micro').length < 3) {
|
|
errors.push('CampScene must route its main 22px-or-smaller equipment slots through -micro textures');
|
|
}
|
|
if (
|
|
!/renderSortieEquipmentIcons\([^)]*frameSize = 18, iconSize = 14[^)]*\)[\s\S]{0,900}`item-\$\{item\.id\}-micro`/.test(
|
|
campScene
|
|
)
|
|
) {
|
|
errors.push('CampScene sortie equipment strip must use item-${id}-micro at its 14px default size');
|
|
}
|
|
}
|
|
|
|
function validateEquipmentIconDirectory(errors, catalogItemIds, directory, expectedSize) {
|
|
let filenames;
|
|
try {
|
|
filenames = readdirSync(directory);
|
|
} catch (error) {
|
|
errors.push(`equipment icon directory cannot be read at "${directory}": ${error.message}`);
|
|
return;
|
|
}
|
|
|
|
const pngFilenames = filenames.filter((filename) => filename.endsWith('.png'));
|
|
filenames
|
|
.filter((filename) => !filename.endsWith('.png'))
|
|
.forEach((filename) => errors.push(`${directory} contains unexpected non-PNG asset "${filename}"`));
|
|
|
|
const assetIds = new Set(pngFilenames.map((filename) => filename.slice(0, -4)));
|
|
Array.from(catalogItemIds)
|
|
.sort()
|
|
.forEach((itemId) => {
|
|
if (!assetIds.has(itemId)) {
|
|
errors.push(`${directory} is missing ${itemId}.png`);
|
|
return;
|
|
}
|
|
const source = `${directory}/${itemId}.png`;
|
|
let dimensions;
|
|
try {
|
|
dimensions = readPngDimensions(source);
|
|
} catch (error) {
|
|
errors.push(`equipment icon cannot be read at "${source}": ${error.message}`);
|
|
return;
|
|
}
|
|
if (dimensions.width !== expectedSize || dimensions.height !== expectedSize) {
|
|
errors.push(`${source} must be exactly ${expectedSize}x${expectedSize}, got ${dimensions.width}x${dimensions.height}`);
|
|
}
|
|
if (dimensions.bitDepth !== 8 || dimensions.colorType !== 6) {
|
|
errors.push(`${source} must be an 8-bit RGBA PNG, got bitDepth=${dimensions.bitDepth} colorType=${dimensions.colorType}`);
|
|
}
|
|
});
|
|
|
|
Array.from(assetIds)
|
|
.sort()
|
|
.forEach((itemId) => {
|
|
if (!catalogItemIds.has(itemId)) {
|
|
errors.push(`${directory} contains extra icon ${itemId}.png with no matching itemCatalog entry`);
|
|
}
|
|
});
|
|
}
|
|
|
|
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),
|
|
bitDepth: bytes[24],
|
|
colorType: bytes[25]
|
|
};
|
|
}
|
|
|
|
function assertPositiveInteger(errors, value, context) {
|
|
if (!Number.isInteger(value) || value <= 0) {
|
|
errors.push(`${context} must be a positive integer`);
|
|
}
|
|
}
|
|
|
|
function assertIntegerAtLeast(errors, value, minimum, context) {
|
|
if (!Number.isInteger(value) || value < minimum) {
|
|
errors.push(`${context} must be at least ${minimum}`);
|
|
}
|
|
}
|
|
|
|
function assertNonEmptyString(errors, value, context) {
|
|
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
errors.push(`${context} must be a non-empty string`);
|
|
}
|
|
}
|