diff --git a/app/features/tournament-bracket/components/BracketMapListDialog.tsx b/app/features/tournament-bracket/components/BracketMapListDialog.tsx index 56846addf..2e98d432b 100644 --- a/app/features/tournament-bracket/components/BracketMapListDialog.tsx +++ b/app/features/tournament-bracket/components/BracketMapListDialog.tsx @@ -91,7 +91,10 @@ export function BracketMapListDialog({ } if (isPreparing) { - return null; + return PreparedMaps.eliminationTeamCountPrefill({ + tournament, + bracketIdx, + }); } return PreparedMaps.eliminationTeamCountOptions(bracketTeamsCount)[0].max; @@ -823,20 +826,21 @@ function EliminationTeamCountSelect({ defaultValue={count ?? ""} > - {PreparedMaps.eliminationTeamCountOptions(realCount).map( - (teamCountRange) => { - const label = - teamCountRange.min === teamCountRange.max - ? teamCountRange.min - : `${teamCountRange.min}-${teamCountRange.max}`; + {PreparedMaps.eliminationTeamCountOptions( + // the prepared for count can be below the current team count e.g. when some of the registered teams are not expected to play + Math.min(realCount, count ?? realCount), + ).map((teamCountRange) => { + const label = + teamCountRange.min === teamCountRange.max + ? teamCountRange.min + : `${teamCountRange.min}-${teamCountRange.max}`; - return ( - - ); - }, - )} + return ( + + ); + })} ); diff --git a/app/features/tournament-bracket/core/PreparedMaps.test.ts b/app/features/tournament-bracket/core/PreparedMaps.test.ts index a9d8703ab..3a2177183 100644 --- a/app/features/tournament-bracket/core/PreparedMaps.test.ts +++ b/app/features/tournament-bracket/core/PreparedMaps.test.ts @@ -1,7 +1,14 @@ +import { addHours, addMinutes, subHours, subMinutes } from "date-fns"; import { describe, expect, test } from "vitest"; import type { PreparedMaps as PreparedMapsType } from "~/db/tables-json"; +import { nullFilledArray } from "~/utils/arrays"; +import { dateToDatabaseTimestamp } from "~/utils/dates"; +import * as Engine from "./engine"; +import type { BracketData } from "./engine/types"; import * as PreparedMaps from "./PreparedMaps"; -import { testTournament } from "./tests/test-utils"; +import type * as Progression from "./Progression"; +import type { TournamentData } from "./Tournament.server"; +import { testTournament, tournamentCtxTeam } from "./tests/test-utils"; const getTestTournament = (thirdPlaceMatchesForBoth = true) => testTournament({ @@ -828,3 +835,318 @@ describe("PreparedMaps - trimPreparedEliminationMaps", () => { createdAt: 1724482944, }; }); + +describe("PreparedMaps - eliminationTeamCountPrefill", () => { + const teams = ({ + count, + firstId = 1, + memberCount = 4, + }: { + count: number; + firstId?: number; + memberCount?: number; + }) => + nullFilledArray(count).map((_, i) => + tournamentCtxTeam(firstId + i, { + memberUserIds: nullFilledArray(memberCount).map( + (_, memberIdx) => (firstId + i) * 10 + memberIdx, + ), + }), + ); + + const tournamentWith = ({ + bracketProgression, + startsAt, + regClosesAt, + isInvitational, + teams, + data, + }: { + bracketProgression: Progression.ParsedBracket[]; + startsAt: Date; + regClosesAt?: Date; + isInvitational?: boolean; + teams: TournamentData["ctx"]["teams"]; + data?: BracketData; + }) => + testTournament({ + data, + ctx: { + startsAt: dateToDatabaseTimestamp(startsAt), + teams, + settings: { + bracketProgression, + regClosesAt: regClosesAt + ? dateToDatabaseTimestamp(regClosesAt) + : undefined, + isInvitational, + }, + }, + }); + + const DOUBLE_ELIMINATION_ONLY: Progression.ParsedBracket[] = [ + { + type: "double_elimination", + name: "Main Bracket", + requiresCheckIn: false, + settings: {}, + }, + ]; + + test("prefills with the registered team count when registration has closed", () => { + const tournament = tournamentWith({ + bracketProgression: DOUBLE_ELIMINATION_ONLY, + startsAt: subHours(new Date(), 1), + teams: teams({ count: 12 }), + }); + + expect( + PreparedMaps.eliminationTeamCountPrefill({ tournament, bracketIdx: 0 }), + ).toBe(16); + }); + + test("does not count teams that never filled their roster", () => { + const tournament = tournamentWith({ + bracketProgression: DOUBLE_ELIMINATION_ONLY, + startsAt: addMinutes(new Date(), 30), + regClosesAt: subMinutes(new Date(), 10), + teams: [ + ...teams({ count: 12 }), + ...teams({ count: 5, firstId: 13, memberCount: 2 }), + ], + }); + + expect( + PreparedMaps.eliminationTeamCountPrefill({ tournament, bracketIdx: 0 }), + ).toBe(16); + }); + + test("prefills invitational tournaments even if the start time is far away", () => { + const tournament = tournamentWith({ + bracketProgression: DOUBLE_ELIMINATION_ONLY, + startsAt: addHours(new Date(), 5), + isInvitational: true, + teams: teams({ count: 8 }), + }); + + expect( + PreparedMaps.eliminationTeamCountPrefill({ tournament, bracketIdx: 0 }), + ).toBe(8); + }); + + test("counts every team of an invitational tournament even if their roster is not full", () => { + const tournament = tournamentWith({ + bracketProgression: DOUBLE_ELIMINATION_ONLY, + startsAt: addMinutes(new Date(), 30), + isInvitational: true, + teams: [ + ...teams({ count: 7 }), + ...teams({ count: 2, firstId: 8, memberCount: 2 }), + ], + }); + + expect( + PreparedMaps.eliminationTeamCountPrefill({ tournament, bracketIdx: 0 }), + ).toBe(16); + }); + + test("does not prefill while registration is still open", () => { + const tournament = tournamentWith({ + bracketProgression: DOUBLE_ELIMINATION_ONLY, + startsAt: addHours(new Date(), 3), + teams: teams({ count: 12 }), + }); + + expect( + PreparedMaps.eliminationTeamCountPrefill({ tournament, bracketIdx: 0 }), + ).toBeNull(); + }); + + test("prefills with the registered team count when registration is about to close", () => { + const tournament = tournamentWith({ + bracketProgression: DOUBLE_ELIMINATION_ONLY, + startsAt: addMinutes(new Date(), 45), + teams: teams({ count: 12 }), + }); + + expect( + PreparedMaps.eliminationTeamCountPrefill({ tournament, bracketIdx: 0 }), + ).toBe(16); + }); + + test("overestimates if registration is about to close with the team count near the range max", () => { + const tournament = tournamentWith({ + bracketProgression: DOUBLE_ELIMINATION_ONLY, + startsAt: addMinutes(new Date(), 45), + teams: teams({ count: 15 }), + }); + + expect( + PreparedMaps.eliminationTeamCountPrefill({ tournament, bracketIdx: 0 }), + ).toBe(32); + }); + + test("prefills a follow-up bracket with the amount of teams that advance", () => { + const tournament = tournamentWith({ + bracketProgression: [ + { + type: "round_robin", + name: "Groups", + requiresCheckIn: false, + settings: {}, + }, + { + type: "single_elimination", + name: "Top Cut", + requiresCheckIn: false, + settings: {}, + sources: [{ bracketIdx: 0, placements: [1, 2] }], + }, + ], + startsAt: subHours(new Date(), 1), + teams: teams({ count: 16 }), + }); + + expect( + PreparedMaps.eliminationTeamCountPrefill({ tournament, bracketIdx: 1 }), + ).toBe(8); + }); + + test("prefills a follow-up bracket with the real participant count of a source bracket that started", () => { + const startedGroups = Engine.create({ + type: "round_robin", + seeding: nullFilledArray(12).map((_, i) => i + 1), + settings: {}, + }); + + const tournament = tournamentWith({ + bracketProgression: [ + { + type: "round_robin", + name: "Groups", + requiresCheckIn: true, + settings: {}, + }, + { + type: "single_elimination", + name: "Top Cut", + requiresCheckIn: false, + settings: {}, + sources: [{ bracketIdx: 0, placements: [1, 2] }], + }, + ], + startsAt: subHours(new Date(), 1), + // 8 of the registered teams never checked in, so they are not in the started bracket + teams: teams({ count: 20 }), + data: startedGroups, + }); + + expect( + tournament.bracketMetaByIdx(0)?.preview, + "test setup: the source bracket should have started", + ).toBe(false); + + expect( + PreparedMaps.eliminationTeamCountPrefill({ tournament, bracketIdx: 1 }), + ).toBe(8); + }); + + test("prefills a follow-up bracket sourcing the rest of the teams", () => { + const tournament = tournamentWith({ + bracketProgression: [ + { + type: "round_robin", + name: "Groups", + requiresCheckIn: false, + settings: {}, + }, + { + type: "single_elimination", + name: "Top Cut", + requiresCheckIn: false, + settings: {}, + sources: [{ bracketIdx: 0, placements: [1] }], + }, + { + type: "single_elimination", + name: "Underground Bracket", + requiresCheckIn: false, + settings: {}, + sources: [{ bracketIdx: 0, placements: [2], rest: true }], + }, + ], + startsAt: subHours(new Date(), 1), + teams: teams({ count: 16 }), + }); + + expect( + PreparedMaps.eliminationTeamCountPrefill({ tournament, bracketIdx: 2 }), + ).toBe(16); + }); + + test("prefills an underground bracket with the amount of teams eliminated early", () => { + const tournament = tournamentWith({ + bracketProgression: [ + ...DOUBLE_ELIMINATION_ONLY, + { + type: "single_elimination", + name: "Underground Bracket", + requiresCheckIn: false, + settings: {}, + sources: [{ bracketIdx: 0, placements: [-1] }], + }, + ], + startsAt: subHours(new Date(), 1), + teams: teams({ count: 16 }), + }); + + expect( + PreparedMaps.eliminationTeamCountPrefill({ tournament, bracketIdx: 1 }), + ).toBe(4); + }); + + test("does not prefill if teams advance based on swiss early advance", () => { + const tournament = tournamentWith({ + bracketProgression: [ + { + type: "swiss", + name: "Swiss", + requiresCheckIn: false, + settings: { advanceThreshold: 3 }, + }, + { + type: "single_elimination", + name: "Top Cut", + requiresCheckIn: false, + settings: {}, + sources: [{ bracketIdx: 0, placements: [] }], + }, + ], + startsAt: subHours(new Date(), 1), + teams: teams({ count: 16 }), + }); + + expect( + PreparedMaps.eliminationTeamCountPrefill({ tournament, bracketIdx: 1 }), + ).toBeNull(); + }); + + test("does not prefill a bracket that is not an elimination bracket", () => { + const tournament = tournamentWith({ + bracketProgression: [ + { + type: "round_robin", + name: "Groups", + requiresCheckIn: false, + settings: {}, + }, + ], + startsAt: subHours(new Date(), 1), + teams: teams({ count: 16 }), + }); + + expect( + PreparedMaps.eliminationTeamCountPrefill({ tournament, bracketIdx: 0 }), + ).toBeNull(); + }); +}); diff --git a/app/features/tournament-bracket/core/PreparedMaps.ts b/app/features/tournament-bracket/core/PreparedMaps.ts index 397a6aad3..9983fe681 100644 --- a/app/features/tournament-bracket/core/PreparedMaps.ts +++ b/app/features/tournament-bracket/core/PreparedMaps.ts @@ -1,10 +1,14 @@ +import { hoursToMilliseconds } from "date-fns"; import * as R from "remeda"; import type { PreparedMaps } from "~/db/tables-json"; +import { TOURNAMENT } from "~/features/tournament/tournament-constants"; import { nullFilledArray } from "~/utils/arrays"; import invariant from "~/utils/invariant"; import type { Bracket } from "./Bracket"; +import * as Engine from "./engine"; +import type { BracketData } from "./engine/types"; import * as Progression from "./Progression"; -import type { Tournament } from "./Tournament"; +import type { BracketMeta, Tournament } from "./Tournament"; /** Returns the prepared maps for one exact bracket index OR maps of a "sibling bracket" i.e. bracket that has the same depth in progression */ export function resolvePreparedForTheBracket({ @@ -73,6 +77,48 @@ export function isValidMaxEliminationTeamCount(count: number) { return ELIMINATION_BRACKET_TEAM_RANGES.some(({ max }) => max === count); } +/** Registration closing within this window means the teams registered are a good enough basis for an estimate. */ +const REGISTRATION_CLOSING_SOON_MS = hoursToMilliseconds(1); + +/** How big a share of a team count range must be unfilled for an estimated count not to be rounded up to the next range. */ +const ESTIMATE_SLACK_RATIO = 0.1; + +/** + * Team count to prefill the "expected teams" selection with when preparing maps for an elimination bracket. + * Null when it can't be told yet how many teams will play in the bracket e.g. registration is still open. + */ +export function eliminationTeamCountPrefill({ + tournament, + bracketIdx, +}: { + tournament: Tournament; + bracketIdx: number; +}): number | null { + const bracket = tournament.bracketMetaByIdx(bracketIdx); + if ( + bracket?.type !== "single_elimination" && + bracket?.type !== "double_elimination" + ) { + return null; + } + + const expected = expectedTeamCount({ tournament, bracketIdx }); + if (!expected || expected.count < TOURNAMENT.ENOUGH_TEAMS_TO_START) { + return null; + } + + const [smallestFitting, nextUp] = eliminationTeamCountOptions(expected.count); + if (!smallestFitting) return null; + if (expected.isExact) return smallestFitting.max; + + const unfilledShare = + (smallestFitting.max - expected.count) / smallestFitting.max; + + return unfilledShare >= ESTIMATE_SLACK_RATIO + ? smallestFitting.max + : (nextUp?.max ?? smallestFitting.max); +} + interface TrimPreparedEliminationMapsAgs { preparedMaps: PreparedMaps | null; teamCount: number; @@ -192,3 +238,277 @@ function filterOutThirdPlaceMatch(prepared: PreparedMaps): PreparedMaps { maps: prepared.maps.filter((map) => map.groupId === 0), }; } + +interface ExpectedTeamCount { + count: number; + /** False when teams can still join, meaning the count can only grow from what it is now */ + isExact: boolean; +} + +function expectedTeamCount({ + tournament, + bracketIdx, +}: { + tournament: Tournament; + bracketIdx: number; +}): ExpectedTeamCount | null { + const bracket = tournament.bracketMetaByIdx(bracketIdx); + if (!bracket) return null; + + if (!bracket.preview) { + return { + count: bracket.participantTournamentTeamIds.length, + isExact: true, + }; + } + + if (bracket.sources && bracket.sources.length > 0) { + return advancingTeamCount({ tournament, sources: bracket.sources }); + } + + return registeredTeamCount({ tournament, bracketIdx }); +} + +function registeredTeamCount({ + tournament, + bracketIdx, +}: { + tournament: Tournament; + bracketIdx: number; +}): ExpectedTeamCount | null { + const teams = tournament.isMultiStartingBracket + ? tournament.ctx.teams.filter( + (team) => (team.startingBracketIdx ?? 0) === bracketIdx, + ) + : tournament.ctx.teams; + + // the organizer adds the teams of an invitational, all of them are expected to play + if (tournament.isInvitational) { + return { count: teams.length, isExact: true }; + } + + if (!tournament.registrationOpen) { + // teams that never filled their roster won't play + const fullTeams = teams.filter( + (team) => team.memberUserIds.length >= tournament.minMembersPerTeam, + ); + + return { count: fullTeams.length, isExact: true }; + } + + const closesIn = tournament.registrationClosesAt.getTime() - Date.now(); + if (closesIn > REGISTRATION_CLOSING_SOON_MS) return null; + + return { count: teams.length, isExact: false }; +} + +function advancingTeamCount({ + tournament, + sources, +}: { + tournament: Tournament; + sources: Progression.DBSource[]; +}): ExpectedTeamCount | null { + let count = 0; + let isExact = true; + + for (const source of sources) { + const sourceBracket = tournament.bracketMetaByIdx(source.bracketIdx); + if (!sourceBracket) return null; + + const participants = expectedTeamCount({ + tournament, + bracketIdx: source.bracketIdx, + }); + if (!participants) return null; + + const advancing = advancingFromSource({ + bracket: sourceBracket, + participantCount: participants.count, + source, + }); + if (advancing === null) return null; + + count += advancing; + if (!participants.isExact) isExact = false; + } + + return { count, isExact }; +} + +/** How many teams the given source sends forward, based on the shape the source bracket would have with the given participant count. */ +function advancingFromSource({ + bracket, + participantCount, + source, +}: { + bracket: BracketMeta; + participantCount: number; + source: Progression.DBSource; +}): number | null { + // swiss early advance, how many advance depends on the results + if (source.placements.length === 0) return null; + if (participantCount < TOURNAMENT.ENOUGH_TEAMS_TO_START) return null; + + const data = Engine.create({ + type: bracket.type, + seeding: nullFilledArray(participantCount).map((_, i) => i + 1), + settings: bracket.settings, + }); + + if (source.placements.some((placement) => placement < 0)) { + return eliminatedInFirstRoundsCount({ bracket, data, source }); + } + + const maxExplicit = Math.max(...source.placements); + + return R.sumBy(standingsPlacementSizes({ bracket, data }), (size, index) => + source.placements.includes(index + 1) || + (source.rest === true && index + 1 >= maxExplicit) + ? size + : 0, + ); +} + +/** How many teams share each successive standings placement e.g. [1, 1, 2, 4] for an 8 team single elimination bracket (1st, 2nd, tied 3rd, tied 5th). */ +function standingsPlacementSizes({ + bracket, + data, +}: { + bracket: BracketMeta; + data: BracketData; +}): number[] { + switch (bracket.type) { + case "round_robin": + case "swiss": + return groupPlacementSizes({ + data, + hasAbDivisions: bracket.settings?.hasAbDivisions === true, + }); + case "single_elimination": + case "double_elimination": + return eliminationPlacementSizes({ type: bracket.type, data }); + } +} + +function groupPlacementSizes({ + data, + hasAbDivisions, +}: { + data: BracketData; + hasAbDivisions: boolean; +}): number[] { + const sizes: number[] = []; + + for (const group of data.group) { + const teamsInGroup = R.unique( + data.match + .filter((match) => match.groupId === group.id) + .flatMap((match) => [match.opponent1?.id, match.opponent2?.id]) + .filter((id) => typeof id === "number"), + ).length; + + const teamsPerStandings = hasAbDivisions + ? [Math.ceil(teamsInGroup / 2), Math.floor(teamsInGroup / 2)] + : [teamsInGroup]; + + for (const teamCount of teamsPerStandings) { + for (let placement = 1; placement <= teamCount; placement++) { + sizes[placement - 1] = (sizes[placement - 1] ?? 0) + 1; + } + } + } + + return sizes; +} + +function eliminationPlacementSizes({ + type, + data, +}: { + type: "single_elimination" | "double_elimination"; + data: BracketData; +}): number[] { + // the winner is not eliminated in any round, in double elimination neither is the team that lost the grand finals + const winnersSizes = type === "double_elimination" ? [1, 1] : [1]; + + const sizes = [ + ...winnersSizes, + ...eliminationRounds({ type, data }) + .map((round) => nonByeMatchCount({ data, roundId: round.id })) + .reverse(), + ].filter((size) => size > 0); + + const thirdPlaceMatchExists = + type === "single_elimination" && data.group.length > 1; + const semiFinalLosersIdx = 2; + if (thirdPlaceMatchExists && sizes[semiFinalLosersIdx] === 2) { + // the third place match splits the semi final losers into 3rd and 4th + sizes.splice(semiFinalLosersIdx, 1, 1, 1); + } + + return sizes; +} + +/** How many teams the given negative placements (e.g. losers of the first two rounds) source. */ +function eliminatedInFirstRoundsCount({ + bracket, + data, + source, +}: { + bracket: BracketMeta; + data: BracketData; + source: Progression.DBSource; +}): number | null { + if ( + bracket.type !== "single_elimination" && + bracket.type !== "double_elimination" + ) { + return null; + } + + const rounds = eliminationRounds({ type: bracket.type, data }); + const firstRoundIsOnlyByes = + bracket.type === "double_elimination" && + rounds.length > 0 && + nonByeMatchCount({ data, roundId: rounds[0].id }) === 0; + + const roundCount = + Math.abs(Math.min(...source.placements)) + (firstRoundIsOnlyByes ? 1 : 0); + + return R.sumBy(rounds.slice(0, roundCount), (round) => + nonByeMatchCount({ data, roundId: round.id }), + ); +} + +/** Rounds where the teams of the bracket get eliminated, in the order they are played. */ +function eliminationRounds({ + type, + data, +}: { + type: "single_elimination" | "double_elimination"; + data: BracketData; +}) { + const groupIds = R.unique(data.round.map((round) => round.groupId)); + // third place match lives in a separate (higher) group, as does the losers bracket + const groupId = + type === "double_elimination" + ? Math.min(...groupIds) + 1 + : Math.min(...groupIds); + + return data.round + .filter((round) => round.groupId === groupId) + .sort((a, b) => a.id - b.id); +} + +function nonByeMatchCount({ + data, + roundId, +}: { + data: BracketData; + roundId: number; +}) { + return data.match.filter( + (match) => match.roundId === roundId && match.opponent1 && match.opponent2, + ).length; +}