feat: deepen battle atmosphere and portrait eras

This commit is contained in:
2026-07-21 23:32:34 +09:00
parent f39de4f47d
commit 3a41f8b671
20 changed files with 1471 additions and 58 deletions

View File

@@ -9,6 +9,7 @@ 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',
@@ -27,6 +28,7 @@ try {
} = await server.ssrLoadModule('/src/game/data/storyAssets.ts');
const {
portraitAssets,
portraitAssetEntryForStoryContext,
portraitAssetEntriesForKey,
portraitAssetEntriesForStoryContext,
portraitTextureKeysForKey,
@@ -49,12 +51,18 @@ try {
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
@@ -99,11 +107,13 @@ try {
usedCutsceneActorIds,
storyBackgroundAssets,
storyBackgroundKeysFor,
portraitAssetEntryForStoryContext,
portraitAssetEntriesForKey,
portraitAssetEntriesForStoryContext,
portraitKeyForSpeaker,
storyCutsceneActorProfileFor,
hasUnitSheetAsset
hasUnitSheetAsset,
portraitSelectionMetrics
});
if (page.cutscene) {
cutsceneCount += 1;
@@ -114,6 +124,14 @@ try {
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):`);
@@ -130,7 +148,9 @@ try {
`Verified ${pageCount} story page references (${uniquePageIds.size} unique pages), ` +
`${cutsceneCount} cutscenes, ${usedBackgroundKeys.size} background candidates, ` +
`${selectedBackgroundKeys.size} selected backgrounds, ` +
`${usedPortraitKeys.size} portrait keys, and ${usedCutsceneActorIds.size} cutscene actor profiles; ` +
`${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}.`
);
@@ -143,6 +163,7 @@ function validatePortraitVersionSelection(context) {
const {
errors,
portraitAssets,
portraitAssetEntryForStoryContext,
portraitAssetEntriesForKey,
portraitAssetEntriesForStoryContext,
portraitTextureKeysForKey
@@ -217,6 +238,61 @@ function validatePortraitVersionSelection(context) {
}
});
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}`;
@@ -511,7 +587,16 @@ function validateRepeatedStoryBackgroundDistribution(errors, selectedBackgroundU
}
function validatePortrait(context, pageContext) {
const { errors, page, usedPortraitKeys, portraitAssetEntriesForStoryContext, portraitKeyForSpeaker } = context;
const {
errors,
page,
usedPortraitKeys,
portraitAssetEntryForStoryContext,
portraitAssetEntriesForStoryContext,
portraitKeyForSpeaker,
portraitSelectionMetrics
} = context;
portraitSelectionMetrics.pages += 1;
const portraitKey = page.portrait ?? portraitKeyForSpeaker(page.speaker);
if (!portraitKey) {
return;
@@ -520,6 +605,20 @@ function validatePortrait(context, pageContext) {
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);
}
@@ -551,7 +650,11 @@ function validateCutsceneActor(context, pageContext, actor, actorIndex) {
usedCutsceneActorIds,
usedPortraitKeys,
storyCutsceneActorProfileFor,
portraitAssetEntryForStoryContext,
portraitAssetEntriesForKey,
portraitAssetEntriesForStoryContext,
portraitSelectionMetrics,
page,
hasUnitSheetAsset
} = context;
const actorContext = `${pageContext}/actor[${actorIndex}]`;
@@ -589,8 +692,25 @@ function validateCutsceneActor(context, pageContext, actor, actorIndex) {
if (!portraitlessActorIds.has(actor.unitId)) {
assertNonEmptyString(errors, profile.portraitKey, `${actorContext}: actor profile portrait key`);
if (portraitAssetEntriesForKey(profile.portraitKey).length === 0) {
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);
}