100 lines
5.9 KiB
JavaScript
100 lines
5.9 KiB
JavaScript
// Read-only comparison: this does not replace production scoreRatio values.
|
|
// Run: node scripts/validate-song-score-formula.mjs
|
|
import assert from 'node:assert/strict';
|
|
import { readFile } from 'node:fs/promises';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { scoreRatioFromFormula, finalNoteScore } from '../src/lib/engine/score.ts';
|
|
|
|
const root = new URL('../', import.meta.url);
|
|
const read = async (relative) => JSON.parse(await readFile(new URL(relative, root), 'utf8'));
|
|
const reference = await read('docs/research/song-score-formula-reference.json');
|
|
const susReference = await read('docs/research/sus-score-formulas.json');
|
|
const calibrations = await read('data/score-calibrations.json');
|
|
const catalog = await read('src/lib/data/exact-chart-catalog.json');
|
|
const key = (row) => `${row.songId}.${row.difficulty}`;
|
|
const referenceRows = [...reference.charts, ...susReference.charts, ...susReference.validationCharts.filter((row) => row.fullComboNoteCount)];
|
|
const byKey = new Map(referenceRows.map((row) => [key(row), row]));
|
|
assert.equal(byKey.size, referenceRows.length, 'Duplicate reference chart');
|
|
|
|
function ratio(row) {
|
|
assert.ok(Number.isInteger(row.perfectNoteWeight) && row.perfectNoteWeight > 0);
|
|
const coefficient = 2.3 * (1 + row.liveScoreCoefficientPermil / 1000 * (row.difficultyLevel - 5));
|
|
assert.ok(coefficient > 0);
|
|
return row.perfectNoteWeight / 1000 / coefficient;
|
|
}
|
|
|
|
// Exact rational ceiling for the documented formula; no fitted ratios or epsilon.
|
|
function predict(measurement, row) {
|
|
const bonusScaled = Math.round(measurement.directSongScoreBonusPercent * 100);
|
|
assert.ok(Math.abs(bonusScaled / 100 - measurement.directSongScoreBonusPercent) < 1e-10);
|
|
assert.ok(Number.isInteger(measurement.overallPower));
|
|
const numerator = BigInt(measurement.overallPower) * 23n
|
|
* BigInt(1000 + row.liveScoreCoefficientPermil * (row.difficultyLevel - 5))
|
|
* BigInt(10000 + bonusScaled);
|
|
const denominator = 10n * BigInt(row.perfectNoteWeight) * 10000n;
|
|
return Number((numerator + denominator - 1n) / denominator);
|
|
}
|
|
|
|
const measuredRows = [];
|
|
for (const calibration of calibrations.charts) {
|
|
const ref = byKey.get(key(calibration));
|
|
assert.ok(ref, `Missing measured reference: ${key(calibration)}`);
|
|
const predictions = calibration.measurements.map((measurement) => {
|
|
const predicted = predict(measurement, ref);
|
|
// ceil(A / R) = S implies R in [A/S, A/(S-1)).
|
|
const amount = measurement.overallPower * (1 + measurement.directSongScoreBonusPercent / 100);
|
|
const lower = amount / measurement.normalPerfectScore;
|
|
const upperExclusive = amount / (measurement.normalPerfectScore - 1);
|
|
assert.ok(ratio(ref) >= lower && ratio(ref) < upperExclusive, `Ratio outside measured interval: ${key(calibration)}`);
|
|
assert.equal(predicted, measurement.normalPerfectScore, `Measured score mismatch: ${key(calibration)}`);
|
|
assert.equal(finalNoteScore(measurement.overallPower / scoreRatioFromFormula(ref), measurement.directSongScoreBonusPercent), predicted,
|
|
`Production engine mismatch: ${key(calibration)}`);
|
|
return { ...measurement, predicted, ratioInterval: { lower, upperExclusive } };
|
|
});
|
|
measuredRows.push({ key: key(calibration), researchRatio: ratio(ref), storedRatio: calibration.scoreRatio, predictions });
|
|
}
|
|
|
|
const missing = [], comparisons = [], noteDiagnostics = [];
|
|
for (const chart of catalog.charts) {
|
|
const ref = byKey.get(key(chart));
|
|
if (!ref) { missing.push(key(chart)); continue; }
|
|
assert.equal(chart.difficultyLevel, ref.difficultyLevel, `Difficulty mismatch: ${key(chart)}`);
|
|
const local = await read(`static${chart.path}`);
|
|
assert.equal(chart.scoreCalibrationSource, 'formula-v1', `Formula not applied: ${key(chart)}`);
|
|
assert.equal(chart.scoreRatio, scoreRatioFromFormula(ref));
|
|
assert.equal(local.scoreRatio, chart.scoreRatio);
|
|
assert.deepEqual(local.scoreFormula, chart.scoreFormula);
|
|
assert.equal(local.notes.length, ref.fullComboNoteCount, `Combo count mismatch: ${key(chart)}`);
|
|
const localWeight = local.notes.reduce((sum, note) => sum + (note.type === 'mid' ? 100 : note.isFlick ? 1050 : 1000), 0);
|
|
noteDiagnostics.push({
|
|
key: key(chart), localWeight, referencePlayableWeight: ref.baseWeight,
|
|
referenceDenominator: ref.perfectNoteWeight,
|
|
nonPlayableWeight: ref.perfectNoteWeight - ref.baseWeight,
|
|
playableClassificationDifference: localWeight - ref.baseWeight
|
|
});
|
|
comparisons.push({ key: key(chart), title: chart.title, source: chart.scoreCalibrationSource,
|
|
// Positive means the current unrounded base score is higher than research.
|
|
currentScoreRelativeToResearchPercent: 100 * (ratio(ref) / chart.scoreRatio - 1) });
|
|
}
|
|
|
|
const comparisonBySource = Object.fromEntries(Object.entries(Object.groupBy(comparisons, (row) => row.source)).map(([source, rows]) => {
|
|
const ordered = rows.toSorted((a, b) => a.currentScoreRelativeToResearchPercent - b.currentScoreRelativeToResearchPercent);
|
|
return [source, { count: rows.length,
|
|
meanAbsoluteDifferencePercent: rows.reduce((sum, row) => sum + Math.abs(row.currentScoreRelativeToResearchPercent), 0) / rows.length,
|
|
over5Percent: rows.filter((row) => Math.abs(row.currentScoreRelativeToResearchPercent) > 5).length,
|
|
over10Percent: rows.filter((row) => Math.abs(row.currentScoreRelativeToResearchPercent) > 10).length,
|
|
minimum: ordered[0], maximum: ordered.at(-1) }];
|
|
}));
|
|
const report = {
|
|
reference: fileURLToPath(new URL('docs/research/song-score-formula-reference.json', root)),
|
|
source: reference.source,
|
|
measuredSongs: new Set(calibrations.charts.map((row) => row.songId)).size,
|
|
measuredCharts: measuredRows.length,
|
|
exactMeasurements: measuredRows.reduce((sum, row) => sum + row.predictions.length, 0),
|
|
catalogCharts: catalog.charts.length, coveredCharts: comparisons.length, missing,
|
|
comparisonBySource,
|
|
measuredRows,
|
|
measuredNoteDiagnostics: noteDiagnostics.filter((row) => measuredRows.some((measured) => measured.key === row.key))
|
|
};
|
|
console.log(JSON.stringify(report, null, 2));
|