298 lines
13 KiB
JavaScript
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);
|
|
}
|