feat: add EZ and RX as available mods
This commit is contained in:
@@ -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
133
src/lib/mods.ts
Normal 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'
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
172
src/lib/server/match/engine-logic.test.ts
Normal file
172
src/lib/server/match/engine-logic.test.ts
Normal 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 }
|
||||
]);
|
||||
});
|
||||
});
|
||||
64
src/lib/server/match/engine-logic.ts
Normal file
64
src/lib/server/match/engine-logic.ts
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
86
src/lib/server/match/helpers.test.ts
Normal file
86
src/lib/server/match/helpers.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
101
src/lib/server/match/queue-math.test.ts
Normal file
101
src/lib/server/match/queue-math.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
53
src/lib/server/match/queue-math.ts
Normal file
53
src/lib/server/match/queue-math.ts
Normal 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;
|
||||
}
|
||||
@@ -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 }>) {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
125
src/lib/server/match/rating-math.test.ts
Normal file
125
src/lib/server/match/rating-math.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
40
src/lib/server/match/rating-math.ts
Normal file
40
src/lib/server/match/rating-math.ts
Normal 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;
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
86
src/lib/server/permissions.test.ts
Normal file
86
src/lib/server/permissions.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
75
src/lib/server/rate-limit.test.ts
Normal file
75
src/lib/server/rate-limit.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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;`
|
||||
: ''}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user