Files
heros_web/scripts/verify-story-asset-data.mjs

361 lines
13 KiB
JavaScript

import { readdirSync } from 'node:fs';
import { join } from 'node:path';
import { createServer } from 'vite';
const validCutsceneKinds = new Set(['muster', 'operation', 'victory']);
const validMarkerSides = new Set(['ally', 'enemy', 'objective']);
const validRewardTones = new Set(['reward', 'next', 'bond']);
const validDirections = new Set(['south', 'east', 'north', 'west']);
const portraitlessActorIds = new Set(['rebel-leader']);
const server = await createServer({
logLevel: 'error',
server: { middlewareMode: true },
appType: 'custom'
});
try {
const scenarioModule = await server.ssrLoadModule('/src/game/data/scenario.ts');
const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts');
const { storyBackgroundAssets, storyBackgroundKeyForPage, storyBackgroundKeysFor } =
await server.ssrLoadModule('/src/game/data/storyAssets.ts');
const { portraitAssetEntriesForKey, portraitKeyForSpeaker } = await server.ssrLoadModule('/src/game/data/portraitAssets.ts');
const { storyCutsceneActorProfileFor } = await server.ssrLoadModule('/src/game/data/storyCutscenes.ts');
const { hasUnitSheetAsset } = await server.ssrLoadModule('/src/game/data/unitAssets.ts');
const errors = [];
const storySources = collectStorySources(scenarioModule, battleScenarios);
const uniquePageIds = new Set();
const usedBackgroundKeys = new Set();
const selectedBackgroundKeys = new Set();
const selectedBackgroundUrlsByBase = new Map();
const usedPortraitKeys = new Set();
const usedCutsceneActorIds = new Set();
let pageCount = 0;
let cutsceneCount = 0;
storySources.forEach(({ source, pages }) => {
if (!Array.isArray(pages) || pages.length === 0) {
errors.push(`${source}: story page list must not be empty`);
return;
}
pages.forEach((page, index) => {
pageCount += 1;
uniquePageIds.add(page.id);
validateStoryPage({
errors,
source,
index,
page,
usedBackgroundKeys,
selectedBackgroundKeys,
selectedBackgroundUrlsByBase,
usedPortraitKeys,
usedCutsceneActorIds,
storyBackgroundAssets,
storyBackgroundKeyForPage,
storyBackgroundKeysFor,
portraitAssetEntriesForKey,
portraitKeyForSpeaker,
storyCutsceneActorProfileFor,
hasUnitSheetAsset
});
if (page.cutscene) {
cutsceneCount += 1;
}
});
});
validateStoryBackgroundVariantCoverage(errors, selectedBackgroundKeys);
validateRepeatedStoryBackgroundDistribution(errors, selectedBackgroundUrlsByBase);
if (errors.length) {
console.error(`Story asset data verification failed with ${errors.length} issue(s):`);
errors.slice(0, 100).forEach((error) => console.error(`- ${error}`));
if (errors.length > 100) {
console.error(`- ...and ${errors.length - 100} more`);
}
process.exitCode = 1;
} else {
console.log(
`Verified ${pageCount} story page references (${uniquePageIds.size} unique pages), ` +
`${cutsceneCount} cutscenes, ${usedBackgroundKeys.size} background candidates, ` +
`${selectedBackgroundKeys.size} selected backgrounds, ` +
`${usedPortraitKeys.size} portrait keys, and ${usedCutsceneActorIds.size} cutscene actor profiles.`
);
}
} finally {
await server.close();
}
function collectStorySources(scenarioModule, battleScenarios) {
const sources = [];
const seenArrays = new WeakSet();
Object.entries(scenarioModule)
.filter(([, value]) => isStoryPageList(value))
.sort(([left], [right]) => left.localeCompare(right))
.forEach(([name, pages]) => {
sources.push({ source: name, pages });
seenArrays.add(pages);
});
Object.entries(battleScenarios)
.sort(([left], [right]) => left.localeCompare(right))
.forEach(([battleId, scenario]) => {
if (!isStoryPageList(scenario.victoryPages) || seenArrays.has(scenario.victoryPages)) {
return;
}
sources.push({ source: `${battleId}.victoryPages`, pages: scenario.victoryPages });
seenArrays.add(scenario.victoryPages);
});
return sources;
}
function isStoryPageList(value) {
return Array.isArray(value) && value.length > 0 && value.every(isStoryPage);
}
function isStoryPage(page) {
return Boolean(
page &&
typeof page === 'object' &&
typeof page.id === 'string' &&
typeof page.chapter === 'string' &&
typeof page.background === 'string' &&
typeof page.text === 'string'
);
}
function validateStoryPage(context) {
const { errors, source, index, page } = context;
const pageContext = `${source}[${index}]/${page.id}`;
assertNonEmptyString(errors, page.id, `${pageContext}: id`);
assertNonEmptyString(errors, page.chapter, `${pageContext}: chapter`);
assertNonEmptyString(errors, page.text, `${pageContext}: text`);
assertNonEmptyString(errors, page.background, `${pageContext}: background`);
validateBackground(context, pageContext);
validatePortrait(context, pageContext);
validateCutscene(context, pageContext);
}
function validateBackground(context, pageContext) {
const {
errors,
page,
usedBackgroundKeys,
selectedBackgroundKeys,
selectedBackgroundUrlsByBase,
storyBackgroundAssets,
storyBackgroundKeyForPage,
storyBackgroundKeysFor
} = context;
if (!page.background) {
return;
}
const keys = storyBackgroundKeysFor(page.background).filter((key) => storyBackgroundAssets[key]);
if (keys.length === 0) {
errors.push(`${pageContext}: missing story background asset "${page.background}"`);
return;
}
keys.forEach((key) => usedBackgroundKeys.add(key));
const selectedKey = storyBackgroundKeyForPage(page.background, page.id);
const repeatedSelection = storyBackgroundKeyForPage(page.background, page.id);
if (selectedKey !== repeatedSelection) {
errors.push(`${pageContext}: story background selection must be deterministic`);
}
if (!keys.includes(selectedKey)) {
errors.push(`${pageContext}: selected background "${selectedKey}" is not a candidate for "${page.background}"`);
return;
}
const selectedUrl = storyBackgroundAssets[selectedKey];
if (!selectedUrl) {
errors.push(`${pageContext}: selected background "${selectedKey}" has no runtime asset URL`);
return;
}
selectedBackgroundKeys.add(selectedKey);
const backgroundGroup = selectedBackgroundUrlsByBase.get(page.background) ?? {
pageCount: 0,
candidateUrls: new Set(),
selectedUrls: new Set()
};
backgroundGroup.pageCount += 1;
keys.forEach((key) => backgroundGroup.candidateUrls.add(storyBackgroundAssets[key]));
backgroundGroup.selectedUrls.add(selectedUrl);
selectedBackgroundUrlsByBase.set(page.background, backgroundGroup);
}
function validateStoryBackgroundVariantCoverage(errors, selectedBackgroundKeys) {
const firstVariantNumber = 33;
const lastVariantNumber = 134;
const expectedVariantCount = lastVariantNumber - firstVariantNumber + 1;
const variantKeys = readdirSync(join('src', 'assets', 'images', 'story'), { withFileTypes: true })
.filter((entry) => entry.isFile())
.map((entry) => entry.name.match(/^(\d+)-(.+)\.png$/i))
.filter((match) => match && Number(match[1]) >= firstVariantNumber && Number(match[1]) <= lastVariantNumber)
.map((match) => `story-${match[2]}`)
.sort();
if (variantKeys.length !== expectedVariantCount) {
errors.push(
`story background variants ${firstVariantNumber}-${lastVariantNumber}: expected ${expectedVariantCount} files, ` +
`found ${variantKeys.length}`
);
}
variantKeys.forEach((key) => {
if (!selectedBackgroundKeys.has(key)) {
errors.push(`story background variant "${key}" is never selected by a campaign page`);
}
});
}
function validateRepeatedStoryBackgroundDistribution(errors, selectedBackgroundUrlsByBase) {
selectedBackgroundUrlsByBase.forEach(({ pageCount, candidateUrls, selectedUrls }, baseKey) => {
if (pageCount > 1 && candidateUrls.size > 1 && selectedUrls.size < 2) {
errors.push(
`${baseKey}: ${pageCount} repeated pages should use at least two of ${candidateUrls.size} background variants`
);
}
});
}
function validatePortrait(context, pageContext) {
const { errors, page, usedPortraitKeys, portraitAssetEntriesForKey, portraitKeyForSpeaker } = context;
const portraitKey = page.portrait ?? portraitKeyForSpeaker(page.speaker);
if (!portraitKey) {
return;
}
const entries = portraitAssetEntriesForKey(portraitKey);
if (entries.length === 0) {
errors.push(`${pageContext}: missing portrait asset "${portraitKey}"`);
}
usedPortraitKeys.add(portraitKey);
}
function validateCutscene(context, pageContext) {
const { errors, page } = context;
const cutscene = page.cutscene;
if (!cutscene) {
return;
}
if (!validCutsceneKinds.has(cutscene.kind)) {
errors.push(`${pageContext}: invalid cutscene kind "${cutscene.kind}"`);
}
assertNonEmptyString(errors, cutscene.title, `${pageContext}: cutscene title`);
if (cutscene.subtitle !== undefined) {
assertNonEmptyString(errors, cutscene.subtitle, `${pageContext}: cutscene subtitle`);
}
(cutscene.actors ?? []).forEach((actor, actorIndex) => validateCutsceneActor(context, pageContext, actor, actorIndex));
(cutscene.markers ?? []).forEach((marker, markerIndex) => validateCutsceneMarker(errors, pageContext, marker, markerIndex));
validateCutsceneBriefing(errors, pageContext, cutscene.briefing);
(cutscene.rewards ?? []).forEach((reward, rewardIndex) => validateCutsceneReward(errors, pageContext, reward, rewardIndex));
}
function validateCutsceneActor(context, pageContext, actor, actorIndex) {
const {
errors,
usedCutsceneActorIds,
usedPortraitKeys,
storyCutsceneActorProfileFor,
portraitAssetEntriesForKey,
hasUnitSheetAsset
} = context;
const actorContext = `${pageContext}/actor[${actorIndex}]`;
assertNonEmptyString(errors, actor.unitId, `${actorContext}: unitId`);
assertNormalizedNumber(errors, actor.x, `${actorContext}: x`);
assertNormalizedNumber(errors, actor.y, `${actorContext}: y`);
if (actor.size !== undefined && (!Number.isFinite(actor.size) || actor.size < 48 || actor.size > 220)) {
errors.push(`${actorContext}: actor size must be a pixel value between 48 and 220`);
}
if (actor.direction !== undefined && !validDirections.has(actor.direction)) {
errors.push(`${actorContext}: invalid direction "${actor.direction}"`);
}
if (actor.label !== undefined) {
assertNonEmptyString(errors, actor.label, `${actorContext}: label`);
}
if (actor.line !== undefined) {
assertNonEmptyString(errors, actor.line, `${actorContext}: line`);
}
const profile = storyCutsceneActorProfileFor(actor.unitId);
if (!profile) {
errors.push(`${actorContext}: missing actor profile for "${actor.unitId}"`);
return;
}
usedCutsceneActorIds.add(actor.unitId);
assertNonEmptyString(errors, profile.name, `${actorContext}: actor profile name`);
assertNonEmptyString(errors, profile.roleLabel, `${actorContext}: actor profile role label`);
assertNonEmptyString(errors, profile.unitTextureKey, `${actorContext}: actor profile unit texture key`);
if (!hasUnitSheetAsset(profile.unitTextureKey)) {
errors.push(`${actorContext}: missing unit sheet "${profile.unitTextureKey}"`);
}
if (!portraitlessActorIds.has(actor.unitId)) {
assertNonEmptyString(errors, profile.portraitKey, `${actorContext}: actor profile portrait key`);
if (portraitAssetEntriesForKey(profile.portraitKey).length === 0) {
errors.push(`${actorContext}: missing actor portrait "${profile.portraitKey}"`);
}
usedPortraitKeys.add(profile.portraitKey);
}
}
function validateCutsceneMarker(errors, pageContext, marker, markerIndex) {
const markerContext = `${pageContext}/marker[${markerIndex}]`;
assertNormalizedNumber(errors, marker.x, `${markerContext}: x`);
assertNormalizedNumber(errors, marker.y, `${markerContext}: y`);
assertNonEmptyString(errors, marker.label, `${markerContext}: label`);
if (!validMarkerSides.has(marker.side)) {
errors.push(`${markerContext}: invalid side "${marker.side}"`);
}
}
function validateCutsceneBriefing(errors, pageContext, briefing) {
if (!briefing) {
return;
}
assertNonEmptyString(errors, briefing.title, `${pageContext}: briefing title`);
if (!Array.isArray(briefing.lines) || briefing.lines.length === 0) {
errors.push(`${pageContext}: briefing lines must not be empty`);
return;
}
briefing.lines.forEach((line, lineIndex) => {
assertNonEmptyString(errors, line, `${pageContext}/briefing[${lineIndex}]`);
});
}
function validateCutsceneReward(errors, pageContext, reward, rewardIndex) {
const rewardContext = `${pageContext}/reward[${rewardIndex}]`;
assertNonEmptyString(errors, reward.label, `${rewardContext}: label`);
if (reward.tone !== undefined && !validRewardTones.has(reward.tone)) {
errors.push(`${rewardContext}: invalid tone "${reward.tone}"`);
}
}
function assertNormalizedNumber(errors, value, context) {
if (!Number.isFinite(value) || value < 0 || value > 1) {
errors.push(`${context} must be a number between 0 and 1`);
}
}
function assertNonEmptyString(errors, value, context) {
if (typeof value !== 'string' || value.trim().length === 0) {
errors.push(`${context} must be a non-empty string`);
}
}