diff --git a/package.json b/package.json index 4c8b6c8..7c858c5 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/verify-release-candidate.mjs b/scripts/verify-release-candidate.mjs index 865ea40..d8e8833 100644 --- a/scripts/verify-release-candidate.mjs +++ b/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); diff --git a/scripts/verify-story-asset-data.mjs b/scripts/verify-story-asset-data.mjs new file mode 100644 index 0000000..fedd310 --- /dev/null +++ b/scripts/verify-story-asset-data.mjs @@ -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`); + } +} diff --git a/src/game/data/storyAssets.ts b/src/game/data/storyAssets.ts index 414d9df..599be1d 100644 --- a/src/game/data/storyAssets.ts +++ b/src/game/data/storyAssets.ts @@ -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; @@ -22,7 +22,8 @@ const storyAssetAliases: Record = { '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) {