fix: calculate chart scores with song formulas and hidden note weights
Publish GitHub Pages output / publish (push) Successful in 54s

This commit is contained in:
2026-09-06 21:51:42 +09:00
parent b97a4008bb
commit e688036d11
409 changed files with 7732 additions and 1559 deletions
File diff suppressed because it is too large Load Diff
+19
View File
@@ -18,6 +18,25 @@ type ActiveCandidate = {
// QUEEN 및 Our Promise Expert의 인게임 실측값으로 보정한 전역 환산 상수다.
export const CHART_SCORE_WEIGHT_COEFFICIENT = 0.3713416563352296;
export type SongScoreFormula = {
/** 일반 PERFECT=1000 단위. 비가시 지점을 포함하며 득점 노트 합과 다르다. */
perfectNoteWeight: number;
liveScoreCoefficientPermil: number;
difficultyLevel: number;
};
export function scoreRatioFromFormula(formula: SongScoreFormula): number {
const { perfectNoteWeight, liveScoreCoefficientPermil, difficultyLevel } = formula;
if (!Number.isSafeInteger(perfectNoteWeight) || perfectNoteWeight <= 0 ||
!Number.isSafeInteger(liveScoreCoefficientPermil) || liveScoreCoefficientPermil < 0 ||
!Number.isSafeInteger(difficultyLevel) || difficultyLevel < 1) {
throw new Error('악곡 점수 공식의 입력값이 유효하지 않습니다.');
}
const musicCoefficient = 1 + liveScoreCoefficientPermil / 1000 * (difficultyLevel - 5);
if (musicCoefficient <= 0) throw new Error('악곡 점수 배율은 양수여야 합니다.');
return perfectNoteWeight / 1000 / (2.3 * musicCoefficient);
}
export function scoreRatioFromNotes(notes: ExactSongChart['notes']): number {
let weight = 0;
+61
View File
@@ -0,0 +1,61 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFile } from 'node:fs/promises';
import reference from '../../../docs/research/song-score-formula-reference.json' with { type: 'json' };
import susReference from '../../../docs/research/sus-score-formulas.json' with { type: 'json' };
import calibrations from '../../../data/score-calibrations.json' with { type: 'json' };
import catalog from '../data/exact-chart-catalog.json' with { type: 'json' };
import { calculateExactScore, perfectNoteScoreFromPower, scoreRatioFromFormula } from './score.ts';
test('곡별 공식과 실제 점수 엔진이 일반 PERFECT 실측 19건을 재현한다', () => {
let count = 0;
for (const calibration of calibrations.charts) {
const formula = reference.charts.find((row) => row.songId === calibration.songId && row.difficulty === calibration.difficulty);
assert.ok(formula);
for (const measurement of calibration.measurements) {
const result = calculateExactScore([], {}, {
songId: calibration.songId, difficulty: calibration.difficulty,
durationSeconds: 2, scoreRatio: scoreRatioFromFormula(formula),
notes: [{ timeSeconds: 1, type: 'regular', isFlick: false }], specialStartSeconds: []
}, {
perfectNoteScore: perfectNoteScoreFromPower(measurement.overallPower, scoreRatioFromFormula(formula)),
directSongScoreBonusPercent: measurement.directSongScoreBonusPercent
});
assert.equal(result.baselineScore, measurement.normalPerfectScore, `${calibration.songId}.${calibration.difficulty}`);
count += 1;
}
}
assert.equal(count, 19);
});
test('공식 적용 카탈로그와 실제 채보가 같은 분모 및 환산값을 사용한다', async () => {
let formulaCount = 0;
for (const row of catalog.charts) {
const chart = JSON.parse(await readFile(new URL(`../../../static${row.path}`, import.meta.url), 'utf8'));
const ref = [...reference.charts, ...susReference.charts].find((item) => item.songId === row.songId && item.difficulty === row.difficulty);
assert.equal(chart.scoreRatio, row.scoreRatio);
assert.equal(chart.scoreCalibrationSource, row.scoreCalibrationSource);
if (!ref) {
assert.notEqual(row.scoreCalibrationSource, 'formula-v1');
continue;
}
assert.equal(row.scoreCalibrationSource, 'formula-v1');
assert.equal(row.scoreRatio, scoreRatioFromFormula(ref));
assert.deepEqual(chart.scoreFormula, row.scoreFormula);
assert.deepEqual(chart.scoreFormula, {
perfectNoteWeight: ref.perfectNoteWeight,
liveScoreCoefficientPermil: ref.liveScoreCoefficientPermil,
difficultyLevel: ref.difficultyLevel
});
assert.equal(chart.notes.length, ref.fullComboNoteCount);
formulaCount += 1;
}
assert.equal(formulaCount, 388);
});
test('잘못된 분모와 난이도로 공식 환산값을 만들지 않는다', () => {
const valid = { perfectNoteWeight: 633950, liveScoreCoefficientPermil: 5, difficultyLevel: 24 };
for (const invalid of [{ perfectNoteWeight: 0 }, { perfectNoteWeight: NaN }, { difficultyLevel: 0 }, { liveScoreCoefficientPermil: -1 }]) {
assert.throws(() => scoreRatioFromFormula({ ...valid, ...invalid }));
}
});
+1
View File
@@ -93,6 +93,7 @@ export type ExactSongChart = {
durationSeconds: number;
scoreRatio: number | null;
scoreCalibrationSource?: 'measured' | 'local-estimated-v1' | string;
scoreFormula?: import('./score.ts').SongScoreFormula;
notes: readonly ExactChartNote[];
specialStartSeconds: readonly (number | null)[];
singerType?: string | null;