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`); } }