feat: add EZ and RX as available mods

This commit is contained in:
2026-03-15 23:01:08 +01:00
parent 07f988de58
commit bb6111e64f
21 changed files with 1067 additions and 100 deletions

View File

@@ -103,7 +103,10 @@ async function main() {
starRating: m.sr,
bpm: m.bpm,
totalLength: 120,
mods: m.category === 'NM' || m.category === 'TB' ? [] : [m.category],
mods:
m.category === 'NM' || m.category === 'TB'
? []
: (m.category.match(/.{2}/g)?.filter((c) => c !== 'NM' && c !== 'TB') ?? [m.category]),
title: m.title,
artist: m.artist,
version: 'Normal'

133
src/lib/mods.ts Normal file
View File

@@ -0,0 +1,133 @@
/**
* Shared mod constants and utilities — used by both server and client.
*/
/** All individual mod codes recognized by the system. */
export const INDIVIDUAL_MODS = ['NM', 'HD', 'HR', 'DT', 'FM', 'EZ', 'FL', 'RX', 'TB'] as const;
/** Canonical display order for mod categories (including common compounds). */
export const MOD_ORDER = [
'NM',
'HD',
'HR',
'DT',
'HDHR',
'HDDT',
'FM',
'EZ',
'FL',
'EZFL',
'RX',
'RXHR',
'RXHDHR',
'TB'
];
/** Categories available in the "Add Slot" dropdown. */
export const ADD_CATEGORIES = [
'NM',
'HD',
'HR',
'DT',
'HDHR',
'FM',
'EZ',
'FL',
'EZFL',
'RX',
'RXHR',
'RXHDHR',
'TB'
];
/** Score multipliers per mod (applied when a slot's mods include this mod). */
export const MOD_MULTIPLIERS: Record<string, number> = {
EZ: 2
};
/**
* Parse a compound category string into individual mod codes for the mods array.
* "HDHR" → ["HD", "HR"], "NM" → [], "TB" → [], "RXHDHR" → ["RX", "HD", "HR"]
*/
export function categoryToMods(category: string): string[] {
if (category === 'NM' || category === 'TB') return [];
const mods: string[] = [];
let remaining = category;
while (remaining.length >= 2) {
const chunk = remaining.slice(0, 2);
if (
(INDIVIDUAL_MODS as readonly string[]).includes(chunk) &&
chunk !== 'NM' &&
chunk !== 'TB'
) {
mods.push(chunk);
} else {
return [category]; // fallback: not a valid compound, treat category as single mod
}
remaining = remaining.slice(2);
}
return mods.length > 0 ? mods : [category];
}
/**
* Validate a category string — must be NM, TB, or a valid concatenation of known mods.
*/
export function isValidCategory(category: string): boolean {
if ((INDIVIDUAL_MODS as readonly string[]).includes(category)) return true;
let remaining = category;
while (remaining.length >= 2) {
const chunk = remaining.slice(0, 2);
if (!(INDIVIDUAL_MODS as readonly string[]).includes(chunk)) return false;
remaining = remaining.slice(2);
}
return remaining.length === 0;
}
/** Regex for matching category codes in bulk import (one or more 2-letter mod codes + optional number). */
export const MOD_REGEX = /^((?:NM|HD|HR|DT|FM|EZ|FL|RX|TB)+)\d*$/i;
/**
* Get sort order index for a category. Lower = earlier.
*/
export function categorySort(category: string): number {
const idx = MOD_ORDER.indexOf(category);
if (idx !== -1) return idx;
// For unknown compounds, sort by first component
if (category.length >= 2) {
const first = category.slice(0, 2);
const firstIdx = MOD_ORDER.indexOf(first);
if (firstIdx !== -1) return firstIdx + 0.5;
}
return MOD_ORDER.length;
}
/**
* Calculate score multiplier for a set of mods.
*/
export function getScoreMultiplier(mods: string[]): number {
let multiplier = 1;
for (const mod of mods) {
if (MOD_MULTIPLIERS[mod]) {
multiplier *= MOD_MULTIPLIERS[mod];
}
}
return multiplier;
}
/** Category colors for UI badges. */
export const CATEGORY_COLORS: Record<string, string> = {
NM: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
HD: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
HR: 'bg-red-500/20 text-red-400 border-red-500/30',
DT: 'bg-purple-500/20 text-purple-400 border-purple-500/30',
HDHR: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
HDDT: 'bg-rose-500/20 text-rose-400 border-rose-500/30',
FM: 'bg-green-500/20 text-green-400 border-green-500/30',
EZ: 'bg-cyan-500/20 text-cyan-400 border-cyan-500/30',
FL: 'bg-orange-500/20 text-orange-400 border-orange-500/30',
EZFL: 'bg-teal-500/20 text-teal-400 border-teal-500/30',
RX: 'bg-indigo-500/20 text-indigo-400 border-indigo-500/30',
RXHR: 'bg-sky-500/20 text-sky-400 border-sky-500/30',
RXHDHR: 'bg-violet-500/20 text-violet-400 border-violet-500/30',
TB: 'bg-pink-500/20 text-pink-400 border-pink-500/30'
};

View File

@@ -358,7 +358,7 @@ export class TournamentLobby {
private parsePlayerMessage(sender: string, text: string) {
// !pick NM1, !pick HD2, etc.
const pickMatch = text.match(/^!pick\s+([A-Z]{2})(\d*)/i);
const pickMatch = text.match(/^!pick\s+([A-Z]{2,})(\d*)/i);
if (pickMatch) {
const category = pickMatch[1].toUpperCase();
const num = pickMatch[2] ? parseInt(pickMatch[2]) : 1;

View File

@@ -0,0 +1,172 @@
import { describe, expect, test } from 'vitest';
import {
isMatchPoint,
validateTiebreakerPick,
determineGameWinner,
hasRollTie,
assignPickOrder,
winsNeeded
} from './engine-logic';
describe('winsNeeded', () => {
test('BO1 → 1', () => {
expect(winsNeeded(1)).toBe(1);
});
test('BO3 → 2', () => {
expect(winsNeeded(3)).toBe(2);
});
test('BO5 → 3', () => {
expect(winsNeeded(5)).toBe(3);
});
test('BO7 → 4', () => {
expect(winsNeeded(7)).toBe(4);
});
test('BO9 → 5', () => {
expect(winsNeeded(9)).toBe(5);
});
});
describe('isMatchPoint', () => {
test('both at winsNeeded-1 → true', () => {
expect(isMatchPoint([2, 2], 3)).toBe(true);
});
test('only one at winsNeeded-1 → false', () => {
expect(isMatchPoint([2, 1], 3)).toBe(false);
});
test('neither at match point → false', () => {
expect(isMatchPoint([0, 0], 3)).toBe(false);
});
test('works with 3 participants', () => {
expect(isMatchPoint([1, 1, 1], 2)).toBe(true);
expect(isMatchPoint([1, 1, 0], 2)).toBe(false);
});
});
describe('validateTiebreakerPick', () => {
test('TB at match point → null (allowed)', () => {
expect(validateTiebreakerPick('TB', true)).toBeNull();
});
test('TB before match point → error', () => {
expect(validateTiebreakerPick('TB', false)).toBe(
'Tiebreaker can only be picked at match point'
);
});
test('non-TB at match point → error', () => {
expect(validateTiebreakerPick('HD', true)).toBe(
'Only tiebreaker maps can be picked at match point'
);
});
test('non-TB before match point → null (allowed)', () => {
expect(validateTiebreakerPick('NM', false)).toBeNull();
});
test('null category before match point → null (allowed)', () => {
expect(validateTiebreakerPick(null, false)).toBeNull();
});
test('null category at match point → error (must pick TB)', () => {
expect(validateTiebreakerPick(null, true)).toBe(
'Only tiebreaker maps can be picked at match point'
);
});
});
describe('determineGameWinner', () => {
test('returns participant with highest total score', () => {
const scores = new Map([
['p1', 500_000],
['p2', 800_000]
]);
expect(determineGameWinner(scores)).toBe('p2');
});
test('returns null for empty map', () => {
expect(determineGameWinner(new Map())).toBeNull();
});
test('handles single participant', () => {
const scores = new Map([['p1', 100]]);
expect(determineGameWinner(scores)).toBe('p1');
});
test('first participant wins on equal score (iteration order)', () => {
const scores = new Map([
['p1', 500],
['p2', 500]
]);
// Map iterates in insertion order; p1 is set first, but p2 doesn't beat p1's score
// so p1 stays as winner (> not >=)
expect(determineGameWinner(scores)).toBe('p1');
});
test('handles zero scores', () => {
const scores = new Map([
['p1', 0],
['p2', 0]
]);
expect(determineGameWinner(scores)).toBe('p1');
});
});
describe('hasRollTie', () => {
test('no tie with distinct values', () => {
expect(hasRollTie([50, 80])).toBe(false);
});
test('tie with duplicate values', () => {
expect(hasRollTie([50, 50])).toBe(true);
});
test('tie with some duplicates in larger set', () => {
expect(hasRollTie([10, 50, 50, 90])).toBe(true);
});
test('no tie with single value', () => {
expect(hasRollTie([42])).toBe(false);
});
test('empty array → no tie', () => {
expect(hasRollTie([])).toBe(false);
});
});
describe('assignPickOrder', () => {
test('highest roll gets pickOrder 1', () => {
const rolls = [
{ id: 'a', rollValue: 30 },
{ id: 'b', rollValue: 90 },
{ id: 'c', rollValue: 60 }
];
const result = assignPickOrder(rolls);
expect(result).toEqual([
{ participantId: 'b', pickOrder: 1 },
{ participantId: 'c', pickOrder: 2 },
{ participantId: 'a', pickOrder: 3 }
]);
});
test('does not mutate input', () => {
const rolls = [
{ id: 'a', rollValue: 10 },
{ id: 'b', rollValue: 20 }
];
assignPickOrder(rolls);
expect(rolls[0].id).toBe('a');
});
test('single participant gets order 1', () => {
expect(assignPickOrder([{ id: 'x', rollValue: 50 }])).toEqual([
{ participantId: 'x', pickOrder: 1 }
]);
});
});

View File

@@ -0,0 +1,64 @@
/**
* Pure match engine logic — no DB, no side effects.
*/
/** Check if all participants are at match point (winsNeeded - 1). */
export function isMatchPoint(scores: number[], winsNeeded: number): boolean {
return scores.every((s) => s === winsNeeded - 1);
}
/** Validate a map pick against tiebreaker rules. Returns an error string or null. */
export function validateTiebreakerPick(
slotCategory: string | null,
allAtMatchPoint: boolean
): string | null {
if (slotCategory === 'TB' && !allAtMatchPoint) {
return 'Tiebreaker can only be picked at match point';
}
if (allAtMatchPoint && slotCategory !== 'TB') {
return 'Only tiebreaker maps can be picked at match point';
}
return null;
}
/**
* Given per-participant aggregate scores for a game,
* return the participant ID with the highest total, or null on empty input.
*/
export function determineGameWinner(participantScores: Map<string, number>): string | null {
let winnerId: string | null = null;
let highScore = -1;
for (const [pid, total] of participantScores) {
if (total > highScore) {
highScore = total;
winnerId = pid;
}
}
return winnerId;
}
/**
* Detect a tie in roll values — returns true if any two participants
* rolled the same value.
*/
export function hasRollTie(rollValues: number[]): boolean {
return new Set(rollValues).size < rollValues.length;
}
/**
* Sort participants by roll value descending and return pick-order assignments.
* Returns array of { participantId, pickOrder }.
*/
export function assignPickOrder(
rolls: { id: string; rollValue: number }[]
): { participantId: string; pickOrder: number }[] {
const sorted = [...rolls].sort((a, b) => b.rollValue - a.rollValue);
return sorted.map((r, i) => ({ participantId: r.id, pickOrder: i + 1 }));
}
/** Calculate wins needed from bestOf config. */
export function winsNeeded(bestOf: number): number {
return Math.ceil(bestOf / 2);
}

View File

@@ -70,6 +70,11 @@ vi.mock('./rating', () => ({
selectMappoolForRating: vi.fn()
}));
vi.mock('./engine-logic', async () => {
const actual = await vi.importActual<typeof import('./engine-logic')>('./engine-logic');
return { ...actual };
});
const { pickMap } = await import('./engine');
describe('pickMap', () => {
@@ -112,4 +117,56 @@ describe('pickMap', () => {
);
expect(mocks.db.insert).not.toHaveBeenCalled();
});
test('rejects already-played maps', async () => {
mocks.db.query.matchGame.findMany.mockResolvedValue([{ mappoolSlotId: 'slot-1' }]);
await expect(pickMap('match-1', 'p1', 'slot-1')).rejects.toThrow(
'This map has already been played'
);
expect(mocks.db.insert).not.toHaveBeenCalled();
});
test('rejects non-TB maps at match point', async () => {
// Set both participants to match point (score = winsNeeded - 1 = 2)
mocks.db.query.matchParticipant.findMany.mockResolvedValue([
{ id: 'p1', pickOrder: 1, score: 2 },
{ id: 'p2', pickOrder: 2, score: 2 }
]);
mocks.helpers.getExpectedPicker.mockReturnValue({ id: 'p1', pickOrder: 1, score: 2 });
mocks.db.query.mappoolSlot.findFirst.mockResolvedValue({
id: 'slot-nm',
category: 'NM'
});
await expect(pickMap('match-1', 'p1', 'slot-nm')).rejects.toThrow(
'Only tiebreaker maps can be picked at match point'
);
});
test('allows TB pick at match point', async () => {
mocks.db.query.matchParticipant.findMany.mockResolvedValue([
{ id: 'p1', pickOrder: 1, score: 2 },
{ id: 'p2', pickOrder: 2, score: 2 }
]);
mocks.helpers.getExpectedPicker.mockReturnValue({ id: 'p1', pickOrder: 1, score: 2 });
mocks.db.query.mappoolSlot.findFirst.mockResolvedValue({
id: 'slot-tb',
category: 'TB'
});
const mockGame = { id: 'game-1' };
mocks.db.insert.mockReturnValue({
values: vi.fn(() => ({ returning: vi.fn(() => Promise.resolve([mockGame])) }))
});
mocks.db.update.mockReturnValue({
set: vi.fn(() => ({ where: vi.fn(() => Promise.resolve()) }))
});
mocks.helpers.getMatchFull.mockResolvedValue({});
const result = await pickMap('match-1', 'p1', 'slot-tb');
expect(result).toEqual(mockGame);
});
});

View File

@@ -13,6 +13,14 @@ import { MATCH_STATES, GAME_STATES, type MatchConfig } from './types';
import { notifyMatchCreated, notifyMatchFinished } from '$lib/server/discord/client';
import { updateElo } from './rating';
import { getExpectedPicker, getMatchOrThrow, assertState, getMatchFull } from './helpers';
import {
validateTiebreakerPick,
determineGameWinner,
hasRollTie,
assignPickOrder,
winsNeeded as calcWinsNeeded
} from './engine-logic';
import { getScoreMultiplier } from '$lib/mods';
export { getMatchFull, getMatchOrThrow };
@@ -118,9 +126,8 @@ export async function submitRoll(matchId: string, participantId: string, value:
}));
const rollValues = finalRolls.map((p) => p.rollValue);
const hasTie = new Set(rollValues).size < rollValues.length;
if (hasTie) {
if (hasRollTie(rollValues)) {
// Reset all rolls so everyone must roll again
for (const p of participants) {
await db
@@ -130,13 +137,13 @@ export async function submitRoll(matchId: string, participantId: string, value:
}
// Stay in ROLLING state
} else {
const sorted = [...finalRolls].sort((a, b) => b.rollValue - a.rollValue);
const order = assignPickOrder(finalRolls);
for (let i = 0; i < sorted.length; i++) {
for (const { participantId, pickOrder } of order) {
await db
.update(matchParticipant)
.set({ pickOrder: i + 1 })
.where(eq(matchParticipant.id, sorted[i].id));
.set({ pickOrder })
.where(eq(matchParticipant.id, participantId));
}
await db.update(match).set({ state: MATCH_STATES.PICKING }).where(eq(match.id, matchId));
@@ -172,20 +179,16 @@ export async function pickMap(matchId: string, participantId: string, mappoolSlo
// ── Tiebreaker restrictions ──
const config = m.config as MatchConfig;
const winsNeeded = Math.ceil(config.bestOf / 2);
const allAtMatchPoint = participants.every((p) => p.score === winsNeeded - 1);
const needed = calcWinsNeeded(config.bestOf);
const allAtMatchPoint = participants.every((p) => p.score === needed - 1);
const slot = await db.query.mappoolSlot.findFirst({
where: eq(mappoolSlot.id, mappoolSlotId)
});
if (slot) {
if (slot.category === 'TB' && !allAtMatchPoint) {
throw new Error('Tiebreaker can only be picked at match point');
}
if (allAtMatchPoint && slot.category !== 'TB') {
throw new Error('Only tiebreaker maps can be picked at match point');
}
const tbError = validateTiebreakerPick(slot.category, allAtMatchPoint);
if (tbError) throw new Error(tbError);
}
const [game] = await db
@@ -224,17 +227,22 @@ export async function submitGameScores(
}[]
) {
const game = await db.query.matchGame.findFirst({
where: eq(matchGame.id, matchGameId)
where: eq(matchGame.id, matchGameId),
with: { slot: true }
});
if (!game) throw new Error('Game not found');
if (game.state !== GAME_STATES.PLAYING) throw new Error(`Game is ${game.state}, not PLAYING`);
// Apply score multiplier based on slot mods (e.g. EZ → 2x)
const slotMods = game.slot?.mods ?? [];
const multiplier = getScoreMultiplier(slotMods);
for (const s of scores) {
await db.insert(matchGameScore).values({
matchGameId,
playerId: s.playerId,
score: s.score,
score: multiplier !== 1 ? Math.round(s.score * multiplier) : s.score,
accuracy: s.accuracy ?? 0,
maxCombo: s.maxCombo ?? 0,
count300: s.count300 ?? 0,
@@ -258,14 +266,7 @@ export async function submitGameScores(
participantScores.set(pid, (participantScores.get(pid) ?? 0) + s.score);
}
let winnerId: string | null = null;
let highScore = -1;
for (const [pid, total] of participantScores) {
if (total > highScore) {
highScore = total;
winnerId = pid;
}
}
const winnerId = determineGameWinner(participantScores);
await db
.update(matchGame)
@@ -290,13 +291,13 @@ export async function submitGameScores(
const m = await getMatchOrThrow(game.matchId);
const config = m.config as MatchConfig;
const winsNeeded = Math.ceil(config.bestOf / 2);
const needed = calcWinsNeeded(config.bestOf);
const participants = await db.query.matchParticipant.findMany({
where: eq(matchParticipant.matchId, game.matchId)
});
const matchWinner = participants.find((p) => p.score >= winsNeeded);
const matchWinner = participants.find((p) => p.score >= needed);
if (matchWinner) {
await db

View File

@@ -0,0 +1,86 @@
import { describe, expect, test, vi } from 'vitest';
vi.mock('$lib/server/db', () => ({
db: { query: { match: { findFirst: vi.fn() } } }
}));
vi.mock('$lib/server/db/schema', () => ({ match: {} }));
vi.mock('drizzle-orm', () => ({ eq: vi.fn(() => ({})) }));
const { getExpectedPicker, assertState } = await import('./helpers');
describe('getExpectedPicker', () => {
const participants = [
{ id: 'p1', pickOrder: 2, score: 0 },
{ id: 'p2', pickOrder: 1, score: 0 }
];
test('returns participant with lowest pickOrder on game 0', () => {
const result = getExpectedPicker(participants, 0);
expect(result.id).toBe('p2');
});
test('alternates picker based on game count', () => {
expect(getExpectedPicker(participants, 0).id).toBe('p2');
expect(getExpectedPicker(participants, 1).id).toBe('p1');
expect(getExpectedPicker(participants, 2).id).toBe('p2');
expect(getExpectedPicker(participants, 3).id).toBe('p1');
});
test('wraps around with more than 2 participants', () => {
const three = [
{ id: 'a', pickOrder: 1, score: 0 },
{ id: 'b', pickOrder: 2, score: 0 },
{ id: 'c', pickOrder: 3, score: 0 }
];
expect(getExpectedPicker(three, 0).id).toBe('a');
expect(getExpectedPicker(three, 1).id).toBe('b');
expect(getExpectedPicker(three, 2).id).toBe('c');
expect(getExpectedPicker(three, 3).id).toBe('a');
});
test('sorts by pickOrder regardless of array order', () => {
const reversed = [
{ id: 'z', pickOrder: 3, score: 0 },
{ id: 'y', pickOrder: 1, score: 0 },
{ id: 'x', pickOrder: 2, score: 0 }
];
expect(getExpectedPicker(reversed, 0).id).toBe('y');
expect(getExpectedPicker(reversed, 1).id).toBe('x');
expect(getExpectedPicker(reversed, 2).id).toBe('z');
});
test('treats null pickOrder as 99 (last)', () => {
const withNull = [
{ id: 'a', pickOrder: null, score: 0 },
{ id: 'b', pickOrder: 1, score: 0 }
];
expect(getExpectedPicker(withNull, 0).id).toBe('b');
expect(getExpectedPicker(withNull, 1).id).toBe('a');
});
test('does not mutate the input array', () => {
const input = [
{ id: 'p1', pickOrder: 2, score: 0 },
{ id: 'p2', pickOrder: 1, score: 0 }
];
getExpectedPicker(input, 0);
expect(input[0].id).toBe('p1');
expect(input[1].id).toBe('p2');
});
});
describe('assertState', () => {
test('does not throw when states match', () => {
expect(() => assertState('PICKING', 'PICKING')).not.toThrow();
});
test('throws with descriptive message when states differ', () => {
expect(() => assertState('LOBBY', 'PICKING')).toThrow('Match is LOBBY, expected PICKING');
});
test('throws for each mismatch combination', () => {
expect(() => assertState('CREATED', 'LOBBY')).toThrow('Match is CREATED, expected LOBBY');
expect(() => assertState('FINISHED', 'PLAYING')).toThrow('Match is FINISHED, expected PLAYING');
});
});

View File

@@ -487,9 +487,9 @@ function setupChatHandlers(matchId: string, lobby: TournamentLobby) {
return;
}
const labelMatch = slotLabel.match(/^([A-Z]{2})(\d+)$/);
const labelMatch = slotLabel.match(/^([A-Z]{2,})(\d+)$/);
if (!labelMatch) {
await lobby.chat(`${username}: Invalid slot. Use format like NM1, HD2, DT1.`);
await lobby.chat(`${username}: Invalid slot. Use format like NM1, HD2, HDHR1, RX1.`);
return;
}
const category = labelMatch[1];
@@ -596,8 +596,9 @@ export async function playPickedMap(matchId: string, matchGameId: string) {
await lobby.setMap(slot.beatmapId);
await sleep(1000);
// Set mods — FM category always uses Freemod regardless of stored mods array
await lobby.setMods(slot.category === 'FM' ? ['FM'] : slot.mods);
// Set mods — if FM is in the slot mods, enable Freemod
const slotMods = slot.mods ?? [];
await lobby.setMods(slotMods.includes('FM') ? ['FM'] : slotMods);
await sleep(500);
// Announce with context: what map, current score, what to do

View File

@@ -0,0 +1,101 @@
import { describe, expect, test } from 'vitest';
import { getAverageMappoolSR, findClosestPair, scoreMappools } from './queue-math';
describe('getAverageMappoolSR', () => {
test('averages star ratings', () => {
const slots = [{ starRating: 4 }, { starRating: 6 }];
expect(getAverageMappoolSR(slots)).toBe(5);
});
test('treats null starRating as 0', () => {
const slots = [{ starRating: 4 }, { starRating: null }];
expect(getAverageMappoolSR(slots)).toBe(2);
});
test('returns 0 for empty slots', () => {
expect(getAverageMappoolSR([])).toBe(0);
});
test('single slot returns its value', () => {
expect(getAverageMappoolSR([{ starRating: 5.5 }])).toBe(5.5);
});
test('handles many slots', () => {
const slots = Array.from({ length: 10 }, (_, i) => ({ starRating: i + 1 }));
expect(getAverageMappoolSR(slots)).toBe(5.5);
});
});
describe('findClosestPair', () => {
test('returns null for fewer than 2 entries', () => {
expect(findClosestPair([])).toBeNull();
expect(findClosestPair([{ elo: 1500 }])).toBeNull();
});
test('returns the only pair when exactly 2 entries', () => {
const queue = [{ elo: 1000 }, { elo: 2000 }];
const pair = findClosestPair(queue);
expect(pair).toEqual([{ elo: 1000 }, { elo: 2000 }]);
});
test('finds closest pair in sorted list', () => {
const queue = [
{ elo: 1000, id: 'a' },
{ elo: 1100, id: 'b' },
{ elo: 1500, id: 'c' },
{ elo: 2000, id: 'd' }
];
const pair = findClosestPair(queue)!;
expect(pair[0].id).toBe('a');
expect(pair[1].id).toBe('b');
});
test('picks first closest pair when tied gap', () => {
const queue = [{ elo: 100 }, { elo: 200 }, { elo: 300 }];
const pair = findClosestPair(queue)!;
expect(pair[0].elo).toBe(100);
expect(pair[1].elo).toBe(200);
});
test('handles equal ELOs', () => {
const queue = [{ elo: 1500 }, { elo: 1500 }, { elo: 2000 }];
const pair = findClosestPair(queue)!;
expect(pair[0].elo).toBe(1500);
expect(pair[1].elo).toBe(1500);
});
});
describe('scoreMappools', () => {
const pool = (sr: number[]) => ({
id: sr.join('-'),
slots: sr.map((s) => ({ starRating: s }))
});
test('returns null for empty pool list', () => {
expect(scoreMappools([], 5)).toBeNull();
});
test('returns null when all pools have empty slots', () => {
expect(scoreMappools([{ id: 'x', slots: [] }], 5)).toBeNull();
});
test('returns the closest pool to target', () => {
const pools = [pool([3, 3]), pool([5, 5]), pool([7, 7])];
expect(scoreMappools(pools, 5)!.id).toBe('5-5');
});
test('returns closest when target is between pools', () => {
const pools = [pool([2, 2]), pool([6, 6])];
expect(scoreMappools(pools, 3)!.id).toBe('2-2');
});
test('handles single pool', () => {
const pools = [pool([4, 4])];
expect(scoreMappools(pools, 10)!.id).toBe('4-4');
});
test('skips pools with empty slots', () => {
const pools = [{ id: 'empty', slots: [] as { starRating: number | null }[] }, pool([5, 5])];
expect(scoreMappools(pools, 5)!.id).toBe('5-5');
});
});

View File

@@ -0,0 +1,53 @@
/**
* Pure queue matching math — no DB, no side effects.
*/
/** Compute the average star-rating of a mappool's slots. */
export function getAverageMappoolSR(slots: { starRating: number | null }[]): number {
if (slots.length === 0) return 0;
return slots.reduce((acc, slot) => acc + (slot.starRating ?? 0), 0) / slots.length;
}
/** Find the index pair with the smallest ELO gap in a sorted list. */
export function findClosestPair<T extends { elo: number }>(queue: T[]): [T, T] | null {
if (queue.length < 2) return null;
let bestPair: [T, T] | null = null;
let smallestGap = Infinity;
for (let i = 0; i < queue.length - 1; i++) {
const gap = Math.abs(queue[i].elo - queue[i + 1].elo);
if (gap < smallestGap) {
smallestGap = gap;
bestPair = [queue[i], queue[i + 1]];
}
}
return bestPair;
}
/**
* Score a list of mappools against a target star-rating and return
* the best match. Picks the pool with the smallest distance to target.
* If multiple pools are within 1 star of the best, returns any of them.
*/
export function scoreMappools<T extends { slots: { starRating: number | null }[] }>(
pools: T[],
targetStars: number
): T | null {
const nonEmpty = pools.filter((p) => p.slots.length > 0);
if (nonEmpty.length === 0) return null;
let bestPool = nonEmpty[0];
let bestDiff = Math.abs(getAverageMappoolSR(bestPool.slots) - targetStars);
for (let i = 1; i < nonEmpty.length; i++) {
const diff = Math.abs(getAverageMappoolSR(nonEmpty[i].slots) - targetStars);
if (diff < bestDiff) {
bestDiff = diff;
bestPool = nonEmpty[i];
}
}
return bestPool;
}

View File

@@ -43,6 +43,11 @@ vi.mock('$lib/server/match/engine', () => ({
createMatch: vi.fn()
}));
vi.mock('./queue-math', async () => {
const actual = await vi.importActual<typeof import('./queue-math')>('./queue-math');
return { ...actual };
});
const { joinQueue } = await import('./queue');
function mockFindRecentActiveMatchRows(rows: Array<{ id: string }>) {

View File

@@ -10,6 +10,7 @@ import {
import { and, asc, desc, eq, inArray, lt, sql } from 'drizzle-orm';
import { MATCH_STATES } from './types';
import { createMatch } from './engine';
import { findClosestPair, getAverageMappoolSR as getAvgSR } from './queue-math';
const QUEUE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
@@ -128,17 +129,7 @@ async function tryMatchFromQueue() {
if (queue.length < 2) return null;
// Find closest ELO pair
let bestPair: [(typeof queue)[0], (typeof queue)[0]] | null = null;
let smallestGap = Infinity;
for (let i = 0; i < queue.length - 1; i++) {
const gap = Math.abs(queue[i].elo - queue[i + 1].elo);
if (gap < smallestGap) {
smallestGap = gap;
bestPair = [queue[i], queue[i + 1]];
}
}
const bestPair = findClosestPair(queue);
if (!bestPair) return null;
// Pick mappool based on average player rating
@@ -187,21 +178,12 @@ async function selectMappoolForRating(avgElo: number) {
let bestPool = pools[0];
for (const pool of pools) {
const avgSR = getAverageMappoolSR(pool);
const avgSR = getAvgSR(pool.slots);
const diff = Math.abs(avgSR - targetStars);
if (diff < Math.abs(getAverageMappoolSR(bestPool) - targetStars)) {
if (diff < Math.abs(getAvgSR(bestPool.slots) - targetStars)) {
bestPool = pool;
}
}
return bestPool;
}
function getAverageMappoolSR(pool: { slots: { starRating: number | null }[] }): number {
return (
pool.slots.reduce(
(acc: number, slot: { starRating: number | null }) => acc + (slot.starRating ?? 0),
0
) / pool.slots.length
);
}

View File

@@ -0,0 +1,125 @@
import { describe, expect, test } from 'vitest';
import {
rankToElo,
getKFactor,
expectedScore,
computeNewElo,
eloToTargetStars
} from './rating-math';
describe('rankToElo', () => {
test('rank 1 → 3500', () => {
expect(rankToElo(1)).toBe(3500);
});
test('rank 1000 → 2000', () => {
expect(rankToElo(1000)).toBe(2000);
});
test('rank 100000 → 1000', () => {
expect(rankToElo(100_000)).toBe(1000);
});
test('rank 1000000 → 500', () => {
expect(rankToElo(1_000_000)).toBe(500);
});
test('extremely high rank clamps to 0', () => {
expect(rankToElo(10 ** 8)).toBe(0);
});
test('rank 0 or negative returns 1000 (fallback)', () => {
expect(rankToElo(0)).toBe(1000);
expect(rankToElo(-5)).toBe(1000);
});
});
describe('getKFactor', () => {
test('K=40 for new players (<10 games)', () => {
expect(getKFactor(0)).toBe(40);
expect(getKFactor(9)).toBe(40);
});
test('K=32 for intermediate players (10-29 games)', () => {
expect(getKFactor(10)).toBe(32);
expect(getKFactor(29)).toBe(32);
});
test('K=24 for experienced players (30+ games)', () => {
expect(getKFactor(30)).toBe(24);
expect(getKFactor(100)).toBe(24);
});
});
describe('expectedScore', () => {
test('equal ratings → ~0.5', () => {
expect(expectedScore(1500, 1500)).toBeCloseTo(0.5, 5);
});
test('400 point advantage → ~0.909', () => {
expect(expectedScore(1900, 1500)).toBeCloseTo(0.909, 2);
});
test('400 point disadvantage → ~0.091', () => {
expect(expectedScore(1100, 1500)).toBeCloseTo(0.091, 2);
});
test('symmetric: E(a,b) + E(b,a) ≈ 1', () => {
const a = expectedScore(1800, 1400);
const b = expectedScore(1400, 1800);
expect(a + b).toBeCloseTo(1, 10);
});
});
describe('computeNewElo', () => {
test('winning against equal opponent increases ELO', () => {
const newElo = computeNewElo(1500, 1500, true, 0);
expect(newElo).toBeGreaterThan(1500);
});
test('losing against equal opponent decreases ELO', () => {
const newElo = computeNewElo(1500, 1500, false, 0);
expect(newElo).toBeLessThan(1500);
});
test('winning against much weaker opponent gives small gain', () => {
const gain = computeNewElo(2000, 1200, true, 50) - 2000;
expect(gain).toBeLessThan(5);
expect(gain).toBeGreaterThanOrEqual(0);
});
test('losing against much weaker opponent gives large loss', () => {
const loss = 2000 - computeNewElo(2000, 1200, false, 50);
expect(loss).toBeGreaterThan(20);
});
test('ELO never goes below 0', () => {
// With only 5 ELO vs 3000, expected score is ~1 so loss penalty is tiny
// Use a case where the loss actually pushes below 0
expect(computeNewElo(1, 1, false, 0)).toBe(0); // 1 - 40*0.5 = -19 → clamped to 0
});
test('K factor affects magnitude of change', () => {
const newPlayer = computeNewElo(1500, 1500, true, 0); // K=40
const veteran = computeNewElo(1500, 1500, true, 50); // K=24
expect(newPlayer - 1500).toBeGreaterThan(veteran - 1500);
});
});
describe('eloToTargetStars', () => {
test('ELO 0 → 2 stars', () => {
expect(eloToTargetStars(0)).toBe(2);
});
test('ELO 700 → 3 stars', () => {
expect(eloToTargetStars(700)).toBe(3);
});
test('ELO 3500 → 7 stars', () => {
expect(eloToTargetStars(3500)).toBe(7);
});
test('ELO 1400 → 4 stars', () => {
expect(eloToTargetStars(1400)).toBe(4);
});
});

View File

@@ -0,0 +1,40 @@
/**
* Pure ELO / rating math — no DB, no side effects.
*/
/** Convert an osu! global rank to an initial ELO rating. */
export function rankToElo(rank: number): number {
if (rank <= 0) return 1000;
const raw = 3500 - Math.log10(rank) * 500;
return Math.round(Math.max(0, Math.min(3500, raw)));
}
/** Pick the K-factor based on how many games a player has completed. */
export function getKFactor(gamesPlayed: number): number {
if (gamesPlayed < 10) return 40;
if (gamesPlayed < 30) return 32;
return 24;
}
/** ELO expected-score formula: probability player beats opponent avg. */
export function expectedScore(playerElo: number, opponentAvgElo: number): number {
return 1 / (1 + Math.pow(10, (opponentAvgElo - playerElo) / 400));
}
/** Compute new ELO after a match result. */
export function computeNewElo(
playerElo: number,
opponentAvgElo: number,
won: boolean,
gamesPlayed: number
): number {
const K = getKFactor(gamesPlayed);
const expected = expectedScore(playerElo, opponentAvgElo);
const actual = won ? 1 : 0;
return Math.max(0, Math.round(playerElo + K * (actual - expected)));
}
/** Map an average ELO to a target star-rating for mappool selection. */
export function eloToTargetStars(avgElo: number): number {
return 2 + avgElo / 700;
}

View File

@@ -4,6 +4,7 @@ import { playerRating, matchParticipantPlayer } from '$lib/server/db/schema';
import { account } from '$lib/server/db/auth.schema';
import { eq, and, isNotNull } from 'drizzle-orm';
import { getUser } from '$lib/server/osu/api';
import { rankToElo, computeNewElo } from './rating-math';
// ── Initial Rating ──────────────────────────────────────────────────────
@@ -26,12 +27,7 @@ export async function calculateInitialElo(
return { elo: 1000, osuRank: null };
}
// elo = 3500 - log10(rank) * 500
// rank 1 → 3500, rank 1000 → 2000, rank 100k → 1000, rank 1M → 500
const rawElo = 3500 - Math.log10(rank) * 500;
const elo = Math.round(Math.max(0, Math.min(3500, rawElo)));
return { elo, osuRank: rank };
return { elo: rankToElo(rank), osuRank: rank };
} catch (err) {
log.rating.warn({ err, userId }, 'failed to fetch osu! rank, defaulting to 1000');
return { elo: 1000, osuRank: null };
@@ -79,11 +75,7 @@ export async function updateElo(participants: { id: string; teamId: string }[],
for (const { userId, rating } of sides.get(p.id)!) {
const gamesPlayed = rating.wins + rating.losses;
const K = gamesPlayed < 10 ? 40 : gamesPlayed < 30 ? 32 : 24;
const expected = 1 / (1 + Math.pow(10, (opponentAvg - rating.elo) / 400));
const actual = isWinner ? 1 : 0;
const newElo = Math.max(0, Math.round(rating.elo + K * (actual - expected)));
const newElo = computeNewElo(rating.elo, opponentAvg, isWinner, gamesPlayed);
await db
.update(playerRating)

View File

@@ -0,0 +1,86 @@
import { describe, expect, test, vi, beforeEach } from 'vitest';
// Mock the env module before importing permissions
vi.mock('$env/dynamic/private', () => ({
env: { ROOT_ADMIN_EMAIL: 'root@example.com' }
}));
vi.mock('$lib/server/db', () => ({
db: { query: { user: { findFirst: vi.fn() } }, update: vi.fn() }
}));
vi.mock('$lib/server/db/auth.schema', () => ({ user: {} }));
vi.mock('drizzle-orm', () => ({ eq: vi.fn(() => ({})) }));
// SvelteKit error function mock — throws an object with status/body like the real one
vi.mock('@sveltejs/kit', () => ({
error: (status: number, body: string) => {
throw { status, body };
}
}));
const { hasRole, isRootAdmin } = await import('./permissions');
describe('hasRole', () => {
test('player meets player requirement', () => {
expect(hasRole('player', 'player')).toBe(true);
});
test('admin meets player requirement', () => {
expect(hasRole('admin', 'player')).toBe(true);
});
test('admin meets referee requirement', () => {
expect(hasRole('admin', 'referee')).toBe(true);
});
test('player does not meet referee requirement', () => {
expect(hasRole('player', 'referee')).toBe(false);
});
test('player does not meet admin requirement', () => {
expect(hasRole('player', 'admin')).toBe(false);
});
test('referee meets referee requirement', () => {
expect(hasRole('referee', 'referee')).toBe(true);
});
test('referee does not meet admin requirement', () => {
expect(hasRole('referee', 'admin')).toBe(false);
});
test('undefined role defaults to player level (0)', () => {
expect(hasRole(undefined, 'player')).toBe(true);
expect(hasRole(undefined, 'referee')).toBe(false);
});
test('unknown role string defaults to player level', () => {
expect(hasRole('bogus', 'player')).toBe(true);
expect(hasRole('bogus', 'referee')).toBe(false);
});
});
describe('isRootAdmin', () => {
test('returns true for matching email', () => {
expect(isRootAdmin('root@example.com')).toBe(true);
});
test('case insensitive comparison', () => {
expect(isRootAdmin('ROOT@EXAMPLE.COM')).toBe(true);
expect(isRootAdmin('Root@Example.Com')).toBe(true);
});
test('returns false for different email', () => {
expect(isRootAdmin('other@example.com')).toBe(false);
});
test('returns false for null/undefined', () => {
expect(isRootAdmin(null)).toBe(false);
expect(isRootAdmin(undefined)).toBe(false);
});
test('returns false for empty string', () => {
expect(isRootAdmin('')).toBe(false);
});
});

View File

@@ -0,0 +1,75 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { checkRateLimit } from './rate-limit';
describe('checkRateLimit', () => {
beforeEach(() => {
// Reset the module-level store between tests by reimporting would be ideal,
// but we can use unique keys per test instead
vi.restoreAllMocks();
});
test('first request is always allowed', () => {
const result = checkRateLimit('test-first', 5, 60_000);
expect(result.ok).toBe(true);
});
test('allows up to the limit', () => {
for (let i = 0; i < 3; i++) {
const result = checkRateLimit('test-limit', 3, 60_000);
expect(result.ok).toBe(true);
}
});
test('rejects after limit is exceeded', () => {
for (let i = 0; i < 5; i++) {
checkRateLimit('test-exceed', 5, 60_000);
}
const result = checkRateLimit('test-exceed', 5, 60_000);
expect(result.ok).toBe(false);
expect(result.retryAfter).toBeGreaterThan(0);
expect(result.retryAfter).toBeLessThanOrEqual(60);
});
test('resets after window expires', () => {
vi.useFakeTimers();
try {
checkRateLimit('test-reset', 1, 1000);
const blocked = checkRateLimit('test-reset', 1, 1000);
expect(blocked.ok).toBe(false);
vi.advanceTimersByTime(1001);
const afterReset = checkRateLimit('test-reset', 1, 1000);
expect(afterReset.ok).toBe(true);
} finally {
vi.useRealTimers();
}
});
test('different keys are independent', () => {
checkRateLimit('key-a', 1, 60_000);
const blockedA = checkRateLimit('key-a', 1, 60_000);
expect(blockedA.ok).toBe(false);
const allowedB = checkRateLimit('key-b', 1, 60_000);
expect(allowedB.ok).toBe(true);
});
test('retryAfter is in seconds', () => {
vi.useFakeTimers();
try {
checkRateLimit('test-retry', 1, 10_000);
const result = checkRateLimit('test-retry', 1, 10_000);
expect(result.ok).toBe(false);
expect(result.retryAfter).toBe(10);
vi.advanceTimersByTime(5000);
const result2 = checkRateLimit('test-retry', 1, 10_000);
expect(result2.ok).toBe(false);
expect(result2.retryAfter).toBe(5);
} finally {
vi.useRealTimers();
}
});
});

View File

@@ -6,6 +6,7 @@ import { error, redirect } from '@sveltejs/kit';
import { getBeatmap } from '$lib/server/osu/api';
import { proxyImage } from '$lib/server/storage/r2';
import { requireAuth } from '$lib/server/permissions';
import { categoryToMods, isValidCategory, MOD_REGEX } from '$lib/mods';
import type { PageServerLoad, Actions } from './$types';
export const load: PageServerLoad = async ({ params, locals }) => {
@@ -163,7 +164,7 @@ export const actions: Actions = {
starRating: beatmap.difficulty_rating,
bpm: beatmap.bpm,
totalLength: beatmap.total_length,
mods: category === 'NM' || category === 'TB' ? [] : [category],
mods: categoryToMods(category),
// Cached metadata — all R2 URLs
title: beatmap.beatmapset.title,
artist: beatmap.beatmapset.artist,
@@ -242,7 +243,7 @@ export const actions: Actions = {
newTargetOrder.splice(clampedIndex, 0, slot);
// Update the moved slot's category and mods
const newMods = targetCategory === 'NM' || targetCategory === 'TB' ? [] : [targetCategory];
const newMods = categoryToMods(targetCategory);
await db
.update(mappoolSlot)
.set({ category: targetCategory, mods: newMods })
@@ -285,7 +286,6 @@ export const actions: Actions = {
.split(/\n/)
.map((l) => l.trim())
.filter(Boolean);
const validMods = ['NM', 'HD', 'HR', 'DT', 'FM', 'TB'];
const errors: string[] = [];
let successCount = 0;
@@ -298,14 +298,13 @@ export const actions: Actions = {
}
// Detect column order: mod+id or id+mod
const modRegex = /^(NM|HD|HR|DT|FM|TB)\d*$/i;
let modCode: string;
let beatmapRaw: string;
if (modRegex.test(parts[0])) {
if (MOD_REGEX.test(parts[0])) {
modCode = parts[0];
beatmapRaw = parts[1];
} else if (modRegex.test(parts[parts.length - 1])) {
} else if (MOD_REGEX.test(parts[parts.length - 1])) {
modCode = parts[parts.length - 1];
beatmapRaw = parts[0];
} else {
@@ -313,7 +312,7 @@ export const actions: Actions = {
continue;
}
const modMatch = modCode.match(modRegex)!;
const modMatch = modCode.match(MOD_REGEX)!;
const category = modMatch[1].toUpperCase();
let beatmapId = beatmapRaw;
@@ -358,7 +357,7 @@ export const actions: Actions = {
starRating: beatmap.difficulty_rating,
bpm: beatmap.bpm,
totalLength: beatmap.total_length,
mods: category === 'NM' || category === 'TB' ? [] : [category],
mods: categoryToMods(category),
title: beatmap.beatmapset.title,
artist: beatmap.beatmapset.artist,
version: beatmap.version,

View File

@@ -3,9 +3,16 @@
import { invalidateAll } from '$app/navigation';
import { page } from '$app/state';
import { ADD_CATEGORIES, CATEGORY_COLORS, categorySort } from '$lib/mods';
const { data } = $props();
const categories = ['NM', 'HD', 'HR', 'DT', 'FM', 'TB'];
// Merge predefined categories with any custom categories found in the pool
const categories = $derived(() => {
const existing = new Set(data.pool.slots.map((s: any) => s.category));
const merged = new Set([...ADD_CATEGORIES, ...existing]);
return [...merged].sort((a, b) => categorySort(a) - categorySort(b));
});
let addingCategory = $state('NM');
let beatmapInput = $state('');
@@ -142,14 +149,7 @@
return `${m}:${s.toString().padStart(2, '0')}`;
}
const categoryColors: Record<string, string> = {
NM: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
HD: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
HR: 'bg-red-500/20 text-red-400 border-red-500/30',
DT: 'bg-purple-500/20 text-purple-400 border-purple-500/30',
FM: 'bg-green-500/20 text-green-400 border-green-500/30',
TB: 'bg-pink-500/20 text-pink-400 border-pink-500/30'
};
const categoryColors = CATEGORY_COLORS;
</script>
<svelte:head>
@@ -305,7 +305,7 @@
bind:value={addingCategory}
class="rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
>
{#each categories as cat}
{#each categories() as cat}
<option value={cat}>{cat}</option>
{/each}
</select>
@@ -378,7 +378,7 @@
<textarea
name="data"
bind:value={bulkData}
placeholder={'NM1\t181589\nNM2\t2603758\nHR1\t948389\nTB\t457061'}
placeholder={'NM1\t181589\nNM2\t2603758\nHDHR1\t948389\nRXHR1\t123456\nTB\t457061'}
rows="6"
class="w-full rounded-md border border-border bg-surface-700 px-3 py-2 font-mono text-sm text-text-primary placeholder:text-text-secondary/40 focus:border-accent focus:outline-none"
></textarea>
@@ -419,7 +419,7 @@
<!-- Map List -->
<div class="mt-6 flex flex-col gap-6">
{#if hasAnySlots || isDragging}
{#each categories as category (category)}
{#each categories() as category (category)}
{@const slots = data.pool.slots.filter((s) => s.category === category)}
{#if slots.length > 0 || isDragging}
<div>
@@ -473,10 +473,10 @@
{draggedSlot?.id === slot.id
? 'border-accent/50 opacity-40'
: 'border-border hover:border-accent/30'}"
style={dropTarget?.category === category && dropTarget.index === i
style={dropTarget?.category === category && dropTarget?.index === i
? `box-shadow: 0 -3px 0 0 var(--color-accent, #8b5cf6); margin-top: 4px;`
: dropTarget?.category === category &&
dropTarget.index === slots.length &&
dropTarget?.index === slots.length &&
i === slots.length - 1
? `box-shadow: 0 3px 0 0 var(--color-accent, #8b5cf6); margin-bottom: 4px;`
: ''}

View File

@@ -43,16 +43,9 @@
// Can picks actually be made right now?
const pickingPhase = $derived(m.state === 'PICKING');
const catColors: Record<string, string> = {
NM: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
HD: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
HR: 'bg-red-500/20 text-red-400 border-red-500/30',
DT: 'bg-purple-500/20 text-purple-400 border-purple-500/30',
FM: 'bg-green-500/20 text-green-400 border-green-500/30',
TB: 'bg-pink-500/20 text-pink-400 border-pink-500/30'
};
import { CATEGORY_COLORS, categorySort } from '$lib/mods';
const MOD_ORDER = ['NM', 'HD', 'HR', 'DT', 'FM', 'TB'];
const catColors = CATEGORY_COLORS;
// Group mappool slots by category
type MappoolSlot = {
@@ -72,10 +65,9 @@
for (const cat of Object.keys(groups)) {
groups[cat].sort((a, b) => a.orderInCategory - b.orderInCategory);
}
// Return entries sorted by canonical mod order
return Object.fromEntries(
MOD_ORDER.filter((cat) => groups[cat]).map((cat) => [cat, groups[cat]])
);
// Return entries sorted by canonical mod order (supports compound categories)
const sortedKeys = Object.keys(groups).sort((a, b) => categorySort(a) - categorySort(b));
return Object.fromEntries(sortedKeys.map((cat) => [cat, groups[cat]]));
});
// Match duration