// Generate score timelines from game SUS assets and metadata from the captured DB. import { readFile, writeFile, mkdir } from 'node:fs/promises'; import { createHash } from 'node:crypto'; import { readGameDbTable as readMaster, prepareGameDbSource, sourceMetadata } from './game-db-source.mjs'; import { susScoreWeight } from './sus-score-weight.mjs'; import { formulaScoreMetadata } from './song-score-formulas.mjs'; import { parseSusTimeline } from './sus-timeline.mjs'; const difficulties = ['easy', 'normal', 'hard', 'expert']; const music = new Map(readMaster('kor', 'Music.json').map(({ data }) => [data.id, data])); const translation = new Map(readMaster('kor', 'LangMusic_Kor.json').map(({ data }) => [data.id, data.text])); const key = data => data.musicId + '.' + data.difficultyType.split('_').at(-1).toLowerCase(); const levels = new Map(readMaster('kor', 'MusicDifficulty.json').map(({ data }) => [key(data), data.difficultyLevel])); const counts = new Map(readMaster('kor', 'MusicDifficultyChart.json').map(({ data }) => [key(data), data.fullComboNoteCount])); const masterDataSource = sourceMetadata(prepareGameDbSource()); const args = process.argv.slice(2); const all = args.length === 0 || (args.length === 1 && args[0] === '--all'); const now = Date.now(); const existingOnly = args.length === 1 && args[0] === '--existing'; const existingCatalog = JSON.parse(await readFile('src/lib/data/exact-chart-catalog.json', 'utf8')); const ids = existingOnly ? [...new Set(existingCatalog.charts.map(e => e.songId))] : all ? [...music.values()].filter(m => Number(m.startTime) <= now).map(m => m.id) : args; const selected = ids.flatMap(value => { const match = value.match(/^(m\d{4})(?:\.(easy|normal|hard|expert))?$/); if (!match) throw new Error('Invalid chart selection: ' + value); return (match[2] ? [match[2]] : difficulties).map(difficulty => ({ songId: match[1], difficulty })); }); const outputs = [], entries = []; for (const { songId, difficulty } of selected) { const master = music.get(songId), chartKey = songId + '.' + difficulty; const bytes = await readFile('data/raw/sus/chart_' + songId + '_' + difficulty + '.sus'); const timeline = parseSusTimeline(bytes.toString('utf8')); const weight = susScoreWeight(bytes.toString('utf8')); const difficultyLevel = levels.get(chartKey), releaseTime = Number(master?.startTime); const durationSeconds = master?.playingSeconds; if (!master || !Number.isInteger(difficultyLevel) || !Number.isFinite(releaseTime) || releaseTime > now || !Number.isFinite(durationSeconds) || durationSeconds + 1 < timeline.notes.at(-1)?.timeSeconds || !Number.isFinite(master.liveScoreCoefficientPermil) || !master.musicSingerType || !translation.has(master.titleLangId)) { throw new Error(chartKey + ': complete, released DB metadata required'); } if (timeline.songId !== songId || timeline.notes.length !== weight.fullComboNoteCount || counts.get(chartKey) !== weight.fullComboNoteCount) { throw new Error(chartKey + ': DB / SUS / timeline note counts differ (' + [counts.get(chartKey), weight.fullComboNoteCount, timeline.notes.length].join('/') + ')'); } const susSha256 = createHash('sha256').update(bytes).digest('hex'); const formula = formulaScoreMetadata(songId, difficulty, difficultyLevel, timeline.notes.length, master.liveScoreCoefficientPermil, susSha256); const output = { schemaVersion: 1, songId, difficulty, durationSeconds, ...formula, notes: timeline.notes, specialStartSeconds: timeline.specialStartSeconds, singerType: master.musicSingerType.split('_').at(-1).toLowerCase(), singerCharacterIds: master.characterIds ?? [], source: { kind: 'game-sus', transcriptionStatus: 'exact_from_decrypted_sus', susSha256, masterDataSource } }; outputs.push(['static/data/charts/' + chartKey + '.json', output]); entries.push({ songId, title: translation.get(master.titleLangId), difficulty, difficultyLevel, durationSeconds, ...formula, totalNotes: output.notes.length, singerType: output.singerType, singerCharacterIds: output.singerCharacterIds, releaseTime, path: '/data/charts/' + chartKey + '.json' }); } const catalogPath = 'src/lib/data/exact-chart-catalog.json'; const old = JSON.parse(await readFile(catalogPath, 'utf8')); const keys = new Set(entries.map(e => e.songId + '.' + e.difficulty)); const charts = [...(all ? [] : old.charts.filter(e => !keys.has(e.songId + '.' + e.difficulty))), ...entries] .sort((a, b) => a.songId.localeCompare(b.songId) || a.difficulty.localeCompare(b.difficulty)); await mkdir('static/data/charts', { recursive: true }); // Nothing is written until every selected chart passes validation. for (const [file, data] of outputs) await writeFile(file, JSON.stringify(data)); await writeFile(catalogPath, JSON.stringify({ schemaVersion: 1, source: 'Game master DB and game SUS assets', masterDataSource, charts }, null, 2) + '\n'); console.log('Generated ' + outputs.length + ' game charts; catalog ' + charts.length + '. Future songs remain in the DB snapshot.');