diff --git a/app/components/Label.tsx b/app/components/Label.tsx index 08e349d28..f5c430a49 100644 --- a/app/components/Label.tsx +++ b/app/components/Label.tsx @@ -14,6 +14,7 @@ type LabelProps = Pick< required?: boolean; className?: string; labelClassName?: string; + spaced?: boolean; }; export function Label({ @@ -23,9 +24,10 @@ export function Label({ htmlFor, className, labelClassName, + spaced = true, }: LabelProps) { return ( -
+
diff --git a/app/db/seed/index.ts b/app/db/seed/index.ts index ad3c98b2a..b22a8f06f 100644 --- a/app/db/seed/index.ts +++ b/app/db/seed/index.ts @@ -58,7 +58,7 @@ import { TOURNAMENT } from "~/features/tournament/tournament-constants"; import * as UserRepository from "~/features/user-page/UserRepository.server"; import type { TournamentMapListMap } from "~/modules/tournament-map-list-generator"; import { nullFilledArray, pickRandomItem } from "~/utils/arrays"; -import type { UserMapModePreferences } from "../tables"; +import type { Tables, UserMapModePreferences } from "../tables"; import type { Art, UserSubmittedImage } from "../types"; import { ADMIN_TEST_AVATAR, @@ -69,9 +69,16 @@ import { } from "./constants"; import placements from "./placements.json"; -const calendarEventWithToToolsSz = () => calendarEventWithToTools(true); +const calendarEventWithToToolsRegOpen = () => + calendarEventWithToTools("PICNIC", true); +const calendarEventWithToToolsSz = () => calendarEventWithToTools("ITZ"); const calendarEventWithToToolsTeamsSz = () => - calendarEventWithToToolsTeams(true); + calendarEventWithToToolsTeams("ITZ"); +const calendarEventWithToToolsPP = () => calendarEventWithToTools("PP"); +const calendarEventWithToToolsPPRegOpen = () => + calendarEventWithToTools("PP", true); +const calendarEventWithToToolsTeamsPP = () => + calendarEventWithToToolsTeams("PP"); const basicSeeds = (variation?: SeedVariation | null) => [ adminUser, @@ -95,15 +102,23 @@ const basicSeeds = (variation?: SeedVariation | null) => [ calendarEvents, calendarEventBadges, calendarEventResults, - calendarEventWithToTools, + variation === "REG_OPEN" + ? calendarEventWithToToolsRegOpen + : calendarEventWithToTools, calendarEventWithToToolsTieBreakerMapPool, - variation === "NO_TOURNAMENT_TEAMS" + variation === "NO_TOURNAMENT_TEAMS" || variation === "REG_OPEN" ? undefined : calendarEventWithToToolsTeams, - variation === "NO_TOURNAMENT_TEAMS" ? undefined : calendarEventWithToToolsSz, + calendarEventWithToToolsSz, variation === "NO_TOURNAMENT_TEAMS" ? undefined : calendarEventWithToToolsTeamsSz, + variation === "REG_OPEN" + ? calendarEventWithToToolsPPRegOpen + : calendarEventWithToToolsPP, + variation === "NO_TOURNAMENT_TEAMS" + ? undefined + : calendarEventWithToToolsTeamsPP, tournamentSubs, adminBuilds, manySplattershotBuilds, @@ -809,9 +824,59 @@ async function calendarEventResults() { } const TO_TOOLS_CALENDAR_EVENT_ID = 201; -function calendarEventWithToTools(sz?: boolean) { - const tournamentId = sz ? 2 : 1; - const eventId = TO_TOOLS_CALENDAR_EVENT_ID + (sz ? 1 : 0); +function calendarEventWithToTools( + event: "PICNIC" | "ITZ" | "PP" = "PICNIC", + registrationOpen: boolean = false, +) { + const tournamentId = { + PICNIC: 1, + ITZ: 2, + PP: 3, + }[event]; + const eventId = { + PICNIC: TO_TOOLS_CALENDAR_EVENT_ID + 0, + ITZ: TO_TOOLS_CALENDAR_EVENT_ID + 1, + PP: TO_TOOLS_CALENDAR_EVENT_ID + 2, + }[event]; + const name = { + PICNIC: "PICNIC #2", + ITZ: "In The Zone 22", + PP: "Paddling Pool 253", + }[event]; + + const settings: Tables["Tournament"]["settings"] = + event === "PP" + ? { + bracketProgression: [ + { type: "round_robin", name: "Groups stage" }, + { + type: "single_elimination", + name: "Final stage", + sources: [{ bracketIdx: 0, placements: [1, 2] }], + }, + { + type: "single_elimination", + name: "Underground bracket", + sources: [{ bracketIdx: 0, placements: [3, 4] }], + }, + ], + } + : event === "ITZ" + ? { + bracketProgression: [ + { type: "double_elimination", name: "Main bracket" }, + { + type: "single_elimination", + name: "Underground bracket", + sources: [{ bracketIdx: 0, placements: [-1, -2] }], + }, + ], + } + : { + bracketProgression: [ + { type: "double_elimination", name: "Main bracket" }, + ], + }; sql .prepare( @@ -819,18 +884,18 @@ function calendarEventWithToTools(sz?: boolean) { insert into "Tournament" ( "id", "mapPickingStyle", - "format" + "settings" ) values ( $id, $mapPickingStyle, - $format + $settings ) returning * `, ) .run({ id: tournamentId, - format: "DE", - mapPickingStyle: sz ? "AUTO_SZ" : "AUTO_ALL", + settings: JSON.stringify(settings), + mapPickingStyle: event === "ITZ" ? "AUTO_SZ" : "AUTO_ALL", }); sql @@ -857,7 +922,7 @@ function calendarEventWithToTools(sz?: boolean) { ) .run({ id: eventId, - name: sz ? "In The Zone 22" : "PICNIC #2", + name, description: faker.lorem.paragraph(), discordInviteCode: faker.lorem.word(), bracketUrl: faker.internet.url(), @@ -865,6 +930,8 @@ function calendarEventWithToTools(sz?: boolean) { tournamentId, }); + const halfAnHourFromNow = new Date(Date.now() + 1000 * 60 * 30); + sql .prepare( ` @@ -879,7 +946,11 @@ function calendarEventWithToTools(sz?: boolean) { ) .run({ eventId, - startTime: dateToDatabaseTimestamp(new Date(Date.now() + 1000 * 60 * 60)), + startTime: dateToDatabaseTimestamp( + registrationOpen + ? halfAnHourFromNow + : new Date(Date.now() - 1000 * 60 * 60), + ), }); } @@ -926,14 +997,28 @@ const availablePairs = rankedModesShort availableStages.map((stageId) => ({ mode, stageId: stageId })), ) .filter((pair) => !tiebreakerPicks.has(pair)); -function calendarEventWithToToolsTeams(sz?: boolean) { +function calendarEventWithToToolsTeams( + event: "PICNIC" | "ITZ" | "PP" = "PICNIC", +) { const userIds = userIdsInAscendingOrderById(); const names = Array.from( new Set(new Array(100).fill(null).map(() => validTournamentTeamName())), ).concat("Chimera"); + const tournamentId = { + PICNIC: 1, + ITZ: 2, + PP: 3, + }[event]; + + const teamIdAddition = { + PICNIC: 0, + ITZ: 100, + PP: 200, + }[event]; + for (let id = 1; id <= 16; id++) { - const teamId = id + (sz ? 100 : 0); + const teamId = id + teamIdAddition; const name = names.pop(); invariant(name, "tournament team name is falsy"); @@ -960,11 +1045,12 @@ function calendarEventWithToToolsTeams(sz?: boolean) { id: teamId, name, createdAt: dateToDatabaseTimestamp(new Date()), - tournamentId: sz ? 2 : 1, + tournamentId, inviteCode: nanoid(INVITE_CODE_LENGTH), }); - if (sz || id !== 1) { + // in PICNIC & PP Chimera is not checked in + if (teamId !== 1 && teamId !== 201) { sql .prepare( ` @@ -978,7 +1064,7 @@ function calendarEventWithToToolsTeams(sz?: boolean) { `, ) .run({ - tournamentTeamId: id + (sz ? 100 : 0), + tournamentTeamId: teamId, checkedInAt: dateToDatabaseTimestamp(new Date()), }); } @@ -1008,7 +1094,7 @@ function calendarEventWithToToolsTeams(sz?: boolean) { `, ) .run({ - tournamentTeamId: id + (sz ? 100 : 0), + tournamentTeamId: id + teamIdAddition, userId, isOwner: i === 0 ? 1 : 0, createdAt: dateToDatabaseTimestamp(new Date()), @@ -1025,14 +1111,15 @@ function calendarEventWithToToolsTeams(sz?: boolean) { const stageUsedCounts: Partial> = {}; for (const pair of shuffledPairs) { - if (sz && pair.mode !== "SZ") continue; + if (event === "ITZ" && pair.mode !== "SZ") continue; - if (pair.mode === "SZ" && SZ >= (sz ? 6 : 2)) continue; + if (pair.mode === "SZ" && SZ >= (event === "ITZ" ? 6 : 2)) continue; if (pair.mode === "TC" && TC >= 2) continue; if (pair.mode === "RM" && RM >= 2) continue; if (pair.mode === "CB" && CB >= 2) continue; - if (stageUsedCounts[pair.stageId] === (sz ? 1 : 2)) continue; + if (stageUsedCounts[pair.stageId] === (event === "ITZ" ? 1 : 2)) + continue; stageUsedCounts[pair.stageId] = (stageUsedCounts[pair.stageId] ?? 0) + 1; @@ -1052,7 +1139,7 @@ function calendarEventWithToToolsTeams(sz?: boolean) { `, ) .run({ - tournamentTeamId: id + (sz ? 100 : 0), + tournamentTeamId: id + teamIdAddition, stageId: pair.stageId, mode: pair.mode, }); diff --git a/app/db/tables.ts b/app/db/tables.ts index 700c173dc..e7fd0bd8d 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -366,10 +366,25 @@ type TournamentMapPickingStyle = | "AUTO_RM" | "AUTO_CB"; -type TournamentFormat = "SE" | "DE"; +export type TournamentBracketProgression = { + type: TournamentStage["type"]; + name: string; + /** Where do the teams come from? If missing then it means the source is the full registered teams list. */ + sources?: { + /** Index of the bracket where the teams come from */ + bracketIdx: number; + /** Team placements that join this bracket. E.g. [1, 2] would mean top 1 & 2 teams. [-1] would mean the last placing teams. */ + placements: number[]; + }[]; +}[]; + +export interface TournamentSettings { + bracketProgression: TournamentBracketProgression; + teamsPerGroup?: number; +} export interface Tournament { - format: TournamentFormat; + settings: ColumnType; id: GeneratedAlways; mapPickingStyle: TournamentMapPickingStyle; showMapListGenerator: Generated; @@ -411,6 +426,8 @@ export interface TournamentMatchGameResult { source: string; stageId: StageId; winnerTeamId: number; + opponentOnePoints: number | null; + opponentTwoPoints: number | null; } export interface TournamentMatchGameResultParticipant { @@ -440,7 +457,7 @@ export interface TournamentStage { number: number; settings: string; tournamentId: number; - type: string; + type: "double_elimination" | "single_elimination" | "round_robin"; } export interface TournamentSub { @@ -472,6 +489,8 @@ export interface TournamentTeam { export interface TournamentTeamCheckIn { checkedInAt: number; + /** Which bracket checked in for. If missing is check in for the whole event. */ + bracketIdx: number | null; tournamentTeamId: number; } diff --git a/app/db/types.ts b/app/db/types.ts index 46fb7347e..605640e8b 100644 --- a/app/db/types.ts +++ b/app/db/types.ts @@ -301,9 +301,6 @@ export enum Status { /** The match is completed. */ Completed = 4, - - /** At least one participant started their following match. */ - Archived = 5, } /** A match between two participants (more participants are not allowed). diff --git a/app/features/api-private/routes/seed.tsx b/app/features/api-private/routes/seed.tsx index a89e5e115..4c34ecd1c 100644 --- a/app/features/api-private/routes/seed.tsx +++ b/app/features/api-private/routes/seed.tsx @@ -4,7 +4,7 @@ import { seed } from "~/db/seed"; import { parseRequestFormData } from "~/utils/remix"; const seedSchema = z.object({ - variation: z.enum(["NO_TOURNAMENT_TEAMS", "DEFAULT"]).nullish(), + variation: z.enum(["NO_TOURNAMENT_TEAMS", "DEFAULT", "REG_OPEN"]).nullish(), }); export type SeedVariation = NonNullable< diff --git a/app/features/calendar/CalendarRepository.server.ts b/app/features/calendar/CalendarRepository.server.ts index bfd7064c7..4561d7e8a 100644 --- a/app/features/calendar/CalendarRepository.server.ts +++ b/app/features/calendar/CalendarRepository.server.ts @@ -2,12 +2,13 @@ import type { ExpressionBuilder, Transaction } from "kysely"; import { sql } from "kysely"; import { jsonArrayFrom } from "kysely/helpers/sqlite"; import { db } from "~/db/sql"; -import type { DB, Tables } from "~/db/tables"; +import type { DB, Tables, TournamentSettings } from "~/db/tables"; import type { CalendarEventTag } from "~/db/types"; import { MapPool } from "~/features/map-list-generator/core/map-pool"; import { dateToDatabaseTimestamp } from "~/utils/dates"; import { sumArray } from "~/utils/number"; import type { Unwrapped } from "~/utils/types"; +import invariant from "tiny-invariant"; // TODO: convert from raw to using the "exists" function const hasBadge = sql/* sql */ `exists ( @@ -360,16 +361,26 @@ type CreateArgs = Pick< mapPoolMaps?: Array>; isFullTournament: boolean; mapPickingStyle: Tables["Tournament"]["mapPickingStyle"]; + bracketProgression: TournamentSettings["bracketProgression"] | null; + teamsPerGroup?: number; }; export async function create(args: CreateArgs) { return db.transaction().execute(async (trx) => { let tournamentId; if (args.isFullTournament) { + invariant(args.bracketProgression, "Expected bracketProgression"); + const settings: Tables["Tournament"]["settings"] = { + bracketProgression: args.bracketProgression, + teamsPerGroup: args.teamsPerGroup, + }; + tournamentId = ( await trx .insertInto("Tournament") - // TODO: format picking - .values({ mapPickingStyle: args.mapPickingStyle, format: "DE" }) + .values({ + mapPickingStyle: args.mapPickingStyle, + settings: JSON.stringify(settings), + }) .returning("id") .executeTakeFirstOrThrow() ).id; @@ -424,6 +435,22 @@ export async function update(args: UpdateArgs) { .returning("tournamentId") .executeTakeFirstOrThrow(); + if (tournamentId) { + invariant(args.bracketProgression, "Expected bracketProgression"); + const settings: Tables["Tournament"]["settings"] = { + bracketProgression: args.bracketProgression, + teamsPerGroup: args.teamsPerGroup, + }; + + await trx + .updateTable("Tournament") + .set({ + settings: JSON.stringify(settings), + }) + .where("id", "=", tournamentId) + .execute(); + } + await trx .deleteFrom("CalendarEventDate") .where("eventId", "=", args.eventId) diff --git a/app/features/calendar/calendar-utils.server.ts b/app/features/calendar/calendar-utils.server.ts index 9f5242826..b6f79406c 100644 --- a/app/features/calendar/calendar-utils.server.ts +++ b/app/features/calendar/calendar-utils.server.ts @@ -1,4 +1,9 @@ +import type { z } from "zod"; import { isAdmin } from "~/permissions"; +import type { newCalendarEventActionSchema } from "./routes/calendar.new"; +import type { TournamentSettings } from "~/db/tables"; +import { BRACKET_NAMES } from "../tournament/tournament-constants"; +import { nullFilledArray } from "~/utils/arrays"; const usersWithTournamentPerms = process.env["TOURNAMENT_PERMS"]?.split(",").map(Number) ?? []; @@ -7,3 +12,78 @@ export function canCreateTournament(user?: { id: number }) { return isAdmin(user) || usersWithTournamentPerms.includes(user.id); } + +export function formValuesToBracketProgression( + args: z.infer, +) { + if (!args.format) return null; + + const result: TournamentSettings["bracketProgression"] = []; + if (args.format === "DE") { + result.push({ + name: BRACKET_NAMES.MAIN, + type: "double_elimination", + }); + + if (args.withUndergroundBracket) { + result.push({ + name: BRACKET_NAMES.UNDERGROUND, + type: "single_elimination", + sources: [{ bracketIdx: 0, placements: [-1, -2] }], + }); + } + } + + if ( + args.format === "RR_TO_SE" && + args.advancingCount && + args.teamsPerGroup && + args.advancingCount <= args.teamsPerGroup + ) { + result.push({ + name: BRACKET_NAMES.GROUPS, + type: "round_robin", + }); + + const allPlacements = nullFilledArray(args.teamsPerGroup).map( + (_, i) => i + 1, + ); + const advancingPlacements = nullFilledArray(args.advancingCount).map( + (_, i) => i + 1, + ); + + result.push({ + name: BRACKET_NAMES.FINALS, + type: "single_elimination", + sources: [ + { + bracketIdx: 0, + placements: advancingPlacements, + }, + ], + }); + + if ( + args.withUndergroundBracket && + advancingPlacements.length !== allPlacements.length + ) { + result.push({ + name: BRACKET_NAMES.UNDERGROUND, + type: "single_elimination", + sources: [ + { + bracketIdx: 0, + placements: allPlacements.filter( + (p) => !advancingPlacements.includes(p), + ), + }, + ], + }); + } + } + + // should not happen + if (result.length === 0) return null; + + return result; +} diff --git a/app/features/calendar/calendar-utils.ts b/app/features/calendar/calendar-utils.ts index b0d0ffb2c..8c4a0e3e3 100644 --- a/app/features/calendar/calendar-utils.ts +++ b/app/features/calendar/calendar-utils.ts @@ -1,4 +1,14 @@ +import type { TournamentSettings } from "~/db/tables"; import { userDiscordIdIsAged } from "~/utils/users"; +import type { TournamentFormatShort } from "../tournament/tournament-constants"; export const canAddNewEvent = (user: { discordId: string }) => userDiscordIdIsAged(user); + +export function bracketProgressionToShortTournamentFormat( + bp: TournamentSettings["bracketProgression"], +): TournamentFormatShort { + if (bp.some((b) => b.type === "double_elimination")) return "DE"; + + return "RR_TO_SE"; +} diff --git a/app/features/calendar/routes/calendar.new.tsx b/app/features/calendar/routes/calendar.new.tsx index 18f613d49..7b2d16494 100644 --- a/app/features/calendar/routes/calendar.new.tsx +++ b/app/features/calendar/routes/calendar.new.tsx @@ -26,11 +26,7 @@ import { RequiredHiddenInput } from "~/components/RequiredHiddenInput"; import { SubmitButton } from "~/components/SubmitButton"; import { Toggle } from "~/components/Toggle"; import { CALENDAR_EVENT } from "~/constants"; -import type { - Badge as BadgeType, - CalendarEventTag, - Tournament, -} from "~/db/types"; +import type { Badge as BadgeType, CalendarEventTag } from "~/db/types"; import { useIsMounted } from "~/hooks/useIsMounted"; import { useTranslation } from "react-i18next"; import { requireUser } from "~/features/auth/core/user.server"; @@ -53,7 +49,7 @@ import { type SendouRouteHandle, } from "~/utils/remix"; import { makeTitle, pathnameFromPotentialURL } from "~/utils/strings"; -import { calendarEventPage } from "~/utils/urls"; +import { calendarEventPage, tournamentBracketsPage } from "~/utils/urls"; import { actualNumber, checkboxValueToBoolean, @@ -70,8 +66,24 @@ import type { RankedModeShort } from "~/modules/in-game-lists"; import { rankedModesShort } from "~/modules/in-game-lists/modes"; import * as BadgeRepository from "~/features/badges/BadgeRepository.server"; import * as CalendarRepository from "~/features/calendar/CalendarRepository.server"; -import { canAddNewEvent } from "../calendar-utils"; -import { canCreateTournament } from "../calendar-utils.server"; +import { + bracketProgressionToShortTournamentFormat, + canAddNewEvent, +} from "../calendar-utils"; +import { + canCreateTournament, + formValuesToBracketProgression, +} from "../calendar-utils.server"; +import { tournamentFromDB } from "~/features/tournament-bracket/core/Tournament.server"; +import type { Tournament } from "~/features/tournament-bracket/core/Tournament"; +import type { Tables } from "~/db/tables"; +import { Divider } from "~/components/Divider"; +import { + BRACKET_NAMES, + FORMATS_SHORT, + TOURNAMENT, + type TournamentFormatShort, +} from "~/features/tournament/tournament-constants"; const MIN_DATE = new Date(Date.UTC(2015, 4, 28)); @@ -93,7 +105,7 @@ export const meta: MetaFunction = (args) => { return [{ title: data.title }]; }; -const newCalendarEventActionSchema = z +export const newCalendarEventActionSchema = z .object({ eventToEditId: z.preprocess(actualNumber, id.nullish()), name: z @@ -139,6 +151,21 @@ const newCalendarEventActionSchema = z pool: z.string().optional(), toToolsEnabled: z.preprocess(checkboxValueToBoolean, z.boolean()), toToolsMode: z.enum(["ALL", "SZ", "TC", "RM", "CB"]).optional(), + // + // tournament format related fields + // + format: z.enum(FORMATS_SHORT).nullish(), + withUndergroundBracket: z.preprocess(checkboxValueToBoolean, z.boolean()), + teamsPerGroup: z.coerce + .number() + .min(TOURNAMENT.MIN_GROUP_SIZE) + .max(TOURNAMENT.MAX_GROUP_SIZE) + .nullish(), + advancingCount: z.coerce + .number() + .min(1) + .max(TOURNAMENT.MAX_GROUP_SIZE) + .nullish(), }) .refine( async (schema) => { @@ -185,7 +212,13 @@ export const action: ActionFunction = async ({ request }) => { toToolsEnabled: canCreateTournament(user) ? Number(data.toToolsEnabled) : 0, toToolsMode: rankedModesShort.find((mode) => mode === data.toToolsMode) ?? null, + bracketProgression: formValuesToBracketProgression(data), + teamsPerGroup: data.teamsPerGroup ?? undefined, }; + validate( + !commonArgs.toToolsEnabled || commonArgs.bracketProgression, + "Bracket progression must be set for tournaments", + ); const deserializedMaps = (() => { if (!data.pool) return; @@ -197,6 +230,13 @@ export const action: ActionFunction = async ({ request }) => { const eventToEdit = badRequestIfFalsy( await CalendarRepository.findById({ id: data.eventToEditId }), ); + if (eventToEdit.tournamentId) { + const tournament = await tournamentFromDB({ + tournamentId: eventToEdit.tournamentId, + user, + }); + validate(!tournament.hasStarted, "Tournament has already started", 400); + } validate( canEditCalendarEvent({ user, event: eventToEdit }), "Not authorized", @@ -248,6 +288,17 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { const canEditEvent = eventToEdit && canEditCalendarEvent({ user, event: eventToEdit }); + let tournament: Tournament | undefined; + if (eventToEdit?.tournamentId) { + tournament = await tournamentFromDB({ + tournamentId: eventToEdit.tournamentId, + user, + }); + if (tournament.hasStarted) { + redirect(tournamentBracketsPage({ tournamentId: tournament.ctx.id })); + } + } + return json({ managedBadges: await BadgeRepository.findManagedByUserId(user.id), recentEventsWithMapPools: @@ -263,6 +314,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { : undefined, title: makeTitle([canEditEvent ? "Edit" : "New", t("pages.calendar")]), canCreateTournament: canCreateTournament(user), + tournamentCtx: tournament?.ctx, }); }; @@ -297,11 +349,13 @@ export default function CalendarNewEventPage() { + {/* can't edit as participants might have chosen maps and changing this might cause impossible states */} {isTournament && !eventToEdit ? ( ) : null} {/* TODO: this will be selectable depending on the tournament map picking style in future */} {!isTournament ? : null} + {isTournament ? : null} {t("actions.submit")} @@ -650,7 +704,7 @@ function BadgesAdder() { } const mapPickingStyleToShort: Record< - Tournament["mapPickingStyle"], + Tables["Tournament"]["mapPickingStyle"], "ALL" | RankedModeShort > = { AUTO_ALL: "ALL", @@ -839,3 +893,107 @@ function MapPoolValidationStatusMessage({
); } + +function TournamentFormatSelector() { + const data = useLoaderData(); + const [format, setFormat] = React.useState( + data.tournamentCtx?.settings.bracketProgression + ? bracketProgressionToShortTournamentFormat( + data.tournamentCtx.settings.bracketProgression, + ) + : "DE", + ); + const [withUndergroundBracket, setWithUndergroundBracket] = React.useState( + data.tournamentCtx + ? data.tournamentCtx.settings.bracketProgression.some( + (b) => b.name === BRACKET_NAMES.UNDERGROUND, + ) + : true, + ); + const [teamsPerGroup, setTeamsPerGroup] = React.useState( + data.tournamentCtx?.settings.teamsPerGroup ?? 4, + ); + + const undergroundBracketExplanation = () => { + if (format === "RR_TO_SE") { + return "Optional bracket for teams that don't make it to the final stage"; + } + + return "Optional bracket for teams who lose in the first two rounds of losers bracket."; + }; + + return ( +
+ Tournament format +
+ + +
+ +
+ + + {undergroundBracketExplanation()} +
+ + {format === "RR_TO_SE" ? ( +
+ + +
+ ) : null} + + {format === "RR_TO_SE" ? ( +
+ + +
+ ) : null} +
+ ); +} diff --git a/app/features/tournament-bracket/brackets-viewer.css b/app/features/tournament-bracket/brackets-viewer.css index 3dfbf9718..bd4fc48fd 100644 --- a/app/features/tournament-bracket/brackets-viewer.css +++ b/app/features/tournament-bracket/brackets-viewer.css @@ -25,6 +25,8 @@ font-family: Lexend, sans-serif !important; font-weight: var(--semi-bold) !important; + + padding: 10px 0; } .brackets-viewer .opponents.connect-previous::before { @@ -39,11 +41,11 @@ border-radius: var(--rounded-sm); } -.brackets-viewer .bracket h2 { - color: transparent; +.brackets-viewer h1 { + display: none; } -.brackets-viewer h1 { +.brackets-viewer h2 { display: none; } @@ -94,3 +96,25 @@ font-size: var(--fonts-xxxs); color: var(--theme); } + +/** Round robin */ + +.brackets-viewer .round-robin { + margin: 0 auto; +} + +th[title="Forfeits"] { + display: none; +} + +th[title="Draws"] { + display: none; +} + +.group td:nth-child(5) { + display: none; +} + +.group td:nth-child(7) { + display: none; +} diff --git a/app/features/tournament-bracket/components/ScoreReporter.tsx b/app/features/tournament-bracket/components/ScoreReporter.tsx index 656d1970e..12ab1a979 100644 --- a/app/features/tournament-bracket/components/ScoreReporter.tsx +++ b/app/features/tournament-bracket/components/ScoreReporter.tsx @@ -1,37 +1,28 @@ -import { - Form, - useActionData, - useLoaderData, - useOutletContext, -} from "@remix-run/react"; +import type { SerializeFrom } from "@remix-run/node"; +import { Form, useLoaderData } from "@remix-run/react"; import clsx from "clsx"; -import { Image } from "~/components/Image"; -import { SubmitButton } from "~/components/SubmitButton"; +import * as React from "react"; import { useTranslation } from "react-i18next"; +import { Image } from "~/components/Image"; +import { NewTabs } from "~/components/NewTabs"; +import { SubmitButton } from "~/components/SubmitButton"; +import { useUser } from "~/features/auth/core"; +import { Chat, useChat } from "~/features/chat/components/Chat"; +import { useTournament } from "~/features/tournament/routes/to.$id"; +import { useIsMounted } from "~/hooks/useIsMounted"; import type { ModeShort, StageId } from "~/modules/in-game-lists"; import type { TournamentMapListMap } from "~/modules/tournament-map-list-generator"; +import { databaseTimestampToDate } from "~/utils/dates"; +import type { Unpacked } from "~/utils/types"; import { modeImageUrl, stageImageUrl } from "~/utils/urls"; import { type TournamentMatchLoaderData } from "../routes/to.$id.matches.$mid"; import { - HACKY_resolvePoolCode, mapCountPlayedInSetWithCertainty, resolveHostingTeam, resolveRoomPass, } from "../tournament-bracket-utils"; -import type { SerializeFrom } from "@remix-run/node"; -import type { Unpacked } from "~/utils/types"; -import type { - TournamentLoaderTeam, - TournamentLoaderData, -} from "~/features/tournament"; -import { canReportTournamentScore, isTournamentOrganizer } from "~/permissions"; -import { useUser } from "~/features/auth/core"; -import { useIsMounted } from "~/hooks/useIsMounted"; -import { databaseTimestampToDate } from "~/utils/dates"; -import { NewTabs } from "~/components/NewTabs"; import { ScoreReporterRosters } from "./ScoreReporterRosters"; -import { Chat, useChat } from "~/features/chat/components/Chat"; -import * as React from "react"; +import type { TournamentDataTeam } from "../core/Tournament.server"; export type Result = Unpacked< SerializeFrom["results"] @@ -47,7 +38,7 @@ export function ScoreReporter({ result, type, }: { - teams: [TournamentLoaderTeam, TournamentLoaderTeam]; + teams: [TournamentDataTeam, TournamentDataTeam]; result?: Result; currentStageWithMode: TournamentMapListMap; modes: ModeShort[]; @@ -58,9 +49,8 @@ export function ScoreReporter({ }) { const { t } = useTranslation(["tournament"]); const isMounted = useIsMounted(); - const actionData = useActionData<{ error?: "locked" }>(); const user = useUser(); - const parentRouteData = useOutletContext(); + const tournament = useTournament(); const data = useLoaderData(); const scoreOne = data.match.opponentOne?.score ?? 0; @@ -92,15 +82,13 @@ export function ScoreReporter({ {t("tournament:match.pool")}{" "} { - HACKY_resolvePoolCode({ - event: parentRouteData.tournament, + tournament.resolvePoolCode({ hostingTeamId: resolveHostingTeam(teams).id, }).prefix } { - HACKY_resolvePoolCode({ - event: parentRouteData.tournament, + tournament.resolvePoolCode({ hostingTeamId: resolveHostingTeam(teams).id, }).lastDigit } @@ -116,8 +104,6 @@ export function ScoreReporter({ , ]; - const matchIsLockedError = actionData?.error === "locked"; - return (
)} - {isTournamentOrganizer({ - user, - tournament: parentRouteData.tournament, - }) && - !parentRouteData.hasFinalized && - presentational && - !matchIsLockedError && ( + {tournament.isOrganizer(user) && + tournament.matchCanBeReopened(data.match.id) && + presentational && (
)} - {matchIsLockedError && ( -
- - {t("tournament:match.action.matchIsLocked")} - -
- )} (); + const tournament = useTournament(); const data = useLoaderData(); const [_unseenMessages, setUnseenMessages] = React.useState(0); const [chatVisible, setChatVisible] = React.useState(false); @@ -358,20 +328,25 @@ function MatchActionSectionTabs({ return Object.fromEntries( [ ...data.match.players.map((p) => ({ ...p, title: undefined })), - ...parentRouteData.tournament.staff.map((s) => ({ + ...tournament.ctx.staff.map((s) => ({ ...s, title: s.role === "STREAMER" ? "Stream" : "TO", })), { - ...parentRouteData.tournament.author, + ...tournament.ctx.author, title: "TO", }, ].map((p) => [p.id, p]), ); - }, [data, parentRouteData]); + }, [data, tournament]); + + const showChat = + data.match.chatCode && + (data.match.players.some((p) => p.id === user?.id) || + tournament.isOrganizerOrStreamer(user)); const rooms = React.useMemo(() => { - return data.match.chatCode + return showChat && data.match.chatCode ? [ { code: data.match.chatCode, @@ -379,7 +354,7 @@ function MatchActionSectionTabs({ }, ] : []; - }, [data.match.chatCode]); + }, [showChat, data.match.chatCode]); const onNewMessage = React.useCallback(() => { setUnseenMessages((msg) => msg + 1); @@ -400,10 +375,6 @@ function MatchActionSectionTabs({ const currentPosition = scores[0] + scores[1]; - const isMemberOfATeamInTheMatch = data.match.players.some( - (p) => p.id === user?.id, - ); - return ( - {data.match.chatCode ? ( + {showChat ? ( ), diff --git a/app/features/tournament-bracket/components/ScoreReporterRosters.tsx b/app/features/tournament-bracket/components/ScoreReporterRosters.tsx index d33cf669c..a11aef188 100644 --- a/app/features/tournament-bracket/components/ScoreReporterRosters.tsx +++ b/app/features/tournament-bracket/components/ScoreReporterRosters.tsx @@ -1,6 +1,5 @@ import * as React from "react"; import { Form, useLoaderData } from "@remix-run/react"; -import type { TournamentLoaderTeam } from "../../tournament/routes/to.$id"; import { TOURNAMENT } from "../../tournament/tournament-constants"; import { SubmitButton } from "~/components/SubmitButton"; import { TeamRosterInputs } from "./TeamRosterInputs"; @@ -11,6 +10,8 @@ import type { TournamentMatchLoaderData } from "../routes/to.$id.matches.$mid"; import type { SerializeFrom } from "@remix-run/node"; import { stageImageUrl } from "~/utils/urls"; import { Image } from "~/components/Image"; +import type { TournamentDataTeam } from "../core/Tournament.server"; +import { useTournament } from "~/features/tournament/routes/to.$id"; export function ScoreReporterRosters({ teams, @@ -21,7 +22,7 @@ export function ScoreReporterRosters({ bestOf, presentational: _presentational, }: { - teams: [TournamentLoaderTeam, TournamentLoaderTeam]; + teams: [TournamentDataTeam, TournamentDataTeam]; position: number; currentStageWithMode: TournamentMapListMap; result?: Result; @@ -29,6 +30,7 @@ export function ScoreReporterRosters({ bestOf: number; presentational?: boolean; }) { + const tournament = useTournament(); const data = useLoaderData(); const [checkedPlayers, setCheckedPlayers] = React.useState< [number[], number[]] @@ -40,6 +42,7 @@ export function ScoreReporterRosters({ }), ); const [winnerId, setWinnerId] = React.useState(); + const [points, setPoints] = React.useState<[number, number]>([0, 0]); const presentational = _presentational || Boolean(result); @@ -49,6 +52,10 @@ export function ScoreReporterRosters({ ]; const wouldEndSet = newScore.some((score) => score > bestOf / 2); + const showPoints = tournament.bracketByIdxOrDefault( + tournament.matchIdToBracketIdx(data.match.id) ?? 0, + ).collectResultsWithPoints; + return (
@@ -58,6 +65,8 @@ export function ScoreReporterRosters({ setWinnerId={setWinnerId} checkedPlayers={checkedPlayers} setCheckedPlayers={setCheckedPlayers} + points={showPoints ? points : undefined} + setPoints={setPoints} result={result} /> {!presentational ? ( @@ -68,8 +77,17 @@ export function ScoreReporterRosters({ name="playerIds" value={JSON.stringify(checkedPlayers.flat())} /> + {showPoints ? ( + + ) : null} + Winner should have more points than loser +

+ ); + } + + if ( + points && + ((points[0] === 100 && points[1] !== 0) || + (points[0] !== 0 && points[1] === 100)) + ) { + return ( +

+ If there was a KO (100 points), other team should have 0 points +

+ ); + } + if ( !checkedPlayers.every( (team) => team.length === TOURNAMENT.TEAM_MIN_MEMBERS_FOR_FULL, diff --git a/app/features/tournament-bracket/components/TeamRosterInputs.tsx b/app/features/tournament-bracket/components/TeamRosterInputs.tsx index 45edb567b..c7b4ab2ad 100644 --- a/app/features/tournament-bracket/components/TeamRosterInputs.tsx +++ b/app/features/tournament-bracket/components/TeamRosterInputs.tsx @@ -3,15 +3,13 @@ import clone from "just-clone"; import * as React from "react"; import { TOURNAMENT } from "../../tournament/tournament-constants"; import { Label } from "~/components/Label"; -import type { - TournamentLoaderData, - TournamentLoaderTeam, -} from "../../tournament/routes/to.$id"; +import { useTournament } from "../../tournament/routes/to.$id"; import { inGameNameWithoutDiscriminator } from "~/utils/strings"; -import { Link, useLoaderData, useOutletContext } from "@remix-run/react"; +import { Link, useLoaderData } from "@remix-run/react"; import type { TournamentMatchLoaderData } from "../routes/to.$id.matches.$mid"; import type { Result } from "./ScoreReporter"; import { tournamentTeamPage } from "~/utils/urls"; +import type { TournamentDataTeam } from "../core/Tournament.server"; /** Inputs to select who played for teams in a match as well as the winner. Can also be used in a presentational way. */ export function TeamRosterInputs({ @@ -20,83 +18,128 @@ export function TeamRosterInputs({ setWinnerId, checkedPlayers, setCheckedPlayers, + points: _points, + setPoints, result, }: { - teams: [TournamentLoaderTeam, TournamentLoaderTeam]; + teams: [TournamentDataTeam, TournamentDataTeam]; winnerId?: number | null; setWinnerId: (newId?: number) => void; checkedPlayers: [number[], number[]]; setCheckedPlayers?: (newPlayerIds: [number[], number[]]) => void; + points?: [number, number]; + setPoints: (newPoints: [number, number]) => void; result?: Result; }) { const presentational = Boolean(result); - const parentRouteData = useOutletContext(); + const tournament = useTournament(); const data = useLoaderData(); React.useEffect(() => { setWinnerId(undefined); - }, [data, setWinnerId]); + setPoints([0, 0]); + }, [data, setWinnerId, setPoints]); + + const points = + typeof result?.opponentOnePoints === "number" && + typeof result?.opponentTwoPoints === "number" + ? ([result.opponentOnePoints, result.opponentTwoPoints] as [ + number, + number, + ]) + : _points; return (
- {teams.map((team, teamI) => ( -
-
-
- Team {teamI + 1} -
-

- - #{data.seeds[teamI]} - {" "} - - {team.name} - -

- setWinnerId?.(team.id)} - team={teamI + 1} - /> - { - const newCheckedPlayers = () => { - const newPlayers = clone(checkedPlayers); - if (checkedPlayers.flat().includes(playerId)) { - newPlayers[teamI] = newPlayers[teamI]!.filter( - (id) => id !== playerId, - ); - } else { - newPlayers[teamI]!.push(playerId); - } + {teams.map((team, teamI) => { + const winnerRadioChecked = result + ? result.winnerTeamId === team.id + : winnerId === team.id; - return newPlayers; - }; - setCheckedPlayers?.(newCheckedPlayers()); - }} - /> -
- ))} + // just so we can center the points nicely + const showWinnerRadio = + !points || !presentational || winnerRadioChecked; + + const seed = tournament.seedByTeamId(team.id); + + return ( +
+
+
+ Team {teamI + 1} +
+

+ {seed ? ( + + #{seed} + + ) : null}{" "} + + {team.name} + +

+
+ {showWinnerRadio ? ( + setWinnerId?.(team.id)} + team={teamI + 1} + /> + ) : null} + {points ? ( + { + const newPoints = clone(points); + newPoints[teamI] = newPoint; + setPoints(newPoints); + }} + presentational={presentational} + /> + ) : null} +
+ { + const newCheckedPlayers = () => { + const newPlayers = clone(checkedPlayers); + if (checkedPlayers.flat().includes(playerId)) { + newPlayers[teamI] = newPlayers[teamI]!.filter( + (id) => id !== playerId, + ); + } else { + newPlayers[teamI]!.push(playerId); + } + + return newPlayers; + }; + setCheckedPlayers?.(newCheckedPlayers()); + }} + /> +
+ ); + })}
); } @@ -120,7 +163,7 @@ function WinnerRadio({ if (presentational) { return (
void; + presentational: boolean; +}) { + const id = React.useId(); + + if (presentational) { + return ( +
+ {value === 100 ? <>KO : <>{value}p} +
+ ); + } + + return ( +
+ onChange(Number(e.target.value))} + type="number" + min={0} + max={100} + value={value} + required + id={id} + /> + +
+ ); +} + function TeamRosterInputsCheckboxes({ teamId, checkedPlayers, diff --git a/app/features/tournament-bracket/core/Bracket.ts b/app/features/tournament-bracket/core/Bracket.ts new file mode 100644 index 000000000..5b1225924 --- /dev/null +++ b/app/features/tournament-bracket/core/Bracket.ts @@ -0,0 +1,707 @@ +import invariant from "tiny-invariant"; +import type { Tables, TournamentBracketProgression } from "~/db/tables"; +import { TOURNAMENT } from "~/features/tournament"; +import type { DataTypes, ValueToArray } from "~/modules/brackets-manager/types"; +import { assertUnreachable } from "~/utils/types"; +import type { OptionalIdObject, Tournament } from "./Tournament"; +import type { TournamentDataTeam } from "./Tournament.server"; +import { removeDuplicates } from "~/utils/arrays"; +import { BRACKET_NAMES } from "~/features/tournament/tournament-constants"; +import { logger } from "~/utils/logger"; + +interface CreateBracketArgs { + id: number; + preview: boolean; + data: ValueToArray; + type: Tables["TournamentStage"]["type"]; + canBeStarted?: boolean; + name: string; + teamsPendingCheckIn?: number[]; + tournament: Tournament; + sources?: { + bracketIdx: number; + placements: number[]; + }[]; +} + +export interface Standing { + team: TournamentDataTeam; + placement: number; // 1st, 2nd, 3rd, 4th, 5th, 5th... +} + +export abstract class Bracket { + id; + preview; + data; + canBeStarted; + name; + teamsPendingCheckIn; + tournament; + sources; + + constructor({ + id, + preview, + data, + canBeStarted, + name, + teamsPendingCheckIn, + tournament, + sources, + }: Omit) { + this.id = id; + this.preview = preview; + this.data = data; + this.canBeStarted = canBeStarted; + this.name = name; + this.teamsPendingCheckIn = teamsPendingCheckIn; + this.tournament = tournament; + this.sources = sources; + } + + get collectResultsWithPoints() { + return false; + } + + get type(): TournamentBracketProgression[number]["type"] { + throw new Error("not implemented"); + } + + get standings(): Standing[] { + throw new Error("not implemented"); + } + + protected standingsWithoutNonParticipants(standings: Standing[]): Standing[] { + return standings.map((standing) => { + return { + ...standing, + team: { + ...standing.team, + members: standing.team.members.filter((member) => + this.tournament.ctx.participatedUsers.includes(member.userId), + ), + }, + }; + }); + } + + get isUnderground() { + return this.name === BRACKET_NAMES.UNDERGROUND; + } + + get everyMatchOver() { + if (this.preview) return false; + + for (const match of this.data.match) { + // BYE + if (match.opponent1 === null || match.opponent2 === null) { + continue; + } + if ( + match.opponent1?.result !== "win" && + match.opponent2?.result !== "win" + ) { + return false; + } + } + + return true; + } + + get enoughTeams() { + return this.data.participant.length >= TOURNAMENT.ENOUGH_TEAMS_TO_START; + } + + canCheckIn(user: OptionalIdObject) { + // using regular check-in + if (!this.teamsPendingCheckIn) return false; + + const team = this.tournament.ownedTeamByUser(user); + if (!team) return false; + + return this.teamsPendingCheckIn.includes(team.id); + } + + source(_placements: number[]): { + relevantMatchesFinished: boolean; + teams: { id: number; name: string }[]; + } { + throw new Error("not implemented"); + } + + teamsWithNames(teams: { id: number }[]) { + return teams.map((team) => { + const name = this.data.participant.find( + (participant) => participant.id === team.id, + )?.name; + invariant(name, `Team name not found for id: ${team.id}`); + + return { + id: team.id, + name, + }; + }); + } + + static create( + args: CreateBracketArgs, + ): SingleEliminationBracket | DoubleEliminationBracket | RoundRobinBracket { + switch (args.type) { + case "single_elimination": { + return new SingleEliminationBracket(args); + } + case "double_elimination": { + return new DoubleEliminationBracket(args); + } + case "round_robin": { + return new RoundRobinBracket(args); + } + default: { + assertUnreachable(args.type); + } + } + } +} + +class SingleEliminationBracket extends Bracket { + constructor(args: CreateBracketArgs) { + super(args); + } + + get type(): TournamentBracketProgression[number]["type"] { + return "single_elimination"; + } + + get standings(): Standing[] { + const teams: { id: number; lostAt: number }[] = []; + + for (const match of this.data.match + .slice() + .sort((a, b) => a.round_id - b.round_id)) { + if ( + match.opponent1?.result !== "win" && + match.opponent2?.result !== "win" + ) { + continue; + } + + const loser = + match.opponent1?.result === "win" ? match.opponent2 : match.opponent1; + invariant(loser?.id, "Loser id not found"); + + teams.push({ id: loser.id, lostAt: match.round_id }); + } + + const teamCountWhoDidntLoseYet = + this.data.participant.length - teams.length; + + const result: Standing[] = []; + for (const roundId of removeDuplicates(teams.map((team) => team.lostAt))) { + const teamsLostThisRound: { id: number }[] = []; + while (teams.length && teams[0].lostAt === roundId) { + teamsLostThisRound.push(teams.shift()!); + } + + for (const { id: teamId } of teamsLostThisRound) { + const team = this.tournament.teamById(teamId); + invariant(team, `Team not found for id: ${teamId}`); + + const teamsPlacedAbove = teamCountWhoDidntLoseYet + teams.length; + + result.push({ + team, + placement: teamsPlacedAbove + 1, + }); + } + } + + if (teamCountWhoDidntLoseYet === 1) { + const winner = this.data.participant.find((participant) => + result.every(({ team }) => team.id !== participant.id), + ); + invariant(winner, "No winner identified"); + + const winnerTeam = this.tournament.teamById(winner.id); + invariant(winnerTeam, `Winner team not found for id: ${winner.id}`); + + result.push({ + team: winnerTeam, + placement: 1, + }); + } + + // TODO: 3rd place match + + return this.standingsWithoutNonParticipants(result.reverse()); + } +} + +class DoubleEliminationBracket extends Bracket { + constructor(args: CreateBracketArgs) { + super(args); + } + + get type(): TournamentBracketProgression[number]["type"] { + return "double_elimination"; + } + + get standings(): Standing[] { + const losersGroupId = this.data.group.find((g) => g.number === 2)?.id; + + const teams: { id: number; lostAt: number }[] = []; + + for (const match of this.data.match + .slice() + .sort((a, b) => a.round_id - b.round_id)) { + if (match.group_id !== losersGroupId) continue; + + if ( + match.opponent1?.result !== "win" && + match.opponent2?.result !== "win" + ) { + continue; + } + + // BYE + if (!match.opponent1 || !match.opponent2) continue; + + const loser = + match.opponent1?.result === "win" ? match.opponent2 : match.opponent1; + invariant(loser?.id, "Loser id not found"); + + teams.push({ id: loser.id, lostAt: match.round_id }); + } + + const teamCountWhoDidntLoseInLosersYet = + this.data.participant.length - teams.length; + + const result: Standing[] = []; + for (const roundId of removeDuplicates(teams.map((team) => team.lostAt))) { + const teamsLostThisRound: { id: number }[] = []; + while (teams.length && teams[0].lostAt === roundId) { + teamsLostThisRound.push(teams.shift()!); + } + + for (const { id: teamId } of teamsLostThisRound) { + const team = this.tournament.teamById(teamId); + invariant(team, `Team not found for id: ${teamId}`); + + const teamsPlacedAbove = + teamCountWhoDidntLoseInLosersYet + teams.length; + + result.push({ + team, + placement: teamsPlacedAbove + 1, + }); + } + } + + // edge case: 1 match only + const noLosersRounds = !losersGroupId; + const grandFinalsNumber = noLosersRounds ? 1 : 3; + const grandFinalsGroupId = this.data.group.find( + (g) => g.number === grandFinalsNumber, + )?.id; + invariant(grandFinalsGroupId, "GF group not found"); + const grandFinalMatches = this.data.match.filter( + (match) => match.group_id === grandFinalsGroupId, + ); + + // if opponent1 won in DE it means that bracket reset is not played + if ( + grandFinalMatches[0].opponent1 && + (noLosersRounds || grandFinalMatches[0].opponent1.result === "win") + ) { + const loserTeam = this.tournament.teamById( + grandFinalMatches[0].opponent2!.id!, + ); + invariant(loserTeam, "Loser team not found"); + const winnerTeam = this.tournament.teamById( + grandFinalMatches[0].opponent1.id!, + ); + invariant(winnerTeam, "Winner team not found"); + + result.push({ + team: loserTeam, + placement: 2, + }); + + result.push({ + team: winnerTeam, + placement: 1, + }); + } else if ( + grandFinalMatches[1].opponent1?.result === "win" || + grandFinalMatches[1].opponent2?.result === "win" + ) { + const loser = + grandFinalMatches[1].opponent1?.result === "win" + ? "opponent2" + : "opponent1"; + const winner = loser === "opponent1" ? "opponent2" : "opponent1"; + + const loserTeam = this.tournament.teamById( + grandFinalMatches[1][loser]!.id!, + ); + invariant(loserTeam, "Loser team not found"); + const winnerTeam = this.tournament.teamById( + grandFinalMatches[1][winner]!.id!, + ); + invariant(winnerTeam, "Winner team not found"); + + result.push({ + team: loserTeam, + placement: 2, + }); + + result.push({ + team: winnerTeam, + placement: 1, + }); + } + + return this.standingsWithoutNonParticipants(result.reverse()); + } + + get everyMatchOver() { + if (this.preview) return false; + + let lastWinner = -1; + for (const [i, match] of this.data.match.entries()) { + // special case - bracket reset might not be played depending on who wins in the grands + const isLast = i === this.data.match.length - 1; + if (isLast && lastWinner === 1) { + continue; + } + // BYE + if (match.opponent1 === null || match.opponent2 === null) { + continue; + } + if ( + match.opponent1?.result !== "win" && + match.opponent2?.result !== "win" + ) { + return false; + } + + lastWinner = match.opponent1?.result === "win" ? 1 : 2; + } + + return true; + } + + source(placements: number[]) { + const resolveLosersGroupId = (data: ValueToArray) => { + const minGroupId = Math.min(...data.round.map((round) => round.group_id)); + + return minGroupId + 1; + }; + const placementsToRoundsIds = ( + data: ValueToArray, + losersGroupId: number, + ) => { + const firstRoundIsOnlyByes = () => { + const losersMatches = data.match.filter( + (match) => match.group_id === losersGroupId, + ); + + const fistRoundId = Math.min(...losersMatches.map((m) => m.round_id)); + + const firstRoundMatches = losersMatches.filter( + (match) => match.round_id === fistRoundId, + ); + + return firstRoundMatches.every( + (match) => match.opponent1 === null || match.opponent2 === null, + ); + }; + + const losersRounds = data.round.filter( + (round) => round.group_id === losersGroupId, + ); + const orderedRoundsIds = losersRounds + .map((round) => round.id) + .sort((a, b) => a - b); + const amountOfRounds = + Math.abs(Math.min(...placements)) + (firstRoundIsOnlyByes() ? 1 : 0); + + return orderedRoundsIds.slice(0, amountOfRounds); + }; + + invariant( + placements.every((placement) => placement < 0), + "Positive placements in DE not implemented", + ); + + const losersGroupId = resolveLosersGroupId(this.data); + const sourceRoundsIds = placementsToRoundsIds( + this.data, + losersGroupId, + ).sort( + // teams who made it further in the bracket get higher seed + (a, b) => b - a, + ); + + const teams: { id: number }[] = []; + let relevantMatchesFinished = true; + for (const roundId of sourceRoundsIds) { + const roundsMatches = this.data.match.filter( + (match) => match.round_id === roundId, + ); + + for (const match of roundsMatches) { + if ( + match.opponent1?.result !== "win" && + match.opponent2?.result !== "win" + ) { + relevantMatchesFinished = false; + continue; + } + + const loser = + match.opponent1?.result === "win" ? match.opponent2 : match.opponent1; + invariant(loser?.id, "Loser id not found"); + + teams.push({ id: loser.id }); + } + } + + return { + relevantMatchesFinished, + teams: this.teamsWithNames(teams), + }; + } +} + +class RoundRobinBracket extends Bracket { + constructor(args: CreateBracketArgs) { + super(args); + } + + get collectResultsWithPoints() { + return true; + } + + source(placements: number[]): { + relevantMatchesFinished: boolean; + teams: { id: number; name: string }[]; + } { + if (placements.some((p) => p < 0)) { + throw new Error("Negative placements not implemented"); + } + const standings = this.standings; + const relevantMatchesFinished = + standings.length === this.data.participant.length; + + const uniquePlacements = removeDuplicates( + standings.map((s) => s.placement), + ); + + // 1,3,5 -> 1,2,3 e.g. + const placementNormalized = (p: number) => { + return uniquePlacements.indexOf(p) + 1; + }; + + return { + relevantMatchesFinished, + teams: standings + .filter((s) => placements.includes(placementNormalized(s.placement))) + .map((s) => ({ id: s.team.id, name: s.team.name })), + }; + } + + get standings(): Standing[] { + const groupIds = this.data.group.map((group) => group.id); + + const placements: (Standing & { groupId: number })[] = []; + for (const groupId of groupIds) { + const matches = this.data.match.filter( + (match) => match.group_id === groupId, + ); + + const groupIsFinished = matches.every( + (match) => + // BYE + match.opponent1 === null || + match.opponent2 === null || + // match was played out + match.opponent1?.result === "win" || + match.opponent2?.result === "win", + ); + + if (!groupIsFinished) continue; + + const teams: { + id: number; + setWins: number; + setLosses: number; + mapWins: number; + mapLosses: number; + winsAgainstTied: number; + points: number; + }[] = []; + + const updateTeam = ({ + teamId, + setWins, + setLosses, + mapWins, + mapLosses, + points, + }: { + teamId: number; + setWins: number; + setLosses: number; + mapWins: number; + mapLosses: number; + points: number; + }) => { + const team = teams.find((team) => team.id === teamId); + if (team) { + team.setWins += setWins; + team.setLosses += setLosses; + team.mapWins += mapWins; + team.mapLosses += mapLosses; + team.points += points; + } else { + teams.push({ + id: teamId, + setWins, + setLosses, + mapWins, + mapLosses, + winsAgainstTied: 0, + points, + }); + } + }; + + for (const match of matches) { + const winner = + match.opponent1?.result === "win" ? match.opponent1 : match.opponent2; + + const loser = + match.opponent1?.result === "win" ? match.opponent2 : match.opponent1; + + if (!winner || !loser) continue; + + invariant( + typeof winner.id === "number" && + typeof loser.id === "number" && + typeof winner.score === "number" && + typeof loser.score === "number", + ); + + if ( + typeof winner.totalPoints !== "number" || + typeof loser.totalPoints !== "number" + ) { + logger.warn( + "RoundRobinBracket.standings: winner or loser points not found", + ); + } + + updateTeam({ + teamId: winner.id, + setWins: 1, + setLosses: 0, + mapWins: winner.score, + mapLosses: loser.score, + points: winner.totalPoints ?? 0, + }); + updateTeam({ + teamId: loser.id, + setWins: 0, + setLosses: 1, + mapWins: loser.score, + mapLosses: winner.score, + points: loser.totalPoints ?? 0, + }); + } + + for (const team of teams) { + for (const team2 of teams) { + if (team.id === team2.id) continue; + if (team.setWins !== team2.setWins) continue; + + // they are different teams and are tied, let's check who won + + const wonTheirMatch = matches.some( + (match) => + (match.opponent1?.id === team.id && + match.opponent2?.id === team2.id && + match.opponent1?.result === "win") || + (match.opponent1?.id === team2.id && + match.opponent2?.id === team.id && + match.opponent2?.result === "win"), + ); + + if (wonTheirMatch) { + team.winsAgainstTied++; + } + } + } + + placements.push( + ...teams + .sort((a, b) => { + if (a.setWins > b.setWins) return -1; + if (a.setWins < b.setWins) return 1; + + if (a.winsAgainstTied > b.winsAgainstTied) return -1; + if (a.winsAgainstTied < b.winsAgainstTied) return 1; + + if (a.mapWins > b.mapWins) return -1; + if (a.mapWins < b.mapWins) return 1; + + if (a.points > b.points) return -1; + if (a.points < b.points) return 1; + + const aSeed = Number(this.tournament.seedByTeamId(a.id)); + const bSeed = Number(this.tournament.seedByTeamId(b.id)); + + if (aSeed < bSeed) return -1; + if (aSeed > bSeed) return 1; + + return 0; + }) + .map((team, i) => { + return { + team: this.tournament.teamById(team.id)!, + placement: i + 1, + groupId, + }; + }), + ); + } + + const sorted = placements.sort((a, b) => { + if (a.placement < b.placement) return -1; + if (a.placement > b.placement) return 1; + + if (a.groupId < b.groupId) return -1; + if (a.groupId > b.groupId) return 1; + + return 0; + }); + + let lastPlacement = 0; + let currentPlacement = 1; + let teamsEncountered = 0; + return sorted.map((team) => { + if (team.placement !== lastPlacement) { + lastPlacement = team.placement; + currentPlacement = teamsEncountered + 1; + } + teamsEncountered++; + return { + ...team, + placement: currentPlacement, + }; + }); + } + + get type(): TournamentBracketProgression[number]["type"] { + return "round_robin"; + } +} diff --git a/app/features/tournament-bracket/core/Tournament.server.ts b/app/features/tournament-bracket/core/Tournament.server.ts new file mode 100644 index 000000000..c1748a264 --- /dev/null +++ b/app/features/tournament-bracket/core/Tournament.server.ts @@ -0,0 +1,53 @@ +import { Tournament } from "./Tournament"; +import { getTournamentManager } from ".."; +import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; +import { notFoundIfFalsy } from "~/utils/remix"; +import type { Unwrapped } from "~/utils/types"; + +const manager = getTournamentManager("SQL"); + +export type TournamentData = Unwrapped; +export type TournamentDataTeam = TournamentData["ctx"]["teams"][number]; +export async function tournamentData({ + user, + tournamentId, +}: { + user?: { id: number }; + tournamentId: number; +}) { + const ctx = notFoundIfFalsy( + await TournamentRepository.findById(tournamentId), + ); + + const revealAllMapPools = + ctx.inProgressBrackets.length > 0 || + ctx.author.id === user?.id || + ctx.staff.some( + (staff) => staff.id === user?.id && staff.role === "ORGANIZER", + ); + + return { + data: manager.get.tournamentData(tournamentId), + ctx: { + ...ctx, + teams: ctx.teams.map((team) => { + const isOwnTeam = team.members.some( + (member) => member.userId === user?.id, + ); + + return { + ...team, + mapPool: revealAllMapPools || isOwnTeam ? team.mapPool : null, + inviteCode: isOwnTeam ? team.inviteCode : null, + }; + }), + }, + }; +} + +export async function tournamentFromDB(args: { + user: { id: number } | undefined; + tournamentId: number; +}) { + return new Tournament(await tournamentData(args)); +} diff --git a/app/features/tournament-bracket/core/Tournament.ts b/app/features/tournament-bracket/core/Tournament.ts new file mode 100644 index 000000000..4e76259f7 --- /dev/null +++ b/app/features/tournament-bracket/core/Tournament.ts @@ -0,0 +1,611 @@ +import invariant from "tiny-invariant"; +import type { TournamentBracketProgression } from "~/db/tables"; +import type { DataTypes, ValueToArray } from "~/modules/brackets-manager/types"; +import { logger } from "~/utils/logger"; +import { assertUnreachable } from "~/utils/types"; +import { isAdmin } from "~/permissions"; +import { TOURNAMENT } from "~/features/tournament"; +import type { TournamentData } from "./Tournament.server"; +import { + HACKY_isInviteOnlyEvent, + HACKY_resolvePicture, +} from "~/features/tournament/tournament-utils"; +import { rankedModesShort } from "~/modules/in-game-lists/modes"; +import type { ModeShort } from "~/modules/in-game-lists"; +import { databaseTimestampToDate } from "~/utils/dates"; +import { getTournamentManager } from ".."; +import { fillWithNullTillPowerOfTwo } from "../tournament-bracket-utils"; +import type { Stage } from "~/modules/brackets-model"; +import { Bracket } from "./Bracket"; +import { BRACKET_NAMES } from "~/features/tournament/tournament-constants"; + +export type OptionalIdObject = { id: number } | undefined; + +/** Extends and providers utility functions on top of the bracket-manager library. Updating data after the bracket has started is responsibility of bracket-manager. */ +export class Tournament { + brackets: Bracket[] = []; + ctx; + + constructor({ data, ctx }: TournamentData) { + const hasStarted = ctx.inProgressBrackets.length > 0; + + const teamsInSeedOrder = ctx.teams.sort((a, b) => { + if (a.seed && b.seed) { + return a.seed - b.seed; + } + + if (a.seed && !b.seed) { + return -1; + } + + if (!a.seed && b.seed) { + return 1; + } + + return a.createdAt - b.createdAt; + }); + this.ctx = { + ...ctx, + teams: hasStarted + ? // after the start the teams who did not check-in are irrelevant + teamsInSeedOrder.filter((team) => team.checkIns.length > 0) + : teamsInSeedOrder, + startTime: databaseTimestampToDate(ctx.startTime), + }; + + this.initBrackets(data); + } + + private initBrackets(data: ValueToArray) { + for (const [ + bracketIdx, + { type, name, sources }, + ] of this.ctx.settings.bracketProgression.entries()) { + const inProgressStage = this.ctx.inProgressBrackets.find( + (stage) => stage.name === name, + ); + + if (inProgressStage) { + const match = data.match.filter( + (match) => match.stage_id === inProgressStage.id, + ); + const participants = new Set( + match + .flatMap((match) => [match.opponent1?.id, match.opponent2?.id]) + .filter((id) => typeof id === "number"), + ); + + this.brackets.push( + Bracket.create({ + id: inProgressStage.id, + tournament: this, + preview: false, + name, + sources, + data: { + ...data, + participant: data.participant.filter((participant) => + participants.has(participant.id), + ), + group: data.group.filter( + (group) => group.stage_id === inProgressStage.id, + ), + match, + stage: data.stage.filter( + (stage) => stage.id === inProgressStage.id, + ), + round: data.round.filter( + (round) => round.stage_id === inProgressStage.id, + ), + }, + type, + }), + ); + } else { + const manager = getTournamentManager("IN_MEMORY"); + const { teams, relevantMatchesFinished } = sources + ? this.resolveTeamsFromSources(sources) + : { + teams: this.ctx.teams, + relevantMatchesFinished: true, + }; + + const { checkedInTeams, notCheckedInTeams } = + this.divideTeamsToCheckedInAndNotCheckedIn({ + teams, + bracketIdx, + }); + + if (checkedInTeams.length >= TOURNAMENT.ENOUGH_TEAMS_TO_START) { + const seeding = checkedInTeams.map((team) => team.name); + manager.create({ + tournamentId: this.ctx.id, + name, + type, + seeding: + type === "round_robin" + ? seeding + : fillWithNullTillPowerOfTwo(seeding), + settings: this.bracketSettings(type, checkedInTeams.length), + }); + } + + this.brackets.push( + Bracket.create({ + id: -1 * bracketIdx, + tournament: this, + preview: true, + name, + data: manager.get.tournamentData(this.ctx.id), + type, + sources, + canBeStarted: + checkedInTeams.length >= TOURNAMENT.ENOUGH_TEAMS_TO_START && + (sources ? relevantMatchesFinished : this.regularCheckInHasEnded), + teamsPendingCheckIn: + bracketIdx !== 0 ? notCheckedInTeams.map((t) => t.id) : undefined, + }), + ); + } + } + } + + private resolveTeamsFromSources( + sources: NonNullable, + ) { + const teams: { id: number; name: string }[] = []; + + let allRelevantMatchesFinished = true; + for (const { bracketIdx, placements } of sources) { + const sourceBracket = this.bracketByIdx(bracketIdx); + invariant(sourceBracket, "Bracket not found"); + + const { teams: sourcedTeams, relevantMatchesFinished } = + sourceBracket.source(placements); + if (!relevantMatchesFinished) { + allRelevantMatchesFinished = false; + } + teams.push(...sourcedTeams); + } + + return { teams, relevantMatchesFinished: allRelevantMatchesFinished }; + } + + private divideTeamsToCheckedInAndNotCheckedIn({ + teams, + bracketIdx, + }: { + teams: { id: number; name: string }[]; + bracketIdx: number; + }) { + return teams.reduce( + (acc, cur) => { + const team = this.teamById(cur.id); + invariant(team, "Team not found"); + + const usesRegularCheckIn = bracketIdx === 0; + if (usesRegularCheckIn) { + if (team.checkIns.length > 0 || !this.regularCheckInStartInThePast) { + acc.checkedInTeams.push(cur); + } else { + acc.notCheckedInTeams.push(cur); + } + } else { + if ( + team.checkIns.some((checkIn) => checkIn.bracketIdx === bracketIdx) + ) { + acc.checkedInTeams.push(cur); + } else { + acc.notCheckedInTeams.push(cur); + } + } + + return acc; + }, + { checkedInTeams: [], notCheckedInTeams: [] } as { + checkedInTeams: { id: number; name: string }[]; + notCheckedInTeams: { id: number; name: string }[]; + }, + ); + } + + bracketSettings( + type: TournamentBracketProgression[number]["type"], + participantsCount: number, + ): Stage["settings"] { + switch (type) { + case "single_elimination": + return { consolationFinal: false }; + case "double_elimination": + return { + grandFinal: "double", + }; + case "round_robin": + return { + groupCount: Math.ceil( + participantsCount / (this.ctx.settings.teamsPerGroup ?? 4), + ), + seedOrdering: ["groups.seed_optimized"], + }; + default: { + assertUnreachable(type); + } + } + } + + get logoSrc() { + return HACKY_resolvePicture(this.ctx); + } + + get modesIncluded(): ModeShort[] { + switch (this.ctx.mapPickingStyle) { + case "AUTO_SZ": { + return ["SZ"]; + } + case "AUTO_TC": { + return ["TC"]; + } + case "AUTO_RM": { + return ["RM"]; + } + case "AUTO_CB": { + return ["CB"]; + } + default: { + return [...rankedModesShort]; + } + } + } + + resolvePoolCode({ hostingTeamId }: { hostingTeamId: number }) { + const tournamentNameWithoutOnlyLetters = this.ctx.name.replace( + /[^a-zA-Z]/g, + "", + ); + const prefix = tournamentNameWithoutOnlyLetters + .split(" ") + .map((word) => word[0]) + .join("") + .toUpperCase() + .slice(0, 3); + const lastDigit = hostingTeamId % 10; + + return { prefix, lastDigit }; + } + + get mapPickCountPerMode() { + return this.modesIncluded.length === 1 + ? TOURNAMENT.COUNTERPICK_ONE_MODE_TOURNAMENT_MAPS_PER_MODE + : TOURNAMENT.COUNTERPICK_MAPS_PER_MODE; + } + + get hasOpenRegistration() { + return !HACKY_isInviteOnlyEvent(this.ctx); + } + + get hasStarted() { + return this.brackets.some((bracket) => !bracket.preview); + } + + get everyBracketOver() { + if (this.ctx.isFinalized) return true; + + return this.brackets.every((bracket) => bracket.everyMatchOver); + } + + teamById(id: number) { + return this.ctx.teams.find((team) => team.id === id); + } + + seedByTeamId(id: number) { + const idx = this.ctx.teams.findIndex((team) => team.id === id); + + if (idx === -1) return null; + + return idx + 1; + } + + participatedPlayersByTeamId(id: number) { + const team = this.teamById(id); + invariant(team, "Team not found"); + + return team.members.filter((member) => + this.ctx.participatedUsers.includes(member.userId), + ); + } + + matchIdToBracketIdx(matchId: number) { + const idx = this.brackets.findIndex((bracket) => + bracket.data.match.some((match) => match.id === matchId), + ); + + if (idx === -1) return null; + + return idx; + } + + get standings() { + for (const bracket of this.brackets) { + if (bracket.name === BRACKET_NAMES.MAIN) { + return bracket.standings; + } + + if (bracket.name === BRACKET_NAMES.FINALS) { + const finalsStandings = bracket.standings; + + const firstStageStandings = this.brackets[0].standings; + + const uniqueFinalsPlacements = new Set(); + const firstStageWithoutFinalsParticipants = firstStageStandings.filter( + (firstStageStanding) => { + const isFinalsParticipant = finalsStandings.some( + (finalsStanding) => + finalsStanding.team.id === firstStageStanding.team.id, + ); + + if (isFinalsParticipant) { + uniqueFinalsPlacements.add(firstStageStanding.placement); + } + + return !isFinalsParticipant; + }, + ); + + return [ + ...finalsStandings, + ...firstStageWithoutFinalsParticipants.filter( + // handle edge case where teams didn't check in to the final stage despite being qualified + // although this would bug out if all teams of certain placement fail to check in + // but probably that should not happen too likely + (p) => !uniqueFinalsPlacements.has(p.placement), + ), + ]; + } + } + + logger.warn("Standings not found"); + return []; + } + + canFinalize(user: OptionalIdObject) { + // can skip underground bracket + const relevantBrackets = this.brackets.filter( + (b) => !b.preview || !b.isUnderground, + ); + + return ( + relevantBrackets.every((b) => b.everyMatchOver) && + this.isOrganizer(user) && + !this.ctx.isFinalized + ); + } + + canReportScore({ + matchId, + user, + }: { + matchId: number; + user: OptionalIdObject; + }) { + const match = this.brackets + .flatMap((bracket) => (bracket.preview ? [] : bracket.data.match)) + .find((match) => match.id === matchId); + invariant(match, "Match not found"); + + // match didn't start yet + if (!match.opponent1 || !match.opponent2) return false; + + const matchIsOver = + match.opponent1.result === "win" || match.opponent2.result === "win"; + + if (matchIsOver) return false; + + const teamMemberOf = this.teamMemberOfByUser(user)?.id; + + const isParticipant = + match.opponent1.id === teamMemberOf || + match.opponent2.id === teamMemberOf; + + return isParticipant || this.isOrganizer(user); + } + + checkInConditionsFulfilled({ + tournamentTeamId, + mapPool, + }: { + tournamentTeamId: number; + mapPool: unknown[]; + }) { + const team = this.teamById(tournamentTeamId); + invariant(team, "Team not found"); + + if (!this.regularCheckInIsOpen && !this.regularCheckInHasEnded) { + return false; + } + + if (team.members.length < TOURNAMENT.TEAM_MIN_MEMBERS_FOR_FULL) { + return false; + } + + if (mapPool.length === 0) { + return false; + } + + return true; + } + + // TODO: get from settings + private isInvitational() { + return this.ctx.name.includes("Finale"); + } + + get subsFeatureEnabled() { + return !this.isInvitational(); + } + + get maxTeamMemberCount() { + const maxMembersBeforeStart = this.isInvitational() + ? 5 + : TOURNAMENT.DEFAULT_TEAM_MAX_MEMBERS_BEFORE_START; + + if (this.hasStarted) { + return maxMembersBeforeStart + 1; + } + + return maxMembersBeforeStart; + } + + get regularCheckInIsOpen() { + return ( + this.regularCheckInStartsAt < new Date() && + this.regularCheckInEndsAt > new Date() + ); + } + + get regularCheckInHasEnded() { + return this.ctx.startTime < new Date(); + } + + get regularCheckInStartInThePast() { + return this.regularCheckInStartsAt < new Date(); + } + + get regularCheckInStartsAt() { + const result = new Date(this.ctx.startTime); + result.setMinutes(result.getMinutes() - 60); + return result; + } + + get regularCheckInEndsAt() { + return this.ctx.startTime; + } + + bracketByIdxOrDefault(idx: number): Bracket { + const bracket = this.brackets[idx]; + if (bracket) return bracket; + + const defaultBracket = this.brackets[0]; + invariant(defaultBracket, "No brackets found"); + + logger.warn("Bracket not found, using fallback bracket"); + return defaultBracket; + } + + bracketByIdx(idx: number) { + const bracket = this.brackets[idx]; + if (!bracket) return null; + + return bracket; + } + + ownedTeamByUser( + user: OptionalIdObject, + ): ((typeof this.ctx.teams)[number] & { inviteCode: string }) | null { + if (!user) return null; + + return this.ctx.teams.find((team) => + team.members.some( + (member) => member.userId === user.id && member.isOwner, + ), + ) as (typeof this.ctx.teams)[number] & { inviteCode: string }; + } + + teamMemberOfByUser(user: OptionalIdObject) { + if (!user) return null; + + return this.ctx.teams.find((team) => + team.members.some((member) => member.userId === user.id), + ); + } + + // basic idea is that they can reopen match as long as they don't have a following match + // in progress whose participants could be dependent on the results of this match + matchCanBeReopened(matchId: number) { + if (this.ctx.isFinalized) return false; + + const allMatches = this.brackets.flatMap((bracket) => + // preview matches don't even have real id's and anyway don't prevent anything + bracket.preview ? [] : bracket.data.match, + ); + const match = allMatches.find((match) => match.id === matchId); + if (!match) { + logger.error("matchCanBeReopened: Match not found"); + return false; + } + + const bracketIdx = this.matchIdToBracketIdx(matchId); + + if (typeof bracketIdx !== "number") { + logger.error("matchCanBeReopened: Bracket not found"); + return false; + } + + const bracket = this.bracketByIdx(bracketIdx); + invariant(bracket, "Bracket not found"); + + const hasInProgressFollowUpBracket = this.brackets.some( + (b) => !b.preview && b.sources?.some((s) => s.bracketIdx === bracketIdx), + ); + + if (hasInProgressFollowUpBracket) return false; + + // round robin matches don't prevent reopening + if (bracket.type === "round_robin") return true; + + // BYE match + if (!match.opponent1 || !match.opponent2) return false; + + const anotherMatchBlocking = allMatches + .filter( + // only interested in matches of the same bracket & not the match being reopened itself + (match2) => + match2.stage_id === match.stage_id && match2.id !== match.id, + ) + .some((match2) => { + const hasAtLeastOneMapReported = + (match2.opponent1?.score && match2.opponent1.score > 0) || + (match2.opponent2?.score && match2.opponent2.score > 0); + + const hasSameParticipant = + match2.opponent1?.id === match.opponent1?.id || + match2.opponent1?.id === match.opponent2?.id || + match2.opponent2?.id === match.opponent1?.id || + match2.opponent2?.id === match.opponent2?.id; + + const isFollowingMatch = + match2.group_id > match.group_id || match2.round_id > match.round_id; + + return ( + hasAtLeastOneMapReported && isFollowingMatch && hasSameParticipant + ); + }); + + return !anotherMatchBlocking; + } + + isOrganizer(user: OptionalIdObject) { + if (!user) return false; + if (isAdmin(user)) return true; + + return this.ctx.staff.some( + (staff) => staff.id === user.id && staff.role === "ORGANIZER", + ); + } + + isOrganizerOrStreamer(user: OptionalIdObject) { + if (!user) return false; + if (isAdmin(user)) return true; + + return this.ctx.staff.some( + (staff) => + staff.id === user.id && ["ORGANIZER", "STREAMER"].includes(staff.role), + ); + } + + isAdmin(user: OptionalIdObject) { + if (!user) return false; + if (isAdmin(user)) return true; + + return this.ctx.author.id === user.id; + } +} diff --git a/app/features/tournament-bracket/core/bestOf.server.ts b/app/features/tournament-bracket/core/bestOf.server.ts index 1db8288b4..f0d500e80 100644 --- a/app/features/tournament-bracket/core/bestOf.server.ts +++ b/app/features/tournament-bracket/core/bestOf.server.ts @@ -1,10 +1,20 @@ import invariant from "tiny-invariant"; -import type { FindAllMatchesByTournamentIdMatch } from "../queries/findAllMatchesByTournamentId.server"; +import type { FindAllMatchesByStageIdItem } from "../queries/findAllMatchesByStageId.server"; +import type { TournamentStage } from "~/db/tables"; -// TODO: this only works for double elimination export function resolveBestOfs( - matches: Array, -) { + matches: Array, + type: TournamentStage["type"], +): [bestOf: 3 | 5 | 7, id: number][] { + if (type === "round_robin") { + return matches.map((match) => [3, match.matchId]); + } + if (type === "single_elimination") { + return matches.map((match) => [5, match.matchId]); + } + + // Double Elimination logic + // 3 is default const result: [bestOf: 5 | 7, id: number][] = []; diff --git a/app/features/tournament-bracket/core/bestOf.test.ts b/app/features/tournament-bracket/core/bestOf.test.ts index c0c963d12..0c83fd670 100644 --- a/app/features/tournament-bracket/core/bestOf.test.ts +++ b/app/features/tournament-bracket/core/bestOf.test.ts @@ -4,13 +4,13 @@ import { resolveBestOfs } from "./bestOf.server"; const ResolveBestOfs = suite("resolveBestOfs()"); -const count = (bestOfs: [bestOf: 5 | 7, id: number][], target: 5 | 7) => +const count = (bestOfs: [bestOf: 3 | 5 | 7, id: number][], target: 5 | 7) => bestOfs.reduce((acc, cur) => acc + (cur[0] === target ? 1 : 0), 0); ResolveBestOfs("2 teams", () => { const matches = [{ matchId: 1, roundNumber: 1, groupNumber: 1 }]; - const bestOfs = resolveBestOfs(matches); + const bestOfs = resolveBestOfs(matches, "double_elimination"); assert.equal(count(bestOfs, 5), 0); assert.equal(count(bestOfs, 7), 1); @@ -27,7 +27,7 @@ ResolveBestOfs("4 teams", () => { { matchId: 7, roundNumber: 2, groupNumber: 3 }, ]; - const bestOfs = resolveBestOfs(matches); + const bestOfs = resolveBestOfs(matches, "double_elimination"); assert.equal(count(bestOfs, 5), 3); }); @@ -51,7 +51,7 @@ ResolveBestOfs("8 teams", () => { { matchId: 15, roundNumber: 2, groupNumber: 3 }, ]; - const bestOfs = resolveBestOfs(matches); + const bestOfs = resolveBestOfs(matches, "double_elimination"); assert.equal(count(bestOfs, 5), 4); }); diff --git a/app/features/tournament-bracket/core/brackets-manager/crud-db.server.ts b/app/features/tournament-bracket/core/brackets-manager/crud-db.server.ts index 52624413f..6adce4ddc 100644 --- a/app/features/tournament-bracket/core/brackets-manager/crud-db.server.ts +++ b/app/features/tournament-bracket/core/brackets-manager/crud-db.server.ts @@ -342,9 +342,14 @@ const match_getByIdStm = sql.prepare(/*sql*/ ` `); const match_getByStageIdStm = sql.prepare(/*sql*/ ` - select * - from "TournamentMatch" - where "TournamentMatch"."stageId" = @stageId + select + "TournamentMatch".*, + sum("TournamentMatchGameResult"."opponentOnePoints") as "opponentOnePointsTotal", + sum("TournamentMatchGameResult"."opponentTwoPoints") as "opponentTwoPointsTotal" + from "TournamentMatch" + left join "TournamentMatchGameResult" on "TournamentMatch"."id" = "TournamentMatchGameResult"."matchId" + where "TournamentMatch"."stageId" = @stageId + group by "TournamentMatch"."id" `); const match_getByRoundAndNumberStm = sql.prepare(/*sql*/ ` @@ -414,14 +419,31 @@ export class Match { this.status = status; } - static #convertMatch(rawMatch: TournamentMatch): MatchType { + static #convertMatch( + rawMatch: TournamentMatch & { + opponentOnePointsTotal: number | null; + opponentTwoPointsTotal: number | null; + }, + ): MatchType { return { id: rawMatch.id, child_count: rawMatch.childCount, group_id: rawMatch.groupId, number: rawMatch.number, - opponent1: JSON.parse(rawMatch.opponentOne), - opponent2: JSON.parse(rawMatch.opponentTwo), + opponent1: + rawMatch.opponentOne === "null" + ? null + : { + ...JSON.parse(rawMatch.opponentOne), + totalPoints: rawMatch.opponentOnePointsTotal ?? undefined, + }, + opponent2: + rawMatch.opponentTwo === "null" + ? null + : { + ...JSON.parse(rawMatch.opponentTwo), + totalPoints: rawMatch.opponentTwoPointsTotal ?? undefined, + }, round_id: rawMatch.roundId, stage_id: rawMatch.stageId, status: rawMatch.status, diff --git a/app/features/tournament-bracket/core/summarizer.server.ts b/app/features/tournament-bracket/core/summarizer.server.ts index dda8f08fc..b68040bfb 100644 --- a/app/features/tournament-bracket/core/summarizer.server.ts +++ b/app/features/tournament-bracket/core/summarizer.server.ts @@ -8,7 +8,6 @@ import type { AllMatchResult } from "../queries/allMatchResultsByTournamentId.se import type { FindTeamsByTournamentIdItem } from "../../tournament/queries/findTeamsByTournamentId.server"; import invariant from "tiny-invariant"; import { removeDuplicates } from "~/utils/arrays"; -import type { FinalStanding } from "./finalStandings.server"; import type { Rating } from "openskill/dist/types"; import { rate, @@ -17,6 +16,7 @@ import { } from "~/features/mmr/mmr-utils"; import shuffle from "just-shuffle"; import type { Unpacked } from "~/utils/types"; +import type { Standing } from "./Bracket"; export interface TournamentSummary { skills: Omit< @@ -36,13 +36,6 @@ type TeamsArg = Array<{ Pick, "userId"> >; }>; -type FinalStandingsArg = Array<{ - placement: FinalStanding["placement"]; - tournamentTeam: { - id: FinalStanding["tournamentTeam"]["id"]; - }; - players: Array<{ id: number }>; -}>; export function tournamentSummary({ results, @@ -55,7 +48,7 @@ export function tournamentSummary({ }: { results: AllMatchResult[]; teams: TeamsArg; - finalStandings: FinalStandingsArg; + finalStandings: Standing[]; queryCurrentTeamRating: (identifier: string) => Rating; queryTeamPlayerRatingAverage: (identifier: string) => Rating; queryCurrentUserRating: (userId: number) => Rating; @@ -473,17 +466,17 @@ function tournamentResults({ finalStandings, }: { participantCount: number; - finalStandings: FinalStandingsArg; + finalStandings: Standing[]; }) { const result: TournamentSummary["tournamentResults"] = []; for (const standing of finalStandings) { - for (const player of standing.players) { + for (const player of standing.team.members) { result.push({ participantCount, placement: standing.placement, - tournamentTeamId: standing.tournamentTeam.id, - userId: player.id, + tournamentTeamId: standing.team.id, + userId: player.userId, }); } } diff --git a/app/features/tournament-bracket/core/summarizer.test.ts b/app/features/tournament-bracket/core/summarizer.test.ts index 3e94a096e..b5dbf0694 100644 --- a/app/features/tournament-bracket/core/summarizer.test.ts +++ b/app/features/tournament-bracket/core/summarizer.test.ts @@ -3,39 +3,50 @@ import * as assert from "uvu/assert"; import { tournamentSummary } from "./summarizer.server"; import { ordinal, rating } from "openskill"; import type { AllMatchResult } from "../queries/allMatchResultsByTournamentId.server"; +import type { TournamentDataTeam } from "./Tournament.server"; const TournamentSummary = suite("tournamentSummary()"); +const createTeam = (teamId: number, userIds: number[]): TournamentDataTeam => ({ + checkIns: [], + createdAt: 0, + id: teamId, + inviteCode: null, + mapPool: [], + members: userIds.map((userId) => ({ + country: null, + customUrl: null, + discordAvatar: null, + discordId: "123", + discordName: "test", + inGameName: "test", + isOwner: 0, + plusTier: null, + userId, + })), + name: "Team " + teamId, + prefersNotToHost: 0, + seed: 1, +}); + function summarize({ results }: { results?: AllMatchResult[] } = {}) { return tournamentSummary({ finalStandings: [ { placement: 1, - players: [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }], - tournamentTeam: { - id: 1, - }, + team: createTeam(1, [1, 2, 3, 4]), }, { placement: 2, - players: [{ id: 5 }, { id: 6 }, { id: 7 }, { id: 8 }], - tournamentTeam: { - id: 2, - }, + team: createTeam(2, [5, 6, 7, 8]), }, { placement: 3, - players: [{ id: 9 }, { id: 10 }, { id: 11 }, { id: 12 }], - tournamentTeam: { - id: 3, - }, + team: createTeam(3, [9, 10, 11, 12]), }, { placement: 4, - players: [{ id: 13 }, { id: 14 }, { id: 15 }, { id: 16 }], - tournamentTeam: { - id: 4, - }, + team: createTeam(4, [13, 14, 15, 16]), }, ], results: results ?? [ diff --git a/app/features/tournament-bracket/core/tests/mocks.ts b/app/features/tournament-bracket/core/tests/mocks.ts new file mode 100644 index 000000000..f95fb0b44 --- /dev/null +++ b/app/features/tournament-bracket/core/tests/mocks.ts @@ -0,0 +1,638 @@ +import type { DataTypes, ValueToArray } from "~/modules/brackets-manager/types"; + +export const FOUR_TEAMS_RR = (): ValueToArray => ({ + stage: [ + { + id: 0, + tournament_id: 1, + name: "Groups stage", + type: "round_robin", + number: 1, + settings: { + groupCount: 1, + roundRobinMode: "simple", + matchesChildCount: 0, + size: 4, + seedOrdering: ["groups.seed_optimized"], + }, + }, + ], + group: [ + { + id: 0, + stage_id: 0, + number: 1, + }, + ], + round: [ + { + id: 0, + number: 1, + stage_id: 0, + group_id: 0, + }, + { + id: 1, + number: 2, + stage_id: 0, + group_id: 0, + }, + { + id: 2, + number: 3, + stage_id: 0, + group_id: 0, + }, + ], + match: [ + { + id: 0, + number: 1, + stage_id: 0, + group_id: 0, + round_id: 0, + child_count: 0, + status: 2, + opponent1: { + id: 0, + position: 1, + }, + opponent2: { + id: 3, + position: 4, + }, + }, + { + id: 1, + number: 2, + stage_id: 0, + group_id: 0, + round_id: 0, + child_count: 0, + status: 2, + opponent1: { + id: 2, + position: 3, + }, + opponent2: { + id: 1, + position: 2, + }, + }, + { + id: 2, + number: 1, + stage_id: 0, + group_id: 0, + round_id: 1, + child_count: 0, + status: 2, + opponent1: { + id: 1, + position: 2, + }, + opponent2: { + id: 3, + position: 4, + }, + }, + { + id: 3, + number: 2, + stage_id: 0, + group_id: 0, + round_id: 1, + child_count: 0, + status: 2, + opponent1: { + id: 0, + position: 1, + }, + opponent2: { + id: 2, + position: 3, + }, + }, + { + id: 4, + number: 1, + stage_id: 0, + group_id: 0, + round_id: 2, + child_count: 0, + status: 2, + opponent1: { + id: 2, + position: 3, + }, + opponent2: { + id: 3, + position: 4, + }, + }, + { + id: 5, + number: 2, + stage_id: 0, + group_id: 0, + round_id: 2, + child_count: 0, + status: 2, + opponent1: { + id: 1, + position: 2, + }, + opponent2: { + id: 0, + position: 1, + }, + }, + ], + match_game: [], + participant: [ + { + id: 0, + tournament_id: 1, + name: "Team 1", + }, + { + id: 1, + tournament_id: 1, + name: "Team 2", + }, + { + id: 2, + tournament_id: 1, + name: "Team 3", + }, + { + id: 3, + tournament_id: 1, + name: "Team 4", + }, + ], +}); + +export const FIVE_TEAMS_RR = (): ValueToArray => ({ + stage: [ + { + id: 0, + tournament_id: 3, + name: "Groups stage", + type: "round_robin", + number: 1, + settings: { + groupCount: 1, + seedOrdering: ["groups.seed_optimized"], + roundRobinMode: "simple", + matchesChildCount: 0, + size: 5, + }, + }, + ], + group: [ + { + id: 0, + stage_id: 0, + number: 1, + }, + ], + round: [ + { + id: 0, + number: 1, + stage_id: 0, + group_id: 0, + }, + { + id: 1, + number: 2, + stage_id: 0, + group_id: 0, + }, + { + id: 2, + number: 3, + stage_id: 0, + group_id: 0, + }, + { + id: 3, + number: 4, + stage_id: 0, + group_id: 0, + }, + { + id: 4, + number: 5, + stage_id: 0, + group_id: 0, + }, + ], + match: [ + { + id: 0, + number: 1, + stage_id: 0, + group_id: 0, + round_id: 0, + child_count: 0, + status: 2, + opponent1: { + id: 4, + position: 5, + }, + opponent2: { + id: 1, + position: 2, + }, + }, + { + id: 2, + number: 1, + stage_id: 0, + group_id: 0, + round_id: 1, + child_count: 0, + status: 2, + opponent1: { + id: 0, + position: 1, + }, + opponent2: { + id: 2, + position: 3, + }, + }, + { + id: 3, + number: 2, + stage_id: 0, + group_id: 0, + round_id: 1, + child_count: 0, + status: 2, + opponent1: { + id: 4, + position: 5, + }, + opponent2: { + id: 3, + position: 4, + }, + }, + { + id: 4, + number: 1, + stage_id: 0, + group_id: 0, + round_id: 2, + child_count: 0, + status: 2, + opponent1: { + id: 1, + position: 2, + }, + opponent2: { + id: 3, + position: 4, + }, + }, + { + id: 5, + number: 2, + stage_id: 0, + group_id: 0, + round_id: 2, + child_count: 0, + status: 2, + opponent1: { + id: 0, + position: 1, + }, + opponent2: { + id: 4, + position: 5, + }, + }, + { + id: 6, + number: 1, + stage_id: 0, + group_id: 0, + round_id: 3, + child_count: 0, + status: 2, + opponent1: { + id: 2, + position: 3, + }, + opponent2: { + id: 4, + position: 5, + }, + }, + { + id: 7, + number: 2, + stage_id: 0, + group_id: 0, + round_id: 3, + child_count: 0, + status: 2, + opponent1: { + id: 1, + position: 2, + }, + opponent2: { + id: 0, + position: 1, + }, + }, + { + id: 8, + number: 1, + stage_id: 0, + group_id: 0, + round_id: 4, + child_count: 0, + status: 2, + opponent1: { + id: 3, + position: 4, + }, + opponent2: { + id: 0, + position: 1, + }, + }, + { + id: 9, + number: 2, + stage_id: 0, + group_id: 0, + round_id: 4, + child_count: 0, + status: 2, + opponent1: { + id: 2, + position: 3, + }, + opponent2: { + id: 1, + position: 2, + }, + }, + { + id: 1, + number: 2, + stage_id: 0, + group_id: 0, + round_id: 0, + child_count: 0, + status: 2, + opponent1: { + id: 3, + position: 4, + }, + opponent2: { + id: 2, + position: 3, + }, + }, + ], + match_game: [], + participant: [ + { + id: 0, + tournament_id: 3, + name: "Team 1", + }, + { + id: 1, + tournament_id: 3, + name: "Team 2", + }, + { + id: 2, + tournament_id: 3, + name: "Team 3", + }, + { + id: 3, + tournament_id: 3, + name: "Team 4", + }, + { + id: 4, + tournament_id: 3, + name: "Team 5", + }, + ], +}); + +export const SIX_TEAMS_TWO_GROUPS_RR = (): ValueToArray => ({ + stage: [ + { + id: 0, + tournament_id: 3, + name: "Groups stage", + type: "round_robin", + number: 1, + settings: { + groupCount: 2, + seedOrdering: ["groups.seed_optimized"], + roundRobinMode: "simple", + matchesChildCount: 0, + size: 6, + }, + }, + ], + group: [ + { + id: 0, + stage_id: 0, + number: 1, + }, + { + id: 1, + stage_id: 0, + number: 2, + }, + ], + round: [ + { + id: 0, + number: 1, + stage_id: 0, + group_id: 0, + }, + { + id: 1, + number: 2, + stage_id: 0, + group_id: 0, + }, + { + id: 2, + number: 3, + stage_id: 0, + group_id: 0, + }, + { + id: 3, + number: 1, + stage_id: 0, + group_id: 1, + }, + { + id: 4, + number: 2, + stage_id: 0, + group_id: 1, + }, + { + id: 5, + number: 3, + stage_id: 0, + group_id: 1, + }, + ], + match: [ + { + id: 0, + number: 1, + stage_id: 0, + group_id: 0, + round_id: 0, + child_count: 0, + status: 2, + opponent1: { + id: 4, + position: 5, + }, + opponent2: { + id: 3, + position: 4, + }, + }, + { + id: 1, + number: 1, + stage_id: 0, + group_id: 0, + round_id: 1, + child_count: 0, + status: 2, + opponent1: { + id: 0, + position: 1, + }, + opponent2: { + id: 4, + position: 5, + }, + }, + { + id: 2, + number: 1, + stage_id: 0, + group_id: 0, + round_id: 2, + child_count: 0, + status: 2, + opponent1: { + id: 3, + position: 4, + }, + opponent2: { + id: 0, + position: 1, + }, + }, + { + id: 3, + number: 1, + stage_id: 0, + group_id: 1, + round_id: 3, + child_count: 0, + status: 2, + opponent1: { + id: 5, + position: 6, + }, + opponent2: { + id: 2, + position: 3, + }, + }, + { + id: 4, + number: 1, + stage_id: 0, + group_id: 1, + round_id: 4, + child_count: 0, + status: 2, + opponent1: { + id: 1, + position: 2, + }, + opponent2: { + id: 5, + position: 6, + }, + }, + { + id: 5, + number: 1, + stage_id: 0, + group_id: 1, + round_id: 5, + child_count: 0, + status: 2, + opponent1: { + id: 2, + position: 3, + }, + opponent2: { + id: 1, + position: 2, + }, + }, + ], + match_game: [], + participant: [ + { + id: 0, + tournament_id: 3, + name: "Team 1", + }, + { + id: 1, + tournament_id: 3, + name: "Team 2", + }, + { + id: 2, + tournament_id: 3, + name: "Team 3", + }, + { + id: 3, + tournament_id: 3, + name: "Team 4", + }, + { + id: 4, + tournament_id: 3, + name: "Team 5", + }, + { + id: 5, + tournament_id: 3, + name: "Team 6", + }, + ], +}); diff --git a/app/features/tournament-bracket/core/tests/round-robin.test.ts b/app/features/tournament-bracket/core/tests/round-robin.test.ts new file mode 100644 index 000000000..8c022d238 --- /dev/null +++ b/app/features/tournament-bracket/core/tests/round-robin.test.ts @@ -0,0 +1,289 @@ +import { suite } from "uvu"; +import { FIVE_TEAMS_RR, FOUR_TEAMS_RR, SIX_TEAMS_TWO_GROUPS_RR } from "./mocks"; +import { adjustResults, testTournament } from "./test-utils"; +import * as assert from "uvu/assert"; +import { BRACKET_NAMES } from "~/features/tournament/tournament-constants"; +import type { TournamentData } from "../Tournament.server"; + +const RoundRobinStandings = suite("Round Robin Standings"); + +const roundRobinTournamentCtx: Partial = { + settings: { + bracketProgression: [{ name: BRACKET_NAMES.GROUPS, type: "round_robin" }], + }, + inProgressBrackets: [ + { id: 0, type: "round_robin", name: BRACKET_NAMES.GROUPS }, + ], +}; + +RoundRobinStandings("resolves standings from points", () => { + const tournament = testTournament( + adjustResults(FOUR_TEAMS_RR(), [ + { ids: [0, 3], score: [2, 0] }, + { ids: [2, 1], score: [0, 2] }, + { ids: [1, 3], score: [2, 0] }, + { ids: [0, 2], score: [2, 0] }, + { ids: [2, 3], score: [2, 0] }, + { ids: [1, 0], score: [0, 2] }, + ]), + roundRobinTournamentCtx, + ); + + const standings = tournament.bracketByIdx(0)!.standings; + + assert.equal(standings.length, 4); + assert.equal(standings[0].team.id, 0); + assert.equal(standings[0].placement, 1); + assert.equal(standings[1].team.id, 1); + assert.equal(standings[2].team.id, 2); + assert.equal(standings[3].team.id, 3); +}); + +RoundRobinStandings("tiebreaker via head-to-head", () => { + // id 0 = WWWL + // id 1 = WWWL + // id 2 = WWLL + // id 3 = WWLL but won against 2 + // id 4 = LLLL + const tournament = testTournament( + adjustResults(FIVE_TEAMS_RR(), [ + { + ids: [4, 1], + score: [0, 2], + }, + { + ids: [0, 2], + score: [2, 0], + }, + { + ids: [4, 3], + score: [0, 2], + }, + { + ids: [1, 3], + score: [2, 0], + }, + { + ids: [0, 4], + score: [2, 0], + }, + { + ids: [2, 4], + score: [2, 0], + }, + { + ids: [1, 0], + score: [2, 0], + }, + { + ids: [3, 0], + score: [0, 2], + }, + { + ids: [2, 1], + score: [2, 0], + }, + { + ids: [3, 2], + score: [2, 0], + }, + ]), + roundRobinTournamentCtx, + ); + + const standings = tournament.bracketByIdx(0)!.standings; + + assert.equal(standings.length, 5); + assert.equal(standings[2].team.id, 3); + assert.equal(standings[2].placement, 3); + assert.equal(standings[3].team.id, 2); + assert.equal(standings[3].placement, 4); +}); + +RoundRobinStandings("tiebreaker via maps won", () => { + // id 0 = WWWW + // id 1 = WWLL + // id 2 = WWLL + // id 3 = WWLL + const tournament = testTournament( + adjustResults(FOUR_TEAMS_RR(), [ + { ids: [0, 3], score: [2, 0] }, + { ids: [2, 1], score: [0, 2] }, + { ids: [1, 3], score: [0, 2] }, + { ids: [0, 2], score: [2, 0] }, + { ids: [2, 3], score: [2, 1] }, + { ids: [1, 0], score: [0, 2] }, + ]), + roundRobinTournamentCtx, + ); + + const standings = tournament.bracketByIdx(0)!.standings; + + // they won the most maps out of the 3 tied teams + assert.equal(standings[1].team.id, 3); +}); + +RoundRobinStandings("three way tiebreaker via points scored", () => { + // id 0 = LLL + // id 1 = WWL + // id 2 = WWL + // id 3 = WWL + const tournament = testTournament( + adjustResults(FOUR_TEAMS_RR(), [ + { ids: [0, 3], score: [0, 2], points: [0, 200] }, + { ids: [2, 1], score: [0, 2], points: [50, 100] }, + { ids: [1, 3], score: [0, 2], points: [0, 200] }, + { ids: [0, 2], score: [0, 2], points: [0, 200] }, + { ids: [2, 3], score: [2, 0], points: [150, 149] }, + { ids: [1, 0], score: [2, 0], points: [200, 0] }, + ]), + roundRobinTournamentCtx, + ); + + const standings = tournament.bracketByIdx(0)!.standings; + + assert.equal(standings[0].team.id, 3); + assert.equal(standings[1].team.id, 2); +}); + +RoundRobinStandings("if everything is tied, uses seeds as tiebreaker", () => { + // id 0 = LLL + // id 1 = WWL + // id 2 = WWL + // id 3 = WWL + const tournament = testTournament( + adjustResults(FOUR_TEAMS_RR(), [ + { ids: [0, 3], score: [0, 2], points: [0, 200] }, + { ids: [2, 1], score: [0, 2], points: [0, 200] }, + { ids: [1, 3], score: [0, 2], points: [0, 200] }, + { ids: [0, 2], score: [0, 2], points: [0, 200] }, + { ids: [2, 3], score: [2, 0], points: [200, 0] }, + { ids: [1, 0], score: [2, 0], points: [200, 0] }, + ]), + roundRobinTournamentCtx, + ); + + const standings = tournament.bracketByIdx(0)!.standings; + + assert.equal(standings[0].team.id, 1); + assert.equal(standings[1].team.id, 2); +}); + +RoundRobinStandings("if two groups finished, standings for both groups", () => { + const tournament = testTournament( + adjustResults(SIX_TEAMS_TWO_GROUPS_RR(), [ + { + ids: [4, 3], + score: [2, 0], + }, + { + ids: [0, 4], + score: [2, 0], + }, + { + ids: [3, 0], + score: [2, 0], + }, + { + ids: [5, 2], + score: [2, 0], + }, + { + ids: [1, 5], + score: [2, 0], + }, + { + ids: [2, 1], + score: [2, 0], + }, + ]), + roundRobinTournamentCtx, + ); + + const standings = tournament.bracketByIdx(0)!.standings; + + assert.equal(standings.length, 6); + assert.equal(standings.filter((s) => s.placement === 1).length, 2); +}); + +RoundRobinStandings( + "if one group finished and other ongoing, standings for just one group", + () => { + const tournament = testTournament( + adjustResults(SIX_TEAMS_TWO_GROUPS_RR(), [ + { + ids: [4, 3], + score: [2, 0], + }, + { + ids: [0, 4], + score: [2, 0], + }, + { + ids: [3, 0], + score: [2, 0], + }, + { + ids: [5, 2], + score: [2, 0], + }, + { + ids: [1, 5], + score: [2, 0], + }, + { + ids: [2, 1], + score: [0, 0], + }, + ]), + roundRobinTournamentCtx, + ); + + const standings = tournament.bracketByIdx(0)!.standings; + + assert.equal(standings.length, 3); + assert.equal(standings.filter((s) => s.placement === 1).length, 1); + }, +); + +RoundRobinStandings( + "teams with same placements are ordered by group id", + () => { + const base = SIX_TEAMS_TWO_GROUPS_RR(); + const tournament = testTournament( + adjustResults({ ...base, group: base.group.reverse() }, [ + { + ids: [4, 3], + score: [2, 0], + }, + { + ids: [0, 4], + score: [0, 2], + }, + { + ids: [3, 0], + score: [2, 0], + }, + { + ids: [5, 2], + score: [2, 0], + }, + { + ids: [1, 5], + score: [2, 0], + }, + { + ids: [2, 1], + score: [2, 0], + }, + ]), + roundRobinTournamentCtx, + ); + + const standings = tournament.bracketByIdx(0)!.standings; + + assert.equal(standings[0].team.id, 4); + }, +); + +RoundRobinStandings.run(); diff --git a/app/features/tournament-bracket/core/tests/test-utils.ts b/app/features/tournament-bracket/core/tests/test-utils.ts new file mode 100644 index 000000000..20b5e5db0 --- /dev/null +++ b/app/features/tournament-bracket/core/tests/test-utils.ts @@ -0,0 +1,126 @@ +import { BRACKET_NAMES } from "~/features/tournament/tournament-constants"; +import { Tournament } from "../Tournament"; +import type { TournamentData } from "../Tournament.server"; +import type { DataTypes, ValueToArray } from "~/modules/brackets-manager/types"; + +const tournamentCtxTeam = ( + teamId: number, + partial?: Partial, +): TournamentData["ctx"]["teams"][0] => { + return { + checkIns: [{ checkedInAt: 1705858841, bracketIdx: null }], + createdAt: 0, + id: teamId, + inviteCode: null, + mapPool: [], + members: [], + name: "Team " + teamId, + prefersNotToHost: 0, + seed: teamId + 1, + ...partial, + }; +}; + +const nTeams = (n: number, startingId: number) => { + const teams = []; + for (let i = 0; i < n; i++) { + teams.push(tournamentCtxTeam(i, tournamentCtxTeam(i + startingId))); + } + return teams; +}; + +export const testTournament = ( + data: ValueToArray, + partialCtx?: Partial, +) => { + return new Tournament({ + data, + ctx: { + eventId: 1, + id: 1, + description: null, + startTime: 1705858842, + isFinalized: 0, + name: "test", + showMapListGenerator: 0, + castTwitchAccounts: [], + staff: [], + tieBreakerMapPool: [], + participatedUsers: [], + mapPickingStyle: "AUTO_SZ", + settings: { + bracketProgression: [ + { name: BRACKET_NAMES.MAIN, type: "double_elimination" }, + ], + }, + inProgressBrackets: data.stage.map((stage) => ({ + id: stage.id, + name: stage.name, + type: stage.type, + })), + bestOfs: data.round.map((round) => ({ bestOf: 3, roundId: round.id })), + teams: nTeams( + data.participant.length, + Math.min(...data.participant.map((p) => p.id)), + ), + author: { + chatNameColor: null, + customUrl: null, + discordAvatar: null, + discordId: "123", + discordName: "test", + id: 1, + }, + ...partialCtx, + }, + }); +}; + +export const adjustResults = ( + data: ValueToArray, + adjustedArr: Array<{ + ids: [number, number]; + score: [number, number]; + points?: [number, number]; + }>, +): ValueToArray => { + return { + ...data, + match: data.match.map((match, idx) => { + const adjusted = adjustedArr[idx]; + if (!adjusted) throw new Error("No adjusted result for match " + idx); + + if (adjusted.ids[0] !== match.opponent1!.id) { + throw new Error("Adjusted match opponent1 id does not match"); + } + + if (adjusted.ids[1] !== match.opponent2!.id) { + throw new Error("Adjusted match opponent2 id does not match"); + } + + return { + ...match, + opponent1: { + ...match.opponent1!, + score: adjusted.score[0], + result: adjusted.score[0] > adjusted.score[1] ? "win" : "loss", + totalPoints: adjusted.points + ? adjusted.points[0] + : adjusted.score[0] > adjusted.score[1] + ? 100 + : 0, + }, + opponent2: { + ...match.opponent2!, + score: adjusted.score[1], + result: adjusted.score[1] > adjusted.score[0] ? "win" : "loss", + totalPoints: adjusted.points + ? adjusted.points[1] + : adjusted.score[1] > adjusted.score[0] + ? 100 + : 0, + }, + }; + }), + }; +}; diff --git a/app/features/tournament-bracket/core/tests/underground-bracket.ts b/app/features/tournament-bracket/core/tests/underground-bracket.ts new file mode 100644 index 000000000..980d78d6e --- /dev/null +++ b/app/features/tournament-bracket/core/tests/underground-bracket.ts @@ -0,0 +1 @@ +// TODO: tests about DE->SE underground bracket progression diff --git a/app/features/tournament-bracket/index.ts b/app/features/tournament-bracket/index.ts index eddaef34c..a7fbddb83 100644 --- a/app/features/tournament-bracket/index.ts +++ b/app/features/tournament-bracket/index.ts @@ -1,7 +1,4 @@ -export { - HACKY_resolvePoolCode, - everyMatchIsOver, -} from "./tournament-bracket-utils"; +export { everyMatchIsOver } from "./tournament-bracket-utils"; export { getTournamentManager } from "./core/brackets-manager"; export { finalStandingOfTeam } from "./core/finalStandings.server"; export { findMapPoolByTeamId } from "./queries/findMapPoolByTeamId.server"; diff --git a/app/features/tournament-bracket/queries/findAllMatchesByTournamentId.server.ts b/app/features/tournament-bracket/queries/findAllMatchesByStageId.server.ts similarity index 68% rename from app/features/tournament-bracket/queries/findAllMatchesByTournamentId.server.ts rename to app/features/tournament-bracket/queries/findAllMatchesByStageId.server.ts index cf2d3b032..096647321 100644 --- a/app/features/tournament-bracket/queries/findAllMatchesByTournamentId.server.ts +++ b/app/features/tournament-bracket/queries/findAllMatchesByStageId.server.ts @@ -9,15 +9,15 @@ const stm = sql.prepare(/* sql */ ` left join "TournamentRound" on "TournamentRound"."id" = "TournamentMatch"."roundId" left join "TournamentGroup" on "TournamentGroup"."id" = "TournamentMatch"."groupId" left join "TournamentStage" on "TournamentStage"."id" = "TournamentMatch"."stageId" - where "TournamentStage"."tournamentId" = @tournamentId + where "TournamentStage"."id" = @stageId `); -export interface FindAllMatchesByTournamentIdMatch { +export interface FindAllMatchesByStageIdItem { matchId: number; roundNumber: number; groupNumber: number; } -export function findAllMatchesByTournamentId(tournamentId: number) { - return stm.all({ tournamentId }) as Array; +export function findAllMatchesByStageId(stageId: number) { + return stm.all({ stageId }) as Array; } diff --git a/app/features/tournament-bracket/queries/findResultsByMatchId.server.ts b/app/features/tournament-bracket/queries/findResultsByMatchId.server.ts index 73a598aa3..18978b807 100644 --- a/app/features/tournament-bracket/queries/findResultsByMatchId.server.ts +++ b/app/features/tournament-bracket/queries/findResultsByMatchId.server.ts @@ -1,4 +1,5 @@ import { sql } from "~/db/sql"; +import type { Tables } from "~/db/tables"; import type { TournamentMatchGameResult, User } from "~/db/types"; import type { TournamentMaplistSource } from "~/modules/tournament-map-list-generator"; import { parseDBArray } from "~/utils/sql"; @@ -11,6 +12,8 @@ const stm = sql.prepare(/* sql */ ` "TournamentMatchGameResult"."mode", "TournamentMatchGameResult"."source", "TournamentMatchGameResult"."createdAt", + "TournamentMatchGameResult"."opponentOnePoints", + "TournamentMatchGameResult"."opponentTwoPoints", json_group_array("TournamentMatchGameResultParticipant"."userId") as "participantIds" from "TournamentMatchGameResult" left join "TournamentMatchGameResultParticipant" @@ -28,6 +31,8 @@ interface FindResultsByMatchIdResult { participantIds: Array; source: TournamentMaplistSource; createdAt: TournamentMatchGameResult["createdAt"]; + opponentOnePoints: Tables["TournamentMatchGameResult"]["opponentOnePoints"]; + opponentTwoPoints: Tables["TournamentMatchGameResult"]["opponentTwoPoints"]; } export function findResultsByMatchId( diff --git a/app/features/tournament-bracket/queries/insertTournamentMatchGameResult.server.ts b/app/features/tournament-bracket/queries/insertTournamentMatchGameResult.server.ts index 2318f76e5..3d417051a 100644 --- a/app/features/tournament-bracket/queries/insertTournamentMatchGameResult.server.ts +++ b/app/features/tournament-bracket/queries/insertTournamentMatchGameResult.server.ts @@ -1,16 +1,17 @@ import { sql } from "~/db/sql"; +import type { Tables } from "~/db/tables"; import type { TournamentMatchGameResult } from "~/db/types"; const stm = sql.prepare(/* sql */ ` insert into "TournamentMatchGameResult" - ("matchId", "stageId", "mode", "winnerTeamId", "reporterId", "number", "source") + ("matchId", "stageId", "mode", "winnerTeamId", "reporterId", "number", "source", "opponentOnePoints", "opponentTwoPoints") values - (@matchId, @stageId, @mode, @winnerTeamId, @reporterId, @number, @source) + (@matchId, @stageId, @mode, @winnerTeamId, @reporterId, @number, @source, @opponentOnePoints, @opponentTwoPoints) returning * `); export function insertTournamentMatchGameResult( - args: Omit, + args: Omit, ) { return stm.get(args) as TournamentMatchGameResult; } diff --git a/app/features/tournament-bracket/routes/to.$id.brackets.tsx b/app/features/tournament-bracket/routes/to.$id.brackets.tsx index 74c8fb29b..aa855c5b0 100644 --- a/app/features/tournament-bracket/routes/to.$id.brackets.tsx +++ b/app/features/tournament-bracket/routes/to.$id.brackets.tsx @@ -1,27 +1,39 @@ -import type { - ActionFunction, - LinksFunction, - LoaderFunctionArgs, - SerializeFrom, -} from "@remix-run/node"; +import type { ActionFunction, LinksFunction } from "@remix-run/node"; import { Form, Link, - useLoaderData, + useFetcher, useNavigate, - useOutletContext, useRevalidator, } from "@remix-run/react"; +import clsx from "clsx"; import * as React from "react"; -import bracketViewerStyles from "../brackets-viewer.css"; -import bracketStyles from "../tournament-bracket.css"; -import { findTeamsByTournamentId } from "../../tournament/queries/findTeamsByTournamentId.server"; +import { useTranslation } from "react-i18next"; +import { useCopyToClipboard } from "react-use"; +import { useEventSource } from "remix-utils/sse/react"; +import invariant from "tiny-invariant"; import { Alert } from "~/components/Alert"; +import { Button } from "~/components/Button"; +import { Divider } from "~/components/Divider"; +import { FormWithConfirm } from "~/components/FormWithConfirm"; +import { Popover } from "~/components/Popover"; import { SubmitButton } from "~/components/SubmitButton"; -import { getTournamentManager } from "../core/brackets-manager"; -import hasTournamentStarted from "../../tournament/queries/hasTournamentStarted.server"; -import { findByIdentifier } from "../../tournament/queries/findByIdentifier.server"; -import { notFoundIfFalsy, parseRequestFormData, validate } from "~/utils/remix"; +import { sql } from "~/db/sql"; +import { Status } from "~/db/types"; +import { requireUser, useUser } from "~/features/auth/core"; +import { + currentSeason, + queryCurrentTeamRating, + queryCurrentUserRating, +} from "~/features/mmr"; +import { queryTeamPlayerRatingAverage } from "~/features/mmr/mmr-utils.server"; +import { TOURNAMENT, tournamentIdFromParams } from "~/features/tournament"; +import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; +import { HACKY_isInviteOnlyEvent } from "~/features/tournament/tournament-utils"; +import { useSearchParamState } from "~/hooks/useSearchParamState"; +import { useVisibilityChange } from "~/hooks/useVisibilityChange"; +import { parseRequestFormData, validate } from "~/utils/remix"; +import { assertUnreachable } from "~/utils/types"; import { SENDOU_INK_BASE_URL, tournamentBracketsSubscribePage, @@ -30,61 +42,28 @@ import { tournamentTeamPage, userPage, } from "~/utils/urls"; -import type { TournamentLoaderData } from "../../tournament/routes/to.$id"; +import { useTournament } from "../../tournament/routes/to.$id"; +import bracketViewerStyles from "../brackets-viewer.css"; +import { tournamentFromDB } from "../core/Tournament.server"; import { resolveBestOfs } from "../core/bestOf.server"; -import { findAllMatchesByTournamentId } from "../queries/findAllMatchesByTournamentId.server"; +import { getTournamentManager } from "../core/brackets-manager"; +import { tournamentSummary } from "../core/summarizer.server"; +import { addSummary } from "../queries/addSummary.server"; +import { allMatchResultsByTournamentId } from "../queries/allMatchResultsByTournamentId.server"; +import { findAllMatchesByStageId } from "../queries/findAllMatchesByStageId.server"; import { setBestOf } from "../queries/setBestOf.server"; -import { isTournamentOrganizer } from "~/permissions"; -import { requireUser, useUser } from "~/features/auth/core"; -import { - TOURNAMENT, - tournamentIdFromParams, - checkInHasStarted, - teamHasCheckedIn, -} from "~/features/tournament"; +import { bracketSchema } from "../tournament-bracket-schemas.server"; import { bracketSubscriptionKey, - everyMatchIsOver, fillWithNullTillPowerOfTwo, - resolveTournamentStageName, - resolveTournamentStageSettings, - resolveTournamentStageType, } from "../tournament-bracket-utils"; -import { sql } from "~/db/sql"; -import { useEventSource } from "remix-utils/sse/react"; -import { Status } from "~/db/types"; -import clsx from "clsx"; -import { Button, LinkButton } from "~/components/Button"; -import { useVisibilityChange } from "~/hooks/useVisibilityChange"; -import { bestOfsByTournamentId } from "../queries/bestOfsByTournamentId.server"; -import type { FinalStanding } from "../core/finalStandings.server"; -import { finalStandings } from "../core/finalStandings.server"; +import bracketStyles from "../tournament-bracket.css"; +import type { Standing } from "../core/Bracket"; +import { removeDuplicates } from "~/utils/arrays"; import { Placement } from "~/components/Placement"; import { Avatar } from "~/components/Avatar"; -import { Divider } from "~/components/Divider"; -import { removeDuplicates } from "~/utils/arrays"; import { Flag } from "~/components/Flag"; -import { databaseTimestampToDate } from "~/utils/dates"; -import { Popover } from "~/components/Popover"; -import { useCopyToClipboard } from "react-use"; -import { useTranslation } from "react-i18next"; -import { bracketSchema } from "../tournament-bracket-schemas.server"; -import { addSummary } from "../queries/addSummary.server"; -import { tournamentSummary } from "../core/summarizer.server"; -import invariant from "tiny-invariant"; -import { allMatchResultsByTournamentId } from "../queries/allMatchResultsByTournamentId.server"; -import { FormWithConfirm } from "~/components/FormWithConfirm"; -import { - currentSeason, - queryCurrentTeamRating, - queryCurrentUserRating, -} from "~/features/mmr"; -import { queryTeamPlayerRatingAverage } from "~/features/mmr/mmr-utils.server"; -import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; -import { - HACKY_isInviteOnlyEvent, - HACKY_maxRosterSizeBeforeStart, -} from "~/features/tournament/tournament-utils"; +import { BRACKET_NAMES } from "~/features/tournament/tournament-constants"; export const links: LinksFunction = () => { return [ @@ -106,92 +85,77 @@ export const links: LinksFunction = () => { export const action: ActionFunction = async ({ params, request }) => { const user = await requireUser(request); const tournamentId = tournamentIdFromParams(params); - const tournament = notFoundIfFalsy( - await TournamentRepository.findById(tournamentId), - ); + const tournament = await tournamentFromDB({ tournamentId, user }); const data = await parseRequestFormData({ request, schema: bracketSchema }); const manager = getTournamentManager("SQL"); - validate(isTournamentOrganizer({ user, tournament })); - switch (data._action) { - case "START_TOURNAMENT": { - const hasStarted = hasTournamentStarted(tournamentId); + case "START_BRACKET": { + validate(tournament.isOrganizer(user)); - validate(!hasStarted); + const bracket = tournament.bracketByIdx(data.bracketIdx); + invariant(bracket, "Bracket not found"); - let teams = findTeamsByTournamentId(tournamentId); - if (checkInHasStarted(tournament)) { - teams = teams.filter(teamHasCheckedIn); - } else { - // in the normal check in process this is handled - teams = teams.filter( - (team) => team.members.length >= TOURNAMENT.TEAM_MIN_MEMBERS_FOR_FULL, - ); - } - - validate(teams.length >= 2, "Not enough teams registered"); + validate(bracket.canBeStarted, "Bracket is not ready to be started"); sql.transaction(() => { - manager.create({ + const participants = bracket.data.participant.map((p) => p.name); + const stage = manager.create({ tournamentId, - name: resolveTournamentStageName(tournament.format), - type: resolveTournamentStageType(tournament.format), - seeding: fillWithNullTillPowerOfTwo(teams.map((team) => team.name)), - settings: resolveTournamentStageSettings(tournament.format), + name: bracket.name, + type: bracket.type, + seeding: + bracket.type === "round_robin" + ? participants + : fillWithNullTillPowerOfTwo(participants), + settings: tournament.bracketSettings( + bracket.type, + participants.length, + ), }); - const matches = findAllMatchesByTournamentId(tournamentId); + const matches = findAllMatchesByStageId(stage.id); // TODO: dynamic best of set when bracket is made - const bestOfs = HACKY_isInviteOnlyEvent(tournament) + const bestOfs = HACKY_isInviteOnlyEvent(tournament.ctx) ? matches.map((match) => [5, match.matchId] as [5, number]) - : resolveBestOfs(matches); + : resolveBestOfs(matches, bracket.type); for (const [bestOf, id] of bestOfs) { setBestOf({ bestOf, id }); } })(); - return null; - } - case "FINALIZE_TOURNAMENT": { - const bracket = manager.get.tournamentData(tournamentId); - invariant( - bracket.stage.length === 1, - "Bracket doesn't have exactly one stage", - ); - const stage = bracket.stage[0]; + // TODO: to transaction + // check in teams to the final stage ahead of time so they don't have to do it + // separately, but also allow for TO's to check them out if needed + if (data.bracketIdx === 0 && tournament.brackets.length > 1) { + const finalStageIdx = tournament.brackets.findIndex( + (b) => b.name === BRACKET_NAMES.FINALS, + ); - const _everyMatchIsOver = everyMatchIsOver(bracket); - validate(_everyMatchIsOver, "Not every match is over"); - - let teams = findTeamsByTournamentId(tournamentId); - if (checkInHasStarted(tournament)) { - teams = teams.filter(teamHasCheckedIn); + if (finalStageIdx !== -1) { + await TournamentRepository.checkInMany({ + bracketIdx: finalStageIdx, + tournamentTeamIds: tournament.ctx.teams.map((t) => t.id), + }); + } } - const _finalStandings = - finalStandings({ - manager, - tournamentId, - includeAll: true, - stageId: stage.id, - }) ?? []; - invariant( - _finalStandings.length === teams.length, - `Final standings length (${_finalStandings.length}) does not match teams length (${teams.length})`, - ); + break; + } + case "FINALIZE_TOURNAMENT": { + validate(tournament.canFinalize(user), "Can't finalize tournament"); + + const _finalStandings = tournament.standings; const results = allMatchResultsByTournamentId(tournamentId); invariant(results.length > 0, "No results found"); - const season = currentSeason( - databaseTimestampToDate(tournament.startTime), - )?.nth; + const season = currentSeason(tournament.ctx.startTime)?.nth; addSummary({ tournamentId, summary: tournamentSummary({ - teams, + teams: tournament.ctx.teams, finalStandings: _finalStandings, results, calculateSeasonalStats: typeof season === "number", @@ -208,67 +172,29 @@ export const action: ActionFunction = async ({ params, request }) => { season, }); - return null; + break; + } + case "BRACKET_CHECK_IN": { + const bracket = tournament.bracketByIdx(data.bracketIdx); + invariant(bracket, "Bracket not found"); + + const ownTeam = tournament.ownedTeamByUser(user); + invariant(ownTeam, "User doesn't have owned team"); + + validate(bracket.canCheckIn(user)); + + await TournamentRepository.checkIn({ + bracketIdx: data.bracketIdx, + tournamentTeamId: ownTeam.id, + }); + break; + } + default: { + assertUnreachable(data); } } -}; -export type TournamentBracketLoaderData = SerializeFrom; - -export const loader = ({ params }: LoaderFunctionArgs) => { - const tournamentId = tournamentIdFromParams(params); - - const hasStarted = hasTournamentStarted(tournamentId); - const manager = getTournamentManager(hasStarted ? "SQL" : "IN_MEMORY"); - - if (hasStarted) { - const bracket = manager.get.tournamentData(tournamentId); - invariant( - bracket.stage.length === 1, - "Bracket doesn't have exactly one stage", - ); - const stage = bracket.stage[0]; - - const _everyMatchIsOver = everyMatchIsOver(bracket); - return { - enoughTeams: true, - bracket, - roundBestOfs: bestOfsByTournamentId(tournamentId), - everyMatchIsOver: _everyMatchIsOver, - finalStandings: _everyMatchIsOver - ? finalStandings({ manager, tournamentId, stageId: stage.id }) - : null, - }; - } - - const tournament = notFoundIfFalsy(findByIdentifier(tournamentId)); - - let teams = findTeamsByTournamentId(tournamentId); - if (checkInHasStarted(tournament)) { - teams = teams.filter(teamHasCheckedIn); - } - - const enoughTeams = teams.length >= TOURNAMENT.ENOUGH_TEAMS_TO_START; - if (enoughTeams) { - manager.create({ - tournamentId, - name: resolveTournamentStageName(tournament.format), - type: resolveTournamentStageType(tournament.format), - seeding: fillWithNullTillPowerOfTwo(teams.map((team) => team.name)), - settings: resolveTournamentStageSettings(tournament.format), - }); - } - - // TODO: use get.stageData - const data = manager.get.tournamentData(tournamentId); - - return { - bracket: data, - enoughTeams, - everyMatchIsOver: false, - roundBestOfs: null, - finalStandings: null, - }; + return null; }; export default function TournamentBracketsPage() { @@ -276,17 +202,26 @@ export default function TournamentBracketsPage() { const visibility = useVisibilityChange(); const { revalidate } = useRevalidator(); const user = useUser(); - const data = useLoaderData(); + const [bracketIdx, setBracketIdx] = useSearchParamState({ + defaultValue: 0, + name: "idx", + revive: Number, + }); const ref = React.useRef(null); const navigate = useNavigate(); - const parentRouteData = useOutletContext(); + const tournament = useTournament(); + + const bracket = React.useMemo( + () => tournament.bracketByIdxOrDefault(bracketIdx), + [tournament, bracketIdx], + ); // TODO: bracket i18n React.useEffect(() => { - if (!data.enoughTeams) return; + if (!bracket.enoughTeams) return; // matches aren't generated before tournament starts - if (parentRouteData.hasStarted) { + if (!bracket.preview) { // @ts-expect-error - brackets-viewer is not typed window.bracketsViewer.onMatchClicked = (match) => { // can't view match page of a bye @@ -295,7 +230,7 @@ export default function TournamentBracketsPage() { } navigate( tournamentMatchPage({ - eventId: parentRouteData.tournament.id, + eventId: tournament.ctx.id, matchId: match.id, }), ); @@ -305,10 +240,10 @@ export default function TournamentBracketsPage() { // @ts-expect-error - brackets-viewer is not typed window.bracketsViewer.render( { - stages: data.bracket.stage, - matches: data.bracket.match, - matchGames: data.bracket.match_game, - participants: data.bracket.participant, + stages: bracket.data.stage, + matches: bracket.data.match, + matchGames: bracket.data.match_game, + participants: bracket.data.participant, }, { customRoundName: (info: any) => { @@ -327,8 +262,8 @@ export default function TournamentBracketsPage() { // my beautiful hack to show seeds // clean up probably not needed as it's not harmful to append more than one - const cssRulesToAppend = parentRouteData.teams.map((team, i) => { - const participantId = parentRouteData.hasStarted ? team.id : i; + const cssRulesToAppend = tournament.ctx.teams.map((team, i) => { + const participantId = tournament.hasStarted ? team.id : i; return /* css */ ` [data-participant-id="${participantId}"] { --seed: "${i + 1} "; @@ -336,15 +271,17 @@ export default function TournamentBracketsPage() { } `; }); - if (parentRouteData.teamMemberOfName) { + + const ownTeam = tournament.teamMemberOfByUser(user); + if (ownTeam) { cssRulesToAppend.push(/* css */ ` - [title="${parentRouteData.teamMemberOfName}"] { + [title="${ownTeam.name}"] { --team-text-color: var(--theme-secondary); } `); } - if (data.roundBestOfs) { - for (const { bestOf, roundId } of data.roundBestOfs) { + if (tournament.ctx.bestOfs) { + for (const { bestOf, roundId } of tournament.ctx.bestOfs) { cssRulesToAppend.push(/* css */ ` [data-round-id="${roundId}"] { --best-of-text: "Bo${bestOf}"; @@ -359,77 +296,70 @@ export default function TournamentBracketsPage() { if (!element) return; element.innerHTML = ""; + // @ts-expect-error - brackets-viewer is not typed + window.bracketsViewer!.onMatchClicked = () => {}; }; - }, [data, navigate, parentRouteData]); + }, [navigate, bracket, tournament, user]); React.useEffect(() => { - if (visibility !== "visible" || data.everyMatchIsOver) return; + if (visibility !== "visible" || tournament.everyBracketOver) return; revalidate(); - }, [visibility, revalidate, data.everyMatchIsOver]); + }, [visibility, revalidate, tournament.everyBracketOver]); - const myTeam = parentRouteData.teams.find((team) => - team.members.some((m) => m.userId === user?.id), - ); + const showAddSubsButton = + !tournament.canFinalize(user) && + !tournament.everyBracketOver && + tournament.ownedTeamByUser(user) && + tournament.hasStarted; - const adminCanStart = () => { - // for testing, is always possible to start in development - if (process.env.NODE_ENV === "development") return true; + const waitingForTeamsText = () => { + if (bracketIdx > 0 || tournament.regularCheckInStartInThePast) { + return t("tournament:bracket.waiting.checkin", { + count: TOURNAMENT.ENOUGH_TEAMS_TO_START, + }); + } - return ( - databaseTimestampToDate(parentRouteData.tournament.startTime).getTime() < - Date.now() - ); + return t("tournament:bracket.waiting", { + count: TOURNAMENT.ENOUGH_TEAMS_TO_START, + }); }; - const { progress, currentMatchId, currentOpponent } = (() => { - let lowestStatus: Status = Infinity; - let currentMatchId: number | undefined; - let currentOpponent: string | undefined; - - if (!myTeam) { - return { - progress: undefined, - currentMatchId: undefined, - currentOpponent: undefined, - }; + const teamsSourceText = () => { + if ( + tournament.brackets[0].type === "round_robin" && + !bracket.isUnderground + ) { + return `Teams that place in the top ${Math.max( + ...(bracket.sources ?? []).flatMap((s) => s.placements), + )} of their group will advance to this stage`; } - for (const match of data.bracket.match) { - // BYE - if (match.opponent1 === null || match.opponent2 === null) { - continue; - } - - if ( - (match.opponent1.id === myTeam.id || - match.opponent2.id === myTeam.id) && - lowestStatus > match.status - ) { - lowestStatus = match.status; - currentMatchId = match.id; - const otherTeam = - match.opponent1.id === myTeam.id ? match.opponent2 : match.opponent1; - currentOpponent = parentRouteData.teams.find( - (team) => team.id === otherTeam.id, - )?.name; - } + if ( + tournament.brackets[0].type === "round_robin" && + bracket.isUnderground + ) { + return "Teams that don't advance to the final stage can play in this bracket (optional)"; } - return { progress: lowestStatus, currentMatchId, currentOpponent }; - })(); + if ( + tournament.brackets[0].type === "double_elimination" && + bracket.isUnderground + ) { + return `Teams that get eliminated in the first ${Math.abs( + Math.min(...(bracket.sources ?? []).flatMap((s) => s.placements)), + )} rounds of the losers bracket can play in this bracket (optional)`; + } + + return null; + }; return (
- {visibility !== "hidden" && !data.everyMatchIsOver ? ( + {visibility !== "hidden" && !tournament.everyBracketOver ? ( ) : null} - {data.finalStandings && - !parentRouteData.hasFinalized && - isTournamentOrganizer({ - user, - tournament: parentRouteData.tournament, - }) ? ( + {tournament.canFinalize(user) ? (
) : null} - {!parentRouteData.hasStarted && data.enoughTeams ? ( - - {!isTournamentOrganizer({ - user, - tournament: parentRouteData.tournament, - }) ? ( + {bracket.preview && bracket.enoughTeams ? ( + + + {!tournament.isOrganizer(user) ? ( {t("tournament:bracket.finalize.text")}{" "} - {adminCanStart() ? ( + {bracket.canBeStarted ? ( {t("tournament:bracket.finalize.action")} @@ -479,40 +407,43 @@ export default function TournamentBracketsPage() { } triggerClassName="tiny outlined" > - {t("tournament:bracket.beforeStart")} + {bracketIdx === 0 + ? t("tournament:bracket.beforeStart") + : t("tournament:bracket.waitingForResults")} )} )} ) : null} - {parentRouteData.hasStarted && progress ? ( - +
+ {bracket.canCheckIn(user) ? ( + + ) : null} + {showAddSubsButton ? ( + // TODO: could also hide this when team is not in any bracket anymore + + ) : null} +
+ {tournament.ctx.isFinalized || tournament.canFinalize(user) ? ( + ) : null} - {!data.finalStandings && - myTeam && - parentRouteData.hasStarted && - parentRouteData.ownTeam && - progress && - progress < Status.Completed ? ( - - ) : null} - {data.finalStandings ? ( - - ) : null} -
- {!data.enoughTeams ? ( -
- {t("tournament:bracket.waiting", { - count: TOURNAMENT.ENOUGH_TEAMS_TO_START, - })} + +
+ {!bracket.enoughTeams ? ( +
+
+ {waitingForTeamsText()} +
+ {bracket.sources ? ( +
+ {teamsSourceText()} +
+ ) : null}
) : null}
@@ -537,11 +468,11 @@ function appendStyleTagToHead(content: string) { function useAutoRefresh() { const { revalidate } = useRevalidator(); - const parentRouteData = useOutletContext(); + const tournament = useTournament(); const lastEvent = useEventSource( - tournamentBracketsSubscribePage(parentRouteData.tournament.id), + tournamentBracketsSubscribePage(tournament.ctx.id), { - event: bracketSubscriptionKey(parentRouteData.tournament.id), + event: bracketSubscriptionKey(tournament.ctx.id), }, ); @@ -576,86 +507,38 @@ function useAutoRefresh() { }, [lastEvent, revalidate]); } -function TournamentProgressPrompt({ - progress, - currentMatchId, - currentOpponent, -}: { - progress: Status; - currentMatchId?: number; - currentOpponent?: string; -}) { - const { t } = useTranslation(["tournament"]); - const parentRouteData = useOutletContext(); - const data = useLoaderData(); - - if (data.finalStandings) return null; - - // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison - if (progress === Infinity) { - console.error("Unexpected no status"); - return null; - } - - if (progress === Status.Waiting) { - return ( - - - - ); - } - - if (progress >= Status.Completed) { - return ( - - {t("tournament:bracket.progress.thanksForPlaying", { - eventName: parentRouteData.tournament.name, - })} - - ); - } - - if (!currentMatchId || !currentOpponent) { - console.error("Unexpected no match id or opponent"); - return null; - } +function BracketCheckinButton({ bracketIdx }: { bracketIdx: number }) { + const fetcher = useFetcher(); return ( - - {t("tournament:bracket.progress.match", { opponent: currentOpponent })} - + + - {t("tournament:bracket.progress.match.action")} - - + Check-in & join the bracket + + ); } -function AddSubsPopOver({ - members, - inviteCode, -}: { - members: unknown[]; - inviteCode: string; -}) { - const parentRouteData = useOutletContext(); +function AddSubsPopOver() { const { t } = useTranslation(["common", "tournament"]); const [, copyToClipboard] = useCopyToClipboard(); + const tournament = useTournament(); + const user = useUser(); + + const ownedTeam = tournament.ownedTeamByUser(user); + invariant(ownedTeam, "User doesn't have owned team"); const subsAvailableToAdd = - HACKY_maxRosterSizeBeforeStart(parentRouteData.tournament) + - 1 - - members.length; + tournament.maxTeamMemberCount - ownedTeam.members.length; const inviteLink = `${SENDOU_INK_BASE_URL}${tournamentJoinPage({ - eventId: parentRouteData.tournament.id, - inviteCode, + eventId: tournament.ctx.id, + inviteCode: ownedTeam.inviteCode, })}`; return ( @@ -663,7 +546,6 @@ function AddSubsPopOver({ buttonChildren={<>{t("tournament:actions.addSub")}} triggerClassName="tiny outlined ml-auto" triggerTestId="add-sub-button" - containerClassName="mt-4" contentClassName="text-xs" > {t("tournament:actions.sub.prompt", { count: subsAvailableToAdd })} @@ -688,9 +570,9 @@ function AddSubsPopOver({ ); } -function FinalStandings({ standings }: { standings: FinalStanding[] }) { +function FinalStandings({ standings }: { standings: Standing[] }) { + const tournament = useTournament(); const { t } = useTranslation(["tournament"]); - const parentRouteData = useOutletContext(); const [viewAll, setViewAll] = React.useState(false); if (standings.length < 2) { @@ -701,6 +583,11 @@ function FinalStandings({ standings }: { standings: FinalStanding[] }) { // eslint-disable-next-line prefer-const let [first, second, third, ...rest] = standings; + if (third && third.placement === rest[0]?.placement) { + rest.unshift(third); + third = undefined as unknown as Standing; + } + const onlyTwoTeams = !third; const nonTopThreePlacements = viewAll @@ -714,7 +601,7 @@ function FinalStandings({ standings }: { standings: FinalStanding[] }) { return (
@@ -722,19 +609,19 @@ function FinalStandings({ standings }: { standings: FinalStanding[] }) {
- {standing.tournamentTeam.name} + {standing.team.name}
- {standing.players.map((player) => { + {standing.team.members.map((player) => { return ( @@ -743,9 +630,9 @@ function FinalStandings({ standings }: { standings: FinalStanding[] }) { })}
- {standing.players.map((player) => { + {standing.team.members.map((player) => { return ( -
+
{player.country ? ( ) : null} @@ -775,23 +662,23 @@ function FinalStandings({ standings }: { standings: FinalStanding[] }) { return (
- {standing.tournamentTeam.name} + {standing.team.name}
- {standing.players.map((player) => { + {standing.team.members.map((player) => { return ( @@ -800,9 +687,12 @@ function FinalStandings({ standings }: { standings: FinalStanding[] }) { })}
- {standing.players.map((player) => { + {standing.team.members.map((player) => { return ( -
+
{player.country ? ( ) : null} @@ -842,34 +732,42 @@ function FinalStandings({ standings }: { standings: FinalStanding[] }) { ); } -function TournamentProgressContainer({ - children, +function BracketNav({ + bracketIdx, + setBracketIdx, }: { - children: React.ReactNode; + bracketIdx: number; + setBracketIdx: (bracketIdx: number) => void; }) { + const tournament = useTournament(); + + if (tournament.ctx.settings.bracketProgression.length < 2) return null; + return ( -
-
{children}
-
- ); -} - -function WaitingForMatchText() { - const { t } = useTranslation(["tournament"]); - const [showDot, setShowDot] = React.useState(false); - - React.useEffect(() => { - const interval = setInterval(() => { - setShowDot((prev) => !prev); - }, 1000); - - return () => clearInterval(interval); - }); - - return ( -
- {t("tournament:bracket.progress.waiting")}.. - . +
+ {tournament.ctx.settings.bracketProgression.map((bracket, i) => { + // underground bracket was never played despite being in the format + if ( + tournament.bracketByIdxOrDefault(i).preview && + tournament.ctx.isFinalized + ) { + return null; + } + + return ( + + ); + })}
); } diff --git a/app/features/tournament-bracket/routes/to.$id.matches.$mid.tsx b/app/features/tournament-bracket/routes/to.$id.matches.$mid.tsx index 37425e081..8634f0f4b 100644 --- a/app/features/tournament-bracket/routes/to.$id.matches.$mid.tsx +++ b/app/features/tournament-bracket/routes/to.$id.matches.$mid.tsx @@ -3,12 +3,7 @@ import type { LinksFunction, LoaderFunctionArgs, } from "@remix-run/node"; -import { - Link, - useLoaderData, - useOutletContext, - useRevalidator, -} from "@remix-run/react"; +import { Link, useLoaderData, useRevalidator } from "@remix-run/react"; import clsx from "clsx"; import { nanoid } from "nanoid"; import * as React from "react"; @@ -18,19 +13,13 @@ import { Avatar } from "~/components/Avatar"; import { LinkButton } from "~/components/Button"; import { ArrowLongLeftIcon } from "~/components/icons/ArrowLongLeft"; import { sql } from "~/db/sql"; -import { - tournamentIdFromParams, - type TournamentLoaderData, -} from "~/features/tournament"; +import { requireUser, useUser } from "~/features/auth/core"; +import { tournamentIdFromParams } from "~/features/tournament"; +import { useTournament } from "~/features/tournament/routes/to.$id"; import { useSearchParamState } from "~/hooks/useSearchParamState"; import { useVisibilityChange } from "~/hooks/useVisibilityChange"; -import { requireUser, useUser } from "~/features/auth/core"; -import { getUserId } from "~/features/auth/core/user.server"; -import { - canReportTournamentScore, - isTournamentOrganizer, - isTournamentStreamerOrOrganizer, -} from "~/permissions"; +import { canReportTournamentScore } from "~/permissions"; +import { logger } from "~/utils/logger"; import { notFoundIfFalsy, parseRequestFormData, validate } from "~/utils/remix"; import { assertUnreachable } from "~/utils/types"; import { @@ -39,8 +28,8 @@ import { tournamentTeamPage, userPage, } from "~/utils/urls"; -import { findTeamsByTournamentId } from "../../tournament/queries/findTeamsByTournamentId.server"; import { ScoreReporter } from "../components/ScoreReporter"; +import { tournamentFromDB } from "../core/Tournament.server"; import { getTournamentManager } from "../core/brackets-manager"; import { emitter } from "../core/emitters.server"; import { resolveMapList } from "../core/mapList.server"; @@ -56,8 +45,6 @@ import { matchSubscriptionKey, } from "../tournament-bracket-utils"; import bracketStyles from "../tournament-bracket.css"; -import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; -import { logger } from "~/utils/logger"; export const links: LinksFunction = () => [ { @@ -76,9 +63,7 @@ export const action: ActionFunction = async ({ params, request }) => { }); const tournamentId = tournamentIdFromParams(params); - const tournament = notFoundIfFalsy( - await TournamentRepository.findById(tournamentId), - ); + const tournament = await tournamentFromDB({ tournamentId, user }); const validateCanReportScore = () => { const isMemberOfATeamInTheMatch = match.players.some( @@ -87,10 +72,9 @@ export const action: ActionFunction = async ({ params, request }) => { validate( canReportTournamentScore({ - tournament, match, isMemberOfATeamInTheMatch, - user, + isOrganizer: tournament.isOrganizer(user), }), "Unauthorized", 401, @@ -118,6 +102,7 @@ export const action: ActionFunction = async ({ params, request }) => { match.opponentTwo?.id === data.winnerTeamId, "Winner team id is invalid", ); + validate(match.opponentOne && match.opponentTwo, "Teams are missing"); const mapList = match.opponentOne?.id && match.opponentTwo?.id @@ -139,6 +124,15 @@ export const action: ActionFunction = async ({ params, request }) => { validate(false, "Winner team id is invalid"); }; + validate( + !data.points || + (scoreToIncrement() === 0 && data.points[0] > data.points[1]) || + (scoreToIncrement() === 1 && data.points[1] > data.points[0]), + "Points are invalid (winner must have more points than loser)", + ); + + // TODO: could also validate that if bracket demands it then points are defined + scores[scoreToIncrement()]++; sql.transaction(() => { @@ -164,6 +158,8 @@ export const action: ActionFunction = async ({ params, request }) => { winnerTeamId: data.winnerTeamId, number: data.position + 1, source: String(currentMap.source), + opponentOnePoints: data.points?.[0] ?? null, + opponentTwoPoints: data.points?.[1] ?? null, }); for (const userId of data.playerIds) { @@ -219,7 +215,6 @@ export const action: ActionFunction = async ({ params, request }) => { break; } - // TODO: bug where you can reopen losers finals after winners finals case "REOPEN_MATCH": { const scoreOne = match.opponentOne?.score ?? 0; const scoreTwo = match.opponentTwo?.score ?? 0; @@ -227,7 +222,11 @@ export const action: ActionFunction = async ({ params, request }) => { invariant(typeof scoreTwo === "number", "Score two is missing"); invariant(scoreOne !== scoreTwo, "Scores are equal"); - validate(isTournamentOrganizer({ tournament, user })); + validate(tournament.isOrganizer(user)); + validate( + tournament.matchCanBeReopened(match.id), + "Match can't be reopened, bracket has progressed", + ); const results = findResultsByMatchId(matchId); const lastResult = results[results.length - 1]; @@ -243,30 +242,20 @@ export const action: ActionFunction = async ({ params, request }) => { `Reopening match: User ID: ${user.id}; Match ID: ${match.id}`, ); - try { - sql.transaction(() => { - deleteTournamentMatchGameResultById(lastResult.id); - manager.update.match({ - id: match.id, - opponent1: { - score: scores[0], - result: undefined, - }, - opponent2: { - score: scores[1], - result: undefined, - }, - }); - })(); - } catch (err) { - if (!(err instanceof Error)) throw err; - - if (err.message.includes("locked")) { - return { error: "locked" }; - } - - throw err; - } + sql.transaction(() => { + deleteTournamentMatchGameResultById(lastResult.id); + manager.update.match({ + id: match.id, + opponent1: { + score: scores[0], + result: undefined, + }, + opponent2: { + score: scores[1], + result: undefined, + }, + }); + })(); break; } @@ -279,7 +268,7 @@ export const action: ActionFunction = async ({ params, request }) => { eventId: nanoid(), userId: user.id, }); - emitter.emit(bracketSubscriptionKey(tournament.id), { + emitter.emit(bracketSubscriptionKey(tournament.ctx.id), { matchId: match.id, scores, isOver: @@ -292,8 +281,7 @@ export const action: ActionFunction = async ({ params, request }) => { export type TournamentMatchLoaderData = typeof loader; -export const loader = async ({ params, request }: LoaderFunctionArgs) => { - const user = await getUserId(request); +export const loader = ({ params }: LoaderFunctionArgs) => { const tournamentId = tournamentIdFromParams(params); const matchId = matchIdFromParams(params); @@ -315,53 +303,23 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => { const currentMap = mapList?.[scoreSum]; - const showChat = - match.players.some((p) => p.id === user?.id) || - isTournamentStreamerOrOrganizer({ - user, - tournament: notFoundIfFalsy( - await TournamentRepository.findById(tournamentId), - ), - }); - const matchIsOver = match.opponentOne?.result === "win" || match.opponentTwo?.result === "win"; return { - match: { - ...match, - chatCode: showChat ? match.chatCode : null, - }, + match, results: findResultsByMatchId(matchId), - seeds: resolveSeeds(), currentMap, modes: mapList?.map((map) => map.mode), matchIsOver, }; - - function resolveSeeds() { - const tournamentId = tournamentIdFromParams(params); - const teams = findTeamsByTournamentId(tournamentId); - - const teamOneIndex = teams.findIndex( - (team) => team.id === match.opponentOne?.id, - ); - const teamTwoIndex = teams.findIndex( - (team) => team.id === match.opponentTwo?.id, - ); - - return [ - teamOneIndex !== -1 ? teamOneIndex + 1 : null, - teamTwoIndex !== -1 ? teamTwoIndex + 1 : null, - ]; - } }; export default function TournamentMatchPage() { const user = useUser(); const visibility = useVisibilityChange(); const { revalidate } = useRevalidator(); - const parentRouteData = useOutletContext(); + const tournament = useTournament(); const data = useLoaderData(); React.useEffect(() => { @@ -370,21 +328,9 @@ export default function TournamentMatchPage() { revalidate(); }, [visibility, revalidate, data.matchIsOver]); - const isMemberOfATeamInTheMatch = data.match.players.some( - (p) => p.id === user?.id, - ); - const type = - canReportTournamentScore({ - tournament: parentRouteData.tournament, - match: data.match, - isMemberOfATeamInTheMatch, - user, - }) || - isTournamentStreamerOrOrganizer({ - user, - tournament: parentRouteData.tournament, - }) + tournament.canReportScore({ matchId: data.match.id, user }) || + tournament.isOrganizerOrStreamer(user) ? "EDIT" : "OTHER"; @@ -401,9 +347,14 @@ export default function TournamentMatchPage() { {!data.matchIsOver && visibility !== "hidden" ? : null}
{/* TODO: better title */} -

Match #{data.match.id}

+

+ Match #{data.match.id} +

(); + const tournament = useTournament(); const data = useLoaderData(); const lastEventId = useEventSource( tournamentMatchSubscribePage({ - eventId: parentRouteData.tournament.id, + eventId: tournament.ctx.id, matchId: data.match.id, }), { @@ -466,10 +417,10 @@ function MapListSection({ type: "EDIT" | "OTHER"; }) { const data = useLoaderData(); - const parentRouteData = useOutletContext(); + const tournament = useTournament(); - const teamOne = parentRouteData.teams.find((team) => team.id === teams[0]); - const teamTwo = parentRouteData.teams.find((team) => team.id === teams[1]); + const teamOne = tournament.teamById(teams[0]); + const teamTwo = tournament.teamById(teams[1]); if (!teamOne || !teamTwo) return null; @@ -488,7 +439,7 @@ function MapListSection({ function ResultsSection() { const data = useLoaderData(); - const parentRouteData = useOutletContext(); + const tournament = useTournament(); const [selectedResultIndex, setSelectedResultIndex] = useSearchParamState({ defaultValue: data.results.length - 1, name: "result", @@ -504,12 +455,12 @@ function ResultsSection() { const result = data.results[selectedResultIndex]; invariant(result, "Result is missing"); - const teamOne = parentRouteData.teams.find( - (team) => team.id === data.match.opponentOne?.id, - ); - const teamTwo = parentRouteData.teams.find( - (team) => team.id === data.match.opponentTwo?.id, - ); + const teamOne = data.match.opponentOne?.id + ? tournament.teamById(data.match.opponentOne.id) + : undefined; + const teamTwo = data.match.opponentTwo?.id + ? tournament.teamById(data.match.opponentTwo.id) + : undefined; if (!teamOne || !teamTwo) { throw new Error("Team is missing"); @@ -534,10 +485,10 @@ function Rosters({ teams: [id: number | null | undefined, id: number | null | undefined]; }) { const data = useLoaderData(); - const parentRouteData = useOutletContext(); + const tournament = useTournament(); - const teamOne = parentRouteData.teams.find((team) => team.id === teams[0]); - const teamTwo = parentRouteData.teams.find((team) => team.id === teams[1]); + const teamOne = teams[0] ? tournament.teamById(teams[0]) : undefined; + const teamTwo = teams[1] ? tournament.teamById(teams[1]) : undefined; const teamOnePlayers = data.match.players.filter( (p) => p.tournamentTeamId === teamOne?.id, ); @@ -560,7 +511,7 @@ function Rosters({ {teamOne ? ( { + if (!val) return true; + const [p1, p2] = val; + + if (p1 === p2) return false; + if (p1 === 100 && p2 !== 0) return false; + if (p2 === 100 && p1 !== 0) return false; + + return true; + }, + { + message: + "Invalid points. Must not be equal & if one is 100, the other must be 0.", + }, + ), + ), }), z.object({ _action: _action("UNDO_REPORT_SCORE"), @@ -32,11 +55,18 @@ export const matchSchema = z.union([ }), ]); +export const bracketIdx = z.coerce.number().int().min(0).max(2); + export const bracketSchema = z.union([ z.object({ - _action: _action("START_TOURNAMENT"), + _action: _action("START_BRACKET"), + bracketIdx, }), z.object({ _action: _action("FINALIZE_TOURNAMENT"), }), + z.object({ + _action: _action("BRACKET_CHECK_IN"), + bracketIdx, + }), ]); diff --git a/app/features/tournament-bracket/tournament-bracket-utils.ts b/app/features/tournament-bracket/tournament-bracket-utils.ts index 0b67b2c4f..0f3802062 100644 --- a/app/features/tournament-bracket/tournament-bracket-utils.ts +++ b/app/features/tournament-bracket/tournament-bracket-utils.ts @@ -1,23 +1,14 @@ -import type { Stage } from "~/modules/brackets-model"; -import type { - TournamentFormat, - TournamentMatch, - TournamentStage, -} from "~/db/types"; -import { - sourceTypes, - seededRandom, -} from "~/modules/tournament-map-list-generator"; -import { assertUnreachable } from "~/utils/types"; -import type { FindMatchById } from "../tournament-bracket/queries/findMatchById.server"; -import type { - TournamentLoaderData, - TournamentLoaderTeam, -} from "~/features/tournament"; import type { Params } from "@remix-run/react"; import invariant from "tiny-invariant"; +import type { TournamentMatch } from "~/db/types"; import type { DataTypes, ValueToArray } from "~/modules/brackets-manager/types"; -import { HACKY_isInviteOnlyEvent } from "../tournament/tournament-utils"; +import { + seededRandom, + sourceTypes, +} from "~/modules/tournament-map-list-generator"; +import { removeDuplicates } from "~/utils/arrays"; +import type { FindMatchById } from "../tournament-bracket/queries/findMatchById.server"; +import type { TournamentDataTeam } from "./core/Tournament.server"; export function matchIdFromParams(params: Params) { const result = Number(params["mid"]); @@ -57,7 +48,7 @@ export function resolveRoomPass(matchId: TournamentMatch["id"]) { } export function resolveHostingTeam( - teams: [TournamentLoaderTeam, TournamentLoaderTeam], + teams: [TournamentDataTeam, TournamentDataTeam], ) { if (teams[0].prefersNotToHost && !teams[1].prefersNotToHost) return teams[1]; if (!teams[0].prefersNotToHost && teams[1].prefersNotToHost) return teams[0]; @@ -71,47 +62,6 @@ export function resolveHostingTeam( return teams[0]; } -export function resolveTournamentStageName(format: TournamentFormat) { - switch (format) { - case "SE": - case "DE": - return "Elimination stage"; - default: { - assertUnreachable(format); - } - } -} - -export function resolveTournamentStageType( - format: TournamentFormat, -): TournamentStage["type"] { - switch (format) { - case "SE": - return "single_elimination"; - case "DE": - return "double_elimination"; - default: { - assertUnreachable(format); - } - } -} - -export function resolveTournamentStageSettings( - format: TournamentFormat, -): Stage["settings"] { - switch (format) { - case "SE": - return {}; - case "DE": - return { - grandFinal: "double", - }; - default: { - assertUnreachable(format); - } - } -} - export function mapCountPlayedInSetWithCertainty({ bestOf, scores, @@ -142,23 +92,6 @@ export function checkSourceIsValid({ return false; } -export function HACKY_resolvePoolCode({ - event, - hostingTeamId, -}: { - event: TournamentLoaderData["tournament"]; - hostingTeamId: number; -}) { - const prefix = event.name.includes("In The Zone") - ? "ITZ" - : HACKY_isInviteOnlyEvent(event) - ? "SQ" - : "PN"; - const lastDigit = hostingTeamId % 10; - - return { prefix, lastDigit }; -} - export function bracketSubscriptionKey(tournamentId: number) { return `BRACKET_CHANGED_${tournamentId}`; } @@ -174,7 +107,13 @@ export function fillWithNullTillPowerOfTwo(arr: T[]) { return [...arr, ...new Array(nullsToAdd).fill(null)]; } -export function everyMatchIsOver(bracket: ValueToArray) { +export function everyMatchIsOver( + bracket: Pick, "match">, +) { + // winners, losers & grand finals+bracket reset are all different stages + const isDoubleElimination = + removeDuplicates(bracket.match.map((match) => match.group_id)).length === 3; + // tournament didn't start yet if (bracket.match.length === 0) return false; @@ -182,7 +121,7 @@ export function everyMatchIsOver(bracket: ValueToArray) { for (const [i, match] of bracket.match.entries()) { // special case - bracket reset might not be played depending on who wins in the grands const isLast = i === bracket.match.length - 1; - if (isLast && lastWinner === 1) { + if (isLast && lastWinner === 1 && isDoubleElimination) { continue; } // BYE @@ -201,3 +140,22 @@ export function everyMatchIsOver(bracket: ValueToArray) { return true; } + +export function everyBracketOver(tournament: ValueToArray) { + const stageIds = tournament.stage.map((stage) => stage.id); + + for (const stageId of stageIds) { + const matches = tournament.match.filter( + (match) => match.stage_id === stageId, + ); + + if (!everyMatchIsOver({ match: matches })) { + return false; + } + } + + return true; +} + +export const bracketHasStarted = (bracket: ValueToArray) => + bracket.stage[0] && bracket.stage[0].id !== 0; diff --git a/app/features/tournament-bracket/tournament-bracket.css b/app/features/tournament-bracket/tournament-bracket.css index d5c2a06d7..e93d06ca4 100644 --- a/app/features/tournament-bracket/tournament-bracket.css +++ b/app/features/tournament-bracket/tournament-bracket.css @@ -303,6 +303,12 @@ height: 8px; } +.tournament-bracket__points-input { + --input-width: 4.5rem; + padding: var(--s-3-5) var(--s-2) !important; + font-size: var(--fonts-sm); +} + .tournament-bracket__progress { display: flex; align-items: center; @@ -322,6 +328,7 @@ gap: var(--s-2); max-width: max-content; margin: 0 auto; + margin-bottom: var(--s-4); } [data-placement="1"] { order: -1; diff --git a/app/features/tournament-subs/routes/to.$id.subs.tsx b/app/features/tournament-subs/routes/to.$id.subs.tsx index 83ea5d492..8caddd3cf 100644 --- a/app/features/tournament-subs/routes/to.$id.subs.tsx +++ b/app/features/tournament-subs/routes/to.$id.subs.tsx @@ -1,37 +1,33 @@ -import { - type TournamentLoaderData, - tournamentIdFromParams, -} from "~/features/tournament"; -import { - type SubByTournamentId, - findSubsByTournamentId, -} from "../queries/findSubsByTournamentId.server"; import { redirect, type ActionFunction, type LinksFunction, type LoaderFunctionArgs, } from "@remix-run/node"; -import { Link, useLoaderData, useOutletContext } from "@remix-run/react"; -import { getUser, requireUser, useUser } from "~/features/auth/core"; -import { assertUnreachable } from "~/utils/types"; -import styles from "../tournament-subs.css"; -import { Avatar } from "~/components/Avatar"; -import { discordFullName } from "~/utils/strings"; -import { tournamentRegisterPage, userPage } from "~/utils/urls"; -import { WeaponImage } from "~/components/Image"; -import { Flag } from "~/components/Flag"; -import { MicrophoneIcon } from "~/components/icons/Microphone"; -import { Button, LinkButton } from "~/components/Button"; -import { deleteSub } from "../queries/deleteSub.server"; -import { FormWithConfirm } from "~/components/FormWithConfirm"; -import { TrashIcon } from "~/components/icons/Trash"; -import { useTranslation } from "react-i18next"; +import { Link, useLoaderData } from "@remix-run/react"; import React from "react"; +import { useTranslation } from "react-i18next"; +import { Avatar } from "~/components/Avatar"; +import { Button, LinkButton } from "~/components/Button"; +import { Flag } from "~/components/Flag"; +import { FormWithConfirm } from "~/components/FormWithConfirm"; +import { WeaponImage } from "~/components/Image"; import { Redirect } from "~/components/Redirect"; -import { notFoundIfFalsy } from "~/utils/remix"; -import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; -import { HACKY_subsFeatureEnabled } from "~/features/tournament/tournament-utils"; +import { MicrophoneIcon } from "~/components/icons/Microphone"; +import { TrashIcon } from "~/components/icons/Trash"; +import { getUser, requireUser, useUser } from "~/features/auth/core"; +import { tournamentIdFromParams } from "~/features/tournament"; +import { useTournament } from "~/features/tournament/routes/to.$id"; +import { discordFullName } from "~/utils/strings"; +import { assertUnreachable } from "~/utils/types"; +import { tournamentRegisterPage, userPage } from "~/utils/urls"; +import { deleteSub } from "../queries/deleteSub.server"; +import { + findSubsByTournamentId, + type SubByTournamentId, +} from "../queries/findSubsByTournamentId.server"; +import styles from "../tournament-subs.css"; +import { tournamentFromDB } from "~/features/tournament-bracket/core/Tournament.server"; export const links: LinksFunction = () => { return [ @@ -58,10 +54,8 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => { const user = await getUser(request); const tournamentId = tournamentIdFromParams(params); - const tournament = notFoundIfFalsy( - await TournamentRepository.findById(tournamentId), - ); - if (!HACKY_subsFeatureEnabled(tournament)) { + const tournament = await tournamentFromDB({ tournamentId, user }); + if (!tournament.subsFeatureEnabled) { throw redirect(tournamentRegisterPage(tournamentId)); } @@ -100,17 +94,15 @@ export default function TournamentSubsPage() { const user = useUser(); const { t } = useTranslation(["tournament"]); const data = useLoaderData(); - const parentRouteData = useOutletContext(); + const tournament = useTournament(); - if (parentRouteData.hasFinalized) { - return ( - - ); + if (tournament.everyBracketOver) { + return ; } return (
- {!parentRouteData.teamMemberOfName && user ? ( + {!tournament.teamMemberOfByUser(user) && user ? (
{data.hasOwnSubPost diff --git a/app/features/tournament/TournamentRepository.server.ts b/app/features/tournament/TournamentRepository.server.ts index 7aab477d7..564f71231 100644 --- a/app/features/tournament/TournamentRepository.server.ts +++ b/app/features/tournament/TournamentRepository.server.ts @@ -2,12 +2,11 @@ import type { NotNull } from "kysely"; import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite"; import { db } from "~/db/sql"; import type { Tables } from "~/db/tables"; +import { dateToDatabaseTimestamp } from "~/utils/dates"; import { COMMON_USER_FIELDS, userChatNameColor } from "~/utils/kysely.server"; -import type { Unwrapped } from "~/utils/types"; -export type FindById = NonNullable>; export async function findById(id: number) { - const row = await db + const result = await db .selectFrom("Tournament") .innerJoin("CalendarEvent", "Tournament.id", "CalendarEvent.tournamentId") .innerJoin( @@ -15,33 +14,22 @@ export async function findById(id: number) { "CalendarEvent.id", "CalendarEventDate.eventId", ) - .select(({ eb }) => [ + .select(({ eb, exists, selectFrom }) => [ "Tournament.id", - "Tournament.mapPickingStyle", - "Tournament.format", + "CalendarEvent.id as eventId", + "Tournament.settings", "Tournament.showMapListGenerator", "Tournament.castTwitchAccounts", - "CalendarEvent.id as eventId", + "Tournament.mapPickingStyle", "CalendarEvent.name", "CalendarEvent.description", - "CalendarEvent.bracketUrl", "CalendarEventDate.startTime", jsonObjectFrom( eb .selectFrom("User") - .whereRef("CalendarEvent.authorId", "=", "User.id") - .select([...COMMON_USER_FIELDS, userChatNameColor]), + .select([...COMMON_USER_FIELDS, userChatNameColor]) + .whereRef("User.id", "=", "CalendarEvent.authorId"), ).as("author"), - jsonArrayFrom( - eb - .selectFrom("MapPoolMap") - .whereRef( - "MapPoolMap.tieBreakerCalendarEventId", - "=", - "CalendarEvent.id", - ) - .select(["MapPoolMap.stageId", "MapPoolMap.mode"]), - ).as("tieBreakerMapPool"), jsonArrayFrom( eb .selectFrom("TournamentStaff") @@ -53,14 +41,230 @@ export async function findById(id: number) { ]) .where("TournamentStaff.tournamentId", "=", id), ).as("staff"), + exists( + selectFrom("TournamentResult") + .where("TournamentResult.tournamentId", "=", id) + .select("TournamentResult.tournamentId"), + ).as("isFinalized"), + jsonArrayFrom( + eb + .selectFrom("TournamentStage") + .select([ + "TournamentStage.id", + "TournamentStage.name", + "TournamentStage.type", + ]) + .where("TournamentStage.tournamentId", "=", id) + .orderBy("TournamentStage.number asc"), + ).as("inProgressBrackets"), + jsonArrayFrom( + eb + .selectFrom("TournamentTeam") + .select(({ eb: innerEb }) => [ + "TournamentTeam.id", + "TournamentTeam.name", + "TournamentTeam.seed", + "TournamentTeam.prefersNotToHost", + "TournamentTeam.inviteCode", + "TournamentTeam.createdAt", + jsonArrayFrom( + innerEb + .selectFrom("TournamentTeamMember") + .innerJoin("User", "TournamentTeamMember.userId", "User.id") + .leftJoin("PlusTier", "User.id", "PlusTier.userId") + .select([ + "User.id as userId", + "User.discordName", + "User.discordId", + "User.discordAvatar", + "User.customUrl", + "User.inGameName", + "User.country", + "PlusTier.tier as plusTier", + "TournamentTeamMember.isOwner", + ]) + .whereRef( + "TournamentTeamMember.tournamentTeamId", + "=", + "TournamentTeam.id", + ) + .orderBy("TournamentTeamMember.createdAt asc"), + ).as("members"), + jsonArrayFrom( + innerEb + .selectFrom("TournamentTeamCheckIn") + .select([ + "TournamentTeamCheckIn.bracketIdx", + "TournamentTeamCheckIn.checkedInAt", + ]) + .whereRef( + "TournamentTeamCheckIn.tournamentTeamId", + "=", + "TournamentTeam.id", + ), + ).as("checkIns"), + jsonArrayFrom( + innerEb + .selectFrom("MapPoolMap") + .whereRef( + "MapPoolMap.tournamentTeamId", + "=", + "TournamentTeam.id", + ) + .select(["MapPoolMap.stageId", "MapPoolMap.mode"]), + ).as("mapPool"), + ]) + .where("TournamentTeam.tournamentId", "=", id) + .orderBy(["TournamentTeam.seed asc", "TournamentTeam.createdAt asc"]), + ).as("teams"), + jsonArrayFrom( + eb + .selectFrom("TournamentRound") + .innerJoin( + "TournamentMatch", + "TournamentMatch.roundId", + "TournamentRound.id", + ) + .innerJoin( + "TournamentStage", + "TournamentRound.stageId", + "TournamentStage.id", + ) + .select(["TournamentRound.id as roundId", "TournamentMatch.bestOf"]) + .groupBy("roundId") + .where("TournamentStage.tournamentId", "=", id), + ).as("bestOfs"), + jsonArrayFrom( + eb + .selectFrom("MapPoolMap") + .select(["MapPoolMap.stageId", "MapPoolMap.mode"]) + .whereRef( + "MapPoolMap.tieBreakerCalendarEventId", + "=", + "CalendarEvent.id", + ), + ).as("tieBreakerMapPool"), + jsonArrayFrom( + eb + .selectFrom("TournamentStage") + .innerJoin( + "TournamentMatch", + "TournamentMatch.stageId", + "TournamentStage.id", + ) + .innerJoin( + "TournamentMatchGameResult", + "TournamentMatch.id", + "TournamentMatchGameResult.matchId", + ) + .innerJoin( + "TournamentMatchGameResultParticipant", + "TournamentMatchGameResult.id", + "TournamentMatchGameResultParticipant.matchGameResultId", + ) + .select("TournamentMatchGameResultParticipant.userId") + .groupBy("TournamentMatchGameResultParticipant.userId") + .where("TournamentStage.tournamentId", "=", id), + ).as("participatedUsers"), ]) .where("Tournament.id", "=", id) .$narrowType<{ author: NotNull }>() .executeTakeFirst(); - if (!row) return null; + if (!result) return null; - return row; + return { + ...result, + participatedUsers: result.participatedUsers.map((user) => user.userId), + }; +} + +export async function findCastTwitchAccountsByTournamentId( + tournamentId: number, +) { + const result = await db + .selectFrom("Tournament") + .select("castTwitchAccounts") + .where("id", "=", tournamentId) + .executeTakeFirst(); + + if (!result) return null; + + return result.castTwitchAccounts; +} + +export function checkedInTournamentTeamsByBracket({ + tournamentId, + bracketIdx, +}: { + tournamentId: number; + bracketIdx: number; +}) { + return db + .selectFrom("TournamentTeamCheckIn") + .innerJoin( + "TournamentTeam", + "TournamentTeamCheckIn.tournamentTeamId", + "TournamentTeam.id", + ) + .select(["TournamentTeamCheckIn.tournamentTeamId"]) + .where("TournamentTeamCheckIn.bracketIdx", "=", bracketIdx) + .where("TournamentTeam.tournamentId", "=", tournamentId) + .execute(); +} + +export function checkIn({ + tournamentTeamId, + bracketIdx, +}: { + tournamentTeamId: number; + bracketIdx: number | null; +}) { + return db + .insertInto("TournamentTeamCheckIn") + .values({ + checkedInAt: dateToDatabaseTimestamp(new Date()), + tournamentTeamId, + bracketIdx, + }) + .execute(); +} + +export function checkOut({ + tournamentTeamId, + bracketIdx, +}: { + tournamentTeamId: number; + bracketIdx: number | null; +}) { + let query = db + .deleteFrom("TournamentTeamCheckIn") + .where("TournamentTeamCheckIn.tournamentTeamId", "=", tournamentTeamId); + + if (typeof bracketIdx === "number") { + query = query.where("TournamentTeamCheckIn.bracketIdx", "=", bracketIdx); + } + + return query.execute(); +} + +export function checkInMany({ + tournamentTeamIds, + bracketIdx, +}: { + tournamentTeamIds: number[]; + bracketIdx: number; +}) { + return db + .insertInto("TournamentTeamCheckIn") + .values( + tournamentTeamIds.map((tournamentTeamId) => ({ + checkedInAt: dateToDatabaseTimestamp(new Date()), + tournamentTeamId, + bracketIdx, + })), + ) + .execute(); } export function addStaff({ diff --git a/app/features/tournament/components/TeamWithRoster.tsx b/app/features/tournament/components/TeamWithRoster.tsx index 878775186..a78f38648 100644 --- a/app/features/tournament/components/TeamWithRoster.tsx +++ b/app/features/tournament/components/TeamWithRoster.tsx @@ -1,10 +1,10 @@ import { Link } from "@remix-run/react"; -import type { FindTeamsByTournamentIdItem } from "../queries/findTeamsByTournamentId.server"; -import { Avatar } from "~/components/Avatar"; -import { userPage } from "~/utils/urls"; -import { ModeImage, StageImage } from "~/components/Image"; import clsx from "clsx"; +import { Avatar } from "~/components/Avatar"; +import { ModeImage, StageImage } from "~/components/Image"; import type { MapPoolMap, User } from "~/db/types"; +import type { TournamentDataTeam } from "~/features/tournament-bracket/core/Tournament.server"; +import { userPage } from "~/utils/urls"; export function TeamWithRoster({ team, @@ -13,7 +13,7 @@ export function TeamWithRoster({ teamPageUrl, activePlayers, }: { - team: Pick; + team: TournamentDataTeam; mapPool?: Array> | null; seed?: number; teamPageUrl?: string; diff --git a/app/features/tournament/core/sets.server.ts b/app/features/tournament/core/sets.server.ts index baa136e25..d1d228b3e 100644 --- a/app/features/tournament/core/sets.server.ts +++ b/app/features/tournament/core/sets.server.ts @@ -4,19 +4,22 @@ import { type SetHistoryByTeamIdItem, setHistoryByTeamId, } from "../queries/setHistoryByTeamId.server"; -import { findRoundNumbersByTournamentId } from "../queries/findRoundNumbersByTournamentId.server"; +import { findRoundsByTournamentId } from "../queries/findRoundsByTournamentId.server"; import type { TournamentMaplistSource } from "~/modules/tournament-map-list-generator"; import { sourceTypes } from "~/modules/tournament-map-list-generator"; import invariant from "tiny-invariant"; +import type { Tables } from "~/db/tables"; +import { logger } from "~/utils/logger"; +import { BRACKET_NAMES } from "../tournament-constants"; export interface PlayedSet { tournamentMatchId: number; score: [teamBeingViewed: number, opponent: number]; round: { - type: "winners" | "losers" | "single_elim"; + type: "winners" | "losers" | "single_elim" | "round_robin"; round: number | "finals" | "grand_finals" | "bracket_reset"; }; - bracket: "main" | "underground"; + stageName: string; maps: Array<{ stageId: StageId; modeShort: ModeShort; @@ -84,9 +87,12 @@ export function tournamentTeamSets({ tournamentId: number; }): PlayedSet[] { const sets = setHistoryByTeamId(tournamentTeamId); - const allRoundNumbers = findRoundNumbersByTournamentId(tournamentId); + const allRounds = findRoundsByTournamentId(tournamentId); return sets.map((set) => { + const round = + allRounds.find((round) => round.stageId === set.stageId) ?? allRounds[0]; + const resolveRound = () => { if (set.groupNumber === 3) { if (set.roundNumber === 2) return "bracket_reset"; @@ -94,24 +100,35 @@ export function tournamentTeamSets({ return "grand_finals"; } - // TODO: also consider stageId const maxRoundNumberOfGroup = Math.max( - ...allRoundNumbers - .filter((round) => round.groupNumber === set.groupNumber) + ...allRounds + .filter( + (round) => + round.groupNumber === set.groupNumber && + round.stageId === set.stageId, + ) .map((round) => round.roundNumber), ); - if (set.roundNumber === maxRoundNumberOfGroup) return "finals"; + if ( + round.stageName !== BRACKET_NAMES.GROUPS && + set.roundNumber === maxRoundNumberOfGroup + ) { + return "finals"; + } return set.roundNumber; }; return { tournamentMatchId: set.tournamentMatchId, - bracket: "main", + stageName: round.stageName, round: { round: resolveRound(), - type: resolveRoundType({ groupNumber: set.groupNumber }), + type: resolveRoundType({ + groupNumber: set.groupNumber, + stageType: round.stageType, + }), }, maps: set.matches.map((match) => ({ stageId: match.stageId, @@ -161,8 +178,21 @@ function flipScoreIfNeeded(set: SetHistoryByTeamIdItem): [number, number] { return score; } -// TODO: this only works for DE -function resolveRoundType({ groupNumber }: { groupNumber: number }) { +function resolveRoundType({ + groupNumber, + stageType, +}: { + groupNumber: number; + stageType: Tables["TournamentStage"]["type"]; +}) { + if (stageType === "single_elimination") { + return "single_elim"; + } + + if (stageType === "round_robin") { + return "round_robin"; + } + if (groupNumber === 1 || groupNumber === 3) { return "winners"; } @@ -171,6 +201,8 @@ function resolveRoundType({ groupNumber }: { groupNumber: number }) { return "losers"; } - // TODO: resolve this correctly + logger.warn( + `resolveRoundType: groupNumber ${groupNumber} and stageType ${stageType} not handled`, + ); return "single_elim"; } diff --git a/app/features/tournament/index.ts b/app/features/tournament/index.ts index f99f6363d..ebc7254ac 100644 --- a/app/features/tournament/index.ts +++ b/app/features/tournament/index.ts @@ -1,11 +1,2 @@ export { TOURNAMENT } from "./tournament-constants"; -export type { - TournamentLoaderTeam, - TournamentLoaderData, -} from "./routes/to.$id"; -export { - tournamentIdFromParams, - modesIncluded, - checkInHasStarted, - teamHasCheckedIn, -} from "./tournament-utils"; +export { tournamentIdFromParams, modesIncluded } from "./tournament-utils"; diff --git a/app/features/tournament/queries/findByIdentifier.server.ts b/app/features/tournament/queries/findByIdentifier.server.ts index c1c889ff9..6686b3b25 100644 --- a/app/features/tournament/queries/findByIdentifier.server.ts +++ b/app/features/tournament/queries/findByIdentifier.server.ts @@ -1,4 +1,5 @@ import { sql } from "~/db/sql"; +import type { Tables } from "~/db/tables"; import type { CalendarEvent, CalendarEventDate, @@ -10,7 +11,7 @@ const stm = sql.prepare(/*sql*/ ` select "Tournament"."id", "Tournament"."mapPickingStyle", - "Tournament"."format", + "Tournament"."settings", "Tournament"."showMapListGenerator", "CalendarEvent"."id" as "eventId", "CalendarEvent"."name", @@ -33,12 +34,11 @@ type FindByIdentifierRow = (Pick< CalendarEvent, "bracketUrl" | "name" | "description" | "authorId" > & - Pick< - Tournament, - "id" | "format" | "mapPickingStyle" | "showMapListGenerator" - > & + Pick & Pick & - Pick) & { eventId: CalendarEvent["id"] }; + Pick) & { + eventId: CalendarEvent["id"]; +} & { settings: string }; export function findByIdentifier(identifier: string | number) { const rows = stm.all({ identifier }) as FindByIdentifierRow[]; @@ -50,6 +50,9 @@ export function findByIdentifier(identifier: string | number) { return { ...rest, + settings: JSON.parse( + tournament.settings, + ) as Tables["Tournament"]["settings"], author: { discordId, discordName, diff --git a/app/features/tournament/queries/findRoundNumbersByTournamentId.server.ts b/app/features/tournament/queries/findRoundsByTournamentId.server.ts similarity index 71% rename from app/features/tournament/queries/findRoundNumbersByTournamentId.server.ts rename to app/features/tournament/queries/findRoundsByTournamentId.server.ts index 7cbd344fe..637c85f1f 100644 --- a/app/features/tournament/queries/findRoundNumbersByTournamentId.server.ts +++ b/app/features/tournament/queries/findRoundsByTournamentId.server.ts @@ -1,8 +1,11 @@ import { sql } from "~/db/sql"; +import type { Tables } from "~/db/tables"; const stm = sql.prepare(/* sql */ ` select "TournamentStage"."id" as "stageId", + "TournamentStage"."name" as "stageName", + "TournamentStage"."type" as "stageType", "TournamentRound"."number" as "roundNumber", "TournamentGroup"."number" as "groupNumber" from "TournamentStage" @@ -12,9 +15,11 @@ const stm = sql.prepare(/* sql */ ` group by "TournamentStage"."id", "TournamentRound"."number", "TournamentGroup"."number" `); -export function findRoundNumbersByTournamentId(tournamentId: number) { +export function findRoundsByTournamentId(tournamentId: number) { return stm.all({ tournamentId }) as Array<{ stageId: number; + stageName: string; + stageType: Tables["TournamentStage"]["type"]; roundNumber: number; groupNumber: number; }>; diff --git a/app/features/tournament/queries/findTeamsByTournamentId.server.ts b/app/features/tournament/queries/findTeamsByTournamentId.server.ts index d60a4ce47..be5f78e0b 100644 --- a/app/features/tournament/queries/findTeamsByTournamentId.server.ts +++ b/app/features/tournament/queries/findTeamsByTournamentId.server.ts @@ -40,7 +40,7 @@ const stm = sql.prepare(/*sql*/ ` ) as "members" from "TournamentTeam" - left join "TournamentTeamCheckIn" on "TournamentTeamCheckIn"."tournamentTeamId" = "TournamentTeam"."id" + left join "TournamentTeamCheckIn" on "TournamentTeamCheckIn"."tournamentTeamId" = "TournamentTeam"."id" and "TournamentTeamCheckIn"."bracketIdx" is null left join "TournamentTeamMember" on "TournamentTeamMember"."tournamentTeamId" = "TournamentTeam"."id" left join "User" on "User"."id" = "TournamentTeamMember"."userId" left join "PlusTier" on "User"."id" = "PlusTier"."userId" diff --git a/app/features/tournament/queries/setHistoryByTeamId.server.ts b/app/features/tournament/queries/setHistoryByTeamId.server.ts index cda48f18b..01cda687b 100644 --- a/app/features/tournament/queries/setHistoryByTeamId.server.ts +++ b/app/features/tournament/queries/setHistoryByTeamId.server.ts @@ -13,6 +13,7 @@ const stm = sql.prepare(/* sql */ ` "otherTeam"."name" as "otherTeamName", "otherTeam"."id" as "otherTeamId", "round"."number" as "roundNumber", + "round"."stageId" as "stageId", "group"."number" as "groupNumber", json_group_array( json_object( @@ -84,6 +85,7 @@ export interface SetHistoryByTeamIdItem { otherTeamName: string; otherTeamId: number; roundNumber: number; + stageId: number; groupNumber: number; matches: { stageId: StageId; diff --git a/app/features/tournament/routes/to.$id.admin.tsx b/app/features/tournament/routes/to.$id.admin.tsx index fade4b0dc..c1648bd1d 100644 --- a/app/features/tournament/routes/to.$id.admin.tsx +++ b/app/features/tournament/routes/to.$id.admin.tsx @@ -1,53 +1,42 @@ import type { ActionFunction } from "@remix-run/node"; -import { useFetcher, useOutletContext, useSubmit } from "@remix-run/react"; +import { useFetcher, useSubmit } from "@remix-run/react"; import * as React from "react"; -import { Button, LinkButton } from "~/components/Button"; -import { Toggle } from "~/components/Toggle"; import { useTranslation } from "react-i18next"; -import { - isTournamentAdmin, - isAdmin, - isTournamentOrganizer, -} from "~/permissions"; -import { notFoundIfFalsy, parseRequestFormData, validate } from "~/utils/remix"; -import { discordFullName } from "~/utils/strings"; -import { findTeamsByTournamentId } from "../queries/findTeamsByTournamentId.server"; -import { updateShowMapListGenerator } from "../queries/updateShowMapListGenerator.server"; -import { requireUserId } from "~/features/auth/core/user.server"; -import { - HACKY_resolveCheckInTime, - tournamentIdFromParams, - validateCanCheckIn, -} from "../tournament-utils"; -import { SubmitButton } from "~/components/SubmitButton"; -import { adminActionSchema } from "../tournament-schemas.server"; -import { changeTeamOwner } from "../queries/changeTeamOwner.server"; import invariant from "tiny-invariant"; -import { assertUnreachable } from "~/utils/types"; -import { checkIn } from "../queries/checkIn.server"; -import { checkOut } from "../queries/checkOut.server"; -import hasTournamentStarted from "../queries/hasTournamentStarted.server"; -import type { TournamentLoaderData } from "./to.$id"; -import { joinTeam, leaveTeam } from "../queries/joinLeaveTeam.server"; -import { deleteTeam } from "../queries/deleteTeam.server"; +import { Avatar } from "~/components/Avatar"; +import { Button, LinkButton } from "~/components/Button"; +import { Divider } from "~/components/Divider"; +import { FormMessage } from "~/components/FormMessage"; +import { FormWithConfirm } from "~/components/FormWithConfirm"; +import { Label } from "~/components/Label"; +import { Redirect } from "~/components/Redirect"; +import { SubmitButton } from "~/components/SubmitButton"; +import { Toggle } from "~/components/Toggle"; +import { UserSearch } from "~/components/UserSearch"; +import { TrashIcon } from "~/components/icons/Trash"; import { useUser } from "~/features/auth/core"; +import { requireUserId } from "~/features/auth/core/user.server"; +import type { TournamentData } from "~/features/tournament-bracket/core/Tournament.server"; +import { tournamentFromDB } from "~/features/tournament-bracket/core/Tournament.server"; +import { isAdmin } from "~/permissions"; +import { databaseTimestampToDate } from "~/utils/dates"; +import { parseRequestFormData, validate } from "~/utils/remix"; +import { assertUnreachable } from "~/utils/types"; import { calendarEditPage, calendarEventPage, tournamentPage, } from "~/utils/urls"; -import { Redirect } from "~/components/Redirect"; -import { FormWithConfirm } from "~/components/FormWithConfirm"; -import { findMapPoolByTeamId } from "~/features/tournament-bracket"; -import { UserSearch } from "~/components/UserSearch"; import * as TournamentRepository from "../TournamentRepository.server"; +import { changeTeamOwner } from "../queries/changeTeamOwner.server"; import { createTeam } from "../queries/createTeam.server"; -import { Divider } from "~/components/Divider"; -import { Avatar } from "~/components/Avatar"; -import { TrashIcon } from "~/components/icons/Trash"; -import { FormMessage } from "~/components/FormMessage"; -import { Label } from "~/components/Label"; -import { databaseTimestampToDate } from "~/utils/dates"; +import { deleteTeam } from "../queries/deleteTeam.server"; +import { joinTeam, leaveTeam } from "../queries/joinLeaveTeam.server"; +import { updateShowMapListGenerator } from "../queries/updateShowMapListGenerator.server"; +import { adminActionSchema } from "../tournament-schemas.server"; +import { tournamentIdFromParams } from "../tournament-utils"; +import { useTournament } from "./to.$id"; +import { findMapPoolByTeamId } from "~/features/tournament-bracket"; export const action: ActionFunction = async ({ request, params }) => { const user = await requireUserId(request); @@ -57,25 +46,22 @@ export const action: ActionFunction = async ({ request, params }) => { }); const tournamentId = tournamentIdFromParams(params); - const tournament = notFoundIfFalsy( - await TournamentRepository.findById(tournamentId), - ); - const teams = findTeamsByTournamentId(tournament.id); + const tournament = await tournamentFromDB({ tournamentId, user }); const validateIsTournamentAdmin = () => - validate(isTournamentAdmin({ user, tournament }), "Unauthorized", 401); + validate(tournament.isAdmin(user), "Unauthorized", 401); const validateIsTournamentOrganizer = () => - validate(isTournamentOrganizer({ user, tournament }), "Unauthorized", 401); + validate(tournament.isOrganizer(user), "Unauthorized", 401); switch (data._action) { case "ADD_TEAM": { validateIsTournamentOrganizer(); validate( - teams.every((t) => t.name !== data.teamName), + tournament.ctx.teams.every((t) => t.name !== data.teamName), "Team name taken", ); validate( - teams.every((t) => t.members.every((m) => m.userId !== data.userId)), + !tournament.teamMemberOfByUser({ id: data.userId }), "User already on a team", ); @@ -91,14 +77,14 @@ export const action: ActionFunction = async ({ request, params }) => { case "UPDATE_SHOW_MAP_LIST_GENERATOR": { validateIsTournamentAdmin(); updateShowMapListGenerator({ - tournamentId: tournament.id, + tournamentId: tournament.ctx.id, showMapListGenerator: Number(data.show), }); break; } case "CHANGE_TEAM_OWNER": { validateIsTournamentOrganizer(); - const team = teams.find((t) => t.id === data.teamId); + const team = tournament.teamById(data.teamId); validate(team, "Invalid team id"); const oldCaptain = team.members.find((m) => m.isOwner); invariant(oldCaptain, "Team has no captain"); @@ -115,31 +101,53 @@ export const action: ActionFunction = async ({ request, params }) => { } case "CHECK_IN": { validateIsTournamentOrganizer(); - const team = teams.find((t) => t.id === data.teamId); + const team = tournament.teamById(data.teamId); validate(team, "Invalid team id"); - validateCanCheckIn({ - event: tournament, - team, - mapPool: findMapPoolByTeamId(team.id), - }); + validate( + data.bracketIdx !== 0 || + tournament.checkInConditionsFulfilled({ + tournamentTeamId: team.id, + mapPool: findMapPoolByTeamId(team.id), + }), + "Can't check-in", + ); - checkIn(team.id); + const bracket = tournament.bracketByIdx(data.bracketIdx); + invariant(bracket, "Invalid bracket idx"); + validate(bracket.preview, "Bracket has been started"); + + await TournamentRepository.checkIn({ + tournamentTeamId: data.teamId, + // 0 = regular check in + bracketIdx: data.bracketIdx === 0 ? null : data.bracketIdx, + }); break; } case "CHECK_OUT": { validateIsTournamentOrganizer(); - const team = teams.find((t) => t.id === data.teamId); + const team = tournament.teamById(data.teamId); validate(team, "Invalid team id"); - validate(!hasTournamentStarted(tournament.id), "Tournament has started"); + validate( + data.bracketIdx !== 0 || !tournament.hasStarted, + "Tournament has started", + ); - checkOut(team.id); + const bracket = tournament.bracketByIdx(data.bracketIdx); + invariant(bracket, "Invalid bracket idx"); + validate(bracket.preview, "Bracket has been started"); + + await TournamentRepository.checkOut({ + tournamentTeamId: data.teamId, + // 0 = regular check in + bracketIdx: data.bracketIdx === 0 ? null : data.bracketIdx, + }); break; } case "REMOVE_MEMBER": { validateIsTournamentOrganizer(); - const team = teams.find((t) => t.id === data.teamId); + const team = tournament.teamById(data.teamId); validate(team, "Invalid team id"); - validate(!team.checkedInAt, "Team is checked in"); + validate(team.checkIns.length === 0, "Team is checked in"); validate( !team.members.find((m) => m.userId === data.memberId)?.isOwner, @@ -156,16 +164,14 @@ export const action: ActionFunction = async ({ request, params }) => { // to add members from a checked in team case "ADD_MEMBER": { validateIsTournamentOrganizer(); - const team = teams.find((t) => t.id === data.teamId); + const team = tournament.teamById(data.teamId); validate(team, "Invalid team id"); - const previousTeam = teams.find((t) => - t.members.some((m) => m.userId === data.userId), - ); + const previousTeam = tournament.teamMemberOfByUser({ id: data.userId }); - if (hasTournamentStarted(tournament.id)) { + if (tournament.hasStarted) { validate( - !previousTeam || !previousTeam.checkedInAt, + !previousTeam || previousTeam.checkIns.length === 0, "User is already on a checked in team", ); } else { @@ -184,9 +190,9 @@ export const action: ActionFunction = async ({ request, params }) => { } case "DELETE_TEAM": { validateIsTournamentOrganizer(); - const team = teams.find((t) => t.id === data.teamId); + const team = tournament.teamById(data.teamId); validate(team, "Invalid team id"); - validate(!hasTournamentStarted(tournament.id), "Tournament has started"); + validate(!tournament.hasStarted, "Tournament has started"); deleteTeam(team.id); break; @@ -195,7 +201,7 @@ export const action: ActionFunction = async ({ request, params }) => { validateIsTournamentAdmin(); await TournamentRepository.addStaff({ role: data.role, - tournamentId: tournament.id, + tournamentId: tournament.ctx.id, userId: data.userId, }); break; @@ -203,7 +209,7 @@ export const action: ActionFunction = async ({ request, params }) => { case "REMOVE_STAFF": { validateIsTournamentAdmin(); await TournamentRepository.removeStaff({ - tournamentId: tournament.id, + tournamentId: tournament.ctx.id, userId: data.userId, }); break; @@ -211,7 +217,7 @@ export const action: ActionFunction = async ({ request, params }) => { case "UPDATE_CAST_TWITCH_ACCOUNTS": { validateIsTournamentOrganizer(); await TournamentRepository.updateCastTwitchAccounts({ - tournamentId: tournament.id, + tournamentId: tournament.ctx.id, castTwitchAccounts: data.castTwitchAccounts, }); break; @@ -224,54 +230,50 @@ export const action: ActionFunction = async ({ request, params }) => { return null; }; +// xxx: download participants of certain bracket (not checked in probably most relevant) // TODO: translations export default function TournamentAdminPage() { const { t } = useTranslation(["calendar"]); - const data = useOutletContext(); + const tournament = useTournament(); const user = useUser(); - if ( - !isTournamentOrganizer({ user, tournament: data.tournament }) || - data.hasFinalized - ) { - return ; + if (!tournament.isOrganizer(user) || tournament.everyBracketOver) { + return ; } return (
- {isTournamentAdmin({ user, tournament: data.tournament }) ? ( + {tournament.isAdmin(user) && !tournament.hasStarted ? (
Edit event info - {!data.hasStarted ? ( - + - - ) : null} + {t("calendar:actions.delete")} + +
) : null} Team actions - {isTournamentAdmin({ user, tournament: data.tournament }) ? ( + {tournament.isAdmin(user) ? ( <> Staff @@ -286,7 +288,12 @@ export default function TournamentAdminPage() { ); } -type Input = "TEAM_NAME" | "REGISTERED_TEAM" | "USER" | "ROSTER_MEMBER"; +type Input = + | "TEAM_NAME" + | "REGISTERED_TEAM" + | "USER" + | "ROSTER_MEMBER" + | "BRACKET"; const actions = [ { type: "ADD_TEAM", @@ -300,13 +307,13 @@ const actions = [ }, { type: "CHECK_IN", - inputs: ["REGISTERED_TEAM"] as Input[], - when: ["CHECK_IN_STARTED", "TOURNAMENT_BEFORE_START"], + inputs: ["REGISTERED_TEAM", "BRACKET"] as Input[], + when: ["CHECK_IN_STARTED"], }, { type: "CHECK_OUT", - inputs: ["REGISTERED_TEAM"] as Input[], - when: ["CHECK_IN_STARTED", "TOURNAMENT_BEFORE_START"], + inputs: ["REGISTERED_TEAM", "BRACKET"] as Input[], + when: ["CHECK_IN_STARTED"], }, { type: "ADD_MEMBER", @@ -328,29 +335,28 @@ const actions = [ function TeamActions() { const fetcher = useFetcher(); const { t } = useTranslation(["tournament"]); - const data = useOutletContext(); - const parentRouteData = useOutletContext(); - const [selectedTeamId, setSelectedTeamId] = React.useState(data.teams[0]?.id); + const tournament = useTournament(); + const [selectedTeamId, setSelectedTeamId] = React.useState( + tournament.ctx.teams[0]?.id, + ); const [selectedAction, setSelectedAction] = React.useState< (typeof actions)[number] >(actions[0]); - const selectedTeam = data.teams.find((team) => team.id === selectedTeamId); + const selectedTeam = tournament.teamById(selectedTeamId); const actionsToShow = actions.filter((action) => { for (const when of action.when) { switch (when) { case "CHECK_IN_STARTED": { - if ( - HACKY_resolveCheckInTime(data.tournament).getTime() > Date.now() - ) { + if (!tournament.regularCheckInStartInThePast) { return false; } break; } case "TOURNAMENT_BEFORE_START": { - if (parentRouteData.hasStarted) { + if (tournament.hasStarted) { return false; } break; @@ -395,7 +401,7 @@ function TeamActions() { value={selectedTeamId} onChange={(e) => setSelectedTeamId(Number(e.target.value))} > - {data.teams + {tournament.ctx.teams .slice() .sort((a, b) => a.name.localeCompare(b.name)) .map((team) => ( @@ -418,7 +424,7 @@ function TeamActions() { @@ -430,6 +436,18 @@ function TeamActions() {
) : null} + {selectedAction.inputs.includes("BRACKET") ? ( +
+ + +
+ ) : null} (); + const tournament = useTournament(); return (
{/* Key so inputs are cleared after staff is added */} - +
); @@ -458,7 +476,7 @@ function Staff() { function CastTwitchAccounts() { const id = React.useId(); const fetcher = useFetcher(); - const data = useOutletContext(); + const tournament = useTournament(); return ( @@ -469,7 +487,7 @@ function CastTwitchAccounts() { id={id} placeholder="dappleproductions" name="castTwitchAccounts" - defaultValue={data.tournament.castTwitchAccounts?.join(",")} + defaultValue={tournament.ctx.castTwitchAccounts?.join(",")} />
(); + const tournament = useTournament(); return ( @@ -504,8 +522,8 @@ function StaffAdder() { id="staff-user" userIdsToOmit={ new Set([ - data.tournament.author.id, - ...data.tournament.staff.map((s) => s.id), + tournament.ctx.author.id, + ...tournament.ctx.staff.map((s) => s.id), ]) } /> @@ -536,11 +554,11 @@ function StaffAdder() { function StaffList() { const { t } = useTranslation(["tournament"]); - const data = useOutletContext(); + const tournament = useTournament(); return (
- {data.tournament.staff.map((staff) => ( + {tournament.ctx.staff.map((staff) => (
(); + const tournament = useTournament(); const submit = useSubmit(); const [eventStarted, setEventStarted] = React.useState( - Boolean(data.tournament.showMapListGenerator), + Boolean(tournament.ctx.showMapListGenerator), ); function handleToggle(toggled: boolean) { setEventStarted(toggled); @@ -614,19 +632,17 @@ function EnableMapList() { } function DownloadParticipants() { - const data = useOutletContext(); + const tournament = useTournament(); function allParticipantsContent() { - return data.teams + return tournament.ctx.teams .slice() .sort((a, b) => a.name.localeCompare(b.name)) .map((team) => { const owner = team.members.find((user) => user.isOwner); invariant(owner); - return `${team.name} - ${discordFullName(owner)} - <@${ - owner.discordId - }>`; + return `${team.name} - ${owner.discordName} - <@${owner.discordId}>`; }) .join("\n"); } @@ -636,17 +652,15 @@ function DownloadParticipants() { return ( header + - data.teams + tournament.ctx.teams .slice() .sort((a, b) => a.createdAt - b.createdAt) - .filter((team) => team.checkedInAt) + .filter((team) => team.checkIns.length > 0) .map((team, i) => { return `${i + 1}) ${team.name} - ${databaseTimestampToDate( team.createdAt, ).toISOString()} - ${team.members - .map( - (member) => `${discordFullName(member)} - <@${member.discordId}>`, - ) + .map((member) => `${member.discordName} - <@${member.discordId}>`) .join(" / ")}`; }) .join("\n") @@ -654,25 +668,23 @@ function DownloadParticipants() { } function notCheckedInParticipantsContent() { - return data.teams + return tournament.ctx.teams .slice() .sort((a, b) => a.name.localeCompare(b.name)) - .filter((team) => !team.checkedInAt) + .filter((team) => team.checkIns.length === 0) .map((team) => { return `${team.name} - ${team.members - .map( - (member) => `${discordFullName(member)} - <@${member.discordId}>`, - ) + .map((member) => `${member.discordName} - <@${member.discordId}>`) .join(" / ")}`; }) .join("\n"); } function simpleListInSeededOrder() { - return data.teams + return tournament.ctx.teams .slice() .sort((a, b) => (a.seed ?? Infinity) - (b.seed ?? Infinity)) - .filter((team) => team.checkedInAt) + .filter((team) => team.checkIns.length > 0) .map((team) => team.name) .join("\n"); } diff --git a/app/features/tournament/routes/to.$id.index.tsx b/app/features/tournament/routes/to.$id.index.tsx index d4f2f96c8..10c455b0e 100644 --- a/app/features/tournament/routes/to.$id.index.tsx +++ b/app/features/tournament/routes/to.$id.index.tsx @@ -10,5 +10,5 @@ export const loader = ({ params }: LoaderFunctionArgs) => { throw redirect(tournamentRegisterPage(eventId)); } - throw redirect(tournamentBracketsPage(eventId)); + throw redirect(tournamentBracketsPage({ tournamentId: eventId })); }; diff --git a/app/features/tournament/routes/to.$id.join.tsx b/app/features/tournament/routes/to.$id.join.tsx index 4ecc35a5e..323352e81 100644 --- a/app/features/tournament/routes/to.$id.join.tsx +++ b/app/features/tournament/routes/to.$id.join.tsx @@ -1,6 +1,8 @@ import type { ActionFunction, LoaderFunctionArgs } from "@remix-run/node"; import { redirect } from "@remix-run/node"; -import { Form, useLoaderData, useOutletContext } from "@remix-run/react"; +import { Form, useLoaderData } from "@remix-run/react"; +import React from "react"; +import { useTranslation } from "react-i18next"; import invariant from "tiny-invariant"; import { SubmitButton } from "~/components/SubmitButton"; import { INVITE_CODE_LENGTH } from "~/constants"; @@ -9,22 +11,19 @@ import { requireUserId } from "~/features/auth/core/user.server"; import { notFoundIfFalsy, parseRequestFormData, validate } from "~/utils/remix"; import { assertUnreachable } from "~/utils/types"; import { tournamentPage } from "~/utils/urls"; +import { findByIdentifier } from "../queries/findByIdentifier.server"; import { findByInviteCode } from "../queries/findTeamByInviteCode.server"; import { findTeamsByTournamentId } from "../queries/findTeamsByTournamentId.server"; +import { giveTrust } from "../queries/giveTrust.server"; +import hasTournamentStarted from "../queries/hasTournamentStarted.server"; import { joinTeam } from "../queries/joinLeaveTeam.server"; import { TOURNAMENT } from "../tournament-constants"; -import type { TournamentLoaderData, TournamentLoaderTeam } from "./to.$id"; -import hasTournamentStarted from "../queries/hasTournamentStarted.server"; -import React from "react"; -import { discordFullName } from "~/utils/strings"; import { joinSchema } from "../tournament-schemas.server"; -import { giveTrust } from "../queries/giveTrust.server"; import { tournamentIdFromParams, tournamentTeamMaxSize, } from "../tournament-utils"; -import { useTranslation } from "react-i18next"; -import { findByIdentifier } from "../queries/findByIdentifier.server"; +import { useTournament } from "./to.$id"; export const action: ActionFunction = async ({ request, params }) => { const tournamentId = tournamentIdFromParams(params); @@ -113,15 +112,13 @@ export default function JoinTeamPage() { const { t } = useTranslation(["tournament", "common"]); const id = React.useId(); const user = useUser(); - const parentRouteData = useOutletContext(); + const tournament = useTournament(); const data = useLoaderData(); - const teamToJoin = parentRouteData.teams.find( - (team) => team.id === data.teamId, - ); + const teamToJoin = data.teamId ? tournament.teamById(data.teamId) : undefined; const captain = teamToJoin?.members.find((member) => member.isOwner); const validationStatus = validateCanJoin({ - tournament: parentRouteData.tournament, + tournament: tournament.ctx, inviteCode: data.inviteCode, teamToJoin, userId: user?.id, @@ -142,7 +139,7 @@ export default function JoinTeamPage() { return t("tournament:join.VALID", { teamName: teamToJoin.name, - eventName: parentRouteData.tournament.name, + eventName: tournament.ctx.name, }); } default: { @@ -160,7 +157,7 @@ export default function JoinTeamPage() { {" "}
@@ -181,7 +178,7 @@ function validateCanJoin({ tournament, }: { inviteCode?: string | null; - teamToJoin?: TournamentLoaderTeam; + teamToJoin?: { members: { userId: number }[] }; userId?: number; tournamentHasStarted: boolean; tournament: { name: string }; diff --git a/app/features/tournament/routes/to.$id.maps.tsx b/app/features/tournament/routes/to.$id.maps.tsx index d9872cf1c..4c8306d3d 100644 --- a/app/features/tournament/routes/to.$id.maps.tsx +++ b/app/features/tournament/routes/to.$id.maps.tsx @@ -1,39 +1,30 @@ import type { LinksFunction, LoaderFunctionArgs } from "@remix-run/node"; import { redirect } from "@remix-run/node"; -import { - useActionData, - useLoaderData, - useOutletContext, -} from "@remix-run/react"; +import { useActionData, useLoaderData } from "@remix-run/react"; import clsx from "clsx"; import * as React from "react"; +import { useTranslation } from "react-i18next"; import { Alert } from "~/components/Alert"; import type { MapPoolMap } from "~/db/types"; -import { useSearchParamState } from "~/hooks/useSearchParamState"; -import { useTranslation } from "react-i18next"; import { useUser } from "~/features/auth/core"; import { getUserId } from "~/features/auth/core/user.server"; -import { createTournamentMapList } from "~/modules/tournament-map-list-generator"; +import { MapPool } from "~/features/map-list-generator/core/map-pool"; +import { tournamentFromDB } from "~/features/tournament-bracket/core/Tournament.server"; +import { useSearchParamState } from "~/hooks/useSearchParamState"; import type { - TournamentMapListMap, BracketType, + TournamentMapListMap, TournamentMaplistInput, TournamentMaplistSource, } from "~/modules/tournament-map-list-generator"; -import { isTournamentAdmin } from "~/permissions"; +import { createTournamentMapList } from "~/modules/tournament-map-list-generator"; import mapsStyles from "~/styles/maps.css"; -import { notFoundIfFalsy, type SendouRouteHandle } from "~/utils/remix"; +import { type SendouRouteHandle } from "~/utils/remix"; import { tournamentPage } from "~/utils/urls"; import { findMapPoolsByTournamentId } from "../queries/findMapPoolsByTournamentId.server"; import { TOURNAMENT } from "../tournament-constants"; -import { - modesIncluded, - resolveOwnedTeam, - tournamentIdFromParams, -} from "../tournament-utils"; -import type { TournamentLoaderData } from "./to.$id"; -import * as TournamentRepository from "../TournamentRepository.server"; -import { MapPool } from "~/features/map-list-generator/core/map-pool"; +import { tournamentIdFromParams } from "../tournament-utils"; +import { useTournament } from "./to.$id"; export const links: LinksFunction = () => { return [{ rel: "stylesheet", href: mapsStyles }]; @@ -45,18 +36,16 @@ export const handle: SendouRouteHandle = { type TeamInState = { id: number; - mapPool?: Pick[]; + mapPool?: Pick[] | null; }; export const loader = async ({ params, request }: LoaderFunctionArgs) => { const tournamentId = tournamentIdFromParams(params); const user = await getUserId(request); - const tournament = notFoundIfFalsy( - await TournamentRepository.findById(tournamentId), - ); + const tournament = await tournamentFromDB({ tournamentId, user }); const mapListGeneratorAvailable = - isTournamentAdmin({ user, tournament }) || tournament.showMapListGenerator; + tournament.isAdmin(user) || tournament.ctx.showMapListGenerator; if (!mapListGeneratorAvailable) { throw redirect(tournamentPage(tournamentId)); @@ -72,7 +61,7 @@ export default function TournamentMapsPage() { const { t } = useTranslation(["tournament"]); const actionData = useActionData<{ failed?: boolean }>(); const data = useLoaderData(); - const parentRouteData = useOutletContext(); + const tournament = useTournament(); const [bestOf, setBestOf] = useSearchParamState< (typeof TOURNAMENT)["AVAILABLE_BEST_OF"][number] @@ -84,15 +73,14 @@ export default function TournamentMapsPage() { const [teamOneId, setTeamOneId] = useSearchParamState({ name: "team-one", defaultValue: - resolveOwnedTeam({ teams: parentRouteData.teams, userId: user?.id }) - ?.id ?? parentRouteData.teams[0]?.id, - revive: reviveTeam(parentRouteData.teams.map((t) => t.id)), + tournament.ownedTeamByUser(user)?.id ?? tournament.ctx.teams[0]?.id, + revive: reviveTeam(tournament.ctx.teams.map((t) => t.id)), }); const [teamTwoId, setTeamTwoId] = useSearchParamState({ name: "team-two", - defaultValue: parentRouteData.teams[1]?.id, + defaultValue: tournament.ctx.teams[1]?.id, revive: reviveTeam( - parentRouteData.teams.map((t) => t.id), + tournament.ctx.teams.map((t) => t.id), teamOneId, ), }); @@ -107,11 +95,11 @@ export default function TournamentMapsPage() { revive: reviveBracketType, }); - const teamOne = parentRouteData.teams.find((t) => t.id === teamOneId) ?? { + const teamOne = tournament.ctx.teams.find((t) => t.id === teamOneId) ?? { id: -1, mapPool: [], }; - const teamTwo = parentRouteData.teams.find((t) => t.id === teamTwoId) ?? { + const teamTwo = tournament.ctx.teams.find((t) => t.id === teamTwoId) ?? { id: -1, mapPool: [], }; @@ -160,7 +148,7 @@ export default function TournamentMapsPage() { ]} bestOf={bestOf} seed={`${bracketType}-${roundNumber}`} - modesIncluded={modesIncluded(parentRouteData.tournament)} + modesIncluded={tournament.modesIncluded} />
); @@ -249,7 +237,7 @@ function TeamsSelect({ setTeam: (newTeamId: number) => void; }) { const { t } = useTranslation(["tournament"]); - const data = useOutletContext(); + const tournament = useTournament(); return (
@@ -265,7 +253,7 @@ function TeamsSelect({ }} > - {data.teams + {tournament.ctx.teams .filter((t) => t.id !== otherTeam.id) .map((team) => (