import { mkdir, readFile, writeFile } from 'node:fs/promises'; import path from 'node:path'; 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(['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 ])); const musicMaster = JSON.parse(await readFile(path.resolve('data/raw/holodoridb/kor/Music.json'), 'utf8')); const musicById = new Map(musicMaster.map((row) => [row.data?.id ?? row.id, row.data ?? row])); const englishMusicText = JSON.parse(await readFile(path.resolve('data/raw/holodoridb/eng/LangMusic_Eng.json'), 'utf8')); const englishTextById = new Map(englishMusicText.map((row) => [row.data?.id ?? row.id, row.data?.text ?? row.text])); const musicDifficultyMaster = JSON.parse(await readFile(path.resolve('data/raw/holodoridb/kor/MusicDifficulty.json'), 'utf8')); 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, /