Files
heros_web/scripts/verify-camp-soundscape-data.mjs

298 lines
13 KiB
JavaScript

import { createServer } from 'vite';
import { createHash } from 'node:crypto';
import { readFileSync, statSync } from 'node:fs';
import { sep } from 'node:path';
const expectedBattleArcs = [
{ skinId: 'yellow-turban', firstBattleOrdinal: 1, lastBattleOrdinal: 4 },
{ skinId: 'anti-dong', firstBattleOrdinal: 5, lastBattleOrdinal: 6 },
{ skinId: 'xuzhou', firstBattleOrdinal: 7, lastBattleOrdinal: 9 },
{ skinId: 'wandering', firstBattleOrdinal: 10, lastBattleOrdinal: 15 },
{ skinId: 'wolong', firstBattleOrdinal: 16, lastBattleOrdinal: 18 },
{ skinId: 'red-cliffs', firstBattleOrdinal: 19, lastBattleOrdinal: 22 },
{ skinId: 'jing-yi', firstBattleOrdinal: 23, lastBattleOrdinal: 34 },
{ skinId: 'hanzhong-shuhan', firstBattleOrdinal: 35, lastBattleOrdinal: 37 },
{ skinId: 'jingzhou-crisis', firstBattleOrdinal: 38, lastBattleOrdinal: 44 },
{ skinId: 'yiling-baidi', firstBattleOrdinal: 45, lastBattleOrdinal: 46 },
{ skinId: 'nanzhong', firstBattleOrdinal: 47, lastBattleOrdinal: 54 },
{ skinId: 'northern', firstBattleOrdinal: 55, lastBattleOrdinal: 66 }
];
const expectedMusicGroups = {
rally: 'camp-rally',
wayfarer: 'camp-wayfarer',
'grand-strategy': 'camp-grand-strategy',
frontier: 'camp-frontier'
};
const expectedAmbiences = {
campfire: 'campfire-ambience',
'river-night': 'river-night-ambience',
'mountain-wind': 'mountain-wind-ambience',
'nanzhong-night': 'nanzhong-night-ambience'
};
const expectedMapping = {
'yellow-turban': { musicGroupId: 'rally', ambienceId: 'campfire' },
'anti-dong': { musicGroupId: 'rally', ambienceId: 'campfire' },
xuzhou: { musicGroupId: 'wayfarer', ambienceId: 'campfire' },
wandering: { musicGroupId: 'wayfarer', ambienceId: 'campfire' },
wolong: { musicGroupId: 'wayfarer', ambienceId: 'mountain-wind' },
'red-cliffs': { musicGroupId: 'grand-strategy', ambienceId: 'river-night' },
'jing-yi': { musicGroupId: 'grand-strategy', ambienceId: 'river-night' },
'hanzhong-shuhan': { musicGroupId: 'grand-strategy', ambienceId: 'mountain-wind' },
'jingzhou-crisis': { musicGroupId: 'grand-strategy', ambienceId: 'river-night' },
'yiling-baidi': { musicGroupId: 'grand-strategy', ambienceId: 'river-night' },
nanzhong: { musicGroupId: 'frontier', ambienceId: 'nanzhong-night' },
northern: { musicGroupId: 'frontier', ambienceId: 'mountain-wind' }
};
const expectedSpecialSteps = {
'hanzhong-king-camp': 'hanzhong-shuhan',
'shu-han-foundation-camp': 'hanzhong-shuhan',
'baidi-entrustment-camp': 'yiling-baidi',
'northern-campaign-prep-camp': 'northern',
'ending-complete': 'northern'
};
const errors = [];
const server = await createServer({
logLevel: 'error',
server: { middlewareMode: true },
appType: 'custom'
});
try {
const { campMusicGroups, campAmbienceDefinitions, campSoundscapeMapping, getCampSoundscape } =
await server.ssrLoadModule('/src/game/data/campSoundscapes.ts');
const { selectCampSkin } = await server.ssrLoadModule('/src/game/data/campSkins.ts');
const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts');
const { musicTracks, ambienceTracks } = await server.ssrLoadModule('/src/game/audio/audioAssets.ts');
const expectedSkinIds = Object.keys(expectedMapping);
const musicEntries = Object.entries(campMusicGroups);
const ambienceEntries = Object.entries(campAmbienceDefinitions);
const mappingEntries = Object.entries(campSoundscapeMapping);
const battleIds = Object.keys(battleScenarios);
assertEqual(expectedSkinIds.length, 12, 'expected camp skin count');
assertEqual(mappingEntries.length, 12, 'camp soundscape mapping count');
assertEqual(musicEntries.length, 4, 'camp music group count');
assertEqual(ambienceEntries.length, 4, 'camp ambience definition count');
assertEqual(battleIds.length, 66, 'battle count used by camp soundscapes');
validateDefinitions(musicEntries, expectedMusicGroups, musicTracks, 'music group', 0.05, 0.4);
validateDefinitions(ambienceEntries, expectedAmbiences, ambienceTracks, 'ambience', 0.03, 0.3);
validateCampAudioAssets(
musicEntries.map(([, definition]) => ({ key: definition.trackKey, url: musicTracks[definition.trackKey], kind: 'music' })),
ambienceEntries.map(([, definition]) => ({ key: definition.trackKey, url: ambienceTracks[definition.trackKey], kind: 'ambience' }))
);
expectedSkinIds.forEach((skinId) => {
const actualMapping = campSoundscapeMapping[skinId];
const expected = expectedMapping[skinId];
assert(Boolean(actualMapping), `${skinId}: missing soundscape mapping`);
if (!actualMapping) {
return;
}
assertEqual(actualMapping.musicGroupId, expected.musicGroupId, `${skinId}: music group`);
assertEqual(actualMapping.ambienceId, expected.ambienceId, `${skinId}: ambience`);
assert(Boolean(campMusicGroups[actualMapping.musicGroupId]), `${skinId}: unknown music group`);
assert(Boolean(campAmbienceDefinitions[actualMapping.ambienceId]), `${skinId}: unknown ambience`);
const soundscape = getCampSoundscape(skinId);
assertEqual(soundscape.skinId, skinId, `${skinId}: resolved skin id`);
assertEqual(soundscape.music, campMusicGroups[expected.musicGroupId], `${skinId}: resolved music definition`);
assertEqual(soundscape.ambience, campAmbienceDefinitions[expected.ambienceId], `${skinId}: resolved ambience definition`);
});
mappingEntries.forEach(([skinId]) => {
assert(expectedSkinIds.includes(skinId), `unexpected camp soundscape mapping "${skinId}"`);
});
battleIds.forEach((battleId, index) => {
const battleOrdinal = index + 1;
const expectedSkinId = expectedSkinForOrdinal(battleOrdinal);
const selection = selectCampSkin({ nextBattleId: battleId });
assertEqual(selection.skin?.id, expectedSkinId, `${battleId}: selected camp skin`);
assertSelectionSoundscape(selection, `${battleId}: battle ${battleOrdinal}`, getCampSoundscape);
});
Object.entries(expectedSpecialSteps).forEach(([campaignStep, expectedSkinId]) => {
const selection = selectCampSkin({
campaignStep,
nextBattleId: battleIds[0],
currentBattleId: battleIds.at(-1)
});
assertEqual(selection.source, 'special-step', `${campaignStep}: selection source`);
assertEqual(selection.skin?.id, expectedSkinId, `${campaignStep}: selected camp skin`);
assertSelectionSoundscape(selection, `${campaignStep}: special override`, getCampSoundscape);
});
if (errors.length > 0) {
throw new Error(`Camp soundscape data verification failed:\n${errors.map((error) => `- ${error}`).join('\n')}`);
}
console.log(
`Verified ${mappingEntries.length} camp soundscape mappings, ${musicEntries.length} music groups, ` +
`${ambienceEntries.length} ambiences, ${battleIds.length} battle selections, and ` +
`${Object.keys(expectedSpecialSteps).length} special-step overrides.`
);
} finally {
await server.close();
}
function validateDefinitions(entries, expectedDefinitions, registeredTracks, context, minimumVolume, maximumVolume) {
const expectedIds = Object.keys(expectedDefinitions);
const registeredKeys = new Set(Object.keys(registeredTracks));
entries.forEach(([id, definition]) => {
assert(expectedIds.includes(id), `unexpected camp ${context} "${id}"`);
assertEqual(definition.id, id, `${context} ${id}: id`);
assertEqual(definition.trackKey, expectedDefinitions[id], `${context} ${id}: track key`);
assertNonEmptyString(definition.label, `${context} ${id}: label`);
assert(
Number.isFinite(definition.volume) &&
definition.volume >= minimumVolume &&
definition.volume <= maximumVolume,
`${context} ${id}: volume must be between ${minimumVolume} and ${maximumVolume}`
);
assert(registeredKeys.has(definition.trackKey), `${context} ${id}: unregistered track "${definition.trackKey}"`);
assertNonEmptyString(registeredTracks[definition.trackKey], `${context} ${id}: registered track URL`);
});
expectedIds.forEach((id) => {
assert(Boolean(entries.find(([actualId]) => actualId === id)), `missing camp ${context} "${id}"`);
});
assertEqual(new Set(entries.map(([, definition]) => definition.trackKey)).size, entries.length, `${context} track keys`);
}
function validateCampAudioAssets(musicAssets, ambienceAssets) {
const assets = [...musicAssets, ...ambienceAssets];
const hashes = new Set();
let totalBytes = 0;
assets.forEach((asset) => {
const path = assetUrlToPath(asset.url);
assert(Boolean(path), `${asset.kind} ${asset.key}: URL does not point at src/assets/audio`);
if (!path) {
return;
}
let bytes;
let size;
try {
bytes = readFileSync(path);
size = statSync(path).size;
} catch (error) {
errors.push(`${asset.kind} ${asset.key}: cannot read ${path}: ${error.message}`);
return;
}
totalBytes += size;
const hash = createHash('sha256').update(bytes).digest('hex');
assert(!hashes.has(hash), `${asset.kind} ${asset.key}: audio content duplicates another camp asset`);
hashes.add(hash);
const metadata = readMp3Metadata(bytes);
assert(Boolean(metadata), `${asset.kind} ${asset.key}: missing readable MPEG-1 Layer III frame`);
if (!metadata) {
return;
}
assertEqual(metadata.sampleRate, 44100, `${asset.kind} ${asset.key}: sample rate`);
assertEqual(metadata.channels, 2, `${asset.kind} ${asset.key}: channel count`);
if (asset.kind === 'music') {
assertEqual(metadata.bitrateKbps, 192, `music ${asset.key}: bitrate`);
assert(size >= 256 * 1024 && size <= 2 * 1024 * 1024, `music ${asset.key}: size must be 256 KiB to 2 MiB`);
assert(metadata.durationSeconds >= 45 && metadata.durationSeconds <= 70, `music ${asset.key}: duration must be 45 to 70 seconds`);
} else {
assertEqual(metadata.bitrateKbps, 128, `ambience ${asset.key}: bitrate`);
assert(size >= 64 * 1024 && size <= 1024 * 1024, `ambience ${asset.key}: size must be 64 KiB to 1 MiB`);
assert(metadata.durationSeconds >= 20 && metadata.durationSeconds <= 45, `ambience ${asset.key}: duration must be 20 to 45 seconds`);
}
});
assert(totalBytes <= 8 * 1024 * 1024, `camp audio assets must stay at or below 8 MiB; received ${totalBytes} bytes`);
}
function assetUrlToPath(url) {
const marker = 'src/assets/audio/';
const index = typeof url === 'string' ? url.indexOf(marker) : -1;
return index >= 0 ? url.slice(index).replaceAll('/', sep) : undefined;
}
function readMp3Metadata(bytes) {
let searchOffset = 0;
if (bytes.subarray(0, 3).toString('ascii') === 'ID3' && bytes.length >= 10) {
const tagSize = ((bytes[6] & 0x7f) << 21) | ((bytes[7] & 0x7f) << 14) | ((bytes[8] & 0x7f) << 7) | (bytes[9] & 0x7f);
searchOffset = 10 + tagSize;
}
const bitrateTable = [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0];
const sampleRateTable = [44100, 48000, 32000, 0];
const searchLimit = Math.min(bytes.length - 4, searchOffset + 65536);
for (let offset = searchOffset; offset <= searchLimit; offset += 1) {
if (bytes[offset] !== 0xff || (bytes[offset + 1] & 0xe0) !== 0xe0) {
continue;
}
const versionBits = (bytes[offset + 1] >> 3) & 0x03;
const layerBits = (bytes[offset + 1] >> 1) & 0x03;
if (versionBits !== 0x03 || layerBits !== 0x01) {
continue;
}
const bitrateKbps = bitrateTable[(bytes[offset + 2] >> 4) & 0x0f];
const sampleRate = sampleRateTable[(bytes[offset + 2] >> 2) & 0x03];
if (!bitrateKbps || !sampleRate) {
continue;
}
const channelMode = (bytes[offset + 3] >> 6) & 0x03;
return {
bitrateKbps,
sampleRate,
channels: channelMode === 0x03 ? 1 : 2,
durationSeconds: ((bytes.length - searchOffset) * 8) / (bitrateKbps * 1000)
};
}
return undefined;
}
function assertSelectionSoundscape(selection, context, getCampSoundscape) {
const skinId = selection.skin?.id;
assert(Boolean(skinId), `${context}: selection has no skin`);
if (!skinId || !expectedMapping[skinId]) {
return;
}
const expected = expectedMapping[skinId];
const soundscape = getCampSoundscape(skinId);
assertEqual(soundscape.music.id, expected.musicGroupId, `${context}: music group`);
assertEqual(soundscape.ambience.id, expected.ambienceId, `${context}: ambience`);
}
function expectedSkinForOrdinal(battleOrdinal) {
return expectedBattleArcs.find(
({ firstBattleOrdinal, lastBattleOrdinal }) =>
battleOrdinal >= firstBattleOrdinal && battleOrdinal <= lastBattleOrdinal
)?.skinId;
}
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);
}