Files
heros_web/scripts/verify-audio-asset-data.mjs

517 lines
18 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 requiredBattlefieldAmbienceKeys = ['battle-rain', 'battle-fire'];
const requiredTacticalCueKeys = [
'ally-turn',
'enemy-turn',
'operation-alert',
'objective-success',
'objective-failure'
];
const requiredNarrativeCueKeys = ['story-page-turn'];
const requiredBattleSceneAudioMethods = [
'playSoundscape',
'playAllyTurnCue',
'playEnemyTurnCue',
'playOperationAlert',
'playObjectiveAchieved',
'playObjectiveFailed'
];
const battlefieldSourceRecords = [
{
projectFile: 'src/assets/audio/ambience/battle-rain.mp3',
source: 'https://pixabay.com/sound-effects/nature-gentle-midday-rain-499668/'
},
{
projectFile: 'src/assets/audio/ambience/battle-fire.mp3',
source: 'https://pixabay.com/sound-effects/film-special-effects-fire-sounds-405444/'
},
...['ally-turn', 'enemy-turn', 'operation-alert'].map((key) => ({
projectFile: `src/assets/audio/sfx/${key}.mp3`,
source: 'https://pixabay.com/sound-effects/musical-ancient-war-drums-463214/'
})),
{
projectFile: 'src/assets/audio/sfx/objective-success.mp3',
source: 'https://pixabay.com/sound-effects/film-special-effects-level-complete-143022/'
},
{
projectFile: 'src/assets/audio/sfx/objective-failure.mp3',
source: 'https://pixabay.com/sound-effects/film-special-effects-failure-1-89170/'
}
];
const narrativeSourceRecord = {
projectFile: 'src/assets/audio/sfx/story-page-turn.mp3',
source: 'https://pixabay.com/sound-effects/film-special-effects-flipping-book-page-499646/',
requiredDocumentation: [
'2026-07-22',
'Flipping Book Page',
'DRAGON-STUDIO',
'48 kHz stereo MP3',
'1.0-second',
'silence-trimmed',
'fade-in/out',
'high-pass and low-pass filtered'
]
};
const server = await createServer({
logLevel: 'error',
server: { middlewareMode: true },
appType: 'custom'
});
try {
const audioAssets = await server.ssrLoadModule('/src/game/audio/audioAssets.ts');
const { musicTracks, ambienceTracks, effectTracks } = audioAssets;
const effectPools = audioAssets.effectPools ?? {};
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');
validateRequiredTrackKeys(errors, 'battlefield ambience', ambienceTracks, requiredBattlefieldAmbienceKeys);
validateRequiredTrackKeys(errors, 'tactical cue', effectTracks, requiredTacticalCueKeys);
validateRequiredTrackKeys(errors, 'narrative cue', effectTracks, requiredNarrativeCueKeys);
validateBattlefieldSourceDocumentation(errors);
validateNarrativeSourceDocumentation(errors);
validateBattleSceneAudioIntegration(errors);
validateEffectPools(errors, effectPools, effectTracks);
validateEffectPoolRegistration(errors, effectPools);
validateAudioDirectoryCoverage(errors, musicTracks, ambienceTracks, effectTracks);
validateStoryBgmReferences(errors, scenarioModule, battleScenarios, musicTracks);
validateLiteralAudioCalls(errors, musicTracks, ambienceTracks, effectTracks, effectPools);
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, ${Object.keys(effectPools).length} effect pools, ` +
`${collectStoryBgmReferences(scenarioModule, battleScenarios).length} story BGM references, and literal audio calls.`
);
}
} finally {
await server.close();
}
function validateEffectPools(errors, effectPools, effectTracks) {
const effectKeys = new Set(Object.keys(effectTracks));
Object.entries(effectPools).forEach(([poolKey, trackKeys]) => {
assertNonEmptyString(errors, poolKey, 'effect pool key');
if (!Array.isArray(trackKeys) || trackKeys.length === 0) {
errors.push(`effect pool "${poolKey}" must contain at least one track key`);
return;
}
const seenTrackKeys = new Set();
trackKeys.forEach((trackKey, index) => {
assertNonEmptyString(errors, trackKey, `effect pool "${poolKey}" track ${index}`);
if (!effectKeys.has(trackKey)) {
errors.push(`effect pool "${poolKey}" references unknown effect track "${trackKey}"`);
}
if (seenTrackKeys.has(trackKey)) {
errors.push(`effect pool "${poolKey}" repeats effect track "${trackKey}"`);
}
seenTrackKeys.add(trackKey);
});
});
}
function validateRequiredTrackKeys(errors, label, tracks, requiredKeys) {
requiredKeys.forEach((key) => {
if (!(key in tracks)) {
errors.push(`required ${label} track "${key}" is not registered`);
}
});
}
function validateBattlefieldSourceDocumentation(errors) {
const docs = readFileSync(join('docs', 'audio-sources.md'), 'utf8');
if (!docs.includes('2026-07-21')) {
errors.push('docs/audio-sources.md must record the 2026-07-21 battlefield audio download date');
}
if (!docs.includes('https://pixabay.com/service/license-summary/')) {
errors.push('docs/audio-sources.md must link the Pixabay Content License summary');
}
battlefieldSourceRecords.forEach(({ projectFile, source }) => {
if (!docs.includes(projectFile)) {
errors.push(`docs/audio-sources.md is missing battlefield audio file "${projectFile}"`);
}
if (!docs.includes(source)) {
errors.push(`docs/audio-sources.md is missing Pixabay source "${source}"`);
}
});
}
function validateNarrativeSourceDocumentation(errors) {
const docs = readFileSync(join('docs', 'audio-sources.md'), 'utf8');
if (!docs.includes(narrativeSourceRecord.projectFile)) {
errors.push(`docs/audio-sources.md is missing narrative audio file "${narrativeSourceRecord.projectFile}"`);
}
if (!docs.includes(narrativeSourceRecord.source)) {
errors.push(`docs/audio-sources.md is missing Pixabay source "${narrativeSourceRecord.source}"`);
}
narrativeSourceRecord.requiredDocumentation.forEach((value) => {
if (!docs.includes(value)) {
errors.push(`docs/audio-sources.md is missing narrative audio detail "${value}"`);
}
});
}
function validateBattleSceneAudioIntegration(errors) {
const path = join('src', 'game', 'scenes', 'BattleScene.ts');
const source = readFileSync(path, 'utf8');
requiredBattleSceneAudioMethods.forEach((method) => {
const pattern = new RegExp(`\\bsoundDirector\\.${method}\\s*\\(`);
if (!pattern.test(source)) {
errors.push(`${path} must call soundDirector.${method}()`);
}
});
}
function validateEffectPoolRegistration(errors, effectPools) {
if (Object.keys(effectPools).length === 0) {
return;
}
const mainSource = readFileSync(join('src', 'main.ts'), 'utf8');
if (!/\bregisterEffectPools\s*\(\s*effectPools\s*\)/.test(mainSource)) {
errors.push('src/main.ts must register exported effectPools with SoundDirector');
}
}
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, effectPools) {
const musicKeys = new Set(Object.keys(musicTracks));
const ambienceKeys = new Set(Object.keys(ambienceTracks));
const effectKeys = new Set([...Object.keys(effectTracks), ...Object.keys(effectPools)]);
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 or pool "${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)) {
const callIndex = match.index ?? 0;
const argumentStart = callIndex + match[0].length;
const argumentEnd = firstArgumentEnd(source, argumentStart);
const argumentSource = source.slice(argumentStart, argumentEnd);
const ternaryIndex = firstTernaryIndex(argumentSource);
const stringPattern = /(['"])([^'"\\]*(?:\\.[^'"\\]*)*)\1/g;
for (const stringMatch of argumentSource.matchAll(stringPattern)) {
if (ternaryIndex >= 0 && (stringMatch.index ?? 0) < ternaryIndex) {
continue;
}
if (stringMatch[2].includes('\\')) {
continue;
}
keys.push({ key: stringMatch[2], index: argumentStart + (stringMatch.index ?? 0) });
}
}
return keys;
}
function firstTernaryIndex(source) {
let quote = '';
for (let index = 0; index < source.length; index += 1) {
const character = source[index];
if (quote) {
if (character === '\\') {
index += 1;
} else if (character === quote) {
quote = '';
}
continue;
}
if (character === "'" || character === '"' || character === '`') {
quote = character;
continue;
}
if (character === '?' && source[index - 1] !== '?' && source[index + 1] !== '?' && source[index + 1] !== '.') {
return index;
}
}
return -1;
}
function firstArgumentEnd(source, startIndex) {
let nestedDepth = 0;
let quote = '';
for (let index = startIndex; index < source.length; index += 1) {
const character = source[index];
if (quote) {
if (character === '\\') {
index += 1;
} else if (character === quote) {
quote = '';
}
continue;
}
if (character === "'" || character === '"' || character === '`') {
quote = character;
continue;
}
if (character === '(' || character === '[' || character === '{') {
nestedDepth += 1;
continue;
}
if (character === ')' || character === ']' || character === '}') {
if (nestedDepth === 0) {
return index;
}
nestedDepth -= 1;
continue;
}
if (character === ',' && nestedDepth === 0) {
return index;
}
}
return source.length;
}
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`);
}
}