data: switch to recovered game DB and refresh cards and charts
Publish GitHub Pages output / publish (push) Successful in 44s

This commit is contained in:
2026-09-29 22:48:08 +09:00
parent 5783072b12
commit 07b98db520
1259 changed files with 11228 additions and 6809 deletions
+47 -60
View File
@@ -1,76 +1,63 @@
// Convert Hololive Dreams' decrypted SUS dialect to this application's compact score timeline.
import { readFile, writeFile } from 'node:fs/promises';
// 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 { readHolodoriDbTable as readMaster, prepareHolodoriDbSource } from './holodoridb-source.mjs';
import path from 'node:path';
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 overrides = JSON.parse(await readFile(new URL('../data/song-metadata-overrides.json', import.meta.url), 'utf8')).songs;
const music = new Map(readMaster('kor', 'Music.json').map((row) => { const data = row.data ?? row; return [data.id, data]; }));
const translation = new Map(readMaster('kor', 'LangMusic_Kor.json').map((row) => { const data = row.data ?? row; return [data.id, data.text]; }));
const levels = new Map(readMaster('kor', 'MusicDifficulty.json').map((row) => { const data = row.data ?? row; return [`${data.musicId}.${data.difficultyType.split('_').at(-1).toLowerCase()}`, data.difficultyLevel]; }));
const reviewedSource = prepareHolodoriDbSource('kor');
const masterDataSource = { repository: reviewedSource.repository, commit: reviewedSource.commit, locale: 'kor' };
const songIds = process.argv.slice(2);
if (songIds.length === 0) throw new Error('곡 ID 또는 곡 ID.난이도를 지정하세요.');
const selected = songIds.flatMap((value) => {
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(`잘못된 채보 선택: ${value}`);
return (match[2] ? [match[2]] : difficulties).map((difficulty) => ({ songId: match[1], difficulty }));
if (!match) throw new Error('Invalid chart selection: ' + value);
return (match[2] ? [match[2]] : difficulties).map(difficulty => ({ songId: match[1], difficulty }));
});
const generatedCatalogEntries = [];
const outputs = [];
const outputs = [], entries = [];
for (const { songId, difficulty } of selected) {
const file = path.resolve(`data/raw/sus/chart_${songId}_${difficulty}.sus`);
const bytes = await readFile(file);
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'));
if (timeline.songId !== songId || timeline.notes.length !== weight.fullComboNoteCount) {
const mids = timeline.notes.filter((note) => note.type === 'mid').length;
throw new Error(`${songId}.${difficulty}: 파서 노트 수 ${timeline.notes.length}/${weight.fullComboNoteCount} 불일치 (중간 ${mids}, 헤더 ${weight.counts.LONG_RELAY + weight.counts.LONG_CONTINUE})`);
}
const officialMaster = music.get(songId);
const metadataSource = !officialMaster ? overrides[songId] : undefined;
let supplemental;
if (metadataSource) {
const metadataBytes = await readFile(path.resolve(metadataSource.file));
if (createHash('sha256').update(metadataBytes).digest('hex') !== metadataSource.sha256) {
throw new Error(songId + ': 보충 메타데이터 해시가 다릅니다.');
}
supplemental = JSON.parse(metadataBytes.toString('utf8'));
if (supplemental.id !== songId) throw new Error(songId + ': 보충 메타데이터 곡 ID가 다릅니다.');
}
// Fresh official master takes precedence over the pinned supplementary response.
const master = officialMaster ?? supplemental;
const supplementalDifficulty = supplemental?.difficulties.find((row) => row.difficultyType.split('_').at(-1).toLowerCase() === difficulty);
const difficultyLevel = levels.get(songId + '.' + difficulty) ?? supplementalDifficulty?.difficultyLevel;
const releaseTime = Number(master?.startTime);
const difficultyLevel = levels.get(chartKey), releaseTime = Number(master?.startTime);
const durationSeconds = master?.playingSeconds;
if (!master || !Number.isInteger(difficultyLevel) || difficultyLevel < 1 || !Number.isFinite(releaseTime) ||
!Number.isFinite(durationSeconds) || durationSeconds < timeline.notes.at(-1).timeSeconds ||
!Number.isFinite(master.liveScoreCoefficientPermil) || !Array.isArray(master.characterIds) || !master.musicSingerType) {
throw new Error(songId + '.' + difficulty + ': 검증된 곡 메타데이터가 필요합니다.');
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 (supplementalDifficulty && (supplementalDifficulty.fullComboNoteCount !== weight.fullComboNoteCount || supplementalDifficulty.normalNoteCount !== weight.counts.NORMAL)) {
throw new Error(songId + '.' + difficulty + ': 메타데이터와 SUS 노트 수가 다릅니다.');
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);
if (!formula) throw new Error(songId + '.' + difficulty + ': 검증된 점수 공식이 필요합니다.');
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: { page: 'Hololive Dreams CDN via holodori-asset-tools', transcriptionStatus: 'exact_from_decrypted_sus', susSha256, ...(metadataSource ? { metadataSource } : { masterDataSource }) } };
outputs.push({ filename: path.resolve('static/data/charts/' + songId + '.' + difficulty + '.json'), output });
generatedCatalogEntries.push({ songId, title: translation.get(master.titleLangId) ?? supplemental?.title?.kor ?? songId, difficulty, difficultyLevel, durationSeconds, scoreRatio: output.scoreRatio, scoreCalibrationSource: output.scoreCalibrationSource, scoreFormula: output.scoreFormula, totalNotes: output.notes.length, singerType: output.singerType, singerCharacterIds: output.singerCharacterIds, releaseTime, path: '/data/charts/' + songId + '.' + difficulty + '.json' });
console.log(`${songId}.${difficulty}: ${timeline.notes.length} notes ${timeline.specialStartSeconds.join(', ')}`);
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 = path.resolve('src/lib/data/exact-chart-catalog.json');
const catalog = JSON.parse(await readFile(catalogPath, 'utf8'));
const generatedKeys = new Set(generatedCatalogEntries.map((entry) => `${entry.songId}.${entry.difficulty}`));
catalog.charts = [...catalog.charts.filter((entry) => !generatedKeys.has(`${entry.songId}.${entry.difficulty}`)), ...generatedCatalogEntries]
.sort((left, right) => left.songId.localeCompare(right.songId) || left.difficulty.localeCompare(right.difficulty));
// Finish validation of every selected chart before writing any service file.
for (const { filename, output } of outputs) await writeFile(filename, JSON.stringify(output));
await writeFile(catalogPath, `${JSON.stringify(catalog, null, 2)}\n`);
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.');