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:
@@ -21,6 +21,9 @@ for (const calibration of calibrations.charts) {
|
||||
// Easy/Normal 실측도 보관하되 현재 생성 대상인 Hard/Expert만 즉시 동기화한다.
|
||||
if (!catalogRow) continue;
|
||||
|
||||
// 공식 기반 채보는 실측 보정으로 덮어쓰지 않는다. 실측은 검증 자료로 유지한다.
|
||||
if (catalogRow.scoreCalibrationSource === 'formula-v1') continue;
|
||||
|
||||
catalogRow.scoreRatio = calibration.scoreRatio;
|
||||
catalogRow.scoreCalibrationSource = 'measured';
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { formulaScoreMetadata } from './song-score-formulas.mjs';
|
||||
|
||||
const root = new URL('../', import.meta.url);
|
||||
const catalogPath = new URL('src/lib/data/exact-chart-catalog.json', root);
|
||||
const catalog = JSON.parse(await readFile(catalogPath, 'utf8'));
|
||||
const updates = [];
|
||||
const missing = [];
|
||||
// Validate every chart before writing any changes. Do not change notes or SP timing.
|
||||
for (const row of catalog.charts) {
|
||||
const chartPath = new URL(`static${row.path}`, root);
|
||||
const chart = JSON.parse(await readFile(chartPath, 'utf8'));
|
||||
const metadata = formulaScoreMetadata(row.songId, row.difficulty, row.difficultyLevel, chart.notes.length, undefined, chart.source?.susSha256);
|
||||
if (!metadata) { missing.push(`${row.songId}.${row.difficulty}`); continue; }
|
||||
if (chart.songId !== row.songId || chart.difficulty !== row.difficulty || row.totalNotes !== chart.notes.length) {
|
||||
throw new Error(`채보 카탈로그 불일치: ${row.songId}.${row.difficulty}`);
|
||||
}
|
||||
Object.assign(row, metadata);
|
||||
Object.assign(chart, metadata);
|
||||
updates.push({ chartPath, chart });
|
||||
}
|
||||
for (const { chartPath, chart } of updates) await writeFile(chartPath, JSON.stringify(chart));
|
||||
await writeFile(catalogPath, `${JSON.stringify(catalog, null, 2)}\n`);
|
||||
console.log(`${updates.length}개 채보에 곡별 공식을 적용했습니다. 기존 환산 유지: ${missing.join(', ') || '없음'}`);
|
||||
@@ -0,0 +1,23 @@
|
||||
// node scripts/calculate-sus-score-formula.mjs data/raw/sus/chart_m0341_expert.sus
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { basename } from 'node:path';
|
||||
import { susScoreWeight } from './sus-score-weight.mjs';
|
||||
import { scoreRatioFromFormula } from '../src/lib/engine/score.ts';
|
||||
|
||||
const file = process.argv[2];
|
||||
if (!file) throw new Error('원본 SUS 경로를 지정하세요.');
|
||||
const bytes = await readFile(file);
|
||||
const result = susScoreWeight(bytes.toString('utf8'));
|
||||
const difficulty = basename(file).match(/_(easy|normal|hard|expert)\.sus$/)?.[1];
|
||||
const reference = JSON.parse(await readFile(new URL('../docs/research/song-score-formula-reference.json', import.meta.url), 'utf8'));
|
||||
const catalog = JSON.parse(await readFile(new URL('../src/lib/data/exact-chart-catalog.json', import.meta.url), 'utf8'));
|
||||
const chart = catalog.charts.find((row) => row.songId === result.songId && row.difficulty === difficulty);
|
||||
const song = reference.charts.find((row) => row.songId === result.songId);
|
||||
if (!chart || !song) throw new Error('카탈로그 난이도 또는 곡별 계수를 찾지 못했습니다.');
|
||||
const local = JSON.parse(await readFile(new URL(`../static${chart.path}`, import.meta.url), 'utf8'));
|
||||
const susSha256 = createHash('sha256').update(bytes).digest('hex');
|
||||
if (local.source?.susSha256 !== susSha256 || local.notes.length !== result.fullComboNoteCount) throw new Error('현재 서비스와 다른 원본 채보입니다.');
|
||||
// SUS SCORE_LEVEL is editor metadata; use the published master difficulty level.
|
||||
const formula = { perfectNoteWeight: result.perfectNoteWeight, liveScoreCoefficientPermil: song.liveScoreCoefficientPermil, difficultyLevel: chart.difficultyLevel };
|
||||
console.log(JSON.stringify({ ...result, difficulty, susSha256, ...formula, scoreRatio: scoreRatioFromFormula(formula) }, null, 2));
|
||||
@@ -1,6 +1,7 @@
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { formulaScoreMetadata } from './song-score-formulas.mjs';
|
||||
|
||||
const sourceOrigin = process.argv[2] ?? 'https://dreams.wf-calc.net';
|
||||
const outputDirectory = path.resolve('static/data/charts');
|
||||
@@ -237,14 +238,18 @@ async function worker() {
|
||||
const releaseTime = Number(music?.startTime);
|
||||
const notes = flattenEvents(chartData);
|
||||
const measuredCalibration = scoreCalibrationByKey.get(`${item.songId}::${item.difficulty.toLowerCase()}`);
|
||||
const difficultyLevel = difficultyLevelByKey.get(`${item.songId}::${item.difficulty.toLowerCase()}`) ??
|
||||
(Number.isFinite(item.difficultyLevel) ? item.difficultyLevel : null);
|
||||
const formulaMetadata = formulaScoreMetadata(item.songId, item.difficulty, difficultyLevel, notes.length, music?.liveScoreCoefficientPermil, chartData.source?.sha256);
|
||||
const output = {
|
||||
schemaVersion: 1,
|
||||
songId: item.songId,
|
||||
difficulty: item.difficulty.toLowerCase(),
|
||||
durationSeconds: sourceEntry?.durationSeconds ?? Math.ceil(notes.at(-1)?.timeSeconds ?? 0),
|
||||
// 실측이 있으면 우선하고, 없을 때만 채보 가중치와 계산기 고유 상수로 추정한다.
|
||||
// 검증된 공식 우선. 참고 분모가 없는 채보만 기존 실측/추정으로 대체한다.
|
||||
scoreRatio: measuredCalibration?.scoreRatio ?? calibratedScoreRatio(chartData),
|
||||
scoreCalibrationSource: measuredCalibration ? 'measured' : 'local-estimated-v1',
|
||||
...formulaMetadata,
|
||||
notes,
|
||||
specialStartSeconds: (chartData.sp_activation_events ?? [])
|
||||
.sort((left, right) => left.slot - right.slot)
|
||||
@@ -268,6 +273,7 @@ async function worker() {
|
||||
durationSeconds: output.durationSeconds,
|
||||
scoreRatio: output.scoreRatio,
|
||||
scoreCalibrationSource: output.scoreCalibrationSource,
|
||||
...(output.scoreFormula ? { scoreFormula: output.scoreFormula } : {}),
|
||||
totalNotes: notes.length,
|
||||
singerType: output.singerType,
|
||||
singerCharacterIds: output.singerCharacterIds,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { scoreRatioFromFormula } from '../src/lib/engine/score.ts';
|
||||
|
||||
// Pinned research snapshot, with source URL, retrieval time and SHA-256.
|
||||
const reference = JSON.parse(await readFile(new URL('../docs/research/song-score-formula-reference.json', import.meta.url), 'utf8'));
|
||||
const susReference = JSON.parse(await readFile(new URL('../docs/research/sus-score-formulas.json', import.meta.url), 'utf8'));
|
||||
const rows = [...reference.charts, ...susReference.charts];
|
||||
const formulas = new Map(rows.map((row) => [`${row.songId}.${row.difficulty}`, row]));
|
||||
if (formulas.size !== rows.length) throw new Error('중복 악곡 점수 공식입니다.');
|
||||
|
||||
export function formulaScoreMetadata(songId, difficulty, difficultyLevel, totalNotes, liveScoreCoefficientPermil, susSha256) {
|
||||
const row = formulas.get(`${songId}.${difficulty.toLowerCase()}`);
|
||||
if (!row) return null;
|
||||
if (row.susSha256 && row.susSha256 !== susSha256) throw new Error(`${songId}.${difficulty}: 원본 SUS 해시가 다릅니다.`);
|
||||
if (row.difficultyLevel !== difficultyLevel || row.fullComboNoteCount !== totalNotes ||
|
||||
(liveScoreCoefficientPermil !== undefined && row.liveScoreCoefficientPermil !== liveScoreCoefficientPermil)) {
|
||||
throw new Error(`${songId}.${difficulty}: 채보 또는 마스터 데이터가 점수 공식 참고값과 다릅니다.`);
|
||||
}
|
||||
const scoreFormula = {
|
||||
perfectNoteWeight: row.perfectNoteWeight,
|
||||
liveScoreCoefficientPermil: row.liveScoreCoefficientPermil,
|
||||
difficultyLevel: row.difficultyLevel
|
||||
};
|
||||
return { scoreRatio: scoreRatioFromFormula(scoreFormula), scoreCalibrationSource: 'formula-v1', scoreFormula };
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// Counts the score denominator, not a playable-note timeline.
|
||||
// SUS headers supply playable categories, including generated continuation ticks.
|
||||
// Body family 3 kinds 4/5 supply non-playable bezier / inactive relay points.
|
||||
const alphabet = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
const categories = ['NORMAL', 'FLICK', 'LONG_START', 'LONG_END', 'LONG_FLICK_END', 'LONG_RELAY', 'LONG_CONTINUE'];
|
||||
|
||||
export function susScoreWeight(text) {
|
||||
const headers = new Map();
|
||||
let invisibleRelayCount = 0;
|
||||
let bezierPointCount = 0;
|
||||
let starts = 0, ends = 0, relays = 0;
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const header = line.match(/^#([A-Z_]+)\s+(\S+)\s*$/);
|
||||
if (header) {
|
||||
if (headers.has(header[1])) throw new Error(`Duplicate SUS header: ${header[1]}`);
|
||||
headers.set(header[1], header[2]);
|
||||
}
|
||||
const match = line.match(/^#\d{3}3[0-9a-zA-Z]{2}:(.*)$/);
|
||||
if (!match) continue;
|
||||
const body = match[1].trim();
|
||||
// Repeated channel lines are additive; do not overwrite them in a map.
|
||||
for (let index = 0; index < body.length;) {
|
||||
if (body.startsWith('0x', index)) {
|
||||
const run = body.slice(index + 2, index + 4);
|
||||
if (run.length !== 2 || [...run].some((char) => !alphabet.includes(char))) throw new Error('Malformed SUS empty run');
|
||||
index += 4; // Empty cells have no denominator contribution.
|
||||
continue;
|
||||
}
|
||||
const token = body.slice(index, index + 2);
|
||||
if (token.length !== 2 || [...token].some((char) => !alphabet.includes(char))) throw new Error('Malformed SUS note token');
|
||||
const kind = token[0];
|
||||
if (kind === '1') starts += 1;
|
||||
else if (kind === '2') ends += 1;
|
||||
else if (kind === '3') relays += 1;
|
||||
else if (kind === '4') bezierPointCount += 1;
|
||||
else if (kind === '5') invisibleRelayCount += 1;
|
||||
else if (kind !== '0') throw new Error(`Unsupported SUS hold kind: ${kind}`);
|
||||
index += 2;
|
||||
}
|
||||
}
|
||||
function integer(name) {
|
||||
const raw = headers.get(name);
|
||||
if (!raw || !/^\d+$/.test(raw) || !Number.isSafeInteger(Number(raw))) throw new Error(`Invalid SUS header: ${name}`);
|
||||
return Number(raw);
|
||||
}
|
||||
const counts = Object.fromEntries(categories.map((kind) => [kind, integer(`${kind}_NOTE_COUNT`)]));
|
||||
// Ghosts are visual guides, not invisible hold relays; they add no score weight.
|
||||
integer('GHOST_NOTE_COUNT');
|
||||
for (const unsupported of ['DAMAGE_NOTE_COUNT']) {
|
||||
if (integer(unsupported) !== 0) throw new Error(`Unsupported scoring category: ${unsupported}`);
|
||||
}
|
||||
const fullComboNoteCount = integer('FULL_COMBO_NOTE_COUNT');
|
||||
if (Object.values(counts).reduce((sum, count) => sum + count, 0) !== fullComboNoteCount ||
|
||||
integer('TOTAL_NOTE_COUNT') !== fullComboNoteCount + integer('GHOST_NOTE_COUNT') ||
|
||||
starts !== counts.LONG_START || ends !== counts.LONG_END + counts.LONG_FLICK_END || relays !== counts.LONG_RELAY) {
|
||||
throw new Error('SUS header/body category mismatch');
|
||||
}
|
||||
const songId = headers.get('MUSIC_ID');
|
||||
if (!songId || !/^m\d{4}$/.test(songId)) throw new Error('Invalid SUS music ID');
|
||||
const baseWeight = 1000 * (counts.NORMAL + counts.LONG_START + counts.LONG_END + counts.LONG_FLICK_END)
|
||||
+ 1050 * counts.FLICK + 100 * (counts.LONG_RELAY + counts.LONG_CONTINUE);
|
||||
return {
|
||||
songId, fullComboNoteCount, counts, invisibleRelayCount, bezierPointCount, baseWeight,
|
||||
perfectNoteWeight: baseWeight + 100 * (invisibleRelayCount + bezierPointCount)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { susScoreWeight } from './sus-score-weight.mjs';
|
||||
|
||||
const text = `#MUSIC_ID m0001
|
||||
#FULL_COMBO_NOTE_COUNT 7
|
||||
#TOTAL_NOTE_COUNT 7
|
||||
#NORMAL_NOTE_COUNT 1
|
||||
#FLICK_NOTE_COUNT 1
|
||||
#LONG_START_NOTE_COUNT 1
|
||||
#LONG_END_NOTE_COUNT 0
|
||||
#LONG_FLICK_END_NOTE_COUNT 1
|
||||
#LONG_RELAY_NOTE_COUNT 1
|
||||
#LONG_CONTINUE_NOTE_COUNT 2
|
||||
#DAMAGE_NOTE_COUNT 0
|
||||
#GHOST_NOTE_COUNT 0
|
||||
#000320:140x0K3454
|
||||
#000320:0xft4424
|
||||
#00052:1c
|
||||
`;
|
||||
test('비가시·베지어는 분모에만 포함하고 끝 플릭은 1000으로 집계한다', () => {
|
||||
const result = susScoreWeight(text);
|
||||
assert.equal(result.fullComboNoteCount, 7);
|
||||
assert.equal(result.baseWeight, 4350);
|
||||
assert.equal(result.invisibleRelayCount, 1);
|
||||
assert.equal(result.bezierPointCount, 1);
|
||||
assert.equal(result.perfectNoteWeight, 4550);
|
||||
});
|
||||
test('잘린 토큰·미지원 종류·헤더 불일치는 잘못된 분모 대신 실패한다', () => {
|
||||
for (const changed of [text.replace('0xft', '0x!t'), text + '#001320:4', text + '#001320:64', text.replace('LONG_START_NOTE_COUNT 1', 'LONG_START_NOTE_COUNT 2')]) {
|
||||
assert.throws(() => susScoreWeight(changed));
|
||||
}
|
||||
});
|
||||
test('고스트 가이드는 총 오브젝트 수에는 포함하지만 점수 분모에는 포함하지 않는다', () => {
|
||||
const withGhost = text.replace('GHOST_NOTE_COUNT 0', 'GHOST_NOTE_COUNT 2').replace('TOTAL_NOTE_COUNT 7', 'TOTAL_NOTE_COUNT 9');
|
||||
assert.equal(susScoreWeight(withGhost).perfectNoteWeight, 4550);
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
// 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));
|
||||
@@ -0,0 +1,21 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import { susScoreWeight } from './sus-score-weight.mjs';
|
||||
|
||||
const record = JSON.parse(await readFile(new URL('../docs/research/sus-score-formulas.json', import.meta.url), 'utf8'));
|
||||
const directory = process.argv[2] ?? 'data/raw/sus';
|
||||
for (const row of [...record.validationCharts, ...record.charts]) {
|
||||
const bytes = await readFile(path.join(directory, `chart_${row.songId}_${row.difficulty}.sus`));
|
||||
assert.equal(createHash('sha256').update(bytes).digest('hex'), row.susSha256);
|
||||
const actual = susScoreWeight(bytes.toString('utf8'));
|
||||
assert.equal(actual.songId, row.songId);
|
||||
assert.equal(actual.perfectNoteWeight, row.perfectNoteWeight, `${row.songId}.${row.difficulty}`);
|
||||
if (row.counts) {
|
||||
assert.deepEqual(actual.counts, row.counts);
|
||||
assert.equal(actual.invisibleRelayCount, row.invisibleRelayCount);
|
||||
assert.equal(actual.bezierPointCount, row.bezierPointCount);
|
||||
}
|
||||
}
|
||||
console.log(`${record.validationCharts.length}개 기존 분모와 ${record.charts.length}개 신규 SUS 분모를 재현했습니다.`);
|
||||
Reference in New Issue
Block a user