Files
HolodoriCalc/scripts/sus-score-weight.mjs
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

67 lines
3.4 KiB
JavaScript

// 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)
};
}