feat: deepen battle atmosphere and portrait eras
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
168
scripts/verify-battle-environment-profiles.mjs
Normal file
168
scripts/verify-battle-environment-profiles.mjs
Normal 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();
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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'
|
||||
];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user