Files
HolodoriCalc/scripts/validate-song-score-formula.mjs
T
dal4segno e688036d11
Publish GitHub Pages output / publish (push) Successful in 54s
fix: calculate chart scores with song formulas and hidden note weights
2026-09-06 21:51:42 +09:00

100 lines
5.8 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];
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));