diff --git a/app/components/Catcher.tsx b/app/components/Catcher.tsx index 03798e37d..9ad37bebe 100644 --- a/app/components/Catcher.tsx +++ b/app/components/Catcher.tsx @@ -3,10 +3,12 @@ import * as React from "react"; import { isRouteErrorResponse, useLocation, + useNavigate, useRevalidator, useRouteError, } from "react-router"; import { useUser } from "~/features/auth/core/user"; +import * as Redirect from "~/modules/redirects/core/Redirect"; import { getSessionId } from "~/utils/session-id"; import { ERROR_GIRL_IMAGE_PATH, @@ -121,12 +123,7 @@ export function Catcher() { ); case 404: - return ( - -

Error {error.status} - Page not found

- -
- ); + return ; default: return ( @@ -147,6 +144,31 @@ export function Catcher() { } } +/** + * A client side navigation to an URL matching no route never reaches the server, so the + * redirects (normally resolved by `redirectsMiddleware`) are checked here as well. + */ +function PageNotFound() { + const location = useLocation(); + const navigate = useNavigate(); + const redirectTo = Redirect.resolve(location); + + React.useEffect(() => { + if (redirectTo) { + navigate(redirectTo, { replace: true }); + } + }, [redirectTo, navigate]); + + if (redirectTo) return null; + + return ( + +

Error 404 - Page not found

+ +
+ ); +} + /** Every branch of the error page, marked so tests can assert one is not shown. */ function ErrorMain({ children }: { children: React.ReactNode }) { return
{children}
; diff --git a/app/db/seed/factories/TournamentFactory.ts b/app/db/seed/factories/TournamentFactory.ts index f546dddcd..b29896c33 100644 --- a/app/db/seed/factories/TournamentFactory.ts +++ b/app/db/seed/factories/TournamentFactory.ts @@ -87,8 +87,9 @@ export const { create } = defineFactory({ applyOptions: async (tournament, { tier }: Options) => { if (!tier) return; - await TournamentRepository.updateTournamentTier({ + await TournamentRepository.upsertDivisionTier({ tournamentId: tournament.id, + bracketIdx: 0, tier, }); }, diff --git a/app/db/tables-json.ts b/app/db/tables-json.ts index f5592713d..66862ad91 100644 --- a/app/db/tables-json.ts +++ b/app/db/tables-json.ts @@ -155,6 +155,8 @@ export interface TournamentSettings { isTest?: boolean; isDraft?: boolean; requireSendouQParticipation?: boolean; + /** Is this tournament a league? Leagues are played over many weeks, each starting bracket being a division. */ + isLeague?: boolean; } export interface CastedMatchesInfo { diff --git a/app/db/tables.ts b/app/db/tables.ts index d6a6762b3..03678cf4c 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -587,8 +587,6 @@ export interface Tournament { castTwitchAccounts: JSONColumnTypeNullable; castedMatchesInfo: JSONColumnTypeNullable; rules: string | null; - /** Related "parent tournament", the tournament that contains the original sign-ups (for leagues) */ - parentTournamentId: number | null; /** Is the tournament finalized meaning all the matches are played and TO has locked it making it read-only */ isFinalized: Generated; /** Snapshot of teams and rosters when seeds were last saved. Used to detect NEW teams/players. */ @@ -607,6 +605,19 @@ export interface SavedCalendarEvent { createdAt: Generated; } +/** + * Tier of one division (= starting bracket) of a tournament, based on the skill of the teams that + * checked in to it. Tournaments where every team plays the same bracket have one row (bracket idx 0) + * matching `Tournament.tier`, tournaments with many starting brackets one row per division. + */ +export interface TournamentDivisionTier { + tournamentId: number; + /** Idx of the starting bracket in `Tournament.settings.bracketProgression`. */ + bracketIdx: number; + /** Same scale as `Tournament.tier`. 1=X, 2=S+, 3=S, 4=A+, 5=A, 6=B+, 7=B, 8=C+, 9=C */ + tier: TournamentTierNumber; +} + export interface TournamentBadgeOwner { badgeId: number; userId: number; @@ -709,6 +720,8 @@ export interface TournamentRound { number: number; stageId: number; maps: JSONColumnType; + /** Datetime the round is played by default (leagues). Null = no default play time, the round is played whenever. */ + defaultPlayTime: number | null; } /** A stage is an intermediate phase in a tournament. In essence a bracket. */ @@ -1361,6 +1374,7 @@ export interface DB { /** VIEW over `AllTeamMember`, same as `TeamMember` but also includes rows where this is the member's secondary (i.e. non-main) team. Insert/update via `AllTeamMember`. */ TeamMemberWithSecondary: TeamMember; Tournament: Tournament; + TournamentDivisionTier: TournamentDivisionTier; TournamentStaff: TournamentStaff; TournamentGroup: TournamentGroup; TournamentLFGLike: TournamentLFGLike; diff --git a/app/features/bracket-test/routes/bracket-test.tsx b/app/features/bracket-test/routes/bracket-test.tsx index 750fbbfec..73690ca35 100644 --- a/app/features/bracket-test/routes/bracket-test.tsx +++ b/app/features/bracket-test/routes/bracket-test.tsx @@ -72,7 +72,7 @@ export default function BracketTestLayout() { isOrganizer: () => false, streamingParticipantIds: [] as number[], streams: [] as unknown[], - isLeagueDivision: false, + isLeague: false, }; const mockBracket = { @@ -189,7 +189,6 @@ export default function BracketTestLayout() { tournament: mockTournament, bracketExpanded, setBracketExpanded, - hasChildTournaments: false, preparedMaps: null, bracket: mockBracket, }} diff --git a/app/features/calendar/CalendarRepository.server.ts b/app/features/calendar/CalendarRepository.server.ts index 478e1ed95..dc67a758e 100644 --- a/app/features/calendar/CalendarRepository.server.ts +++ b/app/features/calendar/CalendarRepository.server.ts @@ -439,7 +439,6 @@ type CreateArgs = Pick< avatarFileName?: string; avatarImgId?: number; autoValidateAvatar?: boolean; - parentTournamentId?: number; }; export async function insert(args: CreateArgs) { const copiedStaff = args.tournamentToCopyId @@ -486,7 +485,6 @@ export async function insert(args: CreateArgs) { .values({ mapPickingStyle: args.mapPickingStyle, settings: JSON.stringify(settings), - parentTournamentId: args.parentTournamentId, rules: args.rules, }) .returning("id") @@ -524,7 +522,7 @@ export async function insert(args: CreateArgs) { bracketUrl: args.bracketUrl, avatarImgId: args.avatarImgId ?? avatarImgId, organizationId: args.organizationId, - hidden: args.parentTournamentId || args.isTest || args.isDraft ? 1 : 0, + hidden: args.isTest || args.isDraft ? 1 : 0, tournamentId, trophyId: args.trophyId ?? null, }) @@ -603,14 +601,13 @@ export async function update(args: UpdateArgs) { : null; if (tournamentId) { - const { parentTournamentId, settings: existingSettings } = await trx + const { settings: existingSettings } = await trx .selectFrom("Tournament") - .select(["parentTournamentId", "settings"]) + .select(["settings"]) .where("id", "=", tournamentId) .executeTakeFirstOrThrow(); - const hidden = - existingSettings.isTest || parentTournamentId || args.isDraft ? 1 : 0; + const hidden = existingSettings.isTest || args.isDraft ? 1 : 0; await trx .updateTable("CalendarEvent") .set({ hidden }) diff --git a/app/features/core/streams/streams.server.ts b/app/features/core/streams/streams.server.ts index 10fa61f8a..27a21e121 100644 --- a/app/features/core/streams/streams.server.ts +++ b/app/features/core/streams/streams.server.ts @@ -30,7 +30,7 @@ export function getLiveTournamentStreams(): SidebarStream[] { const streams: SidebarStream[] = []; for (const tournament of RunningTournaments.all) { - if (tournament.isLeagueDivision) continue; + if (tournament.isLeague) continue; if (tournament.streams.length === 0) continue; streams.push({ @@ -53,7 +53,7 @@ export function getLiveTournamentStreamerTwitchNames(): string[] { const names: string[] = []; for (const tournament of RunningTournaments.all) { - if (tournament.isLeagueDivision) continue; + if (tournament.isLeague) continue; for (const stream of tournament.streams) { names.push(stream.twitchUserName.toLowerCase()); diff --git a/app/features/match-profile/routes/q.settings.tsx b/app/features/match-profile/routes/q.settings.tsx deleted file mode 100644 index ba71d925f..000000000 --- a/app/features/match-profile/routes/q.settings.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { redirect } from "react-router"; -import { MATCH_PROFILE_PAGE } from "~/utils/urls"; - -export const loader = () => { - throw redirect(MATCH_PROFILE_PAGE); -}; - -export default function MatchProfileRedirect() { - return null; -} diff --git a/app/features/plus-suggestions/routes/plus.index.ts b/app/features/plus-suggestions/routes/plus.index.ts deleted file mode 100644 index e399de8b6..000000000 --- a/app/features/plus-suggestions/routes/plus.index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { redirect } from "react-router"; - -export const loader = () => { - throw redirect("/plus/suggestions"); -}; diff --git a/app/features/sendouq-match/PlayerStatRepository.server.ts b/app/features/sendouq-match/PlayerStatRepository.server.ts index 8a99943b4..69889c825 100644 --- a/app/features/sendouq-match/PlayerStatRepository.server.ts +++ b/app/features/sendouq-match/PlayerStatRepository.server.ts @@ -1,6 +1,7 @@ import { type ExpressionBuilder, type NotNull, + type SqlBool, sql, type Transaction, } from "kysely"; @@ -373,10 +374,30 @@ export async function findSeasonTournamentRunsByUserId({ ) .innerJoin("Tournament", "Tournament.id", "Skill.tournamentId") .innerJoin("CalendarEvent", "CalendarEvent.tournamentId", "Tournament.id") + .innerJoin( + "TournamentTeam", + "TournamentTeam.id", + "TournamentResult.tournamentTeamId", + ) + .leftJoin("TournamentDivisionTier", (join) => + join + .onRef( + "TournamentDivisionTier.tournamentId", + "=", + "TournamentResult.tournamentId", + ) + .on( + sql`"TournamentDivisionTier"."bracketIdx" = coalesce("TournamentTeam"."startingBracketIdx", 0)`, + ), + ) .select((eb) => [ "TournamentResult.placement", "TournamentResult.participantCount as teamsCount", - "Tournament.tier", + sql< + Tables["Tournament"]["tier"] + >`coalesce("TournamentDivisionTier"."tier", "Tournament"."tier")`.as( + "tier", + ), "CalendarEvent.name", tournamentLogoWithDefault(eb).as("logoUrl"), eb @@ -387,12 +408,20 @@ export async function findSeasonTournamentRunsByUserId({ "in", eb .selectFrom("TournamentResult as TopEightResult") + .innerJoin( + "TournamentTeam as TopEightTeam", + "TopEightTeam.id", + "TopEightResult.tournamentTeamId", + ) .select("TopEightResult.userId") .whereRef("TopEightResult.tournamentId", "=", "Tournament.id") .where( "TopEightResult.placement", "<=", TOURNAMENT_FIELD_STRENGTH_PLACEMENT, + ) + .where( + sql`coalesce("TopEightTeam"."startingBracketIdx", 0) = coalesce("TournamentTeam"."startingBracketIdx", 0)`, ), ) .as("TopEightLatestSkill"), diff --git a/app/features/sendouq/routes/play.ts b/app/features/sendouq/routes/play.ts deleted file mode 100644 index 0a843e211..000000000 --- a/app/features/sendouq/routes/play.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { type LoaderFunction, redirect } from "react-router"; -import { SENDOUQ_PAGE } from "~/utils/urls"; - -// SendouQ's old URL was /play -export const loader: LoaderFunction = () => { - throw redirect(SENDOUQ_PAGE); -}; diff --git a/app/features/team/TeamRepository.server.ts b/app/features/team/TeamRepository.server.ts index 2ae3ea022..46449840d 100644 --- a/app/features/team/TeamRepository.server.ts +++ b/app/features/team/TeamRepository.server.ts @@ -1,4 +1,4 @@ -import { type Insertable, sql, type Transaction } from "kysely"; +import { type Insertable, type SqlBool, sql, type Transaction } from "kysely"; import { db } from "~/db/sql"; import type { DB, Tables } from "~/db/tables"; import type { CustomTheme, UserMapModePreferences } from "~/db/tables-json"; @@ -264,6 +264,7 @@ export async function findResultsById(teamId: number) { "TournamentResult.tournamentId", "TournamentResult.placement", "TournamentResult.participantCount", + "TournamentTeam.startingBracketIdx", ]) .where("teamId", "=", teamId) .groupBy("TournamentResult.tournamentId"), @@ -280,6 +281,17 @@ export async function findResultsById(teamId: number) { "CalendarEvent.id", ) .innerJoin("Tournament", "Tournament.id", "results.tournamentId") + .leftJoin("TournamentDivisionTier", (join) => + join + .onRef( + "TournamentDivisionTier.tournamentId", + "=", + "results.tournamentId", + ) + .on( + sql`"TournamentDivisionTier"."bracketIdx" = coalesce("results"."startingBracketIdx", 0)`, + ), + ) .select((eb) => [ "results.placement", "results.tournamentId", @@ -287,7 +299,11 @@ export async function findResultsById(teamId: number) { "results.tournamentTeamId", "CalendarEvent.name as tournamentName", "CalendarEventDate.startsAt", - "Tournament.tier", + sql< + Tables["Tournament"]["tier"] + >`coalesce("TournamentDivisionTier"."tier", "Tournament"."tier")`.as( + "tier", + ), tournamentLogoOrNull(eb).as("logoUrl"), jsonArrayFrom( eb diff --git a/app/features/team/routes/t.tsx b/app/features/team/routes/t.tsx deleted file mode 100644 index f00d98522..000000000 --- a/app/features/team/routes/t.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { redirect } from "react-router"; - -export const loader = () => { - return redirect("/?search=open&type=teams"); -}; diff --git a/app/features/tournament-admin/routes/to.$id.admin.brackets.tsx b/app/features/tournament-admin/routes/to.$id.admin.brackets.tsx index 240ddaf20..a21bc02dd 100644 --- a/app/features/tournament-admin/routes/to.$id.admin.brackets.tsx +++ b/app/features/tournament-admin/routes/to.$id.admin.brackets.tsx @@ -50,12 +50,8 @@ export default function TournamentAdminBracketsPage() { ) : null} - {!tournament.isLeagueSignup ? ( - <> - Bracket reset - - - ) : null} + Bracket reset + {showReopen ? ( <> Reopen tournament (dev only) diff --git a/app/features/tournament-admin/routes/to.$id.admin.tsx b/app/features/tournament-admin/routes/to.$id.admin.tsx index e19ef5ba1..41a62fdc2 100644 --- a/app/features/tournament-admin/routes/to.$id.admin.tsx +++ b/app/features/tournament-admin/routes/to.$id.admin.tsx @@ -49,16 +49,10 @@ export default function TournamentAdminLayout() { tournament.ctx.isFinalized && tournament.isAdmin(user), ); - const showEditBrackets = - tournament.isAdmin(user) && - tournament.hasStarted && - !tournament.ctx.isFinalized; const showStaffTab = tournament.isAdmin(user); - const showBracketsTab = tournament.ctx.isFinalized - ? showReopen - : !tournament.isLeagueSignup || showEditBrackets; + const showBracketsTab = tournament.ctx.isFinalized ? showReopen : true; const showStreamTab = !tournament.ctx.isFinalized; - const showSeedsTab = !tournament.hasStarted && !tournament.isLeagueSignup; + const showSeedsTab = !tournament.hasStarted; if (!tournament.isOrganizer(user)) { return ; @@ -84,24 +78,22 @@ export default function TournamentAdminLayout() { > Edit event info - {!tournament.isLeagueSignup ? ( - + - - {t("calendar:actions.delete")} - - - ) : null} + {t("calendar:actions.delete")} + + ) : null} { type: bracket.type, seeding, settings: bracket.settings, - independentRounds: tournament.isLeagueDivision, + independentRounds: tournament.isLeague, abDivisions, maps, }); @@ -141,7 +141,10 @@ export const action: ActionFunction = async ({ params, request }) => { }); } - if (data.bracketIdx === 0 && seeding.length >= MIN_TEAMS_FOR_TIERING) { + const isDivision = Progression.startingBrackets( + tournament.ctx.settings.bracketProgression, + ).includes(data.bracketIdx); + if (isDivision && seeding.length >= MIN_TEAMS_FOR_TIERING) { const checkedInTeams = tournament.ctx.teams .filter((team) => seeding.includes(team.id)) .map((team) => ({ avgOrdinal: team.avgSeedingSkillOrdinal })); @@ -152,8 +155,9 @@ export const action: ActionFunction = async ({ params, request }) => { ); if (tierNumber !== null) { - await TournamentRepository.updateTournamentTier({ + await TournamentRepository.upsertDivisionTier({ tournamentId: tournament.ctx.id, + bracketIdx: data.bracketIdx, tier: tierNumber, }); } diff --git a/app/features/tournament-bracket/components/Bracket/Bracket.browser.test.tsx b/app/features/tournament-bracket/components/Bracket/Bracket.browser.test.tsx index 3257dbe50..82912bd59 100644 --- a/app/features/tournament-bracket/components/Bracket/Bracket.browser.test.tsx +++ b/app/features/tournament-bracket/components/Bracket/Bracket.browser.test.tsx @@ -90,7 +90,7 @@ const mockTournament = { brackets: [], bracketsMeta: [], bracketMetaByIdx: () => null, - isLeagueDivision: false, + isLeague: false, teamById: (id: number) => mockTournament.ctx.teams.find((t) => t.id === id) ?? null, teamMemberOfByUser: () => null, diff --git a/app/features/tournament-bracket/components/Bracket/Match.tsx b/app/features/tournament-bracket/components/Bracket/Match.tsx index 28c006e73..78191445c 100644 --- a/app/features/tournament-bracket/components/Bracket/Match.tsx +++ b/app/features/tournament-bracket/components/Bracket/Match.tsx @@ -407,7 +407,7 @@ function MatchVods({ vods }: MatchVodsProps) { function MatchTimer({ match, bracket }: Pick) { const tournament = useTournament(); - if (tournament.isLeagueDivision) return null; + if (tournament.isLeague) return null; if (!match.startedAt) return null; const isOver = Boolean(match.winnerSide); diff --git a/app/features/tournament-bracket/components/Bracket/RoundHeader.tsx b/app/features/tournament-bracket/components/Bracket/RoundHeader.tsx index 944169f54..445feafe7 100644 --- a/app/features/tournament-bracket/components/Bracket/RoundHeader.tsx +++ b/app/features/tournament-bracket/components/Bracket/RoundHeader.tsx @@ -30,7 +30,7 @@ export function RoundHeader({ roundStartedAt?: number | null; matches?: Array>; }) { - const leagueRoundStartDate = useLeagueWeekStart(bracketIdx, roundId); + const leagueRoundStartDate = useLeagueRoundStartDate(bracketIdx, roundId); const countPrefix = maps?.type === "PLAY_ALL" ? "Play all " : "Bo"; @@ -141,10 +141,10 @@ function RoundTimer({ return
{displayText}
; } -function useLeagueWeekStart(bracketIdx: number, roundId: number) { +function useLeagueRoundStartDate(bracketIdx: number, roundId: number) { const tournament = useTournament(); - if (bracketIdx !== 0 || !tournament.isLeagueDivision) return null; + if (!tournament.isLeague) return null; return resolveLeagueRoundStartDate( tournament, diff --git a/app/features/tournament-bracket/core/Bracket/Bracket.ts b/app/features/tournament-bracket/core/Bracket/Bracket.ts index 22bd8eb21..f6cc233d0 100644 --- a/app/features/tournament-bracket/core/Bracket/Bracket.ts +++ b/app/features/tournament-bracket/core/Bracket/Bracket.ts @@ -388,7 +388,7 @@ export abstract class Bracket { ...this.settings, hasAbDivisions: false, }, - independentRounds: this.tournament.isLeagueDivision, + independentRounds: this.tournament.isLeague, abDivisions, }); } diff --git a/app/features/tournament-bracket/core/Tournament.test.ts b/app/features/tournament-bracket/core/Tournament.test.ts index 3cb494749..c482411fe 100644 --- a/app/features/tournament-bracket/core/Tournament.test.ts +++ b/app/features/tournament-bracket/core/Tournament.test.ts @@ -447,6 +447,54 @@ describe("Adjusting team starting bracket", () => { }); }); +describe("League divisions", () => { + const leagueTournament = (isLeague = true) => + testTournament({ + ctx: { + teams: [0, 0, 0, 2].map((startingBracketIdx, i) => + tournamentCtxTeam(i + 1, { startingBracketIdx }), + ), + settings: { + isLeague, + bracketProgression: progressions.league, + }, + }, + }); + + test("every starting bracket is a division", () => { + expect(leagueTournament().leagueDivisions.map((div) => div.idx)).toEqual([ + 0, 2, + ]); + }); + + test("has no divisions when not a league", () => { + expect(leagueTournament(false).leagueDivisions).toEqual([]); + }); + + test("playoffs belong to the division they are sourced from", () => { + expect(leagueTournament().leagueDivisionOfBracket(3)).toBe(2); + }); + + test("brackets of a division exclude the other divisions'", () => { + expect( + leagueTournament() + .visibleBracketsMetaOfDivision(2) + .map((bracket) => bracket.name), + ).toEqual(["Division 2", "Division 2 Playoffs"]); + }); + + test("every bracket is shown when no division is selected", () => { + expect(leagueTournament().visibleBracketsMetaOfDivision(null)).toHaveLength( + 4, + ); + }); + + test("teams of a division are the ones starting in it", () => { + expect(leagueTournament().teamsCountOfBracket(0)).toBe(3); + expect(leagueTournament().teamsCountOfBracket(2)).toBe(1); + }); +}); + describe("Resolving the team a user is a member of", () => { const USER_ID = 1; diff --git a/app/features/tournament-bracket/core/Tournament.ts b/app/features/tournament-bracket/core/Tournament.ts index add928928..540c14626 100644 --- a/app/features/tournament-bracket/core/Tournament.ts +++ b/app/features/tournament-bracket/core/Tournament.ts @@ -1,10 +1,7 @@ import { sub } from "date-fns"; import type { Tables } from "~/db/tables"; import type { TournamentStageSettings } from "~/db/tables-json"; -import { - LEAGUES, - TOURNAMENT, -} from "~/features/tournament/tournament-constants"; +import { TOURNAMENT } from "~/features/tournament/tournament-constants"; import { modesIncluded, sortTeamsBySeeding, @@ -238,6 +235,83 @@ export class Tournament { ); } + /** + * Divisions of a league. Every starting bracket is a division of its own, identified by its + * bracket idx, the brackets it feeds into (its playoffs) belonging to that division as well. + */ + get leagueDivisions(): BracketMeta[] { + if (!this.isLeague) return []; + + return this.bracketsMeta.filter((bracket) => bracket.isStartingBracket); + } + + /** Division the given bracket belongs to, or null if the tournament has no divisions. */ + leagueDivisionOfBracket(bracketIdx: number): number | null { + const division = this.leagueDivisions.find((division) => + this.bracketIdxsOfDivision(division.idx).includes(bracketIdx), + ); + + return division?.idx ?? null; + } + + /** {@link bracketsMeta} limited to the brackets of one division, if a division is given. */ + bracketsMetaOfDivision(divisionIdx: number | null): BracketMeta[] { + if (divisionIdx === null) return this.bracketsMeta; + + const bracketIdxs = this.bracketIdxsOfDivision(divisionIdx); + + return this.bracketsMeta.filter((bracket) => + bracketIdxs.includes(bracket.idx), + ); + } + + /** {@link visibleBracketsMeta} limited to the brackets of one division, if a division is given. */ + visibleBracketsMetaOfDivision(divisionIdx: number | null): BracketMeta[] { + const visibleIdxs = new Set( + this.visibleBracketsMeta.map((bracket) => bracket.idx), + ); + + return this.bracketsMetaOfDivision(divisionIdx).filter((bracket) => + visibleIdxs.has(bracket.idx), + ); + } + + private bracketIdxsOfDivision(divisionIdx: number) { + return Progression.bracketsReachableFrom( + divisionIdx, + this.ctx.settings.bracketProgression, + ); + } + + /** Teams that can play in the bracket: its participants plus the ones still pending check-in. */ + eligibleTeamsCountOfBracket(bracketIdx: number) { + const bracket = this.bracketsMeta[bracketIdx]; + + if (bracket.sources) { + return ( + (bracket.teamsPendingCheckIn ?? []).length + + bracket.participantTournamentTeamIds.length + ); + } + + if (!this.isMultiStartingBracket) { + return this.ctx.teams.length; + } + + return this.ctx.teams.filter( + (team) => (team.startingBracketIdx ?? 0) === bracketIdx, + ).length; + } + + /** Teams of the bracket: its participants, or every eligible team while it is a preview. */ + teamsCountOfBracket(bracketIdx: number) { + const bracket = this.bracketsMeta[bracketIdx]; + + return bracket.preview + ? this.eligibleTeamsCountOfBracket(bracketIdx) + : bracket.participantTournamentTeamIds.length; + } + /** {@link bracketsMeta} in the shape it is shipped in, i.e. only what match data is needed for. */ get bracketsDerivedMeta(): BracketDerivedMeta[] { if (!this._derivedMeta) { @@ -910,18 +984,11 @@ export class Tournament { } /** - * Is this tournament a league sign-up? League sign-up tournament is a special case which just exists for registration. - * It won't have brackets. + * Is this tournament a league? A league is played over many weeks, each starting bracket + * being a division that teams are placed in by the organizer. * */ - get isLeagueSignup() { - return Object.values(LEAGUES) - .flat() - .some((entry) => entry.tournamentId === this.ctx.id); - } - - /** Is this tournament a league division? League division is a normal tournament that connects to a league sign-up tournament where teams are sourced from. */ - get isLeagueDivision() { - return Boolean(this.ctx.parentTournamentId); + get isLeague() { + return this.ctx.settings.isLeague === true; } /** Does this tournament have many brackets that act as the first bracket? In this format many bracket progressions advance independently from each other (so not all teams can meet). */ diff --git a/app/features/tournament-bracket/core/engine/types.ts b/app/features/tournament-bracket/core/engine/types.ts index 94cd13411..77fc14335 100644 --- a/app/features/tournament-bracket/core/engine/types.ts +++ b/app/features/tournament-bracket/core/engine/types.ts @@ -110,6 +110,8 @@ export interface RoundData { groupId: number; number: number; maps?: TournamentRoundMaps | null; + /** Datetime the round is played by default (leagues). */ + defaultPlayTime?: number | null; } export interface MatchResults { diff --git a/app/features/tournament-bracket/core/finalizeTournament.server.ts b/app/features/tournament-bracket/core/finalizeTournament.server.ts index 2e81b2c2c..e6bb3262c 100644 --- a/app/features/tournament-bracket/core/finalizeTournament.server.ts +++ b/app/features/tournament-bracket/core/finalizeTournament.server.ts @@ -125,8 +125,8 @@ async function updateSeriesTierHistory(tournament: Tournament) { } function resolveFinalizationSeason(tournament: Tournament) { - // league divisions might be running for many weeks - const attributionDate = tournament.isLeagueDivision + // leagues might be running for many weeks + const attributionDate = tournament.isLeague ? new Date() : tournament.ctx.startsAt; const season = Seasons.current(attributionDate); diff --git a/app/features/tournament-bracket/core/tests/mocks-li.ts b/app/features/tournament-bracket/core/tests/mocks-li.ts index 413271125..68e1cb5e2 100644 --- a/app/features/tournament-bracket/core/tests/mocks-li.ts +++ b/app/features/tournament-bracket/core/tests/mocks-li.ts @@ -6200,8 +6200,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ ctx: { id: 815, eventId: 2614, - parentTournamentId: null, - parentTournamentName: null, tier: null, tentativeTier: null, discordUrl: "https://discord.gg/F7RaNUR", diff --git a/app/features/tournament-bracket/core/tests/mocks-sos.ts b/app/features/tournament-bracket/core/tests/mocks-sos.ts index 7e8f3fb37..193ee1373 100644 --- a/app/features/tournament-bracket/core/tests/mocks-sos.ts +++ b/app/features/tournament-bracket/core/tests/mocks-sos.ts @@ -1685,8 +1685,6 @@ export const SWIM_OR_SINK_167 = ( ], ctx: { id: 672, - parentTournamentId: null, - parentTournamentName: null, tier: null, tentativeTier: null, eventId: 2425, diff --git a/app/features/tournament-bracket/core/tests/mocks-zones-weekly.ts b/app/features/tournament-bracket/core/tests/mocks-zones-weekly.ts index cecb33814..a73a1b4d0 100644 --- a/app/features/tournament-bracket/core/tests/mocks-zones-weekly.ts +++ b/app/features/tournament-bracket/core/tests/mocks-zones-weekly.ts @@ -306,8 +306,6 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({ castedMatchesInfo: null, mapPickingStyle: "TO", hasRules: true, - parentTournamentId: null, - parentTournamentName: null, name: "Zones Weekly 38", startsAt: 1734685200, isFinalized: 0, diff --git a/app/features/tournament-bracket/core/tests/mocks.ts b/app/features/tournament-bracket/core/tests/mocks.ts index f11b2e461..a62353dc4 100644 --- a/app/features/tournament-bracket/core/tests/mocks.ts +++ b/app/features/tournament-bracket/core/tests/mocks.ts @@ -1208,8 +1208,6 @@ export const PADDLING_POOL_257 = () => organization: null, tier: null, tentativeTier: null, - parentTournamentId: null, - parentTournamentName: null, tags: null, eventId: 1352, bracketProgressionOverrides: [], @@ -3831,8 +3829,6 @@ export const PADDLING_POOL_255 = () => organization: null, tier: null, tentativeTier: null, - parentTournamentId: null, - parentTournamentName: null, tags: null, eventId: 1286, bracketProgressionOverrides: [], @@ -6338,8 +6334,6 @@ export const IN_THE_ZONE_32 = ({ ], ctx: { id: 11, - parentTournamentId: null, - parentTournamentName: null, organization: null, tier: null, tentativeTier: null, diff --git a/app/features/tournament-bracket/core/tests/test-utils.ts b/app/features/tournament-bracket/core/tests/test-utils.ts index 7f6c0ecac..f9d5464dd 100644 --- a/app/features/tournament-bracket/core/tests/test-utils.ts +++ b/app/features/tournament-bracket/core/tests/test-utils.ts @@ -62,8 +62,6 @@ export const testTournament = ({ organization: null, tier: null, tentativeTier: null, - parentTournamentId: null, - parentTournamentName: null, hasRules: false, logoUrl: "/test.avif", discordUrl: null, @@ -278,6 +276,40 @@ export const progressions = { ], }, ], + league: [ + { + ...DEFAULT_PROGRESSION_ARGS, + type: "round_robin", + name: "Division 1", + }, + { + ...DEFAULT_PROGRESSION_ARGS, + type: "single_elimination", + name: "Division 1 Playoffs", + sources: [ + { + bracketIdx: 0, + placements: [1, 2], + }, + ], + }, + { + ...DEFAULT_PROGRESSION_ARGS, + type: "round_robin", + name: "Division 2", + }, + { + ...DEFAULT_PROGRESSION_ARGS, + type: "single_elimination", + name: "Division 2 Playoffs", + sources: [ + { + bracketIdx: 2, + placements: [1, 2], + }, + ], + }, + ], swissOneGroup: [ { ...DEFAULT_PROGRESSION_ARGS, diff --git a/app/features/tournament-bracket/loaders/to.$id.brackets.server.ts b/app/features/tournament-bracket/loaders/to.$id.brackets.server.ts index ef3e4b129..17a2fac14 100644 --- a/app/features/tournament-bracket/loaders/to.$id.brackets.server.ts +++ b/app/features/tournament-bracket/loaders/to.$id.brackets.server.ts @@ -1,7 +1,8 @@ -import type { LoaderFunctionArgs } from "react-router"; +import { type LoaderFunctionArgs, redirect } from "react-router"; import { resolveNotifications } from "~/features/notifications/core/resolve.server"; import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server"; import type { SerializeFrom } from "~/utils/remix"; +import { tournamentDivisionsPage } from "~/utils/urls"; import type { Bracket } from "../core/Bracket"; import type { Tournament } from "../core/Tournament"; import { @@ -17,6 +18,9 @@ export type TournamentBracketsLoaderData = SerializeFrom; * Match data of the one bracket the view renders, selected by the `idx` search param. * The other brackets are represented by the layout's bracket state alone. Of a swiss * bracket only the group the view renders, selected by the `group` search param. + * + * Of a league only the brackets of one division are shown, selected by the `division` + * search param. Reaching the page without one lands on the divisions page instead. */ export const loader = async ({ params, request }: LoaderFunctionArgs) => { const { tournament, user } = await tournamentFromParams(params, { @@ -25,7 +29,19 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => { const searchParams = tournamentBracketsSearchParams.parse(request); - const bracketIdx = resolveBracketIdx(tournament, searchParams.idx); + const divisionIdx = resolveDivisionIdx(tournament, searchParams); + const hasDivisionToShow = + divisionIdx !== null && + tournament.visibleBracketsMetaOfDivision(divisionIdx).length > 0; + if (tournament.isLeague && !hasDivisionToShow) { + throw redirect(tournamentDivisionsPage(tournament.ctx.id)); + } + + const bracketIdx = resolveBracketIdx( + tournament, + searchParams.idx, + divisionIdx, + ); const bracket = tournament.bracketByIdx(bracketIdx); const groupId = resolveGroupId(bracket, searchParams.group); @@ -41,6 +57,7 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => { return { bracketIdx, + divisionIdx, groupId, bracket: bracket ? serializeBracket(bracket, { @@ -60,13 +77,39 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => { }; }; +/** + * The division to show the brackets of, of a league. Falls back to the division of the selected + * bracket, so that a link to one bracket of a division works without naming the division. + */ +function resolveDivisionIdx( + tournament: Tournament, + searchParams: { division: number | null; idx: number | null }, +) { + if (!tournament.isLeague) return null; + + const isDivision = tournament.leagueDivisions.some( + (division) => division.idx === searchParams.division, + ); + if (isDivision) { + return searchParams.division; + } + + return searchParams.idx !== null + ? tournament.leagueDivisionOfBracket(searchParams.idx) + : null; +} + /** * The bracket to show, always one of the brackets the view actually renders a tab for. Without * a valid `idx` the first bracket, unless it is over and followed by a bracket the tournament * actually continues in. */ -function resolveBracketIdx(tournament: Tournament, idx: number | null) { - const visibleBrackets = tournament.visibleBracketsMeta; +function resolveBracketIdx( + tournament: Tournament, + idx: number | null, + divisionIdx: number | null, +) { + const visibleBrackets = tournament.visibleBracketsMetaOfDivision(divisionIdx); const isVisible = (idx: number) => visibleBrackets.some((bracket) => bracket.idx === idx); @@ -74,15 +117,17 @@ function resolveBracketIdx(tournament: Tournament, idx: number | null) { return idx; } - const brackets = tournament.bracketsMeta; - const defaultIdx = + const brackets = tournament.bracketsMetaOfDivision(divisionIdx); + const defaultBracket = brackets.length <= 1 || brackets[1].isUnderground || !brackets[0].everyMatchOver - ? 0 - : 1; + ? brackets[0] + : brackets[1]; - return isVisible(defaultIdx) ? defaultIdx : (visibleBrackets[0]?.idx ?? 0); + return defaultBracket && isVisible(defaultBracket.idx) + ? defaultBracket.idx + : (visibleBrackets[0]?.idx ?? 0); } function resolveGroupId(bracket: Bracket | null, groupId: number | null) { diff --git a/app/features/tournament-bracket/loaders/to.$id.divisions.server.ts b/app/features/tournament-bracket/loaders/to.$id.divisions.server.ts deleted file mode 100644 index 1265ec1ef..000000000 --- a/app/features/tournament-bracket/loaders/to.$id.divisions.server.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { LoaderFunctionArgs } from "react-router"; -import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; -import { notFoundIfNullish } from "~/utils/remix.server"; -import type { Unwrapped } from "../../../utils/types"; -import { - tournamentFromDB, - tournamentFromParams, -} from "../core/Tournament.server"; - -export const loader = async ({ params }: LoaderFunctionArgs) => { - const { tournamentId, user } = await tournamentFromParams(params, { - for: "view", - }); - - const divisions = notFoundIfNullish(await divisionsCached(tournamentId)); - - return { - divisions, - divsParticipantOf: user - ? divisions - .filter((division) => division.participantUserIds.has(user?.id)) - .map((division) => division.tournamentId) - : [], - }; -}; - -// no purge mechanism in code but new divisions are created so rarely we just reboot the server when it is done -const tournamentDivisionsCache = new Map< - number, - Array> ->(); - -async function divisionsCached(tournamentId: number) { - if (!tournamentDivisionsCache.has(tournamentId)) { - const tournament = await tournamentFromDB(tournamentId); - - if (!tournament.isLeagueSignup) { - return null; - } - - tournamentDivisionsCache.set( - tournamentId, - await TournamentRepository.findChildTournaments(tournamentId), - ); - } - - return tournamentDivisionsCache.get(tournamentId)!; -} diff --git a/app/features/tournament-bracket/routes/to.$id.brackets.tsx b/app/features/tournament-bracket/routes/to.$id.brackets.tsx index 4d1b5d7c3..a6c80bbbc 100644 --- a/app/features/tournament-bracket/routes/to.$id.brackets.tsx +++ b/app/features/tournament-bracket/routes/to.$id.brackets.tsx @@ -56,7 +56,7 @@ import * as AbDivisions from "../core/AbDivisions"; import type { Bracket as BracketType } from "../core/Bracket"; import * as PreparedMaps from "../core/PreparedMaps"; import * as Progression from "../core/Progression"; -import type { BracketMeta, Tournament } from "../core/Tournament"; +import type { Tournament } from "../core/Tournament"; import { loader, type TournamentBracketsLoaderData, @@ -141,8 +141,7 @@ function TournamentBracketsView() { } = useBracketSpoilerCensor(); const showTeamActionsRow = - (!tournament.isLeagueDivision && Boolean(teamProgressStatus)) || - showAddSubsButton; + (!tournament.isLeague && Boolean(teamProgressStatus)) || showAddSubsButton; const showSecondaryActionsRow = tournament.canFinalize(user) || censored || canToggle; @@ -218,17 +217,13 @@ function TournamentBracketsView() { }); }; - if (tournament.isLeagueSignup) { - return null; - } - return (
{showTeamActionsRow ? (
{/** TournamentTeamActions more confusing than helpful for leagues, for example might say "Waiting for match..." when previous match was rescheduled */} - {!tournament.isLeagueDivision ? ( + {!tournament.isLeague ? ( ) : null} {showAddSubsButton ? : null} @@ -257,7 +252,10 @@ function TournamentBracketsView() { ) : null}
) : null} - + {bracket ? ( (team.startingBracketIdx ?? 0) === bracket.idx, - ).length; -} - -function bracketTabTeamCount(tournament: Tournament, bracket: BracketMeta) { - return bracket.preview - ? eligibleTeamCountForBracket(tournament, bracket) - : bracket.participantTournamentTeamIds.length; -} - function getAbDivisionsStartError( bracket: BracketType, tournament: Tournament, @@ -538,19 +510,22 @@ function SubsPopover({ children }: { children: React.ReactNode }) { /** * Bracket switcher. Only the bracket the loader shipped the match data of is rendered; * switching navigates so that the newly selected bracket's data gets loaded, the - * previously loaded bracket staying up until it arrives. + * previously loaded bracket staying up until it arrives. Of a league only the brackets + * of the division the loader resolved can be switched between. */ function BracketTabs({ loadedBracketIdx, + divisionIdx, children, }: { loadedBracketIdx: number; + divisionIdx: number | null; children: React.ReactNode; }) { const tournament = useTournament(); const [, setIdxParam] = useSearchParam(tournamentBracketsSearchParams, "idx"); - const visibleBrackets = tournament.visibleBracketsMeta; + const visibleBrackets = tournament.visibleBracketsMetaOfDivision(divisionIdx); const bracketNameForTab = (name: string) => name.replace("bracket", ""); @@ -564,7 +539,7 @@ function BracketTabs({ {bracketNameForTab(bracket.name)} @@ -726,10 +701,8 @@ function StartBracketAlert({ } const abDivisionsStartError = getAbDivisionsStartError(bracket, tournament); - const totalTeamsAvailableForTheBracket = eligibleTeamCountForBracket( - tournament, - tournament.bracketsMeta[bracketIdx], - ); + const totalTeamsAvailableForTheBracket = + tournament.eligibleTeamsCountOfBracket(bracketIdx); return (
diff --git a/app/features/tournament-bracket/routes/to.$id.divisions.tsx b/app/features/tournament-bracket/routes/to.$id.divisions.tsx index ca6744176..dd03ceac4 100644 --- a/app/features/tournament-bracket/routes/to.$id.divisions.tsx +++ b/app/features/tournament-bracket/routes/to.$id.divisions.tsx @@ -1,56 +1,68 @@ import clsx from "clsx"; import { Users } from "lucide-react"; import { useTranslation } from "react-i18next"; -import { Link, useLoaderData } from "react-router"; +import { Link } from "react-router"; +import { Redirect } from "~/components/Redirect"; +import { useUser } from "~/features/auth/core/user"; +import { useTournament } from "~/features/tournament/tournament-context"; import { tournamentBracketsPage } from "~/features/tournament-bracket/tournament-bracket-urls"; -import type { SerializeFrom } from "~/utils/remix"; - -import { loader } from "../loaders/to.$id.divisions.server"; +import type { BracketMeta } from "../core/Tournament"; import styles from "./to.$id.divisions.module.css"; -export { loader }; - export default function TournamentDivisionsPage() { - const data = useLoaderData(); + const tournament = useTournament(); + const user = useUser(); - if (data.divisions.length === 0) { + const ownTeam = tournament.teamMemberOfByUser(user); + const ownDivisionIdx = ownTeam ? (ownTeam.startingBracketIdx ?? 0) : null; + + if (!tournament.isLeague) { return ( -
- Divisions have not been released yet, check back later -
+ ); } return (
- {data.divisions.map((div) => ( - + {tournament.leagueDivisions.map((division) => ( + ))}
); } function DivisionLink({ - div, + division, + isParticipant, }: { - div: SerializeFrom["divisions"][number]; + division: BracketMeta; + isParticipant: boolean; }) { - const data = useLoaderData(); const { t } = useTranslation(["calendar"]); - const shortName = div.name.split("-").at(-1); + const tournament = useTournament(); return ( - {shortName} + {division.name}
{" "} {t("calendar:count.teams", { - count: div.teamsCount, + count: tournament.teamsCountOfBracket(division.idx), })}
diff --git a/app/features/tournament-bracket/tournament-bracket-search-params.test.ts b/app/features/tournament-bracket/tournament-bracket-search-params.test.ts index 2c487f54b..44ba63939 100644 --- a/app/features/tournament-bracket/tournament-bracket-search-params.test.ts +++ b/app/features/tournament-bracket/tournament-bracket-search-params.test.ts @@ -10,6 +10,7 @@ describe("tournamentBracketsSearchParams", () => { assertRoundTrips(tournamentBracketsSearchParams, { idx: [0, 3], group: [1, 173], + division: [0, 24], }); }); @@ -20,5 +21,9 @@ describe("tournamentBracketsSearchParams", () => { ["1.5"], ]); assertDecodesToDefault(tournamentBracketsSearchParams, "group", [["abc"]]); + assertDecodesToDefault(tournamentBracketsSearchParams, "division", [ + ["-1"], + ["abc"], + ]); }); }); diff --git a/app/features/tournament-bracket/tournament-bracket-search-params.ts b/app/features/tournament-bracket/tournament-bracket-search-params.ts index bdfd4299e..ba9245081 100644 --- a/app/features/tournament-bracket/tournament-bracket-search-params.ts +++ b/app/features/tournament-bracket/tournament-bracket-search-params.ts @@ -11,4 +11,12 @@ export const tournamentBracketsSearchParams = SearchParams.define({ group: SP.param(v.nullable(v.pipe(v.number(), v.integer())), { loader: true, }), + /** Starting bracket idx of the league division whose brackets are shown. Leagues only. */ + division: SP.param( + v.nullable(v.pipe(v.number(), v.integer(), v.minValue(0))), + { + loader: true, + resets: ["idx", "group"], + }, + ), }); diff --git a/app/features/tournament-bracket/tournament-bracket-urls.ts b/app/features/tournament-bracket/tournament-bracket-urls.ts index 60f1c6161..f190339bc 100644 --- a/app/features/tournament-bracket/tournament-bracket-urls.ts +++ b/app/features/tournament-bracket/tournament-bracket-urls.ts @@ -4,12 +4,15 @@ export const tournamentBracketsPage = ({ tournamentId, bracketIdx, groupId, + divisionIdx, }: { tournamentId: number; bracketIdx?: number | null; groupId?: number; + divisionIdx?: number | null; }) => tournamentBracketsSearchParams.href(`/to/${tournamentId}/brackets`, { idx: bracketIdx ?? null, group: groupId ?? null, + division: divisionIdx ?? null, }); diff --git a/app/features/tournament-lfg/loaders/to.$id.looking.server.ts b/app/features/tournament-lfg/loaders/to.$id.looking.server.ts index 7577a6f74..a3c670cb8 100644 --- a/app/features/tournament-lfg/loaders/to.$id.looking.server.ts +++ b/app/features/tournament-lfg/loaders/to.$id.looking.server.ts @@ -35,10 +35,6 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { throw new Response(null, { status: 404 }); } - if (tournament.isLeagueSignup && !tournament.registrationOpen) { - throw new Response(null, { status: 404 }); - } - if (user) { await resolveNotifications({ userIds: [user.id], diff --git a/app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts b/app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts index a38dd1262..cad060d75 100644 --- a/app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts +++ b/app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts @@ -197,7 +197,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { url: tournamentMatchPage({ tournamentId, matchId }), imageUrl: tournament.ctx.logoUrl, participantUserIds: playerIds, - expiresAfter: tournament.isLeagueDivision ? { days: 30 } : { hours: 2 }, + expiresAfter: tournament.isLeague ? { days: 30 } : { hours: 2 }, }); } @@ -211,7 +211,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { tournament.ctx.isFinalized && !isSiteStaff && !isTournamentStaff ? true : !chatAccessible({ - expiresAfterDays: tournament.isLeagueDivision ? 30 : 7, + expiresAfterDays: tournament.isLeague ? 30 : 7, comparedTo: tournament.ctx.startsAt, }); diff --git a/app/features/tournament-organization/tournament-organization-constants.ts b/app/features/tournament-organization/tournament-organization-constants.ts index 06c995464..f01f76466 100644 --- a/app/features/tournament-organization/tournament-organization-constants.ts +++ b/app/features/tournament-organization/tournament-organization-constants.ts @@ -3,6 +3,9 @@ export const TOURNAMENT_SERIES_LEADERBOARD_SIZE = 50; export const MONTH_PARAM_FORMAT = "yyyy-MM"; +/** Id of the "Leagues Under The Ink" (LUTI) organization. */ +export const LUTI_ORGANIZATION_ID = 19; + export const ESTABLISHED_ORG = { MONTHS_CONSIDERED: 6, GAIN_THRESHOLD: 150, diff --git a/app/features/tournament/TournamentRepository.divisionTiers.test.ts b/app/features/tournament/TournamentRepository.divisionTiers.test.ts new file mode 100644 index 000000000..6f24a3370 --- /dev/null +++ b/app/features/tournament/TournamentRepository.divisionTiers.test.ts @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, test } from "vitest"; +import * as TournamentFactory from "~/db/seed/factories/TournamentFactory"; +import * as UserFactory from "~/db/seed/factories/UserFactory"; +import * as TournamentRepository from "./TournamentRepository.server"; + +const users = UserFactory.pool(); +const authorId = () => users.id(1); + +describe("TournamentRepository.upsertDivisionTier", () => { + beforeEach(async () => { + await users.create(1); + }); + + const createTournament = async () => { + const { id } = await TournamentFactory.create({ authorId: authorId() }); + + return id; + }; + + const tierOf = async (tournamentId: number) => + (await TournamentRepository.findById(tournamentId))?.tier; + + test("gives the tournament the tier of its only division", async () => { + const tournamentId = await createTournament(); + + await TournamentRepository.upsertDivisionTier({ + tournamentId, + bracketIdx: 0, + tier: 4, + }); + + expect(await tierOf(tournamentId)).toBe(4); + }); + + test("gives the tournament the best tier of its divisions", async () => { + const tournamentId = await createTournament(); + + await TournamentRepository.upsertDivisionTier({ + tournamentId, + bracketIdx: 0, + tier: 3, + }); + await TournamentRepository.upsertDivisionTier({ + tournamentId, + bracketIdx: 2, + tier: 7, + }); + + expect(await tierOf(tournamentId)).toBe(3); + }); + + test("keeps the best tier when a stronger division is tiered last", async () => { + const tournamentId = await createTournament(); + + await TournamentRepository.upsertDivisionTier({ + tournamentId, + bracketIdx: 0, + tier: 7, + }); + await TournamentRepository.upsertDivisionTier({ + tournamentId, + bracketIdx: 2, + tier: 3, + }); + + expect(await tierOf(tournamentId)).toBe(3); + }); + + // a replaced tier stops counting: the tournament would still be tier 3 if the row was kept + test("replaces the tier of a division that is tiered again", async () => { + const tournamentId = await createTournament(); + + await TournamentRepository.upsertDivisionTier({ + tournamentId, + bracketIdx: 0, + tier: 3, + }); + await TournamentRepository.upsertDivisionTier({ + tournamentId, + bracketIdx: 2, + tier: 6, + }); + await TournamentRepository.upsertDivisionTier({ + tournamentId, + bracketIdx: 0, + tier: 8, + }); + + expect(await tierOf(tournamentId)).toBe(6); + }); +}); diff --git a/app/features/tournament/TournamentRepository.finalize.test.ts b/app/features/tournament/TournamentRepository.finalize.test.ts index bd7270662..f75699475 100644 --- a/app/features/tournament/TournamentRepository.finalize.test.ts +++ b/app/features/tournament/TournamentRepository.finalize.test.ts @@ -1,10 +1,12 @@ import { beforeEach, describe, expect, test } from "vitest"; import * as TournamentFactory from "~/db/seed/factories/TournamentFactory"; import * as TournamentTeamFactory from "~/db/seed/factories/TournamentTeamFactory"; +import * as TrophyFactory from "~/db/seed/factories/TrophyFactory"; import * as UserFactory from "~/db/seed/factories/UserFactory"; import { db } from "~/db/sql"; import type { TournamentSummary } from "../tournament-bracket/core/summarizer.server"; import * as TournamentRepository from "./TournamentRepository.server"; +import * as TournamentTeamRepository from "./TournamentTeamRepository.server"; /** SQLite binds at most 32,766 parameters per statement and `PlayerResult` has * eight columns, so one multi-row insert fits this many rows at most. */ @@ -244,6 +246,7 @@ describe("TournamentRepository.finalize", () => { expect(second.matchesCount).toBe(8); }); + test("finalizes a tournament with more player result deltas than fit in one insert statement", async () => { const { id: tournamentId } = await createTournament(); const playerResultDeltas = playerResultDeltasForEveryPair(users.ids()); @@ -266,4 +269,80 @@ describe("TournamentRepository.finalize", () => { expect(inserted.count).toBe(playerResultDeltas.length); }); + + describe("trophy of a tournament with many divisions", () => { + const TOP_DIVISION_TIER = 2; + const LOW_DIVISION_TIER = 7; + + const finalizeWithTrophyWonBy = async ({ + startingBracketIdx, + divisionTiers = true, + }: { + startingBracketIdx: number; + divisionTiers?: boolean; + }) => { + const { id: tournamentId } = await TournamentFactory.create( + { authorId: users.id(1) }, + { tier: TOP_DIVISION_TIER }, + ); + const trophy = await TrophyFactory.create(); + const { id: tournamentTeamId } = await TournamentTeamFactory.create({ + tournamentId, + memberUserIds: [users.id(1)], + }); + + await TournamentTeamRepository.updateStartingBrackets([ + { tournamentTeamId, startingBracketIdx }, + ]); + if (divisionTiers) { + await TournamentRepository.upsertDivisionTier({ + tournamentId, + bracketIdx: 1, + tier: LOW_DIVISION_TIER, + }); + } + + await TournamentRepository.finalize({ + tournamentId, + season: undefined, + summary: { + ...emptySummary([]), + tournamentResults: [ + { + userId: users.id(1), + placement: 1, + participantCount: 1, + tournamentTeamId, + div: null, + }, + ], + setResults: new Map([[users.id(1), ["W"]]]), + }, + trophyReceiver: { trophyId: trophy.id, userIds: [users.id(1)] }, + }); + + const owner = await db + .selectFrom("TrophyOwner") + .select("tier") + .where("tournamentId", "=", tournamentId) + .executeTakeFirstOrThrow(); + + return owner.tier; + }; + + test("records the tier of the division it was won in", async () => { + expect(await finalizeWithTrophyWonBy({ startingBracketIdx: 1 })).toBe( + LOW_DIVISION_TIER, + ); + }); + + test("records the tournament's tier when the division has none", async () => { + expect( + await finalizeWithTrophyWonBy({ + startingBracketIdx: 1, + divisionTiers: false, + }), + ).toBe(TOP_DIVISION_TIER); + }); + }); }); diff --git a/app/features/tournament/TournamentRepository.server.ts b/app/features/tournament/TournamentRepository.server.ts index d7bfa1587..c73aebdf6 100644 --- a/app/features/tournament/TournamentRepository.server.ts +++ b/app/features/tournament/TournamentRepository.server.ts @@ -1,5 +1,11 @@ import { sub } from "date-fns"; -import { type Insertable, type NotNull, sql, type Transaction } from "kysely"; +import { + type Insertable, + type NotNull, + type SqlBool, + sql, + type Transaction, +} from "kysely"; import { ordinal } from "openskill"; import * as R from "remeda"; import { db } from "~/db/sql"; @@ -58,16 +64,6 @@ export async function findById(id: number) { "Tournament.castedMatchesInfo", "Tournament.mapPickingStyle", sql`"Tournament"."rules" is not null`.as("hasRules"), - "Tournament.parentTournamentId", - eb - .selectFrom("CalendarEvent as ParentCalendarEvent") - .select("ParentCalendarEvent.name") - .whereRef( - "ParentCalendarEvent.tournamentId", - "=", - "Tournament.parentTournamentId", - ) - .as("parentTournamentName"), "Tournament.tier", "CalendarEvent.name", "CalendarEventDate.startsAt", @@ -703,67 +699,6 @@ export async function findSeedingSnapshotById(tournamentId: number) { return row?.seedingSnapshot ?? null; } -export async function hasChildTournaments(parentTournamentId: number) { - const row = await db - .selectFrom("Tournament") - .select("Tournament.id") - .where("Tournament.parentTournamentId", "=", parentTournamentId) - .limit(1) - .executeTakeFirst(); - - return Boolean(row); -} - -export async function findChildTournaments(parentTournamentId: number) { - const rows = await db - .selectFrom("Tournament") - .innerJoin("CalendarEvent", "Tournament.id", "CalendarEvent.tournamentId") - .select((eb) => [ - "Tournament.id as tournamentId", - "CalendarEvent.name", - eb - .selectFrom("TournamentTeam") - .select(({ fn }) => [fn.countAll().as("teamsCount")]) - .whereRef("TournamentTeam.tournamentId", "=", "Tournament.id") - .where("TournamentTeam.isPlaceholder", "=", 0) - .as("teamsCount"), - jsonArrayFrom( - eb - .selectFrom("TournamentTeam") - .innerJoin( - "TournamentTeamMember", - "TournamentTeamMember.tournamentTeamId", - "TournamentTeam.id", - ) - .select(["TournamentTeamMember.userId"]) - .whereRef("TournamentTeam.tournamentId", "=", "Tournament.id") - .where("TournamentTeam.isPlaceholder", "=", 0), - ).as("teamMembers"), - ]) - .where("Tournament.parentTournamentId", "=", parentTournamentId) - .$narrowType<{ teamsCount: NotNull }>() - .execute(); - - return rows.map((row) => ({ - ...row, - participantUserIds: new Set(row.teamMembers.map((member) => member.userId)), - })); -} - -/** Child division tournaments of a league sign-up, with their name and finalized status. */ -export function findChildTournamentsForDivCalc(parentTournamentId: number) { - return db - .selectFrom("Tournament") - .innerJoin("CalendarEvent", "Tournament.id", "CalendarEvent.tournamentId") - .select([ - "Tournament.id as tournamentId", - "CalendarEvent.name", - "Tournament.isFinalized", - ]) - .where("Tournament.parentTournamentId", "=", parentTournamentId) - .execute(); -} - /** * Per-user results of a finalized tournament as persisted at finalization time. * Empty for tournaments that have not been finalized. @@ -783,22 +718,58 @@ export function findResultsByTournamentId(tournamentId: number) { } /** - * User ids eligible for a LUTI division placement in the given tournament: they have a result, were - * on a team that did not drop out, and played at least one match. + * Participants of the latest finalized league of the given organization, along with the bracket + * progression that tells what division (= starting bracket) each of them played in. + * + * Only participants eligible for a division placement are included: they have a result, were on a + * team that did not drop out, and played at least one match. Null if the organization has no + * finalized league. */ -export function findLeagueDivParticipantUserIds(tournamentId: number) { - return db +export async function findLatestFinalizedLeagueParticipants(args: { + organizationId: number; + namePrefix: string; +}) { + const league = await db + .selectFrom("Tournament") + .innerJoin("CalendarEvent", "Tournament.id", "CalendarEvent.tournamentId") + .innerJoin( + "CalendarEventDate", + "CalendarEvent.id", + "CalendarEventDate.eventId", + ) + .select(["Tournament.id", "Tournament.settings"]) + .where("CalendarEvent.organizationId", "=", args.organizationId) + .where("CalendarEvent.name", "like", `${args.namePrefix}%`) + .where("Tournament.isFinalized", "=", 1) + .where( + sql`json_extract("Tournament"."settings", '$.isLeague')`, + "=", + 1, + ) + .orderBy("CalendarEventDate.startsAt", "desc") + .limit(1) + .executeTakeFirst(); + + if (!league) return null; + + const participants = await db .selectFrom("TournamentResult") .innerJoin( "TournamentTeam", "TournamentTeam.id", "TournamentResult.tournamentTeamId", ) - .select("TournamentResult.userId") + .select(["TournamentResult.userId", "TournamentTeam.startingBracketIdx"]) .distinct() - .where("TournamentResult.tournamentId", "=", tournamentId) + .where("TournamentResult.tournamentId", "=", league.id) .where("TournamentTeam.droppedOut", "=", 0) .execute(); + + return { + tournamentId: league.id, + bracketProgression: league.settings.bracketProgression, + participants, + }; } export async function findTOSetMapPoolById(tournamentId: number) { @@ -1591,11 +1562,12 @@ export function finalize({ await trx.insertInto("TournamentBadgeOwner").values(badgeOwners).execute(); if (trophyReceiver && trophyReceiver.userIds.length > 0) { - const tournamentRow = await trx - .selectFrom("Tournament") - .select("tier") - .where("id", "=", tournamentId) - .executeTakeFirst(); + const tier = await trophyTier(trx, { + tournamentId, + tournamentTeamId: summary.tournamentResults.find((result) => + trophyReceiver.userIds.includes(result.userId), + )?.tournamentTeamId, + }); await trx .insertInto("TrophyOwner") @@ -1604,7 +1576,7 @@ export function finalize({ tournamentId, trophyId: trophyReceiver.trophyId, userId, - tier: tournamentRow?.tier ?? null, + tier, })), ) .onConflict((oc) => @@ -1781,18 +1753,43 @@ export function updateTeamSeeds({ }); } -export function updateTournamentTier({ +/** + * Records the tier of one division (= starting bracket), calculated from the teams that checked in + * to it, and updates the tournament's own tier to the best tier of its divisions. Tournaments where + * every team plays the same bracket have one division, making the two the same. + */ +export async function upsertDivisionTier({ tournamentId, + bracketIdx, tier, }: { tournamentId: number; + bracketIdx: number; tier: TournamentTierNumber; }) { - return db - .updateTable("Tournament") - .set({ tier }) - .where("id", "=", tournamentId) - .execute(); + await db.transaction().execute(async (trx) => { + await trx + .insertInto("TournamentDivisionTier") + .values({ tournamentId, bracketIdx, tier }) + .onConflict((oc) => + oc.columns(["tournamentId", "bracketIdx"]).doUpdateSet({ tier }), + ) + .execute(); + + const best = await trx + .selectFrom("TournamentDivisionTier") + .select(({ fn }) => + fn.min("TournamentDivisionTier.tier").as("tier"), + ) + .where("TournamentDivisionTier.tournamentId", "=", tournamentId) + .executeTakeFirstOrThrow(); + + await trx + .updateTable("Tournament") + .set({ tier: best.tier }) + .where("id", "=", tournamentId) + .execute(); + }); } export async function findRunningTournamentIds() { @@ -1828,3 +1825,41 @@ export async function findRunningTournamentIds() { return rows.map((row) => row.id); } + +/** + * Tier the trophy was won at: the tier of the division the winning team played in, falling back to + * the tournament's own tier when the team is not known or its division was never tiered. + */ +async function trophyTier( + trx: Transaction, + { + tournamentId, + tournamentTeamId, + }: { tournamentId: number; tournamentTeamId?: number }, +) { + const divisionTier = tournamentTeamId + ? await trx + .selectFrom("TournamentDivisionTier") + .innerJoin( + "TournamentTeam", + "TournamentTeam.tournamentId", + "TournamentDivisionTier.tournamentId", + ) + .select("TournamentDivisionTier.tier") + .where("TournamentTeam.id", "=", tournamentTeamId) + .where( + sql`"TournamentDivisionTier"."bracketIdx" = coalesce("TournamentTeam"."startingBracketIdx", 0)`, + ) + .executeTakeFirst() + : undefined; + + if (divisionTier) return divisionTier.tier; + + const tournament = await trx + .selectFrom("Tournament") + .select("tier") + .where("id", "=", tournamentId) + .executeTakeFirst(); + + return tournament?.tier ?? null; +} diff --git a/app/features/tournament/TournamentTeamRepository.server.ts b/app/features/tournament/TournamentTeamRepository.server.ts index 9511f317d..421c235b7 100644 --- a/app/features/tournament/TournamentTeamRepository.server.ts +++ b/app/features/tournament/TournamentTeamRepository.server.ts @@ -505,107 +505,6 @@ async function resolveInGameName( return user.inGameName; } -export function copyFromAnotherTournament({ - tournamentTeamId, - destinationTournamentId, - seed, - defaultCheckedIn = false, -}: { - tournamentTeamId: number; - destinationTournamentId: number; - seed?: number; - defaultCheckedIn?: boolean; -}) { - return db.transaction().execute(async (trx) => { - const oldTeam = await trx - .selectFrom("TournamentTeam") - .select([ - "TournamentTeam.avatarImgId", - "TournamentTeam.createdAt", - "TournamentTeam.name", - "TournamentTeam.prefersNotToHost", - "TournamentTeam.teamId", - - // -- exclude these - // "TournamentTeam.id" - // "TournamentTeam.droppedOut" - // "TournamentTeam.activeRosterUserIds" - // "TournamentTeam.seed" - // "TournamentTeam.startingBracketIdx" - // "TournamentTeam.inviteCode" - // "TournamentTeam.tournamentId" - // "TournamentTeam.activeRosterUserIds", - ]) - .where("id", "=", tournamentTeamId) - .executeTakeFirstOrThrow(); - - const oldMembers = await trx - .selectFrom("TournamentTeamMember") - .select([ - "TournamentTeamMember.createdAt", - "TournamentTeamMember.inGameName", - "TournamentTeamMember.role", - "TournamentTeamMember.userId", - "TournamentTeamMember.isSub", - "TournamentTeamMember.isOrganizerAdded", - - // -- exclude these - // "TournamentTeamMember.tournamentTeamId" - ]) - .where("tournamentTeamId", "=", tournamentTeamId) - .execute(); - invariant(oldMembers.length > 0, "Team has no members"); - - const oldMapPool = await trx - .selectFrom("MapPoolMap") - .select(["MapPoolMap.mode", "MapPoolMap.stageId"]) - .where("tournamentTeamId", "=", tournamentTeamId) - .execute(); - - const newTeam = await trx - .insertInto("TournamentTeam") - .values({ - ...oldTeam, - tournamentId: destinationTournamentId, - inviteCode: shortNanoid(), - seed, - }) - .returning("id") - .executeTakeFirstOrThrow(); - - if (defaultCheckedIn) { - await trx - .insertInto("TournamentTeamCheckIn") - .values({ - checkedInAt: databaseTimestampNow(), - tournamentTeamId: newTeam.id, - bracketIdx: null, - }) - .execute(); - } - - await trx - .insertInto("TournamentTeamMember") - .values( - oldMembers.map((member) => ({ - ...member, - tournamentTeamId: newTeam.id, - })), - ) - .execute(); - - await trx - .insertInto("MapPoolMap") - .values( - oldMapPool.map((mapPoolMap) => ({ - ...mapPoolMap, - tournamentTeamId: newTeam.id, - })), - ) - .execute(); - }); -} - export function update({ team, avatarImgId, diff --git a/app/features/tournament/actions/to.$id.register.server.ts b/app/features/tournament/actions/to.$id.register.server.ts index 1bee9fcec..b43e90374 100644 --- a/app/features/tournament/actions/to.$id.register.server.ts +++ b/app/features/tournament/actions/to.$id.register.server.ts @@ -353,7 +353,7 @@ export const action: ActionFunction = async ({ request, params }) => { "You cannot unregister after checking in", ); errorToastIfFalsy( - !tournament.isLeagueSignup || tournament.registrationOpen, + !tournament.isLeague || tournament.registrationOpen, "Unregistering from leagues is not possible after registration has closed", ); diff --git a/app/features/tournament/components/TournamentNav.tsx b/app/features/tournament/components/TournamentNav.tsx index 9a31d919e..967811a85 100644 --- a/app/features/tournament/components/TournamentNav.tsx +++ b/app/features/tournament/components/TournamentNav.tsx @@ -21,19 +21,15 @@ import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "~/features/admin/core/dev-con import { useUser } from "~/features/auth/core/user"; import type { Tournament } from "~/features/tournament-bracket/core/Tournament"; import { useIsomorphicLayoutEffect } from "~/hooks/useIsomorphicLayoutEffect"; -import { - tournamentDivisionsPage, - tournamentInfoPage, - tournamentRulesPage, -} from "~/utils/urls"; +import { tournamentInfoPage, tournamentRulesPage } from "~/utils/urls"; import { tournamentNameParts } from "../tournament-utils"; import styles from "./TournamentNav.module.css"; type NavItemKey = | "register" | "brackets" - | "teams" | "divisions" + | "teams" | "streams" | "results" | "rules" @@ -52,10 +48,10 @@ interface NavItem { const PRIORITY_ORDER: NavItemKey[] = [ "register", "brackets", + "divisions", "teams", "results", "lfg", - "divisions", "streams", "rules", "admin", @@ -64,18 +60,12 @@ const PRIORITY_ORDER: NavItemKey[] = [ export function TournamentNav({ tournament, streamsCount, - hasChildTournaments, }: { tournament: Tournament; streamsCount: number; - hasChildTournaments: boolean; }) { const { t } = useTranslation(["tournament"]); - const navItems = useNavItems({ - tournament, - streamsCount, - hasChildTournaments, - }); + const navItems = useNavItems({ tournament, streamsCount }); const { visibleCount, containerRef, measureRef } = useNavOverflow( navItems.length, ); @@ -85,9 +75,7 @@ export function TournamentNav({ const { name, subtext } = tournamentNameParts(tournament); - const homeHref = tournament.isLeagueDivision - ? tournamentInfoPage(tournament.ctx.parentTournamentId!) - : tournamentInfoPage(tournament.ctx.id); + const homeHref = tournamentInfoPage(tournament.ctx.id); return (
- {!tournament.isLeagueSignup ? ( + {!tournament.isLeague ? ( step.status === "incomplete").length === 1 @@ -367,7 +371,7 @@ function RegistrationProgress({ ) : null}
- {regClosesBeforeStart || tournament.isLeagueSignup ? ( + {regClosesBeforeStart || tournament.isLeague ? ( Registration closes at {registrationClosesAtString} @@ -501,7 +505,7 @@ function TeamInfo({ 1. {t("tournament:pre.info.header")} {canUnregister && - tournament.isLeagueSignup && + tournament.isLeague && !tournament.registrationOpen ? ( (); const pagination = useSearchParamPagination({ definition: tournamentTeamsSearchParams, @@ -22,10 +19,6 @@ export default function TournamentTeamsPage() { pagesCount: data.pagesCount, }); - if (tournament.isLeagueSignup && hasChildTournaments) { - return ; - } - const seedInfoByTeamId = teamSeedInfo(tournament); return ( diff --git a/app/features/tournament/routes/to.$id.tsx b/app/features/tournament/routes/to.$id.tsx index b381fcde4..c94dfb8f0 100644 --- a/app/features/tournament/routes/to.$id.tsx +++ b/app/features/tournament/routes/to.$id.tsx @@ -111,11 +111,7 @@ export function TournamentLayout() { } const content = ( <> - + void; - hasChildTournaments: boolean; friendCode?: string; friendCodes?: TournamentLoaderData["friendCodes"]; preparedMaps: TournamentLoaderData["preparedMaps"]; @@ -161,10 +155,6 @@ export function useBracketExpanded() { return { bracketExpanded, setBracketExpanded }; } -export function useHasChildTournaments() { - return useOutletContext().hasChildTournaments; -} - export function useTournamentFriendCodes() { return useOutletContext().friendCodes; } diff --git a/app/features/tournament/tournament-constants.ts b/app/features/tournament/tournament-constants.ts index c8cbbb0b9..506b583a5 100644 --- a/app/features/tournament/tournament-constants.ts +++ b/app/features/tournament/tournament-constants.ts @@ -1,4 +1,3 @@ -import { Config } from "~/config"; import { TEAM } from "../team/team-constants"; export const TOURNAMENT = { @@ -71,81 +70,3 @@ export const TOURNAMENT_AUDIT_LOG_TYPES = [ export type TournamentAuditLogType = (typeof TOURNAMENT_AUDIT_LOG_TYPES)[number]; - -export const LEAGUES = - process.env.NODE_ENV === "development" && !Config.prodMode - ? { - LUTI: [ - { - tournamentId: 6, - weeks: [ - { - weekNumber: 2, - year: 2025, - }, - { - weekNumber: 3, - year: 2025, - }, - { - weekNumber: 4, - year: 2025, - }, - ], - }, - ], - } - : { - LUTI: [ - { - tournamentId: 1066, - weeks: [ - { - weekNumber: 10, - year: 2025, - }, - { - weekNumber: 11, - year: 2025, - }, - { - weekNumber: 12, - year: 2025, - }, - { - weekNumber: 13, - year: 2025, - }, - { - weekNumber: 14, - year: 2025, - }, - ], - }, - { - tournamentId: 3192, - weeks: [ - { - weekNumber: 9, - year: 2026, - }, - { - weekNumber: 10, - year: 2026, - }, - { - weekNumber: 11, - year: 2026, - }, - { - weekNumber: 12, - year: 2026, - }, - { - weekNumber: 13, - year: 2026, - }, - ], - }, - ], - }; diff --git a/app/features/tournament/tournament-utils.test.ts b/app/features/tournament/tournament-utils.test.ts index badd88bfd..b5b9084d6 100644 --- a/app/features/tournament/tournament-utils.test.ts +++ b/app/features/tournament/tournament-utils.test.ts @@ -760,22 +760,7 @@ describe("splitTournamentName", () => { }); describe("tournamentNameParts", () => { - test("uses the parent tournament name and division subtext for a league division", () => { - const tournament = testTournament({ - ctx: { - name: "LUTI: Season 17 - Division 1", - parentTournamentId: 1, - parentTournamentName: "LUTI: Season 17", - }, - }); - - expect(tournamentNameParts(tournament)).toEqual({ - name: "LUTI: Season 17", - subtext: "Division 1", - }); - }); - - test("falls back to the organization series when not a league division", () => { + test("splits the name by the organization series", () => { const tournament = testTournament({ ctx: { name: "In The Zone 54", @@ -865,6 +850,38 @@ describe("bracketProgressionLabel", () => { ).toEqual({ label: "SW → SE", hasUnderground: true }); }); + test("describes divisions leading to the same shape once", () => { + const division = (idx: number) => [ + bracket({ type: "round_robin", name: `Division ${idx}` }), + bracket({ + type: "single_elimination", + name: `Division ${idx} Playoffs`, + sources: [{ bracketIdx: idx * 2, placements: [1, 2] }], + }), + ]; + + expect( + bracketProgressionLabel([...division(0), ...division(1), ...division(2)]), + ).toEqual({ label: "RR → SE", hasUnderground: false }); + }); + + test("describes every starting bracket when they lead to different shapes", () => { + expect( + bracketProgressionLabel([ + bracket({ type: "round_robin" }), + bracket({ + type: "single_elimination", + sources: [{ bracketIdx: 0, placements: [1, 2] }], + }), + bracket({ type: "swiss" }), + bracket({ + type: "double_elimination", + sources: [{ bracketIdx: 2, placements: [1, 2] }], + }), + ]), + ).toEqual({ label: "RR → SE → SW → DE", hasUnderground: false }); + }); + test("returns empty label for empty progression", () => { expect(bracketProgressionLabel([])).toEqual({ label: "", diff --git a/app/features/tournament/tournament-utils.ts b/app/features/tournament/tournament-utils.ts index cec1ea16c..5d3c69f2d 100644 --- a/app/features/tournament/tournament-utils.ts +++ b/app/features/tournament/tournament-utils.ts @@ -3,7 +3,7 @@ import * as R from "remeda"; import type { CastedMatchesInfo } from "~/db/tables-json"; import { modesShort, rankedModesShort } from "~/modules/in-game-lists/modes"; import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; -import { weekNumberToDate } from "~/utils/dates"; +import { databaseTimestampToDate } from "~/utils/dates"; import { SHORT_NANOID_LENGTH } from "~/utils/id"; import type { Tables } from "../../db/tables"; import { MapPool } from "../map-list-generator/core/map-pool"; @@ -14,7 +14,7 @@ import type { ParsedBracket } from "../tournament-bracket/core/Progression"; import * as Progression from "../tournament-bracket/core/Progression"; import type { Tournament as TournamentClass } from "../tournament-bracket/core/Tournament"; import type { TournamentData } from "../tournament-bracket/core/Tournament.server"; -import { LEAGUES, TOURNAMENT } from "./tournament-constants"; +import { TOURNAMENT } from "./tournament-constants"; const mapPickingStyleToModeRecord = { AUTO_SZ: ["SZ"], @@ -181,37 +181,18 @@ export function tournamentInWeaponReportingWindow({ return tournamentStartTime > windowStart; } +/** Datetime the league round is played by default, or null if the round has no default play time. */ export function resolveLeagueRoundStartDate( tournament: TournamentClass, bracket: BracketClass | undefined, roundId: number, ) { - if (!tournament.isLeagueDivision) return null; - - const league = Object.values(LEAGUES) - .flat() - .find( - (league) => league.tournamentId === tournament.ctx.parentTournamentId, - ); - if (!league) return null; + if (!tournament.isLeague) return null; const round = bracket?.data.round.find((r) => r.id === roundId); - const onlyRelevantRounds = bracket?.data.round.filter( - (r) => r.groupId === round?.groupId, - ); + if (!round?.defaultPlayTime) return null; - const roundIdx = onlyRelevantRounds?.findIndex((r) => r.id === roundId); - if (roundIdx === undefined) return null; - - const week = league.weeks[roundIdx]; - if (!week) return null; - - const date = weekNumberToDate({ - week: week.weekNumber, - year: week.year, - }); - - return date; + return databaseTimestampToDate(round.defaultPlayTime); } const EARLIEST_TIMEZONE_OFFSET_HOURS = 14; @@ -505,11 +486,8 @@ export function splitTournamentName( } /** - * Resolves the display name and subtext for a tournament's identity. - * - * For a league division the parent tournament name is used as the base name and - * the division name (e.g. `"Division 1"`) becomes the subtext. For all other - * tournaments the split is based on the organization's tournament series. + * Resolves the display name and subtext for a tournament's identity, based on + * the organization's tournament series. * * @see {@link splitTournamentName} */ @@ -517,12 +495,6 @@ export function tournamentNameParts(tournament: TournamentClass): { name: string; subtext?: string; } { - if (tournament.isLeagueDivision && tournament.ctx.parentTournamentName) { - return splitTournamentName(tournament.ctx.name, [ - { name: tournament.ctx.parentTournamentName }, - ]); - } - return splitTournamentName( tournament.ctx.name, tournament.ctx.organization?.series ?? [], @@ -552,6 +524,9 @@ const STAGE_TYPE_TO_SHORT_CODE: Record< * caller can render a `+ UG` suffix. Their type and where they branch off from is deliberately not * conveyed, keeping the label to one shape no matter how the underground brackets are set up. * + * Starting brackets that lead to the same shape (a league's divisions) are described once, as they + * are played in parallel rather than one after the other. + * * @example * // [{type: "round_robin"}, {type: "single_elimination"}, ...underground SE brackets] * bracketProgressionLabel(progression) // { label: "RR → SE", hasUnderground: true } @@ -560,22 +535,47 @@ export function bracketProgressionLabel(progression: ParsedBracket[]): { label: string; hasUnderground: boolean; } { - const mainCodes: string[] = []; - let hasUnderground = false; + return { + label: labelOfBrackets(labeledBracketIdxs(progression), progression), + hasUnderground: progression.some((_, idx) => + Progression.isUnderground(idx, progression), + ), + }; +} - for (let i = 0; i < progression.length; i++) { - if (Progression.isUnderground(i, progression)) { - hasUnderground = true; - continue; - } +/** Short code of every given bracket, arrow separated, consecutive duplicates collapsed. */ +function labelOfBrackets(bracketIdxs: number[], progression: ParsedBracket[]) { + const codes: string[] = []; - const code = STAGE_TYPE_TO_SHORT_CODE[progression[i].type]; - if (mainCodes.at(-1) !== code) { - mainCodes.push(code); + for (const idx of bracketIdxs) { + if (Progression.isUnderground(idx, progression)) continue; + + const code = STAGE_TYPE_TO_SHORT_CODE[progression[idx].type]; + if (codes.at(-1) !== code) { + codes.push(code); } } - return { label: mainCodes.join(" → "), hasUnderground }; + return codes.join(" → "); +} + +/** + * Brackets the label describes: every one of them, or the brackets of one starting bracket when + * the tournament has many that all lead to the same shape. + */ +function labeledBracketIdxs(progression: ParsedBracket[]) { + const everyBracketIdx = progression.map((_, idx) => idx); + + const branches = Progression.startingBrackets(progression).map((idx) => + Progression.bracketsReachableFrom(idx, progression).sort((a, b) => a - b), + ); + if (branches.length <= 1) return everyBracketIdx; + + const labels = branches.map((branch) => labelOfBrackets(branch, progression)); + + return labels.every((label) => label === labels[0]) + ? branches[0] + : everyBracketIdx; } /** diff --git a/app/features/user-page/UserRepository.server.ts b/app/features/user-page/UserRepository.server.ts index e69c9812f..c5da444a0 100644 --- a/app/features/user-page/UserRepository.server.ts +++ b/app/features/user-page/UserRepository.server.ts @@ -1,4 +1,4 @@ -import type { ExpressionBuilder, NotNull } from "kysely"; +import type { ExpressionBuilder, NotNull, SqlBool } from "kysely"; import { sql } from "kysely"; import * as R from "remeda"; import { db } from "~/db/sql"; @@ -681,6 +681,11 @@ const baseCalendarEventResultsQuery = ( return query; }; +/** Tier of the division the result was placed in, falling back to the tournament's own tier. */ +const RESULT_TIER = sql< + Tables["Tournament"]["tier"] +>`coalesce("TournamentDivisionTier"."tier", "Tournament"."tier")`; + const baseTournamentResultsQuery = ( userId: number, filters: ResultsFilters, @@ -698,6 +703,17 @@ const baseTournamentResultsQuery = ( "TournamentResult.tournamentId", ) .innerJoin("Tournament", "Tournament.id", "TournamentResult.tournamentId") + .leftJoin("TournamentDivisionTier", (join) => + join + .onRef( + "TournamentDivisionTier.tournamentId", + "=", + "TournamentResult.tournamentId", + ) + .on( + sql`"TournamentDivisionTier"."bracketIdx" = coalesce("TournamentTeam"."startingBracketIdx", 0)`, + ), + ) .where("TournamentResult.userId", "=", userId); if (!includesTournamentResults(filters)) { @@ -737,8 +753,8 @@ const baseTournamentResultsQuery = ( if (isTierFiltered(filters)) { query = query - .where("Tournament.tier", ">=", filters.minTier ?? BEST_TIER_NUMBER) - .where("Tournament.tier", "<=", filters.maxTier ?? WORST_TIER_NUMBER); + .where(RESULT_TIER, ">=", filters.minTier ?? BEST_TIER_NUMBER) + .where(RESULT_TIER, "<=", filters.maxTier ?? WORST_TIER_NUMBER); } if (filters.maxPlacement) { @@ -843,7 +859,7 @@ export function findResultsByUserId( "TournamentTeam.id as teamId", "TournamentTeam.name as teamName", "TournamentResult.isHighlight", - "Tournament.tier", + RESULT_TIER.as("tier"), withMaxEventStartTime(eb), jsonArrayFrom( eb diff --git a/app/features/user-page/UserRepository.test.ts b/app/features/user-page/UserRepository.test.ts index 36e52cfdd..86ca6f900 100644 --- a/app/features/user-page/UserRepository.test.ts +++ b/app/features/user-page/UserRepository.test.ts @@ -2,7 +2,10 @@ import { describe, expect, test } from "vitest"; import * as CalendarEventFactory from "~/db/seed/factories/CalendarEventFactory"; import * as CalendarEventResultFactory from "~/db/seed/factories/CalendarEventResultFactory"; import * as TournamentFactory from "~/db/seed/factories/TournamentFactory"; +import * as TournamentTeamFactory from "~/db/seed/factories/TournamentTeamFactory"; import * as UserFactory from "~/db/seed/factories/UserFactory"; +import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; +import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server"; import { dateToDatabaseTimestamp } from "~/utils/dates"; import * as UserRepository from "./UserRepository.server"; @@ -288,6 +291,89 @@ describe("UserRepository", () => { expect(results).toHaveLength(1); expect(results[0].eventName).toBe("Alpha Invitational"); }); + + describe("of a tournament with many divisions", () => { + const TOP_DIVISION_TIER = 2; + const LOW_DIVISION_TIER = 7; + + const seedDivisionedResults = async () => { + const [topUser, lowUser, topMate, lowMate] = + await UserFactory.createMany(4); + + const { id: tournamentId } = await TournamentFactory.create( + { + name: "Divisioned Open", + authorId: topUser.id, + minMembersPerTeam: 1, + bracketProgression: [ + { + name: "Top Division", + type: "single_elimination", + requiresCheckIn: false, + settings: { thirdPlaceMatch: false }, + }, + { + name: "Low Division", + type: "single_elimination", + requiresCheckIn: false, + settings: { thirdPlaceMatch: false }, + }, + ], + }, + {}, + ); + + const teams = []; + for (const user of [topUser, topMate, lowUser, lowMate]) { + teams.push( + await TournamentTeamFactory.create( + { tournamentId, memberUserIds: [user.id] }, + { isCheckedIn: true }, + ), + ); + } + await TournamentTeamRepository.updateStartingBrackets( + teams.map((team, idx) => ({ + tournamentTeamId: team.id, + startingBracketIdx: idx < 2 ? 0 : 1, + })), + ); + + await TournamentFactory.playOut(tournamentId, "all"); + + await TournamentRepository.upsertDivisionTier({ + tournamentId, + bracketIdx: 0, + tier: TOP_DIVISION_TIER, + }); + await TournamentRepository.upsertDivisionTier({ + tournamentId, + bracketIdx: 1, + tier: LOW_DIVISION_TIER, + }); + + return { topUserId: topUser.id, lowUserId: lowUser.id }; + }; + + test("reports the tier of the division the result is from", async () => { + const { topUserId, lowUserId } = await seedDivisionedResults(); + + const [topResult] = await filteredResults(topUserId, {}); + const [lowResult] = await filteredResults(lowUserId, {}); + + expect(topResult.tier).toBe(TOP_DIVISION_TIER); + expect(lowResult.tier).toBe(LOW_DIVISION_TIER); + }); + + test("filters by the tier of the division the result is from", async () => { + const { topUserId, lowUserId } = await seedDivisionedResults(); + + const topRange = { minTier: 1, maxTier: 3 } as const; + + expect(await filteredResults(topUserId, topRange)).toHaveLength(1); + expect(await filteredResults(lowUserId, topRange)).toHaveLength(0); + }); + }); }); describe("userRoles", () => { diff --git a/app/features/user-search/routes/u.tsx b/app/features/user-search/routes/u.tsx deleted file mode 100644 index ce11f7969..000000000 --- a/app/features/user-search/routes/u.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { redirect } from "react-router"; - -export const loader = () => { - return redirect("/?search=open&type=users"); -}; diff --git a/app/modules/redirects/core/Redirect.test.ts b/app/modules/redirects/core/Redirect.test.ts new file mode 100644 index 000000000..b1d7d5eb6 --- /dev/null +++ b/app/modules/redirects/core/Redirect.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from "vitest"; +import * as Redirect from "./Redirect"; + +describe("Redirect.resolve", () => { + test.each([ + { + why: "exact match", + pathname: "/luti", + expected: "/to/3192", + }, + { + why: "trailing slash", + pathname: "/luti/", + expected: "/to/3192", + }, + { + why: "wildcard root", + pathname: "/to/3325", + expected: "/to/3192", + }, + { + why: "wildcard suffix preserved", + pathname: "/to/3325/teams/58397", + expected: "/to/3192/teams/58397", + }, + { + why: "last division of a season", + pathname: "/to/1253/brackets", + expected: "/to/1066/brackets", + }, + { + why: "page that lost its route", + pathname: "/play", + expected: "/q", + }, + { + why: "target with a query string of its own", + pathname: "/t", + expected: "/?search=open&type=teams", + }, + { + why: "no matching redirect", + pathname: "/to/3192/teams/58397", + expected: null, + }, + { + why: "id only a prefix of a redirected id", + pathname: "/to/33250", + expected: null, + }, + ])("$why", ({ pathname, expected }) => { + expect(Redirect.resolve({ pathname })).toBe(expected); + }); + + test("keeps the query string", () => { + expect( + Redirect.resolve({ pathname: "/to/3325/brackets", search: "?idx=1" }), + ).toBe("/to/3192/brackets?idx=1"); + }); + + test("merges the query string into a target that has one", () => { + expect(Redirect.resolve({ pathname: "/u", search: "?foo=bar" })).toBe( + "/?search=open&type=users&foo=bar", + ); + }); + + test("resolved target is not itself redirected", () => { + const target = Redirect.resolve({ pathname: "/to/3325/teams/58397" }); + + expect(Redirect.resolve({ pathname: target! })).toBeNull(); + }); +}); diff --git a/app/modules/redirects/core/Redirect.ts b/app/modules/redirects/core/Redirect.ts new file mode 100644 index 000000000..8a4077016 --- /dev/null +++ b/app/modules/redirects/core/Redirect.ts @@ -0,0 +1,94 @@ +type RedirectRule = { + /** Path to redirect from. A trailing `/*` matches the path and anything below it. */ + from: string; + /** + * Path to redirect to. Ends with `/*` if `from` does, the matched suffix being appended to it. + * May contain a query string, in which case the original one is merged into it. + */ + to: string; +}; + +const WILDCARD_SUFFIX = "/*"; + +const leagueDivisionRedirects = (args: { + firstDivisionTournamentId: number; + divisionsCount: number; + seasonTournamentId: number; +}): RedirectRule[] => + Array.from({ length: args.divisionsCount }, (_, idx) => ({ + from: `/to/${args.firstDivisionTournamentId + idx}/*`, + to: `/to/${args.seasonTournamentId}/*`, + })); + +const REDIRECTS: RedirectRule[] = [ + // LUTI seasons used to be one tournament per division, now they are one tournament per season + ...leagueDivisionRedirects({ + firstDivisionTournamentId: 1241, + divisionsCount: 13, + seasonTournamentId: 1066, + }), + ...leagueDivisionRedirects({ + firstDivisionTournamentId: 3325, + divisionsCount: 13, + seasonTournamentId: 3192, + }), + // update once per season + { from: "/luti", to: "/to/3192" }, + // pages that used to have a route of their own + { from: "/play", to: "/q" }, + { from: "/q/settings", to: "/settings?tab=match-profile" }, + { from: "/plus", to: "/plus/suggestions" }, + { from: "/u", to: "/?search=open&type=users" }, + { from: "/t", to: "/?search=open&type=teams" }, +]; + +/** + * Resolves where a location should redirect to, or null if it should be served as is. + * The query string and the part of the path matched by a wildcard are preserved. + * + * @example + * Redirect.resolve({ pathname: "/to/3325/teams/58397", search: "" }) // "/to/3192/teams/58397" + */ +export function resolve(location: { + pathname: string; + search?: string; +}): string | null { + const pathname = normalizedPathname(location.pathname); + + for (const redirect of REDIRECTS) { + const target = resolvedTarget(redirect, pathname); + if (target) return withSearch(target, location.search); + } + + return null; +} + +function withSearch(target: string, search?: string) { + if (!search || search === "?") return target; + + return target.includes("?") + ? `${target}&${search.slice(1)}` + : `${target}${search}`; +} + +function resolvedTarget(redirect: RedirectRule, pathname: string) { + if (!redirect.from.endsWith(WILDCARD_SUFFIX)) { + return redirect.from === pathname ? redirect.to : null; + } + + const prefix = redirect.from.slice(0, -WILDCARD_SUFFIX.length); + if (pathname !== prefix && !pathname.startsWith(`${prefix}/`)) return null; + + if (!redirect.to.endsWith(WILDCARD_SUFFIX)) return redirect.to; + + return ( + redirect.to.slice(0, -WILDCARD_SUFFIX.length) + + pathname.slice(prefix.length) + ); +} + +function normalizedPathname(pathname: string) { + return pathname.length > 1 && pathname.endsWith("/") + ? pathname.slice(0, -1) + : pathname; +} diff --git a/app/modules/redirects/redirects-middleware.server.ts b/app/modules/redirects/redirects-middleware.server.ts new file mode 100644 index 000000000..c01f1532c --- /dev/null +++ b/app/modules/redirects/redirects-middleware.server.ts @@ -0,0 +1,27 @@ +import { redirect } from "react-router"; +import * as Redirect from "./core/Redirect"; + +type MiddlewareArgs = { + request: Request; + url: URL; + context: unknown; +}; + +type MiddlewareFn = ( + args: MiddlewareArgs, + next: () => Promise, +) => Promise; + +/** + * Redirects requests targeting a page that has moved. Runs for every kind of request + * (documents, single fetch data requests and resource routes) so that no route needs to + * know about the redirects. + * + * @see {@link Redirect.resolve} + */ +export const redirectsMiddleware: MiddlewareFn = ({ url }, next) => { + const redirectTo = Redirect.resolve(url); + if (redirectTo) throw redirect(redirectTo); + + return next(); +}; diff --git a/app/modules/redirects/routes/$.ts b/app/modules/redirects/routes/$.ts new file mode 100644 index 000000000..f2ae9ff69 --- /dev/null +++ b/app/modules/redirects/routes/$.ts @@ -0,0 +1,10 @@ +import { type LoaderFunctionArgs, redirect } from "react-router"; +import * as Redirect from "~/modules/redirects/core/Redirect"; + +/** Catches every URL matching no other route, so that pages that moved away can still redirect. */ +export const loader = ({ request }: LoaderFunctionArgs) => { + const redirectTo = Redirect.resolve(new URL(request.url)); + if (redirectTo) return redirect(redirectTo); + + throw new Response(null, { status: 404 }); +}; diff --git a/app/root.tsx b/app/root.tsx index 195924dda..4bef1f6cd 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -70,6 +70,7 @@ import { import { localePreloadUrls } from "./modules/i18n/locale-preload.server"; import { useChangeLanguage } from "./modules/i18n/useChangeLanguage"; import { isSupporter } from "./modules/permissions/utils"; +import { redirectsMiddleware } from "./modules/redirects/redirects-middleware.server"; import { SearchParamsProvider } from "./modules/search-params/hooks"; import { IS_E2E_TEST_RUN } from "./utils/e2e"; import { allI18nNamespaces } from "./utils/i18n"; @@ -78,6 +79,7 @@ import { requestContextMiddleware } from "./utils/request-context-middleware.ser import { APP_ICON_URL, pwaSplashScreenImageUrl } from "./utils/urls"; export const middleware: Route.MiddlewareFunction[] = [ + redirectsMiddleware, requestContextMiddleware, sessionIdMiddleware, userMiddleware, diff --git a/app/routes.ts b/app/routes.ts index 73450bfb2..c46d9a1a9 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -71,8 +71,6 @@ export default [ route("/suspended", "features/ban/routes/suspended.tsx"), - route("/u", "features/user-search/routes/u.tsx"), - route("/search", "features/search/routes/search.ts"), route("/u/:identifier", "features/user-page/routes/u.$identifier.tsx", [ @@ -216,7 +214,6 @@ export default [ "features/tournament-match/routes/to.$id.matches.$mid.tsx", ), ]), - route("luti", "features/tournament/routes/luti.ts"), route("/org/new", "features/tournament-organization/routes/org.new.tsx"), ...prefix("/org/:slug", [ @@ -233,7 +230,6 @@ export default [ route("/contributions", "features/info/routes/contributions.tsx"), route("/support", "features/info/routes/support.tsx"), - route("/t", "features/team/routes/t.tsx"), route("/t/new", "features/team/routes/t.new.tsx"), route("/t/:customUrl", "features/team/routes/t.$customUrl.tsx", [ index("features/team/routes/t.$customUrl.index.tsx"), @@ -280,10 +276,8 @@ export default [ route("preparing", "features/sendouq/routes/q.preparing.tsx"), route("ready", "features/sendouq/routes/q.ready.tsx"), route("match/:id", "features/sendouq-match/routes/q.match.$id.tsx"), - route("settings", "features/match-profile/routes/q.settings.tsx"), route("streams", "features/sendouq-streams/routes/q.streams.tsx"), ]), - route("/play", "features/sendouq/routes/play.ts"), route("/friends-for-adding", "features/sendouq/routes/friends-for-adding.ts"), @@ -332,7 +326,6 @@ export default [ ]), route("/plus", "features/plus-suggestions/routes/plus.tsx", [ - index("features/plus-suggestions/routes/plus.index.ts"), route( "suggestions", "features/plus-suggestions/routes/plus.suggestions.tsx", @@ -463,4 +456,6 @@ export default [ route("impersonate/stop", "features/auth/routes/auth.impersonate.stop.ts"), ]), ...devOnlyRoutes, + + route("*", "modules/redirects/routes/$.ts"), ] satisfies RouteConfig; diff --git a/app/routines/computeLutiDivs.ts b/app/routines/computeLutiDivs.ts index 36f32eece..324beb29a 100644 --- a/app/routines/computeLutiDivs.ts +++ b/app/routines/computeLutiDivs.ts @@ -1,56 +1,56 @@ import { parseLutiDivFromName } from "../features/scrims/scrims-utils"; import * as TournamentRepository from "../features/tournament/TournamentRepository.server"; -import { LEAGUES } from "../features/tournament/tournament-constants"; +import { LUTI_ORGANIZATION_ID } from "../features/tournament-organization/tournament-organization-constants"; import * as UserRepository from "../features/user-page/UserRepository.server"; import { logger } from "../utils/logger"; import { Routine } from "./routine.server"; +/** Excludes the other leagues of the organization e.g. FLUTI */ +export const LUTI_NAME_PREFIX = "LUTI"; + /** * Recomputes `User.div` (the user's division in the latest finished LUTI). Looks at the most recent - * LUTI season whose division tournaments are all finalized and sets the division for every eligible - * participant (on a team that did not drop out and played at least one match). Users not in that - * season keep their previous division. Idempotent. + * finalized LUTI season and sets the division for every eligible participant (on a team that did + * not drop out and played at least one match). Users not in that season keep their previous + * division. Idempotent. */ export const ComputeLutiDivsRoutine = new Routine({ name: "ComputeLutiDivs", func: async () => { - const children = await latestFinishedLutiDivisions(); - if (!children) return; + const league = + await TournamentRepository.findLatestFinalizedLeagueParticipants({ + organizationId: LUTI_ORGANIZATION_ID, + namePrefix: LUTI_NAME_PREFIX, + }); + if (!league) return; + + const divByBracketIdx = new Map(); + const divOfBracket = (bracketIdx: number) => { + if (!divByBracketIdx.has(bracketIdx)) { + const bracketName = league.bracketProgression[bracketIdx]?.name; + const div = bracketName ? parseLutiDivFromName(bracketName) : null; + if (!div) { + logger.warn( + `ComputeLutiDivs: could not parse division from bracket name "${bracketName}"`, + ); + } + divByBracketIdx.set(bracketIdx, div); + } + + return divByBracketIdx.get(bracketIdx)!; + }; const updates: Array<{ userId: number; div: string }> = []; - for (const child of children) { - const div = parseLutiDivFromName(child.name); - if (!div) { - logger.warn( - `ComputeLutiDivs: could not parse division from tournament name "${child.name}"`, - ); - continue; - } + for (const participant of league.participants) { + const div = divOfBracket(participant.startingBracketIdx ?? 0); + if (!div) continue; - const userIds = - await TournamentRepository.findLeagueDivParticipantUserIds( - child.tournamentId, - ); - for (const { userId } of userIds) { - updates.push({ userId, div }); - } + updates.push({ userId: participant.userId, div }); } await UserRepository.updateManyDivs(updates); - logger.info(`ComputeLutiDivs: updated div for ${updates.length} users`); + logger.info( + `ComputeLutiDivs: updated div for ${updates.length} users based on tournament ${league.tournamentId}`, + ); }, }); - -async function latestFinishedLutiDivisions() { - for (const league of [...(LEAGUES.LUTI ?? [])].reverse()) { - const children = await TournamentRepository.findChildTournamentsForDivCalc( - league.tournamentId, - ); - if (children.length === 0) continue; - if (children.every((child) => child.isFinalized === 1)) { - return children; - } - } - - return null; -} diff --git a/docs/tournament-leagues.md b/docs/tournament-leagues.md deleted file mode 100644 index 5d57c6ae0..000000000 --- a/docs/tournament-leagues.md +++ /dev/null @@ -1,14 +0,0 @@ -Leagues are a variety of sendou.ink tournaments where each division is a separate competition. Participants sign-up to one "entry tournament" then get divided into divisions by the organizers and one winner emerges per division. - -## Creating a league - -Note: leagues are not an open feature available for everyone and require some amount of manual admin work from Sendou. - -1) Create tournament as normal on sendou.ink. This will be the tournament where users sign up to. Set registration closing time as you wish. -2) Link the bracket to sendou. -3) Once the registration closes from the admin tab download participant list as "league format" (.csv file). -4) Order the teams as you wish. Fill the "div" column with the desired division per participant. -5) Give sendou back the .csv file. -6) Division brackets will be generated. You get the chance to edit each as you wish before starting it. If you want to use the same maps for each division, just do them for one bracket then let Sendou know. There is an admin script that can be ran that copies them across all divisions. -7) (Optional): let Sendou know the start time of each bracket to control during which week which round of groups is played. -8) When everything looks good start divisions and play out the tournament as normal. diff --git a/migrations/20260815145515-leagues-as-normal-tournaments.ts b/migrations/20260815145515-leagues-as-normal-tournaments.ts new file mode 100644 index 000000000..cf83e7869 --- /dev/null +++ b/migrations/20260815145515-leagues-as-normal-tournaments.ts @@ -0,0 +1,607 @@ +import { type Kysely, sql, type Transaction } from "kysely"; + +/** + * Turns leagues into normal tournaments. + * + * Before: a league was a "signup" tournament holding the registrations plus one child tournament + * per division, linked via `Tournament.parentTournamentId`. After: one tournament per season with + * `settings.isLeague` and two brackets per division (group stage + playoffs), teams pointing at + * their division via `startingBracketIdx` and the weekly play dates stored per round. + */ +export async function up(db: Kysely): Promise { + await db.transaction().execute(async (trx) => { + await trx.schema + .alterTable("TournamentRound") + .addColumn("defaultPlayTime", "integer") + .execute(); + + await createDivisionTierTable(trx); + + // the divisions carry their own tier over, so they are done before the backfill below + for (const season of SEASONS) { + await migrateSeason(trx, season); + } + + await trx.schema + .alterTable("Tournament") + .dropColumn("parentTournamentId") + .execute(); + + await backfillDivisionTiers(trx); + }); +} + +type Season = { + /** Tournament that held the registrations. Becomes the tournament of the whole season. */ + signupTournamentId: number; + year: number; + /** ISO week each group stage round is played, in round order. */ + weekNumbers: number[]; + /** Division tournaments from the highest division to the lowest. */ + divisions: Array<{ tournamentId: number; label: string }>; +}; + +const divisionsOfSeason = (firstTournamentId: number) => + [ + "Division X", + "Division 1", + "Division 2", + "Division 3", + "Division 4", + "Division 5", + "Division 6", + "Division 7", + "Division 8", + "Division 9 Americas", + "Division 9 World", + "Division 10 Americas", + "Division 10 World", + ].map((label, idx) => ({ tournamentId: firstTournamentId + idx, label })); + +const SEASONS: Season[] = [ + { + signupTournamentId: 1066, + year: 2025, + weekNumbers: [10, 11, 12, 13, 14], + divisions: divisionsOfSeason(1241), + }, + { + signupTournamentId: 3192, + year: 2026, + weekNumbers: [9, 10, 11, 12, 13], + divisions: divisionsOfSeason(3325), + }, +]; + +async function migrateSeason(trx: Transaction, season: Season) { + const signup = await trx + .selectFrom("Tournament") + .select(["id", "settings", "castTwitchAccounts", "castedMatchesInfo"]) + .where("id", "=", season.signupTournamentId) + .executeTakeFirst(); + + // databases without the production league data (dev, tests) + if (!signup) return; + + const divisions = []; + for (const [idx, division] of season.divisions.entries()) { + const row = await trx + .selectFrom("Tournament") + .select([ + "id", + "settings", + "tier", + "castTwitchAccounts", + "castedMatchesInfo", + ]) + .where("id", "=", division.tournamentId) + .executeTakeFirst(); + + if (!row) { + throw new Error( + `League division tournament ${division.tournamentId} not found`, + ); + } + + const progression = JSON.parse(row.settings).bracketProgression; + if (progression.length !== 2) { + throw new Error( + `Expected 2 brackets in division tournament ${division.tournamentId}, got ${progression.length}`, + ); + } + + divisions.push({ + ...division, + tier: row.tier as number | null, + castTwitchAccounts: parseJson(row.castTwitchAccounts), + castedMatchesInfo: parseJson(row.castedMatchesInfo), + groupStageIdx: idx * 2, + playoffsIdx: idx * 2 + 1, + groupStageBracket: progression[0], + playoffsBracket: progression[1], + }); + } + + await trx + .updateTable("Tournament") + .set({ + settings: JSON.stringify({ + ...JSON.parse(signup.settings), + isLeague: true, + bracketProgression: divisions.flatMap((division) => [ + { ...division.groupStageBracket, name: division.label }, + { + ...division.playoffsBracket, + name: playoffsName(division.label), + sources: division.playoffsBracket.sources.map((source: any) => ({ + ...source, + bracketIdx: division.groupStageIdx, + })), + }, + ]), + }), + castTwitchAccounts: mergedCastTwitchAccounts(signup, divisions), + castedMatchesInfo: mergedCastedMatchesInfo(signup, divisions), + tier: bestTier(divisions), + isFinalized: 1, + }) + .where("id", "=", season.signupTournamentId) + .execute(); + + // each division keeps the tier it was given as a tournament of its own + const tieredDivisions = divisions.filter( + (division) => division.tier !== null, + ); + if (tieredDivisions.length > 0) { + await trx + .insertInto("TournamentDivisionTier") + .values( + tieredDivisions.map((division) => ({ + tournamentId: season.signupTournamentId, + bracketIdx: division.groupStageIdx, + tier: division.tier, + })), + ) + .execute(); + } + + // rosters come from the divisions, where they were kept up to date over the season + await trx + .deleteFrom("TournamentTeam") + .where("tournamentId", "=", season.signupTournamentId) + .execute(); + + for (const division of divisions) { + const teamsOfDivision = (eb: any) => + eb + .selectFrom("TournamentTeam") + .select("TournamentTeam.id") + .where("TournamentTeam.tournamentId", "=", division.tournamentId); + + for (const [oldIdx, newIdx] of [ + [0, division.groupStageIdx], + [1, division.playoffsIdx], + ]) { + await trx + .updateTable("TournamentTeamCheckIn") + .set({ bracketIdx: newIdx }) + .where("bracketIdx", "=", oldIdx) + .where("tournamentTeamId", "in", teamsOfDivision) + .execute(); + } + + const bracketIdxOfDivision = (idx: number) => { + if (idx === 0) return division.groupStageIdx; + if (idx === 1) return division.playoffsIdx; + + // -1 = eliminated from the tournament + return idx; + }; + + const overrides = await trx + .selectFrom("TournamentBracketProgressionOverride") + .selectAll() + .where("tournamentId", "=", division.tournamentId) + .execute(); + for (const override of overrides) { + await trx + .updateTable("TournamentBracketProgressionOverride") + .set({ + tournamentId: season.signupTournamentId, + sourceBracketIdx: bracketIdxOfDivision(override.sourceBracketIdx), + destinationBracketIdx: bracketIdxOfDivision( + override.destinationBracketIdx, + ), + }) + .where("tournamentTeamId", "=", override.tournamentTeamId) + .where("sourceBracketIdx", "=", override.sourceBracketIdx) + .execute(); + } + + await trx + .updateTable("TournamentTeam") + .set({ + tournamentId: season.signupTournamentId, + startingBracketIdx: division.groupStageIdx, + }) + .where("tournamentId", "=", division.tournamentId) + .execute(); + + // brackets resolve their stage by name, so these have to match the progression exactly + for (const [bracket, name, bracketIdx] of [ + [division.groupStageBracket, division.label, division.groupStageIdx], + [ + division.playoffsBracket, + playoffsName(division.label), + division.playoffsIdx, + ], + ] as const) { + const stage = await trx + .selectFrom("TournamentStage") + .select("id") + .where("tournamentId", "=", division.tournamentId) + .where("name", "=", bracket.name) + .executeTakeFirst(); + if (!stage) { + throw new Error( + `Stage "${bracket.name}" not found in division tournament ${division.tournamentId}`, + ); + } + + await trx + .updateTable("TournamentStage") + .set({ + tournamentId: season.signupTournamentId, + name, + number: bracketIdx + 1, + }) + .where("id", "=", stage.id) + .execute(); + + if (bracketIdx !== division.groupStageIdx) continue; + + for (const [roundIdx, weekNumber] of season.weekNumbers.entries()) { + await trx + .updateTable("TournamentRound") + .set({ + defaultPlayTime: weekNumberToTimestamp({ + week: weekNumber, + year: season.year, + }), + }) + .where("stageId", "=", stage.id) + .where("number", "=", roundIdx + 1) + .execute(); + } + } + + // a user who played in two divisions keeps the row of the higher one, which was + // already moved over as divisions are handled from the highest to the lowest + await trx + .deleteFrom("TournamentResult") + .where("tournamentId", "=", division.tournamentId) + .where("userId", "in", (eb: any) => + eb + .selectFrom("TournamentResult as Existing") + .select("Existing.userId") + .where("Existing.tournamentId", "=", season.signupTournamentId), + ) + .execute(); + await trx + .updateTable("TournamentResult") + .set({ tournamentId: season.signupTournamentId, div: division.label }) + .where("tournamentId", "=", division.tournamentId) + .execute(); + + await trx + .deleteFrom("Skill") + .where("tournamentId", "=", division.tournamentId) + .where("userId", "in", (eb: any) => + eb + .selectFrom("Skill as Existing") + .select("Existing.userId") + .where("Existing.tournamentId", "=", season.signupTournamentId) + .where("Existing.userId", "is not", null), + ) + .execute(); + await trx + .deleteFrom("Skill") + .where("tournamentId", "=", division.tournamentId) + .where("userId", "is", null) + .where("identifier", "in", (eb: any) => + eb + .selectFrom("Skill as Existing") + .select("Existing.identifier") + .where("Existing.tournamentId", "=", season.signupTournamentId) + .where("Existing.identifier", "is not", null), + ) + .execute(); + await trx + .updateTable("Skill") + .set({ tournamentId: season.signupTournamentId }) + .where("tournamentId", "=", division.tournamentId) + .execute(); + + await trx + .deleteFrom("TournamentStaff") + .where("tournamentId", "=", division.tournamentId) + .where("userId", "in", (eb: any) => + eb + .selectFrom("TournamentStaff as Existing") + .select("Existing.userId") + .where("Existing.tournamentId", "=", season.signupTournamentId), + ) + .execute(); + await trx + .updateTable("TournamentStaff") + .set({ tournamentId: season.signupTournamentId }) + .where("tournamentId", "=", division.tournamentId) + .execute(); + + await trx + .deleteFrom("CalendarEvent") + .where("tournamentId", "=", division.tournamentId) + .execute(); + await trx + .deleteFrom("Tournament") + .where("id", "=", division.tournamentId) + .execute(); + } +} + +const playoffsName = (divisionLabel: string) => `${divisionLabel} Playoffs`; + +type CastedMatchesInfo = { + lockedMatches: Array<{ twitchAccount: string; matchId: number }>; + castedMatches: Array<{ twitchAccount: string; matchId: number }>; + castedMatchHistory?: Array<{ + twitchAccount: string; + matchId: number; + timestamp: number; + }>; +}; + +function parseJson(value: string | null): T | null { + return value ? (JSON.parse(value) as T) : null; +} + +function mergedCastTwitchAccounts( + signup: { castTwitchAccounts: string | null }, + divisions: Array<{ castTwitchAccounts: string[] | null }>, +) { + const accounts = new Set([ + ...(parseJson(signup.castTwitchAccounts) ?? []), + ...divisions.flatMap((division) => division.castTwitchAccounts ?? []), + ]); + + return accounts.size > 0 ? JSON.stringify([...accounts]) : null; +} + +function mergedCastedMatchesInfo( + signup: { castedMatchesInfo: string | null }, + divisions: Array<{ castedMatchesInfo: CastedMatchesInfo | null }>, +) { + const infos = [ + parseJson(signup.castedMatchesInfo), + ...divisions.map((division) => division.castedMatchesInfo), + ].filter((info) => info !== null); + + if (infos.length === 0) return null; + + const castedMatchHistory = infos.flatMap( + (info) => info.castedMatchHistory ?? [], + ); + + return JSON.stringify({ + castedMatches: infos.flatMap((info) => info.castedMatches), + lockedMatches: infos.flatMap((info) => info.lockedMatches), + castedMatchHistory: + castedMatchHistory.length > 0 ? castedMatchHistory : undefined, + }); +} + +function bestTier(divisions: Array<{ tier: number | null }>) { + const tiers = divisions + .map((division) => division.tier) + .filter((tier) => tier !== null); + + return tiers.length > 0 ? Math.min(...tiers) : null; +} + +/** Unix timestamp of the Monday (UTC) starting the given ISO week. Same as `weekNumberToDate`. */ +function weekNumberToTimestamp({ week, year }: { week: number; year: number }) { + const date = new Date(Date.UTC(year, 0, 4)); + date.setUTCDate( + date.getUTCDate() - (date.getUTCDay() || 7) + 1 + 7 * (week - 1), + ); + + return Math.floor(date.getTime() / 1000); +} + +function createDivisionTierTable(trx: Transaction) { + return ( + trx.schema + .createTable("TournamentDivisionTier") + .addColumn("tournamentId", "integer", (col) => + col.notNull().references("Tournament.id").onDelete("cascade"), + ) + .addColumn("bracketIdx", "integer", (col) => col.notNull()) + .addColumn("tier", "integer", (col) => col.notNull()) + .addPrimaryKeyConstraint("tournament_division_tier_pk", [ + "tournamentId", + "bracketIdx", + ]) + // every table in this schema is strict + .modifyEnd(sql`strict`) + .execute() + ); +} + +/** + * Gives every division (= starting bracket) a tier of its own, so that a result of a tournament + * with many starting brackets stops showing the tier of its strongest division. Tournaments that + * already got their divisions tiered, i.e. the leagues above, are left alone. + * + * Historical tiers can't be recomputed exactly: `SeedingSkill` only keeps current values and skill + * inflates over time, so recomputing a finalized single division tournament lands on its stored tier + * only about half of the time and is 1-3 tiers too good otherwise. Every division of one tournament + * shares that drift, so they are recomputed as one batch and then shifted to make the first starting + * bracket (the one the stored tier was calculated from) land exactly on it. Divisions of tournaments + * with no tier are left out, having nothing to anchor to. + */ +async function backfillDivisionTiers(trx: Transaction) { + const alreadyTiered = new Set( + ( + await trx + .selectFrom("TournamentDivisionTier") + .select("tournamentId") + .distinct() + .execute() + ).map((row: { tournamentId: number }) => row.tournamentId), + ); + + const tournaments = await trx + .selectFrom("Tournament") + .select(["id", "settings", "tier"]) + .where("tier", "is not", null) + .execute(); + + for (const tournament of tournaments) { + if (alreadyTiered.has(tournament.id)) continue; + + const settings = JSON.parse(tournament.settings); + const startingBracketIdxs = (settings.bracketProgression as any[]) + .map((bracket, idx) => ({ bracket, idx })) + .filter(({ bracket }) => !bracket.sources) + .map(({ idx }) => idx); + + // tournaments where every team plays the same bracket already have an accurate tier + if (startingBracketIdxs.length < 2) continue; + + const teams = await teamsWithSeedingSkill(trx, { + tournamentId: tournament.id, + isRanked: settings.isRanked === true, + }); + + const tierByBracketIdx = new Map(); + for (const bracketIdx of startingBracketIdxs) { + const ofDivision = teams.filter( + (team) => (team.startingBracketIdx ?? 0) === bracketIdx, + ); + // teams that did not check in do not play the bracket, but tournaments predating + // check-in data have none of them + const checkedIn = ofDivision.filter((team) => team.checkedIn); + + const tier = tierOfTeams(checkedIn.length > 0 ? checkedIn : ofDivision); + if (tier !== null) { + tierByBracketIdx.set(bracketIdx, tier); + } + } + + const anchorTier = tierByBracketIdx.get(startingBracketIdxs[0]); + if (anchorTier === undefined) continue; + + const offset = tournament.tier - anchorTier; + + await trx + .insertInto("TournamentDivisionTier") + .values( + [...tierByBracketIdx].map(([bracketIdx, tier]) => ({ + tournamentId: tournament.id, + bracketIdx, + tier: clampTier(tier + offset), + })), + ) + .execute(); + } +} + +function teamsWithSeedingSkill( + trx: Transaction, + { tournamentId, isRanked }: { tournamentId: number; isRanked: boolean }, +): Promise< + Array<{ + startingBracketIdx: number | null; + avgOrdinal: number | null; + checkedIn: number; + }> +> { + return trx + .selectFrom("TournamentTeam") + .select((eb) => [ + "TournamentTeam.startingBracketIdx", + eb + .selectFrom("TournamentTeamMember") + .innerJoin("SeedingSkill", (join) => + join + .onRef("SeedingSkill.userId", "=", "TournamentTeamMember.userId") + .on("SeedingSkill.type", "=", isRanked ? "RANKED" : "UNRANKED"), + ) + .select(({ fn }) => fn.avg("SeedingSkill.ordinal").as("v")) + .whereRef( + "TournamentTeamMember.tournamentTeamId", + "=", + "TournamentTeam.id", + ) + .as("avgOrdinal"), + eb + .exists( + eb + .selectFrom("TournamentTeamCheckIn") + .select("TournamentTeamCheckIn.tournamentTeamId") + .whereRef( + "TournamentTeamCheckIn.tournamentTeamId", + "=", + "TournamentTeam.id", + ) + .where("TournamentTeamCheckIn.isCheckOut", "=", 0), + ) + .as("checkedIn"), + ]) + .where("TournamentTeam.tournamentId", "=", tournamentId) + .where("TournamentTeam.isPlaceholder", "=", 0) + .execute() as any; +} + +// frozen copy of app/features/tournament/core/tiering.ts as of this migration +const TIER_THRESHOLDS: Array<[tier: number, threshold: number]> = [ + [1, 32], + [2, 29], + [3, 26], + [4, 24], + [5, 21], + [6, 15], + [7, 10], + [8, 5], +]; +const WORST_TIER = 9; +const TOP_TEAMS_COUNT = 8; +const MIN_TEAMS_FOR_TIERING = 8; +const NO_BONUS_ABOVE = 32; +const MAX_BONUS_PER_10_TEAMS = 1.5; + +function tierOfTeams(teams: Array<{ avgOrdinal: number | null }>) { + if (teams.length < MIN_TEAMS_FOR_TIERING) return null; + + const ordinals = teams + .map((team) => team.avgOrdinal) + .filter((ordinal) => ordinal !== null); + if (ordinals.length === 0) return null; + + const topOrdinals = ordinals.sort((a, b) => b - a).slice(0, TOP_TEAMS_COUNT); + const rawScore = + topOrdinals.reduce((sum, ordinal) => sum + ordinal, 0) / topOrdinals.length; + + const scaleFactor = Math.max(0, (NO_BONUS_ABOVE - rawScore) / NO_BONUS_ABOVE); + const teamsAboveMin = Math.max(0, teams.length - MIN_TEAMS_FOR_TIERING); + const adjustedScore = + rawScore + scaleFactor * MAX_BONUS_PER_10_TEAMS * (teamsAboveMin / 10); + + for (const [tier, threshold] of TIER_THRESHOLDS) { + if (adjustedScore >= threshold) return tier; + } + + return WORST_TIER; +} + +const clampTier = (tier: number) => Math.min(WORST_TIER, Math.max(1, tier)); diff --git a/scripts/benchmark-db/cases.ts b/scripts/benchmark-db/cases.ts index ce9d57c67..7134853f9 100644 --- a/scripts/benchmark-db/cases.ts +++ b/scripts/benchmark-db/cases.ts @@ -40,10 +40,12 @@ import * as TournamentMatchVodRepository from "~/features/tournament-bracket/Tou import * as TournamentLFGRepository from "~/features/tournament-lfg/TournamentLFGRepository.server"; import * as TournamentMatchRepository from "~/features/tournament-match/TournamentMatchRepository.server"; import * as TournamentOrganizationRepository from "~/features/tournament-organization/TournamentOrganizationRepository.server"; +import { LUTI_ORGANIZATION_ID } from "~/features/tournament-organization/tournament-organization-constants"; import * as TrophyRepository from "~/features/trophies/TrophyRepository.server"; import * as UserCardRepository from "~/features/user-card/UserCardRepository.server"; import * as UserRepository from "~/features/user-page/UserRepository.server"; import * as VodRepository from "~/features/vods/VodRepository.server"; +import { LUTI_NAME_PREFIX } from "~/routines/computeLutiDivs"; import { dateToDatabaseTimestamp } from "~/utils/dates"; import type { Fixtures } from "./fixtures"; @@ -1041,35 +1043,17 @@ export function buildCases(fx: Fixtures): { (tournamentId) => TournamentRepository.findSeedingSnapshotById(tournamentId), ); - add( - "TournamentRepository.hasChildTournaments", - fx.parentTournamentId, - (parentTournamentId) => - TournamentRepository.hasChildTournaments(parentTournamentId), - ); - add( - "TournamentRepository.findChildTournaments", - fx.parentTournamentId, - (parentTournamentId) => - TournamentRepository.findChildTournaments(parentTournamentId), - ); - add( - "TournamentRepository.findChildTournamentsForDivCalc", - fx.parentTournamentId, - (parentTournamentId) => - TournamentRepository.findChildTournamentsForDivCalc(parentTournamentId), - ); add( "TournamentRepository.findResultsByTournamentId", fx.heavyResultsTournamentId, (tournamentId) => TournamentRepository.findResultsByTournamentId(tournamentId), ); - add( - "TournamentRepository.findLeagueDivParticipantUserIds", - fx.parentTournamentId, - (parentTournamentId) => - TournamentRepository.findLeagueDivParticipantUserIds(parentTournamentId), + addStatic("TournamentRepository.findLatestFinalizedLeagueParticipants", () => + TournamentRepository.findLatestFinalizedLeagueParticipants({ + organizationId: LUTI_ORGANIZATION_ID, + namePrefix: LUTI_NAME_PREFIX, + }), ); add( "TournamentRepository.findTOSetMapPoolById", diff --git a/scripts/benchmark-db/fixtures.ts b/scripts/benchmark-db/fixtures.ts index ff7388236..07eedb5bd 100644 --- a/scripts/benchmark-db/fixtures.ts +++ b/scripts/benchmark-db/fixtures.ts @@ -39,7 +39,6 @@ export interface Fixtures { tournamentTeamPair: [number, number] | null; tournamentTeamInviteCode: string | null; recentTournamentIds: number[] | null; - parentTournamentId: number | null; heavyTeam: { id: number; customUrl: string; memberUserId: number } | null; heavyCalendarEventId: number | null; resultsEventId: number | null; @@ -149,7 +148,6 @@ export async function resolveFixtures(): Promise { tournamentTeamInviteCode: await resolveTournamentTeamInviteCode(heavyTournamentId), recentTournamentIds: await resolveRecentTournamentIds(), - parentTournamentId: await resolveParentTournamentId(), heavyTeam: await resolveHeavyTeam(), heavyCalendarEventId: await resolveHeavyCalendarEventId(), resultsEventId: await resolveResultsEventId(), @@ -535,22 +533,6 @@ async function resolveRecentTournamentIds() { return rows.length > 0 ? rows.map((row) => row.tournamentId) : null; } -async function resolveParentTournamentId() { - const row = await db - .selectFrom("Tournament") - .select(({ fn }) => [ - "parentTournamentId", - fn.countAll().as("count"), - ]) - .where("parentTournamentId", "is not", null) - .groupBy("parentTournamentId") - .orderBy("count", "desc") - .limit(1) - .executeTakeFirst(); - - return row?.parentTournamentId ?? null; -} - async function resolveHeavyTeam() { const row = await db .selectFrom("TeamMemberWithSecondary") diff --git a/scripts/create-league-divisions.ts b/scripts/create-league-divisions.ts deleted file mode 100644 index 930715e34..000000000 --- a/scripts/create-league-divisions.ts +++ /dev/null @@ -1,217 +0,0 @@ -// for testing use the command `pnpm exec vite-node ./scripts/create-league-divisions.ts 6 'https://gist.githubusercontent.com/sendou-ink/38aa4d5d8426035ce178c09598ae627f/raw/17be9bb53a9f017c2097d0624f365d1c5a029f01/league.csv'` - -import * as v from "valibot"; -import { db } from "~/db/sql"; -import { ADMIN_ID } from "~/features/admin/admin-constants"; -import * as CalendarRepository from "~/features/calendar/CalendarRepository.server"; -import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server"; -import type { Tournament } from "~/features/tournament-bracket/core/Tournament"; -import { tournamentFromDB } from "~/features/tournament-bracket/core/Tournament.server"; -import { dateToDatabaseTimestamp } from "~/utils/dates"; -import invariant from "~/utils/invariant"; -import { logger } from "~/utils/logger"; - -const tournamentId = Number(process.argv[2]?.trim()); - -invariant( - tournamentId && !Number.isNaN(tournamentId), - "tournament id is required (argument 1)", -); - -const csvUrl = process.argv[3]?.trim(); - -invariant( - v.parse(v.pipe(v.string(), v.url()), csvUrl), - "csv url is required (argument 2)", -); - -async function main() { - const tournament = await tournamentFromDB({ - tournamentId, - user: { id: ADMIN_ID }, - }); - invariant(tournament.isLeagueSignup, "Tournament is not a league signup"); - - const csv = await loadCsv(); - - const teams = parseCsv(csv); - for (const team of teams) { - validateTeam(team, tournament); - } - validateDivs(teams); - - const grouped = Object.entries(Object.groupBy(teams, (t) => t.division)).sort( - (a, b) => { - const divAIndex = teams.findIndex((t) => t.division === a[0]); - const divBIndex = teams.findIndex((t) => t.division === b[0]); - - return divAIndex - divBIndex; - }, - ); - - for (const [, divsTeams] of grouped) { - divsTeams!.sort((a, b) => { - const teamAIndex = teams.findIndex((t) => t.id === a.id); - const teamBIndex = teams.findIndex((t) => t.id === b.id); - - return teamAIndex - teamBIndex; - }); - } - - const calendarEvent = await db - .selectFrom("CalendarEvent") - .selectAll() - .where("CalendarEvent.id", "=", tournament.ctx.eventId) - .executeTakeFirstOrThrow(); - - for (const [div, divsTeams] of grouped) { - logger.info(`Creating division ${div}...`); - - const createdEvent = await CalendarRepository.insert({ - parentTournamentId: tournament.ctx.id, - authorId: tournament.ctx.author.id, - bracketProgression: tournament.ctx.settings.bracketProgression, - description: tournament.ctx.description, - discordInviteCode: - tournament.ctx.discordUrl?.replace("https://discord.gg/", "") ?? null, - mapPickingStyle: tournament.ctx.mapPickingStyle, - name: `${tournament.ctx.name} - ${div.startsWith("Division") ? div : `Division ${div}`}`, - organizationId: tournament.ctx.organization?.id ?? null, - rules: tournament.ctx.rules, - startTimes: [dateToDatabaseTimestamp(tournament.ctx.startsAt)], - tags: null, - tournamentToCopyId: tournament.ctx.id, - avatarImgId: calendarEvent.avatarImgId ?? undefined, - avatarFileName: undefined, - mapPoolMaps: - tournament.ctx.mapPickingStyle !== "TO" - ? tournament.ctx.tieBreakerMapPool - : tournament.ctx.toSetMapPool, - badges: [], - enableNoScreenToggle: tournament.ctx.settings.enableNoScreenToggle, - enableSubs: false, - isInvitational: true, - autonomousSubs: false, - isRanked: tournament.ctx.settings.isRanked, - minMembersPerTeam: tournament.ctx.settings.minMembersPerTeam, - maxMembersPerTeam: tournament.ctx.settings.maxMembersPerTeam, - regClosesAt: tournament.ctx.settings.regClosesAt, - requireInGameNames: tournament.ctx.settings.requireInGameNames, - bracketUrl: "https://sendou.ink", - isFullTournament: true, - autoValidateAvatar: true, - // these come from progression - swissGroupCount: undefined, - swissRoundCount: undefined, - teamsPerGroup: undefined, - thirdPlaceMatch: undefined, - }); - - for (const [idx, team] of divsTeams!.entries()) { - await TournamentTeamRepository.copyFromAnotherTournament({ - destinationTournamentId: createdEvent.tournamentId!, - tournamentTeamId: team.id, - seed: idx + 1, - defaultCheckedIn: true, - }); - } - - logger.info(`Created division ${div} (id: ${createdEvent.tournamentId})`); - } -} - -async function loadCsv() { - const response = await fetch(csvUrl); - return response.text(); -} - -const csvSchema = v.array( - v.object({ - "Team id": v.pipe(v.unknown(), v.transform(Number), v.number()), - Div: v.string(), - }), -); - -type ParsedTeam = ReturnType[number]; - -function parseCsv(csv: string) { - const lines = csv.trim().split("\n"); - const headers = splitCsvRow(lines[0]).map((h) => h.trim()); - const rows = lines.slice(1).map((line) => { - const row = splitCsvRow(line); - return headers.reduce( - (acc, header, i) => { - acc[header] = row[i]; - return acc; - }, - {} as Record, - ); - }); - - const validated = v.parse(csvSchema, rows); - - return validated.map((row) => ({ - id: row["Team id"], - division: row.Div, - })); -} - -function validateTeam(team: ParsedTeam, tournament: Tournament) { - invariant( - tournament.ctx.teams.some((t) => t.id === team.id), - `Team with id ${team.id} not found in tournament`, - ); -} - -const MIN_TEAMS_COUNT_PER_DIV = 6; -function validateDivs(teams: ParsedTeam[]) { - const counts = teams.reduce( - (acc, team) => { - acc[team.division] = (acc[team.division] ?? 0) + 1; - return acc; - }, - {} as Record, - ); - - for (const [div, count] of Object.entries(counts)) { - invariant( - count >= MIN_TEAMS_COUNT_PER_DIV, - `Division ${div} has ${count} teams, expected at least ${MIN_TEAMS_COUNT_PER_DIV}`, - ); - } -} - -function splitCsvRow(line: string) { - const fields: string[] = []; - let current = ""; - let inQuotes = false; - - for (let i = 0; i < line.length; i++) { - const char = line[i]; - - if (inQuotes) { - if (char === '"') { - if (line[i + 1] === '"') { - current += '"'; - i++; - } else { - inQuotes = false; - } - } else { - current += char; - } - } else if (char === '"') { - inQuotes = true; - } else if (char === ",") { - fields.push(current); - current = ""; - } else { - current += char; - } - } - - fields.push(current); - return fields; -} - -main();