Files
HolodoriCalc/scripts/collect-exact-charts.mjs
T
dal4segno 0af64052c6
Publish GitHub Pages output / publish (push) Successful in 41s
feat: support every song difficulty including Easy and Normal
2026-09-07 14:18:10 +09:00

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}개 정확 채보를 저장했습니다.`);