Add own prepared teams ranges for DE

Closes #3018
This commit is contained in:
Kalle
2026-08-07 14:54:00 +03:00
parent df02b744fb
commit 7d751bb1c8
4 changed files with 209 additions and 27 deletions

View File

@@ -120,9 +120,10 @@ export const action: ActionFunction = async ({ params, request }) => {
eliminationTeamCount:
bracket.type === "single_elimination" ||
bracket.type === "double_elimination"
? PreparedMapsUtils.eliminationTeamCountOptions(
seeding.length,
)[0].max
? PreparedMapsUtils.eliminationTeamCountOptions({
type: bracket.type,
currentCount: seeding.length,
})[0].max
: undefined,
},
});

View File

@@ -97,7 +97,17 @@ export function BracketMapListDialog({
});
}
return PreparedMaps.eliminationTeamCountOptions(bracketTeamsCount)[0].max;
if (
bracket.type !== "single_elimination" &&
bracket.type !== "double_elimination"
) {
return null;
}
return PreparedMaps.eliminationTeamCountOptions({
type: bracket.type,
currentCount: bracketTeamsCount,
})[0].max;
});
const [thirdPlaceMatchLinked, setThirdPlaceMatchLinked] = React.useState(
() => {
@@ -395,6 +405,7 @@ export function BracketMapListDialog({
(bracket.type === "single_elimination" ||
bracket.type === "double_elimination") ? (
<EliminationTeamCountSelect
type={bracket.type}
count={eliminationTeamCount}
realCount={bracketTeamsCount}
setCount={(newCount) => {
@@ -807,10 +818,12 @@ function teamCountAdjustedBracketData({
}
function EliminationTeamCountSelect({
type,
count,
realCount,
setCount,
}: {
type: PreparedMaps.EliminationBracketType;
count: number | null;
realCount: number;
setCount: (count: number | null) => void;
@@ -826,10 +839,11 @@ function EliminationTeamCountSelect({
defaultValue={count ?? ""}
>
<option value="">Select count</option>
{PreparedMaps.eliminationTeamCountOptions(
{PreparedMaps.eliminationTeamCountOptions({
type,
// 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) => {
currentCount: Math.min(realCount, count ?? realCount),
}).map((teamCountRange) => {
const label =
teamCountRange.min === teamCountRange.max
? teamCountRange.min

View File

@@ -1,12 +1,15 @@
import { addHours, addMinutes, subHours, subMinutes } from "date-fns";
import * as R from "remeda";
import { describe, expect, test } from "vitest";
import type { PreparedMaps as PreparedMapsType } from "~/db/tables-json";
import { TOURNAMENT } from "~/features/tournament/tournament-constants";
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 type * as Progression from "./Progression";
import { getRounds } from "./rounds";
import type { TournamentData } from "./Tournament.server";
import { testTournament, tournamentCtxTeam } from "./tests/test-utils";
@@ -203,16 +206,124 @@ describe("PreparedMaps - resolvePreparedForTheBracket", () => {
});
describe("PreparedMaps - eliminationTeamCountOptions", () => {
test("returns options greater than the count given", () => {
expect(
PreparedMaps.eliminationTeamCountOptions(3).every(
(option) => option.max > 3,
),
).toBe(true);
const HIGHEST_TESTED_TEAM_COUNT = 256;
const ELIMINATION_TYPES = [
"single_elimination",
"double_elimination",
] as const;
test("excludes ranges too small for the count given", () => {
const options = PreparedMaps.eliminationTeamCountOptions({
type: "double_elimination",
currentCount: 3,
});
expect(options.some((option) => option.max < 3)).toBe(false);
expect(options[0]).toEqual({ min: 3, max: 3 });
});
test("returns the option equivalent to the current count", () => {
expect(PreparedMaps.eliminationTeamCountOptions(32)[0].max).toBe(32);
expect(
PreparedMaps.eliminationTeamCountOptions({
type: "double_elimination",
currentCount: 32,
})[0].max,
).toBe(32);
});
test("splits a power of two in two for double elimination only", () => {
const currentCount = 12;
expect(
PreparedMaps.eliminationTeamCountOptions({
type: "double_elimination",
currentCount,
})[0].max,
).toBe(12);
expect(
PreparedMaps.eliminationTeamCountOptions({
type: "single_elimination",
currentCount,
})[0].max,
).toBe(16);
});
for (const type of ELIMINATION_TYPES) {
test(`every ${type} team count plays the same rounds as the max of its range`, () => {
const mismatches: string[] = [];
for (
let teamCount = TOURNAMENT.ENOUGH_TEAMS_TO_START;
teamCount <= HIGHEST_TESTED_TEAM_COUNT;
teamCount++
) {
const rangeMax = PreparedMaps.eliminationTeamCountOptions({
type,
currentCount: teamCount,
})[0].max;
const played = playedRoundNames({ type, teamCount });
const preparedFor = playedRoundNames({ type, teamCount: rangeMax });
if (!R.isDeepEqual(played, preparedFor)) {
mismatches.push(
`${teamCount} teams play [${played.join(", ")}] but preparing for ${rangeMax} shows [${preparedFor.join(", ")}]`,
);
}
}
expect(mismatches).toEqual([]);
});
}
/**
* Names of the rounds that actually get played i.e. what both the bracket and the prepared maps dialog show.
* The third place match is left out because it is trimmed separately when it disappears below four teams.
*/
function playedRoundNames({
type,
teamCount,
}: {
type: (typeof ELIMINATION_TYPES)[number];
teamCount: number;
}) {
const bracketData = Engine.create({
type,
seeding: nullFilledArray(teamCount).map((_, i) => i + 1),
settings: {},
});
const rounds =
type === "single_elimination"
? getRounds({ type: "single", bracketData })
: [
...getRounds({ type: "winners", bracketData }),
...getRounds({ type: "losers", bracketData }),
];
return rounds
.map((round) => round.name)
.filter((name) => name !== TOURNAMENT.ROUND_NAMES.THIRD_PLACE_MATCH);
}
});
describe("PreparedMaps - isValidMaxEliminationTeamCount", () => {
test("accepts a max shared by both elimination types", () => {
expect(PreparedMaps.isValidMaxEliminationTeamCount(4)).toBe(true);
});
test("accepts a max that only double elimination splits at", () => {
expect(PreparedMaps.isValidMaxEliminationTeamCount(12)).toBe(true);
});
test("rejects a count that is not the max of any range", () => {
expect(PreparedMaps.isValidMaxEliminationTeamCount(5)).toBe(false);
});
test("rejects counts outside the supported bracket sizes", () => {
expect(PreparedMaps.isValidMaxEliminationTeamCount(1)).toBe(false);
expect(PreparedMaps.isValidMaxEliminationTeamCount(300)).toBe(false);
});
});
@@ -902,7 +1013,7 @@ describe("PreparedMaps - eliminationTeamCountPrefill", () => {
expect(
PreparedMaps.eliminationTeamCountPrefill({ tournament, bracketIdx: 0 }),
).toBe(16);
).toBe(12);
});
test("does not count teams that never filled their roster", () => {
@@ -918,7 +1029,7 @@ describe("PreparedMaps - eliminationTeamCountPrefill", () => {
expect(
PreparedMaps.eliminationTeamCountPrefill({ tournament, bracketIdx: 0 }),
).toBe(16);
).toBe(12);
});
test("prefills invitational tournaments even if the start time is far away", () => {
@@ -947,7 +1058,7 @@ describe("PreparedMaps - eliminationTeamCountPrefill", () => {
expect(
PreparedMaps.eliminationTeamCountPrefill({ tournament, bracketIdx: 0 }),
).toBe(16);
).toBe(12);
});
test("does not prefill while registration is still open", () => {
@@ -983,7 +1094,7 @@ describe("PreparedMaps - eliminationTeamCountPrefill", () => {
expect(
PreparedMaps.eliminationTeamCountPrefill({ tournament, bracketIdx: 0 }),
).toBe(32);
).toBe(24);
});
test("prefills a follow-up bracket with the amount of teams that advance", () => {

View File

@@ -54,7 +54,18 @@ export function resolvePreparedForTheBracket({
return null;
}
const ELIMINATION_BRACKET_TEAM_RANGES = [
export type EliminationBracketType =
| "single_elimination"
| "double_elimination";
interface TeamCountRange {
min: number;
max: number;
}
/** A round is only added once the bracket size doubles. Three teams playing without a third place match is handled by trimming instead. */
const SINGLE_ELIMINATION_TEAM_RANGES: readonly TeamCountRange[] = [
{ min: 2, max: 2 },
{ min: 3, max: 4 },
{ min: 5, max: 8 },
@@ -63,18 +74,49 @@ const ELIMINATION_BRACKET_TEAM_RANGES = [
{ min: 33, max: 64 },
{ min: 65, max: 128 },
{ min: 129, max: 256 },
] as const;
];
/** Below three quarters of the bracket size the loser bracket loses its first round to byes, so every power of two splits in two. */
const DOUBLE_ELIMINATION_TEAM_RANGES: readonly TeamCountRange[] = [
{ min: 2, max: 2 },
{ min: 3, max: 3 },
{ min: 4, max: 4 },
{ min: 5, max: 6 },
{ min: 7, max: 8 },
{ min: 9, max: 12 },
{ min: 13, max: 16 },
{ min: 17, max: 24 },
{ min: 25, max: 32 },
{ min: 33, max: 48 },
{ min: 49, max: 64 },
{ min: 65, max: 96 },
{ min: 97, max: 128 },
{ min: 129, max: 192 },
{ min: 193, max: 256 },
];
/** For single elimination and double elimination returns the amount of options that are the "steps" that affect the round count. Takes in currentCount as an argument, filtering out counts below that. */
export function eliminationTeamCountOptions(currentCount: number) {
return ELIMINATION_BRACKET_TEAM_RANGES.filter(
({ max }) => max >= currentCount,
);
export function eliminationTeamCountOptions({
type,
currentCount,
}: {
type: EliminationBracketType;
currentCount: number;
}) {
const ranges =
type === "double_elimination"
? DOUBLE_ELIMINATION_TEAM_RANGES
: SINGLE_ELIMINATION_TEAM_RANGES;
return ranges.filter(({ max }) => max >= currentCount);
}
/** Validates that given count is a known "max" elimination team count value */
export function isValidMaxEliminationTeamCount(count: number) {
return ELIMINATION_BRACKET_TEAM_RANGES.some(({ max }) => max === count);
return [
...SINGLE_ELIMINATION_TEAM_RANGES,
...DOUBLE_ELIMINATION_TEAM_RANGES,
].some(({ max }) => max === count);
}
/** Registration closing within this window means the teams registered are a good enough basis for an estimate. */
@@ -107,7 +149,10 @@ export function eliminationTeamCountPrefill({
return null;
}
const [smallestFitting, nextUp] = eliminationTeamCountOptions(expected.count);
const [smallestFitting, nextUp] = eliminationTeamCountOptions({
type: bracket.type,
currentCount: expected.count,
});
if (!smallestFitting) return null;
if (expected.isExact) return smallestFitting.max;
@@ -136,6 +181,14 @@ export function trimPreparedEliminationMaps({
return null;
}
// only elimination brackets have prepared maps to trim, defensive check
if (
rest.bracket.type !== "single_elimination" &&
rest.bracket.type !== "double_elimination"
) {
return null;
}
// eliminationTeamCount should exist here, defensive check
if (
!preparedMaps.eliminationTeamCount ||
@@ -147,7 +200,10 @@ export function trimPreparedEliminationMaps({
const isPerfectCountMatch =
preparedMaps.eliminationTeamCount ===
eliminationTeamCountOptions(teamCount)[0].max;
eliminationTeamCountOptions({
type: rest.bracket.type,
currentCount: teamCount,
})[0].max;
if (isPerfectCountMatch) {
if (thirdPlaceMatchDisappeared({ preparedMaps, teamCount, ...rest })) {