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;
+}