feat: surface campaign officer portraits
This commit is contained in:
@@ -390,6 +390,28 @@ try {
|
||||
await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-sortie-formation.png`, fullPage: true });
|
||||
await assertCanvasPainted(page, 'mid camp sortie formation');
|
||||
|
||||
const midFormationPortraitState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
||||
const missingMidFormationPortraits = midFormationPortraitState?.sortiePortraitRoster?.filter(
|
||||
(unit) => !unit.portraitReady || !unit.portraitTextureKey?.startsWith('portrait-card-')
|
||||
) ?? [];
|
||||
assert(
|
||||
midFormationPortraitState?.sortiePortraitRoster?.length === 23 && missingMidFormationPortraits.length === 0,
|
||||
`Expected all 23 campaign officers to use loaded lightweight portrait cards: ${JSON.stringify(missingMidFormationPortraits)}`
|
||||
);
|
||||
|
||||
await page.mouse.move(530, 260);
|
||||
await page.waitForFunction(() => Boolean(window.__HEROS_DEBUG__?.camp()?.sortieHoveredUnitId), undefined, { timeout: 30000 });
|
||||
const hoveredPortraitUnitId = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortieHoveredUnitId);
|
||||
assert(
|
||||
midFormationPortraitState.sortiePortraitRoster.some(
|
||||
(unit) => unit.id === hoveredPortraitUnitId && unit.portraitReady && unit.portraitTextureKey?.startsWith('portrait-card-')
|
||||
),
|
||||
`Expected portrait-card hover feedback for a mapped officer: ${JSON.stringify(hoveredPortraitUnitId)}`
|
||||
);
|
||||
await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-sortie-formation-hover.png`, fullPage: true });
|
||||
await page.mouse.move(40, 710);
|
||||
await page.waitForFunction(() => window.__HEROS_DEBUG__?.camp()?.sortieHoveredUnitId === null, undefined, { timeout: 30000 });
|
||||
|
||||
const midFormationFirstPage = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView);
|
||||
await page.mouse.click(658, 196);
|
||||
await page.waitForFunction((previousScroll) => {
|
||||
|
||||
@@ -4,11 +4,13 @@ import { createServer } from 'vite';
|
||||
|
||||
const storyDir = join('src', 'assets', 'images', 'story');
|
||||
const portraitDir = join('src', 'assets', 'images', 'portraits');
|
||||
const portraitCardDir = join(portraitDir, 'cards');
|
||||
const titleImagePath = join('src', 'assets', 'images', 'taoyuan-oath-title.png');
|
||||
const storyAssetsSourcePath = join('src', 'game', 'data', 'storyAssets.ts');
|
||||
|
||||
const expectedStorySize = { width: 1672, height: 941 };
|
||||
const expectedPortraitSize = { width: 1254, height: 1254 };
|
||||
const expectedPortraitCardSize = { width: 320, height: 320 };
|
||||
const allowedPngColorTypes = new Map([
|
||||
[2, 'RGB'],
|
||||
[6, 'RGBA']
|
||||
@@ -22,7 +24,20 @@ const server = await createServer({
|
||||
|
||||
try {
|
||||
const { storyBackgroundAssets } = await server.ssrLoadModule('/src/game/data/storyAssets.ts');
|
||||
const { portraitAssets } = await server.ssrLoadModule('/src/game/data/portraitAssets.ts');
|
||||
const {
|
||||
campaignPortraitKeysByUnitId,
|
||||
portraitAssets,
|
||||
portraitCardAssetEntryForKey,
|
||||
portraitCardAssets,
|
||||
portraitCardTextureKey,
|
||||
portraitTextureKey
|
||||
} = await server.ssrLoadModule('/src/game/data/portraitAssets.ts');
|
||||
const { campaignRecruitUnits, firstBattleUnits } = await server.ssrLoadModule('/src/game/data/scenario.ts');
|
||||
const expectedCampaignPortraitUnitIds = new Set(
|
||||
[...firstBattleUnits, ...campaignRecruitUnits]
|
||||
.filter((unit) => unit.faction === 'ally')
|
||||
.map((unit) => unit.id)
|
||||
);
|
||||
const errors = [];
|
||||
const storyAliasKeys = parseStoryAliasKeys(readFileSync(storyAssetsSourcePath, 'utf8'), errors);
|
||||
|
||||
@@ -32,6 +47,17 @@ try {
|
||||
|
||||
const portraitSlugs = validatePortraitImages(errors, portraitAssets);
|
||||
validatePortraitManifestKeys(errors, portraitAssets, portraitSlugs);
|
||||
const portraitCardSlugs = validatePortraitCardImages(errors, portraitCardAssets, expectedCampaignPortraitUnitIds);
|
||||
validatePortraitCardManifestKeys(errors, portraitCardAssets, portraitCardSlugs);
|
||||
validateCampaignPortraitCoverage(errors, {
|
||||
expectedCampaignPortraitUnitIds,
|
||||
campaignPortraitKeysByUnitId,
|
||||
portraitAssets,
|
||||
portraitCardAssets,
|
||||
portraitTextureKey,
|
||||
portraitCardTextureKey,
|
||||
portraitCardAssetEntryForKey
|
||||
});
|
||||
|
||||
if (errors.length) {
|
||||
console.error(`Story image asset verification failed with ${errors.length} issue(s):`);
|
||||
@@ -43,6 +69,7 @@ try {
|
||||
} else {
|
||||
console.log(
|
||||
`Verified ${storySourceKeys.size} story backgrounds, ${portraitSlugs.size} portraits, ` +
|
||||
`${portraitCardSlugs.size} portrait cards, ${expectedCampaignPortraitUnitIds.size} campaign portrait mappings, ` +
|
||||
`${storyAliasKeys.size} story aliases, and the title artwork dimensions.`
|
||||
);
|
||||
}
|
||||
@@ -115,6 +142,111 @@ function validatePortraitManifestKeys(errors, portraitAssets, slugs) {
|
||||
});
|
||||
}
|
||||
|
||||
function validatePortraitCardImages(errors, portraitCardAssets, expectedCampaignPortraitUnitIds) {
|
||||
const slugs = new Set();
|
||||
const files = assetFiles(portraitCardDir, /\.webp$/i);
|
||||
if (files.length !== expectedCampaignPortraitUnitIds.size) {
|
||||
errors.push(
|
||||
`${portraitCardDir}: expected ${expectedCampaignPortraitUnitIds.size} WebP portrait cards, found ${files.length}`
|
||||
);
|
||||
}
|
||||
|
||||
const unexpectedFiles = assetFiles(portraitCardDir, /./).filter((fileName) => !/\.webp$/i.test(fileName));
|
||||
unexpectedFiles.forEach((fileName) => {
|
||||
errors.push(`${join(portraitCardDir, fileName)}: portrait card assets must use WebP`);
|
||||
});
|
||||
|
||||
for (const fileName of files) {
|
||||
const path = join(portraitCardDir, fileName);
|
||||
validateWebpFile(errors, path, expectedPortraitCardSize, 'portrait card');
|
||||
|
||||
const slug = fileName.replace(/\.webp$/i, '');
|
||||
slugs.add(slug);
|
||||
if (!expectedCampaignPortraitUnitIds.has(slug)) {
|
||||
errors.push(`${path}: unexpected campaign portrait card slug "${slug}"`);
|
||||
}
|
||||
if (!portraitCardAssets[slug]) {
|
||||
errors.push(`${path}: expected portraitCardAssets slug "${slug}"`);
|
||||
}
|
||||
validateRuntimeUrl(errors, slug, portraitCardAssets[slug], path);
|
||||
}
|
||||
|
||||
expectedCampaignPortraitUnitIds.forEach((unitId) => {
|
||||
if (!slugs.has(unitId)) {
|
||||
errors.push(`${portraitCardDir}: missing campaign portrait card "${unitId}.webp"`);
|
||||
}
|
||||
});
|
||||
|
||||
return slugs;
|
||||
}
|
||||
|
||||
function validatePortraitCardManifestKeys(errors, portraitCardAssets, slugs) {
|
||||
Object.keys(portraitCardAssets)
|
||||
.sort()
|
||||
.forEach((slug) => {
|
||||
if (!slugs.has(slug)) {
|
||||
errors.push(`portraitCardAssets contains unexpected slug "${slug}"`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function validateCampaignPortraitCoverage(
|
||||
errors,
|
||||
{
|
||||
expectedCampaignPortraitUnitIds,
|
||||
campaignPortraitKeysByUnitId,
|
||||
portraitAssets,
|
||||
portraitCardAssets,
|
||||
portraitTextureKey,
|
||||
portraitCardTextureKey,
|
||||
portraitCardAssetEntryForKey
|
||||
}
|
||||
) {
|
||||
const mappedUnitIds = Object.keys(campaignPortraitKeysByUnitId).sort();
|
||||
if (mappedUnitIds.length !== expectedCampaignPortraitUnitIds.size) {
|
||||
errors.push(
|
||||
`campaignPortraitKeysByUnitId should contain ${expectedCampaignPortraitUnitIds.size} units, found ${mappedUnitIds.length}`
|
||||
);
|
||||
}
|
||||
|
||||
mappedUnitIds.forEach((unitId) => {
|
||||
if (!expectedCampaignPortraitUnitIds.has(unitId)) {
|
||||
errors.push(`campaignPortraitKeysByUnitId contains unexpected unit "${unitId}"`);
|
||||
}
|
||||
});
|
||||
|
||||
expectedCampaignPortraitUnitIds.forEach((unitId) => {
|
||||
const portraitKey = campaignPortraitKeysByUnitId[unitId];
|
||||
if (typeof portraitKey !== 'string' || portraitKey.length === 0) {
|
||||
errors.push(`campaignPortraitKeysByUnitId is missing a portrait key for "${unitId}"`);
|
||||
return;
|
||||
}
|
||||
if (!portraitAssets[unitId]) {
|
||||
errors.push(`campaign unit "${unitId}" is missing its base portrait asset`);
|
||||
}
|
||||
if (!portraitCardAssets[unitId]) {
|
||||
errors.push(`campaign unit "${unitId}" is missing its portrait card asset`);
|
||||
}
|
||||
|
||||
const expectedBaseTextureKey = `portrait-${unitId}`;
|
||||
const expectedCardTextureKey = `portrait-card-${unitId}`;
|
||||
if (portraitTextureKey(portraitKey) !== expectedBaseTextureKey) {
|
||||
errors.push(`campaign unit "${unitId}" does not resolve base texture key "${expectedBaseTextureKey}"`);
|
||||
}
|
||||
if (portraitCardTextureKey(portraitKey) !== expectedCardTextureKey) {
|
||||
errors.push(`campaign unit "${unitId}" does not resolve card texture key "${expectedCardTextureKey}"`);
|
||||
}
|
||||
|
||||
const cardEntry = portraitCardAssetEntryForKey(portraitKey);
|
||||
if (cardEntry?.textureKey !== expectedCardTextureKey) {
|
||||
errors.push(`campaign unit "${unitId}" does not expose card entry texture key "${expectedCardTextureKey}"`);
|
||||
}
|
||||
if (cardEntry?.url !== portraitCardAssets[unitId]) {
|
||||
errors.push(`campaign unit "${unitId}" card entry does not expose its manifest URL`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function validatePngFile(errors, path, expectedSize, label) {
|
||||
let bytes;
|
||||
let stats;
|
||||
@@ -155,6 +287,41 @@ function validatePngFile(errors, path, expectedSize, label) {
|
||||
}
|
||||
}
|
||||
|
||||
function validateWebpFile(errors, path, expectedSize, label) {
|
||||
let bytes;
|
||||
let stats;
|
||||
try {
|
||||
bytes = readFileSync(path);
|
||||
stats = statSync(path);
|
||||
} catch (error) {
|
||||
errors.push(`${path}: cannot read ${label}: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (stats.size < 5 * 1024) {
|
||||
errors.push(`${path}: ${label} is suspiciously small (${stats.size} bytes)`);
|
||||
}
|
||||
if (
|
||||
bytes.subarray(0, 4).toString('ascii') !== 'RIFF' ||
|
||||
bytes.subarray(8, 12).toString('ascii') !== 'WEBP'
|
||||
) {
|
||||
errors.push(`${path}: ${label} does not have a WebP RIFF header`);
|
||||
return;
|
||||
}
|
||||
|
||||
const dimensions = readWebpDimensions(bytes);
|
||||
if (!dimensions) {
|
||||
errors.push(`${path}: ${label} does not expose readable WebP dimensions`);
|
||||
return;
|
||||
}
|
||||
if (dimensions.width !== expectedSize.width || dimensions.height !== expectedSize.height) {
|
||||
errors.push(
|
||||
`${path}: ${label} should be ${expectedSize.width}x${expectedSize.height}, ` +
|
||||
`got ${dimensions.width}x${dimensions.height}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function validateRuntimeUrl(errors, key, url, path) {
|
||||
assertNonEmptyString(errors, url, `${key} runtime asset URL`);
|
||||
if (typeof url !== 'string' || url.startsWith('data:')) {
|
||||
@@ -168,11 +335,15 @@ function validateRuntimeUrl(errors, key, url, path) {
|
||||
}
|
||||
|
||||
function imageFiles(dir) {
|
||||
return assetFiles(dir, /\.png$/i);
|
||||
}
|
||||
|
||||
function assetFiles(dir, pattern) {
|
||||
return readdirSync(dir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isFile())
|
||||
.map((entry) => entry.name)
|
||||
.sort()
|
||||
.filter((fileName) => /\.png$/i.test(fileName));
|
||||
.filter((fileName) => pattern.test(fileName));
|
||||
}
|
||||
|
||||
function storyKeyFromFileName(fileName) {
|
||||
@@ -195,6 +366,58 @@ function parseStoryAliasKeys(source, errors) {
|
||||
return aliases;
|
||||
}
|
||||
|
||||
function readWebpDimensions(bytes) {
|
||||
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;
|
||||
if (dataOffset + chunkSize > bytes.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (chunkType === 'VP8X' && chunkSize >= 10) {
|
||||
return {
|
||||
width: readUInt24LE(bytes, dataOffset + 4) + 1,
|
||||
height: readUInt24LE(bytes, dataOffset + 7) + 1
|
||||
};
|
||||
}
|
||||
|
||||
if (chunkType === 'VP8L' && chunkSize >= 5 && bytes[dataOffset] === 0x2f) {
|
||||
const bits =
|
||||
bytes[dataOffset + 1] |
|
||||
(bytes[dataOffset + 2] << 8) |
|
||||
(bytes[dataOffset + 3] << 16) |
|
||||
(bytes[dataOffset + 4] << 24);
|
||||
return {
|
||||
width: (bits & 0x3fff) + 1,
|
||||
height: ((bits >>> 14) & 0x3fff) + 1
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
chunkType === 'VP8 ' &&
|
||||
chunkSize >= 10 &&
|
||||
bytes[dataOffset + 3] === 0x9d &&
|
||||
bytes[dataOffset + 4] === 0x01 &&
|
||||
bytes[dataOffset + 5] === 0x2a
|
||||
) {
|
||||
return {
|
||||
width: bytes.readUInt16LE(dataOffset + 6) & 0x3fff,
|
||||
height: bytes.readUInt16LE(dataOffset + 8) & 0x3fff
|
||||
};
|
||||
}
|
||||
|
||||
offset = dataOffset + chunkSize + (chunkSize % 2);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readUInt24LE(bytes, offset) {
|
||||
return bytes[offset] | (bytes[offset + 1] << 8) | (bytes[offset + 2] << 16);
|
||||
}
|
||||
|
||||
function assetUrlToPath(url) {
|
||||
const marker = 'src/assets/images/';
|
||||
const index = url.indexOf(marker);
|
||||
|
||||
Reference in New Issue
Block a user