fix: calculate chart scores with song formulas and hidden note weights
Publish GitHub Pages output / publish (push) Successful in 54s
Publish GitHub Pages output / publish (push) Successful in 54s
This commit is contained in:
+3104
-1164
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user