import assert from 'node:assert/strict'; import { existsSync, readFileSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; const frameSize = 192; const idleFrames = 8; const walkFrames = 8; const directionCount = 4; const expectedWidth = frameSize * (idleFrames + walkFrames); const expectedHeight = frameSize * directionCount; const characterDirectory = join('src', 'assets', 'images', 'exploration', 'characters'); const modulePath = join('src', 'game', 'data', 'explorationCharacterAssets.ts'); const villageScenePath = join('src', 'game', 'scenes', 'PrologueVillageScene.ts'); const militiaCampScenePath = join('src', 'game', 'scenes', 'PrologueMilitiaCampScene.ts'); const expectedBaseMappings = { 'unit-liu-bei': 'exploration-liu-bei', 'unit-guan-yu': 'exploration-guan-yu', 'unit-zhang-fei': 'exploration-zhang-fei', 'unit-shu-officer': 'exploration-shu-officer', 'unit-shu-infantry': 'exploration-shu-infantry' }; const expectedNamedTextureFallbacks = { 'exploration-zhuo-recruiting-clerk': 'unit-shu-officer', 'exploration-zhuo-villager': 'unit-shu-infantry', 'exploration-zhuo-quartermaster': 'unit-shu-officer', 'exploration-zou-jing': 'unit-shu-officer' }; const expectedSceneNpcTextureKeys = [ { scenePath: villageScenePath, sceneLabel: 'prologue village', mappingName: 'villageNpcTextureKeyById', mappings: { 'recruiting-clerk': 'exploration-zhuo-recruiting-clerk', 'market-villager': 'exploration-zhuo-villager' } }, { scenePath: militiaCampScenePath, sceneLabel: 'prologue militia camp', mappingName: 'campNpcTextureKeyById', mappings: { quartermaster: 'exploration-zhuo-quartermaster', 'zou-jing': 'exploration-zou-jing', 'nervous-volunteer': 'exploration-shu-infantry' } } ]; const expectedFiles = [ ...Object.values(expectedBaseMappings), ...Object.keys(expectedNamedTextureFallbacks) ] .map((textureKey) => `${textureKey}.webp`) .sort(); assert(existsSync(modulePath), `Missing exploration character asset module: ${modulePath}`); const moduleSource = readFileSync(modulePath, 'utf8'); assert.match( moduleSource, /import\.meta\.glob\(\s*['"]\.\.\/\.\.\/assets\/images\/exploration\/characters\/exploration-\*\.webp['"]/, 'Exploration character sheets must be collected through a static import.meta.glob.' ); assert.match( moduleSource, /export const explorationCharacterFrameSize = 192;/, 'The exploration character frame size must remain 192px.' ); assert.match( moduleSource, /export const explorationCharacterIdleFrameCount = 8;/, 'Each direction must retain eight idle frames.' ); assert.match( moduleSource, /export const explorationCharacterWalkFrameCount = 8;/, 'Each direction must retain eight walk frames.' ); assert.match( moduleSource, /export function ensureExplorationCharacterAnimations\(/, 'The module must expose exploration animation registration.' ); assert.match( moduleSource, /export function releaseExplorationCharacterTextures\(/, 'The module must expose texture and animation cleanup.' ); assert.deepEqual( readStringMap(moduleSource, 'explorationCharacterTextureKeyByUnitTextureKey'), expectedBaseMappings, 'Exploration character unit-to-sheet mappings must exactly match the five reusable base sheets.' ); assert.deepEqual( readStringMap(moduleSource, 'explorationCharacterNamedTextureKeyFallbacks'), expectedNamedTextureFallbacks, 'Exploration character named-sheet fallbacks must exactly match the four dedicated NPC sheets.' ); expectedSceneNpcTextureKeys.forEach(({ scenePath, sceneLabel, mappingName, mappings }) => { assert(existsSync(scenePath), `Missing ${sceneLabel} scene module: ${scenePath}`); assert.deepEqual( readStringMap(readFileSync(scenePath, 'utf8'), mappingName), mappings, `${sceneLabel} must select the expected exploration sheet for each overridden NPC id.` ); }); assert( existsSync(characterDirectory), `Missing exploration character asset directory: ${characterDirectory}` ); const deployedFiles = readdirSync(characterDirectory) .filter((fileName) => /^exploration-.*\.webp$/i.test(fileName)) .sort(); assert.deepEqual( deployedFiles, expectedFiles, `Expected exactly the nine mapped exploration character sheets in ${characterDirectory}.` ); deployedFiles.forEach((fileName) => { const path = join(characterDirectory, fileName); const dimensions = webpDimensions(path); assert.equal( dimensions.width, expectedWidth, `${path}: expected ${expectedWidth}px width for 16 frames per direction.` ); assert.equal( dimensions.height, expectedHeight, `${path}: expected ${expectedHeight}px height for four direction rows.` ); }); console.log( `Verified ${deployedFiles.length} exploration character sheets and ` + `${expectedSceneNpcTextureKeys.reduce((count, scene) => count + Object.keys(scene.mappings).length, 0)} ` + `scene NPC mappings: ` + `${frameSize}px frames, ${directionCount} directions, ${idleFrames} idle + ${walkFrames} walk frames.` ); function readStringMap(source, mappingName) { const escapedMappingName = mappingName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const mappingBlock = source.match( new RegExp( `(?:export\\s+)?const\\s+${escapedMappingName}\\s*=\\s*\\{([\\s\\S]*?)\\}\\s*as const` ) ); assert(mappingBlock, `Could not read ${mappingName}.`); const mappings = {}; for (const entry of mappingBlock[1].matchAll(/(?:['"]([^'"]+)['"]|([A-Za-z0-9_-]+))\s*:\s*['"]([^'"]+)['"]/g)) { mappings[entry[1] ?? entry[2]] = entry[3]; } return mappings; } function webpDimensions(path) { const bytes = readFileSync(path); assert( bytes.subarray(0, 4).toString('ascii') === 'RIFF' && bytes.subarray(8, 12).toString('ascii') === 'WEBP', `${path}: 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; assert( payloadOffset + chunkSize <= bytes.length, `${path}: ${chunkType} chunk exceeds the WebP container.` ); if (chunkType === 'VP8X') { return { width: bytes.readUIntLE(payloadOffset + 4, 3) + 1, height: bytes.readUIntLE(payloadOffset + 7, 3) + 1 }; } if (chunkType === 'VP8L') { const bits = bytes.readUInt32LE(payloadOffset + 1); return { width: (bits & 0x3fff) + 1, height: ((bits >>> 14) & 0x3fff) + 1 }; } if (chunkType === 'VP8 ') { return { width: bytes.readUInt16LE(payloadOffset + 6) & 0x3fff, height: bytes.readUInt16LE(payloadOffset + 8) & 0x3fff }; } offset = payloadOffset + chunkSize + (chunkSize % 2); } throw new Error(`${path}: could not read WebP dimensions.`); }