feat: upgrade prologue exploration presentation

This commit is contained in:
2026-07-27 01:01:27 +09:00
parent 9beed0ac02
commit 40dca5321e
29 changed files with 2215 additions and 196 deletions

View File

@@ -0,0 +1,202 @@
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.`);
}

View File

@@ -0,0 +1,331 @@
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}"`;
}

View File

@@ -0,0 +1,223 @@
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
const explorationAssetDirectory = join('src', 'assets', 'images', 'exploration');
const expectedAssets = [
'prologue-village.webp',
'prologue-militia-camp.webp'
];
const expectedWidth = 1920;
const expectedHeight = 1080;
const maximumAssetBytes = 2 * 1024 * 1024;
const maximumTotalBytes = 4 * 1024 * 1024;
const rasterAssetPattern = /\.(?:avif|jpe?g|png|webp)$/i;
const errors = [];
let totalBytes = 0;
validateDirectoryCoverage(errors);
for (const fileName of expectedAssets) {
const path = join(explorationAssetDirectory, fileName);
const inspection = inspectWebpAsset(errors, path, fileName);
if (!inspection) {
continue;
}
totalBytes += inspection.size;
if (inspection.size > maximumAssetBytes) {
errors.push(
`${path}: ${formatBytes(inspection.size)} exceeds the per-background limit of ` +
`${formatBytes(maximumAssetBytes)}`
);
}
if (inspection.width !== expectedWidth || inspection.height !== expectedHeight) {
errors.push(
`${path}: expected ${expectedWidth}x${expectedHeight}, got ` +
`${inspection.width}x${inspection.height}`
);
}
if (inspection.hasAlpha) {
errors.push(`${path}: exploration backgrounds must be opaque WebP images without alpha`);
}
if (inspection.animated) {
errors.push(`${path}: exploration backgrounds must be static WebP images`);
}
}
if (totalBytes > maximumTotalBytes) {
errors.push(
`${explorationAssetDirectory}: combined background size ${formatBytes(totalBytes)} exceeds ` +
`${formatBytes(maximumTotalBytes)}`
);
}
if (errors.length > 0) {
console.error(`Prologue exploration asset verification failed with ${errors.length} issue(s):`);
errors.forEach((error) => console.error(`- ${error}`));
process.exitCode = 1;
} else {
console.log(
`Verified ${expectedAssets.length} opaque ${expectedWidth}x${expectedHeight} exploration ` +
`WebP backgrounds (${formatBytes(totalBytes)} total; ${formatBytes(maximumAssetBytes)} ` +
`per-file / ${formatBytes(maximumTotalBytes)} combined limits).`
);
}
function validateDirectoryCoverage(validationErrors) {
let entries;
try {
entries = readdirSync(explorationAssetDirectory, { withFileTypes: true });
} catch (error) {
validationErrors.push(
`${explorationAssetDirectory}: cannot read exploration asset directory: ${error.message}`
);
return;
}
const actualRasterFiles = entries
.filter((entry) => entry.isFile() && rasterAssetPattern.test(entry.name))
.map((entry) => entry.name)
.sort();
const expectedRasterFiles = [...expectedAssets].sort();
for (const fileName of expectedRasterFiles) {
if (!actualRasterFiles.includes(fileName)) {
validationErrors.push(`${explorationAssetDirectory}: missing required asset "${fileName}"`);
}
}
for (const fileName of actualRasterFiles) {
if (!expectedRasterFiles.includes(fileName)) {
validationErrors.push(
`${explorationAssetDirectory}: unexpected raster asset "${fileName}"; ` +
`only the two optimized runtime WebP backgrounds should be tracked here`
);
}
}
}
function inspectWebpAsset(validationErrors, path, label) {
let bytes;
let stats;
try {
bytes = readFileSync(path);
stats = statSync(path);
} catch (error) {
validationErrors.push(`${path}: cannot read ${label}: ${error.message}`);
return undefined;
}
if (!stats.isFile()) {
validationErrors.push(`${path}: expected a regular WebP file`);
return undefined;
}
if (bytes.length < 20) {
validationErrors.push(`${path}: file is too small to be a valid WebP image`);
return undefined;
}
if (
bytes.subarray(0, 4).toString('ascii') !== 'RIFF' ||
bytes.subarray(8, 12).toString('ascii') !== 'WEBP'
) {
validationErrors.push(`${path}: missing the WebP RIFF header`);
return undefined;
}
const declaredLength = bytes.readUInt32LE(4) + 8;
if (declaredLength !== bytes.length) {
validationErrors.push(
`${path}: RIFF container declares ${declaredLength} bytes, file contains ${bytes.length}`
);
return undefined;
}
let dimensions;
let hasAlpha = false;
let animated = false;
let foundImagePayload = false;
let offset = 12;
while (offset + 8 <= bytes.length) {
const chunkType = bytes.subarray(offset, offset + 4).toString('ascii');
const chunkSize = bytes.readUInt32LE(offset + 4);
const dataOffset = offset + 8;
const paddedChunkEnd = dataOffset + chunkSize + (chunkSize % 2);
if (dataOffset + chunkSize > bytes.length || paddedChunkEnd > bytes.length) {
validationErrors.push(`${path}: "${chunkType}" chunk exceeds the RIFF container`);
return undefined;
}
if (chunkType === 'VP8X') {
if (chunkSize < 10) {
validationErrors.push(`${path}: VP8X chunk is too short`);
return undefined;
}
const featureFlags = bytes[dataOffset];
dimensions = {
width: readUInt24LE(bytes, dataOffset + 4) + 1,
height: readUInt24LE(bytes, dataOffset + 7) + 1
};
hasAlpha ||= (featureFlags & 0x10) !== 0;
animated ||= (featureFlags & 0x02) !== 0;
} else if (chunkType === 'VP8 ') {
if (
chunkSize < 10 ||
bytes[dataOffset + 3] !== 0x9d ||
bytes[dataOffset + 4] !== 0x01 ||
bytes[dataOffset + 5] !== 0x2a
) {
validationErrors.push(`${path}: invalid lossy VP8 image payload`);
return undefined;
}
dimensions ??= {
width: bytes.readUInt16LE(dataOffset + 6) & 0x3fff,
height: bytes.readUInt16LE(dataOffset + 8) & 0x3fff
};
foundImagePayload = true;
} else if (chunkType === 'VP8L') {
if (chunkSize < 5 || bytes[dataOffset] !== 0x2f) {
validationErrors.push(`${path}: invalid lossless VP8L image payload`);
return undefined;
}
const losslessHeader = bytes.readUInt32LE(dataOffset + 1);
dimensions ??= {
width: (losslessHeader & 0x3fff) + 1,
height: ((losslessHeader >>> 14) & 0x3fff) + 1
};
hasAlpha ||= (losslessHeader & 0x10000000) !== 0;
foundImagePayload = true;
} else if (chunkType === 'ALPH') {
hasAlpha = true;
} else if (chunkType === 'ANIM' || chunkType === 'ANMF') {
animated = true;
}
offset = paddedChunkEnd;
}
if (offset !== bytes.length) {
validationErrors.push(`${path}: RIFF container has an incomplete trailing chunk header`);
return undefined;
}
if (!dimensions || !foundImagePayload) {
validationErrors.push(`${path}: WebP dimensions or image payload could not be read`);
return undefined;
}
return {
size: stats.size,
width: dimensions.width,
height: dimensions.height,
hasAlpha,
animated
};
}
function readUInt24LE(bytes, offset) {
return bytes[offset] | (bytes[offset + 1] << 8) | (bytes[offset + 2] << 16);
}
function formatBytes(bytes) {
return `${(bytes / (1024 * 1024)).toFixed(2)} MiB`;
}

