292 lines
10 KiB
JavaScript
292 lines
10 KiB
JavaScript
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
import { join, sep } from 'node:path';
|
|
import { createServer } from 'vite';
|
|
|
|
const audioRoot = join('src', 'assets', 'audio');
|
|
const sourceFilesToScan = [
|
|
'src/game/audio/SoundDirector.ts',
|
|
'src/game/scenes/BattleScene.ts',
|
|
'src/game/scenes/CampScene.ts',
|
|
'src/game/scenes/EndingScene.ts',
|
|
'src/game/scenes/StoryScene.ts',
|
|
'src/game/scenes/TitleScene.ts'
|
|
];
|
|
|
|
const server = await createServer({
|
|
logLevel: 'error',
|
|
server: { middlewareMode: true },
|
|
appType: 'custom'
|
|
});
|
|
|
|
try {
|
|
const { musicTracks, ambienceTracks, effectTracks } = await server.ssrLoadModule('/src/game/audio/audioAssets.ts');
|
|
const scenarioModule = await server.ssrLoadModule('/src/game/data/scenario.ts');
|
|
const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts');
|
|
const errors = [];
|
|
|
|
validateTrackMap(errors, 'music', musicTracks, 'bgm');
|
|
validateTrackMap(errors, 'ambience', ambienceTracks, 'ambience');
|
|
validateTrackMap(errors, 'effect', effectTracks, 'sfx');
|
|
validateAudioDirectoryCoverage(errors, musicTracks, ambienceTracks, effectTracks);
|
|
validateStoryBgmReferences(errors, scenarioModule, battleScenarios, musicTracks);
|
|
validateLiteralAudioCalls(errors, musicTracks, ambienceTracks, effectTracks);
|
|
|
|
if (errors.length) {
|
|
console.error(`Audio asset data verification failed with ${errors.length} issue(s):`);
|
|
errors.slice(0, 100).forEach((error) => console.error(`- ${error}`));
|
|
if (errors.length > 100) {
|
|
console.error(`- ...and ${errors.length - 100} more`);
|
|
}
|
|
process.exitCode = 1;
|
|
} else {
|
|
console.log(
|
|
`Verified ${Object.keys(musicTracks).length} music tracks, ${Object.keys(ambienceTracks).length} ambience tracks, ` +
|
|
`${Object.keys(effectTracks).length} effect tracks, ` +
|
|
`${collectStoryBgmReferences(scenarioModule, battleScenarios).length} story BGM references, and literal audio calls.`
|
|
);
|
|
}
|
|
} finally {
|
|
await server.close();
|
|
}
|
|
|
|
function validateTrackMap(errors, label, tracks, expectedFolder) {
|
|
Object.entries(tracks).forEach(([key, url]) => {
|
|
assertNonEmptyString(errors, key, `${label} track key`);
|
|
assertNonEmptyString(errors, url, `${label} track "${key}" URL`);
|
|
|
|
const path = assetUrlToPath(url);
|
|
if (!path) {
|
|
errors.push(`${label} track "${key}" URL "${url}" does not point at src/assets/audio`);
|
|
return;
|
|
}
|
|
if (!path.includes(`${sep}${expectedFolder}${sep}`)) {
|
|
errors.push(`${label} track "${key}" should live under ${expectedFolder}, got "${path}"`);
|
|
}
|
|
validateAudioFile(errors, `${label} track "${key}"`, path);
|
|
});
|
|
}
|
|
|
|
function validateAudioDirectoryCoverage(errors, musicTracks, ambienceTracks, effectTracks) {
|
|
const registeredMusicPaths = new Set(Object.values(musicTracks).map(assetUrlToPath).filter(Boolean));
|
|
const registeredAmbiencePaths = new Set(Object.values(ambienceTracks).map(assetUrlToPath).filter(Boolean));
|
|
const registeredEffectPaths = new Set(Object.values(effectTracks).map(assetUrlToPath).filter(Boolean));
|
|
|
|
listAudioFiles(join(audioRoot, 'bgm')).forEach((path) => {
|
|
if (!registeredMusicPaths.has(path)) {
|
|
errors.push(`BGM file "${path}" exists but is not registered in musicTracks`);
|
|
}
|
|
});
|
|
listAudioFiles(join(audioRoot, 'ambience')).forEach((path) => {
|
|
if (!registeredAmbiencePaths.has(path)) {
|
|
errors.push(`Ambience file "${path}" exists but is not registered in ambienceTracks`);
|
|
}
|
|
});
|
|
listAudioFiles(join(audioRoot, 'sfx')).forEach((path) => {
|
|
if (!registeredEffectPaths.has(path)) {
|
|
errors.push(`SFX file "${path}" exists but is not registered in effectTracks`);
|
|
}
|
|
});
|
|
}
|
|
|
|
function validateStoryBgmReferences(errors, scenarioModule, battleScenarios, musicTracks) {
|
|
const musicKeys = new Set(Object.keys(musicTracks));
|
|
collectStoryBgmReferences(scenarioModule, battleScenarios).forEach(({ source, pageId, bgm }) => {
|
|
if (!musicKeys.has(bgm)) {
|
|
errors.push(`${source}/${pageId}: unknown story BGM "${bgm}"`);
|
|
}
|
|
});
|
|
}
|
|
|
|
function validateLiteralAudioCalls(errors, musicTracks, ambienceTracks, effectTracks) {
|
|
const musicKeys = new Set(Object.keys(musicTracks));
|
|
const ambienceKeys = new Set(Object.keys(ambienceTracks));
|
|
const effectKeys = new Set(Object.keys(effectTracks));
|
|
|
|
sourceFilesToScan.forEach((path) => {
|
|
const source = readFileSync(path, 'utf8');
|
|
collectLiteralCallKeys(source, 'playMusic').forEach(({ key, index }) => {
|
|
if (!musicKeys.has(key)) {
|
|
errors.push(`${path}:${lineForIndex(source, index)}: playMusic references unknown track "${key}"`);
|
|
}
|
|
});
|
|
collectLiteralCallKeys(source, 'playAmbience').forEach(({ key, index }) => {
|
|
if (!ambienceKeys.has(key)) {
|
|
errors.push(`${path}:${lineForIndex(source, index)}: playAmbience references unknown track "${key}"`);
|
|
}
|
|
});
|
|
collectLiteralCallKeys(source, 'playEffect').forEach(({ key, index }) => {
|
|
if (!effectKeys.has(key)) {
|
|
errors.push(`${path}:${lineForIndex(source, index)}: playEffect references unknown track "${key}"`);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function collectStoryBgmReferences(scenarioModule, battleScenarios) {
|
|
const refs = [];
|
|
const seenArrays = new WeakSet();
|
|
|
|
Object.entries(scenarioModule)
|
|
.filter(([, value]) => isStoryPageList(value))
|
|
.sort(([left], [right]) => left.localeCompare(right))
|
|
.forEach(([name, pages]) => {
|
|
collectPageBgmRefs(refs, name, pages);
|
|
seenArrays.add(pages);
|
|
});
|
|
|
|
Object.entries(battleScenarios)
|
|
.sort(([left], [right]) => left.localeCompare(right))
|
|
.forEach(([battleId, scenario]) => {
|
|
if (!isStoryPageList(scenario.victoryPages) || seenArrays.has(scenario.victoryPages)) {
|
|
return;
|
|
}
|
|
collectPageBgmRefs(refs, `${battleId}.victoryPages`, scenario.victoryPages);
|
|
seenArrays.add(scenario.victoryPages);
|
|
});
|
|
|
|
return refs;
|
|
}
|
|
|
|
function collectPageBgmRefs(refs, source, pages) {
|
|
pages.forEach((page) => {
|
|
if (page.bgm) {
|
|
refs.push({ source, pageId: page.id, bgm: page.bgm });
|
|
}
|
|
});
|
|
}
|
|
|
|
function isStoryPageList(value) {
|
|
return Array.isArray(value) && value.length > 0 && value.every(isStoryPage);
|
|
}
|
|
|
|
function isStoryPage(page) {
|
|
return Boolean(page && typeof page === 'object' && typeof page.id === 'string' && typeof page.text === 'string');
|
|
}
|
|
|
|
function collectLiteralCallKeys(source, callName) {
|
|
const keys = [];
|
|
const pattern = new RegExp(`\\.${callName}\\(\\s*['"]([^'"]+)['"]`, 'g');
|
|
for (const match of source.matchAll(pattern)) {
|
|
keys.push({ key: match[1], index: match.index ?? 0 });
|
|
}
|
|
return keys;
|
|
}
|
|
|
|
function assetUrlToPath(url) {
|
|
const marker = 'src/assets/audio/';
|
|
const index = url.indexOf(marker);
|
|
if (index < 0) {
|
|
return undefined;
|
|
}
|
|
return url.slice(index).replaceAll('/', sep);
|
|
}
|
|
|
|
function listAudioFiles(path) {
|
|
return readdirSync(path, { withFileTypes: true })
|
|
.flatMap((entry) => {
|
|
const childPath = join(path, entry.name);
|
|
return entry.isDirectory() ? listAudioFiles(childPath) : [childPath];
|
|
})
|
|
.filter((filePath) => /\.(mp3|wav)$/i.test(filePath))
|
|
.sort();
|
|
}
|
|
|
|
function validateAudioFile(errors, context, path) {
|
|
let stats;
|
|
let bytes;
|
|
try {
|
|
stats = statSync(path);
|
|
bytes = readFileSync(path);
|
|
} catch (error) {
|
|
errors.push(`${context} cannot be read at "${path}": ${error.message}`);
|
|
return;
|
|
}
|
|
|
|
if (stats.size < 1024) {
|
|
errors.push(`${context} at "${path}" is too small to be a useful audio file`);
|
|
}
|
|
|
|
if (path.toLowerCase().endsWith('.wav')) {
|
|
if (bytes.subarray(0, 4).toString('ascii') !== 'RIFF' || bytes.subarray(8, 12).toString('ascii') !== 'WAVE') {
|
|
errors.push(`${context} at "${path}" does not have a WAV header`);
|
|
return;
|
|
}
|
|
|
|
const metadata = readWavMetadata(bytes);
|
|
if (!metadata) {
|
|
errors.push(`${context} at "${path}" is missing readable WAV fmt/data chunks`);
|
|
return;
|
|
}
|
|
if (metadata.audioFormat !== 1) {
|
|
errors.push(`${context} at "${path}" should be PCM WAV audio, got format ${metadata.audioFormat}`);
|
|
}
|
|
if (metadata.channels < 1 || metadata.channels > 2) {
|
|
errors.push(`${context} at "${path}" should be mono or stereo WAV audio, got ${metadata.channels} channels`);
|
|
}
|
|
if (metadata.sampleRate < 22050) {
|
|
errors.push(`${context} at "${path}" should use at least 22050 Hz WAV audio, got ${metadata.sampleRate} Hz`);
|
|
}
|
|
if (![16, 24, 32].includes(metadata.bitsPerSample)) {
|
|
errors.push(`${context} at "${path}" should use 16/24/32-bit WAV audio, got ${metadata.bitsPerSample}-bit`);
|
|
}
|
|
if (metadata.dataSize <= 0) {
|
|
errors.push(`${context} at "${path}" does not contain WAV sample data`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (path.toLowerCase().endsWith('.mp3')) {
|
|
const hasId3 = bytes.subarray(0, 3).toString('ascii') === 'ID3';
|
|
const hasFrameSync = bytes[0] === 0xff && (bytes[1] & 0xe0) === 0xe0;
|
|
if (!hasId3 && !hasFrameSync) {
|
|
errors.push(`${context} at "${path}" does not look like an MP3 file`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
errors.push(`${context} at "${path}" uses an unsupported audio extension`);
|
|
}
|
|
|
|
function readWavMetadata(bytes) {
|
|
let offset = 12;
|
|
let format;
|
|
let dataSize = 0;
|
|
|
|
while (offset + 8 <= bytes.length) {
|
|
const chunkType = bytes.subarray(offset, offset + 4).toString('ascii');
|
|
const chunkSize = bytes.readUInt32LE(offset + 4);
|
|
const dataOffset = offset + 8;
|
|
if (dataOffset + chunkSize > bytes.length) {
|
|
return undefined;
|
|
}
|
|
|
|
if (chunkType === 'fmt ' && chunkSize >= 16) {
|
|
format = {
|
|
audioFormat: bytes.readUInt16LE(dataOffset),
|
|
channels: bytes.readUInt16LE(dataOffset + 2),
|
|
sampleRate: bytes.readUInt32LE(dataOffset + 4),
|
|
bitsPerSample: bytes.readUInt16LE(dataOffset + 14)
|
|
};
|
|
} else if (chunkType === 'data') {
|
|
dataSize += chunkSize;
|
|
}
|
|
|
|
offset = dataOffset + chunkSize + (chunkSize % 2);
|
|
}
|
|
|
|
if (!format || dataSize <= 0) {
|
|
return undefined;
|
|
}
|
|
return { ...format, dataSize };
|
|
}
|
|
|
|
function lineForIndex(source, index) {
|
|
return source.slice(0, index).split(/\r?\n/).length;
|
|
}
|
|
|
|
function assertNonEmptyString(errors, value, context) {
|
|
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
errors.push(`${context} must be a non-empty string`);
|
|
}
|
|
}
|