From bb6111e64f175f0b48ebd648d6b3ccc3392e7964 Mon Sep 17 00:00:00 2001 From: Stan Runge Date: Sun, 15 Mar 2026 23:01:08 +0100 Subject: [PATCH] feat: add EZ and RX as available mods --- scripts/seed-db.ts | 5 +- src/lib/mods.ts | 133 ++++++++++++++ src/lib/server/bancho/client.ts | 2 +- src/lib/server/match/engine-logic.test.ts | 172 ++++++++++++++++++ src/lib/server/match/engine-logic.ts | 64 +++++++ src/lib/server/match/engine.test.ts | 57 ++++++ src/lib/server/match/engine.ts | 53 +++--- src/lib/server/match/helpers.test.ts | 86 +++++++++ src/lib/server/match/orchestrator.ts | 9 +- src/lib/server/match/queue-math.test.ts | 101 ++++++++++ src/lib/server/match/queue-math.ts | 53 ++++++ src/lib/server/match/queue.test.ts | 5 + src/lib/server/match/queue.ts | 26 +-- src/lib/server/match/rating-math.test.ts | 125 +++++++++++++ src/lib/server/match/rating-math.ts | 40 ++++ src/lib/server/match/rating.ts | 14 +- src/lib/server/permissions.test.ts | 86 +++++++++ src/lib/server/rate-limit.test.ts | 75 ++++++++ .../(app)/mappools/[id]/+page.server.ts | 15 +- src/routes/(app)/mappools/[id]/+page.svelte | 28 +-- src/routes/(app)/matches/[id]/+page.svelte | 18 +- 21 files changed, 1067 insertions(+), 100 deletions(-) create mode 100644 src/lib/mods.ts create mode 100644 src/lib/server/match/engine-logic.test.ts create mode 100644 src/lib/server/match/engine-logic.ts create mode 100644 src/lib/server/match/helpers.test.ts create mode 100644 src/lib/server/match/queue-math.test.ts create mode 100644 src/lib/server/match/queue-math.ts create mode 100644 src/lib/server/match/rating-math.test.ts create mode 100644 src/lib/server/match/rating-math.ts create mode 100644 src/lib/server/permissions.test.ts create mode 100644 src/lib/server/rate-limit.test.ts diff --git a/scripts/seed-db.ts b/scripts/seed-db.ts index 422733f..1794987 100644 --- a/scripts/seed-db.ts +++ b/scripts/seed-db.ts @@ -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' diff --git a/src/lib/mods.ts b/src/lib/mods.ts new file mode 100644 index 0000000..c5b6945 --- /dev/null +++ b/src/lib/mods.ts @@ -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 = { + 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 = { + 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' +}; diff --git a/src/lib/server/bancho/client.ts b/src/lib/server/bancho/client.ts index 16df853..fc29da5 100644 --- a/src/lib/server/bancho/client.ts +++ b/src/lib/server/bancho/client.ts @@ -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; diff --git a/src/lib/server/match/engine-logic.test.ts b/src/lib/server/match/engine-logic.test.ts new file mode 100644 index 0000000..e8e3dc0 --- /dev/null +++ b/src/lib/server/match/engine-logic.test.ts @@ -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 } + ]); + }); +}); diff --git a/src/lib/server/match/engine-logic.ts b/src/lib/server/match/engine-logic.ts new file mode 100644 index 0000000..74b3654 --- /dev/null +++ b/src/lib/server/match/engine-logic.ts @@ -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 | 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); +} diff --git a/src/lib/server/match/engine.test.ts b/src/lib/server/match/engine.test.ts index a56034d..b4ac430 100644 --- a/src/lib/server/match/engine.test.ts +++ b/src/lib/server/match/engine.test.ts @@ -70,6 +70,11 @@ vi.mock('./rating', () => ({ selectMappoolForRating: vi.fn() })); +vi.mock('./engine-logic', async () => { + const actual = await vi.importActual('./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); + }); }); diff --git a/src/lib/server/match/engine.ts b/src/lib/server/match/engine.ts index 1f353ce..2c91f04 100644 --- a/src/lib/server/match/engine.ts +++ b/src/lib/server/match/engine.ts @@ -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 diff --git a/src/lib/server/match/helpers.test.ts b/src/lib/server/match/helpers.test.ts new file mode 100644 index 0000000..899d23b --- /dev/null +++ b/src/lib/server/match/helpers.test.ts @@ -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'); + }); +}); diff --git a/src/lib/server/match/orchestrator.ts b/src/lib/server/match/orchestrator.ts index 93e559f..da47cc6 100644 --- a/src/lib/server/match/orchestrator.ts +++ b/src/lib/server/match/orchestrator.ts @@ -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 diff --git a/src/lib/server/match/queue-math.test.ts b/src/lib/server/match/queue-math.test.ts new file mode 100644 index 0000000..a2917ba --- /dev/null +++ b/src/lib/server/match/queue-math.test.ts @@ -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'); + }); +}); diff --git a/src/lib/server/match/queue-math.ts b/src/lib/server/match/queue-math.ts new file mode 100644 index 0000000..09fcd5b --- /dev/null +++ b/src/lib/server/match/queue-math.ts @@ -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(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( + 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; +} diff --git a/src/lib/server/match/queue.test.ts b/src/lib/server/match/queue.test.ts index d6200c9..3d0662f 100644 --- a/src/lib/server/match/queue.test.ts +++ b/src/lib/server/match/queue.test.ts @@ -43,6 +43,11 @@ vi.mock('$lib/server/match/engine', () => ({ createMatch: vi.fn() })); +vi.mock('./queue-math', async () => { + const actual = await vi.importActual('./queue-math'); + return { ...actual }; +}); + const { joinQueue } = await import('./queue'); function mockFindRecentActiveMatchRows(rows: Array<{ id: string }>) { diff --git a/src/lib/server/match/queue.ts b/src/lib/server/match/queue.ts index 3722537..d5e1040 100644 --- a/src/lib/server/match/queue.ts +++ b/src/lib/server/match/queue.ts @@ -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 - ); -} diff --git a/src/lib/server/match/rating-math.test.ts b/src/lib/server/match/rating-math.test.ts new file mode 100644 index 0000000..1a7992e --- /dev/null +++ b/src/lib/server/match/rating-math.test.ts @@ -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); + }); +}); diff --git a/src/lib/server/match/rating-math.ts b/src/lib/server/match/rating-math.ts new file mode 100644 index 0000000..d8fd88a --- /dev/null +++ b/src/lib/server/match/rating-math.ts @@ -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; +} diff --git a/src/lib/server/match/rating.ts b/src/lib/server/match/rating.ts index 4457efd..fbfec9f 100644 --- a/src/lib/server/match/rating.ts +++ b/src/lib/server/match/rating.ts @@ -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) diff --git a/src/lib/server/permissions.test.ts b/src/lib/server/permissions.test.ts new file mode 100644 index 0000000..76d1858 --- /dev/null +++ b/src/lib/server/permissions.test.ts @@ -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); + }); +}); diff --git a/src/lib/server/rate-limit.test.ts b/src/lib/server/rate-limit.test.ts new file mode 100644 index 0000000..15c7f03 --- /dev/null +++ b/src/lib/server/rate-limit.test.ts @@ -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(); + } + }); +}); diff --git a/src/routes/(app)/mappools/[id]/+page.server.ts b/src/routes/(app)/mappools/[id]/+page.server.ts index ba141ef..aaace7f 100644 --- a/src/routes/(app)/mappools/[id]/+page.server.ts +++ b/src/routes/(app)/mappools/[id]/+page.server.ts @@ -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, diff --git a/src/routes/(app)/mappools/[id]/+page.svelte b/src/routes/(app)/mappools/[id]/+page.svelte index 730cd40..bac1d25 100644 --- a/src/routes/(app)/mappools/[id]/+page.svelte +++ b/src/routes/(app)/mappools/[id]/+page.svelte @@ -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 = { - 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; @@ -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} {/each} @@ -378,7 +378,7 @@ @@ -419,7 +419,7 @@
{#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}
@@ -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;` : ''} diff --git a/src/routes/(app)/matches/[id]/+page.svelte b/src/routes/(app)/matches/[id]/+page.svelte index 2f830b3..2b50f06 100644 --- a/src/routes/(app)/matches/[id]/+page.svelte +++ b/src/routes/(app)/matches/[id]/+page.svelte @@ -43,16 +43,9 @@ // Can picks actually be made right now? const pickingPhase = $derived(m.state === 'PICKING'); - const catColors: Record = { - 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