839 lines
27 KiB
TypeScript
839 lines
27 KiB
TypeScript
import { superTokensAdminFunctionMiddleware, superTokensFunctionMiddleware } from "@/utils/supertokens";
|
|
import { createServerFn } from "@tanstack/react-start";
|
|
import { pbAdmin } from "@/lib/pocketbase/client";
|
|
import { tournamentInputSchema, GroupStanding } from "@/features/tournaments/types";
|
|
import { logger } from ".";
|
|
import { z } from "zod";
|
|
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
|
import { serverFnLoggingMiddleware } from "@/utils/activities";
|
|
import { fa } from "zod/v4/locales";
|
|
import brackets from "@/features/bracket/utils";
|
|
import { MatchInput } from "@/features/matches/types";
|
|
import { generateSingleEliminationBracket } from "./utils/bracket-generator";
|
|
|
|
export const listTournaments = createServerFn()
|
|
.middleware([superTokensFunctionMiddleware])
|
|
.handler(async () =>
|
|
toServerResult(pbAdmin.listTournaments)
|
|
);
|
|
|
|
export const createTournament = createServerFn()
|
|
.inputValidator(tournamentInputSchema)
|
|
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
|
.handler(async ({ data }) =>
|
|
toServerResult(() => pbAdmin.createTournament(data))
|
|
);
|
|
|
|
export const updateTournament = createServerFn()
|
|
.inputValidator(z.object({
|
|
id: z.string(),
|
|
updates: tournamentInputSchema.partial()
|
|
}))
|
|
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
|
.handler(async ({ data }) =>
|
|
toServerResult(() => pbAdmin.updateTournament(data.id, data.updates))
|
|
);
|
|
|
|
export const getTournament = createServerFn()
|
|
.inputValidator(z.string())
|
|
.middleware([superTokensFunctionMiddleware])
|
|
.handler(async ({ data: tournamentId, context }) => {
|
|
const isAdmin = context.roles.includes("Admin");
|
|
return toServerResult(() => pbAdmin.getTournament(tournamentId, isAdmin));
|
|
});
|
|
|
|
export const getCurrentTournament = createServerFn()
|
|
.handler(async () =>
|
|
toServerResult(() => pbAdmin.getMostRecentTournament())
|
|
);
|
|
|
|
export const enrollTeam = createServerFn()
|
|
.inputValidator(z.object({
|
|
tournamentId: z.string(),
|
|
teamId: z.string()
|
|
}))
|
|
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
|
.handler(async ({ data: { tournamentId, teamId }, context }) =>
|
|
toServerResult(async () => {
|
|
const userId = context.userAuthId;
|
|
const isAdmin = context.roles.includes("Admin");
|
|
|
|
const team = await pbAdmin.getTeam(teamId);
|
|
if (!team) { throw new Error('Team not found'); }
|
|
|
|
//const isPlayerOnTeam = team.players?.some(player => player.id === userId);
|
|
|
|
//if (!isPlayerOnTeam && !isAdmin) {
|
|
// throw new Error('You do not have permission to enroll this team');
|
|
//}
|
|
|
|
const freeAgents = await pbAdmin.getFreeAgents(tournamentId);
|
|
for (const player of team.players || []) {
|
|
const isFreeAgent = freeAgents.some(fa => fa.player?.id === player.id);
|
|
if (isFreeAgent) {
|
|
await pbAdmin.unenrollFreeAgent(player.id, tournamentId);
|
|
}
|
|
}
|
|
|
|
logger.info('Enrolling team in tournament', { tournamentId, teamId, userId });
|
|
const tournament = await pbAdmin.enrollTeam(tournamentId, teamId);
|
|
return tournament;
|
|
})
|
|
);
|
|
|
|
export const unenrollTeam = createServerFn()
|
|
.inputValidator(z.object({
|
|
tournamentId: z.string(),
|
|
teamId: z.string()
|
|
}))
|
|
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
|
.handler(async ({ data: { tournamentId, teamId }, context }) =>
|
|
toServerResult(() => pbAdmin.unenrollTeam(tournamentId, teamId))
|
|
);
|
|
|
|
export const getUnenrolledTeams = createServerFn()
|
|
.inputValidator(z.string())
|
|
.middleware([superTokensAdminFunctionMiddleware])
|
|
.handler(async ({ data: tournamentId }) =>
|
|
toServerResult(() => pbAdmin.getUnenrolledTeams(tournamentId))
|
|
);
|
|
|
|
export const getFreeAgents = createServerFn()
|
|
.inputValidator(z.string())
|
|
.middleware([superTokensFunctionMiddleware])
|
|
.handler(async ({ data: tournamentId }) =>
|
|
toServerResult(() => pbAdmin.getFreeAgents(tournamentId))
|
|
);
|
|
|
|
export const enrollFreeAgent = createServerFn()
|
|
.inputValidator(z.object({ phone: z.string(), tournamentId: z.string() }))
|
|
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
|
.handler(async ({ context, data }) =>
|
|
toServerResult(async () => {
|
|
const userAuthId = context.userAuthId;
|
|
const player = await pbAdmin.getPlayerByAuthId(userAuthId);
|
|
if (!player) throw new Error("Player not found");
|
|
|
|
await pbAdmin.enrollFreeAgent(player.id, data.phone, data.tournamentId);
|
|
logger.info('Player enrolled as free agent', { playerId: player.id, phone: data.phone });
|
|
})
|
|
);
|
|
|
|
export const unenrollFreeAgent = createServerFn()
|
|
.inputValidator(z.object({ tournamentId: z.string() }))
|
|
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
|
.handler(async ({ context, data }) =>
|
|
toServerResult(async () => {
|
|
const userAuthId = context.userAuthId;
|
|
const player = await pbAdmin.getPlayerByAuthId(userAuthId);
|
|
if (!player) throw new Error("Player not found");
|
|
|
|
await pbAdmin.unenrollFreeAgent(player.id, data.tournamentId);
|
|
logger.info('Player unenrolled as free agent', { playerId: player.id });
|
|
})
|
|
);
|
|
|
|
export const generateRandomTeams = createServerFn()
|
|
.inputValidator(z.object({
|
|
tournamentId: z.string(),
|
|
seed: z.number().optional()
|
|
}))
|
|
.middleware([superTokensAdminFunctionMiddleware])
|
|
.handler(async ({ data }) =>
|
|
toServerResult(async () => {
|
|
const freeAgents = await pbAdmin.getFreeAgents(data.tournamentId);
|
|
|
|
if (freeAgents.length < 2) {
|
|
throw new Error("Need at least 2 players to create teams");
|
|
}
|
|
|
|
if (freeAgents.length % 2 !== 0) {
|
|
throw new Error("Need an even number of players to create teams");
|
|
}
|
|
|
|
const uniqueFreeAgents = Array.from(
|
|
new Map(
|
|
freeAgents
|
|
.filter(fa => fa.player?.id)
|
|
.map(fa => [fa.player!.id, fa])
|
|
).values()
|
|
);
|
|
|
|
if (uniqueFreeAgents.length !== freeAgents.length) {
|
|
logger.warn('Duplicate free agents detected', {
|
|
freeAgentCount: freeAgents.length,
|
|
uniquePlayerCount: uniqueFreeAgents.length
|
|
});
|
|
}
|
|
|
|
if (uniqueFreeAgents.length < 2) {
|
|
throw new Error("Need at least 2 unique players to create teams");
|
|
}
|
|
|
|
if (uniqueFreeAgents.length % 2 !== 0) {
|
|
throw new Error("Need an even number of unique players to create teams");
|
|
}
|
|
|
|
const playerIds = uniqueFreeAgents.map(fa => fa.player!.id);
|
|
|
|
const allTeams = await pbAdmin.getTeamsWithFilter(
|
|
playerIds.map(id => `players.id ?= "${id}"`).join(" || "),
|
|
"players,tournaments"
|
|
);
|
|
|
|
const invalidPairings = new Set<string>();
|
|
const mostRecentRegionalPartners = new Map<string, string>();
|
|
|
|
let mostRecentRegionalDate: Date | null = null;
|
|
|
|
for (const team of allTeams) {
|
|
const teamPlayers = (team.expand?.players || []) as any[];
|
|
if (teamPlayers.length !== 2) continue;
|
|
|
|
const [p1, p2] = teamPlayers.map((p: any) => p.id).sort();
|
|
const pairKey = `${p1}|${p2}`;
|
|
|
|
const teamTournaments = (team.expand?.tournaments || []) as any[];
|
|
const hasMainlineTournament = teamTournaments.some((t: any) => !t.regional);
|
|
|
|
if (hasMainlineTournament) {
|
|
invalidPairings.add(pairKey);
|
|
} else if (team.private && teamTournaments.length > 0) {
|
|
const regionalTournaments = teamTournaments.filter((t: any) => t.regional);
|
|
for (const tournament of regionalTournaments) {
|
|
const tournamentDate = new Date(tournament.start_time);
|
|
if (!mostRecentRegionalDate || tournamentDate > mostRecentRegionalDate) {
|
|
mostRecentRegionalDate = tournamentDate;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (mostRecentRegionalDate) {
|
|
for (const team of allTeams) {
|
|
if (!team.private) continue;
|
|
|
|
const teamPlayers = (team.expand?.players || []) as any[];
|
|
if (teamPlayers.length !== 2) continue;
|
|
|
|
const teamTournaments = (team.expand?.tournaments || []) as any[];
|
|
const regionalTournaments = teamTournaments.filter((t: any) => t.regional);
|
|
|
|
for (const tournament of regionalTournaments) {
|
|
const tournamentDate = new Date(tournament.start_time);
|
|
if (tournamentDate.getTime() === mostRecentRegionalDate.getTime()) {
|
|
const [p1Id, p2Id] = teamPlayers.map((p: any) => p.id);
|
|
mostRecentRegionalPartners.set(p1Id, p2Id);
|
|
mostRecentRegionalPartners.set(p2Id, p1Id);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function canPairPlayers(p1Id: string, p2Id: string): boolean {
|
|
const pairKey = [p1Id, p2Id].sort().join('|');
|
|
if (invalidPairings.has(pairKey)) return false;
|
|
|
|
const p1LastPartner = mostRecentRegionalPartners.get(p1Id);
|
|
if (p1LastPartner === p2Id) return false;
|
|
|
|
return true;
|
|
}
|
|
|
|
const seed = data.seed || Math.floor(Math.random() * 1000000);
|
|
|
|
function seededRandom(s: number) {
|
|
const x = Math.sin(s++) * 10000;
|
|
return x - Math.floor(x);
|
|
}
|
|
|
|
let currentSeed = seed;
|
|
const shuffled = [...uniqueFreeAgents];
|
|
for (let i = shuffled.length - 1; i > 0; i--) {
|
|
const j = Math.floor(seededRandom(currentSeed++) * (i + 1));
|
|
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
|
}
|
|
|
|
const assignments = [];
|
|
const paired = new Set<string>();
|
|
const MAX_ATTEMPTS = 1000;
|
|
let attempts = 0;
|
|
|
|
while (paired.size < shuffled.length && attempts < MAX_ATTEMPTS) {
|
|
attempts++;
|
|
|
|
for (let i = 0; i < shuffled.length; i++) {
|
|
if (paired.has(shuffled[i].player!.id)) continue;
|
|
|
|
for (let j = i + 1; j < shuffled.length; j++) {
|
|
if (paired.has(shuffled[j].player!.id)) continue;
|
|
|
|
const player1 = shuffled[i].player!;
|
|
const player2 = shuffled[j].player!;
|
|
|
|
if (canPairPlayers(player1.id, player2.id)) {
|
|
const teamName = `${player1.first_name} And ${player2.first_name}`;
|
|
|
|
assignments.push({
|
|
player1,
|
|
player2,
|
|
teamName
|
|
});
|
|
|
|
paired.add(player1.id);
|
|
paired.add(player2.id);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (paired.size < shuffled.length) {
|
|
currentSeed++;
|
|
for (let i = shuffled.length - 1; i > 0; i--) {
|
|
const j = Math.floor(seededRandom(currentSeed++) * (i + 1));
|
|
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
|
}
|
|
assignments.length = 0;
|
|
paired.clear();
|
|
}
|
|
}
|
|
|
|
if (paired.size < shuffled.length) {
|
|
throw new Error("Unable to create valid pairings with current restrictions. Please manually adjust enrollments.");
|
|
}
|
|
|
|
logger.info('Generated random team assignments with restrictions', {
|
|
tournamentId: data.tournamentId,
|
|
teamCount: assignments.length,
|
|
seed,
|
|
attempts
|
|
});
|
|
|
|
return { assignments, seed };
|
|
})
|
|
);
|
|
|
|
export const confirmTeamAssignments = createServerFn()
|
|
.inputValidator(z.object({
|
|
tournamentId: z.string(),
|
|
assignments: z.array(z.object({
|
|
player1Id: z.string(),
|
|
player2Id: z.string(),
|
|
teamName: z.string()
|
|
}))
|
|
}))
|
|
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
|
.handler(async ({ data }) =>
|
|
toServerResult(async () => {
|
|
const createdTeams = [];
|
|
let reusedCount = 0;
|
|
|
|
for (const assignment of data.assignments) {
|
|
const existingTeams = await pbAdmin.getTeamsWithFilter(
|
|
`private = true && players.id ?= "${assignment.player1Id}" && players.id ?= "${assignment.player2Id}"`,
|
|
"players,tournaments"
|
|
);
|
|
|
|
let teamToUse = null;
|
|
|
|
for (const team of existingTeams) {
|
|
const teamPlayers = (team.expand?.players || []) as any[];
|
|
|
|
if (teamPlayers.length !== 2) continue;
|
|
|
|
const playerIds = teamPlayers.map((p: any) => p.id).sort();
|
|
const assignmentIds = [assignment.player1Id, assignment.player2Id].sort();
|
|
if (playerIds[0] !== assignmentIds[0] || playerIds[1] !== assignmentIds[1]) continue;
|
|
|
|
const teamTournaments = (team.expand?.tournaments || []) as any[];
|
|
const hasMainlineTournament = teamTournaments.some((t: any) => !t.regional);
|
|
|
|
if (!hasMainlineTournament) {
|
|
teamToUse = team;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (teamToUse) {
|
|
await pbAdmin.enrollTeam(data.tournamentId, teamToUse.id);
|
|
createdTeams.push(teamToUse);
|
|
reusedCount++;
|
|
logger.info('Reusing existing regional team', { teamId: teamToUse.id, teamName: teamToUse.name });
|
|
} else {
|
|
const team = await pbAdmin.createTeam({
|
|
name: assignment.teamName,
|
|
players: [assignment.player1Id, assignment.player2Id],
|
|
private: true
|
|
});
|
|
|
|
await pbAdmin.enrollTeam(data.tournamentId, team.id);
|
|
createdTeams.push(team);
|
|
}
|
|
}
|
|
|
|
for (const assignment of data.assignments) {
|
|
await pbAdmin.unenrollFreeAgent(assignment.player1Id, data.tournamentId);
|
|
await pbAdmin.unenrollFreeAgent(assignment.player2Id, data.tournamentId);
|
|
}
|
|
|
|
logger.info('Confirmed team assignments', {
|
|
tournamentId: data.tournamentId,
|
|
teamCount: createdTeams.length,
|
|
reusedCount,
|
|
newCount: createdTeams.length - reusedCount
|
|
});
|
|
|
|
return { teams: createdTeams };
|
|
})
|
|
);
|
|
|
|
async function calculateGroupStandings(groupId: string): Promise<GroupStanding[]> {
|
|
const group = await pbAdmin.getGroup(groupId);
|
|
if (!group) {
|
|
throw new Error("Group not found");
|
|
}
|
|
|
|
const matches = await pbAdmin.getMatchesByGroup(groupId);
|
|
const completedMatches = matches.filter(m => m.status === "ended");
|
|
|
|
const standings = new Map<string, GroupStanding>();
|
|
|
|
for (const team of group.teams || []) {
|
|
standings.set(team.id, {
|
|
team,
|
|
wins: 0,
|
|
losses: 0,
|
|
cups_for: 0,
|
|
cups_against: 0,
|
|
cup_differential: 0,
|
|
rank: 0,
|
|
});
|
|
}
|
|
|
|
for (const match of completedMatches) {
|
|
if (!match.home || !match.away) continue;
|
|
|
|
const homeStanding = standings.get(match.home.id);
|
|
const awayStanding = standings.get(match.away.id);
|
|
|
|
if (homeStanding && awayStanding) {
|
|
homeStanding.cups_for += match.home_cups;
|
|
homeStanding.cups_against += match.away_cups;
|
|
awayStanding.cups_for += match.away_cups;
|
|
awayStanding.cups_against += match.home_cups;
|
|
|
|
if (match.home_cups > match.away_cups) {
|
|
homeStanding.wins++;
|
|
awayStanding.losses++;
|
|
} else {
|
|
awayStanding.wins++;
|
|
homeStanding.losses++;
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const standing of standings.values()) {
|
|
standing.cup_differential = standing.cups_for - standing.cups_against;
|
|
}
|
|
|
|
const h2hRecords = new Map<string, Map<string, { wins: number; cupDiff: number }>>();
|
|
|
|
for (const match of completedMatches) {
|
|
if (!match.home || !match.away) continue;
|
|
|
|
if (!h2hRecords.has(match.home.id)) {
|
|
h2hRecords.set(match.home.id, new Map());
|
|
}
|
|
if (!h2hRecords.has(match.away.id)) {
|
|
h2hRecords.set(match.away.id, new Map());
|
|
}
|
|
|
|
const homeH2H = h2hRecords.get(match.home.id)!;
|
|
const awayH2H = h2hRecords.get(match.away.id)!;
|
|
|
|
if (!homeH2H.has(match.away.id)) {
|
|
homeH2H.set(match.away.id, { wins: 0, cupDiff: 0 });
|
|
}
|
|
if (!awayH2H.has(match.home.id)) {
|
|
awayH2H.set(match.home.id, { wins: 0, cupDiff: 0 });
|
|
}
|
|
|
|
const homeRecord = homeH2H.get(match.away.id)!;
|
|
const awayRecord = awayH2H.get(match.home.id)!;
|
|
|
|
const cupDiff = match.home_cups - match.away_cups;
|
|
homeRecord.cupDiff += cupDiff;
|
|
awayRecord.cupDiff -= cupDiff;
|
|
|
|
if (match.home_cups > match.away_cups) {
|
|
homeRecord.wins++;
|
|
} else {
|
|
awayRecord.wins++;
|
|
}
|
|
}
|
|
|
|
const sortedStandings = Array.from(standings.values()).sort((a, b) => {
|
|
if (b.wins !== a.wins) return b.wins - a.wins;
|
|
|
|
if (b.cup_differential !== a.cup_differential) return b.cup_differential - a.cup_differential;
|
|
|
|
if (b.cups_for !== a.cups_for) return b.cups_for - a.cups_for;
|
|
|
|
const aH2H = h2hRecords.get(a.team.id);
|
|
const bH2H = h2hRecords.get(b.team.id);
|
|
|
|
if (aH2H && bH2H) {
|
|
const aVsB = aH2H.get(b.team.id);
|
|
const bVsA = bH2H.get(a.team.id);
|
|
|
|
if (aVsB && bVsA) {
|
|
if (aVsB.wins !== bVsA.wins) return bVsA.wins - aVsB.wins;
|
|
|
|
if (aVsB.cupDiff !== -bVsA.cupDiff) return aVsB.cupDiff - (-bVsA.cupDiff);
|
|
}
|
|
}
|
|
|
|
return a.team.id.localeCompare(b.team.id);
|
|
});
|
|
|
|
sortedStandings.forEach((standing, index) => {
|
|
standing.rank = index + 1;
|
|
});
|
|
|
|
return sortedStandings;
|
|
}
|
|
|
|
export const getGroupStandings = createServerFn()
|
|
.inputValidator(z.string())
|
|
.middleware([superTokensFunctionMiddleware])
|
|
.handler(async ({ data: groupId }) =>
|
|
toServerResult(() => calculateGroupStandings(groupId))
|
|
);
|
|
|
|
export const generateKnockoutBracket = createServerFn()
|
|
.inputValidator(z.object({
|
|
tournamentId: z.string(),
|
|
}))
|
|
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
|
.handler(async ({ data }) =>
|
|
toServerResult(async () => {
|
|
logger.info('Generating knockout bracket', {
|
|
tournamentId: data.tournamentId,
|
|
});
|
|
|
|
const tournament = await pbAdmin.getTournament(data.tournamentId);
|
|
if (!tournament) {
|
|
throw new Error("Tournament not found");
|
|
}
|
|
|
|
if (tournament.phase !== "group_stage") {
|
|
throw new Error("Tournament must be in group_stage phase to generate knockout bracket");
|
|
}
|
|
|
|
if (!tournament.group_config) {
|
|
throw new Error("Tournament must have group_config");
|
|
}
|
|
|
|
const groups = await pbAdmin.getGroupsByTournament(data.tournamentId);
|
|
if (!groups || groups.length === 0) {
|
|
throw new Error("No groups found for tournament");
|
|
}
|
|
|
|
const qualifiedTeams: { teamId: string; groupOrder: number; rank: number }[] = [];
|
|
|
|
for (const group of groups) {
|
|
const standings = await calculateGroupStandings(group.id);
|
|
|
|
const topTeams = standings.slice(0, tournament.group_config.advance_per_group);
|
|
for (const standing of topTeams) {
|
|
qualifiedTeams.push({
|
|
teamId: standing.team.id,
|
|
groupOrder: group.order,
|
|
rank: standing.rank,
|
|
});
|
|
}
|
|
}
|
|
|
|
const orderedTeamIds: string[] = [];
|
|
const maxRank = tournament.group_config.advance_per_group;
|
|
const numGroups = tournament.group_config.num_groups;
|
|
|
|
const teamsByGroup: string[][] = [];
|
|
for (let g = 0; g < numGroups; g++) {
|
|
teamsByGroup[g] = [];
|
|
}
|
|
|
|
for (const qualified of qualifiedTeams) {
|
|
teamsByGroup[qualified.groupOrder][qualified.rank - 1] = qualified.teamId;
|
|
}
|
|
|
|
const totalTeams = numGroups * maxRank;
|
|
for (let i = 0; i < totalTeams / 2; i++) {
|
|
const group1 = i % numGroups;
|
|
const rankIndex1 = Math.floor(i / numGroups);
|
|
|
|
const group2 = (i + 1) % numGroups;
|
|
const rankIndex2 = maxRank - 1 - rankIndex1;
|
|
|
|
const team1 = teamsByGroup[group1]?.[rankIndex1];
|
|
const team2 = teamsByGroup[group2]?.[rankIndex2];
|
|
|
|
if (team1) orderedTeamIds.push(team1);
|
|
if (team2) orderedTeamIds.push(team2);
|
|
}
|
|
|
|
const teamCount = orderedTeamIds.length;
|
|
|
|
let bracketTemplate: any;
|
|
if (Object.keys(brackets).includes(teamCount.toString())) {
|
|
bracketTemplate = brackets[teamCount as keyof typeof brackets];
|
|
} else {
|
|
bracketTemplate = generateSingleEliminationBracket(teamCount);
|
|
}
|
|
|
|
const seedToTeamId = new Map<number, string>();
|
|
orderedTeamIds.forEach((teamId, index) => {
|
|
seedToTeamId.set(index + 1, teamId);
|
|
});
|
|
|
|
const matchInputs: MatchInput[] = [];
|
|
let matchLid = 1000;
|
|
|
|
bracketTemplate.winners.forEach((round: any[]) => {
|
|
round.forEach((match: any) => {
|
|
const matchInput: MatchInput = {
|
|
lid: matchLid++,
|
|
round: match.round,
|
|
order: match.order || 0,
|
|
reset: false,
|
|
bye: match.bye || false,
|
|
home_cups: 0,
|
|
away_cups: 0,
|
|
ot_count: 0,
|
|
home_from_lid: match.home_from_lid === null ? -1 : (match.home_from_lid + 1000),
|
|
away_from_lid: match.away_from_lid === null ? -1 : (match.away_from_lid + 1000),
|
|
home_from_loser: false,
|
|
away_from_loser: false,
|
|
is_losers_bracket: false,
|
|
match_type: "knockout",
|
|
status: "tbd",
|
|
tournament: data.tournamentId,
|
|
};
|
|
|
|
if (match.home_seed) {
|
|
const teamId = seedToTeamId.get(match.home_seed);
|
|
if (teamId) {
|
|
matchInput.home = teamId;
|
|
matchInput.home_seed = match.home_seed;
|
|
}
|
|
}
|
|
|
|
if (match.away_seed) {
|
|
const teamId = seedToTeamId.get(match.away_seed);
|
|
if (teamId) {
|
|
matchInput.away = teamId;
|
|
matchInput.away_seed = match.away_seed;
|
|
}
|
|
}
|
|
|
|
if (matchInput.home && matchInput.away) {
|
|
matchInput.status = "ready";
|
|
}
|
|
|
|
matchInputs.push(matchInput);
|
|
});
|
|
});
|
|
|
|
const createdMatches = await pbAdmin.createMatches(matchInputs);
|
|
|
|
const existingMatchIds = tournament.matches?.map(m => m.id) || [];
|
|
const newMatchIds = createdMatches.map(m => m.id);
|
|
await pbAdmin.updateTournamentMatches(data.tournamentId, [...existingMatchIds, ...newMatchIds]);
|
|
|
|
await pbAdmin.updateTournament(data.tournamentId, {
|
|
phase: "knockout"
|
|
});
|
|
|
|
logger.info('Knockout bracket generated', {
|
|
tournamentId: data.tournamentId,
|
|
matchCount: createdMatches.length,
|
|
qualifiedTeamCount: qualifiedTeams.length
|
|
});
|
|
|
|
return {
|
|
tournament,
|
|
matchCount: createdMatches.length,
|
|
matches: createdMatches
|
|
};
|
|
})
|
|
);
|
|
|
|
export const adminEnrollPlayer = createServerFn()
|
|
.inputValidator(z.object({
|
|
playerId: z.string(),
|
|
tournamentId: z.string()
|
|
}))
|
|
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
|
.handler(async ({ data }) =>
|
|
toServerResult(async () => {
|
|
await pbAdmin.enrollFreeAgent(data.playerId, "", data.tournamentId);
|
|
logger.info('Admin enrolled player', { playerId: data.playerId, tournamentId: data.tournamentId });
|
|
})
|
|
);
|
|
|
|
export const adminUnenrollPlayer = createServerFn()
|
|
.inputValidator(z.object({
|
|
playerId: z.string(),
|
|
tournamentId: z.string()
|
|
}))
|
|
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
|
.handler(async ({ data }) =>
|
|
toServerResult(async () => {
|
|
await pbAdmin.unenrollFreeAgent(data.playerId, data.tournamentId);
|
|
logger.info('Admin unenrolled player', { playerId: data.playerId, tournamentId: data.tournamentId });
|
|
})
|
|
);
|
|
|
|
export const generateGroupStage = createServerFn()
|
|
.inputValidator(z.object({
|
|
tournamentId: z.string(),
|
|
groupConfig: z.object({
|
|
num_groups: z.number(),
|
|
teams_per_group: z.number(),
|
|
advance_per_group: z.number(),
|
|
matches_guaranteed: z.number(),
|
|
seeding_method: z.enum(["random", "ranked"]),
|
|
}),
|
|
teamAssignments: z.array(z.object({
|
|
groupIndex: z.number(),
|
|
groupName: z.string(),
|
|
teamIds: z.array(z.string())
|
|
})),
|
|
seed: z.number().optional()
|
|
}))
|
|
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
|
.handler(async ({ data }) =>
|
|
toServerResult(async () => {
|
|
logger.info('Generating group stage', {
|
|
tournamentId: data.tournamentId,
|
|
numGroups: data.groupConfig.num_groups,
|
|
seed: data.seed
|
|
});
|
|
|
|
const tournament = await pbAdmin.getTournament(data.tournamentId);
|
|
if (!tournament) {
|
|
throw new Error("Tournament not found");
|
|
}
|
|
|
|
if (tournament.matches && tournament.matches.length > 0) {
|
|
throw new Error("Tournament already has matches generated");
|
|
}
|
|
|
|
await pbAdmin.deleteGroupsByTournament(data.tournamentId);
|
|
|
|
const createdGroups = [];
|
|
const groupStageMatches = [];
|
|
|
|
for (const assignment of data.teamAssignments) {
|
|
const group = await pbAdmin.createGroup({
|
|
tournament: data.tournamentId,
|
|
name: assignment.groupName,
|
|
order: assignment.groupIndex,
|
|
teams: assignment.teamIds
|
|
});
|
|
|
|
createdGroups.push(group);
|
|
|
|
const teamIds = assignment.teamIds;
|
|
|
|
for (let i = 0; i < teamIds.length; i++) {
|
|
for (let j = i + 1; j < teamIds.length; j++) {
|
|
groupStageMatches.push({
|
|
lid: -1,
|
|
round: -1,
|
|
order: groupStageMatches.length + 1,
|
|
reset: false,
|
|
bye: false,
|
|
home: teamIds[i],
|
|
away: teamIds[j],
|
|
home_cups: 0,
|
|
away_cups: 0,
|
|
ot_count: 0,
|
|
home_from_lid: -1,
|
|
away_from_lid: -1,
|
|
home_from_loser: false,
|
|
away_from_loser: false,
|
|
is_losers_bracket: false,
|
|
match_type: "group_stage" as const,
|
|
group: group.id,
|
|
status: "ready" as const,
|
|
tournament: data.tournamentId,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
const knockoutTeamCount = data.groupConfig.num_groups * data.groupConfig.advance_per_group;
|
|
let bracketTemplate: any;
|
|
|
|
if (Object.keys(brackets).includes(knockoutTeamCount.toString())) {
|
|
bracketTemplate = brackets[knockoutTeamCount as keyof typeof brackets];
|
|
} else {
|
|
bracketTemplate = generateSingleEliminationBracket(knockoutTeamCount);
|
|
}
|
|
|
|
const knockoutMatches: any[] = [];
|
|
|
|
bracketTemplate.winners.forEach((round: any[]) => {
|
|
round.forEach((match: any) => {
|
|
knockoutMatches.push({
|
|
lid: match.lid,
|
|
round: match.round,
|
|
order: match.order,
|
|
reset: false,
|
|
bye: match.bye || false,
|
|
home_seed: match.home_seed,
|
|
away_seed: match.away_seed,
|
|
home_cups: 0,
|
|
away_cups: 0,
|
|
ot_count: 0,
|
|
home_from_lid: match.home_from_lid !== null ? match.home_from_lid : -1,
|
|
away_from_lid: match.away_from_lid !== null ? match.away_from_lid : -1,
|
|
home_from_loser: false,
|
|
away_from_loser: false,
|
|
is_losers_bracket: false,
|
|
match_type: "knockout" as const,
|
|
status: "tbd" as const,
|
|
tournament: data.tournamentId,
|
|
});
|
|
});
|
|
});
|
|
|
|
const allMatches = [...groupStageMatches, ...knockoutMatches];
|
|
const createdMatches = await pbAdmin.createMatches(allMatches);
|
|
|
|
const matchIds = createdMatches.map((match) => match.id);
|
|
await pbAdmin.updateTournamentMatches(data.tournamentId, matchIds);
|
|
|
|
await pbAdmin.updateTournament(data.tournamentId, {
|
|
phase: "group_stage",
|
|
group_config: data.groupConfig
|
|
});
|
|
|
|
logger.info('Group stage and knockout bracket generated', {
|
|
tournamentId: data.tournamentId,
|
|
groupCount: createdGroups.length,
|
|
groupMatchCount: groupStageMatches.length,
|
|
knockoutMatchCount: knockoutMatches.length,
|
|
totalMatchCount: createdMatches.length
|
|
});
|
|
|
|
return {
|
|
tournament,
|
|
groups: createdGroups,
|
|
matchCount: createdMatches.length,
|
|
matches: createdMatches
|
|
};
|
|
})
|
|
);
|