mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-14 15:16:09 -05:00
Prefill expected teams
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user