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}"`; }