feat: upgrade prologue exploration presentation
This commit is contained in:
202
scripts/verify-exploration-character-asset-data.mjs
Normal file
202
scripts/verify-exploration-character-asset-data.mjs
Normal 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.`);
|
||||
}
|
||||
331
scripts/verify-prologue-dialogue-portrait-data.mjs
Normal file
331
scripts/verify-prologue-dialogue-portrait-data.mjs
Normal 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}"`;
|
||||
}
|
||||
223
scripts/verify-prologue-exploration-asset-data.mjs
Normal file
223
scripts/verify-prologue-exploration-asset-data.mjs
Normal 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`;
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}"`);
|
||||
|
||||
Reference in New Issue
Block a user