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

@@ -19,6 +19,14 @@ const campaignSlotStorageKey = 'heros-web:campaign-state:slot-1';
const campaignSnapshotPath = process.env.QA_CAMPAIGN_SNAPSHOT_PATH;
const resumeCampaignSnapshot = process.env.QA_RESUME_CAMPAIGN_SNAPSHOT === '1';
const baselineViewport = desktopBrowserViewport;
const battleEnvironmentExpectations = new Map([
['ninth-battle-xuzhou-gate-night-raid', { profileId: 'xuzhou-night-mist', ambienceKey: 'river-night-ambience', objectCount: 5, depthRange: [3.04, 3.12] }],
['twenty-second-battle-red-cliffs-fire', { profileId: 'red-cliffs-firestorm', ambienceKey: 'battle-fire', objectCount: 26, depthRange: [3.04, 3.3] }],
['fortieth-battle-han-river-flood', { profileId: 'han-river-flood-rain', ambienceKey: 'battle-rain', objectCount: 34, depthRange: [3.04, 3.3] }],
['forty-sixth-battle-yiling-fire', { profileId: 'yiling-burning-forest', ambienceKey: 'battle-fire', objectCount: 29, depthRange: [3.04, 3.3] }],
['sixty-first-battle-hanzhong-rain-defense', { profileId: 'hanzhong-defense-rain', ambienceKey: 'battle-rain', objectCount: 39, depthRange: [3.04, 3.3] }],
['sixty-sixth-battle-wuzhang-final', { profileId: 'wuzhang-snow-wind', ambienceKey: 'mountain-wind-ambience', objectCount: 35, depthRange: [3.04, 3.3] }]
]);
function withRenderer(url, renderer) {
if (!['canvas', 'webgl'].includes(renderer)) {
@@ -1348,6 +1356,7 @@ async function setupBattle(page, battle) {
{ timeout: 90000 }
);
await startDeploymentIfNeeded(page, battle.id);
await verifyBattleEnvironment(page, battle.id);
await verifySortieDoctrine(page, battle);
await normalizeRepresentativeBattleUnits(page, battle);
}
@@ -1535,6 +1544,7 @@ async function setupCumulativeBattle(page, battle, firstBattle, route) {
{ timeout: 90000 }
);
await startDeploymentIfNeeded(page, battle.id);
await verifyBattleEnvironment(page, battle.id);
await verifySortieDoctrine(page, battle);
await normalizeRepresentativeBattleUnits(page, battle);
return preparation;
@@ -1653,6 +1663,48 @@ async function startDeploymentIfNeeded(page, battleId) {
);
}
async function verifyBattleEnvironment(page, battleId) {
const state = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
const environment = state?.environment;
const expected = battleEnvironmentExpectations.get(battleId);
const actualAmbienceKey = state?.audio?.currentAmbienceKey ?? state?.audio?.pendingAmbienceKey ?? null;
if (!expected) {
if (
environment?.profileId !== null ||
actualAmbienceKey !== null ||
environment?.active !== false ||
environment?.objectCount !== 0 ||
environment?.particleCount !== 0 ||
environment?.hazeCount !== 0
) {
throw new Error(`Unexpected battle environment for ${battleId}: ${JSON.stringify(environment)}`);
}
return;
}
const depthRange = environment?.depthRange;
const depthMatches = depthRange &&
Math.abs(depthRange.minimum - expected.depthRange[0]) < 0.001 &&
Math.abs(depthRange.maximum - expected.depthRange[1]) < 0.001;
if (
environment?.profileId !== expected.profileId ||
environment?.ambienceKey !== expected.ambienceKey ||
actualAmbienceKey !== expected.ambienceKey ||
environment?.active !== true ||
environment?.objectCount !== expected.objectCount ||
environment?.maskedObjectCount !== expected.objectCount ||
environment?.masked !== true ||
environment?.fixedPool !== true ||
environment?.verification?.valid !== true ||
!depthMatches
) {
throw new Error(
`Battle environment mismatch for ${battleId}: ${JSON.stringify({ expected, actualAmbienceKey, environment })}`
);
}
}
async function verifySortieDoctrine(page, battle) {
const state = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
const doctrine = state?.sortieDoctrine;

View File

@@ -11,6 +11,44 @@ const sourceFilesToScan = [
'src/game/scenes/StoryScene.ts',
'src/game/scenes/TitleScene.ts'
];
const requiredBattlefieldAmbienceKeys = ['battle-rain', 'battle-fire'];
const requiredTacticalCueKeys = [
'ally-turn',
'enemy-turn',
'operation-alert',
'objective-success',
'objective-failure'
];
const requiredBattleSceneAudioMethods = [
'playSoundscape',
'playAllyTurnCue',
'playEnemyTurnCue',
'playOperationAlert',
'playObjectiveAchieved',
'playObjectiveFailed'
];
const battlefieldSourceRecords = [
{
projectFile: 'src/assets/audio/ambience/battle-rain.mp3',
source: 'https://pixabay.com/sound-effects/nature-gentle-midday-rain-499668/'
},
{
projectFile: 'src/assets/audio/ambience/battle-fire.mp3',
source: 'https://pixabay.com/sound-effects/film-special-effects-fire-sounds-405444/'
},
...['ally-turn', 'enemy-turn', 'operation-alert'].map((key) => ({
projectFile: `src/assets/audio/sfx/${key}.mp3`,
source: 'https://pixabay.com/sound-effects/musical-ancient-war-drums-463214/'
})),
{
projectFile: 'src/assets/audio/sfx/objective-success.mp3',
source: 'https://pixabay.com/sound-effects/film-special-effects-level-complete-143022/'
},
{
projectFile: 'src/assets/audio/sfx/objective-failure.mp3',
source: 'https://pixabay.com/sound-effects/film-special-effects-failure-1-89170/'
}
];
const server = await createServer({
logLevel: 'error',
@@ -29,6 +67,10 @@ try {
validateTrackMap(errors, 'music', musicTracks, 'bgm');
validateTrackMap(errors, 'ambience', ambienceTracks, 'ambience');
validateTrackMap(errors, 'effect', effectTracks, 'sfx');
validateRequiredTrackKeys(errors, 'battlefield ambience', ambienceTracks, requiredBattlefieldAmbienceKeys);
validateRequiredTrackKeys(errors, 'tactical cue', effectTracks, requiredTacticalCueKeys);
validateBattlefieldSourceDocumentation(errors);
validateBattleSceneAudioIntegration(errors);
validateEffectPools(errors, effectPools, effectTracks);
validateEffectPoolRegistration(errors, effectPools);
validateAudioDirectoryCoverage(errors, musicTracks, ambienceTracks, effectTracks);
@@ -77,6 +119,43 @@ function validateEffectPools(errors, effectPools, effectTracks) {
});
}
function validateRequiredTrackKeys(errors, label, tracks, requiredKeys) {
requiredKeys.forEach((key) => {
if (!(key in tracks)) {
errors.push(`required ${label} track "${key}" is not registered`);
}
});
}
function validateBattlefieldSourceDocumentation(errors) {
const docs = readFileSync(join('docs', 'audio-sources.md'), 'utf8');
if (!docs.includes('2026-07-21')) {
errors.push('docs/audio-sources.md must record the 2026-07-21 battlefield audio download date');
}
if (!docs.includes('https://pixabay.com/service/license-summary/')) {
errors.push('docs/audio-sources.md must link the Pixabay Content License summary');
}
battlefieldSourceRecords.forEach(({ projectFile, source }) => {
if (!docs.includes(projectFile)) {
errors.push(`docs/audio-sources.md is missing battlefield audio file "${projectFile}"`);
}
if (!docs.includes(source)) {
errors.push(`docs/audio-sources.md is missing Pixabay source "${source}"`);
}
});
}
function validateBattleSceneAudioIntegration(errors) {
const path = join('src', 'game', 'scenes', 'BattleScene.ts');
const source = readFileSync(path, 'utf8');
requiredBattleSceneAudioMethods.forEach((method) => {
const pattern = new RegExp(`\\bsoundDirector\\.${method}\\s*\\(`);
if (!pattern.test(source)) {
errors.push(`${path} must call soundDirector.${method}()`);
}
});
}
function validateEffectPoolRegistration(errors, effectPools) {
if (Object.keys(effectPools).length === 0) {
return;

View File

@@ -0,0 +1,168 @@
import { readFileSync } from 'node:fs';
import { createServer } from 'vite';
const expectedProfiles = {
'ninth-battle-xuzhou-gate-night-raid': {
profileId: 'xuzhou-night-mist',
effect: 'night-mist',
ambienceKey: 'river-night-ambience',
particleKind: null,
hazeKind: 'mist'
},
'twenty-second-battle-red-cliffs-fire': {
profileId: 'red-cliffs-firestorm',
effect: 'embers-smoke',
ambienceKey: 'battle-fire',
particleKind: 'ember',
hazeKind: 'smoke'
},
'fortieth-battle-han-river-flood': {
profileId: 'han-river-flood-rain',
effect: 'rain-mist',
ambienceKey: 'battle-rain',
particleKind: 'rain',
hazeKind: 'mist'
},
'forty-sixth-battle-yiling-fire': {
profileId: 'yiling-burning-forest',
effect: 'embers-smoke',
ambienceKey: 'battle-fire',
particleKind: 'ember',
hazeKind: 'smoke'
},
'sixty-first-battle-hanzhong-rain-defense': {
profileId: 'hanzhong-defense-rain',
effect: 'steady-rain',
ambienceKey: 'battle-rain',
particleKind: 'rain',
hazeKind: 'mist'
},
'sixty-sixth-battle-wuzhang-final': {
profileId: 'wuzhang-snow-wind',
effect: 'snow-wind',
ambienceKey: 'mountain-wind-ambience',
particleKind: 'snow',
hazeKind: 'mist'
}
};
const server = await createServer({
logLevel: 'error',
server: { middlewareMode: true, hmr: false },
appType: 'custom'
});
try {
const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts');
const { ambienceTracks } = await server.ssrLoadModule('/src/game/audio/audioAssets.ts');
const environmentModule = await server.ssrLoadModule('/src/game/data/battleEnvironmentProfiles.ts');
const {
battleEnvironmentProfiles,
battleEnvironmentProfileBattleIds,
battleEnvironmentProfileFor,
battleEnvironmentProfileVerification,
validateBattleEnvironmentProfiles
} = environmentModule;
const errors = [...validateBattleEnvironmentProfiles()];
const expectedBattleIds = Object.keys(expectedProfiles).sort();
const actualBattleIds = Object.keys(battleEnvironmentProfiles).sort();
const declaredBattleIds = [...battleEnvironmentProfileBattleIds].sort();
if (JSON.stringify(actualBattleIds) !== JSON.stringify(expectedBattleIds)) {
errors.push(`expected exactly six representative profile keys: ${JSON.stringify(actualBattleIds)}`);
}
if (JSON.stringify(declaredBattleIds) !== JSON.stringify(expectedBattleIds)) {
errors.push(`representative battle id declaration is out of sync: ${JSON.stringify(declaredBattleIds)}`);
}
const profileIds = new Set();
Object.entries(expectedProfiles).forEach(([battleId, expected]) => {
const profile = battleEnvironmentProfiles[battleId];
if (!battleScenarios[battleId]) {
errors.push(`${battleId}: battle scenario does not exist`);
}
if (!profile) {
errors.push(`${battleId}: missing environment profile`);
return;
}
if (battleEnvironmentProfileFor(battleId) !== profile) {
errors.push(`${battleId}: lookup API must return the registered profile object`);
}
if (profile.id !== expected.profileId || profile.effect !== expected.effect) {
errors.push(`${battleId}: expected ${expected.profileId}/${expected.effect}, got ${profile.id}/${profile.effect}`);
}
if (profile.soundscape?.ambienceKey !== expected.ambienceKey) {
errors.push(
`${battleId}: expected ambience ${expected.ambienceKey}, got ${profile.soundscape?.ambienceKey ?? null}`
);
}
if (!ambienceTracks[profile.soundscape?.ambienceKey]) {
errors.push(`${battleId}: ambience track is not registered: ${profile.soundscape?.ambienceKey ?? null}`);
}
if (
!Number.isFinite(profile.soundscape?.ambienceVolume) ||
profile.soundscape.ambienceVolume <= 0 ||
profile.soundscape.ambienceVolume > 0.16
) {
errors.push(`${battleId}: ambience volume must stay within 0..0.16`);
}
if ((profile.particles?.kind ?? null) !== expected.particleKind) {
errors.push(`${battleId}: expected particle kind ${expected.particleKind}, got ${profile.particles?.kind ?? null}`);
}
if ((profile.haze?.kind ?? null) !== expected.hazeKind) {
errors.push(`${battleId}: expected haze kind ${expected.hazeKind}, got ${profile.haze?.kind ?? null}`);
}
if (profileIds.has(profile.id)) {
errors.push(`${battleId}: duplicate profile id ${profile.id}`);
}
profileIds.add(profile.id);
if (!Number.isInteger(profile.particles?.count ?? 0) || (profile.particles?.count ?? 0) > 40) {
errors.push(`${battleId}: particle fixed-pool budget must be an integer at or below 40`);
}
if (!Number.isInteger(profile.haze?.count ?? 0) || (profile.haze?.count ?? 0) > 4) {
errors.push(`${battleId}: haze fixed-pool budget must be an integer at or below 4`);
}
if ((profile.particles?.count ?? 0) + (profile.haze?.count ?? 0) + 1 > 45) {
errors.push(`${battleId}: total environment object budget exceeds 45`);
}
if (profile.particles && (!Array.isArray(profile.particles.colors) || profile.particles.colors.length === 0)) {
errors.push(`${battleId}: particle colors are required`);
}
});
if (
battleEnvironmentProfileVerification?.valid !== true ||
battleEnvironmentProfileVerification?.profileCount !== expectedBattleIds.length ||
battleEnvironmentProfileVerification?.representativeBattleCount !== expectedBattleIds.length ||
battleEnvironmentProfileVerification?.maximumParticleCount > 40 ||
battleEnvironmentProfileVerification?.maximumHazeCount > 4
) {
errors.push(`invalid profile verification summary: ${JSON.stringify(battleEnvironmentProfileVerification)}`);
}
const battleSceneSource = readFileSync('src/game/scenes/BattleScene.ts', 'utf8');
[
'battleEnvironmentProfileFor(battleScenario.id)',
'this.drawBattleEnvironment();',
'this.updateBattleEnvironment(delta);',
'this.clearBattleEnvironment();',
'ambienceKey: profile?.soundscape.ambienceKey',
'environment: this.battleEnvironmentDebugState()'
].forEach((requiredSource) => {
if (!battleSceneSource.includes(requiredSource)) {
errors.push(`BattleScene environment integration is missing: ${requiredSource}`);
}
});
if (errors.length > 0) {
console.error(`Battle environment profile verification failed with ${errors.length} issue(s):`);
errors.forEach((error) => console.error(`- ${error}`));
process.exitCode = 1;
} else {
console.log(
`Verified ${expectedBattleIds.length} battle environment profiles, fixed-pool budgets, lookup API, and BattleScene lifecycle/debug integration.`
);
}
} finally {
await server.close();
}

View File

@@ -97,6 +97,11 @@ try {
'critical-boom': '/audio/critical-boom.ogg',
'victory-fanfare': '/audio/victory-fanfare.ogg',
'defeat-sting': '/audio/defeat-sting.ogg',
'ally-turn': '/audio/ally-turn.ogg',
'enemy-turn': '/audio/enemy-turn.ogg',
'operation-alert': '/audio/operation-alert.ogg',
'objective-success': '/audio/objective-success.ogg',
'objective-failure': '/audio/objective-failure.ogg',
'ui-select': '/audio/ui-select.ogg'
});
director.registerEffectPools({
@@ -366,6 +371,52 @@ try {
assert.equal(debug.ambience.activeElementCount, 0, 'legacy playMusic ambience cleanup should complete');
assert.equal(audioHarness.activeInstances().length, 1, 'the verifier must finish without orphaned loop elements');
const tacticalCueStart = audioHarness.instances.length;
assert.equal(director.playAllyTurnCue(), true, 'the first allied turn cue should play');
assertClose(audioHarness.byOriginalSrc('/audio/ally-turn.ogg').volume, 0.32, 'allied turn cue volume');
assert.equal(director.playAllyTurnCue(), false, 'a repeated allied turn cue should be coalesced');
assert.equal(director.playEnemyTurnCue(), false, 'turn cue cooldown should be shared across factions');
assert.equal(director.playOperationAlert(), true, 'the first operation alert should play');
assertClose(audioHarness.byOriginalSrc('/audio/operation-alert.ogg').volume, 0.34, 'operation alert volume');
assert.equal(director.playOperationAlert(), false, 'operation alerts in one event flush should be coalesced');
assert.equal(director.playObjectiveAchieved(), true, 'the first objective result should play');
assertClose(audioHarness.byOriginalSrc('/audio/objective-success.ogg').volume, 0.34, 'objective success volume');
assert.equal(director.playObjectiveFailed(), false, 'objective result cooldown should suppress re-entry');
debug = director.getDebugState();
assert.deepEqual(
debug.semanticCues.cooldownMs,
{ turn: 900, operation: 650, objective: 1200 },
'semantic cue cooldowns should be visible in debug state'
);
assert.deepEqual(
debug.semanticCues.lastSuppressed,
{ group: 'objective', key: 'objective-failure', at: clock.now, remainingMs: 1200 },
'debug state should explain the most recently coalesced cue'
);
assert.equal(audioHarness.instances.length, tacticalCueStart + 3, 'coalesced cues must not construct Audio');
clock.advance(650);
assert.equal(director.playOperationAlert(), true, 'operation alert should replay after its cooldown');
clock.advance(250);
assert.equal(director.playEnemyTurnCue(), true, 'enemy turn cue should play after the shared turn cooldown');
assertClose(audioHarness.byOriginalSrc('/audio/enemy-turn.ogg').volume, 0.3, 'enemy turn cue volume');
clock.advance(300);
assert.equal(director.playObjectiveFailed(), true, 'objective failure should play after the result cooldown');
assertClose(audioHarness.byOriginalSrc('/audio/objective-failure.ogg').volume, 0.32, 'objective failure volume');
debug = director.getDebugState();
assert.deepEqual(
debug.semanticCues.lastPlayed,
{ group: 'objective', key: 'objective-failure', at: clock.now },
'debug state should expose the latest semantic cue'
);
assert.equal(audioHarness.instances.length, tacticalCueStart + 6, 'expired cooldowns should permit new Audio');
clock.advance(2500);
debug = director.getDebugState();
assert.equal(debug.activeEffectCount, 0, 'tactical cues should retire after their playback windows');
assert.equal(audioHarness.activeInstances().length, 1, 'tactical cue cleanup should leave only music active');
const semanticEffectStart = audioHarness.instances.length;
director.playMeleeSwing(true, false);
assert.equal(director.getDebugState().lastEffect?.key, 'melee-swing', 'melee API should use the swing pool');
@@ -404,7 +455,7 @@ try {
console.log(
`Verified dual SoundDirector channels across ${debug.music.elementRevision} music and ` +
`${debug.ambience.elementRevision} ambience element revisions, no-repeat effect pools, ` +
'semantic combat effects, category multipliers, and deterministic music ducking.'
'coalesced tactical cues, semantic combat effects, category multipliers, and deterministic music ducking.'
);
} finally {
restoreGlobals();

View File

@@ -29,6 +29,7 @@ const checks = [
'scripts/verify-unit-sprite-asset-data.mjs',
'scripts/verify-battle-action-asset-budgets.mjs',
'scripts/verify-battle-map-asset-data.mjs',
'scripts/verify-battle-environment-profiles.mjs',
'scripts/verify-audio-asset-data.mjs',
'scripts/verify-sound-director.mjs'
];

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