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