332 lines
10 KiB
JavaScript
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}"`;
|
|
}
|