334 lines
14 KiB
JavaScript
334 lines
14 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 { 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,
|
|
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();
|
|
|
|
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];
|
|
assertEqual(battlePlan?.musicKey, profile?.arc.audio.battleMusicKey, `${battleId}: battle music plan`);
|
|
assertEqual(battlePlan?.ambienceKey, environment?.soundscape.ambienceKey, `${battleId}: battle ambience plan`);
|
|
assertEqual(battlePlan?.ambienceVolume, environment?.soundscape.ambienceVolume, `${battleId}: battle ambience volume`);
|
|
assert(battlePlan?.transition === campaignPresentationTransitionPolicy, `${battleId}: transition policy identity`);
|
|
});
|
|
|
|
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-only', 'battle ambience behavior');
|
|
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}`);
|
|
});
|
|
assert(
|
|
titleSceneSource.split("presentationStage: 'story'").length - 1 === 2 &&
|
|
titleSceneSource.split("presentationBattleId: 'first-battle-zhuo-commandery'").length - 1 >= 2,
|
|
'TitleScene must apply the first campaign arc to both new-game and resumed prologue stories'
|
|
);
|
|
|
|
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, 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);
|
|
}
|