67 lines
3.4 KiB
JavaScript
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)
|
|
};
|
|
}
|