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 maxAdjacentBackgroundDuplicateRate = 0.02; const maxAdjacentBackgroundRun = 3; const expectedStoryPageCount = 466; 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, storyBackgroundKeysForPages, storyBackgroundPageHasFixedAssignment } = await server.ssrLoadModule('/src/game/data/storyAssets.ts'); const { portraitAssets, portraitAssetEntryForStoryContext, portraitAssetEntriesForKey, portraitAssetEntriesForStoryContext, portraitTextureKeysForKey, 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(); const adjacencyMetrics = { pairs: 0, duplicatePairs: 0, avoidableDuplicatePairs: 0, maxRun: 0 }; const portraitSelectionMetrics = { pages: 0, dialoguePortraits: 0, cutsceneActorPortraits: 0 }; let pageCount = 0; let cutsceneCount = 0; validatePortraitVersionSelection({ errors, portraitAssets, portraitAssetEntryForStoryContext, portraitAssetEntriesForKey, portraitAssetEntriesForStoryContext, portraitTextureKeysForKey }); storySources.forEach(({ source, pages }) => { if (!Array.isArray(pages) || pages.length === 0) { errors.push(`${source}: story page list must not be empty`); return; } const selectedKeys = storyBackgroundKeysForPages(pages); const repeatedSelection = storyBackgroundKeysForPages(pages); if (selectedKeys.some((key, index) => key !== repeatedSelection[index])) { errors.push(`${source}: story background sequence selection must be deterministic`); } validateStoryBackgroundSequence({ errors, source, pages, selectedKeys, storyBackgroundAssets, storyBackgroundKeysFor, storyBackgroundPageHasFixedAssignment, metrics: adjacencyMetrics }); pages.forEach((page, index) => { pageCount += 1; uniquePageIds.add(page.id); validateStoryPage({ errors, source, index, page, selectedKey: selectedKeys[index], usedBackgroundKeys, selectedBackgroundKeys, selectedBackgroundUrlsByBase, usedPortraitKeys, usedCutsceneActorIds, storyBackgroundAssets, storyBackgroundKeysFor, portraitAssetEntryForStoryContext, portraitAssetEntriesForKey, portraitAssetEntriesForStoryContext, portraitKeyForSpeaker, storyCutsceneActorProfileFor, hasUnitSheetAsset, portraitSelectionMetrics }); if (page.cutscene) { cutsceneCount += 1; } }); }); validateStoryBackgroundVariantCoverage(errors, selectedBackgroundKeys); validateRepeatedStoryBackgroundDistribution(errors, selectedBackgroundUrlsByBase); validateStoryBackgroundAdjacencyMetrics(errors, adjacencyMetrics); if (pageCount !== expectedStoryPageCount) { errors.push(`story page selection coverage: expected ${expectedStoryPageCount} pages, received ${pageCount}`); } if (portraitSelectionMetrics.pages !== pageCount) { errors.push( `story portrait selection coverage: checked ${portraitSelectionMetrics.pages}/${pageCount} pages` ); } 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 { const adjacentDuplicateRate = adjacencyMetrics.pairs > 0 ? adjacencyMetrics.duplicatePairs / adjacencyMetrics.pairs : 0; 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, ${portraitSelectionMetrics.dialoguePortraits} dialogue portrait selections, ` + `${portraitSelectionMetrics.cutsceneActorPortraits} cutscene actor portrait selections, and ` + `${usedCutsceneActorIds.size} cutscene actor profiles; ` + `${adjacencyMetrics.duplicatePairs}/${adjacencyMetrics.pairs} adjacent background repeats ` + `(${(adjacentDuplicateRate * 100).toFixed(1)}%), max run ${adjacencyMetrics.maxRun}.` ); } } finally { await server.close(); } function validatePortraitVersionSelection(context) { const { errors, portraitAssets, portraitAssetEntryForStoryContext, portraitAssetEntriesForKey, portraitAssetEntriesForStoryContext, portraitTextureKeysForKey } = context; const zhugeLiangTextureKeys = portraitTextureKeysForKey('zhugeLiang'); const requiredZhugeLiangTextureKeys = [ 'portrait-zhuge-liang', 'portrait-zhuge-liang-campaign-v2', 'portrait-zhuge-liang-northern-v2', 'portrait-zhuge-liang-redcliffs-v2' ]; const supersededZhugeLiangTextureKeys = [ 'portrait-zhuge-liang-campaign', 'portrait-zhuge-liang-northern', 'portrait-zhuge-liang-redcliffs' ]; requiredZhugeLiangTextureKeys.forEach((textureKey) => { if (!zhugeLiangTextureKeys.includes(textureKey)) { errors.push(`portrait version selection: missing current Zhuge Liang portrait "${textureKey}"`); } }); supersededZhugeLiangTextureKeys.forEach((textureKey) => { if (zhugeLiangTextureKeys.includes(textureKey)) { errors.push(`portrait version selection: superseded Zhuge Liang portrait remains selectable "${textureKey}"`); } }); const zhugeLiangStoryCases = [ { context: { id: 'seventeenth-zhuge-liang-counsel', background: 'story-wolong' }, expected: 'portrait-zhuge-liang' }, { context: { id: 'eighteenth-red-cliffs-wind', background: 'story-wolong' }, expected: 'portrait-zhuge-liang-redcliffs-v2' }, { context: { id: 'twenty-first-zhuge-wind', background: 'story-red-cliffs' }, expected: 'portrait-zhuge-liang-redcliffs-v2' }, { context: { id: 'fiftieth-third-capture-plan', background: 'story-nanzhong-captures' }, expected: 'portrait-zhuge-liang-campaign-v2' }, { context: { id: 'thirty-fifth-hanzhong-front', background: 'story-northern' }, expected: 'portrait-zhuge-liang-campaign-v2' }, { context: { id: 'northern-prep-back-secured', background: 'story-northern' }, expected: 'portrait-zhuge-liang-northern-v2' }, { context: { id: 'fifty-fifth-qishan-road-opens', background: 'story-northern' }, expected: 'portrait-zhuge-liang-northern-v2' }, { context: { id: 'sixtieth-wudu-yinping-order', background: 'story-wudu-yinping' }, expected: 'portrait-zhuge-liang-northern-v2' } ]; zhugeLiangStoryCases.forEach(({ context: storyContext, expected }) => { const selected = portraitAssetEntriesForStoryContext('zhugeLiang', storyContext).map( ({ textureKey }) => textureKey ); if (selected.length !== 1 || selected[0] !== expected) { errors.push( `portrait story context: ${storyContext.id} should select "${expected}", received [${selected.join(', ')}]` ); } }); const contextualPortraitRouteCases = [ { key: 'liuBei', id: 'oath-liu-bei', background: 'title-taoyuan', expected: 'portrait-liu-bei-yellow-turban' }, { key: 'liuBei', id: 'seventh-liu-bei-xuzhou', background: 'story-xuzhou', expected: 'portrait-liu-bei' }, { key: 'liuBei', id: 'seventeenth-liu-bei-third-visit', background: 'story-wolong', expected: 'portrait-liu-bei-campaign' }, { key: 'liuBei', id: 'baidi-entrustment-liu-bei', background: 'story-baidi-entrustment', expected: 'portrait-liu-bei-shuhan' }, { key: 'guanYu', id: 'oath-brothers', background: 'title-taoyuan', expected: 'portrait-guan-yu-yellow-turban' }, { key: 'guanYu', id: 'seventh-guan-yu-field', background: 'story-xuzhou', expected: 'portrait-guan-yu' }, { key: 'guanYu', id: 'eighteenth-generals-doubt', background: 'story-wolong', expected: 'portrait-guan-yu-campaign' }, { key: 'guanYu', id: 'forty-fourth-guan-yu-resolve', background: 'story-maicheng-isolation', expected: 'portrait-guan-yu-jingzhou' }, { key: 'zhangFei', id: 'zhang-fei-joins', background: 'story-three-heroes', expected: 'portrait-zhang-fei-yellow-turban' }, { key: 'zhangFei', id: 'seventh-zhang-fei-charge', background: 'story-xuzhou', expected: 'portrait-zhang-fei' }, { key: 'zhangFei', id: 'nineteenth-zhang-fei-bridge', background: 'story-red-cliffs', expected: 'portrait-zhang-fei-campaign' }, { key: 'zhangFei', id: 'thirty-fifth-hanzhong-front', background: 'story-northern', expected: 'portrait-zhang-fei-ba-command' }, { key: 'zhaoYun', id: 'sixteenth-zhao-yun-returns', background: 'story-wolong', expected: 'portrait-zhao-yun' }, { key: 'zhaoYun', id: 'nineteenth-zhao-yun-rescue', background: 'story-red-cliffs', expected: 'portrait-zhao-yun-changban' }, { key: 'zhaoYun', id: 'twenty-third-jingzhou-cause', background: 'story-yizhou', expected: 'portrait-zhao-yun-campaign' }, { key: 'zhaoYun', id: 'northern-prep-roster-council', background: 'story-northern', expected: 'portrait-zhao-yun-veteran' }, { key: 'jiangWei', id: 'fifty-seventh-victory-jiang-wei-joins', background: 'story-jieting-crisis', expected: 'portrait-jiang-wei-tianshui' }, { key: 'jiangWei', id: 'fifty-eighth-jiang-wei-first-sortie', background: 'story-jieting-crisis', expected: 'portrait-jiang-wei-tianshui' }, { key: 'jiangWei', id: 'sixty-sixth-inherited-ledger', background: 'story-northern', expected: 'portrait-jiang-wei-campaign' }, { key: 'cao-cao', id: 'fifth-liu-bei-choice', background: 'story-anti-dong', expected: 'portrait-cao-cao' }, { key: 'cao-cao', id: 'eleventh-arrive-cao-cao-camp', background: 'story-wandering', expected: 'portrait-cao-cao-campaign' }, { key: 'cao-cao', id: 'twenty-second-cao-cao-retreat', background: 'story-red-cliffs', expected: 'portrait-cao-cao-redcliffs' }, { key: 'simaYi', id: 'fourteenth-secret-edict-shadow', background: 'story-wandering', expected: 'portrait-sima-yi' }, { key: 'simaYi', id: 'fifty-fourth-victory-meng-huo-yields', background: 'story-nanzhong-captures', expected: 'portrait-sima-yi-campaign' }, { key: 'simaYi', id: 'sixty-second-sima-yi-delay-line', background: 'story-qishan-renewed', expected: 'portrait-sima-yi-qishan' }, { key: 'meng-huo', id: 'forty-seventh-meng-huo-shadow', background: 'story-nanzhong', expected: 'portrait-meng-huo' }, { key: 'meng-huo', id: 'forty-ninth-meng-huo-returns', background: 'story-nanzhong-captures', expected: 'portrait-meng-huo-campaign' }, { key: 'meng-huo', id: 'fifty-fourth-victory-meng-huo-yields', background: 'story-nanzhong-captures', expected: 'portrait-meng-huo-final' } ]; ['liuBei', 'guanYu', 'zhangFei', 'zhaoYun', 'jiangWei', 'cao-cao', 'simaYi', 'meng-huo'].forEach((key) => { const routeCaseCount = contextualPortraitRouteCases.filter((testCase) => testCase.key === key).length; if (routeCaseCount < 3) { errors.push(`portrait era route coverage: ${key} requires early/middle/late assertions, received ${routeCaseCount}`); } }); contextualPortraitRouteCases.forEach(({ key, id, background, expected }) => { const storyContext = { id, background }; const candidates = portraitAssetEntriesForStoryContext(key, storyContext); const selected = portraitAssetEntryForStoryContext(key, storyContext); if (candidates.length !== 1 || candidates[0].textureKey !== expected) { errors.push( `portrait era route: ${key}/${id} should expose only "${expected}", received ` + `[${candidates.map(({ textureKey }) => textureKey).join(', ')}]` ); } if (selected?.textureKey !== expected) { errors.push( `portrait era selection: ${key}/${id} should select "${expected}", received "${selected?.textureKey ?? 'none'}"` ); } }); const zhugeLiangBaseRevisionFixture = 'zhuge-liang-v2'; const previousBaseRevision = portraitAssets[zhugeLiangBaseRevisionFixture]; portraitAssets[zhugeLiangBaseRevisionFixture] = `virtual:${zhugeLiangBaseRevisionFixture}`; try { const selected = portraitAssetEntriesForStoryContext('zhugeLiang', { id: 'seventeenth-zhuge-liang-counsel', background: 'story-wolong' }).map(({ textureKey }) => textureKey); if (selected.length !== 1 || selected[0] !== 'portrait-zhuge-liang-v2') { errors.push( `portrait story context: a revised base portrait should remain in the base route, received [${selected.join(', ')}]` ); } } finally { if (previousBaseRevision === undefined) { delete portraitAssets[zhugeLiangBaseRevisionFixture]; } else { portraitAssets[zhugeLiangBaseRevisionFixture] = previousBaseRevision; } } const fixtureSlug = 'portrait-version-fixture'; const fixtureSlugs = [ fixtureSlug, `${fixtureSlug}-campaign`, `${fixtureSlug}-campaign-v2`, `${fixtureSlug}-campaign-v10`, `${fixtureSlug}-northern`, `${fixtureSlug}-redcliffs-v2`, `${fixtureSlug}-redcliffs-v3`, `${fixtureSlug}-vanguard`, `${fixtureSlug}-veteran` ]; const expectedFixtureTextureKeys = [ `portrait-${fixtureSlug}`, `portrait-${fixtureSlug}-campaign-v10`, `portrait-${fixtureSlug}-northern`, `portrait-${fixtureSlug}-redcliffs-v3`, `portrait-${fixtureSlug}-vanguard`, `portrait-${fixtureSlug}-veteran` ]; fixtureSlugs.forEach((slug) => { portraitAssets[slug] = `virtual:${slug}`; }); try { const entryTextureKeys = portraitAssetEntriesForKey(fixtureSlug).map((entry) => entry.textureKey); const textureKeys = portraitTextureKeysForKey(fixtureSlug); if (entryTextureKeys.join('\n') !== expectedFixtureTextureKeys.join('\n')) { errors.push( `portrait version selection: entries should keep independent variants and only the highest numeric -vN suffix; ` + `received [${entryTextureKeys.join(', ')}]` ); } if (textureKeys.join('\n') !== expectedFixtureTextureKeys.join('\n')) { errors.push( `portrait version selection: texture keys should keep independent variants and only the highest numeric -vN suffix; ` + `received [${textureKeys.join(', ')}]` ); } } finally { fixtureSlugs.forEach((slug) => { delete portraitAssets[slug]; }); } } 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, selectedKey, usedBackgroundKeys, selectedBackgroundKeys, selectedBackgroundUrlsByBase, 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}"`); return; } keys.forEach((key) => usedBackgroundKeys.add(key)); 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 validateStoryBackgroundSequence(context) { const { errors, source, pages, selectedKeys, storyBackgroundAssets, storyBackgroundKeysFor, storyBackgroundPageHasFixedAssignment, metrics } = context; let previousUrl; let previousPage; let previousKey; let currentRun = 0; pages.forEach((page, index) => { const selectedKey = selectedKeys[index]; const selectedUrl = storyBackgroundAssets[selectedKey] ?? selectedKey; if (index > 0) { metrics.pairs += 1; } if (previousUrl !== undefined && selectedUrl === previousUrl) { metrics.duplicatePairs += 1; currentRun += 1; const previousUrls = selectableStoryBackgroundUrls( previousPage, previousKey, storyBackgroundAssets, storyBackgroundKeysFor, storyBackgroundPageHasFixedAssignment ); const currentUrls = selectableStoryBackgroundUrls( page, selectedKey, storyBackgroundAssets, storyBackgroundKeysFor, storyBackgroundPageHasFixedAssignment ); const hasDistinctPair = [...previousUrls].some((leftUrl) => [...currentUrls].some((rightUrl) => leftUrl !== rightUrl) ); if (hasDistinctPair) { metrics.avoidableDuplicatePairs += 1; errors.push(`${source}[${index}]/${page.id}: avoidable adjacent story background repeat`); } } else { currentRun = 1; } metrics.maxRun = Math.max(metrics.maxRun, currentRun); previousUrl = selectedUrl; previousPage = page; previousKey = selectedKey; }); } function selectableStoryBackgroundUrls( page, selectedKey, storyBackgroundAssets, storyBackgroundKeysFor, storyBackgroundPageHasFixedAssignment ) { const selectedUrl = storyBackgroundAssets[selectedKey] ?? selectedKey; if (!page || storyBackgroundPageHasFixedAssignment(page.id)) { return new Set([selectedUrl]); } return new Set( storyBackgroundKeysFor(page.background) .map((key) => storyBackgroundAssets[key] ?? key) ); } function validateStoryBackgroundAdjacencyMetrics(errors, metrics) { const duplicateRate = metrics.pairs > 0 ? metrics.duplicatePairs / metrics.pairs : 0; if (metrics.avoidableDuplicatePairs > 0) { errors.push(`${metrics.avoidableDuplicatePairs} avoidable adjacent story background repeat(s) remain`); } if (duplicateRate > maxAdjacentBackgroundDuplicateRate) { errors.push( `adjacent story background duplicate rate ${(duplicateRate * 100).toFixed(1)}% exceeds ` + `${(maxAdjacentBackgroundDuplicateRate * 100).toFixed(1)}%` ); } if (metrics.maxRun > maxAdjacentBackgroundRun) { errors.push(`story background max run ${metrics.maxRun} exceeds ${maxAdjacentBackgroundRun}`); } } 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, portraitAssetEntryForStoryContext, portraitAssetEntriesForStoryContext, portraitKeyForSpeaker, portraitSelectionMetrics } = context; portraitSelectionMetrics.pages += 1; const portraitKey = page.portrait ?? portraitKeyForSpeaker(page.speaker); if (!portraitKey) { return; } const entries = portraitAssetEntriesForStoryContext(portraitKey, page); if (entries.length === 0) { errors.push(`${pageContext}: missing portrait asset "${portraitKey}"`); return; } const selectedEntry = portraitAssetEntryForStoryContext(portraitKey, page); if (!selectedEntry) { errors.push(`${pageContext}: story portrait selector returned no entry for "${portraitKey}"`); } else if (!entries.some(({ textureKey, url }) => textureKey === selectedEntry.textureKey && url === selectedEntry.url )) { errors.push( `${pageContext}: selected portrait "${selectedEntry.textureKey}" is not a contextual candidate for "${portraitKey}"` ); } else { portraitSelectionMetrics.dialoguePortraits += 1; } 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, portraitAssetEntryForStoryContext, portraitAssetEntriesForKey, portraitAssetEntriesForStoryContext, portraitSelectionMetrics, page, 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`); const allEntries = portraitAssetEntriesForKey(profile.portraitKey); if (allEntries.length === 0) { errors.push(`${actorContext}: missing actor portrait "${profile.portraitKey}"`); return; } const contextualEntries = portraitAssetEntriesForStoryContext(profile.portraitKey, page); const selectedEntry = portraitAssetEntryForStoryContext( profile.portraitKey, page, `${page.id}:${actor.unitId}:${actorIndex}` ); if (!selectedEntry) { errors.push(`${actorContext}: contextual actor portrait selector returned no entry`); } else if (!contextualEntries.some(({ textureKey, url }) => textureKey === selectedEntry.textureKey && url === selectedEntry.url )) { errors.push(`${actorContext}: selected actor portrait "${selectedEntry.textureKey}" is not a contextual candidate`); } else { portraitSelectionMetrics.cutsceneActorPortraits += 1; } 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`); } }