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;
+2 -2
View File
@@ -168,7 +168,7 @@
})();
$: overallPower = unitPower.overallPower;
$: perfectNoteScore = perfectNoteScoreFromPower(overallPower, selectedChart?.scoreRatio ?? null);
$: scoreCalibrationLabel = selectedChart?.scoreCalibrationSource === 'measured' ? '실측값' : '추정값';
$: scoreCalibrationLabel = selectedChart?.scoreCalibrationSource === 'formula-v1' ? '곡별 공식' : selectedChart?.scoreCalibrationSource === 'measured' ? '실측값' : '추정값';
$: timelineDuration = selectedChart?.durationSeconds ?? 0;
$: displayedEffectiveActiveWindows = mergeEffectiveActiveWindows(result?.effectiveActiveWindows ?? []);
$: timelineTicks = buildTimelineTicks(timelineDuration);
@@ -859,7 +859,7 @@
<div>
<h1>배치 분석</h1>
<p class="page-description">특정 악곡과 카드 조합에서 최고 점수를 낼 수 있는 스페셜 발동 순서와 빈도 감소 조합을 찾습니다.</p>
{#if selectedChart && selectedChart.scoreCalibrationSource !== 'measured'}
{#if selectedChart && selectedChart.scoreCalibrationSource !== 'measured' && selectedChart.scoreCalibrationSource !== 'formula-v1'}
<p class="calibration-warning" role="status"><strong>추정 점수 안내</strong> 이 악곡의 점수 계수는 실측값이 아니므로 실제 점수와 오차가 크게 발생할 수 있습니다.</p>
{/if}
</div>
+2 -2
View File
@@ -176,7 +176,7 @@
$: songs = Array.from(new Map(charts.map((chart) => [chart.songId, { id: chart.songId, title: chart.title }])).values());
$: if (!songs.some((song) => song.id === selectedSongId)) selectedSongId = songs[0]?.id ?? '';
$: selectedChart = charts.find((chart) => chart.songId === selectedSongId && chart.difficulty === difficulty);
$: scoreCalibrationLabel = selectedChart?.scoreCalibrationSource === 'measured' ? '실측값' : '추정값';
$: scoreCalibrationLabel = selectedChart?.scoreCalibrationSource === 'formula-v1' ? '곡별 공식' : selectedChart?.scoreCalibrationSource === 'measured' ? '실측값' : '추정값';
$: baselineChartMetadata = baselineState
? charts.find((chart) => chart.songId === baselineState!.songId && chart.difficulty === baselineState!.difficulty)
: undefined;
@@ -1129,7 +1129,7 @@
<span>대상 멤버 1명당 +{currentEvent.formationBonusPercent}% · 신규 카드 추가 +{currentEvent.featuredCardBonusPercent}% · 과제곡 대응 카드 스코어 +{currentEvent.challengeSongScoreBonusPercent}%</span>
</div>
{/if}
{#if selectedChart && selectedChart.scoreCalibrationSource !== 'measured'}
{#if selectedChart && selectedChart.scoreCalibrationSource !== 'measured' && selectedChart.scoreCalibrationSource !== 'formula-v1'}
<p class="calibration-warning" role="status"><strong>추정 점수 안내</strong> 이 악곡의 점수 계수는 실측값이 아니므로 실제 점수와 오차가 크게 발생할 수 있습니다.</p>
{/if}
{#if !eventMode}<p class="baseline-guide"><strong>기준 조합</strong>은 배치 분석 결과가 저장되어 있고, 저장된 악곡과 난이도가 현재 탐색 조건과 같을 때 표시됩니다.</p>{/if}