298 lines
14 KiB
JavaScript
298 lines
14 KiB
JavaScript
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
import { execFileSync } from 'node:child_process';
|
|
import path from 'node:path';
|
|
import { formulaScoreMetadata } from './song-score-formulas.mjs';
|
|
|
|
const sourceOrigin = process.argv[2] ?? 'https://dreams.wf-calc.net';
|
|
const outputDirectory = path.resolve('static/data/charts');
|
|
const catalogPath = path.resolve('src/lib/data/exact-chart-catalog.json');
|
|
const selectedDifficulties = new Set(['EASY', 'NORMAL', 'HARD', 'EXPERT']);
|
|
const chartScoreWeightCoefficient = 0.3713416563352296;
|
|
const scoreCalibrations = JSON.parse(await readFile(path.resolve('data/score-calibrations.json'), 'utf8'));
|
|
const scoreCalibrationByKey = new Map(scoreCalibrations.charts.map((row) => [
|
|
`${row.songId}::${row.difficulty}`,
|
|
row
|
|
]));
|
|
function readHolodoriDbSource(locale, filename) {
|
|
const repositoryPath = path.resolve(`data/raw/holodoridb/${locale}`);
|
|
return JSON.parse(execFileSync('git', [
|
|
'-c', `safe.directory=${repositoryPath.replaceAll('\\', '/')}`,
|
|
'-C', repositoryPath,
|
|
'show', `origin/main:${filename}`
|
|
], { encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 }));
|
|
}
|
|
|
|
const musicMaster = readHolodoriDbSource('kor', 'Music.json');
|
|
const musicById = new Map(musicMaster.map((row) => [row.data?.id ?? row.id, row.data ?? row]));
|
|
const englishMusicText = readHolodoriDbSource('eng', 'LangMusic_Eng.json');
|
|
const englishTextById = new Map(englishMusicText.map((row) => [row.data?.id ?? row.id, row.data?.text ?? row.text]));
|
|
const musicDifficultyMaster = readHolodoriDbSource('kor', 'MusicDifficulty.json');
|
|
const difficultyLevelByKey = new Map(musicDifficultyMaster.map((row) => {
|
|
const data = row.data ?? row;
|
|
return [`${data.musicId}::${data.difficultyType.split('_').at(-1)?.toLowerCase()}`, data.difficultyLevel];
|
|
}));
|
|
const singerMetadataOverrides = {
|
|
m0334: { singerType: 'solo', singerCharacterIds: ['chr-04001'] }
|
|
};
|
|
|
|
function singerType(value) {
|
|
return typeof value === 'string' ? value.split('_').at(-1)?.toLowerCase() ?? null : null;
|
|
}
|
|
|
|
async function fetchText(url) {
|
|
const response = await fetch(url);
|
|
if (!response.ok) throw new Error(`${url} 요청 실패: ${response.status}`);
|
|
return response.text();
|
|
}
|
|
|
|
function requiredMatch(text, pattern, label) {
|
|
const match = text.match(pattern);
|
|
if (!match) throw new Error(`${label}을 찾을 수 없습니다.`);
|
|
return match[1];
|
|
}
|
|
|
|
function decodeTemplateLiteral(value) {
|
|
return value.replaceAll('\\\\', '\\').replaceAll('\\`', '`').replaceAll('\\${', '${');
|
|
}
|
|
|
|
function decodeSingleQuotedLiteral(value) {
|
|
let decoded = '';
|
|
for (let index = 0; index < value.length; index += 1) {
|
|
const character = value[index];
|
|
if (character !== '\\') {
|
|
decoded += character;
|
|
continue;
|
|
}
|
|
|
|
const escaped = value[index + 1];
|
|
index += 1;
|
|
if (escaped === '\\' || escaped === "'") decoded += escaped;
|
|
else if (escaped === 'n') decoded += '\n';
|
|
else if (escaped === 'r') decoded += '\r';
|
|
else if (escaped === 't') decoded += '\t';
|
|
else if (escaped === 'b') decoded += '\b';
|
|
else if (escaped === 'f') decoded += '\f';
|
|
else if (escaped === 'u') {
|
|
const code = value.slice(index + 1, index + 5);
|
|
decoded += String.fromCodePoint(Number.parseInt(code, 16));
|
|
index += 4;
|
|
} else decoded += escaped;
|
|
}
|
|
return decoded;
|
|
}
|
|
|
|
function extractJsonParseArgument(moduleText) {
|
|
const prefix = "JSON.parse('";
|
|
const start = moduleText.indexOf(prefix);
|
|
if (start < 0) throw new Error('채보 모듈에서 JSON 데이터를 찾을 수 없습니다.');
|
|
const valueStart = start + prefix.length;
|
|
|
|
for (let index = valueStart; index < moduleText.length; index += 1) {
|
|
if (moduleText[index] !== "'") continue;
|
|
let slashCount = 0;
|
|
for (let cursor = index - 1; cursor >= valueStart && moduleText[cursor] === '\\'; cursor -= 1) {
|
|
slashCount += 1;
|
|
}
|
|
if (slashCount % 2 === 0) {
|
|
return decodeSingleQuotedLiteral(moduleText.slice(valueStart, index));
|
|
}
|
|
}
|
|
throw new Error('채보 JSON 문자열의 끝을 찾을 수 없습니다.');
|
|
}
|
|
|
|
function flattenEvents(chartData) {
|
|
const noteEvents = chartData.note_events ?? {};
|
|
const greenBlocks = (noteEvents.green_blocks ?? noteEvents.green ?? [])
|
|
.map((rawBlock) => Array.isArray(rawBlock) ? rawBlock : [rawBlock]);
|
|
const pink = noteEvents.pink ?? [];
|
|
const internalGreenKeys = new Set(greenBlocks.flatMap((block) => block.slice(1, -1)
|
|
.map((note) => `${note.time_seconds}|${note.lane}`)));
|
|
const beatGap = (end, flick) => flick.beat - end.beat;
|
|
const sameShape = (end, flick) =>
|
|
end.lane_center === flick.lane_center && end.lane_half_width === flick.lane_half_width;
|
|
const shapeOverlapRatio = (end, flick) => {
|
|
const left = Math.max(end.lane_center - end.lane_half_width, flick.lane_center - flick.lane_half_width);
|
|
const right = Math.min(end.lane_center + end.lane_half_width, flick.lane_center + flick.lane_half_width);
|
|
const unionLeft = Math.min(end.lane_center - end.lane_half_width, flick.lane_center - flick.lane_half_width);
|
|
const unionRight = Math.max(end.lane_center + end.lane_half_width, flick.lane_center + flick.lane_half_width);
|
|
return Math.max(0, right - left) / Math.max(Number.EPSILON, unionRight - unionLeft);
|
|
};
|
|
const isDirectContinuation = (end, flick) =>
|
|
beatGap(end, flick) > 0 && beatGap(end, flick) <= 0.25 + 1e-9 && sameShape(end, flick);
|
|
const continuesIntoFlick = (note) => pink.some((flick) => isDirectContinuation(note, flick));
|
|
// 롱노트 끝 플릭은 색상이 아니라 직전 green block과의 연결 관계로 구분한다.
|
|
// 끝이 mid인 블록은 다음 플릭이 끝 판정을 대신하며, 아주 짧은 동일 형상 연결도 같은 경우다.
|
|
const isHoldEndFlick = (flick) => greenBlocks.some((block) => {
|
|
const end = block.at(-1);
|
|
if (!end) return false;
|
|
const gap = beatGap(end, flick);
|
|
return isDirectContinuation(end, flick) ||
|
|
(end.note_type === 'mid' && gap > 0 && gap <= 0.5 + 1e-9 && shapeOverlapRatio(end, flick) >= 0.8);
|
|
});
|
|
const green = greenBlocks
|
|
.flatMap((rawBlock) => {
|
|
const block = Array.isArray(rawBlock) ? rawBlock : [rawBlock];
|
|
return block.map((note, index) => ({
|
|
...note,
|
|
// 롱노트 안쪽 판정은 점 모양이어도 0.1배다. 시작·끝의 일반 판정만 1배다.
|
|
type: (index === 0 || index === block.length - 1) &&
|
|
note.note_type !== 'mid' &&
|
|
!internalGreenKeys.has(`${note.time_seconds}|${note.lane}`) &&
|
|
!(index === block.length - 1 && continuesIntoFlick(note))
|
|
? 'regular'
|
|
: 'mid'
|
|
}));
|
|
});
|
|
return [
|
|
...(noteEvents.blue ?? []).map((note) => ({ ...note, type: note.note_type ?? 'regular' })),
|
|
...green,
|
|
// 롱노트 끝에 붙은 플릭은 판정 모양과 달리 1.0배, 독립 플릭만 1.05배다.
|
|
...pink.map((note) => ({
|
|
...note,
|
|
type: note.note_type ?? 'regular',
|
|
is_flick: !isHoldEndFlick(note)
|
|
}))
|
|
]
|
|
.map((note) => ({
|
|
timeSeconds: note.time_seconds,
|
|
type: note.type === 'mid' ? 'mid' : 'regular',
|
|
isFlick: note.is_flick === true
|
|
}))
|
|
.filter((note) => Number.isFinite(note.timeSeconds))
|
|
.sort((left, right) => left.timeSeconds - right.timeSeconds || (left.type === 'mid' ? 1 : -1));
|
|
}
|
|
|
|
function legacyCalibrationNotes(chartData) {
|
|
const noteEvents = chartData.note_events ?? {};
|
|
const green = (noteEvents.green_blocks ?? noteEvents.green ?? [])
|
|
.flatMap((block) => Array.isArray(block) ? block : [block]);
|
|
return [
|
|
...(noteEvents.blue ?? []).map((note) => ({ ...note, type: note.note_type ?? 'regular' })),
|
|
...green.map((note) => ({ ...note, type: note.note_type ?? 'regular' })),
|
|
...(noteEvents.pink ?? []).map((note) => ({ ...note, type: note.note_type ?? 'regular', is_flick: true }))
|
|
].map((note) => ({
|
|
timeSeconds: note.time_seconds,
|
|
type: note.type === 'mid' ? 'mid' : 'regular',
|
|
isFlick: note.is_flick === true
|
|
}));
|
|
}
|
|
|
|
function calibratedScoreRatio(chartData) {
|
|
const notes = legacyCalibrationNotes(chartData);
|
|
const countedMidTimes = new Set();
|
|
let weight = 0;
|
|
for (const note of notes) {
|
|
if (note.type === 'mid') {
|
|
if (countedMidTimes.has(note.timeSeconds)) continue;
|
|
countedMidTimes.add(note.timeSeconds);
|
|
weight += 0.1;
|
|
} else {
|
|
weight += note.isFlick ? 1.05 : 1;
|
|
}
|
|
}
|
|
return weight * chartScoreWeightCoefficient;
|
|
}
|
|
|
|
const home = await fetchText(sourceOrigin);
|
|
const mainAsset = requiredMatch(home, /<script type="module" crossorigin src="([^"]+)"/, '메인 스크립트');
|
|
const mainModule = await fetchText(new URL(mainAsset, sourceOrigin));
|
|
const simulatorAsset = requiredMatch(mainModule, /\.\/UnitSimulatorPage-([^"']+\.js)/, '점수 시뮬레이터 모듈');
|
|
const simulatorModule = await fetchText(new URL(`/assets/UnitSimulatorPage-${simulatorAsset}`, sourceOrigin));
|
|
|
|
const catalogPrefix = 'us=JSON.parse(`';
|
|
const catalogStart = simulatorModule.indexOf(catalogPrefix);
|
|
const catalogEnd = simulatorModule.indexOf('`)', catalogStart + catalogPrefix.length);
|
|
if (catalogStart < 0 || catalogEnd < 0) throw new Error('채보 카탈로그를 찾을 수 없습니다.');
|
|
const sourceCatalog = JSON.parse(
|
|
decodeTemplateLiteral(simulatorModule.slice(catalogStart + catalogPrefix.length, catalogEnd))
|
|
);
|
|
const catalogByKey = new Map(sourceCatalog.map((chart) => [chart.key, chart]));
|
|
|
|
const imports = [];
|
|
const importPattern = /charts\/([^"/]+\.json)"[^\n]{0,180}?import\("\.\/([^"/]+\.js)"/g;
|
|
for (const match of simulatorModule.matchAll(importPattern)) {
|
|
const [, chartFile, assetFile] = match;
|
|
const [songId, difficultyAndLevel] = chartFile.split('.', 2);
|
|
const [difficultyName, difficultyLevelText] = difficultyAndLevel.split('-');
|
|
const difficulty = difficultyName.toUpperCase();
|
|
const difficultyLevel = Number.parseInt(difficultyLevelText, 10);
|
|
if (selectedDifficulties.has(difficulty)) imports.push({ songId, difficulty, difficultyLevel, chartFile, assetFile });
|
|
}
|
|
|
|
if (imports.length === 0) throw new Error('수집할 채보 모듈을 찾지 못했습니다.');
|
|
await mkdir(outputDirectory, { recursive: true });
|
|
|
|
let completed = 0;
|
|
const normalizedCharts = [];
|
|
const queue = [...imports];
|
|
|
|
async function worker() {
|
|
while (queue.length > 0) {
|
|
const item = queue.shift();
|
|
if (!item) return;
|
|
const moduleText = await fetchText(new URL(`/assets/${item.assetFile}`, sourceOrigin));
|
|
const chartData = JSON.parse(extractJsonParseArgument(moduleText));
|
|
const sourceEntry = catalogByKey.get(`${item.songId}::${item.difficulty}`);
|
|
const music = musicById.get(item.songId);
|
|
const singerOverride = singerMetadataOverrides[item.songId];
|
|
const releaseTime = Number(music?.startTime);
|
|
const notes = flattenEvents(chartData);
|
|
const measuredCalibration = scoreCalibrationByKey.get(`${item.songId}::${item.difficulty.toLowerCase()}`);
|
|
const difficultyLevel = difficultyLevelByKey.get(`${item.songId}::${item.difficulty.toLowerCase()}`) ??
|
|
(Number.isFinite(item.difficultyLevel) ? item.difficultyLevel : null);
|
|
const formulaMetadata = formulaScoreMetadata(item.songId, item.difficulty, difficultyLevel, notes.length, music?.liveScoreCoefficientPermil, chartData.source?.sha256);
|
|
const output = {
|
|
schemaVersion: 1,
|
|
songId: item.songId,
|
|
difficulty: item.difficulty.toLowerCase(),
|
|
durationSeconds: sourceEntry?.durationSeconds ?? Math.ceil(notes.at(-1)?.timeSeconds ?? 0),
|
|
// 검증된 공식 우선. 참고 분모가 없는 채보만 기존 실측/추정으로 대체한다.
|
|
scoreRatio: measuredCalibration?.scoreRatio ?? calibratedScoreRatio(chartData),
|
|
scoreCalibrationSource: measuredCalibration ? 'measured' : 'local-estimated-v1',
|
|
...formulaMetadata,
|
|
notes,
|
|
specialStartSeconds: (chartData.sp_activation_events ?? [])
|
|
.sort((left, right) => left.slot - right.slot)
|
|
.map((event) => event.time_seconds),
|
|
singerType: singerOverride?.singerType ?? singerType(music?.musicSingerType),
|
|
singerCharacterIds: singerOverride?.singerCharacterIds ?? music?.characterIds ?? [],
|
|
source: {
|
|
page: sourceOrigin,
|
|
transcriptionStatus: chartData.transcription_status,
|
|
susSha256: chartData.source?.sha256 ?? null
|
|
}
|
|
};
|
|
const fileName = `${item.songId}.${item.difficulty.toLowerCase()}.json`;
|
|
await writeFile(path.join(outputDirectory, fileName), JSON.stringify(output), 'utf8');
|
|
normalizedCharts.push({
|
|
songId: item.songId,
|
|
title: englishTextById.get(`la-music_title-${item.songId}`) ?? sourceEntry?.title ?? item.songId,
|
|
difficulty: item.difficulty.toLowerCase(),
|
|
difficultyLevel: difficultyLevelByKey.get(`${item.songId}::${item.difficulty.toLowerCase()}`) ??
|
|
(Number.isFinite(item.difficultyLevel) ? item.difficultyLevel : null),
|
|
durationSeconds: output.durationSeconds,
|
|
scoreRatio: output.scoreRatio,
|
|
scoreCalibrationSource: output.scoreCalibrationSource,
|
|
...(output.scoreFormula ? { scoreFormula: output.scoreFormula } : {}),
|
|
totalNotes: notes.length,
|
|
singerType: output.singerType,
|
|
singerCharacterIds: output.singerCharacterIds,
|
|
releaseTime: Number.isFinite(releaseTime) ? releaseTime : null,
|
|
path: `/data/charts/${fileName}`
|
|
});
|
|
completed += 1;
|
|
if (completed % 20 === 0 || completed === imports.length) {
|
|
console.log(`${completed}/${imports.length} 채보 수집 완료`);
|
|
}
|
|
}
|
|
}
|
|
|
|
await Promise.all(Array.from({ length: 6 }, () => worker()));
|
|
normalizedCharts.sort((left, right) => left.songId.localeCompare(right.songId) || left.difficulty.localeCompare(right.difficulty));
|
|
await writeFile(catalogPath, `${JSON.stringify({
|
|
schemaVersion: 1,
|
|
source: `${sourceOrigin}; hololive Dreams CDN via https://github.com/HolodoriDB/holodori-asset-tools`,
|
|
charts: normalizedCharts
|
|
}, null, 2)}\n`, 'utf8');
|
|
console.log(`${normalizedCharts.length}개 정확 채보를 저장했습니다.`);
|