764 lines
29 KiB
JavaScript
764 lines
29 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 maxAdjacentBackgroundDuplicateRate = 0.02;
|
|
const maxAdjacentBackgroundRun = 3;
|
|
const expectedStoryPageCount = 468;
|
|
|
|
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 = 135;
|
|
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`);
|
|
}
|
|
}
|