Files
heros_web/scripts/verify-campaign-presentation-profiles.mjs

423 lines
19 KiB
JavaScript

import { readFileSync } from 'node:fs';
import { createServer } from 'vite';
const expectedArcRanges = {
'yellow-turban': [1, 4],
'anti-dong': [5, 6],
xuzhou: [7, 9],
wandering: [10, 15],
wolong: [16, 18],
'red-cliffs': [19, 22],
'jing-yi': [23, 34],
'hanzhong-shuhan': [35, 37],
'jingzhou-crisis': [38, 44],
'yiling-baidi': [45, 46],
nanzhong: [47, 54],
northern: [55, 66]
};
const expectedBattleMusicKeys = {
'yellow-turban': 'militia-theme',
'anti-dong': 'battle-prep',
xuzhou: 'story-dark',
wandering: 'story-dark',
wolong: 'battle-prep',
'red-cliffs': 'camp-grand-strategy',
'jing-yi': 'camp-grand-strategy',
'hanzhong-shuhan': 'battle-prep',
'jingzhou-crisis': 'story-dark',
'yiling-baidi': 'story-dark',
nanzhong: 'camp-frontier',
northern: 'camp-frontier'
};
const expectedStages = ['camp', 'story', 'sortie', 'battle', 'aftermath'];
const expectedIntensities = new Set(['quiet', 'measured', 'urgent', 'climactic']);
const errors = [];
const server = await createServer({
logLevel: 'error',
server: { middlewareMode: true, hmr: false },
appType: 'custom'
});
try {
const presentationModule = await server.ssrLoadModule(
'/src/game/data/campaignPresentationProfiles.ts'
);
const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts');
const { campaignBattleRouteEntries } = await server.ssrLoadModule(
'/src/game/state/campaignRouting.ts'
);
const { ambienceTrackGainCompensation, musicTracks, ambienceTracks } = await server.ssrLoadModule(
'/src/game/audio/audioAssets.ts'
);
const { campSkinDefinitions, campSkinBattleArcs } = await server.ssrLoadModule(
'/src/game/data/campSkins.ts'
);
const { getCampSoundscape } = await server.ssrLoadModule(
'/src/game/data/campSoundscapes.ts'
);
const { battleEnvironmentProfiles } = await server.ssrLoadModule(
'/src/game/data/battleEnvironmentProfiles.ts'
);
const {
campaignArcPresentationProfiles,
campaignBattlePresentationProfiles,
campaignPresentationStatePolicies,
campaignPresentationTransitionPolicy,
campaignBattleAmbienceFallbackVolume,
campaignPresentationArcForOrdinal,
campaignPresentationProfileFor,
campaignPresentationStateRuleFor,
resolveCampaignPresentationSoundscape
} = presentationModule;
const scenarioBattleIds = Object.keys(battleScenarios);
const battleIds = campaignBattleRouteEntries().map((entry) => entry.battleId);
const arcEntries = Object.entries(campaignArcPresentationProfiles);
const arcIds = Object.keys(expectedArcRanges);
const battleMusicKeys = new Set();
let battleAmbienceFallbackCount = 0;
assertEqual(battleIds.length, 66, 'campaign battle count');
assertEqual(new Set(battleIds).size, 66, 'canonical campaign route uniqueness');
assertEqual(
JSON.stringify(scenarioBattleIds),
JSON.stringify(battleIds),
'battle scenario declaration order must match the independent campaign route'
);
assertEqual(arcEntries.length, 12, 'campaign presentation arc count');
assertEqual(Object.keys(campaignBattlePresentationProfiles).length, 66, 'battle profile count');
assertEqual(Object.keys(campaignPresentationStatePolicies).length, 7, 'state policy count');
arcEntries.forEach(([arcId, profile], index) => {
const expectedRange = expectedArcRanges[arcId];
assert(Boolean(expectedRange), `${arcId}: unexpected presentation arc`);
if (!expectedRange) {
return;
}
const [expectedFirst, expectedLast] = expectedRange;
const campArc = campSkinBattleArcs.find(({ skinId }) => skinId === arcId);
const skin = campSkinDefinitions[arcId];
const campSoundscape = getCampSoundscape(arcId);
const statePolicy = campaignPresentationStatePolicies[profile.statePolicyId];
assertEqual(profile.id, arcId, `${arcId}: profile id`);
assertEqual(profile.skinId, arcId, `${arcId}: skin id`);
assertNonEmptyString(profile.label, `${arcId}: label`);
assertEqual(profile.firstBattleOrdinal, expectedFirst, `${arcId}: first battle`);
assertEqual(profile.lastBattleOrdinal, expectedLast, `${arcId}: last battle`);
assertEqual(campArc?.firstBattleOrdinal, expectedFirst, `${arcId}: camp arc first battle`);
assertEqual(campArc?.lastBattleOrdinal, expectedLast, `${arcId}: camp arc last battle`);
assertEqual(expectedFirst, index === 0 ? 1 : arcEntries[index - 1][1].lastBattleOrdinal + 1, `${arcId}: contiguous start`);
assert(Boolean(skin), `${arcId}: matching camp skin`);
assertEqual(profile.palette.accentColor, skin?.accentColor, `${arcId}: accent color`);
assertEqual(profile.palette.headerColor, skin?.headerColor, `${arcId}: header color`);
assertEqual(profile.palette.washColor, skin?.washColor, `${arcId}: wash color`);
assert(
Number.isFinite(profile.palette.battleTintColor) &&
profile.palette.battleTintColor >= 0 &&
profile.palette.battleTintColor <= 0xffffff,
`${arcId}: battle tint color must be a 24-bit color`
);
assert(
Number.isFinite(profile.palette.battleTintAlpha) &&
profile.palette.battleTintAlpha >= 0 &&
profile.palette.battleTintAlpha <= 0.08,
`${arcId}: battle tint alpha must stay within the 0..0.08 legibility budget`
);
assertEqual(profile.audio.familyId, campSoundscape.music.id, `${arcId}: music family`);
assertEqual(profile.audio.anchorMusicKey, campSoundscape.music.trackKey, `${arcId}: anchor music`);
assertEqual(profile.audio.anchorMusicVolume, campSoundscape.music.volume, `${arcId}: anchor volume`);
assertEqual(profile.audio.defaultAmbienceKey, campSoundscape.ambience.trackKey, `${arcId}: ambience`);
assertEqual(profile.audio.defaultAmbienceVolume, campSoundscape.ambience.volume, `${arcId}: ambience volume`);
assertEqual(profile.audio.battleMusicKey, expectedBattleMusicKeys[arcId], `${arcId}: battle music`);
battleMusicKeys.add(profile.audio.battleMusicKey);
[
profile.audio.anchorMusicKey,
profile.audio.tensionMusicKey,
profile.audio.battleMusicKey,
profile.audio.resolutionMusicKey
].forEach((trackKey) => {
assert(Boolean(musicTracks[trackKey]), `${arcId}: unregistered music track "${trackKey}"`);
});
assert(
Boolean(ambienceTracks[profile.audio.defaultAmbienceKey]),
`${arcId}: unregistered ambience track "${profile.audio.defaultAmbienceKey}"`
);
assert(Array.isArray(profile.motifs) && profile.motifs.length >= 3, `${arcId}: at least three motifs`);
assertEqual(new Set(profile.motifs).size, profile.motifs.length, `${arcId}: unique motifs`);
profile.motifs.forEach((motif) => assertNonEmptyString(motif, `${arcId}: motif`));
assert(Boolean(statePolicy), `${arcId}: known state policy "${profile.statePolicyId}"`);
expectedStages.forEach((stage) => {
const rule = statePolicy?.[stage];
assert(Boolean(rule), `${arcId}/${stage}: state rule`);
assert(expectedIntensities.has(rule?.intensity), `${arcId}/${stage}: valid intensity`);
assert(
campaignPresentationStateRuleFor(battleIds[expectedFirst - 1], stage) === rule,
`${arcId}/${stage}: state rule lookup`
);
});
});
arcIds.forEach((arcId) => {
assert(Boolean(campaignArcPresentationProfiles[arcId]), `${arcId}: missing presentation arc`);
});
assertEqual(arcEntries.at(-1)?.[1].lastBattleOrdinal, 66, 'final arc coverage');
assert(
battleMusicKeys.size >= 4,
`battle music must span at least four existing tracks; received ${[...battleMusicKeys].join(', ')}`
);
battleIds.forEach((battleId, index) => {
const battleOrdinal = index + 1;
const profile = campaignBattlePresentationProfiles[battleId];
const expectedArcId = arcEntries.find(
([, arc]) => battleOrdinal >= arc.firstBattleOrdinal && battleOrdinal <= arc.lastBattleOrdinal
)?.[0];
assert(Boolean(profile), `${battleId}: presentation profile`);
assertEqual(profile?.battleId, battleId, `${battleId}: battle id`);
assertEqual(profile?.battleOrdinal, battleOrdinal, `${battleId}: battle ordinal`);
assertEqual(profile?.arc.id, expectedArcId, `${battleId}: arc id`);
assert(campaignPresentationProfileFor(battleId) === profile, `${battleId}: profile lookup identity`);
assert(campaignPresentationArcForOrdinal(battleOrdinal) === profile?.arc, `${battleId}: ordinal lookup identity`);
const campPlan = resolveCampaignPresentationSoundscape({ battleId, stage: 'camp' });
assertEqual(campPlan?.musicKey, profile?.arc.audio.anchorMusicKey, `${battleId}: camp music`);
assertEqual(campPlan?.ambienceKey, profile?.arc.audio.defaultAmbienceKey, `${battleId}: camp ambience`);
const battlePlan = resolveCampaignPresentationSoundscape({ battleId, stage: 'battle' });
const environment = battleEnvironmentProfiles[battleId];
const expectedAmbienceKey = environment?.soundscape.ambienceKey ?? profile?.arc.audio.defaultAmbienceKey;
const expectedAmbienceVolume = environment?.soundscape.ambienceVolume ??
campaignBattleAmbienceFallbackVolume(
profile?.arc.audio.defaultAmbienceVolume,
profile?.arc.audio.defaultAmbienceKey,
profile?.arc.audio.battleMusicKey,
profile?.arc.audio.anchorMusicVolume
);
assertEqual(battlePlan?.musicKey, profile?.arc.audio.battleMusicKey, `${battleId}: battle music plan`);
assertEqual(battlePlan?.ambienceKey, expectedAmbienceKey, `${battleId}: battle ambience plan`);
assertEqual(battlePlan?.ambienceVolume, expectedAmbienceVolume, `${battleId}: battle ambience volume`);
assert(Boolean(battlePlan?.ambienceKey), `${battleId}: every battle must retain a quiet ambience bed`);
if (!environment) {
battleAmbienceFallbackCount += 1;
assert(
battlePlan.ambienceVolume >= campaignPresentationTransitionPolicy.battleAmbienceFallbackMinimum &&
battlePlan.ambienceVolume <= campaignPresentationTransitionPolicy.battleAmbienceFallbackMaximum,
`${battleId}: fallback ambience must stay inside the quiet battle mix`
);
}
assert(battlePlan?.transition === campaignPresentationTransitionPolicy, `${battleId}: transition policy identity`);
});
assertEqual(
battleAmbienceFallbackCount,
battleIds.length - Object.keys(battleEnvironmentProfiles).length,
'battle ambience fallback count'
);
[
[battleIds[0], 'campfire-ambience', 0.166],
[battleIds[16], 'mountain-wind-ambience', 0.153],
[battleIds[32], 'river-night-ambience', 0.093],
[battleIds[53], 'nanzhong-night-ambience', 0.084]
].forEach(([battleId, ambienceKey, expectedVolume]) => {
const plan = resolveCampaignPresentationSoundscape({ battleId, stage: 'battle' });
assertEqual(plan?.ambienceKey, ambienceKey, `${battleId}: calibrated fallback track`);
assertEqual(plan?.ambienceVolume, expectedVolume, `${battleId}: calibrated fallback target volume`);
});
const quietStoryCampfirePlan = resolveCampaignPresentationSoundscape({ battleId: battleIds[9], stage: 'battle' });
const quietFrontierMountainPlan = resolveCampaignPresentationSoundscape({ battleId: battleIds[54], stage: 'battle' });
assert(
quietStoryCampfirePlan?.musicKey === 'story-dark' &&
quietStoryCampfirePlan.ambienceKey === 'campfire-ambience' &&
quietStoryCampfirePlan.ambienceVolume <= 0.085,
`${quietStoryCampfirePlan?.battleId}: quiet story-dark pairing must attenuate campfire fallback`
);
assert(
quietFrontierMountainPlan?.musicKey === 'camp-frontier' &&
quietFrontierMountainPlan.ambienceKey === 'mountain-wind-ambience' &&
quietFrontierMountainPlan.ambienceVolume <= 0.105,
`${quietFrontierMountainPlan?.battleId}: quiet frontier pairing must attenuate mountain fallback`
);
const redCliffsFirePlan = resolveCampaignPresentationSoundscape({ battleId: battleIds[21], stage: 'battle' });
const yilingFirePlan = resolveCampaignPresentationSoundscape({ battleId: battleIds[45], stage: 'battle' });
const wuzhangSnowPlan = resolveCampaignPresentationSoundscape({ battleId: battleIds[65], stage: 'battle' });
[redCliffsFirePlan, yilingFirePlan].forEach((plan) => {
assert(
plan?.ambienceKey === 'battle-fire' &&
plan.ambienceVolume * ambienceTrackGainCompensation['battle-fire'] <= 0.095,
`${plan?.battleId}: fire crackle effective volume must retain its peak ceiling`
);
});
assert(
wuzhangSnowPlan?.ambienceKey === 'mountain-wind-ambience' &&
wuzhangSnowPlan.ambienceVolume * ambienceTrackGainCompensation['mountain-wind-ambience'] <= 0.08,
`${wuzhangSnowPlan?.battleId}: snow-wind effective volume must retain its gust ceiling`
);
const preservedStoryPlan = resolveCampaignPresentationSoundscape({
battleId: battleIds[0],
stage: 'story',
sceneMusicKey: 'title-theme',
sceneAmbienceKey: 'battle-rain',
sceneAmbienceVolume: 0.11
});
assertEqual(preservedStoryPlan?.musicKey, 'title-theme', 'story scene music preservation');
assertEqual(preservedStoryPlan?.ambienceKey, 'battle-rain', 'story scene ambience preservation');
assertEqual(preservedStoryPlan?.ambienceVolume, 0.11, 'story scene ambience volume preservation');
const foundingStoryPlan = resolveCampaignPresentationSoundscape({
battleId: battleIds[0],
stage: 'story',
sceneMusicKey: 'militia-theme'
});
assertEqual(
foundingStoryPlan?.musicKey,
campaignBattlePresentationProfiles[battleIds[0]].arc.audio.anchorMusicKey,
'story rally cue follows the founding arc anchor'
);
const redCliffsStoryPlan = resolveCampaignPresentationSoundscape({
battleId: battleIds[18],
stage: 'story',
sceneMusicKey: 'battle-prep'
});
assertEqual(
redCliffsStoryPlan?.musicKey,
expectedBattleMusicKeys['red-cliffs'],
'story preparation cue follows the Red Cliffs battle family'
);
const foundingAftermathPlan = resolveCampaignPresentationSoundscape({
battleId: battleIds[0],
stage: 'aftermath',
sceneMusicKey: 'battle-prep'
});
assertEqual(
foundingAftermathPlan?.musicKey,
campaignBattlePresentationProfiles[battleIds[0]].arc.audio.resolutionMusicKey,
'aftermath preparation cue resolves through the arc theme'
);
assertEqual(campaignPresentationProfileFor('unknown-battle'), undefined, 'unknown battle profile');
assertEqual(campaignPresentationStateRuleFor('unknown-battle', 'camp'), undefined, 'unknown state rule');
assertEqual(
resolveCampaignPresentationSoundscape({ battleId: 'unknown-battle', stage: 'battle' }),
undefined,
'unknown soundscape'
);
assertEqual(campaignPresentationTransitionPolicy.sameTrackBehavior, 'keep-current-loop', 'same-track behavior');
assertEqual(campaignPresentationTransitionPolicy.musicCrossfadeMs, 900, 'music crossfade duration');
assertEqual(campaignPresentationTransitionPolicy.ambienceCrossfadeMs, 1200, 'ambience crossfade duration');
assertEqual(
campaignPresentationTransitionPolicy.battleAmbienceBehavior,
'specific-profile-then-arc-fallback',
'battle ambience behavior'
);
assertEqual(campaignPresentationTransitionPolicy.battleAmbienceFallbackScale, 1, 'battle ambience fallback scale');
assertEqual(campaignPresentationTransitionPolicy.battleAmbienceFallbackMinimum, 0.08, 'battle ambience fallback minimum');
assertEqual(campaignPresentationTransitionPolicy.battleAmbienceFallbackMaximum, 0.2, 'battle ambience fallback maximum');
assertEqual(campaignPresentationTransitionPolicy.battleAmbienceTargetRelativeLoudnessLu, -16, 'battle ambience target offset');
assertEqual(campaignPresentationTransitionPolicy.battleAmbienceRelativeLoudnessMinimumLu, -18, 'battle ambience lower bound');
assertEqual(campaignPresentationTransitionPolicy.battleAmbienceRelativeLoudnessMaximumLu, -14, 'battle ambience upper bound');
assert(Object.isFrozen(campaignPresentationTransitionPolicy), 'transition policy must be frozen');
const soundDirectorSource = readFileSync('src/game/audio/SoundDirector.ts', 'utf8');
assert(
soundDirectorSource.includes("createLoopChannel('music', 0.22, 900)"),
'SoundDirector music channel must retain the 900ms crossfade'
);
assert(
soundDirectorSource.includes("createLoopChannel('ambience', 0.08, 1200)"),
'SoundDirector ambience channel must retain the 1200ms crossfade'
);
const battleSceneSource = readFileSync('src/game/scenes/BattleScene.ts', 'utf8');
const storySceneSource = readFileSync('src/game/scenes/StoryScene.ts', 'utf8');
const campSceneSource = readFileSync('src/game/scenes/CampScene.ts', 'utf8');
const titleSceneSource = readFileSync('src/game/scenes/TitleScene.ts', 'utf8');
[
'resolveCampaignPresentationSoundscape({',
"stage: 'battle'",
'musicKey: presentationSoundscape?.musicKey',
'presentationBattleId: battleScenario.id',
"presentationStage: 'aftermath'",
'campaignPresentationProfileFor(battleScenario.id)',
'battleTintColor'
].forEach((requiredSource) => {
assert(battleSceneSource.includes(requiredSource), `BattleScene presentation integration: ${requiredSource}`);
});
[
'presentationBattleId?: string',
"presentationStage?: Extract<CampaignPresentationStage, 'story' | 'aftermath'>",
'resolveCampaignPresentationSoundscape({',
'this.applyStoryPresentationWash()',
'profile.arc.palette.washColor'
].forEach((requiredSource) => {
assert(storySceneSource.includes(requiredSource), `StoryScene presentation integration: ${requiredSource}`);
});
[
'presentationBattleId: flow.nextBattleId',
"presentationStage: 'story'",
'presentationBattleId: flow.afterBattleId'
].forEach((requiredSource) => {
assert(campSceneSource.includes(requiredSource), `CampScene presentation integration: ${requiredSource}`);
});
const prologueOpeningRouteCount = titleSceneSource.split('pages: prologueOpeningPages()').length - 1;
[
"nextScene: 'PrologueVillageScene'",
"campaign.step === 'prologue-town'",
'pages: prologueBrotherhoodPages()',
"nextScene: 'PrologueMilitiaCampScene'",
"campaign.step === 'prologue-camp'",
'pages: prologueDeparturePages()',
"nextScene: 'BattleScene'",
"presentationBattleId: 'first-battle-zhuo-commandery'",
"presentationStage: 'story'"
].forEach((requiredSource) => {
assert(titleSceneSource.includes(requiredSource), `TitleScene prologue presentation route: ${requiredSource}`);
});
assert(
prologueOpeningRouteCount >= 2,
'TitleScene must apply the first campaign arc to both new-game and resumed prologue openings'
);
if (errors.length > 0) {
throw new Error(
`Campaign presentation profile verification failed:\n${errors.map((error) => `- ${error}`).join('\n')}`
);
}
console.log(
`Verified ${arcEntries.length} campaign presentation arcs across ${battleIds.length} battles, ` +
`${battleMusicKeys.size} battle music groups, ${battleAmbienceFallbackCount} loudness-calibrated ambience fallbacks, ` +
'all state policies, palettes, motifs, and crossfade constraints.'
);
} finally {
await server.close();
}
function assertNonEmptyString(value, context) {
assert(typeof value === 'string' && value.trim().length > 0, `${context} must be a non-empty string`);
}
function assertEqual(actual, expected, context) {
assert(Object.is(actual, expected), `${context}: expected ${format(expected)}, received ${format(actual)}`);
}
function assert(condition, message) {
if (!condition) {
errors.push(message);
}
}
function format(value) {
return value === undefined ? 'undefined' : JSON.stringify(value);
}