View File

@@ -58,7 +58,25 @@ try {
await page.waitForTimeout(380);
let village = await readVillage(page);
assert.equal(village.campaignStep, 'prologue-town');
assertExplorationBackground(village.background, 'prologue-village-background');
assert.equal(village.requiredTexturesReady, true);
assert.deepEqual(
village.requiredTextures.map(({ key, ready }) => ({ key, ready })),
[
{ key: 'exploration-liu-bei', ready: true },
{ key: 'exploration-guan-yu', ready: true },
{ key: 'exploration-zhang-fei', ready: true },
{ key: 'exploration-zhuo-recruiting-clerk', ready: true },
{ key: 'exploration-zhuo-villager', ready: true }
]
);
assert.equal(village.player.textureKey, 'exploration-liu-bei');
assertNpcTextureKeys(village.npcs, {
'zhang-fei': 'exploration-zhang-fei',
'guan-yu': 'exploration-guan-yu',
'recruiting-clerk': 'exploration-zhuo-recruiting-clerk',
'market-villager': 'exploration-zhuo-villager'
}, 'prologue village');
assert.equal(village.viewport.width, desktopBrowserViewport.width);
assert.equal(village.viewport.height, desktopBrowserViewport.height);
assert.equal(village.movement.speed, 300);
@@ -68,6 +86,8 @@ try {
assert.equal(village.completedObjectiveIds.length, 0);
assert.equal(village.exitUnlocked, false);
assert.equal(village.dialogue.active, true, 'The first visit should open one short Liu Bei orientation line.');
assert.equal(village.dialogue.portraitVisible, true);
assert.equal(village.dialogue.portraitTextureKey, 'portrait-liu-bei-yellow-turban');
assertBoundsInsideViewport(village.player.bounds, 'initial player');
village.npcs.forEach((npc) => assertBoundsInsideViewport(npc.bounds, `NPC ${npc.id}`));
@@ -100,6 +120,11 @@ try {
await page.waitForFunction(() => (
window.__HEROS_DEBUG__?.village?.()?.dialogue?.sourceNpcId === 'guan-yu'
));
assert.equal(
(await readVillage(page)).dialogue.portraitVisible,
false,
'Narrative context should not impersonate a character portrait.'
);
assert.equal(
(await readVillage(page)).dialogue.totalLines,
1,
@@ -114,6 +139,14 @@ try {
await teleportTo(page, 'zhang-fei');
await page.keyboard.press('e');
await page.waitForFunction(() => window.__HEROS_DEBUG__?.village?.()?.dialogue?.sourceNpcId === 'zhang-fei');
assert.equal((await readVillage(page)).dialogue.portraitVisible, false);
await page.mouse.click(960, 850);
await page.waitForFunction(() => (
window.__HEROS_DEBUG__?.village?.()?.dialogue?.speaker === '장비'
));
const zhangDialogue = await readVillage(page);
assert.equal(zhangDialogue.dialogue.portraitVisible, true);
assert.equal(zhangDialogue.dialogue.portraitTextureKey, 'portrait-zhang-fei-yellow-turban');
await captureStableScreenshot(page, 'dist/verification-prologue-village-active-dialogue.png');
const dialoguePlayerBefore = (await readVillage(page)).player;
await page.keyboard.down('ArrowRight');
@@ -129,6 +162,18 @@ try {
village = await readVillage(page);
assert(village.completedObjectiveIds.includes('meet-zhang-fei'));
assert.equal(village.exitUnlocked, false);
const walkingZhangFei = villageNpc(village, 'zhang-fei');
assert.equal(walkingZhangFei.moving, true, 'Zhang Fei should visibly walk toward the tavern after joining.');
assert.match(walkingZhangFei.animationKey, /exploration-zhang-fei-walk-/);
assert(
walkingZhangFei.x > 360 && walkingZhangFei.x < 1000,
`Zhang Fei should move progressively instead of teleporting: ${JSON.stringify(walkingZhangFei)}`
);
await waitForVillageNpcPosition(page, 'zhang-fei', { x: 1000, y: 540 });
village = await readVillage(page);
const tavernZhangFei = villageNpc(village, 'zhang-fei');
assert.equal(tavernZhangFei.moving, false);
assert.equal(tavernZhangFei.animationKey, 'exploration-zhang-fei-idle-east');
await captureStableScreenshot(page, 'dist/verification-prologue-village-dialogue.png');
await teleportTo(page, 'make-oath');
@@ -149,12 +194,17 @@ try {
village = await readVillage(page);
assert(village.completedObjectiveIds.includes('meet-zhang-fei'), 'A completed NPC goal must survive reload and Continue.');
assert.equal(village.dialogue.active, false, 'The one-time orientation line must not replay on Continue.');
assertNpcPosition(village, 'zhang-fei', { x: 1000, y: 540 }, 'restored Zhang Fei');
await teleportTo(page, 'recruiting-clerk');
await page.keyboard.press('e');
await page.waitForFunction(() => (
window.__HEROS_DEBUG__?.village?.()?.dialogue?.sourceNpcId === 'recruiting-clerk'
));
assert.equal(
(await readVillage(page)).dialogue.portraitTextureKey,
'portrait-zhuo-recruiting-clerk-yellow-turban'
);
assert.equal(
(await readVillage(page)).dialogue.totalLines,
1,
@@ -166,14 +216,38 @@ try {
'A locked recruiting interaction must not complete its objective.'
);
for (const npcId of ['guan-yu', 'recruiting-clerk']) {
await teleportTo(page, npcId);
await page.keyboard.press('e');
await page.waitForFunction((expectedNpcId) => (
window.__HEROS_DEBUG__?.village?.()?.dialogue?.sourceNpcId === expectedNpcId
), npcId);
await advanceDialogueUntilClosed(page);
}
await teleportTo(page, 'guan-yu');
await page.keyboard.press('e');
await page.waitForFunction(() => (
window.__HEROS_DEBUG__?.village?.()?.dialogue?.sourceNpcId === 'guan-yu'
));
await advanceDialogueUntilClosed(page);
village = await readVillage(page);
const walkingGuanYu = villageNpc(village, 'guan-yu');
const walkingZhangToRecruitment = villageNpc(village, 'zhang-fei');
assert.equal(walkingGuanYu.moving, true, 'Guan Yu should walk toward the recruiting office after joining.');
assert.equal(walkingZhangToRecruitment.moving, true, 'Zhang Fei should accompany Guan Yu toward registration.');
assert.match(walkingGuanYu.animationKey, /exploration-guan-yu-walk-/);
assert.match(walkingZhangToRecruitment.animationKey, /exploration-zhang-fei-walk-/);
assert(
walkingGuanYu.x < 1110 && walkingGuanYu.x > 850,
`Guan Yu should move progressively instead of teleporting: ${JSON.stringify(walkingGuanYu)}`
);
assert(
walkingZhangToRecruitment.y > 540 && walkingZhangToRecruitment.y < 805,
`Zhang Fei should move progressively toward registration: ${JSON.stringify(walkingZhangToRecruitment)}`
);
await waitForVillageNarrativeMovement(page);
village = await readVillage(page);
assertNpcPosition(village, 'guan-yu', { x: 850, y: 655 }, 'recruitment Guan Yu');
assertNpcPosition(village, 'zhang-fei', { x: 810, y: 805 }, 'recruitment Zhang Fei');
await teleportTo(page, 'recruiting-clerk');
await page.keyboard.press('e');
await page.waitForFunction(() => (
window.__HEROS_DEBUG__?.village?.()?.dialogue?.sourceNpcId === 'recruiting-clerk'
));
await advanceDialogueUntilClosed(page);
village = await readVillage(page);
assert.deepEqual(
@@ -182,12 +256,32 @@ try {
);
assert.equal(village.exitUnlocked, true);
assert.equal(village.oath.visible, true);
const walkingGuanToOath = villageNpc(village, 'guan-yu');
const walkingZhangToOath = villageNpc(village, 'zhang-fei');
assert.equal(walkingGuanToOath.moving, true, 'Guan Yu should walk to the oath grove after registration.');
assert.equal(walkingZhangToOath.moving, true, 'Zhang Fei should walk to the oath grove after registration.');
assert.match(walkingGuanToOath.animationKey, /exploration-guan-yu-walk-/);
assert.match(walkingZhangToOath.animationKey, /exploration-zhang-fei-walk-/);
await waitForVillageNarrativeMovement(page);
village = await readVillage(page);
assertNpcPosition(village, 'guan-yu', { x: 676, y: 437 }, 'oath Guan Yu');
assertNpcPosition(village, 'zhang-fei', { x: 884, y: 437 }, 'oath Zhang Fei');
await captureStableScreenshot(page, 'dist/verification-prologue-village-ready.png');
await teleportTo(page, 'make-oath');
const oathPlayerBefore = (await readVillage(page)).player;
await page.keyboard.press('e');
await page.waitForFunction(() => window.__HEROS_DEBUG__?.village?.()?.dialogue?.sourceNpcId === 'make-oath');
assert.equal((await readVillage(page)).dialogue.totalLines, 4);
const oathDialogue = await readVillage(page);
assert.equal(oathDialogue.dialogue.totalLines, 4);
assert(
Math.abs(oathDialogue.player.x - oathPlayerBefore.x) < 1 &&
Math.abs(oathDialogue.player.y - oathPlayerBefore.y) < 1,
`Starting the oath should not teleport Liu Bei: ${JSON.stringify({
before: oathPlayerBefore,
after: oathDialogue.player
})}`
);
await advanceDialogueUntilClosed(page);
await waitForStoryReady(page);
@@ -212,7 +306,27 @@ try {
let militiaCamp = await readMilitiaCamp(page);
assert.equal(militiaCamp.campaignStep, 'prologue-camp');
assertExplorationBackground(militiaCamp.background, 'prologue-militia-camp-background');
assert.equal(militiaCamp.requiredTexturesReady, true);
assert.deepEqual(
militiaCamp.requiredTextures.map(({ key, ready }) => ({ key, ready })),
[
{ key: 'exploration-liu-bei', ready: true },
{ key: 'exploration-guan-yu', ready: true },
{ key: 'exploration-zhang-fei', ready: true },
{ key: 'exploration-zhuo-quartermaster', ready: true },
{ key: 'exploration-zou-jing', ready: true },
{ key: 'exploration-shu-infantry', ready: true }
]
);
assert.equal(militiaCamp.player.textureKey, 'exploration-liu-bei');
assertNpcTextureKeys(militiaCamp.npcs, {
'guan-yu': 'exploration-guan-yu',
'zhang-fei': 'exploration-zhang-fei',
quartermaster: 'exploration-zhuo-quartermaster',
'zou-jing': 'exploration-zou-jing',
'nervous-volunteer': 'exploration-shu-infantry'
}, 'prologue militia camp');
assert.equal(militiaCamp.viewport.width, desktopBrowserViewport.width);
assert.equal(militiaCamp.viewport.height, desktopBrowserViewport.height);
assert.equal(militiaCamp.movement.speed, 300);
@@ -226,6 +340,8 @@ try {
true,
'The first camp visit should explain why Liu Bei must inspect the camp.'
);
assert.equal(militiaCamp.dialogue.portraitVisible, true);
assert.equal(militiaCamp.dialogue.portraitTextureKey, 'portrait-liu-bei-yellow-turban');
assertBoundsInsideViewport(militiaCamp.player.bounds, 'initial militia camp player');
assertBoundsInsideViewport(militiaCamp.dialogue.bounds, 'initial militia camp dialogue');
militiaCamp.npcs.forEach((npc) => assertBoundsInsideViewport(npc.bounds, `camp NPC ${npc.id}`));
@@ -269,6 +385,10 @@ try {
await page.waitForFunction(() => (
window.__HEROS_DEBUG__?.militiaCamp?.()?.dialogue?.sourceNpcId === 'quartermaster'
));
assert.equal(
(await readMilitiaCamp(page)).dialogue.portraitTextureKey,
'portrait-zhuo-quartermaster-yellow-turban'
);
const campDialoguePlayerBefore = (await readMilitiaCamp(page)).player;
assertBoundsInsideViewport(
(await readMilitiaCamp(page)).dialogue.bounds,
@@ -334,6 +454,7 @@ try {
));
const commandCamp = await readMilitiaCamp(page);
assert.equal(commandCamp.dialogue.totalLines, 5);
assert.equal(commandCamp.dialogue.portraitTextureKey, 'portrait-zou-jing-yellow-turban');
assert.deepEqual(
commandCamp.npcs
.filter((npc) => ['guan-yu', 'zhang-fei'].includes(npc.id))
@@ -526,6 +647,41 @@ async function interactWithMilitiaCampNpc(page, npcId) {
await advanceMilitiaCampDialogueUntilClosed(page);
}
function villageNpc(village, npcId) {
const npc = village?.npcs?.find((entry) => entry.id === npcId);
assert(npc, `Expected village NPC ${npcId}: ${JSON.stringify(village?.npcs)}`);
return npc;
}
function assertNpcPosition(village, npcId, expected, label) {
const npc = villageNpc(village, npcId);
assert(
Math.abs(npc.x - expected.x) <= 1 && Math.abs(npc.y - expected.y) <= 1,
`${label}: expected ${JSON.stringify(expected)}, received ${JSON.stringify(npc)}`
);
assert.equal(npc.moving, false, `${label} should be idle after reaching the narrative position.`);
}
async function waitForVillageNpcPosition(page, npcId, expected) {
await page.waitForFunction(({ id, x, y }) => {
const village = window.__HEROS_DEBUG__?.village?.();
const npc = village?.npcs?.find((entry) => entry.id === id);
return Boolean(
npc &&
npc.moving === false &&
Math.abs(npc.x - x) <= 1 &&
Math.abs(npc.y - y) <= 1
);
}, { id: npcId, ...expected }, { timeout: 6000 });
}
async function waitForVillageNarrativeMovement(page) {
await page.waitForFunction(() => {
const movement = window.__HEROS_DEBUG__?.village?.()?.narrativeMovement;
return movement && movement.movingNpcIds.length === 0 && movement.oathGatherPending === false;
}, undefined, { timeout: 6000 });
}
async function advanceDialogueUntilClosed(page) {
for (let attempt = 0; attempt < 12; attempt += 1) {
const active = (await readVillage(page))?.dialogue?.active;
@@ -634,6 +790,37 @@ function assertBoundsInsideViewport(bounds, label) {
);
}
function assertNpcTextureKeys(npcs, expectedTextureKeyByNpcId, sceneLabel) {
assert(Array.isArray(npcs), `${sceneLabel}: expected an NPC debug-state array.`);
const actualMappings = npcs
.map(({ id, textureKey }) => ({ id, textureKey }))
.sort((left, right) => left.id.localeCompare(right.id));
const expectedMappings = Object.entries(expectedTextureKeyByNpcId)
.map(([id, textureKey]) => ({ id, textureKey }))
.sort((left, right) => left.id.localeCompare(right.id));
assert.deepEqual(
actualMappings,
expectedMappings,
`${sceneLabel}: every named NPC must render with its assigned exploration texture.`
);
}
function assertExplorationBackground(background, expectedKey) {
assert(background, `Expected ${expectedKey} debug state.`);
assert.equal(background.key, expectedKey);
assert.equal(background.ready, true);
assert.equal(background.fallback, false);
assert.equal(background.sourceWidth, desktopBrowserViewport.width);
assert.equal(background.sourceHeight, desktopBrowserViewport.height);
assert.equal(background.objectCount, 1);
assert.deepEqual(background.bounds, {
x: 0,
y: 0,
width: desktopBrowserViewport.width,
height: desktopBrowserViewport.height
});
}
async function captureStableScreenshot(page, path) {
await page.waitForTimeout(220);
const loopSlept = await page.evaluate(() => {

View File

@@ -23,6 +23,9 @@ const checks = [
'scripts/verify-camp-reward-data.mjs',
'scripts/verify-city-stay-data.mjs',
'scripts/verify-prologue-village-data.mjs',
'scripts/verify-prologue-exploration-asset-data.mjs',
'scripts/verify-exploration-character-asset-data.mjs',
'scripts/verify-prologue-dialogue-portrait-data.mjs',
'scripts/verify-victory-reward-acknowledgements.mjs',
'scripts/verify-camp-skin-data.mjs',
'scripts/verify-camp-soundscape-data.mjs',

View File

@@ -117,11 +117,22 @@ function validateStoryManifestKeys(errors, storyBackgroundAssets, sourceKeys, al
function validatePortraitImages(errors, portraitAssets) {
const slugs = new Set();
for (const fileName of imageFiles(portraitDir)) {
const path = join(portraitDir, fileName);
validatePngFile(errors, path, expectedPortraitSize, 'portrait');
const files = assetFiles(portraitDir, /\.(?:png|webp)$/i);
const unexpectedFiles = assetFiles(portraitDir, /./)
.filter((fileName) => !/\.(?:png|webp)$/i.test(fileName));
unexpectedFiles.forEach((fileName) => {
errors.push(`${join(portraitDir, fileName)}: portraits must use PNG or WebP`);
});
const slug = fileName.replace(/\.png$/i, '');
for (const fileName of files) {
const path = join(portraitDir, fileName);
if (/\.webp$/i.test(fileName)) {
validateWebpFile(errors, path, expectedPortraitSize, 'portrait');
} else {
validatePngFile(errors, path, expectedPortraitSize, 'portrait');
}
const slug = fileName.replace(/\.(?:png|webp)$/i, '');
slugs.add(slug);
if (!portraitAssets[slug]) {
errors.push(`${path}: expected portraitAssets slug "${slug}"`);