Verify story asset references
This commit is contained in:
@@ -13,6 +13,7 @@
|
||||
"measure:performance": "node scripts/measure-performance.mjs",
|
||||
"verify:battle-data": "node scripts/verify-battle-scenario-data.mjs",
|
||||
"verify:campaign-data": "node scripts/verify-campaign-flow-data.mjs",
|
||||
"verify:story-assets": "node scripts/verify-story-asset-data.mjs",
|
||||
"verify:flow": "node scripts/verify-flow.mjs",
|
||||
"verify:save-flow": "node scripts/verify-save-retry-flow.mjs",
|
||||
"verify:release": "node scripts/verify-release-candidate.mjs",
|
||||
|
||||
@@ -11,6 +11,7 @@ let browser;
|
||||
try {
|
||||
runCommand('node', ['scripts/verify-campaign-flow-data.mjs']);
|
||||
runCommand('node', ['scripts/verify-battle-scenario-data.mjs']);
|
||||
runCommand('node', ['scripts/verify-story-asset-data.mjs']);
|
||||
mkdirSync(screenshotDir, { recursive: true });
|
||||
serverProcess = await ensurePreviewServer(targetUrl);
|
||||
|
||||
|
||||
276
scripts/verify-story-asset-data.mjs
Normal file
276
scripts/verify-story-asset-data.mjs
Normal file
@@ -0,0 +1,276 @@
|
||||
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, 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 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,
|
||||
usedPortraitKeys,
|
||||
usedCutsceneActorIds,
|
||||
storyBackgroundAssets,
|
||||
storyBackgroundKeysFor,
|
||||
portraitAssetEntriesForKey,
|
||||
portraitKeyForSpeaker,
|
||||
storyCutsceneActorProfileFor,
|
||||
hasUnitSheetAsset
|
||||
});
|
||||
if (page.cutscene) {
|
||||
cutsceneCount += 1;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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} 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, storyBackgroundAssets, 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}"`);
|
||||
}
|
||||
keys.forEach((key) => usedBackgroundKeys.add(key));
|
||||
}
|
||||
|
||||
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`);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
const storyModules = import.meta.glob('../../assets/images/story/*.png', {
|
||||
const storyModules = import.meta.glob(['../../assets/images/story/*.png', '../../assets/images/taoyuan-oath-title.png'], {
|
||||
eager: true,
|
||||
import: 'default'
|
||||
}) as Record<string, string>;
|
||||
@@ -22,7 +22,8 @@ const storyAssetAliases: Record<string, string> = {
|
||||
'story-qishan-renewed': 'story-qishan-renewed-offensive',
|
||||
'story-hanzhong-rain': 'story-hanzhong-rain-defense',
|
||||
'story-nanzhong-captures': 'story-nanzhong-seven-captures',
|
||||
'story-resolve': 'story-liu-bei-resolve'
|
||||
'story-resolve': 'story-liu-bei-resolve',
|
||||
'title-taoyuan': 'story-taoyuan-oath-title'
|
||||
};
|
||||
|
||||
function storyKeyFromPath(path: string) {
|
||||
|
||||
Reference in New Issue
Block a user