diff --git a/package.json b/package.json index f2f92ab..9ba0bd0 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "verify:campaign-data": "node scripts/verify-campaign-flow-data.mjs", "verify:story-assets": "node scripts/verify-story-asset-data.mjs", "verify:visual-assets": "node scripts/verify-visual-asset-data.mjs", + "verify:audio-assets": "node scripts/verify-audio-asset-data.mjs", "verify:flow": "node scripts/verify-flow.mjs", "verify:save-flow": "node scripts/verify-save-retry-flow.mjs", "verify:release": "node scripts/verify-release-candidate.mjs", diff --git a/scripts/verify-audio-asset-data.mjs b/scripts/verify-audio-asset-data.mjs new file mode 100644 index 0000000..edc05cd --- /dev/null +++ b/scripts/verify-audio-asset-data.mjs @@ -0,0 +1,222 @@ +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, 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, 'effect', effectTracks, 'sfx'); + validateAudioDirectoryCoverage(errors, musicTracks, effectTracks); + validateStoryBgmReferences(errors, scenarioModule, battleScenarios, musicTracks); + validateLiteralAudioCalls(errors, musicTracks, 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(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, effectTracks) { + const registeredMusicPaths = new Set(Object.values(musicTracks).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, '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, effectTracks) { + const musicKeys = new Set(Object.keys(musicTracks)); + 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, '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; + } + + 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 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`); + } +} diff --git a/scripts/verify-release-candidate.mjs b/scripts/verify-release-candidate.mjs index b66935c..0822885 100644 --- a/scripts/verify-release-candidate.mjs +++ b/scripts/verify-release-candidate.mjs @@ -13,6 +13,7 @@ try { runCommand('node', ['scripts/verify-battle-scenario-data.mjs']); runCommand('node', ['scripts/verify-story-asset-data.mjs']); runCommand('node', ['scripts/verify-visual-asset-data.mjs']); + runCommand('node', ['scripts/verify-audio-asset-data.mjs']); mkdirSync(screenshotDir, { recursive: true }); serverProcess = await ensurePreviewServer(targetUrl);