Prefill expected teams

This commit is contained in:
Kalle
2026-08-07 13:56:36 +03:00
parent af86bedf4c
commit df02b744fb
3 changed files with 662 additions and 16 deletions

View File

@@ -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 ?? ""}
>
<option value="">Select count</option>
{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 (
<option key={teamCountRange.max} value={teamCountRange.max}>
{label}
</option>
);
},
)}
return (
<option key={teamCountRange.max} value={teamCountRange.max}>
{label}
</option>
);
})}
</select>
</div>
);

View File

@@ -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();
});
});

View File

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