import { readFile } from 'node:fs/promises'; import { scoreRatioFromFormula } from '../src/lib/engine/score.ts'; // Pinned research snapshot, with source URL, retrieval time and SHA-256. const reference = JSON.parse(await readFile(new URL('../docs/research/song-score-formula-reference.json', import.meta.url), 'utf8')); const susReference = JSON.parse(await readFile(new URL('../docs/research/sus-score-formulas.json', import.meta.url), 'utf8')); // Newly recovered charts carry their full reproducibility record in validationCharts until // the next research snapshot is consolidated. const rows = [...reference.charts, ...susReference.charts, ...susReference.validationCharts.filter((row) => row.fullComboNoteCount)]; const formulas = new Map(rows.map((row) => [`${row.songId}.${row.difficulty}`, row])); if (formulas.size !== rows.length) throw new Error('중복 악곡 점수 공식입니다.'); export function formulaScoreMetadata(songId, difficulty, difficultyLevel, totalNotes, liveScoreCoefficientPermil, susSha256) { const row = formulas.get(`${songId}.${difficulty.toLowerCase()}`); if (!row) return null; if (row.susSha256 && row.susSha256 !== susSha256) throw new Error(`${songId}.${difficulty}: 원본 SUS 해시가 다릅니다.`); if (row.difficultyLevel !== difficultyLevel || row.fullComboNoteCount !== totalNotes || (liveScoreCoefficientPermil !== undefined && row.liveScoreCoefficientPermil !== liveScoreCoefficientPermil)) { throw new Error(`${songId}.${difficulty}: 채보 또는 마스터 데이터가 점수 공식 참고값과 다릅니다.`); } const scoreFormula = { perfectNoteWeight: row.perfectNoteWeight, liveScoreCoefficientPermil: row.liveScoreCoefficientPermil, difficultyLevel: row.difficultyLevel }; return { scoreRatio: scoreRatioFromFormula(scoreFormula), scoreCalibrationSource: 'formula-v1', scoreFormula }; }