diff --git a/app/db/seed/index.ts b/app/db/seed/index.ts index 4aba08ffe..02effe011 100644 --- a/app/db/seed/index.ts +++ b/app/db/seed/index.ts @@ -891,7 +891,14 @@ function calendarEventWithToTools( const settings: Tables["Tournament"]["settings"] = event === "DEPTHS" ? { - bracketProgression: [{ type: "swiss", name: "Swiss" }], + bracketProgression: [ + { + type: "swiss", + name: "Swiss", + requiresCheckIn: false, + settings: {}, + }, + ], enableNoScreenToggle: true, isRanked: false, swiss: { @@ -902,25 +909,38 @@ function calendarEventWithToTools( : event === "SOS" ? { bracketProgression: [ - { type: "round_robin", name: "Groups stage" }, + { + type: "round_robin", + name: "Groups stage", + requiresCheckIn: false, + settings: {}, + }, { type: "single_elimination", name: "Great White", + requiresCheckIn: false, + settings: {}, sources: [{ bracketIdx: 0, placements: [1] }], }, { type: "single_elimination", name: "Hammerhead", + requiresCheckIn: false, + settings: {}, sources: [{ bracketIdx: 0, placements: [2] }], }, { type: "single_elimination", name: "Mako", + requiresCheckIn: false, + settings: {}, sources: [{ bracketIdx: 0, placements: [3] }], }, { type: "single_elimination", name: "Lantern", + requiresCheckIn: false, + settings: {}, sources: [{ bracketIdx: 0, placements: [4] }], }, ], @@ -929,15 +949,24 @@ function calendarEventWithToTools( : event === "PP" ? { bracketProgression: [ - { type: "round_robin", name: "Groups stage" }, + { + type: "round_robin", + name: "Groups stage", + requiresCheckIn: false, + settings: {}, + }, { type: "single_elimination", name: "Final stage", + requiresCheckIn: false, + settings: {}, sources: [{ bracketIdx: 0, placements: [1, 2] }], }, { type: "single_elimination", name: "Underground bracket", + requiresCheckIn: true, + settings: {}, sources: [{ bracketIdx: 0, placements: [3, 4] }], }, ], @@ -945,17 +974,29 @@ function calendarEventWithToTools( : event === "ITZ" ? { bracketProgression: [ - { type: "double_elimination", name: "Main bracket" }, + { + type: "double_elimination", + name: "Main bracket", + requiresCheckIn: false, + settings: {}, + }, { type: "single_elimination", name: "Underground bracket", + requiresCheckIn: false, + settings: {}, sources: [{ bracketIdx: 0, placements: [-1, -2] }], }, ], } : { bracketProgression: [ - { type: "double_elimination", name: "Main bracket" }, + { + type: "double_elimination", + name: "Main bracket", + requiresCheckIn: false, + settings: {}, + }, ], }; diff --git a/app/db/tables.ts b/app/db/tables.ts index 8225fe78a..ac45ce585 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -7,6 +7,7 @@ import type { } from "kysely"; import type { TieredSkill } from "~/features/mmr/tiered.server"; import type { TEAM_MEMBER_ROLES } from "~/features/team"; +import type * as Progression from "~/features/tournament-bracket/core/Progression"; import type { ParticipantResult } from "~/modules/brackets-model"; import type { Ability, @@ -397,24 +398,13 @@ type TournamentMapPickingStyle = | "AUTO_RM" | "AUTO_CB"; -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; + bracketProgression: Progression.ParsedBracket[]; + /** @deprecated use bracketProgression instead */ teamsPerGroup?: number; + /** @deprecated use bracketProgression instead */ thirdPlaceMatch?: boolean; isRanked?: boolean; - autoCheckInAll?: boolean; enableNoScreenToggle?: boolean; deadlines?: "STRICT" | "DEFAULT"; requireInGameNames?: boolean; @@ -423,6 +413,7 @@ export interface TournamentSettings { autonomousSubs?: boolean; /** Timestamp (SQLite format) when reg closes, if missing then means closes at start time */ regClosesAt?: number; + /** @deprecated use bracketProgression instead */ swiss?: { groupCount: number; roundCount: number; @@ -562,6 +553,24 @@ export interface TournamentRound { maps: ColumnType; } +export interface TournamentStageSettings { + // SE + thirdPlaceMatch?: boolean; + // RR + teamsPerGroup?: number; + // SWISS + groupCount?: number; + // SWISS + roundCount?: number; +} + +export const TOURNAMENT_STAGE_TYPES = [ + "single_elimination", + "double_elimination", + "round_robin", + "swiss", +] as const; + /** A stage is an intermediate phase in a tournament. In essence a bracket. */ export interface TournamentStage { id: GeneratedAlways; @@ -569,7 +578,7 @@ export interface TournamentStage { number: number; settings: string; tournamentId: number; - type: "double_elimination" | "single_elimination" | "round_robin" | "swiss"; + type: (typeof TOURNAMENT_STAGE_TYPES)[number]; // not Generated<> because SQLite doesn't allow altering tables to add columns with default values :( createdAt: number | null; } @@ -616,6 +625,8 @@ export interface TournamentTeamCheckIn { /** Which bracket checked in for. If missing is check in for the whole event. */ bracketIdx: number | null; tournamentTeamId: number; + /** Indicates that this bracket defaults to checked in and this team has been explicitly checked out from it */ + isCheckOut: Generated; } export interface TournamentTeamMember { diff --git a/app/features/calendar/CalendarRepository.server.ts b/app/features/calendar/CalendarRepository.server.ts index 98837fc81..550cac760 100644 --- a/app/features/calendar/CalendarRepository.server.ts +++ b/app/features/calendar/CalendarRepository.server.ts @@ -5,6 +5,7 @@ import { db } from "~/db/sql"; 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 * as Progression from "~/features/tournament-bracket/core/Progression"; import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates"; import invariant from "~/utils/invariant"; import { sumArray } from "~/utils/number"; @@ -442,7 +443,6 @@ type CreateArgs = Pick< minMembersPerTeam?: number; teamsPerGroup?: number; thirdPlaceMatch?: boolean; - autoCheckInAll?: boolean; requireInGameNames?: boolean; isRanked?: boolean; isInvitational?: boolean; @@ -482,7 +482,6 @@ export async function create(args: CreateArgs) { enableNoScreenToggle: args.enableNoScreenToggle, autonomousSubs: args.autonomousSubs, regClosesAt: args.regClosesAt, - autoCheckInAll: args.autoCheckInAll, requireInGameNames: args.requireInGameNames, minMembersPerTeam: args.minMembersPerTeam, swiss: @@ -559,7 +558,7 @@ export async function create(args: CreateArgs) { : "calendarEventId", }); - return eventId; + return { eventId, tournamentId }; }); } @@ -632,7 +631,6 @@ export async function update(args: UpdateArgs) { enableNoScreenToggle: args.enableNoScreenToggle, autonomousSubs: args.autonomousSubs, regClosesAt: args.regClosesAt, - autoCheckInAll: args.autoCheckInAll, requireInGameNames: args.requireInGameNames, minMembersPerTeam: args.minMembersPerTeam, swiss: @@ -644,14 +642,25 @@ export async function update(args: UpdateArgs) { : undefined, }; + const existingBracketProgression = ( + await trx + .selectFrom("Tournament") + .select("settings") + .where("id", "=", tournamentId) + .executeTakeFirstOrThrow() + ).settings.bracketProgression; + const { mapPickingStyle: _mapPickingStyle } = await trx .updateTable("Tournament") .set({ settings: JSON.stringify(settings), rules: args.rules, - // when tournament is updated clear the preparedMaps just in case the format changed - // in the future though we might want to be smarter with this i.e. only clear if the format really did change - preparedMaps: null, + preparedMaps: Progression.changedBracketProgressionFormat( + existingBracketProgression, + args.bracketProgression, + ) + ? null + : undefined, }) .where("id", "=", tournamentId) .returning("mapPickingStyle") diff --git a/app/features/calendar/actions/calendar.new.server.ts b/app/features/calendar/actions/calendar.new.server.ts index 088cdf779..9f2303e60 100644 --- a/app/features/calendar/actions/calendar.new.server.ts +++ b/app/features/calendar/actions/calendar.new.server.ts @@ -1,19 +1,18 @@ import type { ActionFunction } from "@remix-run/node"; import { redirect } from "@remix-run/node"; import { z } from "zod"; +import { TOURNAMENT_STAGE_TYPES } from "~/db/tables"; import type { CalendarEventTag } from "~/db/types"; import { requireUser } from "~/features/auth/core/user.server"; import * as CalendarRepository from "~/features/calendar/CalendarRepository.server"; import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server"; import { MapPool } from "~/features/map-list-generator/core/map-pool"; +import * as Progression from "~/features/tournament-bracket/core/Progression"; import { clearTournamentDataCache, tournamentFromDB, } from "~/features/tournament-bracket/core/Tournament.server"; -import { - FORMATS_SHORT, - TOURNAMENT, -} from "~/features/tournament/tournament-constants"; +import { TOURNAMENT } from "~/features/tournament/tournament-constants"; import { rankedModesShort } from "~/modules/in-game-lists/modes"; import { canEditCalendarEvent } from "~/permissions"; import { @@ -45,7 +44,6 @@ import { canAddNewEvent, regClosesAtDate, } from "../calendar-utils"; -import { formValuesToBracketProgression } from "../calendar-utils.server"; export const action: ActionFunction = async ({ request }) => { const user = await requireUser(request); @@ -92,7 +90,7 @@ export const action: ActionFunction = async ({ request }) => { : 0, toToolsMode: rankedModesShort.find((mode) => mode === data.toToolsMode) ?? null, - bracketProgression: formValuesToBracketProgression(data), + bracketProgression: data.bracketProgression ?? null, minMembersPerTeam: data.minMembersPerTeam ?? undefined, teamsPerGroup: data.teamsPerGroup ?? undefined, thirdPlaceMatch: data.thirdPlaceMatch ?? undefined, @@ -101,7 +99,6 @@ export const action: ActionFunction = async ({ request }) => { deadlines: data.strictDeadline ? ("STRICT" as const) : ("DEFAULT" as const), enableNoScreenToggle: data.enableNoScreenToggle ?? undefined, requireInGameNames: data.requireInGameNames ?? undefined, - autoCheckInAll: data.autoCheckInAll ?? undefined, autonomousSubs: data.autonomousSubs ?? undefined, swissGroupCount: data.swissGroupCount ?? undefined, swissRoundCount: data.swissRoundCount ?? undefined, @@ -166,14 +163,16 @@ export const action: ActionFunction = async ({ request }) => { return "AUTO_ALL" as const; }; - const createdEventId = await CalendarRepository.create({ - mapPoolMaps: deserializedMaps, - isFullTournament: data.toToolsEnabled, - mapPickingStyle: mapPickingStyle(), - ...commonArgs, - }); + const { eventId: createdEventId, tournamentId: createdTournamentId } = + await CalendarRepository.create({ + mapPoolMaps: deserializedMaps, + isFullTournament: data.toToolsEnabled, + mapPickingStyle: mapPickingStyle(), + ...commonArgs, + }); - if (data.toToolsEnabled) { + if (createdTournamentId) { + clearTournamentDataCache(createdTournamentId); ShowcaseTournaments.clearParticipationInfoMap(); ShowcaseTournaments.clearCachedTournaments(); } @@ -181,6 +180,38 @@ export const action: ActionFunction = async ({ request }) => { throw redirect(calendarEventPage(createdEventId)); }; +export const bracketProgressionSchema = z.preprocess( + safeJSONParse, + z + .array( + z.object({ + type: z.enum(TOURNAMENT_STAGE_TYPES), + name: z.string().min(1).max(TOURNAMENT.BRACKET_NAME_MAX_LENGTH), + settings: z.object({ + thirdPlaceMatch: z.boolean().optional(), + teamsPerGroup: z.number().int().optional(), + groupCount: z.number().int().optional(), + roundCount: z.number().int().optional(), + }), + requiresCheckIn: z.boolean(), + startTime: z.number().optional(), + sources: z + .array( + z.object({ + bracketIdx: z.number(), + placements: z.array(z.number()), + }), + ) + .optional(), + }), + ) + .refine( + (progression) => + Progression.bracketsToValidationError(progression) === null, + "Invalid bracket progression", + ), +); + export const newCalendarEventActionSchema = z .object({ eventToEditId: z.preprocess(actualNumber, id.nullish()), @@ -255,14 +286,13 @@ export const newCalendarEventActionSchema = z // // tournament format related fields // - format: z.enum(FORMATS_SHORT).nullish(), + bracketProgression: bracketProgressionSchema.nullish(), minMembersPerTeam: z.coerce.number().int().min(1).max(4).nullish(), withUndergroundBracket: z.preprocess(checkboxValueToBoolean, z.boolean()), thirdPlaceMatch: z.preprocess( checkboxValueToBoolean, z.boolean().nullish(), ), - autoCheckInAll: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()), teamsPerGroup: z.coerce .number() .min(TOURNAMENT.MIN_GROUP_SIZE) diff --git a/app/features/calendar/calendar-utils.server.ts b/app/features/calendar/calendar-utils.server.ts deleted file mode 100644 index 67cfb457f..000000000 --- a/app/features/calendar/calendar-utils.server.ts +++ /dev/null @@ -1,94 +0,0 @@ -import type { z } from "zod"; -import type { TournamentSettings } from "~/db/tables"; -import { BRACKET_NAMES } from "../tournament/tournament-constants"; -import type { newCalendarEventActionSchema } from "./actions/calendar.new.server"; -import { validateFollowUpBrackets } from "./calendar-utils"; - -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 === "SWISS") { - result.push({ - name: BRACKET_NAMES.MAIN, - type: "swiss", - }); - } - - if (args.format === "SE") { - result.push({ - name: BRACKET_NAMES.MAIN, - type: "single_elimination", - }); - } - - if ( - args.format === "RR_TO_SE" && - args.teamsPerGroup && - args.followUpBrackets - ) { - if ( - validateFollowUpBrackets( - args.followUpBrackets, - args.format, - args.teamsPerGroup, - ) - ) { - return null; - } - - result.push({ - name: BRACKET_NAMES.GROUPS, - type: "round_robin", - }); - - for (const bracket of args.followUpBrackets) { - result.push({ - name: bracket.name, - type: "single_elimination", - sources: [{ bracketIdx: 0, placements: bracket.placements }], - }); - } - } - - if (args.format === "SWISS_TO_SE" && args.followUpBrackets) { - if (validateFollowUpBrackets(args.followUpBrackets, args.format)) { - return null; - } - - result.push({ - name: BRACKET_NAMES.GROUPS, - type: "swiss", - }); - - for (const bracket of args.followUpBrackets) { - result.push({ - name: bracket.name, - type: "single_elimination", - sources: [{ bracketIdx: 0, placements: bracket.placements }], - }); - } - } - - // 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 85e43befb..aac145b88 100644 --- a/app/features/calendar/calendar-utils.ts +++ b/app/features/calendar/calendar-utils.ts @@ -1,30 +1,11 @@ -import type { TournamentSettings } from "~/db/tables"; import { logger } from "~/utils/logger"; import { assertUnreachable } from "~/utils/types"; import { userDiscordIdIsAged } from "~/utils/users"; -import type { TournamentFormatShort } from "../tournament/tournament-constants"; import type { RegClosesAtOption } from "./calendar-constants"; -import type { FollowUpBracket } from "./calendar-types"; export const canAddNewEvent = (user: { discordId: string }) => userDiscordIdIsAged(user); -export function bracketProgressionToShortTournamentFormat( - bp: TournamentSettings["bracketProgression"], -): TournamentFormatShort { - if (bp.length === 1 && bp[0].type === "single_elimination") return "SE"; - if (bp.some((b) => b.type === "double_elimination")) return "DE"; - if (bp.length === 1 && bp[0].type === "swiss") return "SWISS"; - if ( - bp.some(({ type }) => type === "swiss") && - bp.some(({ type }) => type === "single_elimination") - ) { - return "SWISS_TO_SE"; - } - - return "RR_TO_SE"; -} - export const calendarEventMinDate = () => new Date(Date.UTC(2015, 4, 28)); export const calendarEventMaxDate = () => { const result = new Date(); @@ -32,55 +13,6 @@ export const calendarEventMaxDate = () => { return result; }; -export function validateFollowUpBrackets( - brackets: FollowUpBracket[], - format: TournamentFormatShort, - teamsPerGroup?: number, -) { - const placementsFound: number[] = []; - - for (const bracket of brackets) { - for (const placement of bracket.placements) { - if (placementsFound.includes(placement)) { - return `Duplicate group placement for two different brackets: ${placement}`; - } - placementsFound.push(placement); - } - } - - for ( - let placement = 1; - placement <= Math.max(...placementsFound); - placement++ - ) { - if (!placementsFound.includes(placement)) { - return `No bracket for placement ${placement}`; - } - } - - if ( - format === "RR_TO_SE" && - typeof teamsPerGroup === "number" && - placementsFound.some((p) => p > teamsPerGroup) - ) { - return "Placement higher than teams per group"; - } - - if (brackets.some((b) => !b.name)) { - return "Bracket name can't be empty"; - } - - if (brackets.some((b) => b.placements.length === 0)) { - return "Bracket must have at least one placement"; - } - - if (new Set(brackets.map((b) => b.name)).size !== brackets.length) { - return "Duplicate bracket name"; - } - - return null; -} - export function regClosesAtDate({ startTime, closesAt, diff --git a/app/features/calendar/components/BracketProgressionSelector.tsx b/app/features/calendar/components/BracketProgressionSelector.tsx new file mode 100644 index 000000000..2a77c2e3f --- /dev/null +++ b/app/features/calendar/components/BracketProgressionSelector.tsx @@ -0,0 +1,442 @@ +import { nanoid } from "nanoid"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "~/components/Button"; +import { DateInput } from "~/components/DateInput"; +import { FormMessage } from "~/components/FormMessage"; +import { Input } from "~/components/Input"; +import { Label } from "~/components/Label"; +import { Toggle } from "~/components/Toggle"; +import { PlusIcon } from "~/components/icons/Plus"; +import { TOURNAMENT } from "~/features/tournament"; +import * as Progression from "~/features/tournament-bracket/core/Progression"; + +const defaultBracket = (): Progression.InputBracket => ({ + id: nanoid(), + name: "Main Bracket", + type: "double_elimination", + requiresCheckIn: false, + settings: {}, +}); + +export function BracketProgressionSelector({ + initialBrackets, + isInvitationalTournament, + setErrored, +}: { + initialBrackets?: Progression.InputBracket[]; + isInvitationalTournament: boolean; + setErrored: (errored: boolean) => void; +}) { + const [brackets, setBrackets] = React.useState( + initialBrackets ?? [defaultBracket()], + ); + + const handleAddBracket = () => { + setBrackets([ + ...brackets, + { + ...defaultBracket(), + id: nanoid(), + name: "", + sources: [ + { + bracketId: brackets[0].id, + placements: "", + }, + ], + }, + ]); + }; + + const handleDeleteBracket = (idx: number) => { + const newBrackets = brackets.filter((_, i) => i !== idx); + const newBracketIds = new Set(newBrackets.map((b) => b.id)); + + const updatedBrackets = newBrackets.map((b) => ({ + ...b, + sources: + newBrackets.length === 1 + ? undefined + : b.sources?.map((source) => ({ + ...source, + bracketId: newBracketIds.has(source.bracketId) + ? source.bracketId + : newBrackets[0].id, + })), + })); + + setBrackets(updatedBrackets); + }; + + const validated = Progression.validatedBrackets(brackets); + + React.useEffect(() => { + if (Progression.isError(validated)) { + setErrored(true); + } else { + setErrored(false); + } + }, [validated, setErrored]); + + return ( +
+ {Progression.isBrackets(validated) ? ( + + ) : null} +
+ {brackets.map((bracket, i) => ( + { + const newBrackets = [...brackets]; + newBrackets[i] = newBracket; + setBrackets(newBrackets); + }} + onDelete={ + i !== 0 && !bracket.disabled + ? () => handleDeleteBracket(i) + : undefined + } + count={i + 1} + isInvitationalTournament={isInvitationalTournament} + /> + ))} +
+ + {Progression.isError(validated) ? ( + + ) : null} +
+ ); +} + +function TournamentFormatBracketSelector({ + bracket, + brackets, + onChange, + onDelete, + count, + isInvitationalTournament, +}: { + bracket: Progression.InputBracket; + brackets: Progression.InputBracket[]; + onChange: (newBracket: Progression.InputBracket) => void; + onDelete?: () => void; + count: number; + isInvitationalTournament: boolean; +}) { + const id = React.useId(); + + const createId = (name: string) => { + return `${id}-${name}`; + }; + + const isFirstBracket = count === 1; + + const updateBracket = (newProps: Partial) => { + onChange({ ...bracket, ...newProps }); + }; + + return ( +
+
+
Bracket #{count}
+ {onDelete ? ( + + ) : null} +
+
+
+
+ + updateBracket({ name: e.target.value })} + maxLength={TOURNAMENT.BRACKET_NAME_MAX_LENGTH} + readOnly={bracket.disabled} + /> +
+ + {!isFirstBracket ? ( +
+ + + updateBracket({ startTime: newDate ?? undefined }) + } + readOnly={bracket.disabled} + /> + + If missing, bracket can be started when the previous brackets have + finished + +
+ ) : null} + + {!isFirstBracket ? ( +
+ + + updateBracket({ requiresCheckIn: checked }) + } + disabled={bracket.disabled} + /> + + Check-in starts 1 hour before start time or right after the + previous bracket finishes if no start time is set + +
+ ) : null} + +
+ + +
+ + {bracket.type === "single_elimination" ? ( +
+ + + updateBracket({ + settings: { ...bracket.settings, thirdPlaceMatch: checked }, + }) + } + disabled={bracket.disabled} + /> +
+ ) : null} + + {bracket.type === "round_robin" ? ( +
+ + +
+ ) : null} + + {bracket.type === "swiss" ? ( +
+ + +
+ ) : null} + + {bracket.type === "swiss" ? ( +
+ + +
+ ) : null} + +
+
+ {" "} +
+ {isFirstBracket ? ( + + {isInvitationalTournament ? ( + <>Participants added by the organizer + ) : ( + <>Participants join from sign-up + )} + + ) : ( + bracket.id !== bracket2.id && bracket2.name, + )} + source={bracket.sources?.[0] ?? null} + onChange={(source) => updateBracket({ sources: [source] })} + /> + )} +
+
+
+ ); +} + +function SourcesSelector({ + brackets, + source, + onChange, +}: { + brackets: Progression.InputBracket[]; + source: Progression.EditableSource | null; + onChange: (sources: Progression.EditableSource) => void; +}) { + const id = React.useId(); + + const createId = (label: string) => { + return `${id}-${label}`; + }; + + return ( +
+
+ + +
+
+ + + onChange({ + bracketId: brackets[0].id, + ...source, + placements: e.target.value, + }) + } + /> +
+
+ ); +} + +function ErrorMessage({ error }: { error: Progression.ValidationError }) { + const { t } = useTranslation(["tournament"]); + + const bracketIdxsArr = (() => { + if (typeof (error as { bracketIdx: number }).bracketIdx === "number") { + return [(error as { bracketIdx: number }).bracketIdx]; + } + if ((error as { bracketIdxs: number[] }).bracketIdxs) { + return (error as { bracketIdxs: number[] }).bracketIdxs; + } + + return null; + })(); + + return ( + + Problems with the bracket progression + {bracketIdxsArr ? ( + <> (Bracket {bracketIdxsArr.map((idx) => `#${idx + 1}`).join(", ")}) + ) : null} + : {t(`tournament:progression.error.${error.type}`)} + + ); +} diff --git a/app/features/calendar/routes/calendar.new.tsx b/app/features/calendar/routes/calendar.new.tsx index 426f8858f..a31ef05a5 100644 --- a/app/features/calendar/routes/calendar.new.tsx +++ b/app/features/calendar/routes/calendar.new.tsx @@ -15,7 +15,6 @@ import { Input } from "~/components/Input"; import { Label } from "~/components/Label"; import { Main } from "~/components/Main"; import { MapPoolSelector } from "~/components/MapPoolSelector"; -import { Placement } from "~/components/Placement"; import { RequiredHiddenInput } from "~/components/RequiredHiddenInput"; import { SubmitButton } from "~/components/SubmitButton"; import { Toggle } from "~/components/Toggle"; @@ -25,13 +24,10 @@ import type { Tables } from "~/db/tables"; import type { Badge as BadgeType, CalendarEventTag } from "~/db/types"; import { useUser } from "~/features/auth/core/user"; import { MapPool } from "~/features/map-list-generator/core/map-pool"; -import { - BRACKET_NAMES, - type TournamentFormatShort, -} from "~/features/tournament/tournament-constants"; +import * as Progression from "~/features/tournament-bracket/core/Progression"; import { useIsMounted } from "~/hooks/useIsMounted"; import type { RankedModeShort } from "~/modules/in-game-lists"; -import { isDefined, nullFilledArray } from "~/utils/arrays"; +import { isDefined } from "~/utils/arrays"; import { databaseTimestampToDate, getDateAtNextFullHour, @@ -40,22 +36,20 @@ import { import invariant from "~/utils/invariant"; import type { SendouRouteHandle } from "~/utils/remix.server"; import { pathnameFromPotentialURL } from "~/utils/strings"; -import { userSubmittedImage } from "~/utils/urls"; +import { CREATING_TOURNAMENT_DOC_LINK, userSubmittedImage } from "~/utils/urls"; import { CALENDAR_EVENT, REG_CLOSES_AT_OPTIONS, type RegClosesAtOption, } from "../calendar-constants"; -import type { FollowUpBracket } from "../calendar-types"; import { - bracketProgressionToShortTournamentFormat, calendarEventMaxDate, calendarEventMinDate, datesToRegClosesAt, regClosesAtToDisplayName, - validateFollowUpBrackets, } from "../calendar-utils"; import { canAddNewEvent } from "../calendar-utils"; +import { BracketProgressionSelector } from "../components/BracketProgressionSelector"; import { Tags } from "../components/Tags"; import "~/styles/calendar-new.css"; @@ -74,7 +68,7 @@ export const meta: MetaFunction = (args) => { }; export const handle: SendouRouteHandle = { - i18n: ["calendar", "game-misc"], + i18n: ["calendar", "game-misc", "tournament"], }; const useBaseEvent = () => { @@ -112,6 +106,20 @@ export default function CalendarNewEventPage() { return (
+
+

+ {data.isAddingTournament ? "New tournament" : "New calendar event"} +

+ + ? + +
{data.isAddingTournament ? : null}
@@ -160,10 +168,15 @@ function EventForm() { const fetcher = useFetcher(); const { t } = useTranslation(); const { eventToEdit, eventToCopy } = useLoaderData(); - const ref = React.useRef(null); const [avatarImg, setAvatarImg] = React.useState(null); + const baseEvent = useBaseEvent(); + const [isInvitational, setIsInvitational] = React.useState( + baseEvent?.tournament?.ctx.settings.isInvitational ?? false, + ); const data = useLoaderData(); + const [bracketProgressionErrored, setBracketProgressionErrored] = + React.useState(false); const handleSubmit = () => { const formData = new FormData(ref.current!); @@ -185,6 +198,7 @@ function EventForm() { const submitButtonDisabled = () => { if (fetcher.state !== "idle") return true; + if (bracketProgressionErrored) return true; return false; }; @@ -219,12 +233,16 @@ function EventForm() { {data.isAddingTournament ? ( <> Tournament settings + - + ) : null} @@ -233,7 +251,23 @@ function EventForm() { ) : ( )} - {data.isAddingTournament ? : null} + {data.isAddingTournament ? ( +
+ Tournament format + +
+ ) : null}
); } - -function FollowUpBrackets({ - teamsPerGroup, - format, -}: { - teamsPerGroup: number; - format: TournamentFormatShort; -}) { - const baseEvent = useBaseEvent(); - const [autoCheckInAll, setAutoCheckInAll] = React.useState( - baseEvent?.tournament?.ctx.settings.autoCheckInAll ?? false, - ); - const [_brackets, setBrackets] = React.useState>( - () => { - if ( - baseEvent?.tournament && - ["round_robin", "swiss"].includes( - baseEvent.tournament.ctx.settings.bracketProgression[0].type, - ) - ) { - return baseEvent.tournament.ctx.settings.bracketProgression - .slice(1) - .map((b) => ({ - name: b.name, - placements: b.sources?.flatMap((s) => s.placements) ?? [], - })); - } - - return [{ name: "Top cut", placements: [1, 2] }]; - }, - ); - - const brackets = _brackets.map((b) => ({ - ...b, - // handle teams per group changing after group placements have been set - placements: - format === "RR_TO_SE" - ? b.placements.filter((p) => p <= teamsPerGroup) - : b.placements, - })); - - const validationErrorMsg = validateFollowUpBrackets( - brackets, - format, - teamsPerGroup, - ); - - return ( - <> - {brackets.length > 1 ? ( -
- - - - If disabled, the only follow-up bracket with automatic check-in is - the top cut - -
- ) : null} -
- - -
- {brackets.map((b, i) => ( - { - setBrackets( - brackets.map((oldBracket, j) => - j === i ? newBracket : oldBracket, - ), - ); - }} - bracket={b} - nth={i + 1} - format={format} - /> - ))} -
- - -
- - {validationErrorMsg ? ( - {validationErrorMsg} - ) : null} -
-
- - ); -} - -function FollowUpBracketInputs({ - teamsPerGroup, - bracket, - onChange, - nth, - format, -}: { - teamsPerGroup: number; - bracket: FollowUpBracket; - onChange: (bracket: FollowUpBracket) => void; - nth: number; - format: TournamentFormatShort; -}) { - const id = React.useId(); - return ( -
-
- - onChange({ ...bracket, name: e.target.value })} - id={id} - /> -
- {format === "RR_TO_SE" ? ( - - ) : ( - - )} -
- ); -} - -function FollowUpBracketGroupPlacementCheckboxes({ - teamsPerGroup, - bracket, - onChange, - nth, -}: { - teamsPerGroup: number; - bracket: FollowUpBracket; - onChange: (bracket: FollowUpBracket) => void; - nth: number; -}) { - const id = React.useId(); - - return ( -
- - {nullFilledArray(teamsPerGroup).map((_, i) => { - const placement = i + 1; - return ( -
- - { - const newPlacements = e.target.checked - ? [...bracket.placements, placement] - : bracket.placements.filter((p) => p !== placement); - onChange({ ...bracket, placements: newPlacements }); - }} - /> -
- ); - })} -
- ); -} - -const rangeToPlacements = ([start, end]: [number, number]) => { - if (start > end) { - return []; - } - - const result: number[] = []; - - for (let i = start; i <= end; i++) { - result.push(i); - } - - return result; -}; - -const placementsToRange = (placements: number[]): [number, number] => { - if (placements.length === 0) { - return [1, 2]; - } - - return [placements[0], placements[placements.length - 1]]; -}; - -function FollowUpBracketRangeInputs({ - bracket, - onChange, -}: { - bracket: FollowUpBracket; - onChange: (bracket: FollowUpBracket) => void; -}) { - const [range, setRange] = React.useState<[number, number]>( - placementsToRange(bracket.placements), - ); - - const handleRangeChange = (newRange: [number, number]) => { - setRange(newRange); - onChange({ ...bracket, placements: rangeToPlacements(newRange) }); - }; - - return ( -
- -
- from - - handleRangeChange([Number(e.target.value), range[1]]) - } - /> - to - - handleRangeChange([range[0], Number(e.target.value)]) - } - /> -
-
- ); -} diff --git a/app/features/tournament-bracket/components/BracketMapListDialog.tsx b/app/features/tournament-bracket/components/BracketMapListDialog.tsx index 9482be4c9..35d51f358 100644 --- a/app/features/tournament-bracket/components/BracketMapListDialog.tsx +++ b/app/features/tournament-bracket/components/BracketMapListDialog.tsx @@ -61,8 +61,7 @@ export function BracketMapListDialog({ PreparedMaps.trimPreparedEliminationMaps({ preparedMaps: untrimmedPreparedMaps, teamCount: bracketTeamsCount, - tournament, - type: bracket.type, + bracket, }) : untrimmedPreparedMaps; @@ -82,7 +81,6 @@ export function BracketMapListDialog({ const bracketData = isPreparing ? teamCountAdjustedBracketData({ bracket, - tournament, teamCount: eliminationTeamCount, }) : bracket.data; @@ -290,7 +288,6 @@ export function BracketMapListDialog({ setCount={(newCount) => { const newBracketData = teamCountAdjustedBracketData({ bracket, - tournament, teamCount: newCount, }); @@ -569,30 +566,23 @@ function authorIdToUsername(tournament: Tournament, authorId: number) { function teamCountAdjustedBracketData({ bracket, - tournament, teamCount, -}: { bracket: Bracket; tournament: Tournament; teamCount: number }) { +}: { bracket: Bracket; teamCount: number }) { switch (bracket.type) { case "swiss": // always has the same amount of rounds even if 0 participants return bracket.data; case "round_robin": // ensure a full bracket (no bye round) gets generated even if registration is underway - return tournament.generateMatchesData( + return bracket.generateMatchesData( nullFilledArray(TOURNAMENT.DEFAULT_TEAM_COUNT_PER_RR_GROUP).map( (_, i) => i + 1, ), - bracket.type, ); case "single_elimination": - return tournament.generateMatchesData( - nullFilledArray(teamCount).map((_, i) => i + 1), - "single_elimination", - ); case "double_elimination": - return tournament.generateMatchesData( + return bracket.generateMatchesData( nullFilledArray(teamCount).map((_, i) => i + 1), - "double_elimination", ); } } diff --git a/app/features/tournament-bracket/components/TeamRosterInputs.tsx b/app/features/tournament-bracket/components/TeamRosterInputs.tsx index 92907aa4e..46df13c1e 100644 --- a/app/features/tournament-bracket/components/TeamRosterInputs.tsx +++ b/app/features/tournament-bracket/components/TeamRosterInputs.tsx @@ -191,11 +191,7 @@ function _TeamRoster({ team={team} tournamentId={tournament.ctx.id} /> -
+
{showWinnerRadio ? ( Your team needs to check-in @@ -75,19 +76,37 @@ export function TournamentTeamActions() { return ( - {bracketName} up next - - - - Check-in - - + {bracket.name} check-in + {bracket.canCheckIn(user) ? ( + + + + Check-in + + + ) : bracket.startTime && bracket.startTime > new Date() ? ( + + open{" "} + {sub(bracket.startTime, { hours: 1 }).toLocaleTimeString("en-US", { + hour: "numeric", + minute: "numeric", + weekday: "short", + })}{" "} + -{" "} + {bracket.startTime.toLocaleTimeString("en-US", { + hour: "numeric", + minute: "numeric", + })} + + ) : bracket.startTime && bracket.startTime < new Date() ? ( + over + ) : null} ); } diff --git a/app/features/tournament-bracket/core/Bracket.ts b/app/features/tournament-bracket/core/Bracket.ts index 1f8e5f824..3fa4c1456 100644 --- a/app/features/tournament-bracket/core/Bracket.ts +++ b/app/features/tournament-bracket/core/Bracket.ts @@ -1,4 +1,5 @@ -import type { Tables, TournamentBracketProgression } from "~/db/tables"; +import { sub } from "date-fns"; +import type { Tables, TournamentStageSettings } from "~/db/tables"; import { TOURNAMENT } from "~/features/tournament"; import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types"; import type { Round } from "~/modules/brackets-model"; @@ -6,6 +7,8 @@ import { removeDuplicates } from "~/utils/arrays"; import invariant from "~/utils/invariant"; import { logger } from "~/utils/logger"; import { assertUnreachable } from "~/utils/types"; +import { fillWithNullTillPowerOfTwo } from "../tournament-bracket-utils"; +import * as Progression from "./Progression"; import type { OptionalIdObject, Tournament } from "./Tournament"; import type { TournamentDataTeam } from "./Tournament.server"; import { getTournamentManager } from "./brackets-manager"; @@ -13,8 +16,10 @@ import type { BracketMapCounts } from "./toMapList"; interface CreateBracketArgs { id: number; + /** Index of the bracket in the bracket progression */ + idx: number; preview: boolean; - data: TournamentManagerDataSet; + data?: TournamentManagerDataSet; type: Tables["TournamentStage"]["type"]; canBeStarted?: boolean; name: string; @@ -26,6 +31,9 @@ interface CreateBracketArgs { placements: number[]; }[]; seeding?: number[]; + settings: TournamentStageSettings | null; + checkInRequired: boolean; + startTime: Date | null; } export interface Standing { @@ -46,6 +54,7 @@ export interface Standing { export abstract class Bracket { id; + idx; preview; data; simulatedData: TournamentManagerDataSet | undefined; @@ -56,9 +65,13 @@ export abstract class Bracket { sources; createdAt; seeding; + settings; + checkInRequired; + startTime; constructor({ id, + idx, preview, data, canBeStarted, @@ -68,19 +81,32 @@ export abstract class Bracket { sources, createdAt, seeding, + settings, + checkInRequired, + startTime, }: Omit) { + if (!data && !seeding) { + throw new Error("Bracket: seeding or data required"); + } + this.id = id; + this.idx = idx; this.preview = preview; - this.data = data; + this.seeding = seeding; + this.tournament = tournament; + this.data = data ?? this.generateMatchesData(this.seeding!); this.canBeStarted = canBeStarted; this.name = name; this.teamsPendingCheckIn = teamsPendingCheckIn; - this.tournament = tournament; this.sources = sources; this.createdAt = createdAt; - this.seeding = seeding; + this.settings = settings; + this.checkInRequired = checkInRequired; + this.startTime = startTime; - this.createdSimulation(); + if (this.tournament.simulateBrackets) { + this.createdSimulation(); + } } private createdSimulation() { @@ -215,7 +241,7 @@ export abstract class Bracket { return false; } - get type(): TournamentBracketProgression[number]["type"] { + get type(): Tables["TournamentStage"]["type"] { throw new Error("not implemented"); } @@ -253,14 +279,44 @@ export abstract class Bracket { }); } + generateMatchesData(teams: number[]) { + const manager = getTournamentManager(); + + // we need some number but does not matter what it is as the manager only contains one tournament + const virtualTournamentId = 1; + + if (teams.length >= TOURNAMENT.ENOUGH_TEAMS_TO_START) { + manager.create({ + tournamentId: virtualTournamentId, + name: "Virtual", + type: this.type, + seeding: + this.type === "round_robin" + ? teams + : fillWithNullTillPowerOfTwo(teams), + settings: this.tournament.bracketSettings( + this.settings, + this.type, + teams.length, + ), + }); + } + + return manager.get.tournamentData(virtualTournamentId); + } + get isUnderground() { - return Boolean( - this.sources?.flatMap((s) => s.placements).every((p) => p !== 1), + return Progression.isUnderground( + this.idx, + this.tournament.ctx.settings.bracketProgression, ); } get isFinals() { - return Boolean(this.sources?.some((s) => s.placements.includes(1))); + return Progression.isFinals( + this.idx, + this.tournament.ctx.settings.bracketProgression, + ); } get everyMatchOver() { @@ -293,6 +349,14 @@ export abstract class Bracket { // using regular check-in if (!this.teamsPendingCheckIn) return false; + if (this.startTime) { + const checkInOpen = + sub(this.startTime.getTime(), { hours: 1 }).getTime() < Date.now() && + this.startTime.getTime() > Date.now(); + + if (!checkInOpen) return false; + } + const team = this.tournament.ownedTeamByUser(user); if (!team) return false; @@ -348,7 +412,7 @@ export abstract class Bracket { } class SingleEliminationBracket extends Bracket { - get type(): TournamentBracketProgression[number]["type"] { + get type(): Tables["TournamentStage"]["type"] { return "single_elimination"; } @@ -502,7 +566,7 @@ class SingleEliminationBracket extends Bracket { } class DoubleEliminationBracket extends Bracket { - get type(): TournamentBracketProgression[number]["type"] { + get type(): Tables["TournamentStage"]["type"] { return "double_elimination"; } @@ -1050,7 +1114,7 @@ class RoundRobinBracket extends Bracket { ); } - get type(): TournamentBracketProgression[number]["type"] { + get type(): Tables["TournamentStage"]["type"] { return "round_robin"; } @@ -1423,7 +1487,7 @@ class SwissBracket extends Bracket { ); } - get type(): TournamentBracketProgression[number]["type"] { + get type(): Tables["TournamentStage"]["type"] { return "swiss"; } diff --git a/app/features/tournament-bracket/core/PreparedMaps.test.ts b/app/features/tournament-bracket/core/PreparedMaps.test.ts index 6293ba525..9fedc5aa5 100644 --- a/app/features/tournament-bracket/core/PreparedMaps.test.ts +++ b/app/features/tournament-bracket/core/PreparedMaps.test.ts @@ -11,11 +11,15 @@ describe("PreparedMaps - resolvePreparedForTheBracket", () => { { type: "round_robin", name: "Round Robin", + requiresCheckIn: false, + settings: {}, sources: [], }, { type: "single_elimination", name: "Top Cut", + requiresCheckIn: false, + settings: {}, sources: [ { bracketIdx: 0, @@ -26,6 +30,8 @@ describe("PreparedMaps - resolvePreparedForTheBracket", () => { { type: "single_elimination", name: "Underground Bracket", + requiresCheckIn: false, + settings: {}, sources: [ { bracketIdx: 0, @@ -120,8 +126,14 @@ describe("PreparedMaps - trimPreparedEliminationMaps", () => { const tournament = testTournament({ ctx: { settings: { - thirdPlaceMatch: true, - bracketProgression: [], + bracketProgression: [ + { + type: "single_elimination", + settings: { thirdPlaceMatch: true }, + name: "X", + requiresCheckIn: false, + }, + ], }, }, }); @@ -130,8 +142,7 @@ describe("PreparedMaps - trimPreparedEliminationMaps", () => { const trimmed = PreparedMaps.trimPreparedEliminationMaps({ preparedMaps: null, teamCount: 4, - tournament, - type: "single_elimination", + bracket: tournament.bracketByIdx(0)!, }); expect(trimmed).toBeNull(); @@ -141,8 +152,7 @@ describe("PreparedMaps - trimPreparedEliminationMaps", () => { const trimmed = PreparedMaps.trimPreparedEliminationMaps({ preparedMaps: FOUR_TEAM_SE_PREPARED, teamCount: 8, - tournament, - type: "single_elimination", + bracket: tournament.bracketByIdx(0)!, }); expect(trimmed).toBeNull(); @@ -156,8 +166,7 @@ describe("PreparedMaps - trimPreparedEliminationMaps", () => { const trimmed = PreparedMaps.trimPreparedEliminationMaps({ preparedMaps: copy, teamCount: 4, - tournament, - type: "single_elimination", + bracket: tournament.bracketByIdx(0)!, }); expect(trimmed).toBeNull(); @@ -167,8 +176,7 @@ describe("PreparedMaps - trimPreparedEliminationMaps", () => { const trimmed = PreparedMaps.trimPreparedEliminationMaps({ preparedMaps: FOUR_TEAM_SE_PREPARED, teamCount: 4, - tournament, - type: "single_elimination", + bracket: tournament.bracketByIdx(0)!, }); expect(trimmed).toBe(FOUR_TEAM_SE_PREPARED); @@ -178,8 +186,7 @@ describe("PreparedMaps - trimPreparedEliminationMaps", () => { const trimmed = PreparedMaps.trimPreparedEliminationMaps({ preparedMaps: EIGHT_TEAM_SE_PREPARED, teamCount: 4, - tournament, - type: "single_elimination", + bracket: tournament.bracketByIdx(0)!, }); expect(trimmed?.maps.length).toBe(EIGHT_TEAM_SE_PREPARED.maps.length - 1); @@ -189,8 +196,7 @@ describe("PreparedMaps - trimPreparedEliminationMaps", () => { const trimmed = PreparedMaps.trimPreparedEliminationMaps({ preparedMaps: EIGHT_TEAM_SE_PREPARED, teamCount: 4, - tournament, - type: "single_elimination", + bracket: tournament.bracketByIdx(0)!, }); expect(trimmed?.maps[0].list?.[0].stageId).toBe( @@ -202,14 +208,12 @@ describe("PreparedMaps - trimPreparedEliminationMaps", () => { const trimmed = PreparedMaps.trimPreparedEliminationMaps({ preparedMaps: EIGHT_TEAM_SE_PREPARED, teamCount: 4, - tournament, - type: "single_elimination", + bracket: tournament.bracketByIdx(0)!, }); - const actualBracket = tournament.generateMatchesData( - [1, 2, 3, 4], - "single_elimination", - ); + const actualBracket = tournament + .bracketByIdx(0)! + .generateMatchesData([1, 2, 3, 4]); for (const round of actualBracket.round) { expect( @@ -223,8 +227,7 @@ describe("PreparedMaps - trimPreparedEliminationMaps", () => { const trimmed = PreparedMaps.trimPreparedEliminationMaps({ preparedMaps: EIGHT_TEAM_SE_PREPARED, teamCount: 4, - tournament, - type: "single_elimination", + bracket: tournament.bracketByIdx(0)!, }); expect(trimmed?.maps[0].roundId).toBe(0); @@ -234,8 +237,7 @@ describe("PreparedMaps - trimPreparedEliminationMaps", () => { const trimmed = PreparedMaps.trimPreparedEliminationMaps({ preparedMaps: EIGHT_TEAM_SE_PREPARED, teamCount: 3, - tournament, - type: "single_elimination", + bracket: tournament.bracketByIdx(0)!, }); expect(trimmed?.maps.length).toBe(EIGHT_TEAM_SE_PREPARED.maps.length - 2); @@ -245,12 +247,26 @@ describe("PreparedMaps - trimPreparedEliminationMaps", () => { expect(uniqueGroupIds.size).toBe(1); }); + const doubleEliminationTournament = testTournament({ + ctx: { + settings: { + bracketProgression: [ + { + type: "double_elimination", + settings: { thirdPlaceMatch: true }, + name: "X", + requiresCheckIn: false, + }, + ], + }, + }, + }); + test("trims the maps (DE - both winners and losers)", () => { const trimmed = PreparedMaps.trimPreparedEliminationMaps({ preparedMaps: EIGHT_TEAM_DE_PREPARED, teamCount: 4, - tournament, - type: "double_elimination", + bracket: doubleEliminationTournament.bracketByIdx(0)!, }); const expectedWinnersCount = 2; @@ -275,14 +291,12 @@ describe("PreparedMaps - trimPreparedEliminationMaps", () => { const trimmed = PreparedMaps.trimPreparedEliminationMaps({ preparedMaps: EIGHT_TEAM_DE_PREPARED, teamCount: 4, - tournament, - type: "double_elimination", + bracket: doubleEliminationTournament.bracketByIdx(0)!, }); - const actualBracket = tournament.generateMatchesData( - [1, 2, 3, 4], - "double_elimination", - ); + const actualBracket = doubleEliminationTournament + .bracketByIdx(0)! + .generateMatchesData([1, 2, 3, 4]); for (const round of actualBracket.round) { expect( diff --git a/app/features/tournament-bracket/core/PreparedMaps.ts b/app/features/tournament-bracket/core/PreparedMaps.ts index fda8525bc..4eba52f5b 100644 --- a/app/features/tournament-bracket/core/PreparedMaps.ts +++ b/app/features/tournament-bracket/core/PreparedMaps.ts @@ -2,6 +2,7 @@ import compare from "just-compare"; import type { PreparedMaps } from "~/db/tables"; import { nullFilledArray, removeDuplicates } from "~/utils/arrays"; import invariant from "~/utils/invariant"; +import type { Bracket } from "./Bracket"; import type { Tournament } from "./Tournament"; /** Returns the prepared maps for one exact bracket index OR maps of a "sibling bracket" i.e. bracket that has the same sources */ @@ -71,8 +72,7 @@ export function isValidMaxEliminationTeamCount(count: number) { interface TrimPreparedEliminationMapsAgs { preparedMaps: PreparedMaps | null; teamCount: number; - tournament: Tournament; - type: "double_elimination" | "single_elimination"; + bracket: Bracket; } /** Trim prepared elimination bracket maps to match the actual number. If not prepared or prepared for too few returns null */ @@ -109,12 +109,10 @@ export function trimPreparedEliminationMaps({ function trimMapsByTeamCount({ preparedMaps, teamCount, - tournament, - type, + bracket, }: TrimPreparedEliminationMapsAgs & { preparedMaps: PreparedMaps }) { - const actualRounds = tournament.generateMatchesData( + const actualRounds = bracket.generateMatchesData( nullFilledArray(teamCount).map((_, i) => i + 1), - type, ).round; const groupIds = removeDuplicates(preparedMaps.maps.map((r) => r.groupId)); diff --git a/app/features/tournament-bracket/core/Progression.test.ts b/app/features/tournament-bracket/core/Progression.test.ts new file mode 100644 index 000000000..1e3a2c342 --- /dev/null +++ b/app/features/tournament-bracket/core/Progression.test.ts @@ -0,0 +1,538 @@ +import { describe, expect, it } from "vitest"; +import * as Progression from "./Progression"; +import { progressions } from "./tests/test-utils"; + +describe("bracketsToValidationError - valid formats", () => { + it("accepts SE", () => { + expect( + Progression.bracketsToValidationError(progressions.singleElimination), + ).toBeNull(); + }); + + it("accepts RR->SE", () => { + expect( + Progression.bracketsToValidationError( + progressions.roundRobinToSingleElimination, + ), + ).toBeNull(); + }); + + it("accepts low ink", () => { + expect( + Progression.bracketsToValidationError(progressions.lowInk), + ).toBeNull(); + }); + + it("accepts many starter brackets", () => { + expect( + Progression.bracketsToValidationError(progressions.manyStartBrackets), + ).toBeNull(); + }); + + it("accepts swiss (one group)", () => { + expect( + Progression.bracketsToValidationError(progressions.swissOneGroup), + ).toBeNull(); + }); +}); + +describe("validatedSources - PLACEMENTS_PARSE_ERROR", () => { + const getValidatedBracketsFromPlacements = (placements: string) => { + return Progression.validatedBrackets([ + { + id: "1", + name: "Bracket 1", + type: "round_robin", + settings: {}, + requiresCheckIn: false, + }, + { + id: "2", + name: "Bracket 2", + type: "single_elimination", + settings: {}, + requiresCheckIn: false, + sources: [ + { + bracketId: "1", + placements, + }, + ], + }, + ]); + }; + + it("parses placements correctly (separated by comma)", () => { + const result = getValidatedBracketsFromPlacements( + "1,2,3,4", + ) as Progression.ParsedBracket[]; + + expect(result[1].sources).toEqual([ + { bracketIdx: 0, placements: [1, 2, 3, 4] }, + ]); + }); + + it("parses placements correctly (separated by line)", () => { + const result = getValidatedBracketsFromPlacements( + "1-4", + ) as Progression.ParsedBracket[]; + + expect(result[1].sources).toEqual([ + { bracketIdx: 0, placements: [1, 2, 3, 4] }, + ]); + }); + + it("parses placements correctly (separated by a mix)", () => { + const result = getValidatedBracketsFromPlacements( + "1,2,3-4", + ) as Progression.ParsedBracket[]; + + expect(result[1].sources).toEqual([ + { bracketIdx: 0, placements: [1, 2, 3, 4] }, + ]); + }); + + it("handles placement where ranges start and end is the same", () => { + const result = getValidatedBracketsFromPlacements( + "1-1", + ) as Progression.ParsedBracket[]; + + expect(result[1].sources).toEqual([{ bracketIdx: 0, placements: [1] }]); + }); + + it("handles parsing with extra white space", () => { + const result = getValidatedBracketsFromPlacements( + "1, 2, 3,4 ", + ) as Progression.ParsedBracket[]; + + expect(result[1].sources).toEqual([ + { bracketIdx: 0, placements: [1, 2, 3, 4] }, + ]); + }); + + it("handles parsing with negative placements", () => { + const result = Progression.validatedBrackets([ + { + id: "1", + name: "Bracket 1", + type: "double_elimination", + settings: {}, + requiresCheckIn: false, + }, + { + id: "2", + name: "Bracket 2", + type: "single_elimination", + settings: {}, + requiresCheckIn: false, + sources: [ + { + bracketId: "1", + placements: "-1,-2", + }, + ], + }, + ]) as Progression.ParsedBracket[]; + + expect(result[1].sources).toEqual([ + { bracketIdx: 0, placements: [-1, -2] }, + ]); + }); + + it("parsing fails if invalid characters", () => { + const error = getValidatedBracketsFromPlacements( + "1st,2nd,3rd,4th", + ) as Progression.ValidationError; + + expect(error.type).toBe("PLACEMENTS_PARSE_ERROR"); + }); +}); + +const getValidatedBrackets = ( + brackets: (Omit< + Progression.InputBracket, + "id" | "name" | "requiresCheckIn" + > & { name?: string })[], +) => + Progression.validatedBrackets( + brackets.map((b, i) => ({ + id: String(i), + name: b.name ?? `Bracket ${i + 1}`, + requiresCheckIn: false, + ...b, + })), + ); + +describe("validatedSources - other rules", () => { + it("handles NOT_RESOLVING_WINNER (only round robin)", () => { + const error = getValidatedBrackets([ + { + settings: {}, + type: "round_robin", + }, + ]) as Progression.ValidationError; + + expect(error.type).toBe("NOT_RESOLVING_WINNER"); + }); + + it("handles NOT_RESOLVING_WINNER (ends in round robin)", () => { + const error = getValidatedBrackets([ + { + settings: {}, + type: "single_elimination", + }, + { + settings: {}, + type: "round_robin", + sources: [ + { + bracketId: "0", + placements: "1,2", + }, + ], + }, + ]) as Progression.ValidationError; + + expect(error.type).toBe("NOT_RESOLVING_WINNER"); + }); + + it("handles NOT_RESOLVING_WINNER (swiss with many groups)", () => { + const error = getValidatedBrackets([ + { + settings: { + groupCount: 2, + }, + type: "swiss", + }, + ]) as Progression.ValidationError; + + expect(error.type).toBe("NOT_RESOLVING_WINNER"); + }); + + it("handles SAME_PLACEMENT_TO_MULTIPLE_BRACKETS", () => { + const error = getValidatedBrackets([ + { + settings: {}, + type: "round_robin", + }, + { + settings: {}, + type: "single_elimination", + sources: [ + { + bracketId: "0", + placements: "1-2", + }, + ], + }, + { + settings: {}, + type: "single_elimination", + sources: [ + { + bracketId: "0", + placements: "2-3", + }, + ], + }, + ]) as Progression.ValidationError; + + expect(error.type).toBe("SAME_PLACEMENT_TO_MULTIPLE_BRACKETS"); + expect((error as any).bracketIdxs).toEqual([1, 2]); + }); + + it("handles GAP_IN_PLACEMENTS", () => { + const error = getValidatedBrackets([ + { + settings: {}, + type: "round_robin", + }, + { + settings: {}, + type: "single_elimination", + sources: [ + { + bracketId: "0", + placements: "1", + }, + ], + }, + { + settings: {}, + type: "single_elimination", + sources: [ + { + bracketId: "0", + placements: "3", + }, + ], + }, + ]) as Progression.ValidationError; + + expect(error.type).toBe("GAP_IN_PLACEMENTS"); + expect((error as any).bracketIdxs).toEqual([1, 2]); + }); + + it("handles TOO_MANY_PLACEMENTS", () => { + const error = getValidatedBrackets([ + { + settings: { + teamsPerGroup: 4, + }, + type: "round_robin", + }, + { + settings: {}, + type: "single_elimination", + sources: [ + { + bracketId: "0", + placements: "1,2,3,4,5", + }, + ], + }, + ]) as Progression.ValidationError; + + expect(error.type).toBe("TOO_MANY_PLACEMENTS"); + expect((error as any).bracketIdx).toEqual(1); + }); + + it("handles DUPLICATE_BRACKET_NAME", () => { + const error = getValidatedBrackets([ + { + settings: {}, + type: "round_robin", + name: "Bracket 1", + }, + { + settings: {}, + type: "single_elimination", + name: "Bracket 1", + sources: [ + { + bracketId: "0", + placements: "1-2", + }, + ], + }, + ]) as Progression.ValidationError; + + expect(error.type).toBe("DUPLICATE_BRACKET_NAME"); + expect((error as any).bracketIdxs).toEqual([0, 1]); + }); + + it("handles NAME_MISSING", () => { + const error = getValidatedBrackets([ + { + settings: {}, + type: "round_robin", + name: "", + }, + { + settings: {}, + type: "single_elimination", + sources: [ + { + bracketId: "0", + placements: "1-2", + }, + ], + }, + ]) as Progression.ValidationError; + + expect(error.type).toBe("NAME_MISSING"); + expect((error as any).bracketIdx).toEqual(0); + }); + + it("handles NEGATIVE_PROGRESSION", () => { + const error = getValidatedBrackets([ + { + settings: {}, + type: "round_robin", + }, + { + settings: {}, + type: "single_elimination", + sources: [ + { + bracketId: "0", + placements: "-1,-2", + }, + ], + }, + { + settings: {}, + type: "single_elimination", + sources: [ + { + bracketId: "0", + placements: "1", + }, + ], + }, + ]) as Progression.ValidationError; + + expect(error.type).toBe("NEGATIVE_PROGRESSION"); + expect((error as any).bracketIdx).toEqual(1); + }); + + it("handles NO_SE_SOURCE", () => { + const error = getValidatedBrackets([ + { + settings: {}, + type: "single_elimination", + }, + { + settings: {}, + type: "double_elimination", + sources: [ + { + bracketId: "0", + placements: "1-2", + }, + ], + }, + ]) as Progression.ValidationError; + + expect(error.type).toBe("NO_SE_SOURCE"); + expect((error as any).bracketIdx).toEqual(1); + }); + + it("handles NO_DE_POSITIVE", () => { + const error = getValidatedBrackets([ + { + settings: {}, + type: "double_elimination", + }, + { + settings: {}, + type: "single_elimination", + sources: [ + { + bracketId: "0", + placements: "1-2", + }, + ], + }, + ]) as Progression.ValidationError; + + expect(error.type).toBe("NO_DE_POSITIVE"); + expect((error as any).bracketIdx).toEqual(1); + }); + + it("throws an error if many missing sources", () => { + expect(() => + getValidatedBrackets([ + { + settings: {}, + type: "round_robin", + }, + { + settings: {}, + type: "single_elimination", + }, + ]), + ).toThrow(); + }); +}); + +describe("isFinals", () => { + it("handles SE", () => { + expect(Progression.isFinals(0, progressions.singleElimination)).toBe(true); + }); + + it("handles RR->SE", () => { + expect( + Progression.isFinals(0, progressions.roundRobinToSingleElimination), + ).toBe(false); + expect( + Progression.isFinals(1, progressions.roundRobinToSingleElimination), + ).toBe(true); + }); + + it("handles low ink", () => { + expect(Progression.isFinals(0, progressions.lowInk)).toBe(false); + expect(Progression.isFinals(1, progressions.lowInk)).toBe(false); + expect(Progression.isFinals(2, progressions.lowInk)).toBe(false); + expect(Progression.isFinals(3, progressions.lowInk)).toBe(true); + }); + + it("many starter brackets", () => { + expect(Progression.isFinals(0, progressions.manyStartBrackets)).toBe(false); + expect(Progression.isFinals(1, progressions.manyStartBrackets)).toBe(false); + expect(Progression.isFinals(2, progressions.manyStartBrackets)).toBe(true); + expect(Progression.isFinals(3, progressions.manyStartBrackets)).toBe(false); + }); + + it("throws if given idx is out of bounds", () => { + expect(() => + Progression.isFinals(1, progressions.singleElimination), + ).toThrow(); + }); +}); + +describe("isUnderground", () => { + it("handles SE", () => { + expect(Progression.isUnderground(0, progressions.singleElimination)).toBe( + false, + ); + }); + + it("handles RR->SE", () => { + expect( + Progression.isUnderground(0, progressions.roundRobinToSingleElimination), + ).toBe(false); + expect( + Progression.isUnderground(1, progressions.roundRobinToSingleElimination), + ).toBe(false); + }); + + it("handles low ink", () => { + expect(Progression.isUnderground(0, progressions.lowInk)).toBe(false); + expect(Progression.isUnderground(1, progressions.lowInk)).toBe(true); + expect(Progression.isUnderground(2, progressions.lowInk)).toBe(false); + expect(Progression.isUnderground(3, progressions.lowInk)).toBe(false); + }); + + it("many starter brackets", () => { + expect(Progression.isUnderground(0, progressions.manyStartBrackets)).toBe( + false, + ); + expect(Progression.isUnderground(1, progressions.manyStartBrackets)).toBe( + true, + ); + expect(Progression.isUnderground(2, progressions.manyStartBrackets)).toBe( + false, + ); + expect(Progression.isUnderground(3, progressions.manyStartBrackets)).toBe( + true, + ); + }); + + it("throws if given idx is out of bounds", () => { + expect(() => + Progression.isUnderground(1, progressions.singleElimination), + ).toThrow(); + }); +}); + +describe("changedBracketProgression", () => { + it("reports changed bracket indexes", () => { + const withChanges = structuredClone(progressions.lowInk); + withChanges[0].name = "New name"; + withChanges[1].type = "swiss"; + + expect( + Progression.changedBracketProgression(progressions.lowInk, withChanges), + ).toEqual([0, 1]); + }); + + it("returns an empty array if nothing changed", () => { + expect( + Progression.changedBracketProgression( + progressions.lowInk, + progressions.lowInk, + ), + ).toEqual([]); + }); +}); diff --git a/app/features/tournament-bracket/core/Progression.ts b/app/features/tournament-bracket/core/Progression.ts new file mode 100644 index 000000000..b6888acb5 --- /dev/null +++ b/app/features/tournament-bracket/core/Progression.ts @@ -0,0 +1,626 @@ +// todo + +import compare from "just-compare"; +import type { Tables, TournamentStageSettings } from "~/db/tables"; +import { TOURNAMENT } from "~/features/tournament/tournament-constants"; +import { + databaseTimestampToDate, + dateToDatabaseTimestamp, +} from "~/utils/dates"; +import invariant from "../../../utils/invariant"; + +export interface DBSource { + /** 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 EditableSource { + /** Bracket ID that exists in frontend only while editing. Once the sources are set an index is used to identifyer them instead. See DBSource.bracketIdx for more info. */ + bracketId: string; + /** User editable string of placements. For example might be "1-3" or "1,2,3" which both mean same thing. See DBSource.placements for the validated and serialized version. */ + placements: string; +} + +interface BracketBase { + type: Tables["TournamentStage"]["type"]; + settings: TournamentStageSettings; + name: string; + requiresCheckIn: boolean; +} + +// Note sources is array for future proofing reasons. Currently the array is always of length 1 if it exists. + +export interface InputBracket extends BracketBase { + id: string; + sources?: EditableSource[]; + startTime?: Date; + /** This bracket cannot be edited (because it is already underway) */ + disabled?: boolean; +} + +export interface ParsedBracket extends BracketBase { + sources?: DBSource[]; + startTime?: number; +} + +export type ValidationError = + // user written placements can not be parsed + | { + type: "PLACEMENTS_PARSE_ERROR"; + bracketIdx: number; + } + // tournament is ending with a format that does not resolve a winner such as round robin or grouped swiss + | { + type: "NOT_RESOLVING_WINNER"; + } + // from each bracket one placement can lead to only one bracket + | { + type: "SAME_PLACEMENT_TO_MULTIPLE_BRACKETS"; + bracketIdxs: number[]; + } + // from one bracket e.g. if 1st goes somewhere and 3rd goes somewhere then 2nd must also go somewhere + | { + type: "GAP_IN_PLACEMENTS"; + bracketIdxs: number[]; + } + // if round robin groups size is 4 then it doesn't make sense to have destination for 5 + | { + type: "TOO_MANY_PLACEMENTS"; + bracketIdx: number; + } + // two brackets can not have the same name + | { + type: "DUPLICATE_BRACKET_NAME"; + bracketIdxs: number[]; + } + // all brackets must have a name that is not an empty string + | { + type: "NAME_MISSING"; + bracketIdx: number; + } + // negative progression (e.g. losers of first round go somewhere) is only for elimination bracket + | { + type: "NEGATIVE_PROGRESSION"; + bracketIdx: number; + } + // single elimination is not a valid source bracket (might change in the future) + | { + type: "NO_SE_SOURCE"; + bracketIdx: number; + } + // no DE positive placements (might change in the future) + | { + type: "NO_DE_POSITIVE"; + bracketIdx: number; + }; + +/** Takes validated brackets and returns them in the format that is ready for user input. */ +export function validatedBracketsToInputFormat( + brackets: ParsedBracket[], +): InputBracket[] { + return brackets.map((bracket, bracketIdx) => { + return { + id: String(bracketIdx), + name: bracket.name, + settings: bracket.settings ?? {}, + type: bracket.type, + requiresCheckIn: bracket.requiresCheckIn ?? false, + startTime: bracket.startTime + ? databaseTimestampToDate(bracket.startTime) + : undefined, + sources: bracket.sources?.map((source) => ({ + bracketId: String(source.bracketIdx), + placements: placementsToString(source.placements), + })), + }; + }); +} + +function placementsToString(placements: number[]): string { + if (placements.length === 0) return ""; + + placements.sort((a, b) => a - b); + + if (placements.some((p) => p < 0)) { + placements.sort((a, b) => b - a); + return placements.join(","); + } + + const ranges: string[] = []; + let start = placements[0]; + let end = placements[0]; + + for (let i = 1; i < placements.length; i++) { + if (placements[i] === end + 1) { + end = placements[i]; + } else { + if (start === end) { + ranges.push(`${start}`); + } else { + ranges.push(`${start}-${end}`); + } + start = placements[i]; + end = placements[i]; + } + } + + if (start === end) { + ranges.push(String(start)); + } else { + ranges.push(`${start}-${end}`); + } + + return ranges.join(","); +} + +/** Takes bracket progression as entered by user as input and returns the validated brackets ready for input to the database or errors if any. */ +export function validatedBrackets( + brackets: InputBracket[], +): ParsedBracket[] | ValidationError { + let parsed: ParsedBracket[]; + try { + parsed = toOutputBracketFormat(brackets); + } catch (e) { + if ((e as { badBracketIdx: number }).badBracketIdx) { + return { + type: "PLACEMENTS_PARSE_ERROR", + bracketIdx: (e as { badBracketIdx: number }).badBracketIdx, + }; + } + + throw e; + } + + validateOnlyOneEntry(parsed); + + const validationError = bracketsToValidationError(parsed); + + if (validationError) { + return validationError; + } + + return parsed; +} + +function validateOnlyOneEntry(brackets: ParsedBracket[]) { + const entryBracketCount = brackets.filter( + (bracket) => !bracket.sources, + ).length; + + if (entryBracketCount !== 1) { + throw new Error("Only one bracket can have no sources"); + } +} + +/** Checks parsed brackets for any errors related to how the progression is laid out */ +export function bracketsToValidationError( + brackets: ParsedBracket[], +): ValidationError | null { + if (!resolvesWinner(brackets)) { + return { + type: "NOT_RESOLVING_WINNER", + }; + } + + let faultyBracketIdxs: number[] | null = null; + + faultyBracketIdxs = samePlacementToMultipleBrackets(brackets); + if (faultyBracketIdxs) { + return { + type: "SAME_PLACEMENT_TO_MULTIPLE_BRACKETS", + bracketIdxs: faultyBracketIdxs, + }; + } + + faultyBracketIdxs = duplicateNames(brackets); + if (faultyBracketIdxs) { + return { + type: "DUPLICATE_BRACKET_NAME", + bracketIdxs: faultyBracketIdxs, + }; + } + + faultyBracketIdxs = gapInPlacements(brackets); + if (faultyBracketIdxs) { + return { + type: "GAP_IN_PLACEMENTS", + bracketIdxs: faultyBracketIdxs, + }; + } + + let faultyBracketIdx: number | null = null; + + faultyBracketIdx = tooManyPlacements(brackets); + if (typeof faultyBracketIdx === "number") { + return { + type: "TOO_MANY_PLACEMENTS", + bracketIdx: faultyBracketIdx, + }; + } + + faultyBracketIdx = nameMissing(brackets); + if (typeof faultyBracketIdx === "number") { + return { + type: "NAME_MISSING", + bracketIdx: faultyBracketIdx, + }; + } + + faultyBracketIdx = negativeProgression(brackets); + if (typeof faultyBracketIdx === "number") { + return { + type: "NEGATIVE_PROGRESSION", + bracketIdx: faultyBracketIdx, + }; + } + + faultyBracketIdx = noSingleEliminationAsSource(brackets); + if (typeof faultyBracketIdx === "number") { + return { + type: "NO_SE_SOURCE", + bracketIdx: faultyBracketIdx, + }; + } + + faultyBracketIdx = noDoubleEliminationPositive(brackets); + if (typeof faultyBracketIdx === "number") { + return { + type: "NO_DE_POSITIVE", + bracketIdx: faultyBracketIdx, + }; + } + + return null; +} + +function toOutputBracketFormat(brackets: InputBracket[]): ParsedBracket[] { + const result = brackets.map((bracket, bracketIdx) => { + return { + type: bracket.type, + settings: bracket.settings, + name: bracket.name, + requiresCheckIn: bracket.requiresCheckIn, + startTime: bracket.startTime + ? dateToDatabaseTimestamp(bracket.startTime) + : undefined, + sources: bracket.sources?.map((source) => { + const placements = parsePlacements(source.placements); + if (!placements) { + throw { badBracketIdx: bracketIdx }; + } + + return { + bracketIdx: brackets.findIndex((b) => b.id === source.bracketId), + placements, + }; + }), + }; + }); + + invariant( + result.every( + (bracket) => + !bracket.sources || + bracket.sources.every((source) => source.bracketIdx >= 0), + "Bracket source not found", + ), + ); + + return result; +} + +function parsePlacements(placements: string) { + const parts = placements.split(","); + + const result: number[] = []; + + for (let part of parts) { + part = part.trim(); + + const isNegative = part.match(/^-\d+$/); + if (isNegative) { + result.push(Number(part)); + continue; + } + + const isValid = part.match(/^\d+(-\d+)?$/); + if (!isValid) return null; + + if (part.includes("-")) { + const [start, end] = part.split("-").map(Number); + + for (let i = start; i <= end; i++) { + result.push(i); + } + } else { + result.push(Number(part)); + } + } + + return result; +} + +function resolvesWinner(brackets: ParsedBracket[]) { + const finals = brackets.find((_, idx) => isFinals(idx, brackets)); + + if (!finals) return false; + if (finals?.type === "round_robin") return false; + if ( + finals.type === "swiss" && + (finals.settings.groupCount ?? TOURNAMENT.SWISS_DEFAULT_GROUP_COUNT) > 1 + ) { + return false; + } + + return true; +} + +function samePlacementToMultipleBrackets(brackets: ParsedBracket[]) { + const map = new Map(); + + for (const [bracketIdx, bracket] of brackets.entries()) { + if (!bracket.sources) continue; + + for (const source of bracket.sources) { + for (const placement of source.placements) { + const id = `${source.bracketIdx}-${placement}`; + + if (!map.has(id)) { + map.set(id, []); + } + + map.get(id)!.push(bracketIdx); + } + } + } + + const result: number[] = []; + + for (const [_, bracketIdxs] of map) { + if (bracketIdxs.length > 1) { + result.push(...bracketIdxs); + } + } + + return result.length ? result : null; +} + +function duplicateNames(brackets: ParsedBracket[]) { + const names = new Set(); + + for (const [bracketIdx, bracket] of brackets.entries()) { + if (names.has(bracket.name)) { + return [brackets.findIndex((b) => b.name === bracket.name), bracketIdx]; + } + + names.add(bracket.name); + } + + return null; +} + +function gapInPlacements(brackets: ParsedBracket[]) { + const placementsMap = new Map(); + + for (const bracket of brackets) { + if (!bracket.sources) continue; + + for (const source of bracket.sources) { + if (!placementsMap.has(source.bracketIdx)) { + placementsMap.set(source.bracketIdx, []); + } + + placementsMap.get(source.bracketIdx)!.push(...source.placements); + } + } + + let problematicBracketIdx: number | null = null; + for (const [sourceBracketIdx, placements] of placementsMap.entries()) { + if (problematicBracketIdx !== null) break; + + const placementsToConsider = placements + .filter((placement) => placement > 0) + .sort((a, b) => a - b); + + for (let i = 0; i < placementsToConsider.length - 1; i++) { + if (placementsToConsider[i] + 1 !== placementsToConsider[i + 1]) { + problematicBracketIdx = sourceBracketIdx; + break; + } + } + } + + if (problematicBracketIdx === null) return null; + + return brackets.flatMap((bracket, bracketIdx) => { + if (!bracket.sources) return []; + + return bracket.sources.flatMap( + (source) => source.bracketIdx === problematicBracketIdx, + ) + ? [bracketIdx] + : []; + }); +} + +function tooManyPlacements(brackets: ParsedBracket[]) { + const roundRobins = brackets.flatMap((bracket, bracketIdx) => + bracket.type === "round_robin" ? [bracketIdx] : [], + ); + // technically not correct but i guess not too common to have different round robins in the same bracket + const size = Math.min( + ...roundRobins.map( + (bracketIdx) => + brackets[bracketIdx].settings.teamsPerGroup ?? Number.POSITIVE_INFINITY, + ), + ); + + for (const [bracketIdx, bracket] of brackets.entries()) { + for (const source of bracket.sources ?? []) { + if ( + roundRobins.includes(source.bracketIdx) && + source.placements.some((placement) => placement > size) + ) { + return bracketIdx; + } + } + } + + return null; +} + +function nameMissing(brackets: ParsedBracket[]) { + for (const [bracketIdx, bracket] of brackets.entries()) { + if (!bracket.name) { + return bracketIdx; + } + } + + return null; +} + +function negativeProgression(brackets: ParsedBracket[]) { + for (const [bracketIdx, bracket] of brackets.entries()) { + for (const source of bracket.sources ?? []) { + const sourceBracket = brackets[source.bracketIdx]; + if ( + sourceBracket.type === "double_elimination" || + sourceBracket.type === "single_elimination" + ) { + continue; + } + + if (source.placements.some((placement) => placement < 0)) { + return bracketIdx; + } + } + } + + return null; +} + +function noSingleEliminationAsSource(brackets: ParsedBracket[]) { + for (const [bracketIdx, bracket] of brackets.entries()) { + for (const source of bracket.sources ?? []) { + const sourceBracket = brackets[source.bracketIdx]; + if (sourceBracket.type === "single_elimination") { + return bracketIdx; + } + } + } + + return null; +} + +function noDoubleEliminationPositive(brackets: ParsedBracket[]) { + for (const [bracketIdx, bracket] of brackets.entries()) { + for (const source of bracket.sources ?? []) { + const sourceBracket = brackets[source.bracketIdx]; + if ( + sourceBracket.type === "double_elimination" && + source.placements.some((placement) => placement > 0) + ) { + return bracketIdx; + } + } + } + + return null; +} + +/** Takes the return type of `Progression.validatedBrackets` as an input and narrows the type to a successful validation */ +export function isBrackets( + input: ParsedBracket[] | ValidationError, +): input is ParsedBracket[] { + return Array.isArray(input); +} + +/** Takes the return type of `Progression.validatedBrackets` as an input and narrows the type to a unsuccessful validation */ +export function isError( + input: ParsedBracket[] | ValidationError, +): input is ValidationError { + return !Array.isArray(input); +} + +/** Given bracketIdx and bracketProgression will resolve if this the "final stage" of the tournament that decides the final standings */ +export function isFinals(idx: number, brackets: ParsedBracket[]) { + invariant(idx < brackets.length, "Bracket index out of bounds"); + + return resolveMainBracketProgression(brackets).at(-1) === idx; +} + +/** Given bracketIdx and bracketProgression will resolve if this an "underground bracket". + * Underground bracket is defined as a bracket that is not part of the main tournament progression e.g. optional bracket for early losers + */ +export function isUnderground(idx: number, brackets: ParsedBracket[]) { + invariant(idx < brackets.length, "Bracket index out of bounds"); + + return !resolveMainBracketProgression(brackets).includes(idx); +} + +function resolveMainBracketProgression(brackets: ParsedBracket[]) { + if (brackets.length === 1) return [0]; + + let bracketIdxToFind = 0; + const result = [0]; + while (true) { + const bracket = brackets.findIndex((bracket) => + bracket.sources?.some( + (source) => + source.placements.includes(1) && + source.bracketIdx === bracketIdxToFind, + ), + ); + + if (bracket === -1) break; + + bracketIdxToFind = bracket; + result.push(bracketIdxToFind); + } + + return result; +} + +/** Considering all fields. Returns array of bracket indexes that were changed */ +export function changedBracketProgression( + oldProgression: ParsedBracket[], + newProgression: ParsedBracket[], +) { + const changed: number[] = []; + + for (let i = 0; i < oldProgression.length; i++) { + const oldBracket = oldProgression[i]; + const newBracket = newProgression.at(i); + + if (!newBracket || !compare(oldBracket, newBracket)) { + changed.push(i); + } + } + + return changed; +} + +/** Considering only fields that affect the format. Returns true if the tournament bracket format was changed and false otherwise */ +export function changedBracketProgressionFormat( + oldProgression: ParsedBracket[], + newProgression: ParsedBracket[], +): boolean { + for (let i = 0; i < oldProgression.length; i++) { + const oldBracket = oldProgression[i]; + const newBracket = newProgression.at(i); + + // sources, startTime or requiresCheckIn are not considered + if ( + !newBracket || + newBracket.name !== oldBracket.name || + newBracket.type !== oldBracket.type || + !compare(newBracket.settings, oldBracket.settings) + ) { + return true; + } + } + + return false; +} diff --git a/app/features/tournament-bracket/core/Swiss.ts b/app/features/tournament-bracket/core/Swiss.ts index 3b6d607d9..2fd5625f4 100644 --- a/app/features/tournament-bracket/core/Swiss.ts +++ b/app/features/tournament-bracket/core/Swiss.ts @@ -1,6 +1,7 @@ // separate from brackets-manager as this wasn't part of the original brackets-manager library import type { TournamentRepositoryInsertableMatch } from "~/features/tournament/TournamentRepository.server"; +import { TOURNAMENT } from "~/features/tournament/tournament-constants"; import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types"; import type { InputStage, Match } from "~/modules/brackets-model"; import { nullFilledArray } from "~/utils/arrays"; @@ -12,8 +13,10 @@ export function create( ): TournamentManagerDataSet { const swissSettings = args.settings?.swiss; - const groupCount = swissSettings?.groupCount ?? 1; - const roundCount = swissSettings?.roundCount ?? 5; + const groupCount = + swissSettings?.groupCount ?? TOURNAMENT.SWISS_DEFAULT_GROUP_COUNT; + const roundCount = + swissSettings?.roundCount ?? TOURNAMENT.SWISS_DEFAULT_ROUND_COUNT; const group = nullFilledArray(groupCount).map((_, i) => ({ id: i, diff --git a/app/features/tournament-bracket/core/Tournament.server.ts b/app/features/tournament-bracket/core/Tournament.server.ts index 201e7560c..7fe01d488 100644 --- a/app/features/tournament-bracket/core/Tournament.server.ts +++ b/app/features/tournament-bracket/core/Tournament.server.ts @@ -12,12 +12,17 @@ const manager = getServerTournamentManager(); export const tournamentManagerData = (tournamentId: number) => manager.get.tournamentData(tournamentId); -const combinedTournamentData = async (tournamentId: number) => ({ - data: tournamentManagerData(tournamentId), - ctx: notFoundIfFalsy(await TournamentRepository.findById(tournamentId)), -}); +const combinedTournamentData = async (tournamentId: number) => { + const ctx = await TournamentRepository.findById(tournamentId); + if (!ctx) return null; -export type TournamentData = Unwrapped; + return { + data: tournamentManagerData(tournamentId), + ctx, + }; +}; + +export type TournamentData = NonNullable>; export type TournamentDataTeam = TournamentData["ctx"]["teams"][number]; export async function tournamentData({ user, @@ -26,9 +31,10 @@ export async function tournamentData({ user?: { id: number }; tournamentId: number; }) { - const { data, ctx } = await combinedTournamentData(tournamentId); + const data = await combinedTournamentData(tournamentId); + if (!data) return null; - return dataMapped({ data, ctx, user }); + return dataMapped({ user, ...data }); } function dataMapped({ @@ -81,7 +87,9 @@ export async function tournamentFromDB(args: { user: { id: number } | undefined; tournamentId: number; }) { - return new Tournament(await tournamentData(args)); + const data = notFoundIfFalsy(await tournamentData(args)); + + return new Tournament({ ...data, simulateBrackets: false }); } // caching promise ensures that if many requests are made for the same tournament @@ -101,9 +109,9 @@ export async function tournamentDataCached({ tournamentDataCache.set(tournamentId, combinedTournamentData(tournamentId)); } - const { data, ctx } = await tournamentDataCache.get(tournamentId)!; + const data = notFoundIfFalsy(await tournamentDataCache.get(tournamentId)); - return dataMapped({ data, ctx, user }); + return dataMapped({ user, ...data }); } export function clearTournamentDataCache(tournamentId: number) { diff --git a/app/features/tournament-bracket/core/Tournament.test.ts b/app/features/tournament-bracket/core/Tournament.test.ts index cfebabaed..6f827fb90 100644 --- a/app/features/tournament-bracket/core/Tournament.test.ts +++ b/app/features/tournament-bracket/core/Tournament.test.ts @@ -11,7 +11,16 @@ import { describe("Follow-up bracket progression", () => { const tournamentPP257 = new Tournament(PADDLING_POOL_257()); const tournamentPP255 = new Tournament(PADDLING_POOL_255()); - const tournamentITZ32 = new Tournament(IN_THE_ZONE_32()); + const tournamentITZ32 = new Tournament(IN_THE_ZONE_32({})); + const tournamentITZ32UndergroundWithoutCheckIn = new Tournament( + IN_THE_ZONE_32({ undergroundRequiresCheckIn: false }), + ); + const tournamentITZ32UndergroundWithoutCheckInWithCheckedOut = new Tournament( + IN_THE_ZONE_32({ + undergroundRequiresCheckIn: false, + hasCheckedOutTeam: true, + }), + ); test("correct amount of teams in the top cut", () => { expect(tournamentPP257.brackets[1].seeding?.length).toBe(18); @@ -43,6 +52,19 @@ describe("Follow-up bracket progression", () => { expect(tournamentITZ32.brackets[1].seeding?.length).toBe(4); }); + test("underground bracket includes all teams if does not require check in (DE->SE)", () => { + expect( + tournamentITZ32UndergroundWithoutCheckIn.brackets[1].seeding?.length, + ).toBe(16); + }); + + test("underground bracket excludes checked out teams", () => { + expect( + tournamentITZ32UndergroundWithoutCheckInWithCheckedOut.brackets[1].seeding + ?.length, + ).toBe(15); + }); + const AMOUNT_OF_WORSE_VS_BEST = 5; const AMOUNT_OF_BEST_VS_BEST = 1; const AMOUNT_OF_WORSE_VS_WORSE = 2; diff --git a/app/features/tournament-bracket/core/Tournament.ts b/app/features/tournament-bracket/core/Tournament.ts index ec030dd1f..ce66fcfb6 100644 --- a/app/features/tournament-bracket/core/Tournament.ts +++ b/app/features/tournament-bracket/core/Tournament.ts @@ -1,9 +1,10 @@ import type { - TournamentBracketProgression, + Tables, TournamentStage, + TournamentStageSettings, } from "~/db/tables"; import { TOURNAMENT } from "~/features/tournament"; -import { BRACKET_NAMES } from "~/features/tournament/tournament-constants"; +import type * as Progression from "~/features/tournament-bracket/core/Progression"; import { tournamentIsRanked } from "~/features/tournament/tournament-utils"; import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types"; import type { Match, Stage } from "~/modules/brackets-model"; @@ -35,8 +36,17 @@ export type OptionalIdObject = { id: number } | undefined; export class Tournament { brackets: Bracket[] = []; ctx; + simulateBrackets; - constructor({ data, ctx }: TournamentData) { + constructor({ + data, + ctx, + simulateBrackets = true, + }: { + data: TournamentData["data"]; + ctx: TournamentData["ctx"]; + simulateBrackets?: boolean; + }) { const hasStarted = data.stage.length > 0; const teamsInSeedOrder = ctx.teams.sort((a, b) => { @@ -54,6 +64,7 @@ export class Tournament { return this.compareUnseededTeams(a, b); }); + this.simulateBrackets = simulateBrackets; this.ctx = { ...ctx, teams: hasStarted @@ -94,7 +105,14 @@ export class Tournament { private initBrackets(data: TournamentManagerDataSet) { for (const [ bracketIdx, - { type, name, sources }, + { + type, + name, + sources, + requiresCheckIn = false, + startTime = null, + settings, + }, ] of this.ctx.settings.bracketProgression.entries()) { const inProgressStage = data.stage.find((stage) => stage.name === name); @@ -106,11 +124,15 @@ export class Tournament { this.brackets.push( Bracket.create({ id: inProgressStage.id, + idx: bracketIdx, tournament: this, preview: false, name, sources, createdAt: inProgressStage.createdAt, + checkInRequired: requiresCheckIn ?? false, + startTime: startTime ? databaseTimestampToDate(startTime) : null, + settings: settings ?? null, data: { ...data, group: data.group.filter( @@ -139,20 +161,30 @@ export class Tournament { this.divideTeamsToCheckedInAndNotCheckedIn({ teams, bracketIdx, + usesRegularCheckIn: !sources, + requiresCheckIn, }); this.brackets.push( Bracket.create({ id: -1 * bracketIdx, + idx: bracketIdx, tournament: this, seeding: checkedInTeams, preview: true, name, + checkInRequired: requiresCheckIn ?? false, + startTime: startTime ? databaseTimestampToDate(startTime) : null, + settings: settings ?? null, data: Swiss.create({ tournamentId: this.ctx.id, name, seeding: checkedInTeams, - settings: this.bracketSettings(type, checkedInTeams.length), + settings: this.bracketSettings( + settings, + type, + checkedInTeams.length, + ), }), type, sources, @@ -176,25 +208,31 @@ export class Tournament { this.divideTeamsToCheckedInAndNotCheckedIn({ teams, bracketIdx, + usesRegularCheckIn: !sources, + requiresCheckIn, }); const checkedInTeamsWithReplaysAvoided = - this.avoidReplaysOfPreviousBracketOpponent(checkedInTeams, { - sources, - type, - }); + this.avoidReplaysOfPreviousBracketOpponent( + checkedInTeams, + { + sources, + type, + }, + settings, + ); this.brackets.push( Bracket.create({ id: -1 * bracketIdx, + idx: bracketIdx, tournament: this, seeding: checkedInTeamsWithReplaysAvoided, preview: true, name, - data: this.generateMatchesData( - checkedInTeamsWithReplaysAvoided, - type, - ), + checkInRequired: requiresCheckIn ?? false, + startTime: startTime ? databaseTimestampToDate(startTime) : null, + settings: settings ?? null, type, sources, createdAt: null, @@ -210,28 +248,8 @@ export class Tournament { } } - generateMatchesData(teams: number[], type: TournamentStage["type"]) { - const manager = getTournamentManager(); - - // we need some number but does not matter what it is as the manager only contains one tournament - const virtualTournamentId = 1; - - if (teams.length >= TOURNAMENT.ENOUGH_TEAMS_TO_START) { - manager.create({ - tournamentId: virtualTournamentId, - name: "Virtual", - type, - seeding: - type === "round_robin" ? teams : fillWithNullTillPowerOfTwo(teams), - settings: this.bracketSettings(type, teams.length), - }); - } - - return manager.get.tournamentData(virtualTournamentId); - } - private resolveTeamsFromSources( - sources: NonNullable, + sources: NonNullable, ) { const teams: number[] = []; @@ -254,9 +272,10 @@ export class Tournament { private avoidReplaysOfPreviousBracketOpponent( teams: number[], bracket: { - sources: TournamentBracketProgression[number]["sources"]; - type: TournamentBracketProgression[number]["type"]; + sources: Progression.ParsedBracket["sources"]; + type: Tables["TournamentStage"]["type"]; }, + settings: TournamentStageSettings, ) { // rather arbitrary limit, but with smaller brackets avoiding replays is not possible // and then later while loop hits iteration limit @@ -309,7 +328,11 @@ export class Tournament { "round_robin" | "swiss" >, seeding: fillWithNullTillPowerOfTwo(candidateTeams), - settings: this.bracketSettings(bracket.type, candidateTeams.length), + settings: this.bracketSettings( + settings, + bracket.type, + candidateTeams.length, + ), }); const matches = manager.get.tournamentData(this.ctx.id).match; @@ -394,30 +417,47 @@ export class Tournament { private divideTeamsToCheckedInAndNotCheckedIn({ teams, bracketIdx, + usesRegularCheckIn, + requiresCheckIn, }: { teams: number[]; bracketIdx: number; + usesRegularCheckIn: boolean; + requiresCheckIn: boolean; }) { return teams.reduce( (acc, cur) => { const team = this.teamById(cur); 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) - ) { + } else if (requiresCheckIn) { + const isCheckedIn = team.checkIns.some( + (checkIn) => + checkIn.bracketIdx === bracketIdx && !checkIn.isCheckOut, + ); + + if (isCheckedIn) { acc.checkedInTeams.push(cur); } else { acc.notCheckedInTeams.push(cur); } + } else { + const isCheckedOut = team.checkIns.some( + (checkIn) => + checkIn.bracketIdx === bracketIdx && checkIn.isCheckOut, + ); + + if (isCheckedOut) { + acc.notCheckedInTeams.push(cur); + } else { + acc.checkedInTeams.push(cur); + } } return acc; @@ -430,32 +470,48 @@ export class Tournament { } bracketSettings( - type: TournamentBracketProgression[number]["type"], + selectedSettings: TournamentStageSettings | null, + type: Tables["TournamentStage"]["type"], participantsCount: number, ): Stage["settings"] { switch (type) { - case "single_elimination": + case "single_elimination": { if (participantsCount < 4) { return { consolationFinal: false }; } - return { consolationFinal: this.ctx.settings.thirdPlaceMatch ?? true }; - case "double_elimination": + return { + consolationFinal: + selectedSettings?.thirdPlaceMatch ?? + this.ctx.settings.thirdPlaceMatch ?? + true, + }; + } + case "double_elimination": { return { grandFinal: "double", }; - case "round_robin": + } + case "round_robin": { + const teamsPerGroup = + selectedSettings?.teamsPerGroup ?? + this.ctx.settings.teamsPerGroup ?? + TOURNAMENT.DEFAULT_TEAM_COUNT_PER_RR_GROUP; + return { - groupCount: Math.ceil( - participantsCount / - (this.ctx.settings.teamsPerGroup ?? - TOURNAMENT.DEFAULT_TEAM_COUNT_PER_RR_GROUP), - ), + groupCount: Math.ceil(participantsCount / teamsPerGroup), seedOrdering: ["groups.seed_optimized"], }; + } case "swiss": { return { - swiss: this.ctx.settings.swiss, + swiss: + selectedSettings?.groupCount && selectedSettings.roundCount + ? { + groupCount: selectedSettings.groupCount, + roundCount: selectedSettings.roundCount, + } + : this.ctx.settings.swiss, }; } default: { @@ -591,11 +647,11 @@ export class Tournament { } get standings() { - for (const bracket of this.brackets) { - if (bracket.name === BRACKET_NAMES.MAIN) { - return bracket.standings; - } + if (this.brackets.length === 1) { + return this.brackets[0].standings; + } + for (const bracket of this.brackets) { if (bracket.isFinals) { const finalsStandings = bracket.standings; diff --git a/app/features/tournament-bracket/core/tests/mocks.ts b/app/features/tournament-bracket/core/tests/mocks.ts index 988c85dbe..4a224b9e9 100644 --- a/app/features/tournament-bracket/core/tests/mocks.ts +++ b/app/features/tournament-bracket/core/tests/mocks.ts @@ -1985,10 +1985,17 @@ export const PADDLING_POOL_257 = () => { name: "Group stage", type: "round_robin", + settings: { + teamsPerGroup: 4, + }, }, { name: "Alpha Bracket", type: "single_elimination", + requiresCheckIn: false, + settings: { + thirdPlaceMatch: true, + }, sources: [ { bracketIdx: 0, @@ -1999,6 +2006,10 @@ export const PADDLING_POOL_257 = () => { name: "Beta Btacket", type: "single_elimination", + requiresCheckIn: false, + settings: { + thirdPlaceMatch: true, + }, sources: [ { bracketIdx: 0, @@ -2007,8 +2018,6 @@ export const PADDLING_POOL_257 = () => ], }, ], - teamsPerGroup: 4, - thirdPlaceMatch: true, isRanked: true, }, discordUrl: null, @@ -2157,10 +2166,12 @@ export const PADDLING_POOL_257 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1709744421, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1709748010, }, ], @@ -2281,10 +2292,12 @@ export const PADDLING_POOL_257 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1709745740, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1709748010, }, ], @@ -2392,10 +2405,12 @@ export const PADDLING_POOL_257 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1709745184, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1709748010, }, ], @@ -2503,10 +2518,12 @@ export const PADDLING_POOL_257 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1709744449, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1709748010, }, ], @@ -2627,10 +2644,12 @@ export const PADDLING_POOL_257 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1709745518, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1709748010, }, ], @@ -2751,10 +2770,12 @@ export const PADDLING_POOL_257 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1709744823, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1709748010, }, ], @@ -2875,10 +2896,12 @@ export const PADDLING_POOL_257 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1709745022, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1709748010, }, ], @@ -2986,10 +3009,12 @@ export const PADDLING_POOL_257 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1709745881, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1709748010, }, ], @@ -3123,10 +3148,12 @@ export const PADDLING_POOL_257 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1709747762, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1709748010, }, ], @@ -3247,10 +3274,12 @@ export const PADDLING_POOL_257 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1709745432, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1709748010, }, ], @@ -3371,10 +3400,12 @@ export const PADDLING_POOL_257 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1709747270, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1709748010, }, ], @@ -3505,10 +3536,12 @@ export const PADDLING_POOL_257 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1709745210, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1709748010, }, ], @@ -3629,10 +3662,12 @@ export const PADDLING_POOL_257 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1709744780, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1709748010, }, ], @@ -3766,10 +3801,12 @@ export const PADDLING_POOL_257 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1709745686, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1709748010, }, ], @@ -3877,14 +3914,17 @@ export const PADDLING_POOL_257 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1709744515, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1709748010, }, { bracketIdx: 2, + isCheckOut: 0, checkedInAt: 1709752224, }, ], @@ -3992,14 +4032,17 @@ export const PADDLING_POOL_257 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1709746734, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1709748010, }, { bracketIdx: 2, + isCheckOut: 0, checkedInAt: 1709752183, }, ], @@ -4120,10 +4163,12 @@ export const PADDLING_POOL_257 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1709746000, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1709748010, }, ], @@ -4241,10 +4286,12 @@ export const PADDLING_POOL_257 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1709747494, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1709748010, }, ], @@ -4352,14 +4399,17 @@ export const PADDLING_POOL_257 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1709746233, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1709748010, }, { bracketIdx: 2, + isCheckOut: 0, checkedInAt: 1709752048, }, ], @@ -4464,10 +4514,12 @@ export const PADDLING_POOL_257 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1709745168, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1709748010, }, ], @@ -4575,10 +4627,12 @@ export const PADDLING_POOL_257 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1709745819, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1709748010, }, ], @@ -4686,10 +4740,12 @@ export const PADDLING_POOL_257 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1709744651, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1709748010, }, ], @@ -4797,14 +4853,17 @@ export const PADDLING_POOL_257 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1709744419, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1709748010, }, { bracketIdx: 2, + isCheckOut: 0, checkedInAt: 1709752471, }, ], @@ -4925,14 +4984,17 @@ export const PADDLING_POOL_257 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1709745527, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1709748010, }, { bracketIdx: 2, + isCheckOut: 0, checkedInAt: 1709752537, }, ], @@ -5037,14 +5099,17 @@ export const PADDLING_POOL_257 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1709744409, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1709748010, }, { bracketIdx: 2, + isCheckOut: 0, checkedInAt: 1709752696, }, ], @@ -5165,14 +5230,17 @@ export const PADDLING_POOL_257 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1709744680, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1709748010, }, { bracketIdx: 2, + isCheckOut: 0, checkedInAt: 1709751465, }, ], @@ -5293,10 +5361,12 @@ export const PADDLING_POOL_257 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1709745222, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1709748010, }, ], @@ -5404,10 +5474,12 @@ export const PADDLING_POOL_257 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1709746416, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1709748010, }, ], @@ -5541,10 +5613,12 @@ export const PADDLING_POOL_257 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1709746997, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1709748010, }, ], @@ -5649,14 +5723,17 @@ export const PADDLING_POOL_257 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1709744925, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1709748010, }, { bracketIdx: 2, + isCheckOut: 0, checkedInAt: 1709752730, }, ], @@ -5764,10 +5841,12 @@ export const PADDLING_POOL_257 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1709744524, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1709748010, }, ], @@ -5875,14 +5954,17 @@ export const PADDLING_POOL_257 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1709745142, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1709748010, }, { bracketIdx: 2, + isCheckOut: 0, checkedInAt: 1709752271, }, ], @@ -6003,14 +6085,17 @@ export const PADDLING_POOL_257 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1709747473, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1709748010, }, { bracketIdx: 2, + isCheckOut: 0, checkedInAt: 1709752784, }, ], @@ -6118,10 +6203,12 @@ export const PADDLING_POOL_257 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1709744935, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1709748010, }, ], @@ -6242,14 +6329,17 @@ export const PADDLING_POOL_257 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1709744853, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1709748010, }, { bracketIdx: 2, + isCheckOut: 0, checkedInAt: 1709751622, }, ], @@ -7896,6 +7986,8 @@ export const PADDLING_POOL_255 = () => { name: "Final stage", type: "single_elimination", + requiresCheckIn: false, + settings: {}, sources: [ { bracketIdx: 0, @@ -7906,6 +7998,8 @@ export const PADDLING_POOL_255 = () => { name: "Underground bracket", type: "single_elimination", + requiresCheckIn: false, + settings: {}, sources: [ { bracketIdx: 0, @@ -8058,10 +8152,12 @@ export const PADDLING_POOL_255 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1708535630, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1708538411, }, ], @@ -8169,10 +8265,12 @@ export const PADDLING_POOL_255 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1708537098, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1708538411, }, ], @@ -8293,10 +8391,12 @@ export const PADDLING_POOL_255 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1708535249, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1708538411, }, ], @@ -8414,10 +8514,12 @@ export const PADDLING_POOL_255 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1708538200, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1708538411, }, ], @@ -8525,10 +8627,12 @@ export const PADDLING_POOL_255 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1708537583, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1708538411, }, ], @@ -8649,10 +8753,12 @@ export const PADDLING_POOL_255 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1708535572, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1708538411, }, ], @@ -8760,10 +8866,12 @@ export const PADDLING_POOL_255 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1708536552, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1708538411, }, ], @@ -8868,10 +8976,12 @@ export const PADDLING_POOL_255 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1708535748, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1708538411, }, ], @@ -9005,10 +9115,12 @@ export const PADDLING_POOL_255 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1708535511, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1708538411, }, ], @@ -9116,10 +9228,12 @@ export const PADDLING_POOL_255 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1708537695, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1708538411, }, ], @@ -9237,10 +9351,12 @@ export const PADDLING_POOL_255 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1708535009, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1708538411, }, ], @@ -9361,10 +9477,12 @@ export const PADDLING_POOL_255 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1708535098, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1708538411, }, ], @@ -9495,10 +9613,12 @@ export const PADDLING_POOL_255 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1708535993, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1708538411, }, ], @@ -9606,10 +9726,12 @@ export const PADDLING_POOL_255 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1708535078, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1708538411, }, ], @@ -9730,10 +9852,12 @@ export const PADDLING_POOL_255 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1708537172, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1708538411, }, ], @@ -9841,10 +9965,12 @@ export const PADDLING_POOL_255 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1708536622, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1708538411, }, ], @@ -9952,10 +10078,12 @@ export const PADDLING_POOL_255 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1708534892, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1708538411, }, ], @@ -10073,10 +10201,12 @@ export const PADDLING_POOL_255 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1708537506, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1708538411, }, ], @@ -10197,10 +10327,12 @@ export const PADDLING_POOL_255 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1708535420, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1708538411, }, ], @@ -10308,14 +10440,17 @@ export const PADDLING_POOL_255 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1708534935, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1708538411, }, { bracketIdx: 2, + isCheckOut: 0, checkedInAt: 1708543301, }, ], @@ -10436,14 +10571,17 @@ export const PADDLING_POOL_255 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1708538374, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1708538411, }, { bracketIdx: 2, + isCheckOut: 0, checkedInAt: 1708543244, }, ], @@ -10564,14 +10702,17 @@ export const PADDLING_POOL_255 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1708536800, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1708538411, }, { bracketIdx: 2, + isCheckOut: 0, checkedInAt: 1708542803, }, ], @@ -10689,10 +10830,12 @@ export const PADDLING_POOL_255 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1708537322, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1708538411, }, ], @@ -10800,10 +10943,12 @@ export const PADDLING_POOL_255 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1708536634, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1708538411, }, ], @@ -10924,10 +11069,12 @@ export const PADDLING_POOL_255 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1708536541, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1708538411, }, ], @@ -11048,10 +11195,12 @@ export const PADDLING_POOL_255 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1708536812, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1708538411, }, ], @@ -11159,10 +11308,12 @@ export const PADDLING_POOL_255 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1708537379, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1708538411, }, ], @@ -11296,14 +11447,17 @@ export const PADDLING_POOL_255 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1708538059, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1708538411, }, { bracketIdx: 2, + isCheckOut: 0, checkedInAt: 1708543321, }, ], @@ -11424,10 +11578,12 @@ export const PADDLING_POOL_255 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1708534835, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1708538411, }, ], @@ -11548,14 +11704,17 @@ export const PADDLING_POOL_255 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1708534936, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1708538411, }, { bracketIdx: 2, + isCheckOut: 0, checkedInAt: 1708543391, }, ], @@ -11676,14 +11835,17 @@ export const PADDLING_POOL_255 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1708537625, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1708538411, }, { bracketIdx: 2, + isCheckOut: 0, checkedInAt: 1708543318, }, ], @@ -11788,10 +11950,12 @@ export const PADDLING_POOL_255 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1708536740, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1708538411, }, ], @@ -11899,14 +12063,17 @@ export const PADDLING_POOL_255 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1708537655, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1708538411, }, { bracketIdx: 2, + isCheckOut: 0, checkedInAt: 1708542789, }, ], @@ -12053,14 +12220,17 @@ export const PADDLING_POOL_255 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1708536232, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1708538411, }, { bracketIdx: 2, + isCheckOut: 0, checkedInAt: 1708543311, }, ], @@ -12194,10 +12364,12 @@ export const PADDLING_POOL_255 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1708536479, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1708538411, }, ], @@ -12741,7 +12913,10 @@ export const PADDLING_POOL_255_TOP_CUT_INITIAL_MATCHES = () => [ }, ]; -export const IN_THE_ZONE_32 = () => +export const IN_THE_ZONE_32 = ({ + undergroundRequiresCheckIn = true, + hasCheckedOutTeam = false, +}) => ({ data: { stage: [ @@ -14146,6 +14321,8 @@ export const IN_THE_ZONE_32 = () => { name: "Underground bracket", type: "single_elimination", + requiresCheckIn: undergroundRequiresCheckIn, + settings: {}, sources: [ { bracketIdx: 0, @@ -14274,6 +14451,7 @@ export const IN_THE_ZONE_32 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1707585473, }, ], @@ -14373,6 +14551,7 @@ export const IN_THE_ZONE_32 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1707584712, }, ], @@ -14472,6 +14651,7 @@ export const IN_THE_ZONE_32 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1707584440, }, ], @@ -14597,6 +14777,7 @@ export const IN_THE_ZONE_32 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1707585966, }, ], @@ -14709,6 +14890,7 @@ export const IN_THE_ZONE_32 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1707584938, }, ], @@ -14821,6 +15003,7 @@ export const IN_THE_ZONE_32 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1707585077, }, ], @@ -14946,6 +15129,7 @@ export const IN_THE_ZONE_32 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1707585480, }, ], @@ -15071,6 +15255,7 @@ export const IN_THE_ZONE_32 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1707585114, }, ], @@ -15170,6 +15355,7 @@ export const IN_THE_ZONE_32 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1707585711, }, ], @@ -15269,6 +15455,7 @@ export const IN_THE_ZONE_32 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1707585627, }, ], @@ -15368,6 +15555,7 @@ export const IN_THE_ZONE_32 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1707584566, }, ], @@ -15467,6 +15655,7 @@ export const IN_THE_ZONE_32 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1707584879, }, ], @@ -15579,6 +15768,7 @@ export const IN_THE_ZONE_32 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1707586430, }, ], @@ -15678,6 +15868,7 @@ export const IN_THE_ZONE_32 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1707584839, }, ], @@ -15777,6 +15968,7 @@ export const IN_THE_ZONE_32 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1707584487, }, ], @@ -15889,6 +16081,7 @@ export const IN_THE_ZONE_32 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1707584774, }, ], @@ -16001,10 +16194,12 @@ export const IN_THE_ZONE_32 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1707584763, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1707593849, }, ], @@ -16117,6 +16312,7 @@ export const IN_THE_ZONE_32 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1707587552, }, ], @@ -16242,6 +16438,7 @@ export const IN_THE_ZONE_32 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1707587686, }, ], @@ -16341,6 +16538,7 @@ export const IN_THE_ZONE_32 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1707584597, }, ], @@ -16466,10 +16664,12 @@ export const IN_THE_ZONE_32 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1707584623, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1707593753, }, ], @@ -16676,6 +16876,7 @@ export const IN_THE_ZONE_32 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1707587652, }, ], @@ -16775,6 +16976,7 @@ export const IN_THE_ZONE_32 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1707587079, }, ], @@ -16900,6 +17102,7 @@ export const IN_THE_ZONE_32 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1707586610, }, ], @@ -17012,6 +17215,7 @@ export const IN_THE_ZONE_32 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1707586360, }, ], @@ -17124,10 +17328,12 @@ export const IN_THE_ZONE_32 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1707587551, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1707592696, }, ], @@ -17240,6 +17446,7 @@ export const IN_THE_ZONE_32 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1707584843, }, ], @@ -17352,6 +17559,7 @@ export const IN_THE_ZONE_32 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1707587553, }, ], @@ -17474,6 +17682,7 @@ export const IN_THE_ZONE_32 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1707584415, }, ], @@ -17586,10 +17795,12 @@ export const IN_THE_ZONE_32 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1707584649, }, { bracketIdx: 1, + isCheckOut: 0, checkedInAt: 1707593906, }, ], @@ -17686,6 +17897,7 @@ export const IN_THE_ZONE_32 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1707587518, }, ], @@ -17798,9 +18010,17 @@ export const IN_THE_ZONE_32 = () => checkIns: [ { bracketIdx: null, + isCheckOut: 0, checkedInAt: 1707585754, }, - ], + hasCheckedOutTeam + ? { + bracketIdx: 1, + isCheckOut: 1, + checkedInAt: 1707585754, + } + : null, + ].filter((c) => c !== null), mapPool: [ { stageId: 6, diff --git a/app/features/tournament-bracket/core/tests/test-utils.ts b/app/features/tournament-bracket/core/tests/test-utils.ts index 1170880d5..e90c98d9c 100644 --- a/app/features/tournament-bracket/core/tests/test-utils.ts +++ b/app/features/tournament-bracket/core/tests/test-utils.ts @@ -1,6 +1,6 @@ -import { BRACKET_NAMES } from "~/features/tournament/tournament-constants"; import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types"; import { removeDuplicates } from "~/utils/arrays"; +import type * as Progression from "../Progression"; import { Tournament } from "../Tournament"; import type { TournamentData } from "../Tournament.server"; @@ -9,7 +9,7 @@ const tournamentCtxTeam = ( partial?: Partial, ): TournamentData["ctx"]["teams"][0] => { return { - checkIns: [{ checkedInAt: 1705858841, bracketIdx: null }], + checkIns: [{ checkedInAt: 1705858841, bracketIdx: null, isCheckOut: 0 }], createdAt: 0, id: teamId, inviteCode: null, @@ -77,7 +77,12 @@ export const testTournament = ({ mapPickingStyle: "AUTO_SZ", settings: { bracketProgression: [ - { name: BRACKET_NAMES.MAIN, type: "double_elimination" }, + { + name: "Main Bracket", + type: "double_elimination", + requiresCheckIn: false, + settings: {}, + }, ], }, castedMatchesInfo: null, @@ -143,3 +148,116 @@ export const adjustResults = ( }), }; }; + +const DEFAULT_PROGRESSION_ARGS = { + requiresCheckIn: false, + settings: {}, + name: "Main Bracket", +}; + +export const progressions = { + singleElimination: [ + { + ...DEFAULT_PROGRESSION_ARGS, + type: "single_elimination", + }, + ], + roundRobinToSingleElimination: [ + { + ...DEFAULT_PROGRESSION_ARGS, + type: "round_robin", + }, + { + ...DEFAULT_PROGRESSION_ARGS, + type: "single_elimination", + name: "B1", + sources: [ + { + bracketIdx: 0, + placements: [1, 2], + }, + ], + }, + ], + lowInk: [ + { + ...DEFAULT_PROGRESSION_ARGS, + type: "swiss", + }, + { + ...DEFAULT_PROGRESSION_ARGS, + name: "B1", + type: "double_elimination", + sources: [ + { + bracketIdx: 0, + placements: [3, 4], + }, + ], + }, + { + ...DEFAULT_PROGRESSION_ARGS, + name: "B2", + type: "round_robin", + sources: [ + { + bracketIdx: 0, + placements: [1, 2], + }, + ], + }, + { + ...DEFAULT_PROGRESSION_ARGS, + name: "B3", + type: "double_elimination", + sources: [ + { + bracketIdx: 2, + placements: [1, 2], + }, + ], + }, + ], + manyStartBrackets: [ + { + ...DEFAULT_PROGRESSION_ARGS, + type: "round_robin", + }, + { + ...DEFAULT_PROGRESSION_ARGS, + type: "round_robin", + name: "B1", + }, + { + ...DEFAULT_PROGRESSION_ARGS, + type: "single_elimination", + name: "B2", + sources: [ + { + bracketIdx: 0, + placements: [1, 2], + }, + ], + }, + { + ...DEFAULT_PROGRESSION_ARGS, + type: "single_elimination", + name: "B3", + sources: [ + { + bracketIdx: 1, + placements: [1, 2], + }, + ], + }, + ], + swissOneGroup: [ + { + ...DEFAULT_PROGRESSION_ARGS, + type: "swiss", + settings: { + groupCount: 1, + }, + }, + ], +} satisfies Record; diff --git a/app/features/tournament-bracket/core/toMapList.ts b/app/features/tournament-bracket/core/toMapList.ts index 7b19aa0ac..7f96a2e9f 100644 --- a/app/features/tournament-bracket/core/toMapList.ts +++ b/app/features/tournament-bracket/core/toMapList.ts @@ -2,10 +2,7 @@ import clone from "just-clone"; import shuffle from "just-shuffle"; -import type { - TournamentBracketProgression, - TournamentRoundMaps, -} from "~/db/tables"; +import type { Tables, TournamentRoundMaps } from "~/db/tables"; import type { Round } from "~/modules/brackets-model"; import type { ModeShort, StageId } from "~/modules/in-game-lists"; import { SENDOUQ_DEFAULT_MAPS } from "~/modules/tournament-map-list-generator/constants"; @@ -24,7 +21,7 @@ export interface GenerateTournamentRoundMaplistArgs { pool: Array<{ mode: ModeShort; stageId: StageId }>; rounds: Round[]; mapCounts: BracketMapCounts; - type: TournamentBracketProgression[number]["type"]; + type: Tables["TournamentStage"]["type"]; roundsWithPickBan: Set; pickBanStyle: TournamentRoundMaps["pickBan"]; flavor: "SZ_FIRST" | null; @@ -101,7 +98,7 @@ export function generateTournamentRoundMaplist( function getFilteredRounds( rounds: Round[], - type: TournamentBracketProgression[number]["type"], + type: Tables["TournamentStage"]["type"], ) { if (type !== "round_robin" && type !== "swiss") return rounds; @@ -110,10 +107,7 @@ function getFilteredRounds( return rounds.filter((x) => x.group_id === highestGroupId); } -function sortRounds( - rounds: Round[], - type: TournamentBracketProgression[number]["type"], -) { +function sortRounds(rounds: Round[], type: Tables["TournamentStage"]["type"]) { return rounds.slice().sort((a, b) => { if (type === "double_elimination") { // grands last @@ -133,7 +127,7 @@ function sortRounds( function resolveRoundMapCount( round: Round, counts: BracketMapCounts, - type: TournamentBracketProgression[number]["type"], + type: Tables["TournamentStage"]["type"], ) { // with rr/swiss we just take the first group id // as every group has the same map list diff --git a/app/features/tournament-bracket/routes/to.$id.brackets.tsx b/app/features/tournament-bracket/routes/to.$id.brackets.tsx index c9a7ed1af..6cd8a9267 100644 --- a/app/features/tournament-bracket/routes/to.$id.brackets.tsx +++ b/app/features/tournament-bracket/routes/to.$id.brackets.tsx @@ -1,6 +1,7 @@ import type { ActionFunction } from "@remix-run/node"; import { useRevalidator } from "@remix-run/react"; import clsx from "clsx"; +import { add } from "date-fns"; import * as React from "react"; import { ErrorBoundary } from "react-error-boundary"; import { useTranslation } from "react-i18next"; @@ -28,13 +29,11 @@ import { currentSeason } from "~/features/mmr/season"; import { refreshUserSkills } from "~/features/mmr/tiered.server"; import { TOURNAMENT, tournamentIdFromParams } from "~/features/tournament"; import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; -import { checkInMany } from "~/features/tournament/queries/checkInMany.server"; import { createSwissBracketInTransaction } from "~/features/tournament/queries/createSwissBracketInTransaction.server"; import { updateRoundMaps } from "~/features/tournament/queries/updateRoundMaps.server"; import { useIsMounted } from "~/hooks/useIsMounted"; import { useSearchParamState } from "~/hooks/useSearchParamState"; import { useVisibilityChange } from "~/hooks/useVisibilityChange"; -import { nullFilledArray } from "~/utils/arrays"; import invariant from "~/utils/invariant"; import { logger } from "~/utils/logger"; import { parseRequestPayload, validate } from "~/utils/remix.server"; @@ -112,6 +111,7 @@ export const action: ActionFunction = async ({ params, request }) => { seeding, tournamentId, settings: tournament.bracketSettings( + bracket.settings, bracket.type, seeding.length, ), @@ -126,6 +126,7 @@ export const action: ActionFunction = async ({ params, request }) => { ? seeding : fillWithNullTillPowerOfTwo(seeding), settings: tournament.bracketSettings( + bracket.settings, bracket.type, seeding.length, ), @@ -139,32 +140,6 @@ export const action: ActionFunction = async ({ params, request }) => { bracket, }), ); - - // 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.isFinals, - ); - - if (finalStageIdx !== -1) { - const allFollowUpBracketIdxs = nullFilledArray( - tournament.brackets.length, - ) - .map((_, i) => i) - // filter out groups stage - .filter((i) => i !== 0); - - checkInMany({ - bracketIdxs: tournament.ctx.settings.autoCheckInAll - ? allFollowUpBracketIdxs - : [finalStageIdx], - tournamentTeamIds: tournament.ctx.teams - .filter((t) => t.checkIns.length > 0) - .map((t) => t.id), - }); - } - } })(); break; @@ -483,9 +458,28 @@ export default function TournamentBracketsPage() {
) : null} {bracket.sources?.every((s) => !s.placements.includes(1)) && - !tournament.ctx.settings.autoCheckInAll ? ( + bracket.checkInRequired ? (
- Bracket requires check-in + Bracket requires check-in{" "} + {bracket.startTime ? ( + + (open{" "} + {bracket.startTime.toLocaleString("en-US", { + hour: "numeric", + minute: "numeric", + weekday: "long", + })}{" "} + -{" "} + {add(bracket.startTime, { hours: 1 }).toLocaleTimeString( + "en-US", + { + hour: "numeric", + minute: "numeric", + }, + )} + ) + + ) : null}
) : null}
diff --git a/app/features/tournament/TournamentRepository.server.ts b/app/features/tournament/TournamentRepository.server.ts index d3f928f0e..0432bd4b5 100644 --- a/app/features/tournament/TournamentRepository.server.ts +++ b/app/features/tournament/TournamentRepository.server.ts @@ -2,7 +2,14 @@ import { type Insertable, type NotNull, type Transaction, sql } from "kysely"; import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite"; import { nanoid } from "nanoid"; import { db } from "~/db/sql"; -import type { CastedMatchesInfo, DB, PreparedMaps, Tables } from "~/db/tables"; +import type { + CastedMatchesInfo, + DB, + PreparedMaps, + Tables, + TournamentSettings, +} from "~/db/tables"; +import * as Progression from "~/features/tournament-bracket/core/Progression"; import { Status } from "~/modules/brackets-model"; import { modesShort } from "~/modules/in-game-lists"; import { nullFilledArray } from "~/utils/arrays"; @@ -177,6 +184,7 @@ export async function findById(id: number) { .select([ "TournamentTeamCheckIn.bracketIdx", "TournamentTeamCheckIn.checkedInAt", + "TournamentTeamCheckIn.isCheckOut", ]) .whereRef( "TournamentTeamCheckIn.tournamentTeamId", @@ -541,14 +549,27 @@ export function checkIn({ tournamentTeamId: number; bracketIdx: number | null; }) { - return db - .insertInto("TournamentTeamCheckIn") - .values({ - checkedInAt: dateToDatabaseTimestamp(new Date()), - tournamentTeamId, - bracketIdx, - }) - .execute(); + return db.transaction().execute(async (trx) => { + let query = trx + .deleteFrom("TournamentTeamCheckIn") + .where("TournamentTeamCheckIn.tournamentTeamId", "=", tournamentTeamId) + .where("TournamentTeamCheckIn.isCheckOut", "=", 1); + + if (typeof bracketIdx === "number") { + query = query.where("TournamentTeamCheckIn.bracketIdx", "=", bracketIdx); + } + + await query.execute(); + + await trx + .insertInto("TournamentTeamCheckIn") + .values({ + checkedInAt: dateToDatabaseTimestamp(new Date()), + tournamentTeamId, + bracketIdx, + }) + .execute(); + }); } export function checkOut({ @@ -558,15 +579,90 @@ export function checkOut({ tournamentTeamId: number; bracketIdx: number | null; }) { - let query = db - .deleteFrom("TournamentTeamCheckIn") - .where("TournamentTeamCheckIn.tournamentTeamId", "=", tournamentTeamId); + return db.transaction().execute(async (trx) => { + let query = trx + .deleteFrom("TournamentTeamCheckIn") + .where("TournamentTeamCheckIn.tournamentTeamId", "=", tournamentTeamId); - if (typeof bracketIdx === "number") { - query = query.where("TournamentTeamCheckIn.bracketIdx", "=", bracketIdx); - } + if (typeof bracketIdx === "number") { + query = query.where("TournamentTeamCheckIn.bracketIdx", "=", bracketIdx); + } - return query.execute(); + await query.execute(); + + if (typeof bracketIdx === "number") { + await trx + .insertInto("TournamentTeamCheckIn") + .values({ + checkedInAt: dateToDatabaseTimestamp(new Date()), + tournamentTeamId, + bracketIdx, + isCheckOut: 1, + }) + .execute(); + } + }); +} + +export function updateProgression({ + tournamentId, + bracketProgression, +}: { + tournamentId: number; + bracketProgression: TournamentSettings["bracketProgression"]; +}) { + return db.transaction().execute(async (trx) => { + const { settings: existingSettings } = await trx + .selectFrom("Tournament") + .select("settings") + .where("id", "=", tournamentId) + .executeTakeFirstOrThrow(); + + if ( + Progression.changedBracketProgressionFormat( + existingSettings.bracketProgression, + bracketProgression, + ) + ) { + const allTournamentTeamsOfTournament = ( + await trx + .selectFrom("TournamentTeam") + .select("id") + .where("tournamentId", "=", tournamentId) + .execute() + ).map((t) => t.id); + + // delete all bracket check-ins + await trx + .deleteFrom("TournamentTeamCheckIn") + .where("TournamentTeamCheckIn.bracketIdx", "is not", null) + .where( + "TournamentTeamCheckIn.tournamentTeamId", + "in", + allTournamentTeamsOfTournament, + ) + .execute(); + } + + const newSettings: Tables["Tournament"]["settings"] = { + ...existingSettings, + bracketProgression, + }; + + await trx + .updateTable("Tournament") + .set({ + settings: JSON.stringify(newSettings), + preparedMaps: Progression.changedBracketProgressionFormat( + existingSettings.bracketProgression, + bracketProgression, + ) + ? null + : undefined, + }) + .where("id", "=", tournamentId) + .execute(); + }); } export function updateTeamName({ diff --git a/app/features/tournament/actions/to.$id.admin.server.ts b/app/features/tournament/actions/to.$id.admin.server.ts new file mode 100644 index 000000000..8afb027b6 --- /dev/null +++ b/app/features/tournament/actions/to.$id.admin.server.ts @@ -0,0 +1,472 @@ +import type { ActionFunction } from "@remix-run/node"; +import { z } from "zod"; +import { requireUserId } from "~/features/auth/core/user.server"; +import { userIsBanned } from "~/features/ban/core/banned.server"; +import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server"; +import * as Progression from "~/features/tournament-bracket/core/Progression"; +import { + clearTournamentDataCache, + tournamentFromDB, +} from "~/features/tournament-bracket/core/Tournament.server"; +import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server"; +import invariant from "~/utils/invariant"; +import { logger } from "~/utils/logger"; +import { + badRequestIfFalsy, + parseRequestPayload, + validate, +} from "~/utils/remix.server"; +import { assertUnreachable } from "~/utils/types"; +import { USER } from "../../../constants"; +import { _action, id } from "../../../utils/zod"; +import { bracketProgressionSchema } from "../../calendar/actions/calendar.new.server"; +import { bracketIdx } from "../../tournament-bracket/tournament-bracket-schemas.server"; +import * as TournamentRepository from "../TournamentRepository.server"; +import { changeTeamOwner } from "../queries/changeTeamOwner.server"; +import { deleteTeam } from "../queries/deleteTeam.server"; +import { joinTeam, leaveTeam } from "../queries/joinLeaveTeam.server"; +import { teamName } from "../tournament-schemas.server"; +import { tournamentIdFromParams } from "../tournament-utils"; +import { inGameNameIfNeeded } from "../tournament-utils.server"; + +export const action: ActionFunction = async ({ request, params }) => { + const user = await requireUserId(request); + const data = await parseRequestPayload({ + request, + schema: adminActionSchema, + }); + + const tournamentId = tournamentIdFromParams(params); + const tournament = await tournamentFromDB({ tournamentId, user }); + + const validateIsTournamentAdmin = () => + validate(tournament.isAdmin(user), "Unauthorized", 401); + const validateIsTournamentOrganizer = () => + validate(tournament.isOrganizer(user), "Unauthorized", 401); + + switch (data._action) { + case "ADD_TEAM": { + validateIsTournamentOrganizer(); + validate( + tournament.ctx.teams.every((t) => t.name !== data.teamName), + "Team name taken", + ); + validate( + !tournament.teamMemberOfByUser({ id: data.userId }), + "User already on a team", + ); + + await TournamentTeamRepository.create({ + ownerInGameName: await inGameNameIfNeeded({ + tournament, + userId: data.userId, + }), + team: { + name: data.teamName, + noScreen: 0, + prefersNotToHost: 0, + teamId: null, + }, + userId: data.userId, + tournamentId, + }); + + ShowcaseTournaments.addToParticipationInfoMap({ + tournamentId, + type: "participant", + userId: data.userId, + }); + + break; + } + case "CHANGE_TEAM_OWNER": { + validateIsTournamentOrganizer(); + 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"); + const newCaptain = team.members.find((m) => m.userId === data.memberId); + validate(newCaptain, "Invalid member id"); + + changeTeamOwner({ + newCaptainId: data.memberId, + oldCaptainId: oldCaptain.userId, + tournamentTeamId: data.teamId, + }); + + break; + } + case "CHANGE_TEAM_NAME": { + validateIsTournamentOrganizer(); + const team = tournament.teamById(data.teamId); + validate(team, "Invalid team id"); + + await TournamentRepository.updateTeamName({ + tournamentTeamId: data.teamId, + name: data.teamName, + }); + break; + } + case "CHECK_IN": { + validateIsTournamentOrganizer(); + const team = tournament.teamById(data.teamId); + validate(team, "Invalid team id"); + validate( + data.bracketIdx !== 0 || + tournament.checkInConditionsFulfilledByTeamId(team.id), + "Can't check-in", + ); + validate( + team.checkIns.length > 0 || data.bracketIdx === 0, + "Can't check-in to follow up bracket if not checked in for the event itself", + ); + + const bracket = tournament.bracketByIdx(data.bracketIdx); + invariant(bracket, "Invalid bracket idx"); + validate(bracket.preview, "Bracket has been started"); + + await TournamentRepository.checkIn({ + tournamentTeamId: data.teamId, + // no sources = regular check in + bracketIdx: !bracket.sources ? null : data.bracketIdx, + }); + break; + } + case "CHECK_OUT": { + validateIsTournamentOrganizer(); + const team = tournament.teamById(data.teamId); + validate(team, "Invalid team id"); + validate( + data.bracketIdx !== 0 || !tournament.hasStarted, + "Tournament has started", + ); + + const bracket = tournament.bracketByIdx(data.bracketIdx); + invariant(bracket, "Invalid bracket idx"); + validate(bracket.preview, "Bracket has been started"); + + await TournamentRepository.checkOut({ + tournamentTeamId: data.teamId, + // no sources = regular check in + bracketIdx: !bracket.sources ? null : data.bracketIdx, + }); + logger.info( + `Checked out: tournament team id: ${data.teamId} - user id: ${user.id} - tournament id: ${tournamentId} - bracket idx: ${data.bracketIdx}`, + ); + break; + } + case "REMOVE_MEMBER": { + validateIsTournamentOrganizer(); + const team = tournament.teamById(data.teamId); + validate(team, "Invalid team id"); + validate( + team.checkIns.length === 0 || team.members.length > 4, + "Can't remove last member from checked in team", + ); + validate( + !team.members.find((m) => m.userId === data.memberId)?.isOwner, + "Cannot remove team owner", + ); + validate( + !tournament.hasStarted || + !tournament + .participatedPlayersByTeamId(data.teamId) + .some((p) => p.userId === data.memberId), + "Cannot remove player that has participated in the tournament", + ); + + leaveTeam({ + userId: data.memberId, + teamId: team.id, + }); + + ShowcaseTournaments.removeFromParticipationInfoMap({ + tournamentId, + type: "participant", + userId: data.memberId, + }); + + break; + } + case "ADD_MEMBER": { + validateIsTournamentOrganizer(); + const team = tournament.teamById(data.teamId); + validate(team, "Invalid team id"); + + const previousTeam = tournament.teamMemberOfByUser({ id: data.userId }); + + if (tournament.hasStarted) { + validate( + !previousTeam || previousTeam.checkIns.length === 0, + "User is already on a checked in team", + ); + } else { + validate(!previousTeam, "User is already on a team"); + } + + validate( + !userIsBanned(data.userId), + "User trying to be added currently has an active ban from sendou.ink", + ); + + joinTeam({ + userId: data.userId, + newTeamId: team.id, + previousTeamId: previousTeam?.id, + // this team is not checked in so we can simply delete it + whatToDoWithPreviousTeam: previousTeam ? "DELETE" : undefined, + tournamentId, + inGameName: await inGameNameIfNeeded({ + tournament, + userId: data.userId, + }), + }); + + ShowcaseTournaments.addToParticipationInfoMap({ + tournamentId, + type: "participant", + userId: data.userId, + }); + + break; + } + case "DELETE_TEAM": { + validateIsTournamentOrganizer(); + const team = tournament.teamById(data.teamId); + validate(team, "Invalid team id"); + validate(!tournament.hasStarted, "Tournament has started"); + + deleteTeam(team.id); + + ShowcaseTournaments.clearParticipationInfoMap(); + + break; + } + case "ADD_STAFF": { + validateIsTournamentAdmin(); + + await TournamentRepository.addStaff({ + role: data.role, + tournamentId: tournament.ctx.id, + userId: data.userId, + }); + + if (data.role === "ORGANIZER") { + ShowcaseTournaments.addToParticipationInfoMap({ + tournamentId, + type: "organizer", + userId: data.userId, + }); + } + + break; + } + case "REMOVE_STAFF": { + validateIsTournamentAdmin(); + + await TournamentRepository.removeStaff({ + tournamentId: tournament.ctx.id, + userId: data.userId, + }); + + ShowcaseTournaments.removeFromParticipationInfoMap({ + tournamentId, + type: "organizer", + userId: data.userId, + }); + + break; + } + case "UPDATE_CAST_TWITCH_ACCOUNTS": { + validateIsTournamentOrganizer(); + await TournamentRepository.updateCastTwitchAccounts({ + tournamentId: tournament.ctx.id, + castTwitchAccounts: data.castTwitchAccounts, + }); + break; + } + case "DROP_TEAM_OUT": { + validateIsTournamentOrganizer(); + await TournamentRepository.dropTeamOut({ + tournamentTeamId: data.teamId, + previewBracketIdxs: tournament.brackets.flatMap((b, idx) => + b.preview ? idx : [], + ), + }); + break; + } + case "UNDO_DROP_TEAM_OUT": { + validateIsTournamentOrganizer(); + + await TournamentRepository.undoDropTeamOut(data.teamId); + break; + } + case "RESET_BRACKET": { + validateIsTournamentOrganizer(); + validate(!tournament.ctx.isFinalized, "Tournament is finalized"); + + const bracketToResetIdx = tournament.brackets.findIndex( + (b) => b.id === data.stageId, + ); + const bracketToReset = tournament.brackets[bracketToResetIdx]; + validate(bracketToReset, "Invalid bracket id"); + validate(!bracketToReset.preview, "Bracket has not started"); + + const inProgressBrackets = tournament.brackets.filter((b) => !b.preview); + validate( + inProgressBrackets.every( + (b) => + !b.sources || + b.sources.every((s) => s.bracketIdx !== bracketToResetIdx), + ), + "Some bracket that sources teams from this bracket has started", + ); + + await TournamentRepository.resetBracket(data.stageId); + + break; + } + case "UPDATE_IN_GAME_NAME": { + validateIsTournamentOrganizer(); + + const teamMemberOf = badRequestIfFalsy( + tournament.teamMemberOfByUser({ id: data.memberId }), + ); + + await TournamentTeamRepository.updateMemberInGameName({ + userId: data.memberId, + inGameName: `${data.inGameNameText}#${data.inGameNameDiscriminator}`, + tournamentTeamId: teamMemberOf.id, + }); + break; + } + case "DELETE_LOGO": { + validateIsTournamentOrganizer(); + + await TournamentTeamRepository.deleteLogo(data.teamId); + + break; + } + case "UPDATE_TOURNAMENT_PROGRESSION": { + validateIsTournamentOrganizer(); + validate(!tournament.ctx.isFinalized, "Tournament is finalized"); + + validate( + Progression.changedBracketProgression( + tournament.ctx.settings.bracketProgression, + data.bracketProgression, + ).every( + (changedBracketIdx) => + tournament.bracketByIdx(changedBracketIdx)?.preview, + ), + "Can't change started brackets", + ); + + await TournamentRepository.updateProgression({ + tournamentId: tournament.ctx.id, + bracketProgression: data.bracketProgression, + }); + + break; + } + default: { + assertUnreachable(data); + } + } + + clearTournamentDataCache(tournamentId); + + return null; +}; + +export const adminActionSchema = z.union([ + z.object({ + _action: _action("CHANGE_TEAM_OWNER"), + teamId: id, + memberId: id, + }), + z.object({ + _action: _action("CHANGE_TEAM_NAME"), + teamId: id, + teamName, + }), + z.object({ + _action: _action("CHECK_IN"), + teamId: id, + bracketIdx, + }), + z.object({ + _action: _action("CHECK_OUT"), + teamId: id, + bracketIdx, + }), + z.object({ + _action: _action("ADD_MEMBER"), + teamId: id, + userId: id, + }), + z.object({ + _action: _action("REMOVE_MEMBER"), + teamId: id, + memberId: id, + }), + z.object({ + _action: _action("DELETE_TEAM"), + teamId: id, + }), + z.object({ + _action: _action("ADD_TEAM"), + userId: id, + teamName, + }), + z.object({ + _action: _action("ADD_STAFF"), + userId: id, + role: z.enum(["ORGANIZER", "STREAMER"]), + }), + z.object({ + _action: _action("REMOVE_STAFF"), + userId: id, + }), + z.object({ + _action: _action("DROP_TEAM_OUT"), + teamId: id, + }), + z.object({ + _action: _action("UNDO_DROP_TEAM_OUT"), + teamId: id, + }), + z.object({ + _action: _action("DELETE_LOGO"), + teamId: id, + }), + z.object({ + _action: _action("UPDATE_CAST_TWITCH_ACCOUNTS"), + castTwitchAccounts: z.preprocess( + (val) => + typeof val === "string" + ? val + .split(",") + .map((account) => account.trim()) + .map((account) => account.toLowerCase()) + : val, + z.array(z.string()), + ), + }), + z.object({ + _action: _action("RESET_BRACKET"), + stageId: id, + }), + z.object({ + _action: _action("UPDATE_IN_GAME_NAME"), + inGameNameText: z.string().max(USER.IN_GAME_NAME_TEXT_MAX_LENGTH), + inGameNameDiscriminator: z + .string() + .refine((val) => /^[0-9a-z]{4,5}$/.test(val)), + memberId: id, + }), + z.object({ + _action: _action("UPDATE_TOURNAMENT_PROGRESSION"), + bracketProgression: bracketProgressionSchema, + }), +]); diff --git a/app/features/tournament/queries/checkInMany.server.ts b/app/features/tournament/queries/checkInMany.server.ts deleted file mode 100644 index 2edf90d3d..000000000 --- a/app/features/tournament/queries/checkInMany.server.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { sql } from "~/db/sql"; -import { dateToDatabaseTimestamp } from "~/utils/dates"; - -const stm = sql.prepare(/*sql*/ ` - insert into "TournamentTeamCheckIn" ("checkedInAt", "tournamentTeamId", "bracketIdx") - values (@checkedInAt, @tournamentTeamId, @bracketIdx) -`); - -export function checkInMany({ - tournamentTeamIds, - bracketIdxs, -}: { - tournamentTeamIds: number[]; - bracketIdxs: number[]; -}) { - for (const bracketIdx of bracketIdxs) { - for (const tournamentTeamId of tournamentTeamIds) { - stm.run({ - checkedInAt: dateToDatabaseTimestamp(new Date()), - tournamentTeamId, - bracketIdx, - }); - } - } -} diff --git a/app/features/tournament/routes/to.$id.admin.tsx b/app/features/tournament/routes/to.$id.admin.tsx index 1ad863df7..a478e9985 100644 --- a/app/features/tournament/routes/to.$id.admin.tsx +++ b/app/features/tournament/routes/to.$id.admin.tsx @@ -1,4 +1,3 @@ -import type { ActionFunction } from "@remix-run/node"; import { useFetcher } from "@remix-run/react"; import clsx from "clsx"; import * as React from "react"; @@ -17,373 +16,35 @@ import { UserSearch } from "~/components/UserSearch"; import { TrashIcon } from "~/components/icons/Trash"; import { USER } from "~/constants"; import { useUser } from "~/features/auth/core/user"; -import { requireUserId } from "~/features/auth/core/user.server"; -import { userIsBanned } from "~/features/ban/core/banned.server"; -import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server"; +import * as Progression from "~/features/tournament-bracket/core/Progression"; import type { TournamentData } from "~/features/tournament-bracket/core/Tournament.server"; -import { - clearTournamentDataCache, - tournamentFromDB, -} from "~/features/tournament-bracket/core/Tournament.server"; -import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server"; import { databaseTimestampToDate } from "~/utils/dates"; import invariant from "~/utils/invariant"; -import { logger } from "~/utils/logger"; -import { - badRequestIfFalsy, - parseRequestPayload, - validate, -} from "~/utils/remix.server"; import { assertUnreachable } from "~/utils/types"; import { calendarEventPage, tournamentEditPage, tournamentPage, } from "~/utils/urls"; -import * as TournamentRepository from "../TournamentRepository.server"; -import { changeTeamOwner } from "../queries/changeTeamOwner.server"; -import { deleteTeam } from "../queries/deleteTeam.server"; -import { joinTeam, leaveTeam } from "../queries/joinLeaveTeam.server"; -import { adminActionSchema } from "../tournament-schemas.server"; -import { tournamentIdFromParams } from "../tournament-utils"; -import { inGameNameIfNeeded } from "../tournament-utils.server"; +import { Dialog } from "../../../components/Dialog"; +import { BracketProgressionSelector } from "../../calendar/components/BracketProgressionSelector"; import { useTournament } from "./to.$id"; -export const action: ActionFunction = async ({ request, params }) => { - const user = await requireUserId(request); - const data = await parseRequestPayload({ - request, - schema: adminActionSchema, - }); +import { action } from "../actions/to.$id.admin.server"; +export { action }; - const tournamentId = tournamentIdFromParams(params); - const tournament = await tournamentFromDB({ tournamentId, user }); - - const validateIsTournamentAdmin = () => - validate(tournament.isAdmin(user), "Unauthorized", 401); - const validateIsTournamentOrganizer = () => - validate(tournament.isOrganizer(user), "Unauthorized", 401); - - switch (data._action) { - case "ADD_TEAM": { - validateIsTournamentOrganizer(); - validate( - tournament.ctx.teams.every((t) => t.name !== data.teamName), - "Team name taken", - ); - validate( - !tournament.teamMemberOfByUser({ id: data.userId }), - "User already on a team", - ); - - await TournamentTeamRepository.create({ - ownerInGameName: await inGameNameIfNeeded({ - tournament, - userId: data.userId, - }), - team: { - name: data.teamName, - noScreen: 0, - prefersNotToHost: 0, - teamId: null, - }, - userId: data.userId, - tournamentId, - }); - - ShowcaseTournaments.addToParticipationInfoMap({ - tournamentId, - type: "participant", - userId: data.userId, - }); - - break; - } - case "CHANGE_TEAM_OWNER": { - validateIsTournamentOrganizer(); - 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"); - const newCaptain = team.members.find((m) => m.userId === data.memberId); - validate(newCaptain, "Invalid member id"); - - changeTeamOwner({ - newCaptainId: data.memberId, - oldCaptainId: oldCaptain.userId, - tournamentTeamId: data.teamId, - }); - - break; - } - case "CHANGE_TEAM_NAME": { - validateIsTournamentOrganizer(); - const team = tournament.teamById(data.teamId); - validate(team, "Invalid team id"); - - await TournamentRepository.updateTeamName({ - tournamentTeamId: data.teamId, - name: data.teamName, - }); - break; - } - case "CHECK_IN": { - validateIsTournamentOrganizer(); - const team = tournament.teamById(data.teamId); - validate(team, "Invalid team id"); - validate( - data.bracketIdx !== 0 || - tournament.checkInConditionsFulfilledByTeamId(team.id), - "Can't check-in", - ); - validate( - team.checkIns.length > 0 || data.bracketIdx === 0, - "Can't check-in to follow up bracket if not checked in for the event itself", - ); - - 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 = tournament.teamById(data.teamId); - validate(team, "Invalid team id"); - validate( - data.bracketIdx !== 0 || !tournament.hasStarted, - "Tournament has started", - ); - - 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, - }); - logger.info( - `Checked out: tournament team id: ${data.teamId} - user id: ${user.id} - tournament id: ${tournamentId} - bracket idx: ${data.bracketIdx}`, - ); - break; - } - case "REMOVE_MEMBER": { - validateIsTournamentOrganizer(); - const team = tournament.teamById(data.teamId); - validate(team, "Invalid team id"); - validate( - team.checkIns.length === 0 || team.members.length > 4, - "Can't remove last member from checked in team", - ); - validate( - !team.members.find((m) => m.userId === data.memberId)?.isOwner, - "Cannot remove team owner", - ); - validate( - !tournament.hasStarted || - !tournament - .participatedPlayersByTeamId(data.teamId) - .some((p) => p.userId === data.memberId), - "Cannot remove player that has participated in the tournament", - ); - - leaveTeam({ - userId: data.memberId, - teamId: team.id, - }); - - ShowcaseTournaments.removeFromParticipationInfoMap({ - tournamentId, - type: "participant", - userId: data.memberId, - }); - - break; - } - case "ADD_MEMBER": { - validateIsTournamentOrganizer(); - const team = tournament.teamById(data.teamId); - validate(team, "Invalid team id"); - - const previousTeam = tournament.teamMemberOfByUser({ id: data.userId }); - - if (tournament.hasStarted) { - validate( - !previousTeam || previousTeam.checkIns.length === 0, - "User is already on a checked in team", - ); - } else { - validate(!previousTeam, "User is already on a team"); - } - - validate( - !userIsBanned(data.userId), - "User trying to be added currently has an active ban from sendou.ink", - ); - - joinTeam({ - userId: data.userId, - newTeamId: team.id, - previousTeamId: previousTeam?.id, - // this team is not checked in so we can simply delete it - whatToDoWithPreviousTeam: previousTeam ? "DELETE" : undefined, - tournamentId, - inGameName: await inGameNameIfNeeded({ - tournament, - userId: data.userId, - }), - }); - - ShowcaseTournaments.addToParticipationInfoMap({ - tournamentId, - type: "participant", - userId: data.userId, - }); - - break; - } - case "DELETE_TEAM": { - validateIsTournamentOrganizer(); - const team = tournament.teamById(data.teamId); - validate(team, "Invalid team id"); - validate(!tournament.hasStarted, "Tournament has started"); - - deleteTeam(team.id); - - ShowcaseTournaments.clearParticipationInfoMap(); - - break; - } - case "ADD_STAFF": { - validateIsTournamentAdmin(); - - await TournamentRepository.addStaff({ - role: data.role, - tournamentId: tournament.ctx.id, - userId: data.userId, - }); - - if (data.role === "ORGANIZER") { - ShowcaseTournaments.addToParticipationInfoMap({ - tournamentId, - type: "organizer", - userId: data.userId, - }); - } - - break; - } - case "REMOVE_STAFF": { - validateIsTournamentAdmin(); - - await TournamentRepository.removeStaff({ - tournamentId: tournament.ctx.id, - userId: data.userId, - }); - - ShowcaseTournaments.removeFromParticipationInfoMap({ - tournamentId, - type: "organizer", - userId: data.userId, - }); - - break; - } - case "UPDATE_CAST_TWITCH_ACCOUNTS": { - validateIsTournamentOrganizer(); - await TournamentRepository.updateCastTwitchAccounts({ - tournamentId: tournament.ctx.id, - castTwitchAccounts: data.castTwitchAccounts, - }); - break; - } - case "DROP_TEAM_OUT": { - validateIsTournamentOrganizer(); - await TournamentRepository.dropTeamOut({ - tournamentTeamId: data.teamId, - previewBracketIdxs: tournament.brackets.flatMap((b, idx) => - b.preview ? idx : [], - ), - }); - break; - } - case "UNDO_DROP_TEAM_OUT": { - validateIsTournamentOrganizer(); - - await TournamentRepository.undoDropTeamOut(data.teamId); - break; - } - case "RESET_BRACKET": { - validateIsTournamentOrganizer(); - validate(!tournament.ctx.isFinalized, "Tournament is finalized"); - - const bracketToResetIdx = tournament.brackets.findIndex( - (b) => b.id === data.stageId, - ); - const bracketToReset = tournament.brackets[bracketToResetIdx]; - validate(bracketToReset, "Invalid bracket id"); - validate(!bracketToReset.preview, "Bracket has not started"); - - const inProgressBrackets = tournament.brackets.filter((b) => !b.preview); - validate( - inProgressBrackets.every( - (b) => - !b.sources || - b.sources.every((s) => s.bracketIdx !== bracketToResetIdx), - ), - "Some bracket that sources teams from this bracket has started", - ); - - await TournamentRepository.resetBracket(data.stageId); - - break; - } - case "UPDATE_IN_GAME_NAME": { - validateIsTournamentOrganizer(); - - const teamMemberOf = badRequestIfFalsy( - tournament.teamMemberOfByUser({ id: data.memberId }), - ); - - await TournamentTeamRepository.updateMemberInGameName({ - userId: data.memberId, - inGameName: `${data.inGameNameText}#${data.inGameNameDiscriminator}`, - tournamentTeamId: teamMemberOf.id, - }); - break; - } - case "DELETE_LOGO": { - validateIsTournamentOrganizer(); - - await TournamentTeamRepository.deleteLogo(data.teamId); - - break; - } - default: { - assertUnreachable(data); - } - } - - clearTournamentDataCache(tournamentId); - - return null; -}; - -// TODO: translations export default function TournamentAdminPage() { const { t } = useTranslation(["calendar"]); const tournament = useTournament(); + const [editingProgression, setEditingProgression] = React.useState(false); const user = useUser(); + // biome-ignore lint/correctness/useExhaustiveDependencies: we want to close the dialog after the progression was updated + React.useEffect(() => { + setEditingProgression(false); + }, [tournament]); + if (!tournament.isOrganizer(user) || tournament.everyBracketOver) { return ; } @@ -418,6 +79,25 @@ export default function TournamentAdminPage() { ) : null} + {tournament.isAdmin(user) && + tournament.hasStarted && + !tournament.ctx.isFinalized ? ( +
+ + {editingProgression ? ( + setEditingProgression(false)} + /> + ) : null} +
+ ) : null} Team actions {tournament.isAdmin(user) ? ( @@ -1037,3 +717,42 @@ function BracketReset() { ); } + +function BracketProgressionEditDialog({ close }: { close: () => void }) { + const tournament = useTournament(); + const fetcher = useFetcher(); + const [bracketProgressionErrored, setBracketProgressionErrored] = + React.useState(false); + + const disabledBracketIdxs = tournament.brackets + .filter((bracket) => !bracket.preview) + .map((bracket) => bracket.idx); + + return ( + + + ({ + ...bracket, + disabled: disabledBracketIdxs.includes(idx), + }))} + isInvitationalTournament={tournament.isInvitational} + setErrored={setBracketProgressionErrored} + /> +
+ + Save changes + + +
+
+
+ ); +} diff --git a/app/features/tournament/routes/to.$id.streams.tsx b/app/features/tournament/routes/to.$id.streams.tsx index 6e54328d7..66b1b5448 100644 --- a/app/features/tournament/routes/to.$id.streams.tsx +++ b/app/features/tournament/routes/to.$id.streams.tsx @@ -3,6 +3,7 @@ import { useLoaderData } from "@remix-run/react"; import { useTranslation } from "react-i18next"; import { Redirect } from "~/components/Redirect"; import { tournamentData } from "~/features/tournament-bracket/core/Tournament.server"; +import { notFoundIfFalsy } from "~/utils/remix.server"; import { tournamentRegisterPage } from "~/utils/urls"; import { TournamentStream } from "../components/TournamentStream"; import { streamsByTournamentId } from "../core/streams.server"; @@ -13,7 +14,7 @@ export type TournamentStreamsLoader = typeof loader; export const loader = async ({ params }: LoaderFunctionArgs) => { const tournamentId = tournamentIdFromParams(params); - const tournament = await tournamentData({ tournamentId }); + const tournament = notFoundIfFalsy(await tournamentData({ tournamentId })); return { streams: await streamsByTournamentId(tournament.ctx), diff --git a/app/features/tournament/tournament-constants.ts b/app/features/tournament/tournament-constants.ts index 76045641d..8296b1d9f 100644 --- a/app/features/tournament/tournament-constants.ts +++ b/app/features/tournament/tournament-constants.ts @@ -7,22 +7,10 @@ export const TOURNAMENT = { ENOUGH_TEAMS_TO_START: 2, MIN_GROUP_SIZE: 3, MAX_GROUP_SIZE: 6, + MAX_BRACKETS_PER_TOURNAMENT: 10, + BRACKET_NAME_MAX_LENGTH: 32, // just a fallback, normally this should be set by user explicitly DEFAULT_TEAM_COUNT_PER_RR_GROUP: 4, + SWISS_DEFAULT_GROUP_COUNT: 1, + SWISS_DEFAULT_ROUND_COUNT: 5, } as const; - -export const BRACKET_NAMES = { - UNDERGROUND: "Underground bracket", - MAIN: "Main bracket", - GROUPS: "Group stage", - FINALS: "Final stage", -}; - -export const FORMATS_SHORT = [ - "DE", - "SE", - "RR_TO_SE", - "SWISS", - "SWISS_TO_SE", -] as const; -export type TournamentFormatShort = (typeof FORMATS_SHORT)[number]; diff --git a/app/features/tournament/tournament-schemas.server.ts b/app/features/tournament/tournament-schemas.server.ts index b800eb660..2ade11107 100644 --- a/app/features/tournament/tournament-schemas.server.ts +++ b/app/features/tournament/tournament-schemas.server.ts @@ -1,5 +1,4 @@ import { z } from "zod"; -import { USER } from "~/constants"; import { _action, checkboxValueToBoolean, @@ -9,10 +8,13 @@ import { safeJSONParse, stageId, } from "~/utils/zod"; -import { bracketIdx } from "../tournament-bracket/tournament-bracket-schemas.server"; import { TOURNAMENT } from "./tournament-constants"; -const teamName = z.string().trim().min(1).max(TOURNAMENT.TEAM_NAME_MAX_LENGTH); +export const teamName = z + .string() + .trim() + .min(1) + .max(TOURNAMENT.TEAM_NAME_MAX_LENGTH); export const registerSchema = z.union([ z.object({ @@ -55,94 +57,6 @@ export const seedsActionSchema = z.object({ seeds: z.preprocess(safeJSONParse, z.array(id)), }); -export const adminActionSchema = z.union([ - z.object({ - _action: _action("CHANGE_TEAM_OWNER"), - teamId: id, - memberId: id, - }), - z.object({ - _action: _action("CHANGE_TEAM_NAME"), - teamId: id, - teamName, - }), - z.object({ - _action: _action("CHECK_IN"), - teamId: id, - bracketIdx, - }), - z.object({ - _action: _action("CHECK_OUT"), - teamId: id, - bracketIdx, - }), - z.object({ - _action: _action("ADD_MEMBER"), - teamId: id, - userId: id, - }), - z.object({ - _action: _action("REMOVE_MEMBER"), - teamId: id, - memberId: id, - }), - z.object({ - _action: _action("DELETE_TEAM"), - teamId: id, - }), - z.object({ - _action: _action("ADD_TEAM"), - userId: id, - teamName, - }), - z.object({ - _action: _action("ADD_STAFF"), - userId: id, - role: z.enum(["ORGANIZER", "STREAMER"]), - }), - z.object({ - _action: _action("REMOVE_STAFF"), - userId: id, - }), - z.object({ - _action: _action("DROP_TEAM_OUT"), - teamId: id, - }), - z.object({ - _action: _action("UNDO_DROP_TEAM_OUT"), - teamId: id, - }), - z.object({ - _action: _action("DELETE_LOGO"), - teamId: id, - }), - z.object({ - _action: _action("UPDATE_CAST_TWITCH_ACCOUNTS"), - castTwitchAccounts: z.preprocess( - (val) => - typeof val === "string" - ? val - .split(",") - .map((account) => account.trim()) - .map((account) => account.toLowerCase()) - : val, - z.array(z.string()), - ), - }), - z.object({ - _action: _action("RESET_BRACKET"), - stageId: id, - }), - z.object({ - _action: _action("UPDATE_IN_GAME_NAME"), - inGameNameText: z.string().max(USER.IN_GAME_NAME_TEXT_MAX_LENGTH), - inGameNameDiscriminator: z - .string() - .refine((val) => /^[0-9a-z]{4,5}$/.test(val)), - memberId: id, - }), -]); - export const joinSchema = z.object({ trust: z.preprocess(checkboxValueToBoolean, z.boolean()), }); diff --git a/app/styles/common.css b/app/styles/common.css index bbd238fb0..76589f8e4 100644 --- a/app/styles/common.css +++ b/app/styles/common.css @@ -1911,3 +1911,15 @@ html[dir="rtl"] .fix-rtl { font-size: var(--fonts-xxxs); font-weight: var(--bold); } + +.format-selector__count { + color: var(--theme); + font-size: var(--fonts-sm); + white-space: nowrap; +} + +.format-selector__divider { + background-color: var(--theme-transparent); + width: 2px; + align-self: stretch; +} diff --git a/app/utils/urls.ts b/app/utils/urls.ts index 4b2c1e701..e6f075bfc 100644 --- a/app/utils/urls.ts +++ b/app/utils/urls.ts @@ -55,6 +55,9 @@ export const SENDOU_INK_BASE_URL = "https://sendou.ink"; export const BADGES_DOC_LINK = "https://github.com/Sendouc/sendou.ink/blob/rewrite/docs/badges.md"; +export const CREATING_TOURNAMENT_DOC_LINK = + "https://github.com/Sendouc/sendou.ink/blob/rewrite/docs/tournament-creation.md"; + const USER_SUBMITTED_IMAGE_ROOT = "https://sendou.nyc3.cdn.digitaloceanspaces.com"; export const userSubmittedImage = (fileName: string) => diff --git a/docs/img/tournament-auto-subs.png b/docs/img/tournament-auto-subs.png new file mode 100644 index 000000000..c604fa0ad Binary files /dev/null and b/docs/img/tournament-auto-subs.png differ diff --git a/docs/img/tournament-bracket-start.png b/docs/img/tournament-bracket-start.png new file mode 100644 index 000000000..d596d757d Binary files /dev/null and b/docs/img/tournament-bracket-start.png differ diff --git a/docs/img/tournament-creation-add.png b/docs/img/tournament-creation-add.png new file mode 100644 index 000000000..34c073b28 Binary files /dev/null and b/docs/img/tournament-creation-add.png differ diff --git a/docs/img/tournament-map-list-algo.png b/docs/img/tournament-map-list-algo.png new file mode 100644 index 000000000..a70c9265d Binary files /dev/null and b/docs/img/tournament-map-list-algo.png differ diff --git a/docs/img/tournament-placement-mapping.png b/docs/img/tournament-placement-mapping.png new file mode 100644 index 000000000..a99142e08 Binary files /dev/null and b/docs/img/tournament-placement-mapping.png differ diff --git a/docs/img/tournament-team-map-pick.png b/docs/img/tournament-team-map-pick.png new file mode 100644 index 000000000..8cccea35d Binary files /dev/null and b/docs/img/tournament-team-map-pick.png differ diff --git a/docs/tournament-creation.md b/docs/tournament-creation.md new file mode 100644 index 000000000..d8a2f6d98 --- /dev/null +++ b/docs/tournament-creation.md @@ -0,0 +1,145 @@ +# Creating a tournament + +## About + +Sendou.ink can used to run Splatoon 3 tournaments without the need of another bracket hosting website. Currently it is in limited beta. You can request access via our Discord if you are an established tournament organizer. + +## Creating + +Tournaments can be created via the add menu on the top right of your screen after logging in assuming you have access: + +![alt text](./img/tournament-creation-add.png) + +## Fields + +This section explains all the different options when you are creating a tournament and what they do. + +### Name + +Name of the tournament. + +### Description + +Description of the tournament, shown when registering. Supports Markdown including embedding images. + +### Rules + +Rules of the tournament. Supports Markdown including embedding images. + +### Dates + +When tournament starts. Note that unlike calendar events, tournaments can only have one actual starting time. + +### Discord server invite URL + +Invite link URL to your tournament's Discord server. + +### Tags + +Tags that apply to your tournament. Please take a look at the selection and choose all that apply. + +### Logo + +Tournament logo you can upload to be shown in various places. + +### Players count + +Choose whether you want to host a regular 4v4 tournmament or 3v3/2v2/1v1 tournament. + +### Registration closes at + +Choose relative to the tournament start time when sign ups close. When the registration closes new teams can't sign up, add team members, change their registration info and new users can't join the list of subs. Everything but the last is possible via admin actions regardless of whether the registration is open or not. + +### Ranked + +Host the event as ranked or not. If there is a ranked season open on the site then ranked tournaments contribute to the seasonal rankings. Some events are not allowed to be run as ranked: + +- Gimmick rules (some weapon restrictions is fine for example "no duplicate specials") +- 3v3/2v2/1v1 +- Skill capped in any way + +If you are not sure whether your event qualifies to be ran as ranked, ask before hosting. + +### Autonomous subs + +Allow teams to add subs while the tournament is in progress on their own. If off then all the subs have to be added by the tournament organizers. + +![alt text](./img/tournament-auto-subs.png) +*Tournament team member adding a sub in the middle of a tournament* + +### Require in-game names + +Especially for tournaments where verification is important. Players need to have submit an in-game name (e.g. Sendou#1234) and this can't be changed after registration closes. + +### Invitational + +All teams added by the tournament organizer manually. No open registration or subs list. In addition for invitational teams can add only 5 members before the tournament starts on their own (and 6 during it if autonomous subs are enabled). + +### Strict deadlines + +Display the "deadline" for each round as 5 minutes stricter. Note that this is only visual and it's up to the tournament organizer how to enforce these if at all. + +## Tournament maps + +With sendou.ink tournaments all maps are decided ahead of time. + +### Prepicked by teams + +Map pool is always the same as current SendouQ seasonal map pool in terms of bans. + +For SZ/TC/RM/CB only no maps are picked by the tournament organizer. + +For all modes the tournament organizer picks one tiebreaker map per mode. + +![alt text](./img/tournament-team-map-pick.png) +*Team picking maps as part of their registration process* + +Then when the tournament in in progress an algorithm decides the map list for each match: + +![alt text](./img/tournament-map-list-algo.png) + +[More info on how it works](https://gist.github.com/Sendouc/285c697ad98171243bf5c08a4c7e1f30). + +### Picked by TO + +Note that here you select just the map pool. The actual map lists are picked when the bracket starts (or prepared) in advance: + +![alt text](./img/tournament-bracket-start.png) +*View when starting bracket* + +## Tournament format + +Choose the tournament format. You can have at most 10 brackets with teams advancing between them as you wish. + +Source bracket means a bracket where teams come from. Target bracket means a bracket where teams go to after first playing some other bracket. A bracket can be both at the same time. + +### Placements + +Placements is a comma separated list of placements. So e.g. the following are valid: + +- `1,2,3` +- `1-3` +- `-1,-2` + +Placements are relative in the sense that the amount of teams that sign up don't affect them. `1` is always the 1st placement but `2` is the "2nd best possible placement to achieve" and so on. So for example with round robin the amount of teams advancing from that bracket depends entirely on the amount of groups (which is decided via sign ups.) + +![alt text](./img/tournament-placement-mapping.png) +*A screenshot from one Swim or Sink and how the placements map* + +### Start time + +Whether to start the bracket right after the previous one concludes or at some other time. This can be useful for two day tournaments. Note that it's not really meant to organize an event that spans many weeks (organization page features can be used instead). + +### Check-in required + +Whether to require check-in to the bracket or not. Note even if you leave it off, you can still check out teams. + +### Limitations + +Current limitations. Feel free to leave feedback if it's blocking you from running some event you wish: + +- Single-elimination can not be a source bracket +- Double-elimination can only be a source bracket when it comes to people who drop in the losers round (negative placements) +- All teams start the tournament in the same bracket +- Only one source bracket per target bracket. + diff --git a/e2e/tournament-bracket.spec.ts b/e2e/tournament-bracket.spec.ts index 095a0c8d6..a0b95cf16 100644 --- a/e2e/tournament-bracket.spec.ts +++ b/e2e/tournament-bracket.spec.ts @@ -459,9 +459,8 @@ test.describe("Tournament bracket", () => { }); await page.getByTestId("edit-event-info-button").click(); - await page.getByLabel("Auto check-in to follow-up brackets").check(); - await page.getByTestId("remove-bracket").click(); - await page.getByTestId("placement-3-4").click(); + await page.getByTestId("delete-bracket-button").last().click(); + await page.getByTestId("placements-input").last().fill("3,4"); await submit(page); diff --git a/locales/en/tournament.json b/locales/en/tournament.json index cc56ab530..eeb799a64 100644 --- a/locales/en/tournament.json +++ b/locales/en/tournament.json @@ -143,5 +143,16 @@ "subs.weapons.info": "Choose between {{min}} and {{max}}", "subs.message.header": "Message", "subs.visibility.header": "Visibility", - "subs.visibility.everyone": "Everyone" + "subs.visibility.everyone": "Everyone", + + "progression.error.PLACEMENTS_PARSE_ERROR": "Error parsing placements", + "progression.error.NOT_RESOLVING_WINNER": "Progression does not resolve winner", + "progression.error.SAME_PLACEMENT_TO_MULTIPLE_BRACKETS": "Same placement leads to multiple brackets", + "progression.error.GAP_IN_PLACEMENTS": "Gap in placements that advance", + "progression.error.TOO_MANY_PLACEMENTS": "Too many placements (more than teams in groups)", + "progression.error.DUPLICATE_BRACKET_NAME": "Duplicate bracket name", + "progression.error.NAME_MISSING": "Bracket name missing", + "progression.error.NEGATIVE_PROGRESSION": "Negative progression only possible for double elimination", + "progression.error.NO_SE_SOURCE": "Single elimination is not a valid source bracket", + "progression.error.NO_DE_POSITIVE": "Double elimination is not valid for positive progression" } diff --git a/migrations/072-free-bracket-progression.js b/migrations/072-free-bracket-progression.js new file mode 100644 index 000000000..e0df2a836 --- /dev/null +++ b/migrations/072-free-bracket-progression.js @@ -0,0 +1,7 @@ +export function up(db) { + db.transaction(() => { + db.prepare( + /* sql */ `alter table "TournamentTeamCheckIn" add "isCheckOut" integer default 0`, + ).run(); + })(); +}