Files
heros_web/scripts/verify-prologue-dialogue-portrait-data.mjs

332 lines
10 KiB
JavaScript

import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { extname, join } from 'node:path';
import { createServer } from 'vite';
const portraitDirectory = join('src', 'assets', 'images', 'portraits');
const minimumPortraitDimension = 1000;
const dialogueFields = ['dialogue', 'lockedDialogue', 'repeatDialogue'];
const excludedNonPersonSpeakers = new Set(['전황']);
const expectedPersonPortraits = new Map([
['유비', { portraitKey: 'liuBei', slug: 'liu-bei' }],
['관우', { portraitKey: 'guanYu', slug: 'guan-yu' }],
['장비', { portraitKey: 'zhangFei', slug: 'zhang-fei' }],
['탁현 모병 관리', {
portraitKey: 'zhuoRecruitingClerk',
slug: 'zhuo-recruiting-clerk'
}],
['탁현 주민', { portraitKey: 'zhuoVillager', slug: 'zhuo-villager' }],
['의용군 군수관', {
portraitKey: 'zhuoQuartermaster',
slug: 'zhuo-quartermaster'
}],
['추정', { portraitKey: 'zouJing', slug: 'zou-jing' }],
['젊은 의병', {
portraitKey: 'zhuoYoungVolunteer',
slug: 'zhuo-young-volunteer'
}]
]);
const dialogueContexts = {
village: {
id: 'prologue-village-dialogue',
background: 'story-three-heroes'
},
camp: {
id: 'prologue-militia-camp-dialogue',
background: 'story-militia'
}
};
const failures = [];
const server = await createServer({
logLevel: 'error',
server: { middlewareMode: true },
appType: 'custom'
});
try {
const village = await server.ssrLoadModule('/src/game/data/prologueVillage.ts');
const camp = await server.ssrLoadModule('/src/game/data/prologueMilitiaCamp.ts');
const portraits = await server.ssrLoadModule('/src/game/data/portraitAssets.ts');
const speakerLocations = new Map();
collectDialogueSpeakers(
village.prologueVillageNpcDefinitions,
'village',
speakerLocations
);
collectDialogueSpeakers(
camp.prologueMilitiaCampNpcDefinitions,
'camp',
speakerLocations
);
const observedSpeakers = new Set(speakerLocations.keys());
check(
observedSpeakers.has('전황'),
'the prologue dialogue data must retain at least one intentionally portrait-free "전황" line'
);
check(
portraits.portraitKeyForSpeaker('전황') === undefined,
'"전황" is narrative context, not a person, and must not be mapped in speakerPortraitKeys'
);
const personSpeakers = new Set(
[...observedSpeakers].filter((speaker) => !excludedNonPersonSpeakers.has(speaker))
);
checkSetEquals(
personSpeakers,
new Set(expectedPersonPortraits.keys()),
'actual prologue village/camp person speakers'
);
const filesBySlug = portraitFilesBySlug();
const verifiedFiles = new Map();
for (const [speaker, expected] of expectedPersonPortraits) {
const mappedPortraitKey = portraits.portraitKeyForSpeaker(speaker);
check(
mappedPortraitKey === expected.portraitKey,
`${speaker}: expected speakerPortraitKeys mapping "${expected.portraitKey}", ` +
`received ${formatValue(mappedPortraitKey)}`
);
if (mappedPortraitKey !== expected.portraitKey) {
continue;
}
const mappedSlug = portraits.portraitSlugForKey(mappedPortraitKey);
check(
mappedSlug === expected.slug,
`${speaker}: expected portrait slug "${expected.slug}", received ${formatValue(mappedSlug)}`
);
if (mappedSlug !== expected.slug) {
continue;
}
const locations = speakerLocations.get(speaker);
check(
locations instanceof Set && locations.size > 0,
`${speaker}: expected at least one real village or camp dialogue line`
);
for (const location of locations ?? []) {
const context = dialogueContexts[location];
const entries = portraits.portraitAssetEntriesForStoryContext(
mappedPortraitKey,
context
);
check(
entries.length > 0,
`${speaker}: no portrait asset is routed for the ${location} dialogue context`
);
for (const entry of entries) {
const assetSlug = entry.textureKey.replace(/^portrait-/, '');
const yellowTurbanSlug = `${expected.slug}-yellow-turban`;
check(
assetSlug === yellowTurbanSlug ||
new RegExp(`^${yellowTurbanSlug}-v[1-9]\\d*$`).test(assetSlug),
`${speaker}: ${location} dialogue must use the Yellow Turban-era portrait, ` +
`received "${assetSlug}"`
);
check(
Boolean(portraits.portraitAssets[assetSlug]),
`${speaker}: portrait slug "${assetSlug}" is absent from the portraitAssets glob`
);
const matchingFiles = filesBySlug.get(assetSlug) ?? [];
check(
matchingFiles.length > 0,
`${speaker}: missing PNG or WebP file for portrait slug "${assetSlug}"`
);
for (const fileName of matchingFiles) {
const assetPath = join(portraitDirectory, fileName);
if (verifiedFiles.has(assetPath)) {
continue;
}
try {
const dimensions = rasterDimensions(assetPath);
check(
dimensions.width >= minimumPortraitDimension &&
dimensions.height >= minimumPortraitDimension,
`${assetPath}: expected at least ${minimumPortraitDimension}x` +
`${minimumPortraitDimension}, received ${dimensions.width}x${dimensions.height}`
);
verifiedFiles.set(assetPath, dimensions);
} catch (error) {
failures.push(`${assetPath}: ${error.message}`);
}
}
}
}
}
if (failures.length > 0) {
throw new Error(
`Prologue dialogue portrait verification failed with ${failures.length} issue(s):\n` +
failures.map((failure) => `- ${failure}`).join('\n')
);
}
const formatCounts = [...verifiedFiles.keys()].reduce((counts, path) => {
const extension = extname(path).slice(1).toUpperCase();
counts.set(extension, (counts.get(extension) ?? 0) + 1);
return counts;
}, new Map());
const formatSummary = [...formatCounts]
.map(([format, count]) => `${count} ${format}`)
.join(', ');
console.log(
`Verified ${personSpeakers.size} prologue person speakers, ${verifiedFiles.size} ` +
`Yellow Turban-era portrait assets (${formatSummary}), minimum ` +
`${minimumPortraitDimension}x${minimumPortraitDimension}; "전황" remains intentionally excluded.`
);
} finally {
await server.close();
}
function collectDialogueSpeakers(npcs, location, locationsBySpeaker) {
for (const npc of npcs) {
for (const field of dialogueFields) {
for (const line of npc[field] ?? []) {
if (typeof line.speaker !== 'string' || line.speaker.trim().length === 0) {
failures.push(`${location}/${npc.id}/${field}: dialogue speaker is missing`);
continue;
}
const speaker = line.speaker.trim();
const locations = locationsBySpeaker.get(speaker) ?? new Set();
locations.add(location);
locationsBySpeaker.set(speaker, locations);
}
}
}
}
function portraitFilesBySlug() {
const filesBySlug = new Map();
if (!existsSync(portraitDirectory)) {
failures.push(`missing portrait directory: ${portraitDirectory}`);
return filesBySlug;
}
for (const fileName of readdirSync(portraitDirectory)) {
if (!/\.(?:png|webp)$/i.test(fileName)) {
continue;
}
const slug = fileName.replace(/\.(?:png|webp)$/i, '');
const files = filesBySlug.get(slug) ?? [];
files.push(fileName);
filesBySlug.set(slug, files);
}
return filesBySlug;
}
function rasterDimensions(path) {
const extension = extname(path).toLowerCase();
if (extension === '.png') {
return pngDimensions(path);
}
if (extension === '.webp') {
return webpDimensions(path);
}
throw new Error(`unsupported portrait format "${extension}"`);
}
function pngDimensions(path) {
const bytes = readFileSync(path);
const pngSignature = '89504e470d0a1a0a';
if (
bytes.length < 24 ||
bytes.subarray(0, 8).toString('hex') !== pngSignature ||
bytes.subarray(12, 16).toString('ascii') !== 'IHDR'
) {
throw new Error('expected a PNG with a valid signature and IHDR chunk');
}
return {
width: bytes.readUInt32BE(16),
height: bytes.readUInt32BE(20)
};
}
function webpDimensions(path) {
const bytes = readFileSync(path);
if (
bytes.length < 20 ||
bytes.subarray(0, 4).toString('ascii') !== 'RIFF' ||
bytes.subarray(8, 12).toString('ascii') !== 'WEBP'
) {
throw new Error('expected a WebP RIFF container');
}
let offset = 12;
while (offset + 8 <= bytes.length) {
const chunkType = bytes.subarray(offset, offset + 4).toString('ascii');
const chunkSize = bytes.readUInt32LE(offset + 4);
const payloadOffset = offset + 8;
const paddedChunkEnd = payloadOffset + chunkSize + (chunkSize % 2);
if (paddedChunkEnd > bytes.length) {
throw new Error(`"${chunkType}" chunk exceeds the WebP container`);
}
if (chunkType === 'VP8X') {
if (chunkSize < 10) {
throw new Error('VP8X chunk is too short to contain dimensions');
}
return {
width: bytes.readUIntLE(payloadOffset + 4, 3) + 1,
height: bytes.readUIntLE(payloadOffset + 7, 3) + 1
};
}
if (chunkType === 'VP8L') {
if (chunkSize < 5 || bytes[payloadOffset] !== 0x2f) {
throw new Error('invalid lossless VP8L payload');
}
const bits = bytes.readUInt32LE(payloadOffset + 1);
return {
width: (bits & 0x3fff) + 1,
height: ((bits >>> 14) & 0x3fff) + 1
};
}
if (chunkType === 'VP8 ') {
if (
chunkSize < 10 ||
bytes[payloadOffset + 3] !== 0x9d ||
bytes[payloadOffset + 4] !== 0x01 ||
bytes[payloadOffset + 5] !== 0x2a
) {
throw new Error('invalid lossy VP8 payload');
}
return {
width: bytes.readUInt16LE(payloadOffset + 6) & 0x3fff,
height: bytes.readUInt16LE(payloadOffset + 8) & 0x3fff
};
}
offset = paddedChunkEnd;
}
throw new Error('could not read WebP dimensions');
}
function checkSetEquals(actual, expected, label) {
const missing = [...expected].filter((value) => !actual.has(value));
const unexpected = [...actual].filter((value) => !expected.has(value));
check(
missing.length === 0 && unexpected.length === 0,
`${label}: missing [${missing.join(', ')}], unexpected [${unexpected.join(', ')}]`
);
}
function check(condition, message) {
if (!condition) {
failures.push(message);
}
}
function formatValue(value) {
return value === undefined ? 'undefined' : `"${value}"`;
}