feat: add tournaments and validation and security

This commit is contained in:
2026-03-26 15:36:22 +01:00
parent d76e8922cc
commit 25d2d1af4a
47 changed files with 5175 additions and 58 deletions

View File

@@ -7,6 +7,7 @@
"dependencies": {
"bancho.js": "^0.12.3",
"discord.js": "^14.25.1",
"zod": "^4.3.6",
},
"devDependencies": {
"@commitlint/cli": "^20.5.0",

View File

@@ -74,6 +74,7 @@
},
"dependencies": {
"bancho.js": "^0.12.3",
"discord.js": "^14.25.1"
"discord.js": "^14.25.1",
"zod": "^4.3.6"
}
}

View File

@@ -898,12 +898,10 @@ async function seedMappools(users: UserData[]): Promise<PoolData[]> {
function selectPoolForElo(pools: PoolData[], avgElo: number): PoolData {
const targetSr = Math.max(2, Math.min(8, 2 + avgElo / 700));
let best = pools[0];
let bestDist = Math.abs(best.avgSr - targetSr);
let bestDist = Math.abs(pools[0].avgSr - targetSr);
for (const p of pools) {
const dist = Math.abs(p.avgSr - targetSr);
if (dist < bestDist) {
best = p;
bestDist = dist;
}
}
@@ -996,8 +994,8 @@ async function seedMatches(
let matchState: string;
const winnerId: string | null = null;
let finishedAt: Date | null = null;
let startedAt: Date | null = null;
let gamesToPlay = 0;
let startedAt: Date | null;
let gamesToPlay: number;
if (state === 'FINISHED') {
matchState = 'FINISHED';

View File

@@ -29,6 +29,10 @@ if (!building) {
.then(({ initDMHandler }) => initDMHandler())
.catch((err) => log.dm.warn({ err: err.message }, 'DM handler init skipped'));
import('$lib/server/tournament/scheduler')
.then(({ startTournamentScheduler }) => startTournamentScheduler())
.catch((err) => log.tournament.warn({ err: err.message }, 'Tournament scheduler init skipped'));
process.on('unhandledRejection', (reason) => {
log.http.error({ err: reason }, 'Unhandled rejection');
});
@@ -87,9 +91,22 @@ const handleRequestLogging: Handle = async ({ event, resolve }) => {
// ── Auth + rate limiting ──
const handleBetterAuth: Handle = async ({ event, resolve }) => {
// Rate limiting for API routes: 600 req/min per IP
// Rate limiting for API routes
if (event.url.pathname.startsWith('/api/')) {
const ip = event.getClientAddress();
// Stricter limit for auth endpoints: 10 req/min per IP
if (event.url.pathname.startsWith('/api/auth/')) {
const { ok, retryAfter } = checkRateLimit(`auth:${ip}`, 10, 60_000);
if (!ok) {
return new Response('Too Many Requests', {
status: 429,
headers: { 'Retry-After': String(retryAfter ?? 60), 'Content-Type': 'text/plain' }
});
}
}
// General API limit: 600 req/min per IP
const { ok, retryAfter } = checkRateLimit(`api:${ip}`, 600, 60_000);
if (!ok) {
return new Response('Too Many Requests', {

View File

@@ -106,6 +106,22 @@
height="7"
/><rect x="3" y="14" width="7" height="7" /></svg
>
{:else if name === 'tournaments'}
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6" /><path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18" /><path
d="M4 22h16"
/><path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22" /><path
d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22"
/><path d="M18 2H6v7a6 6 0 0 0 12 0V2Z" /></svg
>
{:else if name === 'matches'}
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -368,7 +384,7 @@
<!-- Nav -->
<nav class="mt-0 flex flex-1 flex-col gap-0.5 px-3">
{#each [{ href: '/', label: 'Dashboard', icon: 'dashboard' }, { href: '/matches', label: 'Matches', icon: 'matches' }, { href: '/mappools', label: 'Mappools', icon: 'mappools' }, { href: '/teams', label: 'Teams', icon: 'teams' }, { href: '/leaderboard', label: 'Leaderboard', icon: 'leaderboard' }] as item (item.href)}
{#each [{ href: '/', label: 'Dashboard', icon: 'dashboard' }, { href: '/tournaments', label: 'Tournaments', icon: 'tournaments' }, { href: '/matches', label: 'Matches', icon: 'matches' }, { href: '/mappools', label: 'Mappools', icon: 'mappools' }, { href: '/teams', label: 'Teams', icon: 'teams' }, { href: '/leaderboard', label: 'Leaderboard', icon: 'leaderboard' }] as item (item.href)}
<a
href={item.href}
class="font-500 flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors {isActive(

View File

@@ -254,6 +254,183 @@ export const matchInvite = pgTable(
]
);
// ── Tournaments ────────────────────────────────────────────────────────
export const tournament = pgTable(
'tournament',
{
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
description: text('description'),
format: text('format').notNull(), // single_elim | double_elim | groups_bracket
state: text('state').default('DRAFT').notNull(),
config: jsonb('config').notNull(),
maxSlots: integer('max_slots').notNull(),
bannerUrl: text('banner_url'),
createdBy: text('created_by').notNull(),
registrationOpenAt: timestamp('registration_open_at', { withTimezone: true }),
registrationCloseAt: timestamp('registration_close_at', { withTimezone: true }),
startAt: timestamp('start_at', { withTimezone: true }),
finishedAt: timestamp('finished_at', { withTimezone: true }),
winnerId: uuid('winner_id').references(() => team.id),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull()
},
(t) => [
index('idx_tournament_state').on(t.state),
index('idx_tournament_created_at').on(t.createdAt)
]
);
// ── Tournament Staff ───────────────────────────────────────────────────
export const tournamentStaff = pgTable(
'tournament_staff',
{
id: uuid('id').primaryKey().defaultRandom(),
tournamentId: uuid('tournament_id')
.notNull()
.references(() => tournament.id, { onDelete: 'cascade' }),
userId: text('user_id').notNull(),
role: text('role').notNull() // organizer | admin | referee | pooler | streamer
},
(t) => [
uniqueIndex('idx_tstaff_tournament_user').on(t.tournamentId, t.userId),
index('idx_tstaff_tournament').on(t.tournamentId)
]
);
// ── Tournament Rounds ──────────────────────────────────────────────────
export const tournamentRound = pgTable(
'tournament_round',
{
id: uuid('id').primaryKey().defaultRandom(),
tournamentId: uuid('tournament_id')
.notNull()
.references(() => tournament.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
abbreviation: text('abbreviation'),
roundOrder: integer('round_order').notNull(),
bestOf: integer('best_of').notNull(),
mappoolId: uuid('mappool_id').references(() => mappool.id),
bracketType: text('bracket_type'), // winners | losers | grand_final | group | null
scheduledAt: timestamp('scheduled_at', { withTimezone: true })
},
(t) => [index('idx_tround_tournament').on(t.tournamentId)]
);
// ── Tournament Registrations ───────────────────────────────────────────
export const tournamentRegistration = pgTable(
'tournament_registration',
{
id: uuid('id').primaryKey().defaultRandom(),
tournamentId: uuid('tournament_id')
.notNull()
.references(() => tournament.id, { onDelete: 'cascade' }),
teamId: uuid('team_id')
.notNull()
.references(() => team.id),
userId: text('user_id').notNull(),
seed: integer('seed'),
qualifierScore: real('qualifier_score'),
status: text('status').default('registered').notNull(),
registeredAt: timestamp('registered_at', { withTimezone: true }).defaultNow().notNull()
},
(t) => [
uniqueIndex('idx_treg_tournament_team').on(t.tournamentId, t.teamId),
index('idx_treg_tournament').on(t.tournamentId)
]
);
// ── Tournament Matches (bracket structure) ─────────────────────────────
export const tournamentMatch = pgTable(
'tournament_match',
{
id: uuid('id').primaryKey().defaultRandom(),
tournamentId: uuid('tournament_id')
.notNull()
.references(() => tournament.id, { onDelete: 'cascade' }),
roundId: uuid('round_id')
.notNull()
.references(() => tournamentRound.id, { onDelete: 'cascade' }),
matchId: uuid('match_id').references(() => match.id),
bracketPosition: integer('bracket_position').notNull(),
winnerGoesToId: uuid('winner_goes_to_id'),
loserGoesToId: uuid('loser_goes_to_id'),
winnerSlot: integer('winner_slot'),
loserSlot: integer('loser_slot'),
team1Id: uuid('team1_id').references(() => team.id),
team2Id: uuid('team2_id').references(() => team.id),
winnerId: uuid('winner_id').references(() => team.id),
scheduledAt: timestamp('scheduled_at', { withTimezone: true }),
state: text('state').default('PENDING').notNull()
},
(t) => [
index('idx_tmatch_tournament').on(t.tournamentId),
index('idx_tmatch_round').on(t.roundId),
index('idx_tmatch_match').on(t.matchId),
index('idx_tmatch_state').on(t.state)
]
);
// ── Tournament Groups ──────────────────────────────────────────────────
export const tournamentGroup = pgTable(
'tournament_group',
{
id: uuid('id').primaryKey().defaultRandom(),
tournamentId: uuid('tournament_id')
.notNull()
.references(() => tournament.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
groupOrder: integer('group_order').notNull()
},
(t) => [index('idx_tgroup_tournament').on(t.tournamentId)]
);
// ── Tournament Group Entries ───────────────────────────────────────────
export const tournamentGroupEntry = pgTable(
'tournament_group_entry',
{
id: uuid('id').primaryKey().defaultRandom(),
groupId: uuid('group_id')
.notNull()
.references(() => tournamentGroup.id, { onDelete: 'cascade' }),
teamId: uuid('team_id')
.notNull()
.references(() => team.id),
wins: integer('wins').default(0).notNull(),
losses: integer('losses').default(0).notNull(),
mapWins: integer('map_wins').default(0).notNull(),
mapLosses: integer('map_losses').default(0).notNull(),
seed: integer('seed')
},
(t) => [
uniqueIndex('idx_tge_group_team').on(t.groupId, t.teamId),
index('idx_tge_group').on(t.groupId)
]
);
// ── Tournament Qualifier Scores ────────────────────────────────────────
export const tournamentQualifierScore = pgTable(
'tournament_qualifier_score',
{
id: uuid('id').primaryKey().defaultRandom(),
tournamentId: uuid('tournament_id')
.notNull()
.references(() => tournament.id, { onDelete: 'cascade' }),
teamId: uuid('team_id')
.notNull()
.references(() => team.id),
mappoolSlotId: uuid('mappool_slot_id')
.notNull()
.references(() => mappoolSlot.id),
totalScore: integer('total_score').notNull(),
accuracy: real('accuracy'),
playedAt: timestamp('played_at', { withTimezone: true }).defaultNow().notNull()
},
(t) => [
index('idx_tqs_tournament_team').on(t.tournamentId, t.teamId),
index('idx_tqs_tournament').on(t.tournamentId)
]
);
// ── Relations ───────────────────────────────────────────────────────────
export const teamRelations = relations(team, ({ many }) => ({
@@ -325,3 +502,93 @@ export const matchInviteRelations = relations(matchInvite, ({ one }) => ({
mappool: one(mappool, { fields: [matchInvite.mappoolId], references: [mappool.id] }),
match: one(match, { fields: [matchInvite.matchId], references: [match.id] })
}));
// ── Tournament Relations ────────────────────────────────────────────────
export const tournamentRelations = relations(tournament, ({ many }) => ({
rounds: many(tournamentRound),
registrations: many(tournamentRegistration),
matches: many(tournamentMatch),
groups: many(tournamentGroup),
staff: many(tournamentStaff),
qualifierScores: many(tournamentQualifierScore)
}));
export const tournamentStaffRelations = relations(tournamentStaff, ({ one }) => ({
tournament: one(tournament, {
fields: [tournamentStaff.tournamentId],
references: [tournament.id]
})
}));
export const tournamentRoundRelations = relations(tournamentRound, ({ one, many }) => ({
tournament: one(tournament, {
fields: [tournamentRound.tournamentId],
references: [tournament.id]
}),
mappool: one(mappool, { fields: [tournamentRound.mappoolId], references: [mappool.id] }),
matches: many(tournamentMatch)
}));
export const tournamentRegistrationRelations = relations(tournamentRegistration, ({ one }) => ({
tournament: one(tournament, {
fields: [tournamentRegistration.tournamentId],
references: [tournament.id]
}),
team: one(team, { fields: [tournamentRegistration.teamId], references: [team.id] })
}));
export const tournamentMatchRelations = relations(tournamentMatch, ({ one }) => ({
tournament: one(tournament, {
fields: [tournamentMatch.tournamentId],
references: [tournament.id]
}),
round: one(tournamentRound, {
fields: [tournamentMatch.roundId],
references: [tournamentRound.id]
}),
match: one(match, { fields: [tournamentMatch.matchId], references: [match.id] }),
team1: one(team, {
fields: [tournamentMatch.team1Id],
references: [team.id],
relationName: 'tournamentMatchTeam1'
}),
team2: one(team, {
fields: [tournamentMatch.team2Id],
references: [team.id],
relationName: 'tournamentMatchTeam2'
}),
winner: one(team, {
fields: [tournamentMatch.winnerId],
references: [team.id],
relationName: 'tournamentMatchWinner'
})
}));
export const tournamentGroupRelations = relations(tournamentGroup, ({ one, many }) => ({
tournament: one(tournament, {
fields: [tournamentGroup.tournamentId],
references: [tournament.id]
}),
entries: many(tournamentGroupEntry)
}));
export const tournamentGroupEntryRelations = relations(tournamentGroupEntry, ({ one }) => ({
group: one(tournamentGroup, {
fields: [tournamentGroupEntry.groupId],
references: [tournamentGroup.id]
}),
team: one(team, { fields: [tournamentGroupEntry.teamId], references: [team.id] })
}));
export const tournamentQualifierScoreRelations = relations(tournamentQualifierScore, ({ one }) => ({
tournament: one(tournament, {
fields: [tournamentQualifierScore.tournamentId],
references: [tournament.id]
}),
team: one(team, { fields: [tournamentQualifierScore.teamId], references: [team.id] }),
slot: one(mappoolSlot, {
fields: [tournamentQualifierScore.mappoolSlotId],
references: [mappoolSlot.id]
})
}));

View File

@@ -29,5 +29,6 @@ export const log = {
rating: logger.child({ subsystem: 'rating' }),
admin: logger.child({ subsystem: 'admin' }),
timeout: logger.child({ subsystem: 'timeout' }),
http: logger.child({ subsystem: 'http' })
http: logger.child({ subsystem: 'http' }),
tournament: logger.child({ subsystem: 'tournament' })
};

View File

@@ -21,6 +21,7 @@ import {
winsNeeded as calcWinsNeeded
} from './engine-logic';
import { getScoreMultiplier } from '$lib/mods';
import { tryAdvanceTournament } from '$lib/server/tournament/progression';
export { getMatchFull, getMatchOrThrow };
@@ -315,6 +316,9 @@ export async function submitGameScores(
const finishedMatch = await getMatchFull(game.matchId);
notifyMatchFinished(finishedMatch).catch(() => {});
// advance tournament bracket if this is a tournament match
tryAdvanceTournament(game.matchId).catch(() => {});
} else {
await db.update(match).set({ state: MATCH_STATES.PICKING }).where(eq(match.id, game.matchId));
}

View File

@@ -77,7 +77,9 @@ export async function createInvite(opts: {
sendDM(
u.name,
`Match invite: ${title}. Accept/decline at https://esports.vash.software/matches or reply !accept ${invite.id.slice(0, 8)}`
).catch(() => {}); // Silent best-effort
).catch((err) => {
log.invites.warn({ err, username: u.name }, 'Failed to send Bancho DM for invite');
});
}
}

View File

@@ -0,0 +1,242 @@
import { describe, it, expect } from 'vitest';
import {
standardBracketSeeding,
nextPowerOf2,
generateSingleElimBracket,
generateDoubleElimBracket,
generateGroupAssignments,
generateRoundRobinPairings,
generateGroupStageMatches
} from './bracket';
describe('nextPowerOf2', () => {
it('returns correct values', () => {
expect(nextPowerOf2(1)).toBe(1);
expect(nextPowerOf2(2)).toBe(2);
expect(nextPowerOf2(3)).toBe(4);
expect(nextPowerOf2(5)).toBe(8);
expect(nextPowerOf2(8)).toBe(8);
expect(nextPowerOf2(9)).toBe(16);
expect(nextPowerOf2(17)).toBe(32);
});
});
describe('standardBracketSeeding', () => {
it('returns [1, 2] for size 2', () => {
expect(standardBracketSeeding(2)).toEqual([1, 2]);
});
it('returns correct seeding for size 4', () => {
const seeds = standardBracketSeeding(4);
expect(seeds).toEqual([1, 4, 2, 3]);
});
it('returns correct seeding for size 8', () => {
const seeds = standardBracketSeeding(8);
// 1v8, 4v5, 2v7, 3v6
expect(seeds).toEqual([1, 8, 4, 5, 2, 7, 3, 6]);
});
it('ensures seed 1 and 2 are on opposite halves for size 16', () => {
const seeds = standardBracketSeeding(16);
expect(seeds).toHaveLength(16);
// seed 1 should be in top half, seed 2 in bottom half
const topHalf = seeds.slice(0, 8);
const bottomHalf = seeds.slice(8);
expect(topHalf).toContain(1);
expect(bottomHalf).toContain(2);
});
});
describe('generateSingleElimBracket', () => {
it('generates correct bracket for 4 teams', () => {
const slots = generateSingleElimBracket(4);
// 4 teams, 2 rounds, 3 total matches
expect(slots).toHaveLength(3);
const round0 = slots.filter((s) => s.roundIndex === 0);
const round1 = slots.filter((s) => s.roundIndex === 1);
expect(round0).toHaveLength(2);
expect(round1).toHaveLength(1);
// finals has no winnerGoesTo
expect(round1[0].winnerGoesTo).toBeNull();
// no byes
expect(slots.every((s) => !s.isBye)).toBe(true);
});
it('generates correct bracket for 8 teams', () => {
const slots = generateSingleElimBracket(8);
// 8 teams, 3 rounds, 7 matches
expect(slots).toHaveLength(7);
const round0 = slots.filter((s) => s.roundIndex === 0);
expect(round0).toHaveLength(4);
// all first round matches have seeds assigned
for (const slot of round0) {
expect(slot.team1Seed).not.toBeNull();
expect(slot.team2Seed).not.toBeNull();
}
});
it('handles 6 teams with BYEs', () => {
const slots = generateSingleElimBracket(6);
// bracket size = 8, so 7 matches
expect(slots).toHaveLength(7);
const byes = slots.filter((s) => s.isBye);
// 2 byes (seeds 7 and 8 don't exist)
expect(byes).toHaveLength(2);
// verify top 2 seeds get byes
const byeSeeds = byes.flatMap((s) => [s.team1Seed, s.team2Seed]);
expect(byeSeeds).toContain(1);
expect(byeSeeds).toContain(2);
});
it('generates correct progression links', () => {
const slots = generateSingleElimBracket(4);
const round0 = slots.filter((s) => s.roundIndex === 0);
// match 0 winner goes to round 1, position 0, slot 1
expect(round0[0].winnerGoesTo).toEqual({
roundIndex: 1,
bracketPosition: 0,
slot: 1
});
// match 1 winner goes to round 1, position 0, slot 2
expect(round0[1].winnerGoesTo).toEqual({
roundIndex: 1,
bracketPosition: 0,
slot: 2
});
});
it('all bracket types are winners', () => {
const slots = generateSingleElimBracket(8);
expect(slots.every((s) => s.bracketType === 'winners')).toBe(true);
});
});
describe('generateDoubleElimBracket', () => {
it('generates correct bracket for 4 teams', () => {
const slots = generateDoubleElimBracket(4);
const wb = slots.filter((s) => s.bracketType === 'winners');
const lb = slots.filter((s) => s.bracketType === 'losers');
const gf = slots.filter((s) => s.bracketType === 'grand_final');
// WB: 2 rounds (2 + 1 = 3 matches)
expect(wb).toHaveLength(3);
// LB: 2*(2-1) = 2 rounds
expect(lb.length).toBeGreaterThan(0);
// GF: 2 matches (GF1 + GF2)
expect(gf).toHaveLength(2);
});
it('generates correct bracket for 8 teams', () => {
const slots = generateDoubleElimBracket(8);
const wb = slots.filter((s) => s.bracketType === 'winners');
const lb = slots.filter((s) => s.bracketType === 'losers');
const gf = slots.filter((s) => s.bracketType === 'grand_final');
// WB: 3 rounds (4 + 2 + 1 = 7 matches)
expect(wb).toHaveLength(7);
// GF: always 2
expect(gf).toHaveLength(2);
// LB: 2*(3-1) = 4 rounds
const lbRounds = new Set(lb.map((s) => s.roundIndex));
expect(lbRounds.size).toBe(4);
});
it('WB losers have loserGoesTo set', () => {
const slots = generateDoubleElimBracket(8);
const wb = slots.filter((s) => s.bracketType === 'winners');
// all WB matches should have loserGoesTo pointing to LB
for (const slot of wb) {
expect(slot.loserGoesTo).not.toBeNull();
}
});
it('GF1 links to GF2', () => {
const slots = generateDoubleElimBracket(8);
const gf1 = slots.find((s) => s.bracketType === 'grand_final' && s.roundIndex === 0);
expect(gf1).toBeDefined();
expect(gf1!.winnerGoesTo).toEqual({
roundIndex: 1,
bracketPosition: 0,
slot: 1
});
});
});
describe('generateGroupAssignments', () => {
it('snake seeds correctly for 8 teams, 2 groups', () => {
const groups = generateGroupAssignments(8, 2);
expect(groups).toHaveLength(2);
// Group A: 1, 4, 5, 8
expect(groups[0].teamSeeds).toEqual([1, 4, 5, 8]);
// Group B: 2, 3, 6, 7
expect(groups[1].teamSeeds).toEqual([2, 3, 6, 7]);
});
it('snake seeds correctly for 16 teams, 4 groups', () => {
const groups = generateGroupAssignments(16, 4);
expect(groups).toHaveLength(4);
// Group A: 1, 8, 9, 16
expect(groups[0].teamSeeds).toEqual([1, 8, 9, 16]);
// Group B: 2, 7, 10, 15
expect(groups[1].teamSeeds).toEqual([2, 7, 10, 15]);
});
it('handles uneven groups', () => {
const groups = generateGroupAssignments(7, 2);
// one group gets 4, other gets 3
const total = groups.reduce((sum, g) => sum + g.teamSeeds.length, 0);
expect(total).toBe(7);
});
});
describe('generateRoundRobinPairings', () => {
it('generates correct number of pairings for 4 teams', () => {
const pairings = generateRoundRobinPairings([1, 2, 3, 4]);
// C(4,2) = 6 matches
expect(pairings).toHaveLength(6);
});
it('generates correct number of pairings for 3 teams', () => {
const pairings = generateRoundRobinPairings([1, 2, 3]);
// C(3,2) = 3 matches
expect(pairings).toHaveLength(3);
});
it('every team plays every other team exactly once', () => {
const teams = [1, 2, 3, 4];
const pairings = generateRoundRobinPairings(teams);
for (const a of teams) {
for (const b of teams) {
if (a >= b) continue;
const found = pairings.filter(
(p) =>
(p.team1Seed === a && p.team2Seed === b) || (p.team1Seed === b && p.team2Seed === a)
);
expect(found).toHaveLength(1);
}
}
});
});
describe('generateGroupStageMatches', () => {
it('generates groups and all round-robin matches', () => {
const { groups, matches } = generateGroupStageMatches(8, 2);
expect(groups).toHaveLength(2);
// 4 teams per group, C(4,2) = 6 matches per group, 12 total
expect(matches).toHaveLength(12);
// each match has a groupIndex
const group0Matches = matches.filter((m) => m.groupIndex === 0);
const group1Matches = matches.filter((m) => m.groupIndex === 1);
expect(group0Matches).toHaveLength(6);
expect(group1Matches).toHaveLength(6);
});
});

View File

@@ -0,0 +1,397 @@
/**
* Pure bracket generation logic - no DB, no side effects.
* Generates the structure of tournament brackets for single elim, double elim, and group stages.
*/
export interface BracketSlot {
roundIndex: number;
bracketPosition: number;
team1Seed: number | null; // null = TBD (filled by previous match winner)
team2Seed: number | null;
winnerGoesTo: { roundIndex: number; bracketPosition: number; slot: 1 | 2 } | null;
loserGoesTo: { roundIndex: number; bracketPosition: number; slot: 1 | 2 } | null;
bracketType: 'winners' | 'losers' | 'grand_final';
isBye: boolean;
}
export interface GroupAssignment {
groupIndex: number;
teamSeeds: number[];
}
export interface GroupMatch {
groupIndex: number;
team1Seed: number;
team2Seed: number;
}
/**
* Standard tournament seeding placement for a bracket of given size.
* Ensures seed 1 and 2 are on opposite sides, high seeds are spread out.
* Returns array of seed numbers in bracket order (position 0 = top of bracket).
*/
export function standardBracketSeeding(size: number): number[] {
if (size < 2) return [1];
if (size === 2) return [1, 2];
const result: number[] = new Array(size);
result[0] = 1;
result[1] = 2;
// iteratively place seeds by splitting the bracket
for (let round = 1; round < Math.log2(size); round++) {
const count = 1 << round; // seeds already placed: 2, 4, 8...
const nextCount = count * 2;
const temp: number[] = new Array(nextCount);
for (let i = 0; i < count; i++) {
temp[i * 2] = result[i];
temp[i * 2 + 1] = nextCount + 1 - result[i];
}
for (let i = 0; i < nextCount; i++) {
result[i] = temp[i];
}
}
return result;
}
/**
* Finds the smallest power of 2 >= n
*/
export function nextPowerOf2(n: number): number {
let p = 1;
while (p < n) p *= 2;
return p;
}
/**
* Generate a single elimination bracket.
*/
export function generateSingleElimBracket(teamCount: number): BracketSlot[] {
const bracketSize = nextPowerOf2(teamCount);
const totalRounds = Math.log2(bracketSize);
const seeding = standardBracketSeeding(bracketSize);
const slots: BracketSlot[] = [];
// generate all rounds
for (let round = 0; round < totalRounds; round++) {
const matchesInRound = bracketSize / (2 << round);
for (let pos = 0; pos < matchesInRound; pos++) {
const isFirstRound = round === 0;
const isFinalRound = round === totalRounds - 1;
let team1Seed: number | null = null;
let team2Seed: number | null = null;
let isBye = false;
if (isFirstRound) {
const idx1 = pos * 2;
const idx2 = pos * 2 + 1;
team1Seed = seeding[idx1];
team2Seed = seeding[idx2];
// if either seed > teamCount, it's a BYE
isBye = team1Seed > teamCount || team2Seed > teamCount;
}
const winnerGoesTo = isFinalRound
? null
: {
roundIndex: round + 1,
bracketPosition: Math.floor(pos / 2),
slot: ((pos % 2) + 1) as 1 | 2
};
slots.push({
roundIndex: round,
bracketPosition: pos,
team1Seed,
team2Seed,
winnerGoesTo,
loserGoesTo: null,
bracketType: 'winners',
isBye
});
}
}
return slots;
}
/**
* Generate a double elimination bracket.
* Winners bracket + losers bracket + grand finals (with potential bracket reset).
*/
export function generateDoubleElimBracket(teamCount: number): BracketSlot[] {
const bracketSize = nextPowerOf2(teamCount);
const wbRounds = Math.log2(bracketSize);
const lbRounds = 2 * (wbRounds - 1);
const seeding = standardBracketSeeding(bracketSize);
const slots: BracketSlot[] = [];
// helper to find a slot by type + round + position
const findSlot = (type: string, round: number, pos: number) =>
slots.find(
(s) => s.bracketType === type && s.roundIndex === round && s.bracketPosition === pos
);
// ── Generate Losers Bracket first (so we can reference it from WB) ──
// LB has alternating "feed-in" and "reduction" rounds
// LB round 0: WB R0 losers play each other (bracketSize/4 matches)
// LB round 1: LB R0 winners vs WB R1 losers (feed-in)
// LB round 2: LB R1 winners play each other (reduction)
// LB round 3: LB R2 winners vs WB R2 losers (feed-in)
// ... and so on
for (let lbRound = 0; lbRound < lbRounds; lbRound++) {
const isFeedIn = lbRound % 2 === 1;
// number of matches halves every 2 rounds
const matchesInRound = bracketSize / (2 << Math.floor((lbRound + 1) / 2));
for (let pos = 0; pos < matchesInRound; pos++) {
const isLastLbRound = lbRound === lbRounds - 1;
const winnerGoesTo = isLastLbRound
? null // will be linked to GF later
: {
roundIndex: lbRound + 1,
bracketPosition: isFeedIn ? pos : Math.floor(pos / 2),
slot: (isFeedIn ? 1 : (pos % 2) + 1) as 1 | 2
};
slots.push({
roundIndex: lbRound,
bracketPosition: pos,
team1Seed: null,
team2Seed: null,
winnerGoesTo,
loserGoesTo: null, // losers bracket losers are eliminated
bracketType: 'losers',
isBye: false
});
}
}
// ── Generate Winners Bracket ──
for (let round = 0; round < wbRounds; round++) {
const matchesInRound = bracketSize / (2 << round);
for (let pos = 0; pos < matchesInRound; pos++) {
const isFirstRound = round === 0;
const isLastWbRound = round === wbRounds - 1;
let team1Seed: number | null = null;
let team2Seed: number | null = null;
let isBye = false;
if (isFirstRound) {
const idx1 = pos * 2;
const idx2 = pos * 2 + 1;
team1Seed = seeding[idx1];
team2Seed = seeding[idx2];
isBye = team1Seed > teamCount || team2Seed > teamCount;
}
const winnerGoesTo = isLastWbRound
? null // will be linked to GF later
: {
roundIndex: round + 1,
bracketPosition: Math.floor(pos / 2),
slot: ((pos % 2) + 1) as 1 | 2
};
// losers go to losers bracket
// WB R0 losers → LB R0, WB R1 losers → LB R2, WB R2 losers → LB R4, etc.
const lbTargetRound = round === 0 ? 0 : round * 2 - 1;
// determine position in LB - for feed-in rounds, position maps directly
// for the first WB round, losers pair up: WB match 0,1 → LB match 0; WB match 2,3 → LB match 1
let lbTargetPos: number;
let lbTargetSlot: 1 | 2;
if (round === 0) {
// WB R0 losers pair up in LB R0
lbTargetPos = Math.floor(pos / 2);
lbTargetSlot = ((pos % 2) + 1) as 1 | 2;
} else {
// WB R1+ losers enter feed-in rounds as slot 2
lbTargetPos = pos;
lbTargetSlot = 2;
}
const loserGoesTo = {
roundIndex: lbTargetRound,
bracketPosition: lbTargetPos,
slot: lbTargetSlot
};
slots.push({
roundIndex: round,
bracketPosition: pos,
team1Seed,
team2Seed,
winnerGoesTo,
loserGoesTo,
bracketType: 'winners',
isBye
});
}
}
// ── Grand Finals ──
// GF1: WB winner vs LB winner
slots.push({
roundIndex: 0,
bracketPosition: 0,
team1Seed: null, // WB winner
team2Seed: null, // LB winner
winnerGoesTo: null, // conditionally goes to GF2
loserGoesTo: null,
bracketType: 'grand_final',
isBye: false
});
// GF2 (bracket reset): only played if LB winner wins GF1
slots.push({
roundIndex: 1,
bracketPosition: 0,
team1Seed: null,
team2Seed: null,
winnerGoesTo: null,
loserGoesTo: null,
bracketType: 'grand_final',
isBye: false
});
// ── Wire up WB finals → GF1, LB finals → GF1 ──
const wbFinal = slots.find((s) => s.bracketType === 'winners' && s.roundIndex === wbRounds - 1);
if (wbFinal) {
wbFinal.winnerGoesTo = { roundIndex: 0, bracketPosition: 0, slot: 1 };
}
const lbFinal = slots.find((s) => s.bracketType === 'losers' && s.roundIndex === lbRounds - 1);
if (lbFinal) {
lbFinal.winnerGoesTo = { roundIndex: 0, bracketPosition: 0, slot: 2 };
}
// GF1 winner → GF2 (conditionally)
const gf1 = slots.find((s) => s.bracketType === 'grand_final' && s.roundIndex === 0);
if (gf1) {
gf1.winnerGoesTo = { roundIndex: 1, bracketPosition: 0, slot: 1 };
gf1.loserGoesTo = { roundIndex: 1, bracketPosition: 0, slot: 2 };
}
return slots;
}
/**
* Generate group assignments using snake seeding.
* e.g. 4 groups, 16 teams:
* Group A: 1, 8, 9, 16
* Group B: 2, 7, 10, 15
* Group C: 3, 6, 11, 14
* Group D: 4, 5, 12, 13
*/
export function generateGroupAssignments(teamCount: number, groupCount: number): GroupAssignment[] {
const groups: GroupAssignment[] = Array.from({ length: groupCount }, (_, i) => ({
groupIndex: i,
teamSeeds: []
}));
for (let seed = 1; seed <= teamCount; seed++) {
const row = Math.floor((seed - 1) / groupCount);
const col = (seed - 1) % groupCount;
// snake: even rows go left→right, odd rows go right→left
const groupIdx = row % 2 === 0 ? col : groupCount - 1 - col;
groups[groupIdx].teamSeeds.push(seed);
}
return groups;
}
/**
* Generate round-robin pairings for a group of teams.
* Uses the circle method to ensure all teams play each other exactly once.
*/
export function generateRoundRobinPairings(teamSeeds: number[]): GroupMatch[] {
const matches: GroupMatch[] = [];
const n = teamSeeds.length;
// for round-robin with n teams, we need n-1 rounds (or n if n is odd, with byes)
// using the circle method
const teams = [...teamSeeds];
if (teams.length % 2 !== 0) {
teams.push(-1); // -1 = BYE
}
const count = teams.length;
const rounds = count - 1;
const half = count / 2;
// fix team[0] and rotate the rest
for (let round = 0; round < rounds; round++) {
for (let i = 0; i < half; i++) {
const home = i === 0 ? teams[0] : teams[count - i];
const away = teams[i === 0 ? (count - 1 - round < 1 ? count - 1 : 0) : i];
// actually let's use a simpler approach
}
}
// simpler: just enumerate all unique pairs
for (let i = 0; i < teamSeeds.length; i++) {
for (let j = i + 1; j < teamSeeds.length; j++) {
matches.push({
groupIndex: 0, // caller sets this
team1Seed: teamSeeds[i],
team2Seed: teamSeeds[j]
});
}
}
return matches;
}
/**
* Generate all group stage matches for a tournament.
*/
export function generateGroupStageMatches(
teamCount: number,
groupCount: number
): { groups: GroupAssignment[]; matches: GroupMatch[] } {
const groups = generateGroupAssignments(teamCount, groupCount);
const allMatches: GroupMatch[] = [];
for (const group of groups) {
const pairings = generateRoundRobinPairings(group.teamSeeds);
for (const match of pairings) {
allMatches.push({ ...match, groupIndex: group.groupIndex });
}
}
return { groups, matches: allMatches };
}
/**
* Get the round name for a single/double elim bracket based on remaining teams.
*/
export function getRoundName(
matchesInRound: number,
bracketType: 'winners' | 'losers' | 'grand_final'
): string {
if (bracketType === 'grand_final') return 'Grand Finals';
const prefix = bracketType === 'losers' ? 'LB ' : '';
switch (matchesInRound) {
case 1:
return `${prefix}Finals`;
case 2:
return `${prefix}Semifinals`;
case 4:
return `${prefix}Quarterfinals`;
default:
return `${prefix}Round of ${matchesInRound * 2}`;
}
}

View File

@@ -0,0 +1,6 @@
export * from './types';
export * from './bracket';
export * from './lifecycle';
export * from './progression';
export * from './scheduler';
export * from './qualifiers';

View File

@@ -0,0 +1,714 @@
import { db } from '$lib/server/db';
import {
tournament,
tournamentStaff,
tournamentRound,
tournamentRegistration,
tournamentMatch,
tournamentGroup,
tournamentGroupEntry,
match
} from '$lib/server/db/schema';
import { eq, and, inArray, asc } from 'drizzle-orm';
import { log } from '$lib/server/logger';
import {
TOURNAMENT_STATES,
TOURNAMENT_MATCH_STATES,
TOURNAMENT_STAFF_ROLES,
type TournamentConfig,
type TournamentFormat
} from './types';
import {
generateSingleElimBracket,
generateDoubleElimBracket,
generateGroupAssignments,
generateGroupStageMatches,
nextPowerOf2,
getRoundName,
type BracketSlot
} from './bracket';
import { processByes } from './progression';
// ── Create Tournament ──────────────────────────────────────────────────
export async function createTournament(opts: {
name: string;
description?: string;
format: TournamentFormat;
config: TournamentConfig;
maxSlots: number;
bannerUrl?: string;
createdBy: string;
registrationOpenAt?: Date;
registrationCloseAt?: Date;
startAt?: Date;
}) {
const [created] = await db
.insert(tournament)
.values({
name: opts.name,
description: opts.description,
format: opts.format,
config: opts.config,
maxSlots: opts.maxSlots,
bannerUrl: opts.bannerUrl,
createdBy: opts.createdBy,
registrationOpenAt: opts.registrationOpenAt,
registrationCloseAt: opts.registrationCloseAt,
startAt: opts.startAt,
state: TOURNAMENT_STATES.DRAFT
})
.returning();
// add creator as organizer
await db.insert(tournamentStaff).values({
tournamentId: created.id,
userId: opts.createdBy,
role: TOURNAMENT_STAFF_ROLES.ORGANIZER
});
log.tournament.info({ tournamentId: created.id, name: opts.name }, 'tournament created');
return created;
}
// ── Open Registration ──────────────────────────────────────────────────
export async function openRegistration(tournamentId: string) {
const t = await getTournamentOrThrow(tournamentId);
assertTournamentState(t.state, TOURNAMENT_STATES.DRAFT);
// validate at least one round exists
const rounds = await db.query.tournamentRound.findMany({
where: eq(tournamentRound.tournamentId, tournamentId)
});
if (rounds.length === 0) {
throw new Error('Cannot open registration: no rounds configured');
}
await db
.update(tournament)
.set({
state: TOURNAMENT_STATES.REGISTRATION,
registrationOpenAt: new Date()
})
.where(eq(tournament.id, tournamentId));
log.tournament.info({ tournamentId }, 'registration opened');
}
// ── Close Registration ─────────────────────────────────────────────────
export async function closeRegistration(tournamentId: string) {
const t = await getTournamentOrThrow(tournamentId);
assertTournamentState(t.state, TOURNAMENT_STATES.REGISTRATION);
const config = t.config as TournamentConfig;
const nextState = config.qualifiers?.enabled
? TOURNAMENT_STATES.QUALIFIERS
: TOURNAMENT_STATES.SEEDING;
await db
.update(tournament)
.set({
state: nextState,
registrationCloseAt: new Date()
})
.where(eq(tournament.id, tournamentId));
log.tournament.info({ tournamentId, nextState }, 'registration closed');
}
// ── Register for Tournament ────────────────────────────────────────────
export async function registerForTournament(tournamentId: string, teamId: string, userId: string) {
const t = await getTournamentOrThrow(tournamentId);
assertTournamentState(t.state, TOURNAMENT_STATES.REGISTRATION);
// check max slots
const registrations = await db.query.tournamentRegistration.findMany({
where: and(
eq(tournamentRegistration.tournamentId, tournamentId),
inArray(tournamentRegistration.status, ['registered', 'confirmed'])
)
});
const config = t.config as TournamentConfig;
const hasQualifiers = config.qualifiers?.enabled;
// if no qualifiers, cap at maxSlots. with qualifiers, allow more signups
if (!hasQualifiers && registrations.length >= t.maxSlots) {
throw new Error('Tournament is full');
}
// check not already registered
const existing = await db.query.tournamentRegistration.findFirst({
where: and(
eq(tournamentRegistration.tournamentId, tournamentId),
eq(tournamentRegistration.teamId, teamId)
)
});
if (existing) {
throw new Error('Already registered for this tournament');
}
const [reg] = await db
.insert(tournamentRegistration)
.values({
tournamentId,
teamId,
userId,
status: 'registered'
})
.returning();
log.tournament.info({ tournamentId, teamId }, 'team registered');
return reg;
}
// ── Withdraw from Tournament ───────────────────────────────────────────
export async function withdrawFromTournament(tournamentId: string, teamId: string) {
const reg = await db.query.tournamentRegistration.findFirst({
where: and(
eq(tournamentRegistration.tournamentId, tournamentId),
eq(tournamentRegistration.teamId, teamId)
)
});
if (!reg) throw new Error('Not registered for this tournament');
await db
.update(tournamentRegistration)
.set({ status: 'withdrawn' })
.where(eq(tournamentRegistration.id, reg.id));
log.tournament.info({ tournamentId, teamId }, 'team withdrew');
}
// ── Finalize Seeding ───────────────────────────────────────────────────
export async function finalizeSeeding(tournamentId: string) {
const t = await getTournamentOrThrow(tournamentId);
if (t.state !== TOURNAMENT_STATES.SEEDING && t.state !== TOURNAMENT_STATES.QUALIFIERS) {
throw new Error(`Cannot finalize seeding in state ${t.state}`);
}
const registrations = await db.query.tournamentRegistration.findMany({
where: and(
eq(tournamentRegistration.tournamentId, tournamentId),
inArray(tournamentRegistration.status, ['registered', 'confirmed'])
),
orderBy: [asc(tournamentRegistration.seed), asc(tournamentRegistration.registeredAt)]
});
if (registrations.length < 2) {
throw new Error('Need at least 2 teams to generate bracket');
}
// cap to maxSlots if needed (qualifiers should have already reduced)
const activeTeams = registrations.slice(0, t.maxSlots);
// assign seeds if not already set (from qualifiers)
for (let i = 0; i < activeTeams.length; i++) {
if (!activeTeams[i].seed) {
await db
.update(tournamentRegistration)
.set({ seed: i + 1 })
.where(eq(tournamentRegistration.id, activeTeams[i].id));
activeTeams[i] = { ...activeTeams[i], seed: i + 1 };
}
}
// eliminate teams that didn't make the cut
const eliminated = registrations.slice(t.maxSlots);
for (const reg of eliminated) {
await db
.update(tournamentRegistration)
.set({ status: 'eliminated' })
.where(eq(tournamentRegistration.id, reg.id));
}
const format = t.format as TournamentFormat;
const config = t.config as TournamentConfig;
// build seed → teamId map
const seedToTeam = new Map<number, string>();
for (const reg of activeTeams) {
seedToTeam.set(reg.seed!, reg.teamId);
}
if (format === 'groups_bracket' && config.groupStage) {
await generateGroupsAndBracket(tournamentId, activeTeams.length, config, seedToTeam);
} else if (format === 'double_elim') {
await generateDoubleElimFromSeeds(tournamentId, activeTeams.length, seedToTeam);
} else {
await generateSingleElimFromSeeds(tournamentId, activeTeams.length, seedToTeam);
}
await db
.update(tournament)
.set({ state: TOURNAMENT_STATES.BRACKET })
.where(eq(tournament.id, tournamentId));
// auto-advance BYE matches
await processByes(tournamentId);
log.tournament.info({ tournamentId, teamCount: activeTeams.length, format }, 'bracket generated');
}
// ── Cancel Tournament ──────────────────────────────────────────────────
export async function cancelTournament(tournamentId: string) {
const t = await getTournamentOrThrow(tournamentId);
if (t.state === TOURNAMENT_STATES.FINISHED || t.state === TOURNAMENT_STATES.CANCELLED) {
throw new Error(`Tournament is already ${t.state}`);
}
// cancel any active matches
const activeMatches = await db.query.tournamentMatch.findMany({
where: and(
eq(tournamentMatch.tournamentId, tournamentId),
inArray(tournamentMatch.state, [
TOURNAMENT_MATCH_STATES.SCHEDULED,
TOURNAMENT_MATCH_STATES.LIVE
])
)
});
for (const tm of activeMatches) {
if (tm.matchId) {
await db
.update(match)
.set({ state: 'CANCELLED', finishedAt: new Date() })
.where(eq(match.id, tm.matchId));
}
await db
.update(tournamentMatch)
.set({ state: TOURNAMENT_MATCH_STATES.FINISHED })
.where(eq(tournamentMatch.id, tm.id));
}
await db
.update(tournament)
.set({ state: TOURNAMENT_STATES.CANCELLED, finishedAt: new Date() })
.where(eq(tournament.id, tournamentId));
log.tournament.info({ tournamentId }, 'tournament cancelled');
}
// ── Check Tournament Completion ────────────────────────────────────────
export async function checkTournamentCompletion(tournamentId: string) {
const allMatches = await db.query.tournamentMatch.findMany({
where: eq(tournamentMatch.tournamentId, tournamentId)
});
const incomplete = allMatches.filter(
(m) => m.state !== TOURNAMENT_MATCH_STATES.FINISHED && m.state !== TOURNAMENT_MATCH_STATES.BYE
);
if (incomplete.length > 0) return false;
// find the final match winner
// for single/double elim, it's the last match with no winnerGoesToId
const finalMatch = allMatches.find((m) => !m.winnerGoesToId && m.winnerId);
const winnerId = finalMatch?.winnerId;
await db
.update(tournament)
.set({
state: TOURNAMENT_STATES.FINISHED,
finishedAt: new Date(),
winnerId
})
.where(eq(tournament.id, tournamentId));
log.tournament.info({ tournamentId, winnerId }, 'tournament finished');
return true;
}
// ── Internal: Generate Single Elim from Seeds ──────────────────────────
async function generateSingleElimFromSeeds(
tournamentId: string,
teamCount: number,
seedToTeam: Map<number, string>
) {
const bracketSlots = generateSingleElimBracket(teamCount);
const bracketSize = nextPowerOf2(teamCount);
const totalRounds = Math.log2(bracketSize);
// get or create rounds
const rounds = await getOrCreateRounds(tournamentId, totalRounds, 'winners');
// create tournament matches
await insertBracketMatches(tournamentId, bracketSlots, rounds, seedToTeam, 'winners');
}
// ── Internal: Generate Double Elim from Seeds ──────────────────────────
async function generateDoubleElimFromSeeds(
tournamentId: string,
teamCount: number,
seedToTeam: Map<number, string>
) {
const bracketSlots = generateDoubleElimBracket(teamCount);
const bracketSize = nextPowerOf2(teamCount);
const wbRounds = Math.log2(bracketSize);
const lbRounds = 2 * (wbRounds - 1);
// create rounds for WB, LB, and GF
const wbRoundRecords = await getOrCreateRounds(tournamentId, wbRounds, 'winners');
const lbRoundRecords = await getOrCreateRounds(tournamentId, lbRounds, 'losers');
const gfRoundRecords = await getOrCreateRounds(tournamentId, 2, 'grand_final');
// build round map: bracketType + roundIndex → round record
const roundMap = new Map<string, { id: string }>();
for (let i = 0; i < wbRoundRecords.length; i++) {
roundMap.set(`winners-${i}`, wbRoundRecords[i]);
}
for (let i = 0; i < lbRoundRecords.length; i++) {
roundMap.set(`losers-${i}`, lbRoundRecords[i]);
}
for (let i = 0; i < gfRoundRecords.length; i++) {
roundMap.set(`grand_final-${i}`, gfRoundRecords[i]);
}
// insert all bracket matches (in two passes: first create, then link)
// first pass: create all matches without progression links
const slotToMatchId = new Map<string, string>();
for (const slot of bracketSlots) {
const roundKey = `${slot.bracketType}-${slot.roundIndex}`;
const round = roundMap.get(roundKey);
if (!round) continue;
const team1Id = slot.team1Seed ? (seedToTeam.get(slot.team1Seed) ?? null) : null;
const team2Id = slot.team2Seed ? (seedToTeam.get(slot.team2Seed) ?? null) : null;
const isBye = slot.isBye;
let state: string = TOURNAMENT_MATCH_STATES.PENDING;
if (isBye) state = TOURNAMENT_MATCH_STATES.BYE;
else if (team1Id && team2Id) state = TOURNAMENT_MATCH_STATES.READY;
const [created] = await db
.insert(tournamentMatch)
.values({
tournamentId,
roundId: round.id,
bracketPosition: slot.bracketPosition,
team1Id: team1Id,
team2Id: team2Id,
state
})
.returning();
const key = `${slot.bracketType}-${slot.roundIndex}-${slot.bracketPosition}`;
slotToMatchId.set(key, created.id);
}
// second pass: link progression
for (const slot of bracketSlots) {
const key = `${slot.bracketType}-${slot.roundIndex}-${slot.bracketPosition}`;
const matchId = slotToMatchId.get(key);
if (!matchId) continue;
const updates: Record<string, unknown> = {};
if (slot.winnerGoesTo) {
const targetKey = `${slot.winnerGoesTo.roundIndex === 0 && slot.bracketType !== 'grand_final' ? slot.bracketType : slot.winnerGoesTo.roundIndex >= 0 ? '' : ''}`;
// determine target bracket type from the slot's winnerGoesTo
let targetBracketType = slot.bracketType;
if (
slot.bracketType === 'winners' &&
slot.roundIndex ===
Math.log2(
nextPowerOf2(bracketSlots.filter((s) => s.bracketType === 'winners').length + 1)
) -
1
) {
targetBracketType = 'grand_final';
}
if (slot.bracketType === 'losers') {
const maxLbRound = Math.max(
...bracketSlots.filter((s) => s.bracketType === 'losers').map((s) => s.roundIndex)
);
if (slot.roundIndex === maxLbRound) {
targetBracketType = 'grand_final';
}
}
// actually, the winnerGoesTo already encodes which round/position, we just need to find the bracket type
// the bracket generator already set winnerGoesTo correctly, we need to figure out the target bracket type
// for WB final → GF: winnerGoesTo points to GF roundIndex 0
// for LB final → GF: winnerGoesTo points to GF roundIndex 0
// for GF1 → GF2: winnerGoesTo points to GF roundIndex 1
// let's use a simpler approach: try all bracket types
const wgt = slot.winnerGoesTo;
let targetId: string | undefined;
for (const bt of ['winners', 'losers', 'grand_final'] as const) {
const tKey = `${bt}-${wgt.roundIndex}-${wgt.bracketPosition}`;
targetId = slotToMatchId.get(tKey);
if (targetId) break;
}
if (targetId) {
updates.winnerGoesToId = targetId;
updates.winnerSlot = wgt.slot;
}
}
if (slot.loserGoesTo) {
const lgt = slot.loserGoesTo;
let targetId: string | undefined;
for (const bt of ['winners', 'losers', 'grand_final'] as const) {
const tKey = `${bt}-${lgt.roundIndex}-${lgt.bracketPosition}`;
targetId = slotToMatchId.get(tKey);
if (targetId) break;
}
if (targetId) {
updates.loserGoesToId = targetId;
updates.loserSlot = lgt.slot;
}
}
if (Object.keys(updates).length > 0) {
await db.update(tournamentMatch).set(updates).where(eq(tournamentMatch.id, matchId));
}
}
}
// ── Internal: Generate Groups + Bracket ────────────────────────────────
async function generateGroupsAndBracket(
tournamentId: string,
teamCount: number,
config: TournamentConfig,
seedToTeam: Map<number, string>
) {
const { groupCount, advanceCount } = config.groupStage!;
const { groups, matches } = generateGroupStageMatches(teamCount, groupCount);
// get or create group stage round
const existingRounds = await db.query.tournamentRound.findMany({
where: and(
eq(tournamentRound.tournamentId, tournamentId),
eq(tournamentRound.bracketType, 'group')
)
});
let groupRound: { id: string };
if (existingRounds.length > 0) {
groupRound = existingRounds[0];
} else {
const [created] = await db
.insert(tournamentRound)
.values({
tournamentId,
name: 'Group Stage',
abbreviation: 'GS',
roundOrder: 0,
bestOf: 5, // default, can be configured per round
bracketType: 'group'
})
.returning();
groupRound = created;
}
// create groups and entries
for (const group of groups) {
const [createdGroup] = await db
.insert(tournamentGroup)
.values({
tournamentId,
name: `Group ${String.fromCharCode(65 + group.groupIndex)}`,
groupOrder: group.groupIndex
})
.returning();
for (let i = 0; i < group.teamSeeds.length; i++) {
const teamId = seedToTeam.get(group.teamSeeds[i]);
if (!teamId) continue;
await db.insert(tournamentGroupEntry).values({
groupId: createdGroup.id,
teamId,
seed: i + 1
});
}
}
// create group stage matches
let position = 0;
for (const gm of matches) {
const team1Id = seedToTeam.get(gm.team1Seed) ?? null;
const team2Id = seedToTeam.get(gm.team2Seed) ?? null;
await db.insert(tournamentMatch).values({
tournamentId,
roundId: groupRound.id,
bracketPosition: position++,
team1Id,
team2Id,
state: team1Id && team2Id ? TOURNAMENT_MATCH_STATES.READY : TOURNAMENT_MATCH_STATES.PENDING
});
}
// bracket stage will be generated after groups finish
// (via progression.ts when all group matches are done)
}
// ── Internal: Get or Create Rounds ─────────────────────────────────────
async function getOrCreateRounds(
tournamentId: string,
count: number,
bracketType: string
): Promise<{ id: string }[]> {
const existing = await db.query.tournamentRound.findMany({
where: and(
eq(tournamentRound.tournamentId, tournamentId),
eq(tournamentRound.bracketType, bracketType)
),
orderBy: [asc(tournamentRound.roundOrder)]
});
if (existing.length >= count) {
return existing.slice(0, count);
}
const rounds: { id: string }[] = [...existing];
for (let i = existing.length; i < count; i++) {
// calculate matches in this round for naming
const bracketSize = 1 << count; // 2^count for winners
const matchesInRound = bracketType === 'grand_final' ? 1 : bracketSize / (2 << i);
const name =
bracketType === 'grand_final'
? i === 0
? 'Grand Finals'
: 'Grand Finals (Reset)'
: getRoundName(
Math.max(matchesInRound, 1),
bracketType as 'winners' | 'losers' | 'grand_final'
);
const prefix = bracketType === 'winners' ? 'WB' : bracketType === 'losers' ? 'LB' : 'GF';
const [created] = await db
.insert(tournamentRound)
.values({
tournamentId,
name,
abbreviation: `${prefix}${i + 1}`,
roundOrder: rounds.length,
bestOf: 5, // default, organizer can change later
bracketType
})
.returning();
rounds.push(created);
}
return rounds;
}
// ── Internal: Insert Bracket Matches (for single elim) ─────────────────
async function insertBracketMatches(
tournamentId: string,
bracketSlots: BracketSlot[],
rounds: { id: string }[],
seedToTeam: Map<number, string>,
bracketType: string
) {
// first pass: create all matches
const slotToMatchId = new Map<string, string>();
for (const slot of bracketSlots) {
if (slot.bracketType !== bracketType) continue;
const round = rounds[slot.roundIndex];
if (!round) continue;
const team1Id = slot.team1Seed ? (seedToTeam.get(slot.team1Seed) ?? null) : null;
const team2Id = slot.team2Seed ? (seedToTeam.get(slot.team2Seed) ?? null) : null;
const isBye = slot.isBye;
let state: string = TOURNAMENT_MATCH_STATES.PENDING;
if (isBye) state = TOURNAMENT_MATCH_STATES.BYE;
else if (team1Id && team2Id) state = TOURNAMENT_MATCH_STATES.READY;
const [created] = await db
.insert(tournamentMatch)
.values({
tournamentId,
roundId: round.id,
bracketPosition: slot.bracketPosition,
team1Id,
team2Id,
state
})
.returning();
const key = `${slot.roundIndex}-${slot.bracketPosition}`;
slotToMatchId.set(key, created.id);
}
// second pass: link progression
for (const slot of bracketSlots) {
if (slot.bracketType !== bracketType) continue;
const key = `${slot.roundIndex}-${slot.bracketPosition}`;
const matchId = slotToMatchId.get(key);
if (!matchId) continue;
const updates: Record<string, unknown> = {};
if (slot.winnerGoesTo) {
const targetKey = `${slot.winnerGoesTo.roundIndex}-${slot.winnerGoesTo.bracketPosition}`;
const targetId = slotToMatchId.get(targetKey);
if (targetId) {
updates.winnerGoesToId = targetId;
updates.winnerSlot = slot.winnerGoesTo.slot;
}
}
if (Object.keys(updates).length > 0) {
await db.update(tournamentMatch).set(updates).where(eq(tournamentMatch.id, matchId));
}
}
}
// ── Helpers ────────────────────────────────────────────────────────────
export async function getTournamentOrThrow(tournamentId: string) {
const t = await db.query.tournament.findFirst({
where: eq(tournament.id, tournamentId)
});
if (!t) throw new Error('Tournament not found');
return t;
}
export function assertTournamentState(current: string, expected: string) {
if (current !== expected) {
throw new Error(`Tournament is in state ${current}, expected ${expected}`);
}
}
export async function getTournamentFull(tournamentId: string) {
return db.query.tournament.findFirst({
where: eq(tournament.id, tournamentId),
with: {
rounds: {
orderBy: [asc(tournamentRound.roundOrder)]
},
registrations: true,
matches: true,
groups: {
with: { entries: true }
},
staff: true
}
});
}

View File

@@ -0,0 +1,408 @@
import { db } from '$lib/server/db';
import {
tournament,
tournamentMatch,
tournamentRound,
tournamentGroup,
tournamentGroupEntry,
tournamentRegistration,
match,
matchParticipant
} from '$lib/server/db/schema';
import { eq, and, inArray } from 'drizzle-orm';
import { log } from '$lib/server/logger';
import { TOURNAMENT_MATCH_STATES, type TournamentConfig } from './types';
import { generateSingleElimBracket, nextPowerOf2, getRoundName } from './bracket';
import { checkTournamentCompletion } from './lifecycle';
// ── Try Advance Tournament ─────────────────────────────────────────────
export async function tryAdvanceTournament(matchId: string) {
const tm = await db.query.tournamentMatch.findFirst({
where: eq(tournamentMatch.matchId, matchId)
});
if (!tm) return; // not a tournament match
const finishedMatch = await db.query.match.findFirst({
where: eq(match.id, matchId)
});
if (!finishedMatch || !finishedMatch.winnerId) return;
const winnerId = finishedMatch.winnerId;
const loserId = tm.team1Id === winnerId ? tm.team2Id : tm.team1Id;
// update tournament match
await db
.update(tournamentMatch)
.set({ state: TOURNAMENT_MATCH_STATES.FINISHED, winnerId })
.where(eq(tournamentMatch.id, tm.id));
log.tournament.info(
{ tournamentId: tm.tournamentId, matchId, winnerId },
'tournament match finished'
);
// get the round to check bracket type
const round = await db.query.tournamentRound.findFirst({
where: eq(tournamentRound.id, tm.roundId)
});
if (round?.bracketType === 'group') {
await handleGroupMatchResult(tm.tournamentId, winnerId, loserId, finishedMatch);
return;
}
if (round?.bracketType === 'grand_final') {
await handleGrandFinalsResult(tm, winnerId, loserId);
return;
}
// standard bracket advancement
if (tm.winnerGoesToId && winnerId) {
await advanceTeamToMatch(tm.winnerGoesToId, tm.winnerSlot!, winnerId);
}
if (tm.loserGoesToId && loserId) {
await advanceTeamToMatch(tm.loserGoesToId, tm.loserSlot!, loserId);
}
await checkTournamentCompletion(tm.tournamentId);
}
// ── Process BYE Matches ────────────────────────────────────────────────
export async function processByes(tournamentId: string) {
const byeMatches = await db.query.tournamentMatch.findMany({
where: and(
eq(tournamentMatch.tournamentId, tournamentId),
eq(tournamentMatch.state, TOURNAMENT_MATCH_STATES.BYE)
)
});
for (const tm of byeMatches) {
const winnerId = tm.team1Id ?? tm.team2Id;
if (!winnerId) continue;
// skip if already has a winner (already processed)
if (tm.winnerId) continue;
await db.update(tournamentMatch).set({ winnerId }).where(eq(tournamentMatch.id, tm.id));
if (tm.winnerGoesToId) {
await advanceTeamToMatch(tm.winnerGoesToId, tm.winnerSlot!, winnerId);
}
log.tournament.info({ tournamentId, matchId: tm.id, winnerId }, 'BYE auto-advanced');
}
}
// ── Handle Team Withdrawal ─────────────────────────────────────────────
export async function handleWithdrawal(tournamentId: string, teamId: string) {
const teamMatches = await db.query.tournamentMatch.findMany({
where: and(
eq(tournamentMatch.tournamentId, tournamentId),
inArray(tournamentMatch.state, [
TOURNAMENT_MATCH_STATES.PENDING,
TOURNAMENT_MATCH_STATES.READY,
TOURNAMENT_MATCH_STATES.SCHEDULED
])
)
});
for (const tm of teamMatches) {
if (tm.team1Id !== teamId && tm.team2Id !== teamId) continue;
const opponentId = tm.team1Id === teamId ? tm.team2Id : tm.team1Id;
if (tm.matchId) {
await db
.update(match)
.set({ state: 'CANCELLED', finishedAt: new Date() })
.where(eq(match.id, tm.matchId));
}
if (opponentId) {
await db
.update(tournamentMatch)
.set({ state: TOURNAMENT_MATCH_STATES.FINISHED, winnerId: opponentId })
.where(eq(tournamentMatch.id, tm.id));
if (tm.winnerGoesToId) {
await advanceTeamToMatch(tm.winnerGoesToId, tm.winnerSlot!, opponentId);
}
} else {
await db
.update(tournamentMatch)
.set({ state: TOURNAMENT_MATCH_STATES.FINISHED })
.where(eq(tournamentMatch.id, tm.id));
}
}
await db
.update(tournamentRegistration)
.set({ status: 'withdrawn' })
.where(
and(
eq(tournamentRegistration.tournamentId, tournamentId),
eq(tournamentRegistration.teamId, teamId)
)
);
log.tournament.info({ tournamentId, teamId }, 'team withdrew mid-tournament');
await checkTournamentCompletion(tournamentId);
}
// ── Internal: Advance Team to Match ────────────────────────────────────
async function advanceTeamToMatch(targetMatchId: string, targetSlot: number, teamId: string) {
const updates: Record<string, unknown> =
targetSlot === 1 ? { team1Id: teamId } : { team2Id: teamId };
await db.update(tournamentMatch).set(updates).where(eq(tournamentMatch.id, targetMatchId));
// check if both teams are now known → mark as READY
const updated = await db.query.tournamentMatch.findFirst({
where: eq(tournamentMatch.id, targetMatchId)
});
if (
updated &&
updated.team1Id &&
updated.team2Id &&
updated.state === TOURNAMENT_MATCH_STATES.PENDING
) {
await db
.update(tournamentMatch)
.set({ state: TOURNAMENT_MATCH_STATES.READY })
.where(eq(tournamentMatch.id, targetMatchId));
}
}
// ── Internal: Handle Group Match Result ────────────────────────────────
async function handleGroupMatchResult(
tournamentId: string,
winnerId: string,
loserId: string | null,
finishedMatch: typeof match.$inferSelect
) {
// update group standings
const groups = await db.query.tournamentGroup.findMany({
where: eq(tournamentGroup.tournamentId, tournamentId),
with: { entries: true }
});
// get match participant scores for map difference
const participants = await db.query.matchParticipant.findMany({
where: eq(matchParticipant.matchId, finishedMatch.id)
});
const winnerParticipant = participants.find((p) => p.teamId === winnerId);
const loserParticipant = loserId ? participants.find((p) => p.teamId === loserId) : null;
for (const group of groups) {
const winnerEntry = group.entries.find((e) => e.teamId === winnerId);
const loserEntry = loserId ? group.entries.find((e) => e.teamId === loserId) : null;
if (winnerEntry) {
await db
.update(tournamentGroupEntry)
.set({
wins: winnerEntry.wins + 1,
mapWins: winnerEntry.mapWins + (winnerParticipant?.score ?? 0),
mapLosses: winnerEntry.mapLosses + (loserParticipant?.score ?? 0)
})
.where(eq(tournamentGroupEntry.id, winnerEntry.id));
}
if (loserEntry) {
await db
.update(tournamentGroupEntry)
.set({
losses: loserEntry.losses + 1,
mapWins: loserEntry.mapWins + (loserParticipant?.score ?? 0),
mapLosses: loserEntry.mapLosses + (winnerParticipant?.score ?? 0)
})
.where(eq(tournamentGroupEntry.id, loserEntry.id));
}
}
// check if all group matches are finished
const groupRounds = await db.query.tournamentRound.findMany({
where: and(
eq(tournamentRound.tournamentId, tournamentId),
eq(tournamentRound.bracketType, 'group')
)
});
const groupRoundIds = groupRounds.map((r) => r.id);
if (groupRoundIds.length === 0) return;
const allGroupMatches = await db.query.tournamentMatch.findMany({
where: and(
eq(tournamentMatch.tournamentId, tournamentId),
inArray(tournamentMatch.roundId, groupRoundIds)
)
});
const allDone = allGroupMatches.every(
(m) => m.state === TOURNAMENT_MATCH_STATES.FINISHED || m.state === TOURNAMENT_MATCH_STATES.BYE
);
if (allDone) {
await advanceGroupsToPlayoffs(tournamentId);
}
}
// ── Internal: Advance Groups to Playoffs ───────────────────────────────
async function advanceGroupsToPlayoffs(tournamentId: string) {
const t = await db.query.tournament.findFirst({
where: eq(tournament.id, tournamentId)
});
if (!t) return;
const config = t.config as TournamentConfig;
const advanceCount = config.groupStage?.advanceCount ?? 2;
const groups = await db.query.tournamentGroup.findMany({
where: eq(tournamentGroup.tournamentId, tournamentId),
with: { entries: true }
});
// rank teams and determine who advances
const advancingTeams: { teamId: string; globalSeed: number }[] = [];
let seedCounter = 1;
for (const group of groups) {
const sorted = [...group.entries].sort((a, b) => {
if (b.wins !== a.wins) return b.wins - a.wins;
const aDiff = a.mapWins - a.mapLosses;
const bDiff = b.mapWins - b.mapLosses;
return bDiff - aDiff;
});
for (const entry of sorted.slice(0, advanceCount)) {
advancingTeams.push({ teamId: entry.teamId, globalSeed: seedCounter++ });
}
}
// generate playoff bracket
const seedToTeam = new Map<number, string>();
for (const t of advancingTeams) {
seedToTeam.set(t.globalSeed, t.teamId);
}
const bracketSlots = generateSingleElimBracket(advancingTeams.length);
const bracketSize = nextPowerOf2(advancingTeams.length);
const totalRounds = Math.log2(bracketSize);
// create playoff rounds
const rounds: { id: string }[] = [];
for (let i = 0; i < totalRounds; i++) {
const matchesInRound = bracketSize / (2 << i);
const name = getRoundName(matchesInRound, 'winners');
const [created] = await db
.insert(tournamentRound)
.values({
tournamentId,
name,
abbreviation: `PO${i + 1}`,
roundOrder: 100 + i,
bestOf: 7,
bracketType: 'winners'
})
.returning();
rounds.push(created);
}
// insert bracket matches
const slotToMatchId = new Map<string, string>();
for (const slot of bracketSlots) {
const round = rounds[slot.roundIndex];
if (!round) continue;
const team1Id = slot.team1Seed ? (seedToTeam.get(slot.team1Seed) ?? null) : null;
const team2Id = slot.team2Seed ? (seedToTeam.get(slot.team2Seed) ?? null) : null;
let state: string = TOURNAMENT_MATCH_STATES.PENDING;
if (slot.isBye) state = TOURNAMENT_MATCH_STATES.BYE;
else if (team1Id && team2Id) state = TOURNAMENT_MATCH_STATES.READY;
const [created] = await db
.insert(tournamentMatch)
.values({
tournamentId,
roundId: round.id,
bracketPosition: slot.bracketPosition,
team1Id,
team2Id,
state
})
.returning();
slotToMatchId.set(`${slot.roundIndex}-${slot.bracketPosition}`, created.id);
}
// link progression
for (const slot of bracketSlots) {
if (!slot.winnerGoesTo) continue;
const key = `${slot.roundIndex}-${slot.bracketPosition}`;
const mid = slotToMatchId.get(key);
if (!mid) continue;
const targetKey = `${slot.winnerGoesTo.roundIndex}-${slot.winnerGoesTo.bracketPosition}`;
const targetId = slotToMatchId.get(targetKey);
if (!targetId) continue;
await db
.update(tournamentMatch)
.set({ winnerGoesToId: targetId, winnerSlot: slot.winnerGoesTo.slot })
.where(eq(tournamentMatch.id, mid));
}
// process byes
await processByes(tournamentId);
log.tournament.info(
{ tournamentId, teamCount: advancingTeams.length },
'groups advanced to playoffs'
);
}
// ── Internal: Handle Grand Finals Result ───────────────────────────────
async function handleGrandFinalsResult(
tm: typeof tournamentMatch.$inferSelect,
winnerId: string,
loserId: string | null
) {
if (tm.winnerGoesToId) {
// this is GF1 (has progression to GF2)
// WB winner is always team1 (slot 1) in GF
if (winnerId === tm.team1Id) {
// WB winner won → skip bracket reset, mark GF2 as BYE
await db
.update(tournamentMatch)
.set({ state: TOURNAMENT_MATCH_STATES.BYE as string, winnerId })
.where(eq(tournamentMatch.id, tm.winnerGoesToId));
log.tournament.info(
{ tournamentId: tm.tournamentId, winnerId },
'WB winner won GF1, skipping bracket reset'
);
} else {
// LB winner won → bracket reset
await advanceTeamToMatch(tm.winnerGoesToId, 1, winnerId);
if (loserId) {
await advanceTeamToMatch(tm.winnerGoesToId, 2, loserId);
}
log.tournament.info({ tournamentId: tm.tournamentId }, 'bracket reset activated');
}
}
await checkTournamentCompletion(tm.tournamentId);
}

View File

@@ -0,0 +1,149 @@
import { db } from '$lib/server/db';
import {
tournament,
tournamentRegistration,
tournamentQualifierScore,
mappool,
mappoolSlot
} from '$lib/server/db/schema';
import { eq, and, inArray, asc, desc } from 'drizzle-orm';
import { log } from '$lib/server/logger';
import { TOURNAMENT_STATES, type TournamentConfig } from './types';
// ── Submit Qualifier Score ─────────────────────────────────────────────
export async function submitQualifierScore(opts: {
tournamentId: string;
teamId: string;
mappoolSlotId: string;
totalScore: number;
accuracy?: number;
}) {
const t = await db.query.tournament.findFirst({
where: eq(tournament.id, opts.tournamentId)
});
if (!t) throw new Error('Tournament not found');
if (t.state !== TOURNAMENT_STATES.QUALIFIERS) {
throw new Error('Tournament is not in qualifiers phase');
}
// verify team is registered
const reg = await db.query.tournamentRegistration.findFirst({
where: and(
eq(tournamentRegistration.tournamentId, opts.tournamentId),
eq(tournamentRegistration.teamId, opts.teamId),
inArray(tournamentRegistration.status, ['registered', 'confirmed'])
)
});
if (!reg) throw new Error('Team is not registered for this tournament');
const [score] = await db
.insert(tournamentQualifierScore)
.values({
tournamentId: opts.tournamentId,
teamId: opts.teamId,
mappoolSlotId: opts.mappoolSlotId,
totalScore: opts.totalScore,
accuracy: opts.accuracy
})
.returning();
log.tournament.info(
{ tournamentId: opts.tournamentId, teamId: opts.teamId, score: opts.totalScore },
'qualifier score submitted'
);
return score;
}
// ── Get Qualifier Rankings ─────────────────────────────────────────────
export async function getQualifierRankings(tournamentId: string) {
const scores = await db.query.tournamentQualifierScore.findMany({
where: eq(tournamentQualifierScore.tournamentId, tournamentId)
});
// aggregate total score per team across all maps
const teamTotals = new Map<string, { totalScore: number; mapCount: number }>();
for (const score of scores) {
const existing = teamTotals.get(score.teamId) ?? { totalScore: 0, mapCount: 0 };
existing.totalScore += score.totalScore;
existing.mapCount += 1;
teamTotals.set(score.teamId, existing);
}
// rank by total score descending
const ranked = Array.from(teamTotals.entries())
.map(([teamId, data]) => ({
teamId,
totalScore: data.totalScore,
mapCount: data.mapCount,
averageScore: data.mapCount > 0 ? data.totalScore / data.mapCount : 0
}))
.sort((a, b) => b.totalScore - a.totalScore);
return ranked;
}
// ── Finalize Qualifiers ────────────────────────────────────────────────
export async function finalizeQualifiers(tournamentId: string) {
const t = await db.query.tournament.findFirst({
where: eq(tournament.id, tournamentId)
});
if (!t) throw new Error('Tournament not found');
if (t.state !== TOURNAMENT_STATES.QUALIFIERS) {
throw new Error('Tournament is not in qualifiers phase');
}
const rankings = await getQualifierRankings(tournamentId);
// top maxSlots teams qualify, rest are eliminated
for (let i = 0; i < rankings.length; i++) {
const isQualified = i < t.maxSlots;
// update registration with seed and qualifier score
await db
.update(tournamentRegistration)
.set({
seed: isQualified ? i + 1 : null,
qualifierScore: rankings[i].totalScore,
status: isQualified ? 'confirmed' : 'eliminated'
})
.where(
and(
eq(tournamentRegistration.tournamentId, tournamentId),
eq(tournamentRegistration.teamId, rankings[i].teamId)
)
);
}
// move to seeding phase
await db
.update(tournament)
.set({ state: TOURNAMENT_STATES.SEEDING })
.where(eq(tournament.id, tournamentId));
log.tournament.info(
{ tournamentId, qualified: Math.min(rankings.length, t.maxSlots), total: rankings.length },
'qualifiers finalized'
);
}
// ── Get Qualifier Mappool ──────────────────────────────────────────────
export async function getQualifierMappool(tournamentId: string) {
const t = await db.query.tournament.findFirst({
where: eq(tournament.id, tournamentId)
});
if (!t) throw new Error('Tournament not found');
const config = t.config as TournamentConfig;
if (!config.qualifiers?.mappoolId) return null;
return db.query.mappool.findFirst({
where: eq(mappool.id, config.qualifiers.mappoolId),
with: { slots: true }
});
}

View File

@@ -0,0 +1,245 @@
import { db } from '$lib/server/db';
import { tournament, tournamentMatch, tournamentRound } from '$lib/server/db/schema';
import { eq, and, lte, asc, inArray } from 'drizzle-orm';
import { log } from '$lib/server/logger';
import { TOURNAMENT_MATCH_STATES, TOURNAMENT_STATES, type TournamentConfig } from './types';
import { createMatch } from '$lib/server/match/engine';
import { initMatchLobby } from '$lib/server/match/orchestrator';
import { getActiveLobbyCount } from '$lib/server/bancho/client';
import type { MatchConfig } from '$lib/server/match/types';
const MAX_TOTAL_LOBBIES = 4;
const SCHEDULER_INTERVAL_MS = 30_000; // 30 seconds
let schedulerTimer: ReturnType<typeof setInterval> | null = null;
// ── Start/Stop Scheduler ───────────────────────────────────────────────
export function startTournamentScheduler() {
if (schedulerTimer) return;
schedulerTimer = setInterval(() => {
processTournamentMatchQueue().catch((err) =>
log.tournament.error({ err }, 'scheduler tick failed')
);
}, SCHEDULER_INTERVAL_MS);
log.tournament.info('tournament scheduler started');
}
export function stopTournamentScheduler() {
if (schedulerTimer) {
clearInterval(schedulerTimer);
schedulerTimer = null;
log.tournament.info('tournament scheduler stopped');
}
}
// ── Process Tournament Match Queue ─────────────────────────────────────
export async function processTournamentMatchQueue() {
// check how many lobby slots are available
const activeLobbies = getActiveLobbyCount();
const available = MAX_TOTAL_LOBBIES - activeLobbies;
if (available <= 0) {
log.tournament.debug({ activeLobbies }, 'no lobby slots available');
return;
}
// find tournaments in BRACKET state
const activeTournaments = await db.query.tournament.findMany({
where: eq(tournament.state, TOURNAMENT_STATES.BRACKET)
});
if (activeTournaments.length === 0) return;
// find READY tournament matches across all active tournaments
// that are either past their scheduled time or have no scheduled time
const readyMatches = await db.query.tournamentMatch.findMany({
where: and(
inArray(
tournamentMatch.tournamentId,
activeTournaments.map((t) => t.id)
),
eq(tournamentMatch.state, TOURNAMENT_MATCH_STATES.READY)
),
orderBy: [asc(tournamentMatch.scheduledAt)]
});
// filter to matches that are ready to start (scheduled time passed or no schedule)
const now = new Date();
const eligible = readyMatches.filter((m) => !m.scheduledAt || m.scheduledAt <= now);
if (eligible.length === 0) return;
// respect per-tournament concurrency limits
const tournamentConfigs = new Map<string, TournamentConfig>();
for (const t of activeTournaments) {
tournamentConfigs.set(t.id, t.config as TournamentConfig);
}
// count currently live matches per tournament
const liveMatches = await db.query.tournamentMatch.findMany({
where: and(
inArray(
tournamentMatch.tournamentId,
activeTournaments.map((t) => t.id)
),
eq(tournamentMatch.state, TOURNAMENT_MATCH_STATES.LIVE)
)
});
const livePerTournament = new Map<string, number>();
for (const m of liveMatches) {
livePerTournament.set(m.tournamentId, (livePerTournament.get(m.tournamentId) ?? 0) + 1);
}
let slotsUsed = 0;
for (const tm of eligible) {
if (slotsUsed >= available) break;
const config = tournamentConfigs.get(tm.tournamentId);
const maxConcurrent = config?.scheduling?.maxConcurrentLobbies ?? 3;
const currentLive = livePerTournament.get(tm.tournamentId) ?? 0;
if (currentLive >= maxConcurrent) continue;
try {
await startTournamentMatch(tm);
slotsUsed++;
livePerTournament.set(tm.tournamentId, currentLive + 1);
} catch (err) {
log.tournament.error(
{ err, tournamentMatchId: tm.id, tournamentId: tm.tournamentId },
'failed to start tournament match'
);
}
}
if (slotsUsed > 0) {
log.tournament.info({ started: slotsUsed }, 'started tournament matches');
}
}
// ── Start a Single Tournament Match ────────────────────────────────────
async function startTournamentMatch(tm: typeof tournamentMatch.$inferSelect) {
if (!tm.team1Id || !tm.team2Id) {
throw new Error('Cannot start match without both teams');
}
// get the round for config (bestOf, mappool)
const round = await db.query.tournamentRound.findFirst({
where: eq(tournamentRound.id, tm.roundId)
});
if (!round) throw new Error('Round not found');
// get tournament for config
const t = await db.query.tournament.findFirst({
where: eq(tournament.id, tm.tournamentId)
});
if (!t) throw new Error('Tournament not found');
const tournamentConfig = t.config as TournamentConfig;
if (!round.mappoolId) {
throw new Error(`Round ${round.name} has no mappool assigned`);
}
// build match config from tournament + round settings
const matchConfig: MatchConfig = {
bestOf: round.bestOf,
teamSize: tournamentConfig.teamSize,
scoringType: tournamentConfig.scoringType,
freemod: tournamentConfig.freemod,
forceNoFail: tournamentConfig.forceNoFail,
allowEloChange: false // tournament matches don't affect elo
};
// create the actual match using the existing engine
const matchName = `${t.name} - ${round.name} - Match ${tm.bracketPosition + 1}`;
const createdMatch = await createMatch({
name: matchName,
config: matchConfig,
mappoolId: round.mappoolId,
teams: [tm.team1Id, tm.team2Id],
createdBy: t.createdBy
});
// link tournament match to the real match
await db
.update(tournamentMatch)
.set({
matchId: createdMatch.id,
state: TOURNAMENT_MATCH_STATES.LIVE as string
})
.where(eq(tournamentMatch.id, tm.id));
// start the IRC lobby
await initMatchLobby(createdMatch.id);
log.tournament.info(
{
tournamentId: tm.tournamentId,
tournamentMatchId: tm.id,
matchId: createdMatch.id,
round: round.name
},
'tournament match started'
);
}
// ── Schedule Matches for a Round ───────────────────────────────────────
export async function scheduleTournamentMatches(tournamentId: string, roundId?: string) {
const t = await db.query.tournament.findFirst({
where: eq(tournament.id, tournamentId)
});
if (!t) throw new Error('Tournament not found');
const config = t.config as TournamentConfig;
const intervalMs = (config.scheduling?.matchIntervalMinutes ?? 30) * 60 * 1000;
// get matches to schedule
const where = roundId
? and(
eq(tournamentMatch.tournamentId, tournamentId),
eq(tournamentMatch.roundId, roundId),
eq(tournamentMatch.state, TOURNAMENT_MATCH_STATES.READY)
)
: and(
eq(tournamentMatch.tournamentId, tournamentId),
eq(tournamentMatch.state, TOURNAMENT_MATCH_STATES.READY)
);
const matches = await db.query.tournamentMatch.findMany({
where,
orderBy: [asc(tournamentMatch.bracketPosition)]
});
// get the round's scheduled start time
const round = roundId
? await db.query.tournamentRound.findFirst({
where: eq(tournamentRound.id, roundId)
})
: null;
const baseTime = round?.scheduledAt ?? t.startAt ?? new Date();
// assign scheduled times with intervals
for (let i = 0; i < matches.length; i++) {
const scheduledAt = new Date(baseTime.getTime() + i * intervalMs);
await db
.update(tournamentMatch)
.set({ scheduledAt })
.where(eq(tournamentMatch.id, matches[i].id));
}
log.tournament.info(
{ tournamentId, roundId, count: matches.length },
'scheduled tournament matches'
);
}

View File

@@ -0,0 +1,71 @@
export interface TournamentConfig {
teamSize: number;
scoringType: 'score' | 'score_v2' | 'accuracy' | 'combo';
freemod?: boolean;
forceNoFail?: boolean;
qualifiers?: {
enabled: boolean;
mappoolId?: string;
lobbySizeMax?: number; // players per qualifier lobby (default 8)
};
groupStage?: {
groupCount: number;
advanceCount: number; // top N per group advance to bracket
};
scheduling?: {
matchIntervalMinutes: number; // gap between matches (default 30)
maxConcurrentLobbies: number; // respect IRC limits (default 3)
};
}
export const TOURNAMENT_STATES = {
DRAFT: 'DRAFT',
REGISTRATION: 'REGISTRATION',
QUALIFIERS: 'QUALIFIERS',
SEEDING: 'SEEDING',
BRACKET: 'BRACKET',
FINISHED: 'FINISHED',
CANCELLED: 'CANCELLED'
} as const;
export type TournamentState = (typeof TOURNAMENT_STATES)[keyof typeof TOURNAMENT_STATES];
export const TOURNAMENT_MATCH_STATES = {
PENDING: 'PENDING',
READY: 'READY',
SCHEDULED: 'SCHEDULED',
LIVE: 'LIVE',
FINISHED: 'FINISHED',
BYE: 'BYE'
} as const;
export type TournamentMatchState =
(typeof TOURNAMENT_MATCH_STATES)[keyof typeof TOURNAMENT_MATCH_STATES];
export const TOURNAMENT_FORMATS = {
SINGLE_ELIM: 'single_elim',
DOUBLE_ELIM: 'double_elim',
GROUPS_BRACKET: 'groups_bracket'
} as const;
export type TournamentFormat = (typeof TOURNAMENT_FORMATS)[keyof typeof TOURNAMENT_FORMATS];
export const TOURNAMENT_STAFF_ROLES = {
ORGANIZER: 'organizer',
ADMIN: 'admin',
REFEREE: 'referee',
POOLER: 'pooler',
STREAMER: 'streamer'
} as const;
export type TournamentStaffRole =
(typeof TOURNAMENT_STAFF_ROLES)[keyof typeof TOURNAMENT_STAFF_ROLES];
export const REGISTRATION_STATUSES = {
REGISTERED: 'registered',
CONFIRMED: 'confirmed',
ELIMINATED: 'eliminated',
WITHDRAWN: 'withdrawn'
} as const;
export type RegistrationStatus = (typeof REGISTRATION_STATUSES)[keyof typeof REGISTRATION_STATUSES];

View File

@@ -0,0 +1,352 @@
import { describe, expect, test, vi } from 'vitest';
// Mock SvelteKit error to throw like the real one
vi.mock('@sveltejs/kit', () => ({
error: (status: number, body: string) => {
throw { status, body };
}
}));
const {
matchConfigSchema,
createMatchSchema,
createInviteSchema,
submitScoreSchema,
createMappoolSchema,
createMappoolSlotSchema,
submitRollSchema,
pickMapSchema,
parseBody
} = await import('./validation');
// ── matchConfigSchema ──
describe('matchConfigSchema', () => {
const validConfig = {
bestOf: 3,
teamSize: 1,
scoringType: 'score_v2' as const
};
test('accepts valid minimal config', () => {
expect(matchConfigSchema.parse(validConfig)).toEqual(validConfig);
});
test('accepts valid full config', () => {
const full = {
...validConfig,
teamSizes: [1, 2],
warmups: 2,
bans: 1,
freemod: true,
forceNoFail: true,
allowEloChange: false,
description: 'test match'
};
expect(matchConfigSchema.parse(full)).toEqual(full);
});
test('rejects bestOf = 0', () => {
expect(() => matchConfigSchema.parse({ ...validConfig, bestOf: 0 })).toThrow();
});
test('rejects bestOf > 15', () => {
expect(() => matchConfigSchema.parse({ ...validConfig, bestOf: 16 })).toThrow();
});
test('rejects non-integer bestOf', () => {
expect(() => matchConfigSchema.parse({ ...validConfig, bestOf: 3.5 })).toThrow();
});
test('rejects invalid scoringType', () => {
expect(() => matchConfigSchema.parse({ ...validConfig, scoringType: 'invalid' })).toThrow();
});
test('rejects extra unknown fields (strict mode)', () => {
expect(() => matchConfigSchema.parse({ ...validConfig, injectedField: 'malicious' })).toThrow();
});
test('rejects teamSize = 0', () => {
expect(() => matchConfigSchema.parse({ ...validConfig, teamSize: 0 })).toThrow();
});
test('rejects teamSize > 8', () => {
expect(() => matchConfigSchema.parse({ ...validConfig, teamSize: 9 })).toThrow();
});
test('rejects description over 500 chars', () => {
expect(() =>
matchConfigSchema.parse({ ...validConfig, description: 'a'.repeat(501) })
).toThrow();
});
test('rejects missing required fields', () => {
expect(() => matchConfigSchema.parse({})).toThrow();
expect(() => matchConfigSchema.parse({ bestOf: 3 })).toThrow();
expect(() => matchConfigSchema.parse({ bestOf: 3, teamSize: 1 })).toThrow();
});
});
// ── createMatchSchema ──
describe('createMatchSchema', () => {
const uuid1 = '550e8400-e29b-41d4-a716-446655440000';
const uuid2 = '550e8400-e29b-41d4-a716-446655440001';
const uuid3 = '550e8400-e29b-41d4-a716-446655440002';
const validMatch = {
name: 'Test Match',
config: { bestOf: 3, teamSize: 1, scoringType: 'score_v2' },
mappoolId: uuid1,
teams: [uuid2, uuid3]
};
test('accepts valid match creation', () => {
expect(createMatchSchema.parse(validMatch)).toBeTruthy();
});
test('rejects empty name', () => {
expect(() => createMatchSchema.parse({ ...validMatch, name: '' })).toThrow();
});
test('rejects name over 200 chars', () => {
expect(() => createMatchSchema.parse({ ...validMatch, name: 'a'.repeat(201) })).toThrow();
});
test('rejects non-uuid mappoolId', () => {
expect(() => createMatchSchema.parse({ ...validMatch, mappoolId: 'not-a-uuid' })).toThrow();
});
test('rejects teams with less than 2 entries', () => {
expect(() => createMatchSchema.parse({ ...validMatch, teams: [uuid2] })).toThrow();
});
test('rejects teams with more than 2 entries', () => {
expect(() =>
createMatchSchema.parse({ ...validMatch, teams: [uuid1, uuid2, uuid3] })
).toThrow();
});
test('rejects non-uuid team ids', () => {
expect(() => createMatchSchema.parse({ ...validMatch, teams: ['bad', 'ids'] })).toThrow();
});
});
// ── createInviteSchema ──
describe('createInviteSchema', () => {
const uuid1 = '550e8400-e29b-41d4-a716-446655440000';
const uuid2 = '550e8400-e29b-41d4-a716-446655440001';
const uuid3 = '550e8400-e29b-41d4-a716-446655440002';
const validInvite = {
creatorTeamId: uuid1,
invitedTeamId: uuid2,
config: { bestOf: 5, teamSize: 2, scoringType: 'score' },
mappoolId: uuid3
};
test('accepts valid minimal invite', () => {
expect(createInviteSchema.parse(validInvite)).toBeTruthy();
});
test('accepts invite with optional fields', () => {
const full = {
...validInvite,
name: 'Cool Match',
message: 'lets go',
scheduledAt: '2026-04-01T12:00:00Z'
};
expect(createInviteSchema.parse(full)).toBeTruthy();
});
test('rejects message over 500 chars', () => {
expect(() => createInviteSchema.parse({ ...validInvite, message: 'x'.repeat(501) })).toThrow();
});
test('rejects invalid scheduledAt format', () => {
expect(() =>
createInviteSchema.parse({ ...validInvite, scheduledAt: 'next tuesday' })
).toThrow();
});
test('accepts null scheduledAt', () => {
expect(createInviteSchema.parse({ ...validInvite, scheduledAt: null })).toBeTruthy();
});
});
// ── submitScoreSchema ──
describe('submitScoreSchema', () => {
const uuid1 = '550e8400-e29b-41d4-a716-446655440000';
const uuid2 = '550e8400-e29b-41d4-a716-446655440001';
test('accepts valid score submission', () => {
const valid = {
matchGameId: uuid1,
scores: [{ participantId: uuid2, score: 1000000 }]
};
expect(submitScoreSchema.parse(valid)).toBeTruthy();
});
test('rejects negative score', () => {
expect(() =>
submitScoreSchema.parse({
matchGameId: uuid1,
scores: [{ participantId: uuid2, score: -1 }]
})
).toThrow();
});
test('rejects empty scores array', () => {
expect(() => submitScoreSchema.parse({ matchGameId: uuid1, scores: [] })).toThrow();
});
test('rejects scores array over 16 entries', () => {
const scores = Array.from({ length: 17 }, (_, i) => ({
participantId: `550e8400-e29b-41d4-a716-4466554400${String(i).padStart(2, '0')}`,
score: 100
}));
expect(() => submitScoreSchema.parse({ matchGameId: uuid1, scores })).toThrow();
});
test('rejects non-uuid matchGameId', () => {
expect(() =>
submitScoreSchema.parse({
matchGameId: 'bad',
scores: [{ participantId: uuid2, score: 100 }]
})
).toThrow();
});
});
// ── createMappoolSchema ──
describe('createMappoolSchema', () => {
test('accepts valid name', () => {
expect(createMappoolSchema.parse({ name: 'Pool A' })).toEqual({ name: 'Pool A' });
});
test('trims whitespace', () => {
expect(createMappoolSchema.parse({ name: ' Pool A ' })).toEqual({ name: 'Pool A' });
});
test('rejects empty name', () => {
expect(() => createMappoolSchema.parse({ name: '' })).toThrow();
});
test('rejects whitespace-only name', () => {
expect(() => createMappoolSchema.parse({ name: ' ' })).toThrow();
});
test('rejects name over 200 chars', () => {
expect(() => createMappoolSchema.parse({ name: 'a'.repeat(201) })).toThrow();
});
});
// ── createMappoolSlotSchema ──
describe('createMappoolSlotSchema', () => {
test('accepts valid slot with string beatmapId', () => {
const result = createMappoolSlotSchema.parse({ beatmapId: '12345', category: 'nm' });
expect(result).toEqual({ beatmapId: '12345', category: 'NM' });
});
test('accepts valid slot with numeric beatmapId', () => {
const result = createMappoolSlotSchema.parse({ beatmapId: 12345, category: 'hd' });
expect(result).toEqual({ beatmapId: '12345', category: 'HD' });
});
test('uppercases category', () => {
const result = createMappoolSlotSchema.parse({ beatmapId: '1', category: 'dt' });
expect(result.category).toBe('DT');
});
test('accepts mods array', () => {
const result = createMappoolSlotSchema.parse({
beatmapId: '1',
category: 'fm',
mods: ['HD', 'HR']
});
expect(result.mods).toEqual(['HD', 'HR']);
});
test('rejects empty category', () => {
expect(() => createMappoolSlotSchema.parse({ beatmapId: '1', category: '' })).toThrow();
});
});
// ── submitRollSchema ──
describe('submitRollSchema', () => {
test('accepts valid roll', () => {
expect(submitRollSchema.parse({ value: 50 })).toEqual({ value: 50 });
});
test('rejects value = 0', () => {
expect(() => submitRollSchema.parse({ value: 0 })).toThrow();
});
test('rejects value > 100', () => {
expect(() => submitRollSchema.parse({ value: 101 })).toThrow();
});
test('rejects non-integer', () => {
expect(() => submitRollSchema.parse({ value: 3.14 })).toThrow();
});
test('rejects missing value', () => {
expect(() => submitRollSchema.parse({})).toThrow();
});
});
// ── pickMapSchema ──
describe('pickMapSchema', () => {
test('accepts valid uuid', () => {
const uuid = '550e8400-e29b-41d4-a716-446655440000';
expect(pickMapSchema.parse({ mappoolSlotId: uuid })).toEqual({ mappoolSlotId: uuid });
});
test('rejects non-uuid', () => {
expect(() => pickMapSchema.parse({ mappoolSlotId: 'not-a-uuid' })).toThrow();
});
test('rejects missing field', () => {
expect(() => pickMapSchema.parse({})).toThrow();
});
});
// ── parseBody ──
describe('parseBody', () => {
test('returns parsed data on success', () => {
const result = parseBody(submitRollSchema, { value: 42 });
expect(result).toEqual({ value: 42 });
});
test('throws sveltekit error with 400 on validation failure', () => {
try {
parseBody(submitRollSchema, { value: 'not a number' });
expect.unreachable('should have thrown');
} catch (e: any) {
expect(e.status).toBe(400);
expect(e.body).toContain('Validation failed');
}
});
test('includes field path in error message', () => {
try {
parseBody(createMatchSchema, {
name: 'test',
config: { bestOf: 'bad', teamSize: 1, scoringType: 'score' },
mappoolId: '550e8400-e29b-41d4-a716-446655440000',
teams: ['550e8400-e29b-41d4-a716-446655440001', '550e8400-e29b-41d4-a716-446655440002']
});
expect.unreachable('should have thrown');
} catch (e: any) {
expect(e.status).toBe(400);
expect(e.body).toContain('config.bestOf');
}
});
});

View File

@@ -0,0 +1,92 @@
import { error } from '@sveltejs/kit';
import { z } from 'zod';
// ── Match config schema ──
export const matchConfigSchema = z
.object({
bestOf: z.number().int().min(1).max(15),
teamSize: z.number().int().min(1).max(8),
teamSizes: z.array(z.number().int().min(1).max(8)).optional(),
scoringType: z.enum(['score', 'score_v2', 'accuracy', 'combo']),
warmups: z.number().int().min(0).max(5).optional(),
bans: z.number().int().min(0).max(10).optional(),
freemod: z.boolean().optional(),
forceNoFail: z.boolean().optional(),
allowEloChange: z.boolean().optional(),
description: z.string().max(500).optional()
})
.strict();
export type ValidatedMatchConfig = z.infer<typeof matchConfigSchema>;
// ── Match creation ──
export const createMatchSchema = z.object({
name: z.string().min(1).max(200),
config: matchConfigSchema,
mappoolId: z.string().uuid(),
teams: z.array(z.string().uuid()).min(2).max(2)
});
// ── Invite creation ──
export const createInviteSchema = z.object({
creatorTeamId: z.string().uuid(),
invitedTeamId: z.string().uuid(),
config: matchConfigSchema,
mappoolId: z.string().uuid(),
name: z.string().max(200).optional(),
message: z.string().max(500).optional(),
scheduledAt: z.string().datetime().nullable().optional()
});
// ── Score submission ──
const scoreEntrySchema = z.object({
participantId: z.string().uuid(),
score: z.number().int().min(0)
});
export const submitScoreSchema = z.object({
matchGameId: z.string().uuid(),
scores: z.array(scoreEntrySchema).min(1).max(16)
});
// ── Mappool creation ──
export const createMappoolSchema = z.object({
name: z
.string()
.max(200)
.transform((s) => s.trim())
.pipe(z.string().min(1, 'Name cannot be empty'))
});
// ── Mappool slot creation ──
export const createMappoolSlotSchema = z.object({
beatmapId: z.union([z.string(), z.number()]).transform(String),
category: z
.string()
.min(1)
.max(10)
.transform((s) => s.toUpperCase()),
mods: z.array(z.string()).optional()
});
// ── Roll submission ──
export const submitRollSchema = z.object({
value: z.number().int().min(1).max(100)
});
// ── Pick submission ──
export const pickMapSchema = z.object({
mappoolSlotId: z.string().uuid()
});
/**
* Parse request body with a zod schema. Throws SvelteKit error(400) on failure.
*/
export function parseBody<T>(schema: z.ZodType<T>, data: unknown): T {
const result = schema.safeParse(data);
if (!result.success) {
const issues = result.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ');
error(400, `Validation failed: ${issues}`);
}
return result.data;
}

View File

@@ -0,0 +1,23 @@
import { db } from '$lib/server/db';
import { tournament } from '$lib/server/db/schema';
import { desc } from 'drizzle-orm';
import { requireAuth } from '$lib/server/permissions';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
requireAuth(locals);
const tournaments = await db.query.tournament.findMany({
with: {
registrations: {
columns: { id: true, status: true }
},
staff: {
columns: { userId: true, role: true }
}
},
orderBy: desc(tournament.createdAt)
});
return { tournaments };
};

View File

@@ -0,0 +1,131 @@
<script lang="ts">
const { data } = $props();
const formatLabels: Record<string, string> = {
single_elim: 'Single Elimination',
double_elim: 'Double Elimination',
groups_bracket: 'Groups + Bracket'
};
const stateColors: Record<string, string> = {
DRAFT: 'bg-surface-600 text-text-secondary',
REGISTRATION: 'bg-green-900 text-green-300',
QUALIFIERS: 'bg-yellow-900 text-yellow-300',
SEEDING: 'bg-yellow-900 text-yellow-300',
BRACKET: 'bg-blue-900 text-blue-300',
FINISHED: 'bg-surface-700 text-text-secondary',
CANCELLED: 'bg-red-900 text-red-300'
};
let filter = $state('all');
const filtered = $derived(
filter === 'all'
? data.tournaments
: data.tournaments.filter((t: any) => {
if (filter === 'upcoming')
return ['DRAFT', 'REGISTRATION', 'QUALIFIERS', 'SEEDING'].includes(t.state);
if (filter === 'ongoing') return t.state === 'BRACKET';
if (filter === 'finished') return t.state === 'FINISHED';
return true;
})
);
function timeAgo(date: string | Date) {
const d = new Date(date);
const diff = Date.now() - d.getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
</script>
<svelte:head>
<title>Tournaments — Vash Esports</title>
</svelte:head>
<div class="mx-auto max-w-5xl">
<div class="flex items-center justify-between">
<div>
<h1 class="font-700 text-2xl tracking-tight">Tournaments</h1>
<p class="mt-1 text-sm text-text-secondary">
{data.tournaments.length} tournament{data.tournaments.length !== 1 ? 's' : ''}
</p>
</div>
<a
href="/tournaments/new"
class="font-600 rounded-md bg-accent px-4 py-2 text-sm text-surface-900 transition-colors hover:bg-accent-hover"
>
Create Tournament
</a>
</div>
<!-- Filters -->
<div class="mt-4 flex gap-1.5">
{#each [['all', 'All'], ['upcoming', 'Upcoming'], ['ongoing', 'Ongoing'], ['finished', 'Finished']] as [value, label]}
<button
class="font-600 rounded-md px-3 py-1.5 text-xs transition-colors {filter === value
? 'bg-accent text-surface-900'
: 'border border-border bg-surface-700 text-text-secondary hover:border-accent/40 hover:text-text-primary'}"
onclick={() => (filter = value)}
>
{label}
</button>
{/each}
</div>
<!-- Tournament List -->
{#if filtered.length === 0}
<div class="mt-6 rounded-lg border border-dashed border-border py-12 text-center">
<p class="text-sm text-text-secondary">No tournaments found</p>
</div>
{:else}
<div class="mt-6 flex flex-col gap-2">
{#each filtered as t (t.id)}
<a
href="/tournaments/{t.id}"
class="group flex items-center gap-4 rounded-lg border border-border bg-surface-800 p-4 transition-colors hover:border-accent/30 hover:bg-surface-700"
>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="font-600 text-sm">{t.name}</span>
<span
class="font-500 rounded px-2 py-0.5 text-xs {stateColors[t.state] ??
'bg-surface-600 text-text-secondary'}"
>
{t.state}
</span>
</div>
{#if t.description}
<p class="mt-0.5 truncate text-xs text-text-secondary">{t.description}</p>
{/if}
<p class="mt-1 text-xs text-text-secondary">
{formatLabels[t.format] ?? t.format}
&middot; {t.registrations.filter(
(r: any) => r.status !== 'withdrawn' && r.status !== 'eliminated'
).length}/{t.maxSlots} players
{#if t.startAt}
&middot; starts {new Date(t.startAt).toLocaleDateString()}
{/if}
&middot; {timeAgo(t.createdAt)}
</p>
</div>
<svg
class="h-4 w-4 text-text-secondary transition-colors group-hover:text-text-primary"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
</a>
{/each}
</div>
{/if}
</div>

View File

@@ -0,0 +1,291 @@
import { error, redirect } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import {
tournament,
tournamentRound,
tournamentMatch,
tournamentRegistration,
tournamentGroup,
tournamentGroupEntry,
tournamentStaff,
teamMember,
mappool
} from '$lib/server/db/schema';
import { eq, and, asc, desc } from 'drizzle-orm';
import { requireAuth } from '$lib/server/permissions';
import type { PageServerLoad, Actions } from './$types';
export const load: PageServerLoad = async ({ params, locals }) => {
requireAuth(locals);
const t = await db.query.tournament.findFirst({
where: eq(tournament.id, params.id),
with: {
rounds: {
orderBy: [asc(tournamentRound.roundOrder)],
with: { mappool: { with: { slots: true } } }
},
registrations: {
with: { team: true },
orderBy: [asc(tournamentRegistration.seed), asc(tournamentRegistration.registeredAt)]
},
staff: true,
groups: {
with: {
entries: { with: { team: true } }
}
}
}
});
if (!t) error(404, 'Tournament not found');
const matches = await db.query.tournamentMatch.findMany({
where: eq(tournamentMatch.tournamentId, params.id),
with: {
round: true,
team1: true,
team2: true,
winner: true,
match: {
with: {
participants: {
with: { team: true },
orderBy: (p: any, { asc }: any) => [asc(p.slot)]
}
}
}
}
});
// check if current user is staff
const isStaff = t.staff.some((s) => s.userId === locals.user!.id);
// check if current user is registered
const userTeams = await db.query.teamMember.findMany({
where: eq(teamMember.userId, locals.user!.id),
with: { team: true }
});
const userRegistration = t.registrations.find((r) =>
userTeams.some((ut) => ut.teamId === r.teamId)
);
// load mappools for round config (staff only in DRAFT)
let mappools: { id: string; name: string; slotCount: number }[] = [];
if (isStaff) {
const allMappools = await db.query.mappool.findMany({
with: { slots: { columns: { id: true } } },
orderBy: desc(mappool.createdAt)
});
mappools = allMappools.map((m) => ({
id: m.id,
name: m.name,
slotCount: m.slots.length
}));
}
return {
tournament: t,
matches,
isStaff,
userTeams: userTeams.map((ut) => ut.team),
userRegistration,
mappools
};
};
export const actions: Actions = {
register: async ({ params, request, locals }) => {
const user = requireAuth(locals);
const form = await request.formData();
const teamId = form.get('teamId')?.toString();
if (!teamId) return { error: 'Select a team' };
const { registerForTournament } = await import('$lib/server/tournament/lifecycle');
try {
await registerForTournament(params.id, teamId, user.id);
return { success: true };
} catch (e: any) {
return { error: e.message };
}
},
withdraw: async ({ params, request, locals }) => {
requireAuth(locals);
const form = await request.formData();
const teamId = form.get('teamId')?.toString();
if (!teamId) return { error: 'Missing teamId' };
const { withdrawFromTournament } = await import('$lib/server/tournament/lifecycle');
try {
await withdrawFromTournament(params.id, teamId);
return { withdrawn: true };
} catch (e: any) {
return { error: e.message };
}
},
openRegistration: async ({ params, locals }) => {
requireAuth(locals);
const { openRegistration } = await import('$lib/server/tournament/lifecycle');
try {
await openRegistration(params.id);
} catch (e: any) {
return { error: e.message };
}
},
closeRegistration: async ({ params, locals }) => {
requireAuth(locals);
const { closeRegistration } = await import('$lib/server/tournament/lifecycle');
try {
await closeRegistration(params.id);
} catch (e: any) {
return { error: e.message };
}
},
finalizeSeeding: async ({ params, locals }) => {
requireAuth(locals);
const { finalizeSeeding } = await import('$lib/server/tournament/lifecycle');
try {
await finalizeSeeding(params.id);
} catch (e: any) {
return { error: e.message };
}
},
cancel: async ({ params, locals }) => {
requireAuth(locals);
const { cancelTournament } = await import('$lib/server/tournament/lifecycle');
try {
await cancelTournament(params.id);
} catch (e: any) {
return { error: e.message };
}
},
addRound: async ({ params, request, locals }) => {
requireAuth(locals);
const form = await request.formData();
const name = form.get('name')?.toString()?.trim();
const abbreviation = form.get('abbreviation')?.toString()?.trim() || null;
const bestOf = parseInt(form.get('bestOf')?.toString() ?? '5');
const mappoolId = form.get('mappoolId')?.toString() || null;
const scheduledAt = form.get('scheduledAt')?.toString();
if (!name) return { error: 'Round name is required' };
if (bestOf < 1 || bestOf % 2 === 0) return { error: 'Best of must be an odd number' };
// get current max round order
const existing = await db.query.tournamentRound.findMany({
where: eq(tournamentRound.tournamentId, params.id),
orderBy: [desc(tournamentRound.roundOrder)]
});
const nextOrder = existing.length > 0 ? existing[0].roundOrder + 1 : 0;
try {
await db.insert(tournamentRound).values({
tournamentId: params.id,
name,
abbreviation,
roundOrder: nextOrder,
bestOf,
mappoolId: mappoolId || null,
scheduledAt: scheduledAt ? new Date(scheduledAt) : null
});
return { roundAdded: true };
} catch (e: any) {
return { error: e.message };
}
},
updateRound: async ({ params, request, locals }) => {
requireAuth(locals);
const form = await request.formData();
const roundId = form.get('roundId')?.toString();
const name = form.get('name')?.toString()?.trim();
const abbreviation = form.get('abbreviation')?.toString()?.trim() || null;
const bestOf = parseInt(form.get('bestOf')?.toString() ?? '5');
const mappoolId = form.get('mappoolId')?.toString() || null;
const scheduledAt = form.get('scheduledAt')?.toString();
if (!roundId) return { error: 'Missing roundId' };
if (!name) return { error: 'Round name is required' };
try {
await db
.update(tournamentRound)
.set({
name,
abbreviation,
bestOf,
mappoolId: mappoolId || null,
scheduledAt: scheduledAt ? new Date(scheduledAt) : null
})
.where(and(eq(tournamentRound.id, roundId), eq(tournamentRound.tournamentId, params.id)));
return { roundUpdated: true };
} catch (e: any) {
return { error: e.message };
}
},
deleteRound: async ({ params, request, locals }) => {
requireAuth(locals);
const form = await request.formData();
const roundId = form.get('roundId')?.toString();
if (!roundId) return { error: 'Missing roundId' };
try {
await db
.delete(tournamentRound)
.where(and(eq(tournamentRound.id, roundId), eq(tournamentRound.tournamentId, params.id)));
return { roundDeleted: true };
} catch (e: any) {
return { error: e.message };
}
},
addStaff: async ({ params, request, locals }) => {
requireAuth(locals);
const form = await request.formData();
const userId = form.get('userId')?.toString();
const role = form.get('role')?.toString() ?? 'referee';
if (!userId) return { error: 'User is required' };
try {
await db.insert(tournamentStaff).values({
tournamentId: params.id,
userId,
role
});
return { staffAdded: true };
} catch (e: any) {
return { error: e.message };
}
},
removeStaff: async ({ params, request, locals }) => {
requireAuth(locals);
const form = await request.formData();
const staffId = form.get('staffId')?.toString();
if (!staffId) return { error: 'Missing staffId' };
try {
await db
.delete(tournamentStaff)
.where(and(eq(tournamentStaff.id, staffId), eq(tournamentStaff.tournamentId, params.id)));
return { staffRemoved: true };
} catch (e: any) {
return { error: e.message };
}
}
};

View File

@@ -0,0 +1,903 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { invalidateAll } from '$app/navigation';
import { SvelteMap } from 'svelte/reactivity';
const { data } = $props();
const t = $derived(data.tournament);
const matches = $derived(data.matches);
let activeTab = $state('overview');
let actionError = $state('');
let actionSuccess = $state('');
// round form state
let showAddRound = $state(false);
let editingRoundId = $state<string | null>(null);
const formatLabels: Record<string, string> = {
single_elim: 'Single Elimination',
double_elim: 'Double Elimination',
groups_bracket: 'Groups + Bracket'
};
const stateColors: Record<string, string> = {
DRAFT: 'bg-surface-600 text-text-secondary',
REGISTRATION: 'bg-green-900 text-green-300',
QUALIFIERS: 'bg-yellow-900 text-yellow-300',
SEEDING: 'bg-yellow-900 text-yellow-300',
BRACKET: 'bg-blue-900 text-blue-300',
FINISHED: 'bg-surface-700 text-text-secondary',
CANCELLED: 'bg-red-900 text-red-300'
};
const activeRegs = $derived(
t.registrations.filter((r: any) => r.status !== 'withdrawn' && r.status !== 'eliminated')
);
// invite link
const inviteUrl = $derived(
typeof window !== 'undefined'
? `${window.location.origin}/tournaments/${t.id}`
: `/tournaments/${t.id}`
);
let copied = $state(false);
function copyInvite() {
navigator.clipboard.writeText(inviteUrl);
copied = true;
setTimeout(() => (copied = false), 2000);
}
// bracket helpers
const wbMatches = $derived(
matches.filter((m: any) => m.round?.bracketType === 'winners' || !m.round?.bracketType)
);
const lbMatches = $derived(matches.filter((m: any) => m.round?.bracketType === 'losers'));
const gfMatches = $derived(matches.filter((m: any) => m.round?.bracketType === 'grand_final'));
function groupByRound(matchList: any[]) {
const rounds = new SvelteMap<number, { round: any; matches: any[] }>();
for (const m of matchList) {
const order = m.round?.roundOrder ?? 0;
if (!rounds.has(order)) {
rounds.set(order, { round: m.round, matches: [] });
}
rounds.get(order)!.matches.push(m);
}
return Array.from(rounds.values()).sort((a, b) => a.round.roundOrder - b.round.roundOrder);
}
const wbRounds = $derived(groupByRound(wbMatches));
const lbRounds = $derived(groupByRound(lbMatches));
function handleAction() {
return ({ result }: any) => {
if (result.type === 'failure' || (result.type === 'success' && result.data?.error)) {
actionError = result.data?.error ?? 'Something went wrong';
actionSuccess = '';
} else {
actionError = '';
invalidateAll();
}
};
}
function handleActionWithMessage(msg: string) {
return ({ result }: any) => {
if (result.type === 'failure' || (result.type === 'success' && result.data?.error)) {
actionError = result.data?.error ?? 'Something went wrong';
actionSuccess = '';
} else {
actionError = '';
actionSuccess = msg;
showAddRound = false;
editingRoundId = null;
invalidateAll();
setTimeout(() => (actionSuccess = ''), 3000);
}
};
}
// which tabs to show
const tabs = $derived(() => {
const list: [string, string][] = [['overview', 'Overview']];
if (matches.length > 0) list.push(['bracket', 'Bracket']);
list.push(['registrations', 'Registrations']);
list.push(['rounds', 'Rounds']);
if (t.format === 'groups_bracket' && t.groups.length > 0) list.push(['groups', 'Groups']);
if (data.isStaff) list.push(['settings', 'Settings']);
return list;
});
</script>
<svelte:head>
<title>{t.name} — Vash Esports</title>
</svelte:head>
<div class="mx-auto max-w-5xl">
<!-- Header -->
<div class="flex items-start justify-between">
<div>
<div class="flex items-center gap-3">
<h1 class="font-700 text-2xl tracking-tight">{t.name}</h1>
<span
class="font-500 rounded px-2 py-0.5 text-xs {stateColors[t.state] ??
'bg-surface-600 text-text-secondary'}"
>
{t.state}
</span>
</div>
{#if t.description}
<p class="mt-1 text-sm text-text-secondary">{t.description}</p>
{/if}
<p class="mt-1 text-xs text-text-secondary">
{formatLabels[t.format] ?? t.format}
&middot; {activeRegs.length}/{t.maxSlots} players
{#if t.startAt}
&middot; starts {new Date(t.startAt).toLocaleDateString()}
{/if}
</p>
</div>
<div class="flex gap-2">
<button
onclick={copyInvite}
class="font-600 rounded-md border border-border px-3 py-2 text-xs text-text-secondary transition-colors hover:border-accent/40 hover:text-text-primary"
>
{copied ? 'Copied!' : 'Copy Link'}
</button>
</div>
</div>
<!-- Errors / Success -->
{#if actionError}
<div class="mt-4 rounded-md border border-red-800 bg-red-900/30 px-4 py-3 text-sm text-red-300">
{actionError}
<button onclick={() => (actionError = '')} class="ml-2 text-red-400 hover:text-red-300"
>&times;</button
>
</div>
{/if}
{#if actionSuccess}
<div
class="mt-4 rounded-md border border-green-800 bg-green-900/30 px-4 py-3 text-sm text-green-300"
>
{actionSuccess}
</div>
{/if}
<!-- Registration banner for players -->
{#if t.state === 'REGISTRATION' && !data.isStaff}
<div class="mt-4 rounded-lg border border-border bg-surface-800 p-4">
{#if data.userRegistration}
<div class="flex items-center justify-between">
<span class="text-sm text-green-400"
>You are registered with {data.userRegistration.team?.name ?? 'your team'}</span
>
<form method="POST" action="?/withdraw" use:enhance={() => handleAction()}>
<input type="hidden" name="teamId" value={data.userRegistration.teamId} />
<button
class="font-600 rounded-md border border-red-800 px-3 py-1.5 text-xs text-red-300 hover:bg-red-900/30"
>
Withdraw
</button>
</form>
</div>
{:else if data.userTeams.length > 0}
<form
method="POST"
action="?/register"
use:enhance={() => handleAction()}
class="flex items-center gap-3"
>
<span class="font-500 text-xs text-text-secondary">Register as:</span>
<select
name="teamId"
required
class="rounded-md border border-border bg-surface-700 px-3 py-1.5 text-sm text-text-primary focus:border-accent focus:outline-none"
>
{#each data.userTeams as team}
<option value={team.id}>{team.name}</option>
{/each}
</select>
<button
class="font-600 rounded-md bg-accent px-4 py-1.5 text-xs text-surface-900 transition-colors hover:bg-accent-hover"
>
Register
</button>
</form>
{:else}
<p class="text-sm text-text-secondary">You need a team to register for this tournament</p>
{/if}
</div>
{/if}
<!-- Tabs -->
<div class="mt-6 flex border-b border-border">
{#each tabs() as [value, label]}
<button
class="font-500 border-b-2 px-4 py-2 text-sm transition-colors {activeTab === value
? 'border-accent text-text-primary'
: 'border-transparent text-text-secondary hover:text-text-primary'}"
onclick={() => (activeTab = value)}
>
{label}
</button>
{/each}
</div>
<!-- Tab Content -->
<div class="mt-4">
{#if activeTab === 'overview'}
{@render overviewTab()}
{:else if activeTab === 'bracket'}
{@render bracketTab()}
{:else if activeTab === 'registrations'}
{@render registrationsTab()}
{:else if activeTab === 'rounds'}
{@render roundsTab()}
{:else if activeTab === 'groups'}
{@render groupsTab()}
{:else if activeTab === 'settings'}
{@render settingsTab()}
{/if}
</div>
</div>
<!-- ─── Overview Tab ─────────────────────────────────────────────────── -->
{#snippet overviewTab()}
<div class="grid gap-4 md:grid-cols-2">
<!-- Tournament Info -->
<div class="rounded-lg border border-border bg-surface-800 p-5">
<h3 class="font-600 text-sm">Details</h3>
<dl class="mt-3 flex flex-col gap-2 text-sm">
<div class="flex justify-between">
<dt class="text-text-secondary">Format</dt>
<dd class="font-500">{formatLabels[t.format] ?? t.format}</dd>
</div>
<div class="flex justify-between">
<dt class="text-text-secondary">Players</dt>
<dd class="font-500">{activeRegs.length} / {t.maxSlots}</dd>
</div>
<div class="flex justify-between">
<dt class="text-text-secondary">Team Size</dt>
<dd class="font-500">
{(t.config as any)?.teamSize ?? 1}v{(t.config as any)?.teamSize ?? 1}
</dd>
</div>
<div class="flex justify-between">
<dt class="text-text-secondary">Scoring</dt>
<dd class="font-500">{(t.config as any)?.scoringType ?? 'score_v2'}</dd>
</div>
<div class="flex justify-between">
<dt class="text-text-secondary">Rounds</dt>
<dd class="font-500">{t.rounds.length}</dd>
</div>
{#if t.startAt}
<div class="flex justify-between">
<dt class="text-text-secondary">Start Date</dt>
<dd class="font-500">{new Date(t.startAt).toLocaleString()}</dd>
</div>
{/if}
</dl>
</div>
<!-- Staff -->
<div class="rounded-lg border border-border bg-surface-800 p-5">
<h3 class="font-600 text-sm">Staff</h3>
{#if t.staff.length === 0}
<p class="mt-3 text-sm text-text-secondary">No staff assigned</p>
{:else}
<div class="mt-3 flex flex-col gap-1.5">
{#each t.staff as s}
<div class="flex items-center justify-between text-sm">
<span class="font-500">{s.userId.slice(0, 8)}...</span>
<span class="rounded bg-surface-600 px-2 py-0.5 text-xs text-text-secondary"
>{s.role}</span
>
</div>
{/each}
</div>
{/if}
</div>
<!-- Share / Invite -->
<div class="rounded-lg border border-border bg-surface-800 p-5 md:col-span-2">
<h3 class="font-600 text-sm">Invite Players</h3>
<p class="mt-1 text-xs text-text-secondary">
Share this link with players to let them register
</p>
<div class="mt-3 flex gap-2">
<input
type="text"
readonly
value={inviteUrl}
class="flex-1 rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:outline-none"
/>
<button
onclick={copyInvite}
class="font-600 rounded-md bg-accent px-4 py-2 text-sm text-surface-900 transition-colors hover:bg-accent-hover"
>
{copied ? 'Copied!' : 'Copy'}
</button>
</div>
</div>
<!-- Quick Rounds Preview -->
{#if t.rounds.length > 0}
<div class="rounded-lg border border-border bg-surface-800 p-5 md:col-span-2">
<div class="flex items-center justify-between">
<h3 class="font-600 text-sm">Rounds</h3>
<button
onclick={() => (activeTab = 'rounds')}
class="text-xs text-accent hover:text-accent-hover">View all</button
>
</div>
<div class="mt-3 flex flex-wrap gap-2">
{#each t.rounds as round}
<div
class="flex items-center gap-2 rounded-md border border-border bg-surface-700 px-3 py-1.5 text-xs"
>
<span class="font-600">{round.abbreviation ?? round.name}</span>
<span class="text-text-secondary">BO{round.bestOf}</span>
{#if round.mappool}
<span class="text-text-secondary">&middot; {round.mappool.name}</span>
{:else}
<span class="text-yellow-400">no pool</span>
{/if}
</div>
{/each}
</div>
</div>
{/if}
</div>
{/snippet}
<!-- ─── Bracket Tab ──────────────────────────────────────────────────── -->
{#snippet bracketTab()}
{#if matches.length === 0}
<div class="rounded-lg border border-dashed border-border py-12 text-center">
<p class="text-sm text-text-secondary">Bracket has not been generated yet</p>
</div>
{:else}
<!-- Winners Bracket -->
{#if wbRounds.length > 0}
{#if t.format === 'double_elim'}
<h3 class="font-600 mb-3 text-xs text-text-secondary uppercase">Winners Bracket</h3>
{/if}
<div class="mb-8 flex gap-6 overflow-x-auto pb-4">
{#each wbRounds as { round, matches: roundMatches }}
<div class="shrink-0">
<div class="mb-2 text-center text-xs text-text-secondary">
{round?.name ?? 'Round'}
</div>
<div
class="flex flex-col justify-around gap-3"
style="min-height: {roundMatches.length * 80}px"
>
{#each roundMatches.sort((a: any, b: any) => a.bracketPosition - b.bracketPosition) as m}
{@render matchCard(m)}
{/each}
</div>
</div>
{/each}
</div>
{/if}
<!-- Losers Bracket -->
{#if lbRounds.length > 0}
<h3 class="font-600 mb-3 text-xs text-text-secondary uppercase">Losers Bracket</h3>
<div class="mb-8 flex gap-6 overflow-x-auto pb-4">
{#each lbRounds as { round, matches: roundMatches }}
<div class="shrink-0">
<div class="mb-2 text-center text-xs text-text-secondary">
{round?.name ?? 'Round'}
</div>
<div class="flex flex-col justify-around gap-3">
{#each roundMatches.sort((a: any, b: any) => a.bracketPosition - b.bracketPosition) as m}
{@render matchCard(m)}
{/each}
</div>
</div>
{/each}
</div>
{/if}
<!-- Grand Finals -->
{#if gfMatches.length > 0}
<h3 class="font-600 mb-3 text-xs text-text-secondary uppercase">Grand Finals</h3>
<div class="mb-8 flex gap-6">
{#each gfMatches.sort((a: any, b: any) => (a.round?.roundOrder ?? 0) - (b.round?.roundOrder ?? 0)) as m}
{@render matchCard(m)}
{/each}
</div>
{/if}
{/if}
{/snippet}
<!-- ─── Registrations Tab ────────────────────────────────────────────── -->
{#snippet registrationsTab()}
<div class="rounded-lg border border-border bg-surface-800">
{#if t.registrations.length === 0}
<div class="py-8 text-center text-sm text-text-secondary">No registrations yet</div>
{:else}
<table class="w-full text-sm">
<thead>
<tr class="border-b border-border text-left">
<th class="font-500 px-4 py-3 text-xs text-text-secondary">Seed</th>
<th class="font-500 px-4 py-3 text-xs text-text-secondary">Team</th>
<th class="font-500 px-4 py-3 text-xs text-text-secondary">Status</th>
<th class="font-500 px-4 py-3 text-xs text-text-secondary">Registered</th>
</tr>
</thead>
<tbody>
{#each t.registrations as reg}
<tr class="border-b border-border/50 hover:bg-surface-700">
<td class="px-4 py-3 text-text-secondary">{reg.seed ?? '-'}</td>
<td class="font-500 px-4 py-3">{reg.team?.name ?? 'Unknown'}</td>
<td class="px-4 py-3">
<span
class="font-500 rounded px-2 py-0.5 text-xs {reg.status === 'registered' ||
reg.status === 'confirmed'
? 'bg-green-900 text-green-300'
: reg.status === 'withdrawn'
? 'bg-red-900 text-red-300'
: 'bg-surface-600 text-text-secondary'}"
>
{reg.status}
</span>
</td>
<td class="px-4 py-3 text-text-secondary">
{new Date(reg.registeredAt).toLocaleDateString()}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
{/snippet}
<!-- ─── Rounds Tab ───────────────────────────────────────────────────── -->
{#snippet roundsTab()}
<div class="flex flex-col gap-3">
{#if t.rounds.length === 0 && !showAddRound}
<div class="rounded-lg border border-dashed border-border py-8 text-center">
<p class="text-sm text-text-secondary">No rounds configured yet</p>
{#if data.isStaff && t.state === 'DRAFT'}
<button
onclick={() => (showAddRound = true)}
class="font-600 mt-3 rounded-md bg-accent px-4 py-2 text-xs text-surface-900 transition-colors hover:bg-accent-hover"
>
Add First Round
</button>
{/if}
</div>
{:else}
{#each t.rounds as round}
{#if editingRoundId === round.id && data.isStaff}
<!-- Inline edit form -->
<form
method="POST"
action="?/updateRound"
use:enhance={() => handleActionWithMessage('Round updated')}
class="rounded-lg border border-accent/20 bg-surface-800 p-4"
>
<input type="hidden" name="roundId" value={round.id} />
<div class="grid grid-cols-2 gap-3">
<div>
<span class="font-500 text-xs text-text-secondary">Name</span>
<input
name="name"
type="text"
value={round.name}
required
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
/>
</div>
<div>
<span class="font-500 text-xs text-text-secondary">Abbreviation</span>
<input
name="abbreviation"
type="text"
value={round.abbreviation ?? ''}
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
/>
</div>
<div>
<span class="font-500 text-xs text-text-secondary">Best Of</span>
<select
name="bestOf"
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
>
{#each [3, 5, 7, 9, 11, 13] as n}
<option value={n} selected={n === round.bestOf}>BO{n}</option>
{/each}
</select>
</div>
<div>
<span class="font-500 text-xs text-text-secondary">Mappool</span>
<select
name="mappoolId"
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
>
<option value="">None</option>
{#each data.mappools as mp}
<option value={mp.id} selected={mp.id === round.mappoolId}
>{mp.name} ({mp.slotCount} maps)</option
>
{/each}
</select>
</div>
<div class="col-span-2">
<span class="font-500 text-xs text-text-secondary">Scheduled At</span>
<input
name="scheduledAt"
type="datetime-local"
value={round.scheduledAt
? new Date(round.scheduledAt).toISOString().slice(0, 16)
: ''}
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
/>
</div>
</div>
<div class="mt-3 flex justify-end gap-2">
<button
type="button"
onclick={() => (editingRoundId = null)}
class="font-600 rounded-md border border-border px-3 py-1.5 text-xs text-text-secondary hover:text-text-primary"
>
Cancel
</button>
<button
type="submit"
class="font-600 rounded-md bg-accent px-4 py-1.5 text-xs text-surface-900 transition-colors hover:bg-accent-hover"
>
Save
</button>
</div>
</form>
{:else}
<!-- Round card -->
<div class="rounded-lg border border-border bg-surface-800 p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="font-600 text-sm">{round.name}</span>
{#if round.abbreviation}
<span class="rounded bg-surface-600 px-2 py-0.5 text-xs text-text-secondary"
>{round.abbreviation}</span
>
{/if}
<span class="text-xs text-text-secondary">BO{round.bestOf}</span>
</div>
<div class="flex items-center gap-3">
{#if round.mappool}
<span class="text-xs text-text-secondary"
>{round.mappool.name} ({round.mappool.slots.length} maps)</span
>
{:else}
<span class="text-xs text-yellow-400">no mappool</span>
{/if}
{#if round.scheduledAt}
<span class="text-xs text-text-secondary"
>{new Date(round.scheduledAt).toLocaleString()}</span
>
{/if}
{#if data.isStaff && t.state === 'DRAFT'}
<button
onclick={() => (editingRoundId = round.id)}
class="text-xs text-accent hover:text-accent-hover"
>
Edit
</button>
<form
method="POST"
action="?/deleteRound"
use:enhance={() => handleActionWithMessage('Round deleted')}
>
<input type="hidden" name="roundId" value={round.id} />
<button class="text-xs text-red-400 hover:text-red-300">Delete</button>
</form>
{/if}
</div>
</div>
</div>
{/if}
{/each}
{/if}
<!-- Add Round Form -->
{#if data.isStaff && t.state === 'DRAFT'}
{#if showAddRound}
<form
method="POST"
action="?/addRound"
use:enhance={() => handleActionWithMessage('Round added')}
class="rounded-lg border border-accent/20 bg-surface-800 p-4"
>
<h3 class="font-600 text-sm">Add Round</h3>
<div class="mt-3 grid grid-cols-2 gap-3">
<div>
<span class="font-500 text-xs text-text-secondary">Name</span>
<input
name="name"
type="text"
required
placeholder="e.g. Quarterfinals"
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary placeholder:text-text-secondary/50 focus:border-accent focus:outline-none"
/>
</div>
<div>
<span class="font-500 text-xs text-text-secondary">Abbreviation</span>
<input
name="abbreviation"
type="text"
placeholder="e.g. QF"
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary placeholder:text-text-secondary/50 focus:border-accent focus:outline-none"
/>
</div>
<div>
<span class="font-500 text-xs text-text-secondary">Best Of</span>
<select
name="bestOf"
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
>
{#each [3, 5, 7, 9, 11, 13] as n}
<option value={n} selected={n === 5}>BO{n}</option>
{/each}
</select>
</div>
<div>
<span class="font-500 text-xs text-text-secondary">Mappool</span>
<select
name="mappoolId"
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
>
<option value="">None</option>
{#each data.mappools as mp}
<option value={mp.id}>{mp.name} ({mp.slotCount} maps)</option>
{/each}
</select>
</div>
<div class="col-span-2">
<span class="font-500 text-xs text-text-secondary">Scheduled At (optional)</span>
<input
name="scheduledAt"
type="datetime-local"
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
/>
</div>
</div>
<div class="mt-3 flex justify-end gap-2">
<button
type="button"
onclick={() => (showAddRound = false)}
class="font-600 rounded-md border border-border px-3 py-1.5 text-xs text-text-secondary hover:text-text-primary"
>
Cancel
</button>
<button
type="submit"
class="font-600 rounded-md bg-accent px-4 py-1.5 text-xs text-surface-900 transition-colors hover:bg-accent-hover"
>
Add Round
</button>
</div>
</form>
{:else if t.rounds.length > 0}
<button
onclick={() => (showAddRound = true)}
class="font-600 self-start rounded-md border border-dashed border-border px-4 py-2 text-xs text-text-secondary transition-colors hover:border-accent/40 hover:text-text-primary"
>
+ Add Round
</button>
{/if}
{/if}
</div>
{/snippet}
<!-- ─── Groups Tab ───────────────────────────────────────────────────── -->
{#snippet groupsTab()}
<div class="grid gap-4 md:grid-cols-2">
{#each t.groups as group}
<div class="rounded-lg border border-border bg-surface-800 p-4">
<h3 class="font-600 text-sm">{group.name}</h3>
<table class="mt-3 w-full text-sm">
<thead>
<tr class="border-b border-border text-left">
<th class="font-500 px-2 py-2 text-xs text-text-secondary">Team</th>
<th class="font-500 px-2 py-2 text-center text-xs text-text-secondary">W</th>
<th class="font-500 px-2 py-2 text-center text-xs text-text-secondary">L</th>
<th class="font-500 px-2 py-2 text-center text-xs text-text-secondary">MD</th>
</tr>
</thead>
<tbody>
{#each group.entries.sort((a: any, b: any) => b.wins - a.wins || b.mapWins - b.mapLosses - (a.mapWins - a.mapLosses)) as entry}
<tr class="border-b border-border/50">
<td class="font-500 px-2 py-2">{entry.team?.name ?? 'TBD'}</td>
<td class="px-2 py-2 text-center text-green-400">{entry.wins}</td>
<td class="px-2 py-2 text-center text-red-400">{entry.losses}</td>
<td class="px-2 py-2 text-center text-text-secondary">
{entry.mapWins - entry.mapLosses > 0 ? '+' : ''}{entry.mapWins - entry.mapLosses}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/each}
</div>
{/snippet}
<!-- ─── Settings Tab (Staff Only) ────────────────────────────────────── -->
{#snippet settingsTab()}
<div class="flex flex-col gap-4">
<!-- Lifecycle Actions -->
<div class="rounded-lg border border-border bg-surface-800 p-5">
<h3 class="font-600 text-sm">Tournament Actions</h3>
<p class="mt-1 text-xs text-text-secondary">Manage the tournament state and lifecycle</p>
<div class="mt-4 flex flex-wrap gap-2">
{#if t.state === 'DRAFT'}
<form method="POST" action="?/openRegistration" use:enhance={() => handleAction()}>
<button
class="font-600 rounded-md bg-green-800 px-4 py-2 text-xs text-green-200 transition-colors hover:bg-green-700"
>
Open Registration
</button>
</form>
{/if}
{#if t.state === 'REGISTRATION'}
<form method="POST" action="?/closeRegistration" use:enhance={() => handleAction()}>
<button
class="font-600 rounded-md bg-yellow-800 px-4 py-2 text-xs text-yellow-200 transition-colors hover:bg-yellow-700"
>
Close Registration
</button>
</form>
{/if}
{#if t.state === 'SEEDING' || t.state === 'QUALIFIERS'}
<form method="POST" action="?/finalizeSeeding" use:enhance={() => handleAction()}>
<button
class="font-600 rounded-md bg-blue-800 px-4 py-2 text-xs text-blue-200 transition-colors hover:bg-blue-700"
>
Generate Bracket
</button>
</form>
{/if}
{#if t.state !== 'FINISHED' && t.state !== 'CANCELLED'}
<form method="POST" action="?/cancel" use:enhance={() => handleAction()}>
<button
class="font-600 rounded-md bg-red-800 px-4 py-2 text-xs text-red-200 transition-colors hover:bg-red-700"
>
Cancel Tournament
</button>
</form>
{/if}
{#if t.state === 'FINISHED' || t.state === 'CANCELLED'}
<p class="self-center text-xs text-text-secondary">This tournament has ended</p>
{/if}
</div>
{#if t.state === 'DRAFT' && t.rounds.length === 0}
<p class="mt-3 text-xs text-yellow-400">
You need to add at least one round before opening registration
</p>
{/if}
</div>
<!-- Staff Management -->
<div class="rounded-lg border border-border bg-surface-800 p-5">
<h3 class="font-600 text-sm">Staff</h3>
<div class="mt-3 flex flex-col gap-2">
{#each t.staff as s}
<div
class="flex items-center justify-between rounded-md bg-surface-700 px-3 py-2 text-sm"
>
<div class="flex items-center gap-2">
<span class="font-500">{s.userId.slice(0, 8)}...</span>
<span class="rounded bg-surface-600 px-2 py-0.5 text-xs text-text-secondary"
>{s.role}</span
>
</div>
{#if t.staff.length > 1}
<form
method="POST"
action="?/removeStaff"
use:enhance={() => handleActionWithMessage('Staff removed')}
>
<input type="hidden" name="staffId" value={s.id} />
<button class="text-xs text-red-400 hover:text-red-300">Remove</button>
</form>
{/if}
</div>
{/each}
</div>
</div>
<!-- Tournament Info -->
<div class="rounded-lg border border-border bg-surface-800 p-5">
<h3 class="font-600 text-sm">Info</h3>
<dl class="mt-3 flex flex-col gap-1 text-sm">
<div class="flex justify-between">
<dt class="text-text-secondary">ID</dt>
<dd class="font-mono text-xs text-text-secondary">{t.id}</dd>
</div>
<div class="flex justify-between">
<dt class="text-text-secondary">Created</dt>
<dd class="text-text-secondary">{new Date(t.createdAt).toLocaleString()}</dd>
</div>
{#if t.registrationOpenAt}
<div class="flex justify-between">
<dt class="text-text-secondary">Registration Opened</dt>
<dd class="text-text-secondary">{new Date(t.registrationOpenAt).toLocaleString()}</dd>
</div>
{/if}
{#if t.finishedAt}
<div class="flex justify-between">
<dt class="text-text-secondary">Finished</dt>
<dd class="text-text-secondary">{new Date(t.finishedAt).toLocaleString()}</dd>
</div>
{/if}
</dl>
</div>
</div>
{/snippet}
<!-- ─── Match Card Snippet ───────────────────────────────────────────── -->
{#snippet matchCard(m: any)}
<div
class="w-52 overflow-hidden rounded-lg border bg-surface-800 {m.state === 'LIVE'
? 'border-blue-500/50'
: 'border-border'}"
>
<div
class="flex items-center justify-between border-b border-border/50 px-3 py-2 {m.winnerId ===
m.team1Id
? 'bg-green-900/20'
: ''}"
>
<span class="font-500 truncate text-sm {m.team1 ? '' : 'text-text-secondary'}">
{m.team1?.name ?? 'TBD'}
</span>
{#if m.match?.participants}
{@const p = m.match.participants.find((p: any) => p.teamId === m.team1Id)}
{#if p}
<span
class="font-700 ml-2 font-mono text-sm tabular-nums {m.winnerId === m.team1Id
? 'text-green-400'
: 'text-text-secondary'}">{p.score}</span
>
{/if}
{/if}
</div>
<div
class="flex items-center justify-between px-3 py-2 {m.winnerId === m.team2Id
? 'bg-green-900/20'
: ''}"
>
<span class="font-500 truncate text-sm {m.team2 ? '' : 'text-text-secondary'}">
{m.team2?.name ?? 'TBD'}
</span>
{#if m.match?.participants}
{@const p = m.match.participants.find((p: any) => p.teamId === m.team2Id)}
{#if p}
<span
class="font-700 ml-2 font-mono text-sm tabular-nums {m.winnerId === m.team2Id
? 'text-green-400'
: 'text-text-secondary'}">{p.score}</span
>
{/if}
{/if}
</div>
{#if m.state === 'LIVE'}
<div class="bg-blue-900/30 px-3 py-1 text-center text-xs text-blue-300">LIVE</div>
{:else if m.state === 'BYE'}
<div class="bg-surface-700 px-3 py-1 text-center text-xs text-text-secondary">BYE</div>
{:else if m.matchId}
<a
href="/matches/{m.matchId}"
class="block bg-surface-700 px-3 py-1 text-center text-xs text-text-secondary hover:text-text-primary"
>
View Match
</a>
{/if}
</div>
{/snippet}

View File

@@ -0,0 +1,55 @@
import { redirect } from '@sveltejs/kit';
import { requireAuth } from '$lib/server/permissions';
import { createTournament } from '$lib/server/tournament/lifecycle';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
requireAuth(locals);
return {};
};
export const actions: Actions = {
default: async ({ request, locals }) => {
const user = requireAuth(locals);
const form = await request.formData();
const name = form.get('name')?.toString()?.trim();
const description = form.get('description')?.toString()?.trim();
const format = form.get('format')?.toString() as
| 'single_elim'
| 'double_elim'
| 'groups_bracket';
const maxSlots = parseInt(form.get('maxSlots')?.toString() ?? '16');
const teamSize = parseInt(form.get('teamSize')?.toString() ?? '1');
const scoringType = (form.get('scoringType')?.toString() ?? 'score_v2') as
| 'score'
| 'score_v2'
| 'accuracy'
| 'combo';
const startAt = form.get('startAt')?.toString();
if (!name) return { error: 'Tournament name is required' };
if (!format) return { error: 'Format is required' };
let tournamentId: string;
try {
const t = await createTournament({
name,
description,
format,
config: {
teamSize,
scoringType
},
maxSlots,
createdBy: user.id,
startAt: startAt ? new Date(startAt) : undefined
});
tournamentId = t.id;
} catch (e: any) {
return { error: e.message };
}
redirect(303, `/tournaments/${tournamentId}`);
}
};

View File

@@ -0,0 +1,158 @@
<script lang="ts">
import { enhance } from '$app/forms';
let error = $state('');
let submitting = $state(false);
let format = $state('single_elim');
</script>
<svelte:head>
<title>Create Tournament — Vash Esports</title>
</svelte:head>
<div class="mx-auto max-w-2xl">
<div>
<h1 class="font-700 text-2xl tracking-tight">Create Tournament</h1>
<p class="mt-1 text-sm text-text-secondary">
Set up your tournament, then configure rounds and mappools
</p>
</div>
{#if error}
<p class="mt-4 text-sm text-red-400">{error}</p>
{/if}
<form
method="POST"
use:enhance={() => {
submitting = true;
error = '';
return async ({ result, update }) => {
submitting = false;
if (
result.type === 'failure' ||
(result.type === 'success' && (result.data as any)?.error)
) {
error = (result.data as any)?.error ?? 'Failed to create tournament';
} else {
await update();
}
};
}}
class="mt-6 flex flex-col gap-5"
>
<!-- Name -->
<div>
<label for="name" class="font-500 text-xs text-text-secondary">Tournament Name</label>
<input
id="name"
name="name"
type="text"
required
placeholder="e.g. Vash Open #1"
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary placeholder:text-text-secondary/50 focus:border-accent focus:outline-none"
/>
</div>
<!-- Description -->
<div>
<label for="description" class="font-500 text-xs text-text-secondary"
>Description (optional)</label
>
<textarea
id="description"
name="description"
rows="2"
placeholder="What's this tournament about?"
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary placeholder:text-text-secondary/50 focus:border-accent focus:outline-none"
></textarea>
</div>
<!-- Format + Slots -->
<div class="grid grid-cols-2 gap-4">
<div>
<label for="format" class="font-500 text-xs text-text-secondary">Format</label>
<select
id="format"
name="format"
required
bind:value={format}
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
>
<option value="single_elim">Single Elimination</option>
<option value="double_elim">Double Elimination</option>
<option value="groups_bracket">Groups + Bracket</option>
</select>
</div>
<div>
<label for="maxSlots" class="font-500 text-xs text-text-secondary">Max Players</label>
<select
id="maxSlots"
name="maxSlots"
required
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
>
{#each [4, 8, 16, 32, 64, 128] as n}
<option value={n} selected={n === 16}>{n}</option>
{/each}
</select>
</div>
</div>
<!-- Team Size + Scoring -->
<div class="grid grid-cols-2 gap-4">
<div>
<label for="teamSize" class="font-500 text-xs text-text-secondary">Team Size</label>
<select
id="teamSize"
name="teamSize"
required
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
>
<option value="1" selected>1v1</option>
<option value="2">2v2</option>
<option value="3">3v3</option>
<option value="4">4v4</option>
</select>
</div>
<div>
<label for="scoringType" class="font-500 text-xs text-text-secondary">Scoring</label>
<select
id="scoringType"
name="scoringType"
required
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
>
<option value="score_v2" selected>ScoreV2</option>
<option value="score">Score V1</option>
<option value="accuracy">Accuracy</option>
<option value="combo">Combo</option>
</select>
</div>
</div>
<!-- Start Date -->
<div>
<label for="startAt" class="font-500 text-xs text-text-secondary">Start Date (optional)</label
>
<input
id="startAt"
name="startAt"
type="datetime-local"
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
/>
</div>
<div class="mt-1 flex justify-end">
<button
type="submit"
disabled={submitting}
class="font-600 rounded-md bg-accent px-5 py-2 text-sm text-surface-900 transition-colors hover:bg-accent-hover disabled:opacity-50"
>
{submitting ? 'Creating...' : 'Create Tournament'}
</button>
</div>
</form>
</div>

View File

@@ -1,26 +1,27 @@
import { json, error } from '@sveltejs/kit';
import { createInvite, getInvitesForUser } from '$lib/server/match/invites';
import { requireAuth } from '$lib/server/permissions';
import { createInviteSchema, parseBody } from '$lib/server/validation';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ locals }) => {
if (!locals.user) error(401, 'Not logged in');
const invites = await getInvitesForUser(locals.user.id);
const user = requireAuth(locals);
const invites = await getInvitesForUser(user.id);
return json(invites);
};
export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.user) error(401, 'Not logged in');
const user = requireAuth(locals);
const body = await request.json();
const { creatorTeamId, invitedTeamId, config, mappoolId, name, message, scheduledAt } = body;
if (!creatorTeamId || !invitedTeamId || !config || !mappoolId) {
error(400, 'Missing required fields');
}
const { creatorTeamId, invitedTeamId, config, mappoolId, name, message, scheduledAt } = parseBody(
createInviteSchema,
body
);
try {
const invite = await createInvite({
createdBy: locals.user.id,
createdBy: user.id,
creatorTeamId,
invitedTeamId,
config,

View File

@@ -1,11 +1,13 @@
import { json, error } from '@sveltejs/kit';
import { json } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { mappool } from '$lib/server/db/schema';
import { requireAuth, requireRole } from '$lib/server/permissions';
import { createMappoolSchema, parseBody } from '$lib/server/validation';
import type { RequestHandler } from './$types';
// List all mappools
export const GET: RequestHandler = async ({ locals }) => {
if (!locals.user) error(401, 'Not logged in');
requireAuth(locals);
const mappools = await db.query.mappool.findMany({
orderBy: (m, { desc }) => [desc(m.createdAt)]
@@ -14,18 +16,18 @@ export const GET: RequestHandler = async ({ locals }) => {
return json(mappools);
};
// Create a mappool
// Create a mappool (admin only)
export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.user) error(401, 'Not logged in');
requireRole(locals, 'admin', 'Only admins can create mappools');
const { name } = await request.json();
if (!name?.trim()) error(400, 'Name is required');
const body = await request.json();
const { name } = parseBody(createMappoolSchema, body);
const [created] = await db
.insert(mappool)
.values({
name: name.trim(),
createdBy: locals.user.id
name,
createdBy: locals.user!.id
})
.returning();

View File

@@ -3,6 +3,7 @@ import { db } from '$lib/server/db';
import { mappool, mappoolSlot } from '$lib/server/db/schema';
import { eq, and } from 'drizzle-orm';
import { requireOwnerOrAdmin } from '$lib/server/permissions';
import { createMappoolSlotSchema, parseBody } from '$lib/server/validation';
import type { RequestHandler } from './$types';
// Add a slot to a mappool
@@ -15,26 +16,20 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
requireOwnerOrAdmin(locals, pool.createdBy, 'Only the mappool creator or an admin can add slots');
const { beatmapId, category, mods } = await request.json();
if (!beatmapId || !category) {
error(400, 'beatmapId and category are required');
}
const body = await request.json();
const { beatmapId, category, mods } = parseBody(createMappoolSlotSchema, body);
// Count existing slots in this category to auto-increment order
const existing = await db.query.mappoolSlot.findMany({
where: and(
eq(mappoolSlot.mappoolId, params.id),
eq(mappoolSlot.category, category.toUpperCase())
)
where: and(eq(mappoolSlot.mappoolId, params.id), eq(mappoolSlot.category, category))
});
const [created] = await db
.insert(mappoolSlot)
.values({
mappoolId: params.id,
beatmapId: String(beatmapId),
category: category.toUpperCase(),
beatmapId,
category,
orderInCategory: existing.length + 1,
mods: mods ?? []
})

View File

@@ -2,10 +2,12 @@
import { json, error } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { createMatch } from '$lib/server/match/engine';
import { requireAuth, requireRole } from '$lib/server/permissions';
import { createMatchSchema, parseBody } from '$lib/server/validation';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ locals }) => {
if (!locals.user) error(401, 'Not logged in');
requireAuth(locals);
const matches = await db.query.match.findMany({
with: {
@@ -18,14 +20,10 @@ export const GET: RequestHandler = async ({ locals }) => {
};
export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.user) error(401, 'Not logged in');
requireRole(locals, 'referee', 'Only referees or admins can create matches');
const body = await request.json();
const { name, config, mappoolId, teams } = body;
if (!name || !config || !mappoolId || !teams?.length) {
error(400, 'Missing required fields');
}
const { name, config, mappoolId, teams } = parseBody(createMatchSchema, body);
try {
const result = await createMatch({
@@ -33,7 +31,7 @@ export const POST: RequestHandler = async ({ request, locals }) => {
config,
mappoolId,
teams,
createdBy: locals.user.id
createdBy: locals.user!.id
});
return json(result, { status: 201 });
} catch (e: any) {

View File

@@ -3,15 +3,14 @@ import { json, error } from '@sveltejs/kit';
import { pickMap } from '$lib/server/match/engine';
import { requireAuth, hasRole } from '$lib/server/permissions';
import { getMatchFull } from '$lib/server/match/helpers';
import { pickMapSchema, parseBody } from '$lib/server/validation';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ params, request, locals }) => {
const user = requireAuth(locals);
const { mappoolSlotId } = await request.json();
if (!mappoolSlotId) {
error(400, 'mappoolSlotId required');
}
const body = await request.json();
const { mappoolSlotId } = parseBody(pickMapSchema, body);
const match = await getMatchFull(params.id);

View File

@@ -3,15 +3,14 @@ import { json, error } from '@sveltejs/kit';
import { submitRoll } from '$lib/server/match/engine';
import { requireAuth, hasRole } from '$lib/server/permissions';
import { getMatchFull } from '$lib/server/match/helpers';
import { submitRollSchema, parseBody } from '$lib/server/validation';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ params, request, locals }) => {
const user = requireAuth(locals);
const { value } = await request.json();
if (value == null) {
error(400, 'value required');
}
const body = await request.json();
const { value } = parseBody(submitRollSchema, body);
const match = await getMatchFull(params.id);

View File

@@ -2,15 +2,14 @@
import { json, error } from '@sveltejs/kit';
import { submitGameScores } from '$lib/server/match/engine';
import { requireRole } from '$lib/server/permissions';
import { submitScoreSchema, parseBody } from '$lib/server/validation';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ request, locals }) => {
requireRole(locals, 'referee', 'Only referees or admins can submit scores');
const { matchGameId, scores } = await request.json();
if (!matchGameId || !scores?.length) {
error(400, 'matchGameId and scores required');
}
const body = await request.json();
const { matchGameId, scores } = parseBody(submitScoreSchema, body);
try {
const m = await submitGameScores(matchGameId, scores);

View File

@@ -0,0 +1,76 @@
import { json, error } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { tournament } from '$lib/server/db/schema';
import { eq, desc } from 'drizzle-orm';
import { requireAuth } from '$lib/server/permissions';
import { createTournament } from '$lib/server/tournament/lifecycle';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ url }) => {
const state = url.searchParams.get('state');
const format = url.searchParams.get('format');
const query = db.query.tournament.findMany({
with: {
registrations: true,
staff: true
},
orderBy: [desc(tournament.createdAt)]
});
const results = await query;
// filter in memory (drizzle doesn't support dynamic where chaining easily)
let filtered = results;
if (state) filtered = filtered.filter((t) => t.state === state);
if (format) filtered = filtered.filter((t) => t.format === format);
return json(filtered);
};
export const POST: RequestHandler = async ({ request, locals }) => {
const user = requireAuth(locals);
const body = await request.json();
const {
name,
description,
format,
config,
maxSlots,
bannerUrl,
registrationOpenAt,
registrationCloseAt,
startAt
} = body;
if (!name || !format || !config || !maxSlots) {
error(400, 'Missing required fields: name, format, config, maxSlots');
}
if (!['single_elim', 'double_elim', 'groups_bracket'].includes(format)) {
error(400, 'Invalid format. Must be single_elim, double_elim, or groups_bracket');
}
if (!config.teamSize || !config.scoringType) {
error(400, 'Config must include teamSize and scoringType');
}
try {
const result = await createTournament({
name,
description,
format,
config,
maxSlots,
bannerUrl,
createdBy: user.id,
registrationOpenAt: registrationOpenAt ? new Date(registrationOpenAt) : undefined,
registrationCloseAt: registrationCloseAt ? new Date(registrationCloseAt) : undefined,
startAt: startAt ? new Date(startAt) : undefined
});
return json(result, { status: 201 });
} catch (e: any) {
error(400, e.message);
}
};

View File

@@ -0,0 +1,83 @@
import { json, error } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { tournament, tournamentStaff } from '$lib/server/db/schema';
import { eq, and } from 'drizzle-orm';
import { requireAuth } from '$lib/server/permissions';
import { getTournamentFull } from '$lib/server/tournament/lifecycle';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params }) => {
const result = await getTournamentFull(params.id);
if (!result) error(404, 'Tournament not found');
return json(result);
};
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
const user = requireAuth(locals);
await requireTournamentStaff(params.id, user.id);
const t = await db.query.tournament.findFirst({
where: eq(tournament.id, params.id)
});
if (!t) error(404, 'Tournament not found');
if (t.state !== 'DRAFT') error(400, 'Can only edit tournaments in DRAFT state');
const body = await request.json();
const allowed = [
'name',
'description',
'format',
'config',
'maxSlots',
'bannerUrl',
'registrationOpenAt',
'registrationCloseAt',
'startAt'
];
const updates: Record<string, unknown> = {};
for (const key of allowed) {
if (body[key] !== undefined) {
if (key.endsWith('At') && body[key]) {
updates[key] = new Date(body[key]);
} else {
updates[key] = body[key];
}
}
}
if (Object.keys(updates).length === 0) {
error(400, 'No valid fields to update');
}
await db.update(tournament).set(updates).where(eq(tournament.id, params.id));
return json(await getTournamentFull(params.id));
};
export const DELETE: RequestHandler = async ({ params, locals }) => {
const user = requireAuth(locals);
await requireTournamentStaff(params.id, user.id);
const t = await db.query.tournament.findFirst({
where: eq(tournament.id, params.id)
});
if (!t) error(404, 'Tournament not found');
if (t.state === 'DRAFT') {
await db.delete(tournament).where(eq(tournament.id, params.id));
return json({ deleted: true });
}
// for non-draft, cancel instead of delete
const { cancelTournament } = await import('$lib/server/tournament/lifecycle');
await cancelTournament(params.id);
return json(await getTournamentFull(params.id));
};
async function requireTournamentStaff(tournamentId: string, userId: string) {
const staff = await db.query.tournamentStaff.findFirst({
where: and(eq(tournamentStaff.tournamentId, tournamentId), eq(tournamentStaff.userId, userId))
});
if (!staff) error(403, 'Not a staff member of this tournament');
return staff;
}

View File

@@ -0,0 +1,28 @@
import { json, error } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { tournamentMatch, tournamentRound } from '$lib/server/db/schema';
import { eq, asc } from 'drizzle-orm';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params }) => {
const rounds = await db.query.tournamentRound.findMany({
where: eq(tournamentRound.tournamentId, params.id),
orderBy: [asc(tournamentRound.roundOrder)]
});
const matches = await db.query.tournamentMatch.findMany({
where: eq(tournamentMatch.tournamentId, params.id),
with: {
team1: true,
team2: true,
winner: true,
match: {
with: {
participants: { with: { team: true } }
}
}
}
});
return json({ rounds, matches });
};

View File

@@ -0,0 +1,23 @@
import { json, error } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { tournamentStaff } from '$lib/server/db/schema';
import { eq, and } from 'drizzle-orm';
import { requireAuth } from '$lib/server/permissions';
import { cancelTournament, getTournamentFull } from '$lib/server/tournament/lifecycle';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ params, locals }) => {
const user = requireAuth(locals);
const staff = await db.query.tournamentStaff.findFirst({
where: and(eq(tournamentStaff.tournamentId, params.id), eq(tournamentStaff.userId, user.id))
});
if (!staff) error(403, 'Not a staff member of this tournament');
try {
await cancelTournament(params.id);
return json(await getTournamentFull(params.id));
} catch (e: any) {
error(400, e.message);
}
};

View File

@@ -0,0 +1,23 @@
import { json, error } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { tournamentStaff } from '$lib/server/db/schema';
import { eq, and } from 'drizzle-orm';
import { requireAuth } from '$lib/server/permissions';
import { closeRegistration, getTournamentFull } from '$lib/server/tournament/lifecycle';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ params, locals }) => {
const user = requireAuth(locals);
const staff = await db.query.tournamentStaff.findFirst({
where: and(eq(tournamentStaff.tournamentId, params.id), eq(tournamentStaff.userId, user.id))
});
if (!staff) error(403, 'Not a staff member of this tournament');
try {
await closeRegistration(params.id);
return json(await getTournamentFull(params.id));
} catch (e: any) {
error(400, e.message);
}
};

View File

@@ -0,0 +1,23 @@
import { json, error } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { tournamentStaff } from '$lib/server/db/schema';
import { eq, and } from 'drizzle-orm';
import { requireAuth } from '$lib/server/permissions';
import { finalizeSeeding, getTournamentFull } from '$lib/server/tournament/lifecycle';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ params, locals }) => {
const user = requireAuth(locals);
const staff = await db.query.tournamentStaff.findFirst({
where: and(eq(tournamentStaff.tournamentId, params.id), eq(tournamentStaff.userId, user.id))
});
if (!staff) error(403, 'Not a staff member of this tournament');
try {
await finalizeSeeding(params.id);
return json(await getTournamentFull(params.id));
} catch (e: any) {
error(400, e.message);
}
};

View File

@@ -0,0 +1,25 @@
import { json } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { tournamentMatch } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params }) => {
const matches = await db.query.tournamentMatch.findMany({
where: eq(tournamentMatch.tournamentId, params.id),
with: {
round: true,
team1: true,
team2: true,
winner: true,
match: {
with: {
participants: { with: { team: true } },
games: { with: { slot: true } }
}
}
}
});
return json(matches);
};

View File

@@ -0,0 +1,23 @@
import { json, error } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { tournamentStaff } from '$lib/server/db/schema';
import { eq, and } from 'drizzle-orm';
import { requireAuth } from '$lib/server/permissions';
import { openRegistration, getTournamentFull } from '$lib/server/tournament/lifecycle';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ params, locals }) => {
const user = requireAuth(locals);
const staff = await db.query.tournamentStaff.findFirst({
where: and(eq(tournamentStaff.tournamentId, params.id), eq(tournamentStaff.userId, user.id))
});
if (!staff) error(403, 'Not a staff member of this tournament');
try {
await openRegistration(params.id);
return json(await getTournamentFull(params.id));
} catch (e: any) {
error(400, e.message);
}
};

View File

@@ -0,0 +1,50 @@
import { json, error } from '@sveltejs/kit';
import {
getQualifierRankings,
getQualifierMappool,
submitQualifierScore
} from '$lib/server/tournament/qualifiers';
import { db } from '$lib/server/db';
import { tournamentStaff } from '$lib/server/db/schema';
import { eq, and } from 'drizzle-orm';
import { requireAuth } from '$lib/server/permissions';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params }) => {
const [rankings, mappool] = await Promise.all([
getQualifierRankings(params.id),
getQualifierMappool(params.id)
]);
return json({ rankings, mappool });
};
export const POST: RequestHandler = async ({ params, request, locals }) => {
const user = requireAuth(locals);
// only staff can submit qualifier scores (referees run qualifier lobbies)
const staff = await db.query.tournamentStaff.findFirst({
where: and(eq(tournamentStaff.tournamentId, params.id), eq(tournamentStaff.userId, user.id))
});
if (!staff) error(403, 'Only tournament staff can submit qualifier scores');
const body = await request.json();
const { teamId, mappoolSlotId, totalScore, accuracy } = body;
if (!teamId || !mappoolSlotId || totalScore === undefined) {
error(400, 'Missing required fields: teamId, mappoolSlotId, totalScore');
}
try {
const score = await submitQualifierScore({
tournamentId: params.id,
teamId,
mappoolSlotId,
totalScore,
accuracy
});
return json(score, { status: 201 });
} catch (e: any) {
error(400, e.message);
}
};

View File

@@ -0,0 +1,32 @@
import { json, error } from '@sveltejs/kit';
import { requireAuth } from '$lib/server/permissions';
import { registerForTournament, withdrawFromTournament } from '$lib/server/tournament/lifecycle';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ params, request, locals }) => {
const user = requireAuth(locals);
const body = await request.json();
if (!body.teamId) error(400, 'Missing teamId');
try {
const reg = await registerForTournament(params.id, body.teamId, user.id);
return json(reg, { status: 201 });
} catch (e: any) {
error(400, e.message);
}
};
export const DELETE: RequestHandler = async ({ params, request, locals }) => {
const user = requireAuth(locals);
const body = await request.json();
if (!body.teamId) error(400, 'Missing teamId');
try {
await withdrawFromTournament(params.id, body.teamId);
return json({ withdrawn: true });
} catch (e: any) {
error(400, e.message);
}
};

View File

@@ -0,0 +1,50 @@
import { json, error } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { tournamentRound, tournamentStaff } from '$lib/server/db/schema';
import { eq, and, asc } from 'drizzle-orm';
import { requireAuth } from '$lib/server/permissions';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params }) => {
const rounds = await db.query.tournamentRound.findMany({
where: eq(tournamentRound.tournamentId, params.id),
with: { mappool: { with: { slots: true } } },
orderBy: [asc(tournamentRound.roundOrder)]
});
return json(rounds);
};
export const POST: RequestHandler = async ({ params, request, locals }) => {
const user = requireAuth(locals);
const staff = await db.query.tournamentStaff.findFirst({
where: and(eq(tournamentStaff.tournamentId, params.id), eq(tournamentStaff.userId, user.id))
});
if (!staff) error(403, 'Not a staff member of this tournament');
const body = await request.json();
const { name, abbreviation, bestOf, mappoolId, bracketType, scheduledAt } = body;
if (!name || !bestOf) error(400, 'Missing required fields: name, bestOf');
// get next round order
const existing = await db.query.tournamentRound.findMany({
where: eq(tournamentRound.tournamentId, params.id)
});
const [created] = await db
.insert(tournamentRound)
.values({
tournamentId: params.id,
name,
abbreviation,
roundOrder: existing.length,
bestOf,
mappoolId,
bracketType,
scheduledAt: scheduledAt ? new Date(scheduledAt) : undefined
})
.returning();
return json(created, { status: 201 });
};

View File

@@ -0,0 +1,50 @@
import { json, error } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { tournamentRound, tournamentStaff } from '$lib/server/db/schema';
import { eq, and } from 'drizzle-orm';
import { requireAuth } from '$lib/server/permissions';
import type { RequestHandler } from './$types';
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
const user = requireAuth(locals);
const staff = await db.query.tournamentStaff.findFirst({
where: and(eq(tournamentStaff.tournamentId, params.id), eq(tournamentStaff.userId, user.id))
});
if (!staff) error(403, 'Not a staff member of this tournament');
const body = await request.json();
const updates: Record<string, unknown> = {};
if (body.name !== undefined) updates.name = body.name;
if (body.abbreviation !== undefined) updates.abbreviation = body.abbreviation;
if (body.bestOf !== undefined) updates.bestOf = body.bestOf;
if (body.mappoolId !== undefined) updates.mappoolId = body.mappoolId;
if (body.scheduledAt !== undefined)
updates.scheduledAt = body.scheduledAt ? new Date(body.scheduledAt) : null;
if (Object.keys(updates).length === 0) {
error(400, 'No valid fields to update');
}
await db.update(tournamentRound).set(updates).where(eq(tournamentRound.id, params.roundId));
const updated = await db.query.tournamentRound.findFirst({
where: eq(tournamentRound.id, params.roundId),
with: { mappool: { with: { slots: true } } }
});
return json(updated);
};
export const DELETE: RequestHandler = async ({ params, locals }) => {
const user = requireAuth(locals);
const staff = await db.query.tournamentStaff.findFirst({
where: and(eq(tournamentStaff.tournamentId, params.id), eq(tournamentStaff.userId, user.id))
});
if (!staff) error(403, 'Not a staff member of this tournament');
await db.delete(tournamentRound).where(eq(tournamentRound.id, params.roundId));
return json({ deleted: true });
};

View File

@@ -0,0 +1,47 @@
import { json, error } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { tournamentStaff } from '$lib/server/db/schema';
import { eq, and } from 'drizzle-orm';
import { requireAuth } from '$lib/server/permissions';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params }) => {
const staff = await db.query.tournamentStaff.findMany({
where: eq(tournamentStaff.tournamentId, params.id)
});
return json(staff);
};
export const POST: RequestHandler = async ({ params, request, locals }) => {
const user = requireAuth(locals);
// only organizers can add staff
const actor = await db.query.tournamentStaff.findFirst({
where: and(eq(tournamentStaff.tournamentId, params.id), eq(tournamentStaff.userId, user.id))
});
if (!actor || (actor.role !== 'organizer' && actor.role !== 'admin')) {
error(403, 'Only organizers can manage staff');
}
const body = await request.json();
if (!body.userId || !body.role) error(400, 'Missing userId or role');
const validRoles = ['organizer', 'admin', 'referee', 'pooler', 'streamer'];
if (!validRoles.includes(body.role)) {
error(400, `Invalid role. Must be one of: ${validRoles.join(', ')}`);
}
try {
const [created] = await db
.insert(tournamentStaff)
.values({
tournamentId: params.id,
userId: body.userId,
role: body.role
})
.returning();
return json(created, { status: 201 });
} catch (e: any) {
error(400, 'User is already a staff member');
}
};

View File

@@ -0,0 +1,20 @@
import { json, error } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { tournamentStaff } from '$lib/server/db/schema';
import { eq, and } from 'drizzle-orm';
import { requireAuth } from '$lib/server/permissions';
import type { RequestHandler } from './$types';
export const DELETE: RequestHandler = async ({ params, locals }) => {
const user = requireAuth(locals);
const actor = await db.query.tournamentStaff.findFirst({
where: and(eq(tournamentStaff.tournamentId, params.id), eq(tournamentStaff.userId, user.id))
});
if (!actor || (actor.role !== 'organizer' && actor.role !== 'admin')) {
error(403, 'Only organizers can manage staff');
}
await db.delete(tournamentStaff).where(eq(tournamentStaff.id, params.staffId));
return json({ deleted: true });
};

View File

@@ -0,0 +1,27 @@
import { json, error } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { tournamentStaff } from '$lib/server/db/schema';
import { eq, and } from 'drizzle-orm';
import { requireAuth } from '$lib/server/permissions';
import { getTournamentFull } from '$lib/server/tournament/lifecycle';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ params, locals }) => {
const user = requireAuth(locals);
const staff = await db.query.tournamentStaff.findFirst({
where: and(eq(tournamentStaff.tournamentId, params.id), eq(tournamentStaff.userId, user.id))
});
if (!staff) error(403, 'Not a staff member of this tournament');
// qualifiers are started via closeRegistration when qualifiers are enabled
// this endpoint is for manually triggering qualifier finalization
const { finalizeQualifiers } = await import('$lib/server/tournament/qualifiers');
try {
await finalizeQualifiers(params.id);
return json(await getTournamentFull(params.id));
} catch (e: any) {
error(400, e.message);
}
};