Bracket engine refactor (#3249)
Some checks are pending
E2E Tests / e2e (push) Waiting to run
Tests and checks on push / run-checks-and-tests (push) Waiting to run
Updates translation progress / update-translation-progress-issue (push) Waiting to run

This commit is contained in:
Kalle 2026-07-26 12:40:43 +03:00 committed by GitHub
parent a28819654c
commit 6dc2dd2986
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
161 changed files with 10111 additions and 14543 deletions

View File

@ -21,7 +21,7 @@
}
}
.withPoints {
.withKo {
grid-template-areas:
"header header header"
"actions actions actions"

View File

@ -39,8 +39,8 @@ interface MatchActionTabProps {
ownTeamId: number | null;
stageId: StageId;
mode: ModeShort;
withPoints: boolean;
onSubmit?: (data: { winnerId: number; points?: [number, number] }) => void;
withKo: boolean;
onSubmit?: (data: { winnerId: number; ko?: boolean }) => void;
isSubmitting?: boolean;
setEnding?: SetEndingData;
actionButtons?: React.ReactNode;
@ -52,7 +52,7 @@ export function MatchActionTab({
ownTeamId,
stageId,
mode,
withPoints,
withKo,
onSubmit,
isSubmitting,
setEnding,
@ -73,14 +73,7 @@ export function MatchActionTab({
const submit = () => {
if (winnerId === null) return;
const submitPoints: [number, number] | undefined = withPoints
? isKo
? winnerId === teams[0].id
? [100, 0]
: [0, 100]
: [0, 0]
: undefined;
onSubmit?.({ winnerId, points: submitPoints });
onSubmit?.({ winnerId, ko: withKo ? isKo : undefined });
};
return (
@ -92,14 +85,14 @@ export function MatchActionTab({
mode={mode}
winnerId={winnerId}
teams={teams}
withPoints={withPoints}
withKo={withKo}
isKo={isKo}
isSubmitting={isSubmitting}
onBack={() => setConfirming(false)}
onConfirm={submit}
/>
) : (
<div className={clsx(styles.root, { [styles.withPoints]: withPoints })}>
<div className={clsx(styles.root, { [styles.withKo]: withKo })}>
<div className={styles.title}>{t("q:match.action.selectWinner")}</div>
{actionButtons ? (
<div className={styles.actionButtons}>{actionButtons}</div>
@ -156,7 +149,7 @@ export function MatchActionTab({
/>
</RadioGroup>
{withPoints ? (
{withKo ? (
<div className={styles.ko}>
<label className={styles.koLabel}>
<input
@ -199,7 +192,7 @@ function SetEndingConfirmation({
mode,
winnerId,
teams,
withPoints,
withKo,
isKo,
isSubmitting,
onBack,
@ -210,7 +203,7 @@ function SetEndingConfirmation({
mode: ModeShort;
winnerId: number;
teams: [ActionTabTeam, ActionTabTeam];
withPoints: boolean;
withKo: boolean;
isKo: boolean;
isSubmitting?: boolean;
onBack: () => void;
@ -226,11 +219,7 @@ function SetEndingConfirmation({
timestamp: Date.now(),
winner: winnerSide,
rosters: setEnding.currentRosters,
points: withPoints
? isKo
? [winnerSide === "ALPHA" ? 100 : 0, winnerSide === "BRAVO" ? 100 : 0]
: [0, 0]
: undefined,
ko: withKo ? isKo : undefined,
};
const updatedScore = {

View File

@ -48,8 +48,8 @@ export interface TimelineMap {
alpha: Array<MainWeaponId | null>;
bravo: Array<MainWeaponId | null>;
};
/** Optional point values [alpha, bravo] */
points?: [number, number];
/** Whether the game ended in a knockout. Undefined if not collected. */
ko?: boolean;
/** Side that picked this map (counterpick / postGame map PICK). Renders a click indicator next to that side's WIN/LOSS label. */
pickedBy?: MatchSide;
}
@ -210,15 +210,12 @@ function TimelineHeader({
function TimelineMapRow({ map }: { map: TimelineMap }) {
const { t } = useTranslation(["game-misc"]);
const alphaPoints = map.points?.[0];
const bravoPoints = map.points?.[1];
return (
<div className={styles.mapEvent}>
<div className={styles.mapSide}>
<SideResult
result={map.winner === "ALPHA" ? "WIN" : "LOSS"}
points={alphaPoints}
isKo={map.ko && map.winner === "ALPHA"}
weapons={map.weapons?.alpha}
isPicked={map.pickedBy === "ALPHA"}
/>
@ -242,7 +239,7 @@ function TimelineMapRow({ map }: { map: TimelineMap }) {
<div className={styles.mapSide}>
<SideResult
result={map.winner === "BRAVO" ? "WIN" : "LOSS"}
points={bravoPoints}
isKo={map.ko && map.winner === "BRAVO"}
weapons={map.weapons?.bravo}
isPicked={map.pickedBy === "BRAVO"}
/>
@ -253,12 +250,12 @@ function TimelineMapRow({ map }: { map: TimelineMap }) {
function SideResult({
result,
points,
isKo,
weapons,
isPicked,
}: {
result: "WIN" | "LOSS";
points?: number;
isKo?: boolean;
weapons?: Array<MainWeaponId | null>;
isPicked?: boolean;
}) {
@ -288,7 +285,7 @@ function SideResult({
? t("q:match.timeline.win")
: t("q:match.timeline.loss")}
</span>
{points === 100 ? (
{isKo ? (
<span className={styles.resultPoints}>{t("q:match.action.ko")}</span>
) : null}
</div>

View File

@ -438,8 +438,8 @@ function finalizedBracket() {
];
const matchInsertStm = sql.prepare(
`insert into "TournamentMatch" ("stageId", "groupId", "roundId", "number", "status", "opponentOne", "opponentTwo")
values ($stageId, $groupId, $roundId, $number, $status, $opponentOne, $opponentTwo) returning id`,
`insert into "TournamentMatch" ("stageId", "groupId", "roundId", "number", "opponentOne", "opponentTwo", "winnerSide")
values ($stageId, $groupId, $roundId, $number, $opponentOne, $opponentTwo, $winnerSide) returning id`,
);
const gameResultInsertStm = sql.prepare(
@ -454,17 +454,15 @@ function finalizedBracket() {
groupId,
roundId: roundIds[m.round],
number: m.number,
status: 4,
opponentOne: JSON.stringify({
id: m.team1,
score: m.winner === m.team1 ? 2 : 0,
result: m.winner === m.team1 ? "win" : "loss",
}),
opponentTwo: JSON.stringify({
id: m.team2,
score: m.winner === m.team2 ? 2 : 0,
result: m.winner === m.team2 ? "win" : "loss",
}),
winnerSide: m.winner === m.team1 ? "opponent1" : "opponent2",
}) as { id: number }
).id;

View File

@ -13,10 +13,14 @@ import type { Notification as NotificationValue } from "~/features/notifications
import type { ScrimFilters } from "~/features/scrims/scrims-types";
import type { TEAM_MEMBER_ROLES } from "~/features/team/team-constants";
import type { TournamentTierNumber } from "~/features/tournament/core/tiering";
import type {
ParticipantResult,
Side,
StageSettings,
} from "~/features/tournament-bracket/core/engine/types";
import type * as PickBan from "~/features/tournament-bracket/core/PickBan";
import type * as Progression from "~/features/tournament-bracket/core/Progression";
import type { StoredWidget } from "~/features/user-page/core/widgets/types";
import type { ParticipantResult } from "~/modules/brackets-model";
import type {
Ability,
BuildAbilitiesTuple,
@ -644,36 +648,19 @@ export interface TournamentGroup {
stageId: number;
}
export const TournamentMatchStatus = {
/** The two matches leading to this one are not completed yet. */
Locked: 0,
/** One participant is ready and waiting for the other one. */
Waiting: 1,
/** Both participants are ready to start. */
Ready: 2,
/** The match is running. */
Running: 3,
/** The match is completed. */
Completed: 4,
};
export interface TournamentMatch {
chatCode: string | null;
groupId: number;
id: GeneratedAlways<number>;
number: number;
opponentOne: JSONColumnType<ParticipantResult>;
opponentTwo: JSONColumnType<ParticipantResult>;
opponentOne: JSONColumnTypeNullable<ParticipantResult>;
opponentTwo: JSONColumnTypeNullable<ParticipantResult>;
roundId: number;
stageId: number;
status: (typeof TournamentMatchStatus)[keyof typeof TournamentMatchStatus];
// set when match becomes ongoing (both teams ready and no earlier matches for either team)
// for swiss: set at creation time
// set when the match becomes playable i.e. its status is "STARTED"
startedAt: number | null;
/** The side that won the set. `null` while the match has no winner. */
winnerSide: Side | null;
}
/** Represents one decision, pick or ban, during tournaments pick/ban (counterpick, ban 2) phase. */
@ -690,6 +677,8 @@ export interface TournamentMatchPickBanEvent {
export interface TournamentMatchGameResult {
createdAt: Generated<number>;
id: GeneratedAlways<number>;
/** Whether the game ended in a knockout. `null` if not collected for this bracket. */
ko: DBBoolean | null;
matchId: number;
mode: ModeShort;
number: number;
@ -697,8 +686,6 @@ export interface TournamentMatchGameResult {
source: string;
stageId: StageId;
winnerTeamId: number;
opponentOnePoints: number | null;
opponentTwoPoints: number | null;
}
export interface TournamentMatchGameResultParticipant {
@ -810,7 +797,7 @@ export interface TournamentStage {
id: GeneratedAlways<number>;
name: string;
number: number;
settings: string;
settings: JSONColumnType<StageSettings>;
tournamentId: number;
type: (typeof TOURNAMENT_STAGE_TYPES)[number];
// not Generated<> because SQLite doesn't allow altering tables to add columns with default values :(

View File

@ -58,7 +58,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
? (map.source as MapListMap["source"])
: Number(map.source),
participatedUserIds: null,
points: null,
ko: null,
})),
teamAlpha: {
id: match.groupAlpha.id,

View File

@ -42,6 +42,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
"TournamentMatch.id",
"TournamentMatch.opponentOne",
"TournamentMatch.opponentTwo",
"TournamentMatch.winnerSide",
"Tournament.mapPickingStyle",
"TournamentRound.maps",
jsonArrayFrom(
@ -52,8 +53,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
"TournamentMatchGameResult.mode",
"TournamentMatchGameResult.winnerTeamId",
"TournamentMatchGameResult.source",
"TournamentMatchGameResult.opponentOnePoints",
"TournamentMatchGameResult.opponentTwoPoints",
"TournamentMatchGameResult.ko",
jsonArrayFrom(
innerEb
.selectFrom("TournamentMatchGameResultParticipant")
@ -89,14 +89,12 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
return parsed;
};
const mapList = async (): Promise<GetTournamentMatchResponse["mapList"]> => {
if (!match.opponentOne.id || !match.opponentTwo.id) {
const { opponentOne, opponentTwo } = match;
if (!opponentOne?.id || !opponentTwo?.id) {
return null;
}
if (
match.opponentOne.result === "win" ||
match.opponentTwo.result === "win"
) {
if (match.winnerSide) {
return match.playedMapList.map((playedMap) => ({
map: {
mode: playedMap.mode,
@ -108,10 +106,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
participatedUserIds: playedMap.participants.map((p) => p.userId),
winnerTeamId: playedMap.winnerTeamId,
source: parseSource(playedMap.source),
points:
playedMap.opponentOnePoints && playedMap.opponentTwoPoints
? [playedMap.opponentOnePoints, playedMap.opponentTwoPoints]
: null,
ko: playedMap.ko !== null ? Boolean(playedMap.ko) : null,
}));
}
@ -122,7 +117,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
return resolveMapList({
tournamentId: match.tournamentId,
matchId: id,
teams: [match.opponentOne.id, match.opponentTwo.id],
teams: [opponentOne.id, opponentTwo.id],
mapPoolByTeamId: (teamId) => tournament.teamById(teamId)?.mapPool ?? [],
mapPickingStyle: match.mapPickingStyle,
maps: match.maps,
@ -131,7 +126,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
recentlyPlayedMaps:
match.mapPickingStyle !== "TO"
? await TournamentTeamRepository.findRecentlyPlayedMapsByIds({
teamIds: [match.opponentOne.id, match.opponentTwo.id],
teamIds: [opponentOne.id, opponentTwo.id],
}).catch((error) => {
logger.error("Failed to fetch recently played maps", error);
return [];
@ -149,7 +144,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
participatedUserIds: null,
winnerTeamId: null,
source: mapListMap.source,
points: null,
ko: null,
};
});
};
@ -158,13 +153,13 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
tournament.matchContextNamesById(id);
const result: GetTournamentMatchResponse = {
teamOne: match.opponentOne.id
teamOne: match.opponentOne?.id
? {
id: match.opponentOne.id,
score: match.opponentOne.score ?? 0,
}
: null,
teamTwo: match.opponentTwo.id
teamTwo: match.opponentTwo?.id
? {
id: match.opponentTwo.id,
score: match.opponentTwo.score ?? 0,

View File

@ -1,6 +1,6 @@
import type { Pronouns } from "~/db/tables";
import type { TierName } from "~/features/mmr/mmr-constants";
import type { DataTypes, ValueToArray } from "~/modules/brackets-manager/types";
import type { BracketData } from "~/features/tournament-bracket/core/engine/types";
/** GET /api/user/{userId|discordId} */
@ -368,7 +368,10 @@ export interface GetTournamentBracketStandingsResponse {
setLosses: number;
mapWins: number;
mapLosses: number;
points: number;
/** @deprecated points are no longer tracked, see koCount instead */
points?: number;
/** (round robin only) how many knockout wins the team has */
koCount?: number;
winsAgainstTied: number;
lossesAgainstTied?: number;
buchholzSets?: number;
@ -523,8 +526,8 @@ export type MapListMap = {
| "ROLL";
winnerTeamId: number | null;
participatedUserIds: Array<number> | null;
/** (round robin only) points of the match used for tiebreaker purposes. e.g. [100, 0] indicates a knockout. */
points: [number, number] | null;
/** (round robin only) whether the map ended in a knockout. `null` if not tracked. */
ko: boolean | null;
};
type TournamentMatchTeam = {
@ -537,7 +540,7 @@ type TournamentBracket = {
name: string;
};
type TournamentBracketData = ValueToArray<DataTypes>;
type TournamentBracketData = BracketData;
/** POST /api/tournament/{id}/seeds */

View File

@ -7,10 +7,8 @@ import { Label } from "~/components/Label";
import { Main } from "~/components/Main";
import type { Tables } from "~/db/tables";
import type { Bracket as BracketType } from "~/features/tournament-bracket/core/Bracket";
import { getTournamentManager } from "~/features/tournament-bracket/core/brackets-manager";
import * as Swiss from "~/features/tournament-bracket/core/Swiss";
import { fillWithNullTillPowerOfTwo } from "~/features/tournament-bracket/tournament-bracket-utils";
import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types";
import * as Engine from "~/features/tournament-bracket/core/engine";
import type { BracketData } from "~/features/tournament-bracket/core/engine/types";
import styles from "../bracket-test.module.css";
type FormatType = Tables["TournamentStage"]["type"];
@ -204,7 +202,7 @@ function generateTeams(count: number) {
}));
}
function countRounds(data: TournamentManagerDataSet, isDoubleElim: boolean) {
function countRounds(data: BracketData, isDoubleElim: boolean) {
const totalRounds = Math.max(...data.round.map((r) => r.number));
if (!isDoubleElim) return { totalRounds, wbRounds: 0, lbRounds: 0 };
@ -212,14 +210,14 @@ function countRounds(data: TournamentManagerDataSet, isDoubleElim: boolean) {
const wbGroupId = data.group.find((g) => g.number === 1)?.id;
const lbGroupId = data.group.find((g) => g.number === 2)?.id;
const wbRounds = data.round.filter((r) => r.group_id === wbGroupId).length;
const lbRounds = data.round.filter((r) => r.group_id === lbGroupId).length;
const wbRounds = data.round.filter((r) => r.groupId === wbGroupId).length;
const lbRounds = data.round.filter((r) => r.groupId === lbGroupId).length;
return { totalRounds, wbRounds, lbRounds };
}
function simulateCompletedRoundsByGroup(
data: TournamentManagerDataSet,
data: BracketData,
wbCompleted: number,
lbCompleted: number,
) {
@ -228,10 +226,10 @@ function simulateCompletedRoundsByGroup(
const completedRoundIds = new Set<number>();
for (const round of data.round) {
if (round.group_id === wbGroupId && round.number <= wbCompleted) {
if (round.groupId === wbGroupId && round.number <= wbCompleted) {
completedRoundIds.add(round.id);
}
if (round.group_id === lbGroupId && round.number <= lbCompleted) {
if (round.groupId === lbGroupId && round.number <= lbCompleted) {
completedRoundIds.add(round.id);
}
}
@ -239,10 +237,7 @@ function simulateCompletedRoundsByGroup(
markMatchesCompleted(data, completedRoundIds);
}
function simulateCompletedRounds(
data: TournamentManagerDataSet,
completedRounds: number,
) {
function simulateCompletedRounds(data: BracketData, completedRounds: number) {
if (completedRounds <= 0) return;
const roundsByNumber = new Map<number, number[]>();
@ -263,56 +258,42 @@ function simulateCompletedRounds(
}
function markMatchesCompleted(
data: TournamentManagerDataSet,
data: BracketData,
completedRoundIds: Set<number>,
) {
for (const match of data.match) {
if (!completedRoundIds.has(match.round_id)) continue;
if (!completedRoundIds.has(match.roundId)) continue;
// skip BYE matches (opponent slot is null entirely)
if (match.opponent1 === null || match.opponent2 === null) continue;
match.opponent1 = { ...match.opponent1, score: 2, result: "win" };
match.opponent2 = { ...match.opponent2, score: 0, result: "loss" };
match.status = 4;
match.opponent1 = { ...match.opponent1, score: 2 };
match.opponent2 = { ...match.opponent2, score: 0 };
match.winnerSide = "opponent1";
}
}
function generateBracketData(
format: FormatType,
teamIds: number[],
): TournamentManagerDataSet {
): BracketData {
if (format === "swiss") {
return Swiss.create({
tournamentId: 1,
name: "Test Bracket",
return Engine.create({
type: "swiss",
seeding: teamIds,
settings: {
swiss: { groupCount: 1, roundCount: 5 },
},
settings: { groupCount: 1, roundCount: 5 },
});
}
const manager = getTournamentManager();
const seeding =
format === "round_robin" ? teamIds : fillWithNullTillPowerOfTwo(teamIds);
const settings =
format === "single_elimination"
? { consolationFinal: false }
? { thirdPlaceMatch: false }
: format === "double_elimination"
? { grandFinal: "double" as const }
: {
groupCount: Math.ceil(teamIds.length / 4),
seedOrdering: ["groups.seed_optimized" as const],
};
? null
: { teamsPerGroup: 4 };
manager.create({
tournamentId: 1,
name: "Test Bracket",
return Engine.create({
type: format,
seeding,
seeding: teamIds,
settings,
});
return manager.get.tournamentData(1);
}

View File

@ -4,10 +4,8 @@ import { z } from "zod";
import { requireUser } from "~/features/auth/core/user.server";
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server";
import {
clearTournamentDataCache,
tournamentManagerData,
} from "~/features/tournament-bracket/core/Tournament.server";
import * as BracketRepository from "~/features/tournament-bracket/BracketRepository.server";
import { clearTournamentDataCache } from "~/features/tournament-bracket/core/Tournament.server";
import { databaseTimestampToDate } from "~/utils/dates";
import { errorToastIfFalsy, notFoundIfNullish } from "~/utils/remix.server";
import { CALENDAR_PAGE } from "~/utils/urls";
@ -25,7 +23,8 @@ export const action: ActionFunction = async ({ params }) => {
if (event.tournamentId) {
errorToastIfFalsy(
tournamentManagerData(event.tournamentId).stage.length === 0,
(await BracketRepository.findByTournamentId(event.tournamentId)).stage
.length === 0,
"Tournament has already started",
);
} else {

View File

@ -1,8 +1,8 @@
import { z } from "zod";
import { type CalendarEventTag, TOURNAMENT_STAGE_TYPES } from "~/db/tables";
import { TOURNAMENT } from "~/features/tournament/tournament-constants";
import * as Swiss from "~/features/tournament-bracket/core/engine/swiss/team-status";
import * as Progression from "~/features/tournament-bracket/core/Progression";
import * as Swiss from "~/features/tournament-bracket/core/Swiss";
import {
array,
checkboxGroup,

View File

@ -9,8 +9,8 @@ import { FormMessage } from "~/components/FormMessage";
import { Input } from "~/components/Input";
import { Label } from "~/components/Label";
import { TOURNAMENT } from "~/features/tournament/tournament-constants";
import * as Swiss from "~/features/tournament-bracket/core/engine/swiss/team-status";
import * as Progression from "~/features/tournament-bracket/core/Progression";
import * as Swiss from "~/features/tournament-bracket/core/Swiss";
import { defaultBracketSettings } from "../../tournament/tournament-utils";
import styles from "./BracketProgressionSelector.module.css";

View File

@ -1,7 +1,6 @@
import type { TournamentTierNumber } from "~/features/tournament/core/tiering";
import { RunningTournaments } from "~/features/tournament-bracket/core/RunningTournaments.server";
import type { Tournament } from "~/features/tournament-bracket/core/Tournament";
import { Status } from "~/modules/brackets-model";
import { cache } from "~/utils/cache.server";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import { tournamentStreamsPage } from "~/utils/urls";
@ -70,17 +69,11 @@ function deriveCurrentRound(tournament: Tournament): string {
if (bracket.isUnderground) continue;
for (const match of bracket.data.match) {
const isActive =
match.status === Status.Ready || match.status === Status.Running;
const hasParticipants = match.opponent1 && match.opponent2;
const isNotFinished =
!match.opponent1?.result && !match.opponent2?.result;
if (bracket.matchStatus(match.id) !== "STARTED") continue;
if (isActive && hasParticipants && isNotFinished) {
const context = tournament.matchContextNamesById(match.id);
if (context?.roundNameWithoutMatchIdentifier) {
return context.roundNameWithoutMatchIdentifier;
}
const context = tournament.matchContextNamesById(match.id);
if (context?.roundNameWithoutMatchIdentifier) {
return context.roundNameWithoutMatchIdentifier;
}
}

View File

@ -92,7 +92,6 @@ const createTournamentMatch = async ({
groupId: group.id,
roundId: round.id,
number: 1,
status: 4,
opponentOne: JSON.stringify({ id: null }),
opponentTwo: JSON.stringify({ id: null }),
})

View File

@ -290,7 +290,7 @@ export default function MatchPageTestRoute() {
ownTeamId={1}
stageId={4}
mode="SZ"
withPoints={true}
withKo={true}
actionButtons={
<SendouButton
variant="minimal-destructive"
@ -688,7 +688,7 @@ export default function MatchPageTestRoute() {
mode: "RM",
timestamp: 1712856200,
winner: "ALPHA",
points: [100, 42],
ko: true,
weapons: {
alpha: [40, null, 1100, 3040],
bravo: [null, 210, null, 4010],

View File

@ -74,7 +74,7 @@ function ReportMapSection({ viewerSide }: { viewerSide: ScrimSide }) {
ownTeamId={ownTeamId}
stageId={map.stageId}
mode={map.mode}
withPoints={false}
withKo={false}
isSubmitting={fetcher.state !== "idle"}
onSubmit={({ winnerId }) => {
fetcher.submit(

View File

@ -422,7 +422,7 @@ function InProgressTab({
ownTeamId={ownTeamId}
stageId={currentMap.stageId}
mode={currentMap.mode}
withPoints={false}
withKo={false}
isSubmitting={fetcher.state !== "idle"}
setEnding={setEnding}
onSubmit={({ winnerId }) => {

View File

@ -2,6 +2,7 @@ import type { ActionFunction } from "react-router";
import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "~/features/admin/core/dev-controls";
import { requireUser } from "~/features/auth/core/user.server";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
import * as BracketRepository from "~/features/tournament-bracket/BracketRepository.server";
import * as Progression from "~/features/tournament-bracket/core/Progression";
import {
clearTournamentDataCache,
@ -57,7 +58,7 @@ export const action: ActionFunction = async ({ request, params }) => {
"Some bracket that sources teams from this bracket has started",
);
await TournamentRepository.resetBracket(data.stageId);
await BracketRepository.resetBracket(data.stageId);
message = "Bracket reset";
break;

View File

@ -1,11 +1,12 @@
import type { ActionFunction } from "react-router";
import * as R from "remeda";
import { db } from "~/db/sql";
import { requireUser } from "~/features/auth/core/user.server";
import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server";
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
import { endDroppedTeamMatches } from "~/features/tournament/tournament-utils.server";
import { getServerTournamentManager } from "~/features/tournament-bracket/core/brackets-manager/manager.server";
import * as BracketRepository from "~/features/tournament-bracket/BracketRepository.server";
import {
clearTournamentDataCache,
tournamentFromDB,
@ -116,7 +117,6 @@ export const action: ActionFunction = async ({ request, params }) => {
const endedMatchIds = await dropTeamOut({
tournament,
manager: getServerTournamentManager(),
teamId: data.teamId,
});
@ -152,11 +152,9 @@ export const action: ActionFunction = async ({ request, params }) => {
*/
async function dropTeamOut({
tournament,
manager,
teamId,
}: {
tournament: Awaited<ReturnType<typeof tournamentFromDB>>;
manager: ReturnType<typeof getServerTournamentManager>;
teamId: number;
}) {
const droppingTeam = tournament.teamById(teamId);
@ -176,10 +174,22 @@ async function dropTeamOut({
});
}
const endedMatchIds = endDroppedTeamMatches({
tournament,
manager,
droppedTeamId: teamId,
const endedMatchIds = await db.transaction().execute(async (trx) => {
const bracketData = await BracketRepository.findByTournamentId(
tournament.ctx.id,
trx,
);
const droppedResult = endDroppedTeamMatches({
tournament,
data: bracketData,
droppedTeamId: teamId,
});
await BracketRepository.applyMatchChanges(
{ previousData: bracketData, result: droppedResult },
trx,
);
return droppedResult.endedMatchIds;
});
await TournamentTeamRepository.dropOut({

View File

@ -0,0 +1,379 @@
import {
type Kysely,
sql as kyselySql,
type RawBuilder,
type Transaction,
} from "kysely";
import { jsonArrayFrom } from "kysely/helpers/sqlite";
import { db } from "~/db/sql";
import type { DB } from "~/db/tables";
import { databaseTimestampNow } from "~/utils/dates";
import { shortNanoid } from "~/utils/id";
import { matchStatuses } from "./core/engine/status";
import type {
BracketData,
EngineResult,
GeneratedRound,
ParticipantResult,
} from "./core/engine/types";
type DbOrTrx = Kysely<DB> | Transaction<DB>;
/**
* Loads the full BracketData for a tournament (all stages). Includes the
* score/totalKos aggregation over TournamentMatchGameResult. Direct replacement
* for the old manager.get.tournamentData(); also called from write actions
* inside their transaction (propagation must be computed from fresh rows, not
* the cached Tournament instance).
*/
export async function findByTournamentId(
tournamentId: number,
trx: DbOrTrx = db,
): Promise<BracketData> {
const { stage, group, round, match } = await trx
.selectNoFrom((eb) => [
jsonArrayFrom(
eb
.selectFrom("TournamentStage")
.select([
"TournamentStage.id",
"TournamentStage.name",
"TournamentStage.type",
"TournamentStage.settings",
"TournamentStage.number",
"TournamentStage.createdAt",
])
.where("TournamentStage.tournamentId", "=", tournamentId)
.orderBy("TournamentStage.id", "asc"),
).as("stage"),
jsonArrayFrom(
eb
.selectFrom("TournamentGroup")
.innerJoin(
"TournamentStage",
"TournamentStage.id",
"TournamentGroup.stageId",
)
.select([
"TournamentGroup.id",
"TournamentGroup.stageId",
"TournamentGroup.number",
])
.where("TournamentStage.tournamentId", "=", tournamentId)
.orderBy("TournamentGroup.stageId", "asc")
.orderBy("TournamentGroup.id", "asc"),
).as("group"),
jsonArrayFrom(
eb
.selectFrom("TournamentRound")
.innerJoin(
"TournamentStage",
"TournamentStage.id",
"TournamentRound.stageId",
)
.select([
"TournamentRound.id",
"TournamentRound.stageId",
"TournamentRound.groupId",
"TournamentRound.number",
"TournamentRound.maps",
])
.where("TournamentStage.tournamentId", "=", tournamentId)
.orderBy("TournamentRound.stageId", "asc")
.orderBy("TournamentRound.id", "asc"),
).as("round"),
jsonArrayFrom(
eb
.selectFrom("TournamentMatch")
.innerJoin(
"TournamentStage",
"TournamentStage.id",
"TournamentMatch.stageId",
)
.leftJoin(
"TournamentMatchGameResult",
"TournamentMatch.id",
"TournamentMatchGameResult.matchId",
)
.select([
"TournamentMatch.id",
"TournamentMatch.stageId",
"TournamentMatch.groupId",
"TournamentMatch.roundId",
"TournamentMatch.number",
"TournamentMatch.startedAt",
"TournamentMatch.winnerSide",
// totalKos is never persisted, it is aggregated fresh from the game results
serializedOpponentWithKos("opponentOne").as("opponent1"),
serializedOpponentWithKos("opponentTwo").as("opponent2"),
])
.where("TournamentStage.tournamentId", "=", tournamentId)
.groupBy("TournamentMatch.id")
.orderBy("TournamentMatch.stageId", "asc")
.orderBy("TournamentMatch.id", "asc"),
).as("match"),
])
.executeTakeFirstOrThrow();
return { stage, group, round, match };
}
/**
* Builds the opponent JSON with the freshly aggregated KO count: sets
* `totalKos` to the SQL sum over the match's game results. Resolves to `null`
* for BYEs (the column is `null`).
*/
function serializedOpponentWithKos(
column: "opponentOne" | "opponentTwo",
): RawBuilder<ParticipantResult | null> {
return kyselySql<ParticipantResult | null>`json_set(
${kyselySql.ref(`TournamentMatch.${column}`)},
'$.totalKos',
sum(
case
when "TournamentMatchGameResult"."ko" = 1
and "TournamentMatchGameResult"."winnerTeamId" = ${kyselySql.ref(`TournamentMatch.${column}`)} ->> '$.id'
then 1
else 0
end
)
)`;
}
/**
* Persists Engine.create output in one transaction. Inserts stage groups
* rounds matches, translating the engine's local ids to real row ids. The
* stage number is assigned from the existing stages of the tournament.
*/
export function insertBracket(args: {
tournamentId: number;
name: string;
bracket: BracketData;
}): Promise<{ stageId: number }> {
const stageInput = args.bracket.stage[0];
if (!stageInput) throw new Error("Bracket has no stage");
return db.transaction().execute(async (trx) => {
const stage = await trx
.insertInto("TournamentStage")
.values({
tournamentId: args.tournamentId,
name: args.name,
type: stageInput.type,
settings: JSON.stringify(stageInput.settings),
number: kyselySql<number>`(select coalesce(max("number"), 0) + 1 from "TournamentStage" where "tournamentId" = ${args.tournamentId})`,
createdAt: databaseTimestampNow(),
})
.returning(["id"])
.executeTakeFirstOrThrow();
const groupIdMapping = new Map<number, number>();
const roundIdMapping = new Map<number, number>();
for (const group of args.bracket.group) {
const inserted = await trx
.insertInto("TournamentGroup")
.values({
stageId: stage.id,
number: group.number,
})
.returning(["id"])
.executeTakeFirstOrThrow();
groupIdMapping.set(group.id, inserted.id);
}
for (const round of args.bracket.round) {
if (!round.maps) throw new Error("Round is missing maps");
const inserted = await trx
.insertInto("TournamentRound")
.values({
stageId: stage.id,
groupId: groupIdMapping.get(round.groupId)!,
number: round.number,
maps: JSON.stringify(round.maps),
})
.returning(["id"])
.executeTakeFirstOrThrow();
roundIdMapping.set(round.id, inserted.id);
}
const statuses = matchStatuses(args.bracket);
for (const match of args.bracket.match) {
await trx
.insertInto("TournamentMatch")
.values({
stageId: stage.id,
groupId: groupIdMapping.get(match.groupId)!,
roundId: roundIdMapping.get(match.roundId)!,
number: match.number,
opponentOne: serializeOpponent(match.opponent1),
opponentTwo: serializeOpponent(match.opponent2),
winnerSide: match.winnerSide,
chatCode: shortNanoid(),
startedAt:
statuses.get(match.id) === "STARTED"
? databaseTimestampNow()
: null,
})
.execute();
}
return { stageId: stage.id };
});
}
/**
* UPDATEs the opponents of the changed matches and keeps startedAt in sync with
* the statuses that the new state implies. Called inside the caller's
* transaction with the bracket data the operation was computed from.
*/
export async function applyMatchChanges(
args: { previousData: BracketData; result: EngineResult },
trx: DbOrTrx = db,
): Promise<void> {
for (const match of args.result.changedMatches) {
await trx
.updateTable("TournamentMatch")
.set({
opponentOne: serializeOpponent(match.opponent1),
opponentTwo: serializeOpponent(match.opponent2),
winnerSide: match.winnerSide,
})
.where("id", "=", match.id)
.execute();
}
await syncStartedAt(args.previousData, args.result.data, trx);
}
/**
* A match starts when it stops being pending, which can also happen as a side
* effect of another match's result (the teams of a round robin round becoming
* free, an opponent advancing). Matches that were already started keep the
* timestamp they got, and one that goes back to pending loses it.
*/
async function syncStartedAt(
previousData: BracketData,
data: BracketData,
trx: DbOrTrx,
): Promise<void> {
const previousStatuses = matchStatuses(previousData);
const statuses = matchStatuses(data);
const wasPending = (matchId: number) =>
previousStatuses.get(matchId) === "PENDING";
const isPending = (matchId: number) => statuses.get(matchId) === "PENDING";
const startedMatchIds = data.match
.filter(
(match) =>
wasPending(match.id) && !isPending(match.id) && !match.startedAt,
)
.map((match) => match.id);
const pendingMatchIds = data.match
.filter(
(match) =>
!wasPending(match.id) && isPending(match.id) && match.startedAt,
)
.map((match) => match.id);
if (startedMatchIds.length > 0) {
await trx
.updateTable("TournamentMatch")
.set({ startedAt: databaseTimestampNow() })
.where("id", "in", startedMatchIds)
.execute();
}
if (pendingMatchIds.length > 0) {
await trx
.updateTable("TournamentMatch")
.set({ startedAt: null })
.where("id", "in", pendingMatchIds)
.execute();
}
}
/** INSERTs a generated round's matches (swiss advance). */
export async function insertRoundMatches(
args: {
stageId: number;
round: GeneratedRound;
},
trx: DbOrTrx = db,
): Promise<void> {
if (args.round.matches.length === 0) {
throw new Error("No matches to insert");
}
await trx
.insertInto("TournamentMatch")
.values(
args.round.matches.map((match) => ({
stageId: args.stageId,
groupId: args.round.groupId,
roundId: args.round.roundId,
number: match.number,
opponentOne: serializeOpponent(match.opponent1),
opponentTwo: serializeOpponent(match.opponent2),
winnerSide: null,
chatCode: shortNanoid(),
// swiss rounds are only generated once they can be played
startedAt:
match.opponent1?.id && match.opponent2?.id
? databaseTimestampNow()
: null,
})),
)
.execute();
}
/** DELETEs a round's matches (swiss unadvance). */
export async function deleteRoundMatches(args: {
groupId: number;
roundId: number;
}): Promise<void> {
await db
.deleteFrom("TournamentMatch")
.where("groupId", "=", args.groupId)
.where("roundId", "=", args.roundId)
.execute();
}
/** Deletes the whole stage subtree (matches, rounds, groups, stage). */
export function resetBracket(tournamentStageId: number) {
return db.transaction().execute(async (trx) => {
await trx
.deleteFrom("TournamentMatch")
.where("stageId", "=", tournamentStageId)
.execute();
await trx
.deleteFrom("TournamentRound")
.where("stageId", "=", tournamentStageId)
.execute();
await trx
.deleteFrom("TournamentGroup")
.where("stageId", "=", tournamentStageId)
.execute();
await trx
.deleteFrom("TournamentStage")
.where("id", "=", tournamentStageId)
.execute();
});
}
/** Opponents are stored as JSON with the SQL-aggregated fields stripped (NULL for BYEs). */
function serializeOpponent(opponent: ParticipantResult | null): string | null {
if (!opponent) return null;
const { totalKos, ...persisted } = opponent;
return JSON.stringify(persisted);
}

View File

@ -1,5 +1,4 @@
import type { ActionFunction } from "react-router";
import { sql } from "~/db/sql";
import { requireUser } from "~/features/auth/core/user.server";
import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
import { notify } from "~/features/notifications/core/notify.server";
@ -7,12 +6,9 @@ import {
calculateTournamentTierFromTeams,
MIN_TEAMS_FOR_TIERING,
} from "~/features/tournament/core/tiering";
import { createSwissBracketInTransaction } from "~/features/tournament/queries/createSwissBracketInTransaction.server";
import { updateRoundMaps } from "~/features/tournament/queries/updateRoundMaps.server";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
import * as Progression from "~/features/tournament-bracket/core/Progression";
import { roundMapsFromInput } from "~/features/tournament-match/core/mapList.server";
import invariant from "~/utils/invariant";
import { logger } from "~/utils/logger";
import {
@ -24,20 +20,17 @@ import {
import { assertUnreachable } from "~/utils/types";
import { idObject } from "~/utils/zod";
import type { PreparedMaps } from "../../../db/tables";
import * as BracketRepository from "../BracketRepository.server";
import * as AbDivisions from "../core/AbDivisions";
import { getServerTournamentManager } from "../core/brackets-manager/manager.server";
import * as Engine from "../core/engine";
import * as PreparedMapsUtils from "../core/PreparedMaps";
import * as Swiss from "../core/Swiss";
import type { Tournament } from "../core/Tournament";
import {
clearTournamentDataCache,
tournamentFromDB,
} from "../core/Tournament.server";
import { bracketSchema } from "../tournament-bracket-schemas.server";
import {
fillWithNullTillPowerOfTwo,
tournamentWebsocketRoom,
} from "../tournament-bracket-utils";
import { tournamentWebsocketRoom } from "../tournament-bracket-utils";
export const action: ActionFunction = async ({ params, request }) => {
const user = requireUser();
@ -47,7 +40,6 @@ export const action: ActionFunction = async ({ params, request }) => {
});
const tournament = await tournamentFromDB({ tournamentId, user });
const data = await parseRequestPayload({ request, schema: bracketSchema });
const manager = getServerTournamentManager();
let emitTournamentUpdate = false;
@ -70,16 +62,15 @@ export const action: ActionFunction = async ({ params, request }) => {
"Bracket is not ready to be started",
);
const groupCount = new Set(bracket.data.round.map((r) => r.group_id))
.size;
const groupCount = new Set(bracket.data.round.map((r) => r.groupId)).size;
const settings = tournament.bracketManagerSettings(
bracket.settings,
bracket.type,
seeding.length,
);
const hasThirdPlaceMatch = Engine.hasThirdPlaceMatch({
type: bracket.type,
settings: bracket.settings,
participantsCount: seeding.length,
});
const maps = settings.consolationFinal
const maps = hasThirdPlaceMatch
? adjustLinkedRounds({
maps: data.maps,
thirdPlaceMatchLinked: data.thirdPlaceMatchLinked,
@ -105,38 +96,20 @@ export const action: ActionFunction = async ({ params, request }) => {
"Invalid map count",
);
sql.transaction(() => {
const stage =
bracket.type === "swiss"
? createSwissBracketInTransaction(
Swiss.create({
name: bracket.name,
seeding,
tournamentId,
settings,
}),
)
: manager.create({
tournamentId,
name: bracket.name,
type: bracket.type,
seeding:
bracket.type === "round_robin"
? seeding
: fillWithNullTillPowerOfTwo(seeding),
settings,
abDivisions,
});
const createdBracket = Engine.create({
type: bracket.type,
seeding,
settings: bracket.settings,
independentRounds: tournament.isLeagueDivision,
abDivisions,
maps,
});
updateRoundMaps(
roundMapsFromInput({
virtualRounds: bracket.data.round,
roundsFromDB: manager.get.stageData(stage.id).round,
maps,
bracket,
}),
);
})();
await BracketRepository.insertBracket({
tournamentId,
name: bracket.name,
bracket: createdBracket,
});
// persist maps as prepared even if they weren't initially so sibling brackets can reuse them
const existingPreparedMaps =
@ -229,11 +202,12 @@ export const action: ActionFunction = async ({ params, request }) => {
"Bracket has started, preparing maps no longer possible",
);
const hasThirdPlaceMatch = tournament.bracketManagerSettings(
bracket.settings,
bracket.type,
data.eliminationTeamCount ?? (bracket.seeding ?? []).length,
).consolationFinal;
const hasThirdPlaceMatch = Engine.hasThirdPlaceMatch({
type: bracket.type,
settings: bracket.settings,
participantsCount:
data.eliminationTeamCount ?? (bracket.seeding ?? []).length,
});
await TournamentRepository.upsertPreparedMaps({
bracketIdx: data.bracketIdx,
@ -257,14 +231,23 @@ export const action: ActionFunction = async ({ params, request }) => {
const bracket = tournament.bracketByIdx(data.bracketIdx);
errorToastIfFalsy(bracket, "Bracket not found");
const matches = Swiss.generateMatchUps({
bracket,
const round = Engine.generateRound(bracket.data, {
groupId: data.groupId,
standings: bracket.standings,
settings: bracket.settings,
});
errorToastIfErr(matches);
errorToastIfErr(round);
await TournamentRepository.insertSwissMatches(matches.value);
const stageId = bracket.data.match.find(
(match) => match.groupId === data.groupId,
)?.stageId;
errorToastIfFalsy(stageId, "No matches found for group");
await BracketRepository.insertRoundMatches({
stageId,
round: round.value,
});
emitTournamentUpdate = true;
@ -281,7 +264,7 @@ export const action: ActionFunction = async ({ params, request }) => {
);
errorToastIfFalsyNoFollowUpBrackets(tournament);
await TournamentRepository.deleteSwissMatches({
await BracketRepository.deleteRoundMatches({
groupId: data.groupId,
roundId: data.roundId,
});

View File

@ -2,7 +2,7 @@ import { createMemoryRouter, RouterProvider } from "react-router";
import { describe, expect, test, vi } from "vitest";
import { render } from "vitest-browser-react";
import styles from "~/features/tournament-bracket/components/Bracket/bracket.module.css";
import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types";
import type { BracketData } from "~/features/tournament-bracket/core/engine/types";
import type { Bracket as BracketType } from "../../core/Bracket";
import { EliminationBracketSide } from "./Elimination";
import { Bracket } from "./index";
@ -108,7 +108,7 @@ vi.mock("~/features/tournament/routes/to.$id", () => ({
useStreamingParticipants: () => [],
}));
function createSingleEliminationData(): TournamentManagerDataSet {
function createSingleEliminationData(): BracketData {
return {
stage: [
{
@ -116,31 +116,30 @@ function createSingleEliminationData(): TournamentManagerDataSet {
name: "Main Bracket",
number: 1,
type: "single_elimination",
tournament_id: 1,
settings: { size: 8 },
settings: {},
},
],
group: [{ id: 1, number: 1, stage_id: 1 }],
group: [{ id: 1, number: 1, stageId: 1 }],
round: [
{
id: 1,
group_id: 1,
groupId: 1,
number: 1,
stage_id: 1,
stageId: 1,
maps: { count: 3, type: "BEST_OF", pickBan: null },
},
{
id: 2,
group_id: 1,
groupId: 1,
number: 2,
stage_id: 1,
stageId: 1,
maps: { count: 3, type: "BEST_OF", pickBan: null },
},
{
id: 3,
group_id: 1,
groupId: 1,
number: 3,
stage_id: 1,
stageId: 1,
maps: { count: 5, type: "BEST_OF", pickBan: null },
},
],
@ -148,78 +147,78 @@ function createSingleEliminationData(): TournamentManagerDataSet {
{
id: 1,
number: 1,
stage_id: 1,
group_id: 1,
round_id: 1,
status: 2,
opponent1: { id: 1, score: 2, result: "win" },
opponent2: { id: 8, score: 0, result: "loss" },
stageId: 1,
groupId: 1,
roundId: 1,
opponent1: { id: 1, score: 2 },
opponent2: { id: 8, score: 0 },
winnerSide: "opponent1",
},
{
id: 2,
number: 2,
stage_id: 1,
group_id: 1,
round_id: 1,
status: 2,
opponent1: { id: 4, score: 2, result: "win" },
opponent2: { id: 5, score: 1, result: "loss" },
stageId: 1,
groupId: 1,
roundId: 1,
opponent1: { id: 4, score: 2 },
opponent2: { id: 5, score: 1 },
winnerSide: "opponent1",
},
{
id: 3,
number: 3,
stage_id: 1,
group_id: 1,
round_id: 1,
status: 2,
opponent1: { id: 3, score: 0, result: "loss" },
opponent2: { id: 6, score: 2, result: "win" },
stageId: 1,
groupId: 1,
roundId: 1,
opponent1: { id: 3, score: 0 },
opponent2: { id: 6, score: 2 },
winnerSide: "opponent2",
},
{
id: 4,
number: 4,
stage_id: 1,
group_id: 1,
round_id: 1,
status: 2,
opponent1: { id: 2, score: 2, result: "win" },
opponent2: { id: 7, score: 0, result: "loss" },
stageId: 1,
groupId: 1,
roundId: 1,
opponent1: { id: 2, score: 2 },
opponent2: { id: 7, score: 0 },
winnerSide: "opponent1",
},
{
id: 5,
number: 1,
stage_id: 1,
group_id: 1,
round_id: 2,
status: 2,
opponent1: { id: 1, score: 2, result: "win" },
opponent2: { id: 4, score: 1, result: "loss" },
stageId: 1,
groupId: 1,
roundId: 2,
opponent1: { id: 1, score: 2 },
opponent2: { id: 4, score: 1 },
winnerSide: "opponent1",
},
{
id: 6,
number: 2,
stage_id: 1,
group_id: 1,
round_id: 2,
status: 2,
opponent1: { id: 6, score: 1, result: "loss" },
opponent2: { id: 2, score: 2, result: "win" },
stageId: 1,
groupId: 1,
roundId: 2,
opponent1: { id: 6, score: 1 },
opponent2: { id: 2, score: 2 },
winnerSide: "opponent2",
},
{
id: 7,
number: 1,
stage_id: 1,
group_id: 1,
round_id: 3,
status: 4,
stageId: 1,
groupId: 1,
roundId: 3,
opponent1: { id: 1 },
opponent2: { id: 2 },
winnerSide: null,
},
],
};
}
function createByeHeavySingleEliminationData(): TournamentManagerDataSet {
function createByeHeavySingleEliminationData(): BracketData {
return {
stage: [
{
@ -227,31 +226,30 @@ function createByeHeavySingleEliminationData(): TournamentManagerDataSet {
name: "Main Bracket",
number: 1,
type: "single_elimination",
tournament_id: 1,
settings: { size: 8 },
settings: {},
},
],
group: [{ id: 1, number: 1, stage_id: 1 }],
group: [{ id: 1, number: 1, stageId: 1 }],
round: [
{
id: 1,
group_id: 1,
groupId: 1,
number: 1,
stage_id: 1,
stageId: 1,
maps: { count: 3, type: "BEST_OF", pickBan: null },
},
{
id: 2,
group_id: 1,
groupId: 1,
number: 2,
stage_id: 1,
stageId: 1,
maps: { count: 3, type: "BEST_OF", pickBan: null },
},
{
id: 3,
group_id: 1,
groupId: 1,
number: 3,
stage_id: 1,
stageId: 1,
maps: { count: 5, type: "BEST_OF", pickBan: null },
},
],
@ -260,80 +258,80 @@ function createByeHeavySingleEliminationData(): TournamentManagerDataSet {
{
id: 1,
number: 1,
stage_id: 1,
group_id: 1,
round_id: 1,
status: 2,
stageId: 1,
groupId: 1,
roundId: 1,
opponent1: { id: 1 },
opponent2: null,
winnerSide: null,
},
{
id: 2,
number: 2,
stage_id: 1,
group_id: 1,
round_id: 1,
status: 3,
stageId: 1,
groupId: 1,
roundId: 1,
opponent1: { id: 4, score: 1 },
opponent2: { id: 5, score: 1 },
winnerSide: null,
},
{
id: 3,
number: 3,
stage_id: 1,
group_id: 1,
round_id: 1,
status: 2,
stageId: 1,
groupId: 1,
roundId: 1,
opponent1: { id: 2 },
opponent2: null,
winnerSide: null,
},
{
id: 4,
number: 4,
stage_id: 1,
group_id: 1,
round_id: 1,
status: 2,
stageId: 1,
groupId: 1,
roundId: 1,
opponent1: { id: 3 },
opponent2: null,
winnerSide: null,
},
// Round 2 - Semis
{
id: 5,
number: 1,
stage_id: 1,
group_id: 1,
round_id: 2,
status: 4,
stageId: 1,
groupId: 1,
roundId: 2,
opponent1: { id: 1 },
opponent2: { id: null },
winnerSide: null,
},
{
id: 6,
number: 2,
stage_id: 1,
group_id: 1,
round_id: 2,
status: 4,
stageId: 1,
groupId: 1,
roundId: 2,
opponent1: { id: 2 },
opponent2: { id: 3 },
winnerSide: null,
},
// Round 3 - Finals
{
id: 7,
number: 1,
stage_id: 1,
group_id: 1,
round_id: 3,
status: 4,
stageId: 1,
groupId: 1,
roundId: 3,
opponent1: { id: null },
opponent2: { id: null },
winnerSide: null,
},
],
};
}
function createDoubleEliminationData(): TournamentManagerDataSet {
function createDoubleEliminationData(): BracketData {
return {
stage: [
{
@ -341,41 +339,40 @@ function createDoubleEliminationData(): TournamentManagerDataSet {
name: "Main Bracket",
number: 1,
type: "double_elimination",
tournament_id: 1,
settings: { size: 4, grandFinal: "double" },
settings: {},
},
],
group: [
{ id: 1, number: 1, stage_id: 1 },
{ id: 2, number: 2, stage_id: 1 },
{ id: 1, number: 1, stageId: 1 },
{ id: 2, number: 2, stageId: 1 },
],
round: [
{
id: 1,
group_id: 1,
groupId: 1,
number: 1,
stage_id: 1,
stageId: 1,
maps: { count: 3, type: "BEST_OF", pickBan: null },
},
{
id: 2,
group_id: 1,
groupId: 1,
number: 2,
stage_id: 1,
stageId: 1,
maps: { count: 5, type: "BEST_OF", pickBan: null },
},
{
id: 3,
group_id: 2,
groupId: 2,
number: 1,
stage_id: 1,
stageId: 1,
maps: { count: 3, type: "BEST_OF", pickBan: null },
},
{
id: 4,
group_id: 2,
groupId: 2,
number: 2,
stage_id: 1,
stageId: 1,
maps: { count: 5, type: "BEST_OF", pickBan: null },
},
],
@ -383,58 +380,58 @@ function createDoubleEliminationData(): TournamentManagerDataSet {
{
id: 1,
number: 1,
stage_id: 1,
group_id: 1,
round_id: 1,
status: 2,
opponent1: { id: 1, score: 2, result: "win" },
opponent2: { id: 4, score: 0, result: "loss" },
stageId: 1,
groupId: 1,
roundId: 1,
opponent1: { id: 1, score: 2 },
opponent2: { id: 4, score: 0 },
winnerSide: "opponent1",
},
{
id: 2,
number: 2,
stage_id: 1,
group_id: 1,
round_id: 1,
status: 2,
opponent1: { id: 2, score: 2, result: "win" },
opponent2: { id: 3, score: 1, result: "loss" },
stageId: 1,
groupId: 1,
roundId: 1,
opponent1: { id: 2, score: 2 },
opponent2: { id: 3, score: 1 },
winnerSide: "opponent1",
},
{
id: 3,
number: 1,
stage_id: 1,
group_id: 1,
round_id: 2,
status: 4,
stageId: 1,
groupId: 1,
roundId: 2,
opponent1: { id: 1 },
opponent2: { id: 2 },
winnerSide: null,
},
{
id: 4,
number: 1,
stage_id: 1,
group_id: 2,
round_id: 3,
status: 2,
opponent1: { id: 4, score: 1, result: "loss" },
opponent2: { id: 3, score: 2, result: "win" },
stageId: 1,
groupId: 2,
roundId: 3,
opponent1: { id: 4, score: 1 },
opponent2: { id: 3, score: 2 },
winnerSide: "opponent2",
},
{
id: 5,
number: 1,
stage_id: 1,
group_id: 2,
round_id: 4,
status: 4,
stageId: 1,
groupId: 2,
roundId: 4,
opponent1: { id: 3 },
opponent2: { id: null },
winnerSide: null,
},
],
};
}
function createRoundRobinData(): TournamentManagerDataSet {
function createRoundRobinData(): BracketData {
return {
stage: [
{
@ -442,55 +439,54 @@ function createRoundRobinData(): TournamentManagerDataSet {
name: "Group Stage",
number: 1,
type: "round_robin",
tournament_id: 1,
settings: { groupCount: 2, roundRobinMode: "simple", size: 6 },
settings: { groupCount: 2 },
},
],
group: [
{ id: 1, number: 1, stage_id: 1 },
{ id: 2, number: 2, stage_id: 1 },
{ id: 1, number: 1, stageId: 1 },
{ id: 2, number: 2, stageId: 1 },
],
round: [
{
id: 1,
group_id: 1,
groupId: 1,
number: 1,
stage_id: 1,
stageId: 1,
maps: { count: 3, type: "BEST_OF", pickBan: null },
},
{
id: 2,
group_id: 1,
groupId: 1,
number: 2,
stage_id: 1,
stageId: 1,
maps: { count: 3, type: "BEST_OF", pickBan: null },
},
{
id: 3,
group_id: 1,
groupId: 1,
number: 3,
stage_id: 1,
stageId: 1,
maps: { count: 3, type: "BEST_OF", pickBan: null },
},
{
id: 4,
group_id: 2,
groupId: 2,
number: 1,
stage_id: 1,
stageId: 1,
maps: { count: 3, type: "BEST_OF", pickBan: null },
},
{
id: 5,
group_id: 2,
groupId: 2,
number: 2,
stage_id: 1,
stageId: 1,
maps: { count: 3, type: "BEST_OF", pickBan: null },
},
{
id: 6,
group_id: 2,
groupId: 2,
number: 3,
stage_id: 1,
stageId: 1,
maps: { count: 3, type: "BEST_OF", pickBan: null },
},
],
@ -498,68 +494,68 @@ function createRoundRobinData(): TournamentManagerDataSet {
{
id: 1,
number: 1,
stage_id: 1,
group_id: 1,
round_id: 1,
status: 2,
opponent1: { id: 1, score: 2, result: "win" },
opponent2: { id: 2, score: 0, result: "loss" },
stageId: 1,
groupId: 1,
roundId: 1,
opponent1: { id: 1, score: 2 },
opponent2: { id: 2, score: 0 },
winnerSide: "opponent1",
},
{
id: 2,
number: 2,
stage_id: 1,
group_id: 1,
round_id: 2,
status: 2,
opponent1: { id: 1, score: 2, result: "win" },
opponent2: { id: 3, score: 1, result: "loss" },
stageId: 1,
groupId: 1,
roundId: 2,
opponent1: { id: 1, score: 2 },
opponent2: { id: 3, score: 1 },
winnerSide: "opponent1",
},
{
id: 3,
number: 3,
stage_id: 1,
group_id: 1,
round_id: 3,
status: 4,
stageId: 1,
groupId: 1,
roundId: 3,
opponent1: { id: 2 },
opponent2: { id: 3 },
winnerSide: null,
},
{
id: 4,
number: 1,
stage_id: 1,
group_id: 2,
round_id: 4,
status: 2,
opponent1: { id: 4, score: 2, result: "win" },
opponent2: { id: 5, score: 1, result: "loss" },
stageId: 1,
groupId: 2,
roundId: 4,
opponent1: { id: 4, score: 2 },
opponent2: { id: 5, score: 1 },
winnerSide: "opponent1",
},
{
id: 5,
number: 2,
stage_id: 1,
group_id: 2,
round_id: 5,
status: 4,
stageId: 1,
groupId: 2,
roundId: 5,
opponent1: { id: 4 },
opponent2: { id: 6 },
winnerSide: null,
},
{
id: 6,
number: 3,
stage_id: 1,
group_id: 2,
round_id: 6,
status: 4,
stageId: 1,
groupId: 2,
roundId: 6,
opponent1: { id: 5 },
opponent2: { id: 6 },
winnerSide: null,
},
],
};
}
function createSwissData(): TournamentManagerDataSet {
function createSwissData(): BracketData {
return {
stage: [
{
@ -567,31 +563,30 @@ function createSwissData(): TournamentManagerDataSet {
name: "Swiss Stage",
number: 1,
type: "swiss",
tournament_id: 1,
settings: { groupCount: 1 },
},
],
group: [{ id: 1, number: 1, stage_id: 1 }],
group: [{ id: 1, number: 1, stageId: 1 }],
round: [
{
id: 1,
group_id: 1,
groupId: 1,
number: 1,
stage_id: 1,
stageId: 1,
maps: { count: 3, type: "BEST_OF", pickBan: null },
},
{
id: 2,
group_id: 1,
groupId: 1,
number: 2,
stage_id: 1,
stageId: 1,
maps: { count: 3, type: "BEST_OF", pickBan: null },
},
{
id: 3,
group_id: 1,
groupId: 1,
number: 3,
stage_id: 1,
stageId: 1,
maps: { count: 3, type: "BEST_OF", pickBan: null },
},
],
@ -599,62 +594,62 @@ function createSwissData(): TournamentManagerDataSet {
{
id: 1,
number: 1,
stage_id: 1,
group_id: 1,
round_id: 1,
status: 2,
opponent1: { id: 1, score: 2, result: "win" },
opponent2: { id: 8, score: 0, result: "loss" },
stageId: 1,
groupId: 1,
roundId: 1,
opponent1: { id: 1, score: 2 },
opponent2: { id: 8, score: 0 },
winnerSide: "opponent1",
},
{
id: 2,
number: 2,
stage_id: 1,
group_id: 1,
round_id: 1,
status: 2,
opponent1: { id: 2, score: 2, result: "win" },
opponent2: { id: 7, score: 1, result: "loss" },
stageId: 1,
groupId: 1,
roundId: 1,
opponent1: { id: 2, score: 2 },
opponent2: { id: 7, score: 1 },
winnerSide: "opponent1",
},
{
id: 3,
number: 3,
stage_id: 1,
group_id: 1,
round_id: 1,
status: 2,
opponent1: { id: 3, score: 1, result: "loss" },
opponent2: { id: 6, score: 2, result: "win" },
stageId: 1,
groupId: 1,
roundId: 1,
opponent1: { id: 3, score: 1 },
opponent2: { id: 6, score: 2 },
winnerSide: "opponent2",
},
{
id: 4,
number: 4,
stage_id: 1,
group_id: 1,
round_id: 1,
status: 2,
opponent1: { id: 4, score: 0, result: "loss" },
opponent2: { id: 5, score: 2, result: "win" },
stageId: 1,
groupId: 1,
roundId: 1,
opponent1: { id: 4, score: 0 },
opponent2: { id: 5, score: 2 },
winnerSide: "opponent2",
},
{
id: 5,
number: 1,
stage_id: 1,
group_id: 1,
round_id: 2,
status: 4,
stageId: 1,
groupId: 1,
roundId: 2,
opponent1: { id: 1 },
opponent2: { id: 2 },
winnerSide: null,
},
{
id: 6,
number: 2,
stage_id: 1,
group_id: 1,
round_id: 2,
status: 4,
stageId: 1,
groupId: 1,
roundId: 2,
opponent1: { id: 5 },
opponent2: { id: 6 },
winnerSide: null,
},
],
};
@ -662,7 +657,7 @@ function createSwissData(): TournamentManagerDataSet {
function createLargeSingleEliminationData(options?: {
ongoingRoundIdx?: number;
}): TournamentManagerDataSet {
}): BracketData {
const { ongoingRoundIdx } = options ?? {};
return {
@ -672,38 +667,37 @@ function createLargeSingleEliminationData(options?: {
name: "Main Bracket",
number: 1,
type: "single_elimination",
tournament_id: 1,
settings: { size: 16 },
settings: {},
},
],
group: [{ id: 1, number: 1, stage_id: 1 }],
group: [{ id: 1, number: 1, stageId: 1 }],
round: [
{
id: 1,
group_id: 1,
groupId: 1,
number: 1,
stage_id: 1,
stageId: 1,
maps: { count: 3, type: "BEST_OF", pickBan: null },
},
{
id: 2,
group_id: 1,
groupId: 1,
number: 2,
stage_id: 1,
stageId: 1,
maps: { count: 3, type: "BEST_OF", pickBan: null },
},
{
id: 3,
group_id: 1,
groupId: 1,
number: 3,
stage_id: 1,
stageId: 1,
maps: { count: 3, type: "BEST_OF", pickBan: null },
},
{
id: 4,
group_id: 1,
groupId: 1,
number: 4,
stage_id: 1,
stageId: 1,
maps: { count: 5, type: "BEST_OF", pickBan: null },
},
],
@ -712,103 +706,85 @@ function createLargeSingleEliminationData(options?: {
{
id: 1,
number: 1,
stage_id: 1,
group_id: 1,
round_id: 1,
status: ongoingRoundIdx === 0 ? 3 : 2,
opponent1:
ongoingRoundIdx === 0
? { id: 1, score: 1 }
: { id: 1, score: 2, result: "win" },
opponent2:
ongoingRoundIdx === 0
? { id: 8, score: 1 }
: { id: 8, score: 0, result: "loss" },
stageId: 1,
groupId: 1,
roundId: 1,
opponent1: { id: 1, score: ongoingRoundIdx === 0 ? 1 : 2 },
opponent2: { id: 8, score: ongoingRoundIdx === 0 ? 1 : 0 },
winnerSide: ongoingRoundIdx === 0 ? null : "opponent1",
},
{
id: 2,
number: 2,
stage_id: 1,
group_id: 1,
round_id: 1,
status: 2,
opponent1: { id: 2, score: 2, result: "win" },
opponent2: { id: 7, score: 0, result: "loss" },
stageId: 1,
groupId: 1,
roundId: 1,
opponent1: { id: 2, score: 2 },
opponent2: { id: 7, score: 0 },
winnerSide: "opponent1",
},
{
id: 3,
number: 3,
stage_id: 1,
group_id: 1,
round_id: 1,
status: 2,
opponent1: { id: 3, score: 2, result: "win" },
opponent2: { id: 6, score: 1, result: "loss" },
stageId: 1,
groupId: 1,
roundId: 1,
opponent1: { id: 3, score: 2 },
opponent2: { id: 6, score: 1 },
winnerSide: "opponent1",
},
{
id: 4,
number: 4,
stage_id: 1,
group_id: 1,
round_id: 1,
status: 2,
opponent1: { id: 4, score: 2, result: "win" },
opponent2: { id: 5, score: 0, result: "loss" },
stageId: 1,
groupId: 1,
roundId: 1,
opponent1: { id: 4, score: 2 },
opponent2: { id: 5, score: 0 },
winnerSide: "opponent1",
},
// Round 2 - 4 matches (all completed unless ongoingRoundIdx === 1)
{
id: 5,
number: 1,
stage_id: 1,
group_id: 1,
round_id: 2,
status: ongoingRoundIdx === 1 ? 3 : 2,
opponent1:
ongoingRoundIdx === 1
? { id: 1, score: 1 }
: { id: 1, score: 2, result: "win" },
opponent2:
ongoingRoundIdx === 1
? { id: 2, score: 1 }
: { id: 2, score: 1, result: "loss" },
stageId: 1,
groupId: 1,
roundId: 2,
opponent1: { id: 1, score: ongoingRoundIdx === 1 ? 1 : 2 },
opponent2: { id: 2, score: 1 },
winnerSide: ongoingRoundIdx === 1 ? null : "opponent1",
},
{
id: 6,
number: 2,
stage_id: 1,
group_id: 1,
round_id: 2,
status: 2,
opponent1: { id: 3, score: 1, result: "loss" },
opponent2: { id: 4, score: 2, result: "win" },
stageId: 1,
groupId: 1,
roundId: 2,
opponent1: { id: 3, score: 1 },
opponent2: { id: 4, score: 2 },
winnerSide: "opponent2",
},
// Round 3 - Semifinals (completed unless ongoingRoundIdx === 2)
{
id: 7,
number: 1,
stage_id: 1,
group_id: 1,
round_id: 3,
status: ongoingRoundIdx === 2 ? 3 : 2,
opponent1:
ongoingRoundIdx === 2
? { id: 1, score: 1 }
: { id: 1, score: 2, result: "win" },
opponent2:
ongoingRoundIdx === 2
? { id: 4, score: 1 }
: { id: 4, score: 0, result: "loss" },
stageId: 1,
groupId: 1,
roundId: 3,
opponent1: { id: 1, score: ongoingRoundIdx === 2 ? 1 : 2 },
opponent2: { id: 4, score: ongoingRoundIdx === 2 ? 1 : 0 },
winnerSide: ongoingRoundIdx === 2 ? null : "opponent1",
},
// Round 4 - Finals (ongoing by default)
{
id: 8,
number: 1,
stage_id: 1,
group_id: 1,
round_id: 4,
status: 4,
stageId: 1,
groupId: 1,
roundId: 4,
opponent1: { id: 1 },
opponent2: { id: 4 },
winnerSide: null,
},
],
};
@ -816,7 +792,7 @@ function createLargeSingleEliminationData(options?: {
function createMockBracket(
type: "single_elimination" | "double_elimination" | "round_robin" | "swiss",
data: TournamentManagerDataSet,
data: BracketData,
): BracketType {
return {
id: 1,

View File

@ -33,14 +33,10 @@ export function EliminationBracketSide(props: EliminationBracketSideProps) {
if (roundIdx >= rounds.length - 2) return false;
const roundMatches = props.bracket.data.match.filter(
(match) => match.round_id === round.id,
(match) => match.roundId === round.id,
);
return !roundMatches.some(
(match) =>
match.opponent1 &&
match.opponent2 &&
match.opponent1.result !== "win" &&
match.opponent2.result !== "win",
(match) => match.opponent1 && match.opponent2 && !match.winnerSide,
);
})
.map((round) => round.id),
@ -50,7 +46,7 @@ export function EliminationBracketSide(props: EliminationBracketSideProps) {
(round) => !hiddenRoundIds.has(round.id),
);
const firstVisibleRoundMatchCount = props.bracket.data.match.filter(
(match) => match.round_id === firstVisibleRound?.id,
(match) => match.roundId === firstVisibleRound?.id,
).length;
const compactedFirstRoundId = resolveCompactedFirstRoundId({
@ -73,7 +69,7 @@ export function EliminationBracketSide(props: EliminationBracketSideProps) {
const bestOf = round.maps?.count;
const allRoundMatches = props.bracket.data.match.filter(
(match) => match.round_id === round.id,
(match) => match.roundId === round.id,
);
const matches =
round.id === compactedFirstRoundId
@ -88,16 +84,12 @@ export function EliminationBracketSide(props: EliminationBracketSideProps) {
const nextRound = rounds[roundIdx + 1];
const nextRoundMatchCount = nextRound
? props.bracket.data.match.filter(
(match) => match.round_id === nextRound.id,
(match) => match.roundId === nextRound.id,
).length
: 0;
const someMatchOngoing = matches.some(
(match) =>
match.opponent1 &&
match.opponent2 &&
match.opponent1.result !== "win" &&
match.opponent2.result !== "win",
(match) => match.opponent1 && match.opponent2 && !match.winnerSide,
);
if (hiddenRoundIds.has(round.id)) {
@ -219,10 +211,10 @@ function resolveCompactedFirstRoundId(args: {
if (firstRound.id !== args.firstVisibleRoundId) return null;
const firstRoundMatches = args.bracketData.match.filter(
(match) => match.round_id === firstRound.id,
(match) => match.roundId === firstRound.id,
);
const secondRoundMatchCount = args.bracketData.match.filter(
(match) => match.round_id === secondRound.id,
(match) => match.roundId === secondRound.id,
).length;
if (firstRoundMatches.length !== secondRoundMatchCount * 2) return null;

View File

@ -11,7 +11,7 @@ import {
useTournament,
useTournamentVods,
} from "~/features/tournament/routes/to.$id";
import { matchEndedEarly } from "~/features/tournament-match/tournament-match-utils";
import { matchEndedEarly } from "~/features/tournament-bracket/core/engine";
import { useAutoRerender } from "~/hooks/useAutoRerender";
import { databaseTimestampToDate } from "~/utils/dates";
import type { Unpacked } from "~/utils/types";
@ -88,8 +88,7 @@ function MatchHeader({ match, type, roundNumber, group }: MatchProps) {
return "";
};
const isOver =
match.opponent1?.result === "win" || match.opponent2?.result === "win";
const isOver = Boolean(match.winnerSide);
const matchVods = isOver ? vods.filter((v) => v.matchId === match.id) : [];
const hasStreams = () => {
if (isOver || !match.opponent1?.id || !match.opponent2?.id) return false;
@ -218,27 +217,29 @@ function MatchRow({
if (!match.opponent1?.id || !match.opponent2?.id || isPreview) return null;
const opponentScore = opponent!.score;
const opponentResult = opponent!.result;
// Display W/L as the score might not reflect the winner set in the early ending
const round = bracket.data.round.find((r) => r.id === match.round_id);
const round = bracket.data.round.find((r) => r.id === match.roundId);
if (
round?.maps &&
matchEndedEarly({
opponentOne: match.opponent1,
opponentTwo: match.opponent2,
winnerSide: match.winnerSide,
count: round.maps.count,
countType: round.maps.type,
})
) {
if (opponentResult === "win") return "W";
if (opponentResult === "loss") return "L";
return match.winnerSide === opponentKey ? "W" : "L";
}
return opponentScore ?? 0;
};
const isLoser = spoilerCensor ? false : opponent?.result === "loss";
const isLoser =
spoilerCensor || !match.winnerSide
? false
: match.winnerSide !== opponentKey;
const { team, simulated } = (() => {
if (opponent?.id) {
@ -424,8 +425,7 @@ function MatchTimer({ match, bracket }: Pick<MatchProps, "match" | "bracket">) {
if (tournament.isLeagueDivision) return null;
if (!match.startedAt) return null;
const isOver =
match.opponent1?.result === "win" || match.opponent2?.result === "win";
const isOver = Boolean(match.winnerSide);
if (isOver) return null;
const isLocked = tournament.ctx.castedMatchesInfo?.lockedMatches?.some(
@ -433,7 +433,7 @@ function MatchTimer({ match, bracket }: Pick<MatchProps, "match" | "bracket">) {
);
if (isLocked) return null;
const round = bracket.data.round.find((r) => r.id === match.round_id);
const round = bracket.data.round.find((r) => r.id === match.roundId);
const bestOf = round?.maps?.count;
if (!bestOf) return null;

View File

@ -10,10 +10,9 @@ import {
tournamentTeamPage,
} from "../../../../utils/urls";
import { useUser } from "../../../auth/core/user";
import { TOURNAMENT } from "../../../tournament/tournament-constants";
import type { Bracket, Standing } from "../../core/Bracket";
import * as Swiss from "../../core/engine/swiss/team-status";
import * as Progression from "../../core/Progression";
import * as Swiss from "../../core/Swiss";
import styles from "./bracket.module.css";
export function PlacementsTable({
@ -32,7 +31,7 @@ export function PlacementsTable({
.filter((s) => s.groupId === groupId);
const missingTeams = bracket.data.match.reduce((acc, cur) => {
if (cur.group_id !== groupId) return acc;
if (cur.groupId !== groupId) return acc;
if (
cur.opponent1?.id &&
@ -60,7 +59,6 @@ export function PlacementsTable({
stats: {
mapLosses: 0,
mapWins: 0,
points: 0,
koCount: 0,
setLosses: 0,
setWins: 0,
@ -88,8 +86,7 @@ export function PlacementsTable({
advanceThreshold: bracket.settings.advanceThreshold,
losses: stats.setLosses,
wins: stats.setWins,
roundCount:
bracket.settings.roundCount ?? TOURNAMENT.SWISS_DEFAULT_ROUND_COUNT,
roundCount: bracket.swissRoundCount,
}) === "advanced"
? bracket.tournament.brackets.find((otherBracket) =>
otherBracket.sources?.some(
@ -289,9 +286,7 @@ function StandingsTable({
advanceThreshold: bracket.settings.advanceThreshold,
losses: s.stats.setLosses,
wins: s.stats.setWins,
roundCount:
bracket.settings.roundCount ??
TOURNAMENT.SWISS_DEFAULT_ROUND_COUNT,
roundCount: bracket.swissRoundCount,
}) === "eliminated";
if (renderQualifiedRow) qualifiedRowRendered = true;

View File

@ -1,4 +1,4 @@
import type { Match as MatchType } from "~/modules/brackets-model";
import type { MatchData as MatchType } from "~/features/tournament-bracket/core/engine/types";
import type { Bracket as BracketType } from "../../core/Bracket";
import { groupNumberToLetters } from "../../tournament-bracket-utils";
import styles from "./bracket.module.css";
@ -14,19 +14,15 @@ export function RoundRobinBracket({ bracket }: { bracket: BracketType }) {
return (
<div className="stack xl">
{groups.map(({ groupName, groupId }) => {
const rounds = bracket.data.round.filter((r) => r.group_id === groupId);
const rounds = bracket.data.round.filter((r) => r.groupId === groupId);
const allMatchesFinished = rounds.every((round) => {
const matches = bracket.data.match.filter(
(match) => match.round_id === round.id,
(match) => match.roundId === round.id,
);
return matches.every(
(match) =>
!match.opponent1 ||
!match.opponent2 ||
match.opponent1?.result === "win" ||
match.opponent2?.result === "win",
(match) => !match.opponent1 || !match.opponent2 || match.winnerSide,
);
});
@ -41,15 +37,12 @@ export function RoundRobinBracket({ bracket }: { bracket: BracketType }) {
const bestOf = round.maps?.count;
const matches = bracket.data.match.filter(
(match) => match.round_id === round.id,
(match) => match.roundId === round.id,
);
const someMatchOngoing = matches.some(
(match) =>
match.opponent1 &&
match.opponent2 &&
match.opponent1.result !== "win" &&
match.opponent2.result !== "win",
match.opponent1 && match.opponent2 && !match.winnerSide,
);
return (
@ -114,7 +107,7 @@ function getGroups(bracket: BracketType) {
for (const group of bracket.data.group) {
const matches = bracket.data.match.filter(
(match) => match.group_id === group.id,
(match) => match.groupId === group.id,
);
result.push({

View File

@ -8,8 +8,8 @@ import {
useBracketExpanded,
useTournament,
} from "~/features/tournament/routes/to.$id";
import type { MatchData as MatchType } from "~/features/tournament-bracket/core/engine/types";
import { useSearchParamState } from "~/hooks/useSearchParamState";
import type { Match as MatchType } from "~/modules/brackets-model";
import type { Bracket as BracketType } from "../../core/Bracket";
import styles from "../../tournament-bracket.module.css";
import { groupNumberToLetters } from "../../tournament-bracket-utils";
@ -44,7 +44,7 @@ export function SwissBracket({
const selectedGroup = groups.find((g) => g.groupId === selectedGroupId)!;
const rounds = bracket.data.round.filter(
(r) => r.group_id === selectedGroupId,
(r) => r.groupId === selectedGroupId,
);
// when bracket starts we go from "virtual id" to a real one
@ -56,18 +56,14 @@ export function SwissBracket({
const someMatchOngoing = (matches: MatchType[]) =>
matches.some(
(match) =>
match.opponent1 &&
match.opponent2 &&
match.opponent1.result !== "win" &&
match.opponent2.result !== "win",
(match) => match.opponent1 && match.opponent2 && !match.winnerSide,
);
const allRoundsFinished = () => {
for (const round of rounds) {
const matches = bracket.data.match.filter(
(match) =>
match.round_id === round.id && match.group_id === selectedGroupId,
match.roundId === round.id && match.groupId === selectedGroupId,
);
if (matches.length === 0 || someMatchOngoing(matches)) {
@ -84,7 +80,7 @@ export function SwissBracket({
for (const round of rounds) {
const matches = bracket.data.match.filter(
(match) =>
match.round_id === round.id && match.group_id === selectedGroupId,
match.roundId === round.id && match.groupId === selectedGroupId,
);
if (someMatchOngoing(matches) && matches.length > 0) {
@ -127,8 +123,7 @@ export function SwissBracket({
{rounds.map((round, roundI) => {
const matches = bracket.data.match.filter(
(match) =>
match.round_id === round.id &&
match.group_id === selectedGroupId,
match.roundId === round.id && match.groupId === selectedGroupId,
);
if (
@ -143,11 +138,7 @@ export function SwissBracket({
const bestOf = round.maps?.count;
const ongoingMatches = matches.filter(
(m) =>
m.opponent1 &&
m.opponent2 &&
!m.opponent1.result &&
!m.opponent2.result,
(m) => m.opponent1 && m.opponent2 && !m.winnerSide,
);
const startedAtValues = ongoingMatches
.map((m) => m.startedAt)
@ -286,7 +277,7 @@ function getGroups(bracket: BracketType) {
for (const group of bracket.data.group) {
const matches = bracket.data.match.filter(
(match) => match.group_id === group.id,
(match) => match.groupId === group.id,
);
result.push({

View File

@ -29,8 +29,8 @@ import {
useTournamentPreparedMaps,
} from "~/features/tournament/routes/to.$id";
import { TOURNAMENT } from "~/features/tournament/tournament-constants";
import type { BracketData } from "~/features/tournament-bracket/core/engine/types";
import * as PickBan from "~/features/tournament-bracket/core/PickBan";
import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types";
import { modesShort } from "~/modules/in-game-lists/modes";
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import { nullFilledArray } from "~/utils/arrays";
@ -40,6 +40,7 @@ import { calendarEditPage } from "~/utils/urls";
import { SendouButton } from "../../../components/elements/Button";
import { logger } from "../../../utils/logger";
import type { Bracket } from "../core/Bracket";
import * as Engine from "../core/engine";
import * as PreparedMaps from "../core/PreparedMaps";
import { getRounds } from "../core/rounds";
import type { Tournament } from "../core/Tournament";
@ -99,11 +100,11 @@ export function BracketMapListDialog({
const [thirdPlaceMatchLinked, setThirdPlaceMatchLinked] = React.useState(
() => {
if (
!tournament.bracketManagerSettings(
bracket.settings,
bracket.type,
eliminationTeamCount ?? 2,
).consolationFinal
!Engine.hasThirdPlaceMatch({
type: bracket.type,
settings: bracket.settings,
participantsCount: eliminationTeamCount ?? 2,
})
) {
return true; // default to true if not applicable or elimination team count not yet set (initial state)
}
@ -209,12 +210,12 @@ export function BracketMapListDialog({
if (bracket.type === "single_elimination") {
const rounds = getRounds({ type: "single", bracketData });
const hasThirdPlaceMatch = rounds.some((round) => round.group_id === 1);
const hasThirdPlaceMatch = rounds.some((round) => round.groupId === 1);
if (!thirdPlaceMatchLinked || !hasThirdPlaceMatch) return rounds;
return rounds
.filter((round) => round.group_id !== 1)
.filter((round) => round.groupId !== 1)
.map((round) =>
round.name === "Finals"
? {
@ -342,7 +343,7 @@ export function BracketMapListDialog({
Array.from(maps.entries()).map(([key, value]) => ({
...value,
roundId: key,
groupId: rounds.find((r) => r.id === key)?.group_id,
groupId: rounds.find((r) => r.id === key)?.groupId,
type: countType,
customFlow: value.pickBan === "CUSTOM" ? customFlow : undefined,
})),
@ -579,14 +580,14 @@ export function BracketMapListDialog({
);
const groupInfo = newMapCounts.get(
bracketRound.group_id,
bracketRound.groupId,
);
invariant(
groupInfo,
"Expected group info to be defined",
);
const oldMapInfo = newMapCounts
.get(bracketRound.group_id)
.get(bracketRound.groupId)
?.get(bracketRound.number);
invariant(
oldMapInfo,
@ -714,7 +715,7 @@ function inferMapCounts({
tournamentRoundMapList,
}: {
bracket: Bracket;
data: TournamentManagerDataSet;
data: BracketData;
tournamentRoundMapList: TournamentRoundMapList;
}) {
const result: BracketMapCounts = new Map();
@ -722,7 +723,7 @@ function inferMapCounts({
for (const [groupId, value] of bracket.defaultRoundBestOfs(data).entries()) {
for (const roundNumber of value.keys()) {
const roundId = data.round.find(
(round) => round.group_id === groupId && round.number === roundNumber,
(round) => round.groupId === groupId && round.number === roundNumber,
)?.id;
invariant(typeof roundId === "number", "Expected roundId to be defined");

View File

@ -1,9 +1,9 @@
import * as R from "remeda";
import { describe, expect, it } from "vitest";
import { BracketsManager } from "~/modules/brackets-manager";
import { InMemoryDatabase } from "~/modules/brackets-memory-db";
import invariant from "../../../utils/invariant";
import * as Swiss from "../core/Swiss";
import * as Engine from "./engine";
import { createResolved } from "./engine/create";
import type { BracketData, MatchData } from "./engine/types";
import { Tournament } from "./Tournament";
import { PADDLING_POOL_255 } from "./tests/mocks";
import { LOW_INK_DECEMBER_2024 } from "./tests/mocks-li";
@ -98,15 +98,12 @@ describe("swiss standings - losses against tied", () => {
});
const inProgressSwissTestTournament = () => {
const data = Swiss.create({
tournamentId: 1,
name: "Swiss",
const data = Engine.create({
type: "swiss",
seeding: [1, 2, 3],
settings: {
swiss: {
groupCount: 1,
roundCount: 5,
},
groupCount: 1,
roundCount: 5,
},
});
@ -232,17 +229,11 @@ describe("round robin standings - dropped out teams", () => {
skipMatchups?: string[];
forfeitMatchups?: string[];
} = {}) => {
const storage = new InMemoryDatabase();
const manager = new BracketsManager(storage);
manager.create({
name: "RR",
tournamentId: 1,
let data = createResolved({
type: "round_robin",
seeding: [1, 2, 3, 4],
settings: {
groupCount: 1,
seedOrdering: ["groups.seed_optimized"],
},
});
@ -252,34 +243,27 @@ describe("round robin standings - dropped out teams", () => {
winnerScore: number,
loserScore: number,
) => {
const match = storage.select<any>("match", matchId);
invariant(match, `match ${matchId} not found`);
const winnerIsOpp1 = match.opponent1.id === winnerId;
manager.update.match({
id: match.id,
opponent1: winnerIsOpp1
? { score: winnerScore, result: "win" }
: { score: loserScore },
opponent2: winnerIsOpp1
? { score: loserScore }
: { score: winnerScore, result: "win" },
});
const match = matchById(data, matchId);
const winnerIsOpp1 = match.opponent1?.id === winnerId;
data = Engine.reportResult(data, {
matchId,
scores: [
winnerIsOpp1 ? winnerScore : loserScore,
winnerIsOpp1 ? loserScore : winnerScore,
],
winnerSide: winnerIsOpp1 ? "opponent1" : "opponent2",
}).data;
};
// Mimics endDroppedTeamMatches: sets a winner via result only, with no
// score recorded on either side (the match was never actually played).
const forfeitMatch = (matchId: number, winnerId: number) => {
const match = storage.select<any>("match", matchId);
invariant(match, `match ${matchId} not found`);
manager.update.match({
id: match.id,
opponent1: {
result: match.opponent1.id === winnerId ? "win" : "loss",
},
opponent2: {
result: match.opponent2.id === winnerId ? "win" : "loss",
},
});
const match = matchById(data, matchId);
data = Engine.reportResult(data, {
matchId,
winnerSide:
match.opponent1?.id === winnerId ? "opponent1" : "opponent2",
}).data;
};
// Team 1 beat everyone, team 2 beat 3 and 4, team 3 beat 4.
@ -291,9 +275,9 @@ describe("round robin standings - dropped out teams", () => {
"2-4": 2,
"3-4": 3,
};
for (const match of storage.select<any>("match")!) {
const a = match.opponent1.id as number;
const b = match.opponent2.id as number;
for (const match of data.match) {
const a = match.opponent1!.id as number;
const b = match.opponent2!.id as number;
const key = a < b ? `${a}-${b}` : `${b}-${a}`;
if (skipMatchups.includes(key)) continue;
const winnerId = winnerByMatchup[key];
@ -305,8 +289,6 @@ describe("round robin standings - dropped out teams", () => {
}
}
const data = manager.get.tournamentData(1);
return testTournament({
ctx: {
settings: {
@ -410,19 +392,13 @@ describe("round robin standings - dropped out teams", () => {
describe("round robin A/B divisions standings", () => {
const abDivisionsTournament = () => {
const storage = new InMemoryDatabase();
const manager = new BracketsManager(storage);
manager.create({
name: "AB RR",
tournamentId: 1,
let data = createResolved({
type: "round_robin",
seeding: [1, 2, 3, 4],
abDivisions: [0, 1, 0, 1],
settings: {
groupCount: 1,
hasAbDivisions: true,
seedOrdering: ["groups.seed_optimized"],
},
});
@ -432,18 +408,16 @@ describe("round robin A/B divisions standings", () => {
winnerScore: number,
loserScore: number,
) => {
const match = storage.select<any>("match", matchId);
invariant(match, `match ${matchId} not found`);
const winnerIsOpp1 = match.opponent1.id === winnerId;
manager.update.match({
id: match.id,
opponent1: winnerIsOpp1
? { score: winnerScore, result: "win" }
: { score: loserScore },
opponent2: winnerIsOpp1
? { score: loserScore }
: { score: winnerScore, result: "win" },
});
const match = matchById(data, matchId);
const winnerIsOpp1 = match.opponent1?.id === winnerId;
data = Engine.reportResult(data, {
matchId,
scores: [
winnerIsOpp1 ? winnerScore : loserScore,
winnerIsOpp1 ? loserScore : winnerScore,
],
winnerSide: winnerIsOpp1 ? "opponent1" : "opponent2",
}).data;
};
const winnerByMatchup: Record<string, number> = {
@ -452,9 +426,9 @@ describe("round robin A/B divisions standings", () => {
"2-3": 2,
"3-4": 3,
};
for (const match of storage.select<any>("match")!) {
const a = match.opponent1.id as number;
const b = match.opponent2.id as number;
for (const match of data.match) {
const a = match.opponent1!.id as number;
const b = match.opponent2!.id as number;
const key = a < b ? `${a}-${b}` : `${b}-${a}`;
const winnerId = winnerByMatchup[key];
invariant(winnerId, `unexpected matchup ${key}`);
@ -462,8 +436,6 @@ describe("round robin A/B divisions standings", () => {
setResult(match.id, winnerId, 2, loserScore);
}
const data = manager.get.tournamentData(1);
return testTournament({
ctx: {
settings: {
@ -535,33 +507,28 @@ describe("single elimination standings - third place match", () => {
}: {
thirdPlaceMatchReported: boolean;
}) => {
const storage = new InMemoryDatabase();
const manager = new BracketsManager(storage);
manager.create({
name: "SE",
tournamentId: 1,
let data = createResolved({
type: "single_elimination",
seeding: [1, 2, 3, 4],
settings: { consolationFinal: true },
});
const reportLowerTeamIdAsWinner = (matchId: number) => {
manager.update.match({
id: matchId,
opponent1: { score: 2, result: "win" },
opponent2: { score: 0 },
});
data = Engine.reportResult(data, {
matchId,
scores: [2, 0],
winnerSide: "opponent1",
}).data;
};
const semifinals = storage
.select<any>("match")!
.filter((match) => match.opponent1?.id && match.opponent2?.id);
const semifinals = data.match.filter(
(match) => match.opponent1?.id && match.opponent2?.id,
);
invariant(semifinals.length === 2, "Expected two semifinal matches");
const semifinalLoserIds: number[] = [];
for (const match of semifinals) {
semifinalLoserIds.push(match.opponent2.id);
semifinalLoserIds.push(match.opponent2!.id!);
reportLowerTeamIdAsWinner(match.id);
}
@ -569,14 +536,14 @@ describe("single elimination standings - third place match", () => {
let thirdPlaceLoserId: number | undefined;
if (thirdPlaceMatchReported) {
const thirdPlaceGroupId = Math.max(
...storage.select<any>("group")!.map((group) => group.id),
...data.group.map((group) => group.id),
);
const thirdPlaceMatch = data.match.find(
(match) => match.groupId === thirdPlaceGroupId,
);
const thirdPlaceMatch = storage
.select<any>("match")!
.find((match) => match.group_id === thirdPlaceGroupId);
invariant(thirdPlaceMatch, "Third place match not found");
thirdPlaceWinnerId = thirdPlaceMatch.opponent1.id;
thirdPlaceLoserId = thirdPlaceMatch.opponent2.id;
thirdPlaceWinnerId = thirdPlaceMatch.opponent1!.id!;
thirdPlaceLoserId = thirdPlaceMatch.opponent2!.id!;
reportLowerTeamIdAsWinner(thirdPlaceMatch.id);
}
@ -594,7 +561,7 @@ describe("single elimination standings - third place match", () => {
],
},
},
data: manager.get.tournamentData(1),
data,
});
return { tournament, thirdPlaceWinnerId, thirdPlaceLoserId };
@ -627,60 +594,28 @@ describe("single elimination standings - third place match", () => {
});
});
const reportLowerIdWinner = (
storage: InMemoryDatabase,
manager: BracketsManager,
matchId: number,
) => {
const match = storage.select<any>("match", matchId);
invariant(match, `match ${matchId} not found`);
const opponent1Lower = match.opponent1.id < match.opponent2.id;
manager.update.match({
id: matchId,
opponent1: opponent1Lower ? { score: 2, result: "win" } : { score: 0 },
opponent2: opponent1Lower ? { score: 0 } : { score: 2, result: "win" },
});
};
const readyMatches = (
storage: InMemoryDatabase,
predicate: (match: any) => boolean,
) =>
storage
.select<any>("match")!
.filter(
(match) =>
predicate(match) &&
match.opponent1?.id != null &&
match.opponent2?.id != null &&
match.opponent1.result == null &&
match.opponent2.result == null,
);
describe("single elimination standings - projected ties", () => {
// Two semifinal losers tie for 3rd (no consolation final). Reports only one
// semifinal so the other is still in progress, mirroring the projected
// standings bug where the finished team is shown one placement too low.
const partialSingleEliminationTournament = () => {
const storage = new InMemoryDatabase();
const manager = new BracketsManager(storage);
manager.create({
name: "SE",
tournamentId: 1,
let data = createResolved({
type: "single_elimination",
seeding: [1, 2, 3, 4],
settings: {},
});
const semifinals = storage
.select<any>("match")!
.filter((match) => match.opponent1?.id && match.opponent2?.id);
const semifinals = data.match.filter(
(match) => match.opponent1?.id && match.opponent2?.id,
);
invariant(semifinals.length === 2, "Expected two semifinal matches");
const decided = semifinals[0];
const decidedLoserId = Math.max(decided.opponent1.id, decided.opponent2.id);
reportLowerIdWinner(storage, manager, decided.id);
const decidedLoserId = Math.max(
decided.opponent1!.id!,
decided.opponent2!.id!,
);
data = reportLowerIdWinner(data, decided.id);
const tournament = testTournament({
ctx: {
@ -696,7 +631,7 @@ describe("single elimination standings - projected ties", () => {
],
},
},
data: manager.get.tournamentData(1),
data,
});
return { tournament, decidedLoserId };
@ -719,64 +654,56 @@ describe("double elimination standings - projected ties", () => {
// losers round 2 matches so its loser should already project to tied 5th
// while the sibling match is still unfinished.
const partialDoubleEliminationTournament = () => {
const storage = new InMemoryDatabase();
const manager = new BracketsManager(storage);
manager.create({
name: "DE",
tournamentId: 1,
let data = createResolved({
type: "double_elimination",
seeding: [1, 2, 3, 4, 5, 6, 7, 8],
settings: { grandFinal: "double", seedOrdering: ["natural"] },
settings: {},
});
const groupId = (number: number) =>
storage.select<any>("group")!.find((g) => g.number === number)!.id;
data.group.find((group) => group.number === number)!.id;
const winnersGroupId = groupId(1);
const losersGroupId = groupId(2);
const losersRoundId = (number: number) =>
storage
.select<any>("round")!
.find((r) => r.group_id === losersGroupId && r.number === number)!.id;
data.round.find(
(round) => round.groupId === losersGroupId && round.number === number,
)!.id;
// play out the entire winners bracket so all losers feed in
let winnersReady = readyMatches(
storage,
(m) => m.group_id === winnersGroupId,
);
let winnersReady = readyMatches(data, (m) => m.groupId === winnersGroupId);
while (winnersReady.length) {
for (const match of winnersReady) {
reportLowerIdWinner(storage, manager, match.id);
data = reportLowerIdWinner(data, match.id);
}
winnersReady = readyMatches(
storage,
(m) => m.group_id === winnersGroupId,
);
winnersReady = readyMatches(data, (m) => m.groupId === winnersGroupId);
}
// losers round 1: both matches -> two teams eliminated, tied 7th/8th
for (const match of readyMatches(
storage,
(m) => m.round_id === losersRoundId(1),
data,
(m) => m.roundId === losersRoundId(1),
)) {
reportLowerIdWinner(storage, manager, match.id);
data = reportLowerIdWinner(data, match.id);
}
// losers round 2: report only one of the two matches
const losersRound2 = readyMatches(
storage,
(m) => m.round_id === losersRoundId(2),
data,
(m) => m.roundId === losersRoundId(2),
);
invariant(losersRound2.length === 2, "Expected two losers round 2 matches");
const decided = losersRound2[0];
const decidedLoserId = Math.max(decided.opponent1.id, decided.opponent2.id);
const decidedLoserId = Math.max(
decided.opponent1!.id!,
decided.opponent2!.id!,
);
const stillPlayingTeamIds = [
losersRound2[1].opponent1.id,
losersRound2[1].opponent2.id,
losersRound2[1].opponent1!.id,
losersRound2[1].opponent2!.id,
];
reportLowerIdWinner(storage, manager, decided.id);
data = reportLowerIdWinner(data, decided.id);
const tournament = testTournament({
ctx: {
@ -792,7 +719,7 @@ describe("double elimination standings - projected ties", () => {
],
},
},
data: manager.get.tournamentData(1),
data,
});
return { tournament, decidedLoserId, stillPlayingTeamIds };
@ -824,39 +751,29 @@ describe("single elimination source - underground", () => {
// 8-team SE played out fully. The four first-round losers tie for last, so
// sourcing [-1] should feed exactly those teams into an underground bracket.
const playedSingleEliminationTournament = () => {
const storage = new InMemoryDatabase();
const manager = new BracketsManager(storage);
manager.create({
name: "SE",
tournamentId: 1,
let data = createResolved({
type: "single_elimination",
seeding: [1, 2, 3, 4, 5, 6, 7, 8],
settings: {},
});
const winnersGroupId = Math.min(
...storage.select<any>("group")!.map((g) => g.id),
);
const firstRoundId = Math.min(
...storage
.select<any>("round")!
.filter((r) => r.group_id === winnersGroupId)
.map((r) => r.id),
);
const winnersGroupId = data.group.find((group) => group.number === 1)!.id;
const firstRoundId = data.round.find(
(round) => round.groupId === winnersGroupId && round.number === 1,
)!.id;
// lower id wins, so the higher id in each first-round match is the loser
const firstRoundLoserIds = readyMatches(
storage,
(m) => m.round_id === firstRoundId,
).map((m) => Math.max(m.opponent1.id, m.opponent2.id));
data,
(match) => match.roundId === firstRoundId,
).map((match) => Math.max(match.opponent1!.id!, match.opponent2!.id!));
let ready = readyMatches(storage, (m) => m.group_id === winnersGroupId);
let ready = readyMatches(data, (match) => match.groupId === winnersGroupId);
while (ready.length) {
for (const match of ready) {
reportLowerIdWinner(storage, manager, match.id);
data = reportLowerIdWinner(data, match.id);
}
ready = readyMatches(storage, (m) => m.group_id === winnersGroupId);
ready = readyMatches(data, (match) => match.groupId === winnersGroupId);
}
const tournament = testTournament({
@ -873,7 +790,7 @@ describe("single elimination source - underground", () => {
],
},
},
data: manager.get.tournamentData(1),
data,
});
return { tournament, firstRoundLoserIds };
@ -893,3 +810,34 @@ describe("single elimination source - underground", () => {
);
});
});
function reportLowerIdWinner(data: BracketData, matchId: number): BracketData {
const match = matchById(data, matchId);
const opponent1Lower = match.opponent1!.id! < match.opponent2!.id!;
return Engine.reportResult(data, {
matchId,
scores: [opponent1Lower ? 2 : 0, opponent1Lower ? 0 : 2],
winnerSide: opponent1Lower ? "opponent1" : "opponent2",
}).data;
}
function readyMatches(
data: BracketData,
predicate: (match: MatchData) => boolean,
) {
return data.match.filter(
(match) =>
predicate(match) &&
match.opponent1?.id != null &&
match.opponent2?.id != null &&
match.winnerSide == null,
);
}
function matchById(data: BracketData, id: number) {
const found = data.match.find((match) => match.id === id);
if (!found) throw new Error(`Match ${id} not found`);
return found;
}

View File

@ -2,13 +2,14 @@ import { sub } from "date-fns";
import * as R from "remeda";
import type { Tables, TournamentStageSettings } from "~/db/tables";
import { TOURNAMENT } from "~/features/tournament/tournament-constants";
import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types";
import type { Round } from "~/modules/brackets-model";
import type {
BracketData,
RoundData,
} from "~/features/tournament-bracket/core/engine/types";
import invariant from "~/utils/invariant";
import { logger } from "~/utils/logger";
import { fillWithNullTillPowerOfTwo } from "../../tournament-bracket-utils";
import * as AbDivisions from "../AbDivisions";
import { getTournamentManager } from "../brackets-manager";
import * as Engine from "../engine";
import * as Progression from "../Progression";
import type { OptionalIdObject, Tournament } from "../Tournament";
import type { TournamentDataTeam } from "../Tournament.server";
@ -18,7 +19,7 @@ export interface CreateBracketArgs {
id: number;
idx: number;
preview: boolean;
data?: TournamentManagerDataSet;
data?: BracketData;
type: Tables["TournamentStage"]["type"];
canBeStarted?: boolean;
name: string;
@ -44,7 +45,6 @@ export interface Standing {
setLosses: number;
mapWins: number;
mapLosses: number;
points: number;
koCount?: number;
winsAgainstTied: number;
lossesAgainstTied?: number;
@ -63,7 +63,7 @@ export abstract class Bracket {
idx;
preview;
data;
simulatedData: TournamentManagerDataSet | undefined;
simulatedData: BracketData | undefined;
canBeStarted;
name;
teamsPendingCheckIn;
@ -74,6 +74,7 @@ export abstract class Bracket {
settings;
requiresCheckIn;
startTime;
private _matchStatuses: Map<number, Engine.MatchStatus> | undefined;
constructor({
id,
@ -125,9 +126,7 @@ export abstract class Bracket {
return;
try {
const manager = getTournamentManager();
manager.importData(this.data);
let data = this.data;
const teamOrder = this.teamOrderForSimulation();
@ -141,13 +140,9 @@ export abstract class Bracket {
matchesToResolve = false;
loopCount++;
for (const match of manager.export().match) {
if (!match) continue;
for (const match of data.match) {
// we have a result already
if (
match.opponent1?.result === "win" ||
match.opponent2?.result === "win"
) {
if (match.winnerSide) {
continue;
}
// no opponent yet, let's simulate this in a coming loop
@ -176,21 +171,15 @@ export abstract class Bracket {
? 1
: 2;
manager.update.match({
id: match.id,
opponent1: {
score: winner === 1 ? 1 : 0,
result: winner === 1 ? "win" : undefined,
},
opponent2: {
score: winner === 2 ? 1 : 0,
result: winner === 2 ? "win" : undefined,
},
});
data = Engine.reportResult(data, {
matchId: match.id,
scores: winner === 1 ? [1, 0] : [0, 1],
winnerSide: winner === 1 ? "opponent1" : "opponent2",
}).data;
}
}
this.simulatedData = manager.export();
this.simulatedData = data;
} catch (e) {
logger.error("Bracket.createdSimulation: ", e);
}
@ -200,11 +189,7 @@ export abstract class Bracket {
const result = new Map(this.tournament.ctx.teams.map((t, i) => [t.id, i]));
for (const match of this.data.match) {
if (
!match.opponent1?.id ||
!match.opponent2?.id ||
(match.opponent1?.result !== "win" && match.opponent2?.result !== "win")
) {
if (!match.opponent1?.id || !match.opponent2?.id || !match.winnerSide) {
continue;
}
@ -215,11 +200,11 @@ export abstract class Bracket {
continue;
}
if (opponent1Seed < opponent2Seed && match.opponent1?.result === "win") {
if (opponent1Seed < opponent2Seed && match.winnerSide === "opponent1") {
continue;
}
if (opponent2Seed < opponent1Seed && match.opponent2?.result === "win") {
if (opponent2Seed < opponent1Seed && match.winnerSide === "opponent2") {
continue;
}
@ -238,21 +223,25 @@ export abstract class Bracket {
simulatedMatch(matchId: number) {
if (!this.simulatedData) return;
return this.simulatedData.match
.filter(Boolean)
.find((match) => match.id === matchId);
return this.simulatedData.match.find((match) => match.id === matchId);
}
get collectResultsWithPoints() {
/** Whether reporting a game in this bracket also records if the game was a KO win. */
get collectsKos() {
return false;
}
get type(): Tables["TournamentStage"]["type"] {
throw new Error("not implemented");
}
abstract get type(): Tables["TournamentStage"]["type"];
get standings(): Standing[] {
throw new Error("not implemented");
abstract get standings(): Standing[];
/**
* How many rounds a swiss bracket has. Comes from the bracket's own stage
* settings rather than `settings` (the progression's, editable at any time),
* so it can't drift from the bracket that actually exists.
*/
get swissRoundCount() {
return Engine.swissRoundCount(this.data);
}
get participantTournamentTeamIds() {
@ -267,7 +256,7 @@ export abstract class Bracket {
return this.standings;
}
winnersSourceRound(_roundNumber: number): Round | undefined {
winnersSourceRound(_roundNumber: number): RoundData | undefined {
return;
}
@ -290,49 +279,37 @@ export abstract class Bracket {
});
}
generateMatchesData(teams: number[]) {
const manager = getTournamentManager();
const virtualTournamentId = 1;
generateMatchesData(teams: number[]): BracketData {
if (teams.length >= TOURNAMENT.ENOUGH_TEAMS_TO_START) {
const settings = this.tournament.bracketManagerSettings(
this.settings,
this.type,
teams.length,
);
const abDivisions =
this.type === "round_robin" && this.settings?.hasAbDivisions === true
? this.abDivisionsForPreview(teams, settings.groupCount)
? this.abDivisionsForPreview(
teams,
Engine.roundRobinGroupCount(this.settings, teams.length),
)
: undefined;
manager.create({
tournamentId: virtualTournamentId,
name: "Virtual",
return Engine.create({
type: this.type,
seeding:
this.type === "round_robin"
? teams
: fillWithNullTillPowerOfTwo(teams),
seeding: teams,
settings: abDivisions
? settings
? this.settings
: {
...settings,
...this.settings,
hasAbDivisions: false,
},
independentRounds: this.tournament.isLeagueDivision,
abDivisions,
});
}
return manager.get.tournamentData(virtualTournamentId);
return { stage: [], group: [], round: [], match: [] };
}
private abDivisionsForPreview(
teams: number[],
groupCount: number | undefined,
groupCount: number,
): (0 | 1)[] | undefined {
if (!groupCount) return undefined;
const assignments = teams.map((teamId) => {
const team = this.tournament.teamById(teamId);
return team?.abDivision ?? null;
@ -388,10 +365,7 @@ export abstract class Bracket {
if (match.opponent1 === null || match.opponent2 === null) {
continue;
}
if (
match.opponent1?.result !== "win" &&
match.opponent2?.result !== "win"
) {
if (!match.winnerSide) {
return false;
}
}
@ -424,16 +398,14 @@ export abstract class Bracket {
return this.teamsPendingCheckIn.includes(team.id);
}
source(_options: {
abstract source(options: {
placements: number[];
advanceThreshold?: number;
rest?: boolean;
}): {
relevantMatchesFinished: boolean;
teams: number[];
} {
throw new Error("not implemented");
}
};
teamsWithNames(teams: { id: number }[]) {
return teams.map((team) => {
@ -449,6 +421,23 @@ export abstract class Bracket {
});
}
/** Statuses of every match of the bracket, keyed by match id. */
matchStatuses() {
if (!this._matchStatuses) {
this._matchStatuses = Engine.matchStatuses(this.data);
}
return this._matchStatuses;
}
/** Status of one match of the bracket. */
matchStatus(matchId: number) {
const status = this.matchStatuses().get(matchId);
invariant(status, `Match not found: ${matchId}`);
return status;
}
/**
* Returns match IDs that are currently ongoing (ready to start).
* A match is ongoing when:
@ -465,10 +454,7 @@ export abstract class Bracket {
(a, b) => a.number - b.number,
)) {
if (!match.opponent1?.id || !match.opponent2?.id) continue;
if (
match.opponent1.result === "win" ||
match.opponent2.result === "win"
) {
if (match.winnerSide) {
continue;
}
@ -487,7 +473,5 @@ export abstract class Bracket {
return ongoingMatchIds;
}
defaultRoundBestOfs(_data: TournamentManagerDataSet): BracketMapCounts {
throw new Error("not implemented");
}
abstract defaultRoundBestOfs(data: BracketData): BracketMapCounts;
}

View File

@ -1,7 +1,9 @@
import * as R from "remeda";
import type { Tables } from "~/db/tables";
import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types";
import type { Round } from "~/modules/brackets-model";
import type {
BracketData,
RoundData,
} from "~/features/tournament-bracket/core/engine/types";
import invariant from "~/utils/invariant";
import type { BracketMapCounts } from "../toMapList";
import { Bracket, type Standing } from "./Bracket";
@ -12,15 +14,15 @@ export class DoubleEliminationBracket extends Bracket {
return "double_elimination";
}
defaultRoundBestOfs(data: TournamentManagerDataSet) {
defaultRoundBestOfs(data: BracketData) {
const result: BracketMapCounts = new Map();
for (const group of data.group) {
const roundsOfGroup = data.round.filter(
(round) => round.group_id === group.id,
(round) => round.groupId === group.id,
);
const defaultOfRound = (round: Round) => {
const defaultOfRound = (round: RoundData) => {
if (group.number === 3) return 5;
if (group.number === 2) {
const lastRoundNumber = Math.max(
@ -38,7 +40,7 @@ export class DoubleEliminationBracket extends Bracket {
for (const round of roundsOfGroup) {
const atLeastOneNonByeMatch = data.match.some(
(match) =>
match.round_id === round.id && match.opponent1 && match.opponent2,
match.roundId === round.id && match.opponent1 && match.opponent2,
);
if (!atLeastOneNonByeMatch) continue;
@ -65,7 +67,7 @@ export class DoubleEliminationBracket extends Bracket {
const groupIdWB = this.data.group.find((g) => g.number === 1)?.id;
return this.data.round.find(
(round) => round.number === roundNumberWB && round.group_id === groupIdWB,
(round) => round.number === roundNumberWB && round.groupId === groupIdWB,
);
}
@ -75,16 +77,13 @@ export class DoubleEliminationBracket extends Bracket {
const losersGroupId = this.data.group.find((g) => g.number === 2)?.id;
const losersMatches = this.data.match
.filter((match) => match.group_id === losersGroupId)
.sort((a, b) => a.round_id - b.round_id);
.filter((match) => match.groupId === losersGroupId)
.sort((a, b) => a.roundId - b.roundId);
const teams: { id: number; lostAt: number }[] = [];
for (const match of losersMatches) {
if (
match.opponent1?.result !== "win" &&
match.opponent2?.result !== "win"
) {
if (!match.winnerSide) {
continue;
}
@ -92,10 +91,10 @@ export class DoubleEliminationBracket extends Bracket {
if (!match.opponent1 || !match.opponent2) continue;
const loser =
match.opponent1?.result === "win" ? match.opponent2 : match.opponent1;
match.winnerSide === "opponent1" ? match.opponent2 : match.opponent1;
invariant(loser?.id, "Loser id not found");
teams.push({ id: loser.id, lostAt: match.round_id });
teams.push({ id: loser.id, lostAt: match.roundId });
}
const eliminationsThroughLosersRound =
@ -132,16 +131,16 @@ export class DoubleEliminationBracket extends Bracket {
)?.id;
invariant(grandFinalsGroupId, "GF group not found");
const grandFinalMatches = this.data.match.filter(
(match) => match.group_id === grandFinalsGroupId,
(match) => match.groupId === grandFinalsGroupId,
);
// if opponent1 won in DE it means that bracket reset is not played
if (
grandFinalMatches[0].opponent1 &&
(noLosersRounds || grandFinalMatches[0].opponent1.result === "win")
(noLosersRounds || grandFinalMatches[0].winnerSide === "opponent1")
) {
const loser =
grandFinalMatches[0].opponent1.result === "win"
grandFinalMatches[0].winnerSide === "opponent1"
? "opponent2"
: "opponent1";
const winner = loser === "opponent1" ? "opponent2" : "opponent1";
@ -164,12 +163,9 @@ export class DoubleEliminationBracket extends Bracket {
team: winnerTeam,
placement: 1,
});
} else if (
grandFinalMatches[1].opponent1?.result === "win" ||
grandFinalMatches[1].opponent2?.result === "win"
) {
} else if (grandFinalMatches[1].winnerSide) {
const loser =
grandFinalMatches[1].opponent1?.result === "win"
grandFinalMatches[1].winnerSide === "opponent1"
? "opponent2"
: "opponent1";
const winner = loser === "opponent1" ? "opponent2" : "opponent1";
@ -211,14 +207,11 @@ export class DoubleEliminationBracket extends Bracket {
if (match.opponent1 === null || match.opponent2 === null) {
continue;
}
if (
match.opponent1?.result !== "win" &&
match.opponent2?.result !== "win"
) {
if (!match.winnerSide) {
return false;
}
lastWinner = match.opponent1?.result === "win" ? 1 : 2;
lastWinner = match.winnerSide === "opponent1" ? 1 : 2;
}
return true;
@ -226,24 +219,24 @@ export class DoubleEliminationBracket extends Bracket {
source({ placements }: { placements: number[] }) {
invariant(placements.length > 0, "Empty placements not supported");
const resolveLosersGroupId = (data: TournamentManagerDataSet) => {
const minGroupId = Math.min(...data.round.map((round) => round.group_id));
const resolveLosersGroupId = (data: BracketData) => {
const minGroupId = Math.min(...data.round.map((round) => round.groupId));
return minGroupId + 1;
};
const placementsToRoundsIds = (
data: TournamentManagerDataSet,
data: BracketData,
losersGroupId: number,
) => {
const firstRoundIsOnlyByes = () => {
const losersMatches = data.match.filter(
(match) => match.group_id === losersGroupId,
(match) => match.groupId === losersGroupId,
);
const fistRoundId = Math.min(...losersMatches.map((m) => m.round_id));
const fistRoundId = Math.min(...losersMatches.map((m) => m.roundId));
const firstRoundMatches = losersMatches.filter(
(match) => match.round_id === fistRoundId,
(match) => match.roundId === fistRoundId,
);
return firstRoundMatches.every(
@ -252,7 +245,7 @@ export class DoubleEliminationBracket extends Bracket {
};
const losersRounds = data.round.filter(
(round) => round.group_id === losersGroupId,
(round) => round.groupId === losersGroupId,
);
const orderedRoundsIds = losersRounds
.map((round) => round.id)
@ -281,7 +274,7 @@ export class DoubleEliminationBracket extends Bracket {
let relevantMatchesFinished = true;
for (const roundId of sourceRoundsIds) {
const roundsMatches = this.data.match.filter(
(match) => match.round_id === roundId,
(match) => match.roundId === roundId,
);
for (const match of roundsMatches) {
@ -289,16 +282,13 @@ export class DoubleEliminationBracket extends Bracket {
if (!match.opponent1 || !match.opponent2) {
continue;
}
if (
match.opponent1?.result !== "win" &&
match.opponent2?.result !== "win"
) {
if (!match.winnerSide) {
relevantMatchesFinished = false;
continue;
}
const loser =
match.opponent1?.result === "win" ? match.opponent2 : match.opponent1;
match.winnerSide === "opponent1" ? match.opponent2 : match.opponent1;
invariant(loser?.id, "Loser id not found");
teams.push(loser.id);

View File

@ -1,14 +1,13 @@
import * as R from "remeda";
import type { Tables } from "~/db/tables";
import * as Standings from "~/features/tournament/core/Standings";
import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types";
import type { BracketData } from "~/features/tournament-bracket/core/engine/types";
import invariant from "~/utils/invariant";
import { logger } from "~/utils/logger";
import type { BracketMapCounts } from "../toMapList";
import { Bracket, type Standing } from "./Bracket";
export class RoundRobinBracket extends Bracket {
get collectResultsWithPoints() {
get collectsKos() {
return true;
}
@ -97,7 +96,7 @@ export class RoundRobinBracket extends Bracket {
const placements: (Standing & { groupId: number })[] = [];
for (const groupId of groupIds) {
const matches = this.data.match.filter(
(match) => match.group_id === groupId,
(match) => match.groupId === groupId,
);
const groupIsFinished = matches.every(
@ -106,8 +105,7 @@ export class RoundRobinBracket extends Bracket {
match.opponent1 === null ||
match.opponent2 === null ||
// match was played out
match.opponent1?.result === "win" ||
match.opponent2?.result === "win",
match.winnerSide,
);
if (!groupIsFinished && !includeUnfinishedGroups) continue;
@ -136,7 +134,6 @@ export class RoundRobinBracket extends Bracket {
mapWins: number;
mapLosses: number;
winsAgainstTied: number;
points: number;
koCount: number;
}[] = [];
@ -146,7 +143,6 @@ export class RoundRobinBracket extends Bracket {
setLosses,
mapWins,
mapLosses,
points,
koCount,
}: {
teamId: number;
@ -154,7 +150,6 @@ export class RoundRobinBracket extends Bracket {
setLosses: number;
mapWins: number;
mapLosses: number;
points: number;
koCount: number;
}) => {
const team = teams.find((team) => team.id === teamId);
@ -163,7 +158,6 @@ export class RoundRobinBracket extends Bracket {
team.setLosses += setLosses;
team.mapWins += mapWins;
team.mapLosses += mapLosses;
team.points += points;
team.koCount += koCount;
} else {
teams.push({
@ -173,17 +167,13 @@ export class RoundRobinBracket extends Bracket {
mapWins,
mapLosses,
winsAgainstTied: 0,
points,
koCount,
});
}
};
for (const match of matches) {
if (
match.opponent1?.result !== "win" &&
match.opponent2?.result !== "win"
) {
if (!match.winnerSide) {
continue;
}
@ -199,10 +189,10 @@ export class RoundRobinBracket extends Bracket {
}
const winner =
match.opponent1?.result === "win" ? match.opponent1 : match.opponent2;
match.winnerSide === "opponent1" ? match.opponent1 : match.opponent2;
const loser =
match.opponent1?.result === "win" ? match.opponent2 : match.opponent1;
match.winnerSide === "opponent1" ? match.opponent2 : match.opponent1;
if (!winner || !loser) continue;
@ -212,15 +202,6 @@ export class RoundRobinBracket extends Bracket {
"RoundRobinBracket.standings: winner or loser id not found",
);
if (
typeof winner.totalPoints !== "number" ||
typeof loser.totalPoints !== "number"
) {
logger.warn(
"RoundRobinBracket.standings: winner or loser points not found",
);
}
// note: score might be missing in the case the set was ended early. In the future we might want to handle this differently than defaulting both to 0.
updateTeam({
@ -229,7 +210,6 @@ export class RoundRobinBracket extends Bracket {
setLosses: 0,
mapWins: winner.score ?? 0,
mapLosses: loser.score ?? 0,
points: winner.totalPoints ?? 0,
koCount: winner.totalKos ?? 0,
});
updateTeam({
@ -238,7 +218,6 @@ export class RoundRobinBracket extends Bracket {
setLosses: 1,
mapWins: loser.score ?? 0,
mapLosses: winner.score ?? 0,
points: loser.totalPoints ?? 0,
koCount: loser.totalKos ?? 0,
});
}
@ -254,10 +233,10 @@ export class RoundRobinBracket extends Bracket {
(match) =>
(match.opponent1?.id === team.id &&
match.opponent2?.id === team2.id &&
match.opponent1?.result === "win") ||
match.winnerSide === "opponent1") ||
(match.opponent1?.id === team2.id &&
match.opponent2?.id === team.id &&
match.opponent2?.result === "win"),
match.winnerSide === "opponent2"),
);
if (wonTheirMatch) {
@ -275,7 +254,6 @@ export class RoundRobinBracket extends Bracket {
mapWins: 0,
mapLosses: 0,
winsAgainstTied: 0,
points: 0,
koCount: 0,
});
}
@ -306,8 +284,8 @@ export class RoundRobinBracket extends Bracket {
if (a.mapLosses < b.mapLosses) return -1;
if (a.mapLosses > b.mapLosses) return 1;
if (a.points > b.points) return -1;
if (a.points < b.points) return 1;
if (a.koCount > b.koCount) return -1;
if (a.koCount < b.koCount) return 1;
const aSeed = Number(this.tournament.teamById(a.id)?.seed);
const bSeed = Number(this.tournament.teamById(b.id)?.seed);
@ -327,7 +305,6 @@ export class RoundRobinBracket extends Bracket {
setLosses: team.setLosses,
mapWins: team.mapWins,
mapLosses: team.mapLosses,
points: team.points,
koCount: team.koCount,
winsAgainstTied: team.winsAgainstTied,
},
@ -355,16 +332,16 @@ export class RoundRobinBracket extends Bracket {
return "round_robin";
}
defaultRoundBestOfs(data: TournamentManagerDataSet) {
defaultRoundBestOfs(data: BracketData) {
const result: BracketMapCounts = new Map();
for (const round of data.round) {
if (!result.get(round.group_id)) {
result.set(round.group_id, new Map());
if (!result.get(round.groupId)) {
result.set(round.groupId, new Map());
}
result
.get(round.group_id)!
.get(round.groupId)!
.set(round.number, { count: 3, type: "BEST_OF" });
}

View File

@ -1,7 +1,9 @@
import * as R from "remeda";
import type { Tables } from "~/db/tables";
import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types";
import type { Round } from "~/modules/brackets-model";
import type {
BracketData,
RoundData,
} from "~/features/tournament-bracket/core/engine/types";
import invariant from "~/utils/invariant";
import type { BracketMapCounts } from "../toMapList";
import { Bracket, type Standing } from "./Bracket";
@ -12,16 +14,16 @@ export class SingleEliminationBracket extends Bracket {
return "single_elimination";
}
defaultRoundBestOfs(data: TournamentManagerDataSet) {
defaultRoundBestOfs(data: BracketData) {
const result: BracketMapCounts = new Map();
const maxRoundNumber = Math.max(...data.round.map((round) => round.number));
for (const group of data.group) {
const roundsOfGroup = data.round.filter(
(round) => round.group_id === group.id,
(round) => round.groupId === group.id,
);
const defaultOfRound = (round: Round) => {
const defaultOfRound = (round: RoundData) => {
// 3rd place match
if (group.number === 2) return 5;
@ -40,7 +42,7 @@ export class SingleEliminationBracket extends Bracket {
for (const round of roundsOfGroup) {
const atLeastOneNonByeMatch = data.match.some(
(match) =>
match.round_id === round.id && match.opponent1 && match.opponent2,
match.roundId === round.id && match.opponent1 && match.opponent2,
);
if (!atLeastOneNonByeMatch) continue;
@ -59,7 +61,7 @@ export class SingleEliminationBracket extends Bracket {
}
private hasThirdPlaceMatch() {
return R.unique(this.data.match.map((m) => m.group_id)).length > 1;
return R.unique(this.data.match.map((m) => m.groupId)).length > 1;
}
get standings(): Standing[] {
@ -71,27 +73,24 @@ export class SingleEliminationBracket extends Bracket {
}
const thirdPlaceMatch = this.data.match.find(
(m) => m.group_id === Math.max(...this.data.group.map((g) => g.id)),
(m) => m.groupId === Math.max(...this.data.group.map((g) => g.id)),
);
return this.data.match.filter(
(m) => m.group_id !== thirdPlaceMatch?.group_id,
(m) => m.groupId !== thirdPlaceMatch?.groupId,
);
})();
for (const match of matches.sort((a, b) => a.round_id - b.round_id)) {
if (
match.opponent1?.result !== "win" &&
match.opponent2?.result !== "win"
) {
for (const match of matches.sort((a, b) => a.roundId - b.roundId)) {
if (!match.winnerSide) {
continue;
}
const loser =
match.opponent1?.result === "win" ? match.opponent2 : match.opponent1;
match.winnerSide === "opponent1" ? match.opponent2 : match.opponent1;
invariant(loser?.id, "Loser id not found");
teams.push({ id: loser.id, lostAt: match.round_id });
teams.push({ id: loser.id, lostAt: match.roundId });
}
const teamCountWhoDidntLoseYet =
@ -138,12 +137,12 @@ export class SingleEliminationBracket extends Bracket {
}
const thirdPlaceMatch = this.hasThirdPlaceMatch()
? this.data.match.find((m) => m.group_id !== matches[0].group_id)
? this.data.match.find((m) => m.groupId !== matches[0].groupId)
: undefined;
const thirdPlaceMatchWinner =
thirdPlaceMatch?.opponent1?.result === "win"
thirdPlaceMatch?.winnerSide === "opponent1"
? thirdPlaceMatch.opponent1
: thirdPlaceMatch?.opponent2?.result === "win"
: thirdPlaceMatch?.winnerSide === "opponent2"
? thirdPlaceMatch.opponent2
: undefined;
@ -174,7 +173,7 @@ export class SingleEliminationBracket extends Bracket {
const mainGroupId = Math.min(...this.data.group.map((group) => group.id));
const orderedRoundsIds = this.data.round
.filter((round) => round.group_id === mainGroupId)
.filter((round) => round.groupId === mainGroupId)
.map((round) => round.id)
.sort((a, b) => a - b);
@ -189,7 +188,7 @@ export class SingleEliminationBracket extends Bracket {
let relevantMatchesFinished = true;
for (const roundId of sourceRoundsIds) {
const roundsMatches = this.data.match.filter(
(match) => match.round_id === roundId,
(match) => match.roundId === roundId,
);
for (const match of roundsMatches) {
@ -197,16 +196,13 @@ export class SingleEliminationBracket extends Bracket {
if (!match.opponent1 || !match.opponent2) {
continue;
}
if (
match.opponent1?.result !== "win" &&
match.opponent2?.result !== "win"
) {
if (!match.winnerSide) {
relevantMatchesFinished = false;
continue;
}
const loser =
match.opponent1?.result === "win" ? match.opponent2 : match.opponent1;
match.winnerSide === "opponent1" ? match.opponent2 : match.opponent1;
invariant(loser?.id, "Loser id not found");
teams.push(loser.id);

View File

@ -1,20 +1,15 @@
import * as R from "remeda";
import type { Tables } from "~/db/tables";
import * as Standings from "~/features/tournament/core/Standings";
import { TOURNAMENT } from "~/features/tournament/tournament-constants";
import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types";
import type { BracketData } from "~/features/tournament-bracket/core/engine/types";
import invariant from "~/utils/invariant";
import { logger } from "~/utils/logger";
import { cutToNDecimalPlaces } from "../../../../utils/number";
import { calculateTeamStatus } from "../Swiss";
import { calculateTeamStatus } from "../engine/swiss/team-status";
import type { BracketMapCounts } from "../toMapList";
import { Bracket, type Standing, type TeamTrackRecord } from "./Bracket";
export class SwissBracket extends Bracket {
get collectResultsWithPoints() {
return false;
}
source({
placements,
advanceThreshold,
@ -38,19 +33,14 @@ export class SwissBracket extends Bracket {
const relevantMatchesFinished = this.data.round.every((round) => {
const roundsMatches = this.data.match.filter(
(match) => match.round_id === round.id,
(match) => match.roundId === round.id,
);
// some round has not started yet
if (roundsMatches.length === 0) return false;
return roundsMatches.every((match) => {
if (
match.opponent1 &&
match.opponent2 &&
match.opponent1?.result !== "win" &&
match.opponent2?.result !== "win"
) {
if (match.opponent1 && match.opponent2 && !match.winnerSide) {
return false;
}
@ -68,9 +58,7 @@ export class SwissBracket extends Bracket {
advanceThreshold,
wins: standing.stats?.setWins ?? 0,
losses: standing.stats?.setLosses ?? 0,
roundCount:
this.settings?.roundCount ??
TOURNAMENT.SWISS_DEFAULT_ROUND_COUNT,
roundCount: this.swissRoundCount,
}),
}))
.filter((t) => t.status === "advanced")
@ -108,7 +96,7 @@ export class SwissBracket extends Bracket {
const placements: (Standing & { groupId: number })[] = [];
for (const groupId of groupIds) {
const matches = this.data.match.filter(
(match) => match.group_id === groupId,
(match) => match.groupId === groupId,
);
const groupIsFinished = matches.every(
@ -117,8 +105,7 @@ export class SwissBracket extends Bracket {
match.opponent1 === null ||
match.opponent2 === null ||
// match was played out
match.opponent1?.result === "win" ||
match.opponent2?.result === "win",
match.winnerSide,
);
if (!groupIsFinished && !includeUnfinishedGroups) continue;
@ -195,18 +182,15 @@ export class SwissBracket extends Bracket {
]);
}
if (
match.opponent1?.result !== "win" &&
match.opponent2?.result !== "win"
) {
if (!match.winnerSide) {
continue;
}
const winner =
match.opponent1?.result === "win" ? match.opponent1 : match.opponent2;
match.winnerSide === "opponent1" ? match.opponent1 : match.opponent2;
const loser =
match.opponent1?.result === "win" ? match.opponent2 : match.opponent1;
match.winnerSide === "opponent1" ? match.opponent2 : match.opponent1;
if (!winner || !loser) continue;
@ -246,7 +230,7 @@ export class SwissBracket extends Bracket {
}
const round = this.data.round.find(
(round) => round.id === match.round_id,
(round) => round.id === match.roundId,
);
const mapWins =
round?.maps?.type === "PLAY_ALL"
@ -323,9 +307,7 @@ export class SwissBracket extends Bracket {
(match.opponent1?.id === team2.id &&
match.opponent2?.id === team.id);
const isFinished =
match.opponent1?.result === "win" ||
match.opponent2?.result === "win";
const isFinished = Boolean(match.winnerSide);
return isBetweenTeams && isFinished;
});
@ -335,9 +317,9 @@ export class SwissBracket extends Bracket {
const wonTheirMatch =
(finishedMatchBetweenTeams.opponent1!.id === team.id &&
finishedMatchBetweenTeams.opponent1!.result === "win") ||
finishedMatchBetweenTeams.winnerSide === "opponent1") ||
(finishedMatchBetweenTeams.opponent2!.id === team.id &&
finishedMatchBetweenTeams.opponent2!.result === "win");
finishedMatchBetweenTeams.winnerSide === "opponent2");
if (wonTheirMatch) {
team.winsAgainstTied++;
@ -438,7 +420,6 @@ export class SwissBracket extends Bracket {
opponentMapWinPercentage: this.trackRecordToWinPercentage(
team.opponentMaps,
),
points: 0,
},
};
}),
@ -476,16 +457,16 @@ export class SwissBracket extends Bracket {
return "swiss";
}
defaultRoundBestOfs(data: TournamentManagerDataSet) {
defaultRoundBestOfs(data: BracketData) {
const result: BracketMapCounts = new Map();
for (const round of data.round) {
if (!result.get(round.group_id)) {
result.set(round.group_id, new Map());
if (!result.get(round.groupId)) {
result.set(round.groupId, new Map());
}
result
.get(round.group_id)!
.get(round.groupId)!
.set(round.number, { count: 3, type: "BEST_OF" });
}

View File

@ -1,19 +1,19 @@
import * as R from "remeda";
import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types";
import type { BracketData } from "~/features/tournament-bracket/core/engine/types";
/**
* Maps each round_id to the cumulative number of teams eliminated by the end of
* Maps each roundId to the cumulative number of teams eliminated by the end of
* that round, counting one elimination per non-bye match. This is a structural
* property of the bracket that does not depend on which matches have already
* been reported, so teams tied at the same placement resolve to the same
* placement even while some of their round's matches are still in progress.
*/
export function cumulativeEliminationsByRound(
matches: TournamentManagerDataSet["match"],
matches: BracketData["match"],
): Map<number, number> {
const result = new Map<number, number>();
const roundIds = R.unique(matches.map((match) => match.round_id)).sort(
const roundIds = R.unique(matches.map((match) => match.roundId)).sort(
(a, b) => a - b,
);
@ -21,7 +21,7 @@ export function cumulativeEliminationsByRound(
for (const roundId of roundIds) {
const eliminationsThisRound = matches.filter(
(match) =>
match.round_id === roundId && match.opponent1 && match.opponent2,
match.roundId === roundId && match.opponent1 && match.opponent2,
).length;
cumulativeEliminations += eliminationsThisRound;
result.set(roundId, cumulativeEliminations);

View File

@ -128,7 +128,7 @@ function trimMapsByTeamCount({
const result = { ...preparedMaps };
for (const groupId of groupIds) {
const actualRoundsForGroup = actualRounds.filter(
(r) => r.group_id === groupId,
(r) => r.groupId === groupId,
);
const preparedRoundsForGroup = preparedMaps.maps.filter(

View File

@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import type { TournamentStageSettings } from "~/db/tables";
import { Tournament } from "~/features/tournament-bracket/core/Tournament";
import {
LOW_INK_AUGUST_2025,
@ -6,28 +7,33 @@ import {
} from "~/features/tournament-bracket/core/tests/mocks-swiss";
import { ZONES_WEEKLY_38 } from "~/features/tournament-bracket/core/tests/mocks-zones-weekly";
import invariant from "~/utils/invariant";
import * as Swiss from "./Swiss";
import * as Engine from "./engine";
import { pairUp } from "./engine/swiss/pairing";
import * as TeamStatus from "./engine/swiss/team-status";
const Swiss = {
...TeamStatus,
pairUp,
create: (args: { seeding: number[]; settings?: TournamentStageSettings }) =>
Engine.create({
...args,
type: "swiss",
settings: args.settings ?? null,
}),
};
describe("Swiss", () => {
const createArgsWithDefaults = (
args: Partial<Parameters<typeof Swiss.create>[0]> = {},
): Parameters<typeof Swiss.create>[0] => {
return {
name: "Swiss Tournament",
seeding: [1, 2, 3, 4],
settings: {},
tournamentId: 1,
...args,
};
};
describe("create()", () => {
it("attaches the correct tournament id to the data", () => {
const data = Swiss.create(createArgsWithDefaults());
expect(data.stage[0].tournament_id).toBe(1);
});
it("creates a swiss bracket with correct amount of initial matches", () => {
const data = Swiss.create(createArgsWithDefaults());
@ -44,10 +50,8 @@ describe("Swiss", () => {
const data = Swiss.create(
createArgsWithDefaults({
settings: {
swiss: {
groupCount: 1,
roundCount: 4,
},
groupCount: 1,
roundCount: 4,
},
}),
);
@ -59,17 +63,15 @@ describe("Swiss", () => {
const data = Swiss.create(
createArgsWithDefaults({
settings: {
swiss: {
groupCount: 2,
roundCount: 5,
},
groupCount: 2,
roundCount: 5,
},
}),
);
expect(data.round).toHaveLength(10);
const matchGroupIds = data.match.map((m) => m.group_id);
const matchGroupIds = data.match.map((m) => m.groupId);
expect(matchGroupIds).toContain(0);
expect(matchGroupIds).toContain(1);
});
@ -114,17 +116,18 @@ describe("Swiss", () => {
const bracket = tournament.bracketByIdx(0)!;
const matches = Swiss.generateMatchUps({
bracket,
const matches = Engine.generateRound(bracket.data as Engine.BracketData, {
groupId: 4443,
})._unsafeUnwrap();
standings: bracket.standings,
settings: bracket.settings,
})._unsafeUnwrap().matches;
it("finds new opponents for each team in the last round", () => {
for (const match of matches) {
if (match.opponentTwo === "null") continue;
if (match.opponent2 === null) continue;
const opponent1 = JSON.parse(match.opponentOne).id as number;
const opponent2 = JSON.parse(match.opponentTwo).id as number;
const opponent1 = match.opponent1!.id as number;
const opponent2 = match.opponent2.id as number;
const existingMatch = bracket.data.match.find(
(m) =>
@ -138,16 +141,16 @@ describe("Swiss", () => {
});
it("generates a bye", () => {
const byes = matches.filter((match) => match.opponentTwo === "null");
const byes = matches.filter((match) => match.opponent2 === null);
expect(byes).toHaveLength(1);
});
it("every pair is max one set win from each other", () => {
for (const match of matches) {
if (match.opponentTwo === "null") continue;
if (match.opponent2 === null) continue;
const opponent1 = JSON.parse(match.opponentOne).id as number;
const opponent2 = JSON.parse(match.opponentTwo).id as number;
const opponent1 = match.opponent1!.id as number;
const opponent2 = match.opponent2.id as number;
const opponent1Stats = bracket.standings.find(
(s) => s.team.id === opponent1,

View File

@ -1,533 +0,0 @@
// separate from brackets-manager as this wasn't part of the original brackets-manager library
import blossom from "edmonds-blossom-fixed";
import { err, ok } from "neverthrow";
import * as R from "remeda";
import type { TournamentRepositoryInsertableMatch } from "~/features/tournament/TournamentRepository.server";
import { TOURNAMENT } from "~/features/tournament/tournament-constants";
import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types";
import type { InputStage, Match } from "~/modules/brackets-model";
import { nullFilledArray } from "~/utils/arrays";
import invariant from "~/utils/invariant";
import type { Bracket } from "./Bracket";
/**
* Creates a Swiss tournament data set (initial matches) based on the provided arguments. Mimics bracket-manager module's interfaces.
*/
export function create(
args: Omit<InputStage, "type" | "number" | "seeding"> & { seeding: number[] },
): TournamentManagerDataSet {
const swissSettings = args.settings?.swiss;
const groupCount =
swissSettings?.groupCount ?? TOURNAMENT.SWISS_DEFAULT_GROUP_COUNT;
const roundCount =
swissSettings?.roundCount ?? TOURNAMENT.SWISS_DEFAULT_ROUND_COUNT;
const group = nullFilledArray(groupCount).map((_, i) => ({
id: i,
stage_id: 0,
number: i + 1,
}));
let roundId = 0;
return {
group,
match: firstRoundMatches({ seeding: args.seeding, groupCount, roundCount }),
round: group.flatMap((g) =>
nullFilledArray(roundCount).map((_, i) => ({
id: roundId++,
group_id: g.id,
number: i + 1,
stage_id: 0,
})),
),
stage: [
{
id: 0,
name: args.name,
number: 1,
settings: args.settings ?? {},
tournament_id: args.tournamentId,
type: "swiss",
},
],
};
}
function firstRoundMatches({
seeding,
groupCount,
roundCount,
}: {
seeding: InputStage["seeding"];
groupCount: number;
roundCount: number;
}): Match[] {
// split the teams to one or more groups. For example with 16 teams and 3 groups this would result in
// group 1: 1, 4, 7, 10, 13, 16
// group 2: 2, 5, 8, 11, 14
// group 3: 3, 6, 9, 12, 15
const groups = splitToGroups();
const result: Match[] = [];
let matchId = 0;
for (const [groupIdx, participants] of groups.entries()) {
// if there is an uneven number of teams the last seed gets a bye
const bye = participants.length % 2 === 0 ? null : participants.pop();
const halfI = participants.length / 2;
const upperHalf = participants.slice(0, halfI);
const lowerHalf = participants.slice(halfI);
invariant(
upperHalf.length === lowerHalf.length,
"firstRoundMatches: halfs not equal",
);
// first round every team plays the matching team "on the opposite side"
// so for example with 8 teams match ups look like this:
// seed 1 vs. seed 5
// seed 2 vs. seed 6
// seed 3 vs. seed 7
// seed 4 vs. seed 8
// ---
// this way each match has "equal distance"
const roundId = groupIdx * roundCount;
for (let i = 0; i < upperHalf.length; i++) {
const upper = upperHalf[i];
const lower = lowerHalf[i];
result.push({
id: matchId++,
group_id: groupIdx,
stage_id: 0,
round_id: roundId,
number: i + 1,
opponent1: {
id: upper,
},
opponent2: {
id: lower,
},
status: 2,
});
}
if (bye) {
result.push({
id: matchId++,
group_id: groupIdx,
stage_id: 0,
round_id: roundId,
number: upperHalf.length + 1,
opponent1: {
id: bye,
},
opponent2: null,
status: 2,
});
}
}
return result;
function splitToGroups() {
if (!seeding) return [];
if (groupCount === 1) return [[...seeding]];
const groups: number[][] = nullFilledArray(groupCount).map(() => []);
for (let i = 0; i < seeding.length; i++) {
const groupIndex = i % groupCount;
groups[groupIndex].push(seeding[i]!);
}
return groups;
}
}
function everyMatchOver(matches: Match[]) {
for (const match of matches) {
// bye
if (!match.opponent1 || !match.opponent2) continue;
if (match.opponent1.result !== "win" && match.opponent2.result !== "win") {
return false;
}
}
return true;
}
/**
* Generates the next round of matchups for a Swiss tournament bracket within a specific group.
*
* Considers only the matches and teams within the specified group. Teams that have dropped out are excluded from the pairing process.
* If the group has an uneven number of teams, the lowest standing team that has not already received a bye is preferred to receive one.
* Matches are generated such that teams do not replay previous opponents if possible.
*/
export function generateMatchUps({
bracket,
groupId,
}: {
bracket: Bracket;
groupId: number;
}) {
// lets consider only this groups matches
// in the case that there are more than one group
const groupsMatches = bracket.data.match.filter(
(m) => m.group_id === groupId,
);
if (groupsMatches.length === 0) return err("No matches found for group");
if (bracket.type !== "swiss") return err("Bracket is not Swiss type");
// new matches can't be generated till old are over
if (!everyMatchOver(groupsMatches)) {
return err("Not all matches are over");
}
const groupsTeams = groupsMatches
.flatMap((match) => [match.opponent1, match.opponent2])
.filter(Boolean);
const groupsStandings = bracket.standings.filter((standing) => {
return groupsTeams.some((team) => team?.id === standing.team.id);
});
// teams who have dropped out are not considered
let standingsWithoutDropouts = groupsStandings.filter(
(s) => !s.team.droppedOut,
);
// filter out teams that have advanced or been eliminated if early advance/elimination is enabled
if (typeof bracket.settings?.advanceThreshold === "number") {
const roundCount =
bracket.settings.roundCount ?? TOURNAMENT.SWISS_DEFAULT_ROUND_COUNT;
const advanceThreshold = bracket.settings.advanceThreshold;
standingsWithoutDropouts = standingsWithoutDropouts.filter((standing) => {
const wins = standing.stats?.setWins ?? 0;
const losses = standing.stats?.setLosses ?? 0;
const status = calculateTeamStatus({
wins,
losses,
advanceThreshold,
roundCount,
});
return status === "active";
});
}
// if there are fewer than 2 active teams, no more matches can be generated
if (standingsWithoutDropouts.length < 2) {
return err("Not enough active teams to generate matches");
}
const teamsThatHaveHadByes = groupsMatches
.filter((m) => m.opponent2 === null)
.map((m) => m.opponent1?.id);
const pairs = pairUp(
standingsWithoutDropouts.map((standing) => ({
id: standing.team.id,
score: standing.stats?.setWins ?? 0,
receivedBye: teamsThatHaveHadByes.includes(standing.team.id),
avoid: groupsMatches.flatMap((match) => {
if (match.opponent1?.id === standing.team.id) {
return match.opponent2?.id ? [match.opponent2.id] : [];
}
if (match.opponent2?.id === standing.team.id) {
return match.opponent1?.id ? [match.opponent1.id] : [];
}
return [];
}),
})),
);
let matchNumber = 1;
const newRoundId = bracket.data.round
.slice()
.sort((a, b) => a.id - b.id)
.filter((r) => r.group_id === groupId)
.find(
(r) => r.id > Math.max(...groupsMatches.map((match) => match.round_id)),
)?.id;
invariant(newRoundId, "newRoundId not found");
const result: TournamentRepositoryInsertableMatch[] = pairs.map(
({ opponentOne, opponentTwo }) => ({
groupId,
number: matchNumber++,
roundId: newRoundId,
stageId: groupsMatches[0].stage_id,
opponentOne: JSON.stringify({
id: opponentOne,
}),
opponentTwo:
typeof opponentTwo === "number"
? JSON.stringify({
id: opponentTwo,
})
: JSON.stringify(null),
}),
);
return ok(result);
}
interface SwissPairingTeam {
id: number;
/** How many matches has the team won */
score: number;
/** List of tournament team ids this team already played */
avoid: Array<number>;
receivedBye?: boolean;
}
// adapted from https://github.com/slashinfty/tournament-pairings
export function pairUp(players: SwissPairingTeam[]) {
if (players.length < 2) {
throw new Error("Need at least two players to pair up");
}
if (players.length === 2) {
return [{ opponentOne: players[0].id, opponentTwo: players[1].id }];
}
// uncomment to add a new test case to PAIR_UP_TEST_CASES
// console.log(players);
const matches = [];
const playerArray = R.shuffle(players).map((p, i) => ({ ...p, index: i }));
const scoreGroups = [...new Set(playerArray.map((p) => p.score))].sort(
(a, b) => a - b,
);
const scoreSums = [
...new Set(
scoreGroups.flatMap((s, i, a) => {
const sums = [];
for (let j = i; j < a.length; j++) {
sums.push(s + a[j]);
}
return sums;
}),
),
].sort((a, b) => a - b);
let pairs = generateWeightedPairs({ playerArray, scoreGroups, scoreSums });
if (pairs.length === 0) {
// no possible pairs without rematches, try again allowing rematches
pairs = generateWeightedPairs({
playerArray,
scoreGroups,
scoreSums,
considerAvoid: false,
});
}
const blossomPairs = blossom(pairs, true);
const playerCopy = [...playerArray];
let byeArray = [];
do {
const indexA = playerCopy[0].index;
const indexB = blossomPairs[indexA];
if (indexB === -1) {
byeArray.push(playerCopy.splice(0, 1)[0]);
continue;
}
playerCopy.splice(0, 1);
playerCopy.splice(
playerCopy.findIndex((p) => p.index === indexB),
1,
);
const playerA = playerArray.find((p) => p.index === indexA);
const playerB = playerArray.find((p) => p.index === indexB);
invariant(playerA, "Player A not found");
invariant(playerB, "Player B not found");
matches.push({
opponentOne: playerA.id,
opponentTwo: playerB.id,
});
} while (
playerCopy.length >
blossomPairs.reduce(
(sum: number, idx: number) => (idx === -1 ? sum + 1 : sum),
0,
)
);
byeArray = [...byeArray, ...playerCopy];
for (let i = 0; i < byeArray.length; i++) {
matches.push({
opponentOne: byeArray[i].id,
opponentTwo: null,
});
}
return matches;
}
function generateWeightedPairs({
playerArray,
scoreGroups,
scoreSums,
considerAvoid = true,
}: {
playerArray: (SwissPairingTeam & { index: number })[];
scoreGroups: number[];
scoreSums: number[];
considerAvoid?: boolean;
}) {
const pairs: [number, number, number][] = [];
for (let i = 0; i < playerArray.length; i++) {
const curr = playerArray[i];
const next = playerArray.slice(i + 1);
for (let j = 0; j < next.length; j++) {
const opp = next[j];
if (
considerAvoid &&
Object.hasOwn(curr, "avoid") &&
curr.avoid.includes(opp.id)
) {
continue;
}
let wt =
75 - 75 / (scoreGroups.indexOf(Math.min(curr.score, opp.score)) + 2);
wt +=
5 - 5 / (scoreSums.findIndex((s) => s === curr.score + opp.score) + 1);
const scoreGroupDiff = Math.abs(
scoreGroups.indexOf(curr.score) - scoreGroups.indexOf(opp.score),
);
// TODO: consider "pairedUpDown"
// if (
// scoreGroupDiff === 1 &&
// curr.hasOwnProperty("pairedUpDown") &&
// curr.pairedUpDown === false &&
// opp.hasOwnProperty("pairedUpDown") &&
// opp.pairedUpDown === false
// ) {
// scoreGroupDiff -= 0.65;
// } else if (
// scoreGroupDiff > 0 &&
// ((curr.hasOwnProperty("pairedUpDown") && curr.pairedUpDown === true) ||
// (opp.hasOwnProperty("pairedUpDown") && opp.pairedUpDown === true))
// ) {
// scoreGroupDiff += 0.2;
// }
wt += 23 / (2 * (scoreGroupDiff + 2));
// Lower weight for larger score differences, we really want to avoid 2-0 playing 0-2 etc.
if (scoreGroupDiff >= 2) {
wt -= 10;
}
if (
(Object.hasOwn(curr, "receivedBye") && curr.receivedBye) ||
(Object.hasOwn(opp, "receivedBye") && opp.receivedBye)
) {
wt += 40;
}
pairs.push([curr.index, opp.index, wt]);
}
}
return pairs;
}
export type SwissTeamStatus = "active" | "advanced" | "eliminated";
/**
* Calculates whether a team should advance, be eliminated, or remain active
* in a Swiss tournament with early advance/elimination rules.
*
* @returns The team's status: "advanced" if they've secured advancement,
* "eliminated" if they can no longer mathematically advance, or "active" if still competing
*
* @example
* // In a 5-round Swiss where teams need 3 wins to advance:
* calculateTeamStatus({ wins: 3, losses: 1, advanceThreshold: 3, roundCount: 5 }) // "advanced"
* calculateTeamStatus({ wins: 2, losses: 3, advanceThreshold: 3, roundCount: 5 }) // "eliminated"
* calculateTeamStatus({ wins: 2, losses: 2, advanceThreshold: 3, roundCount: 5 }) // "active"
*/
export function calculateTeamStatus({
wins,
losses,
advanceThreshold,
roundCount,
}: {
/** Number of matches the team has won */
wins: number;
/** Number of matches the team has lost */
losses: number;
/** Number of wins required to advance to the next stage */
advanceThreshold: number;
/** Total number of rounds in the Swiss stage */
roundCount: number;
}): SwissTeamStatus {
if (wins >= advanceThreshold) {
return "advanced";
}
if (losses >= eliminationThreshold({ roundCount, advanceThreshold })) {
return "eliminated";
}
return "active";
}
/**
* Calculates the maximum valid advance threshold for a given round count.
* The threshold must allow for meaningful play - teams need a chance to both advance and be eliminated.
*/
export function maxAdvanceThreshold({ roundCount }: { roundCount: number }) {
return Math.ceil(roundCount / 2) + 1;
}
/**
* Calculates the maximum losses allowed before elimination given an advance threshold and round count.
*/
export function eliminationThreshold({
roundCount,
advanceThreshold,
}: {
roundCount: number;
advanceThreshold: number;
}) {
return roundCount - advanceThreshold + 1;
}
/**
* Validates if an advance threshold is valid for the given round count.
*/
export function isValidAdvanceThreshold({
roundCount,
advanceThreshold,
}: {
roundCount: number;
advanceThreshold: number;
}) {
return validAdvanceThresholdOptions({ roundCount }).includes(
advanceThreshold,
);
}
/**
* Returns a list of valid advance threshold options for a given round count.
* Starts from 2 wins minimum up to the calculated maximum.
*/
export function validAdvanceThresholdOptions({
roundCount,
}: {
roundCount: number;
}) {
const result: number[] = [];
for (let i = 2; i <= maxAdvanceThreshold({ roundCount }); i++) {
result.push(i);
}
return result;
}

View File

@ -2,27 +2,22 @@ import { sub } from "date-fns";
import { ServerConfig } from "~/config.server";
import { clearCombinedStreamsCache } from "~/features/core/streams/streams.server";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
import * as BracketRepository from "~/features/tournament-bracket/BracketRepository.server";
import type { BracketData } from "~/features/tournament-bracket/core/engine/types";
import { getTentativeTier } from "~/features/tournament-organization/core/tentativeTiers.server";
import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types";
import { isAdmin } from "~/modules/permissions/utils";
import { databaseTimestampToDate } from "~/utils/dates";
import { notFoundIfNullish } from "~/utils/remix.server";
import type { Unwrapped } from "~/utils/types";
import { getServerTournamentManager } from "./brackets-manager/manager.server";
import { RunningTournaments } from "./RunningTournaments.server";
import { Tournament } from "./Tournament";
const manager = getServerTournamentManager();
export const tournamentManagerData = (tournamentId: number) =>
manager.get.tournamentData(tournamentId);
const combinedTournamentData = async (tournamentId: number) => {
const ctx = await TournamentRepository.findById(tournamentId);
if (!ctx) return null;
return {
data: tournamentManagerData(tournamentId),
data: await BracketRepository.findByTournamentId(tournamentId),
ctx,
};
};
@ -47,7 +42,7 @@ function dataMapped({
ctx,
user,
}: {
data: TournamentManagerDataSet;
data: BracketData;
ctx: TournamentRepository.FindById;
user?: { id: number };
}) {

View File

@ -1,5 +1,5 @@
import { describe, expect, it, test } from "vitest";
import type { Match } from "~/modules/brackets-model";
import type { MatchData } from "~/features/tournament-bracket/core/engine/types";
import { Tournament } from "./Tournament";
import {
IN_THE_ZONE_32,
@ -117,7 +117,10 @@ describe("Follow-up bracket progression", () => {
).toBe(AMOUNT_OF_BEST_VS_BEST);
});
const validateNoRematches = (rrMatches: Match[], topCutMatches: Match[]) => {
const validateNoRematches = (
rrMatches: MatchData[],
topCutMatches: MatchData[],
) => {
for (const topCutMatch of topCutMatches) {
if (!topCutMatch.opponent1?.id || !topCutMatch.opponent2?.id) {
continue;

View File

@ -13,9 +13,11 @@ import {
tournamentInWeaponReportingWindow,
tournamentIsRanked,
} from "~/features/tournament/tournament-utils";
import type {
BracketData,
MatchData,
} from "~/features/tournament-bracket/core/engine/types";
import type * as Progression from "~/features/tournament-bracket/core/Progression";
import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types";
import type { Match, Stage } from "~/modules/brackets-model";
import type { ModeShort } from "~/modules/in-game-lists/types";
import { isAdmin } from "~/modules/permissions/utils";
import {
@ -26,14 +28,10 @@ import {
import invariant from "~/utils/invariant";
import { logger } from "~/utils/logger";
import { assertUnreachable } from "~/utils/types";
import {
fillWithNullTillPowerOfTwo,
groupNumberToLetters,
} from "../tournament-bracket-utils";
import { groupNumberToLetters } from "../tournament-bracket-utils";
import { type Bracket, createBracket } from "./Bracket";
import { getTournamentManager } from "./brackets-manager";
import * as Engine from "./engine";
import { getRounds } from "./rounds";
import * as Swiss from "./Swiss";
import type { TournamentData, TournamentDataTeam } from "./Tournament.server";
export type OptionalIdObject = { id: number } | undefined;
@ -77,7 +75,7 @@ export class Tournament {
this.initBrackets(data);
}
private initBrackets(data: TournamentManagerDataSet) {
private initBrackets(data: BracketData) {
for (const [
bracketIdx,
{
@ -93,7 +91,7 @@ export class Tournament {
if (inProgressStage) {
const match = data.match.filter(
(match) => match.stage_id === inProgressStage.id,
(match) => match.stageId === inProgressStage.id,
);
this.brackets.push(
@ -111,64 +109,19 @@ export class Tournament {
data: {
...data,
group: data.group.filter(
(group) => group.stage_id === inProgressStage.id,
(group) => group.stageId === inProgressStage.id,
),
match,
stage: data.stage.filter(
(stage) => stage.id === inProgressStage.id,
),
round: data.round.filter(
(round) => round.stage_id === inProgressStage.id,
(round) => round.stageId === inProgressStage.id,
),
},
type,
}),
);
} else if (type === "swiss") {
const { teams, relevantMatchesFinished } = sources
? this.resolveTeamsFromSources(sources, bracketIdx)
: this.resolveTeamsFromSignups(bracketIdx);
const { checkedInTeams, notCheckedInTeams } =
this.divideTeamsToCheckedInAndNotCheckedIn({
teams,
bracketIdx,
usesRegularCheckIn: !sources,
requiresCheckIn,
});
this.brackets.push(
createBracket({
id: -1 * bracketIdx,
idx: bracketIdx,
tournament: this,
seeding: checkedInTeams,
preview: true,
name,
requiresCheckIn,
startTime: startTime ? databaseTimestampToDate(startTime) : null,
settings: settings ?? null,
data: Swiss.create({
tournamentId: this.ctx.id,
name,
seeding: checkedInTeams,
settings: this.bracketManagerSettings(
settings,
type,
checkedInTeams.length,
),
}),
type,
sources,
createdAt: null,
canBeStarted:
(!startTime || startTime < databaseTimestampNow()) &&
checkedInTeams.length >= TOURNAMENT.ENOUGH_TEAMS_TO_START &&
(sources ? relevantMatchesFinished : this.regularCheckInHasEnded),
teamsPendingCheckIn:
bracketIdx !== 0 ? notCheckedInTeams : undefined,
}),
);
} else {
const { teams, relevantMatchesFinished } = sources
? this.resolveTeamsFromSources(sources, bracketIdx)
@ -385,23 +338,14 @@ export class Tournament {
);
const bracketReplays = (candidateTeams: number[]) => {
const manager = getTournamentManager();
manager.create({
tournamentId: this.ctx.id,
name: "X",
const matches = Engine.create({
type: bracket.type as Exclude<
TournamentStage["type"],
"round_robin" | "swiss"
>,
seeding: fillWithNullTillPowerOfTwo(candidateTeams),
settings: this.bracketManagerSettings(
settings,
bracket.type,
candidateTeams.length,
),
});
const matches = manager.get.tournamentData(this.ctx.id).match;
seeding: candidateTeams,
settings,
}).match;
const replays: [number, number][] = [];
for (const match of matches) {
if (!match.opponent1?.id || !match.opponent2?.id) continue;
@ -532,61 +476,6 @@ export class Tournament {
);
}
/** Provides settings for the brackets-manager module with our selected defaults */
bracketManagerSettings(
selectedSettings: TournamentStageSettings | null,
type: Tables["TournamentStage"]["type"],
participantsCount: number,
): Stage["settings"] {
switch (type) {
case "single_elimination": {
if (participantsCount < 4) {
return { consolationFinal: false };
}
return {
consolationFinal:
selectedSettings?.thirdPlaceMatch ??
TOURNAMENT.SE_DEFAULT_HAS_THIRD_PLACE_MATCH,
};
}
case "double_elimination": {
return {
grandFinal: "double",
};
}
case "round_robin": {
const teamsPerGroup =
selectedSettings?.teamsPerGroup ??
TOURNAMENT.RR_DEFAULT_TEAM_COUNT_PER_GROUP;
return {
groupCount: Math.ceil(participantsCount / teamsPerGroup),
seedOrdering: ["groups.seed_optimized"],
hasAbDivisions: selectedSettings?.hasAbDivisions ?? false,
...(this.isLeagueDivision ? { independentRounds: true } : {}),
};
}
case "swiss": {
return {
swiss:
selectedSettings?.groupCount && selectedSettings.roundCount
? {
groupCount: selectedSettings.groupCount,
roundCount: selectedSettings.roundCount,
}
: {
groupCount: TOURNAMENT.SWISS_DEFAULT_GROUP_COUNT,
roundCount: TOURNAMENT.SWISS_DEFAULT_ROUND_COUNT,
},
};
}
default: {
assertUnreachable(type);
}
}
}
/** Is tournament ranked (affects SP/Skill). For tournament to be ranked the organizer needs to enable it and it needs to fit the conditions e.g. it needs to happen when a ranked season is active. */
get ranked() {
return tournamentIsRanked({
@ -739,6 +628,20 @@ export class Tournament {
);
}
/** Status of the given match, derived from the state of its bracket. */
matchStatusById(matchId: number) {
for (const bracket of this.brackets) {
// preview brackets have locally generated match ids that can collide with real ones
if (bracket.preview) continue;
if (bracket.data.match.some((match) => match.id === matchId)) {
return bracket.matchStatus(matchId);
}
}
throw new Error("Match not found");
}
matchIdToBracketIdx(matchId: number) {
const idx = this.brackets.findIndex((bracket) =>
bracket.data.match.some((match) => match.id === matchId),
@ -767,7 +670,7 @@ export class Tournament {
return this.brackets[0].data.round.every((round) => {
const hasMatches = this.brackets[0].data.match.some(
(match) => match.round_id === round.id,
(match) => match.roundId === round.id,
);
return hasMatches;
@ -947,19 +850,19 @@ export class Tournament {
if (bracket.type === "round_robin") {
const group = bracket.data.group.find(
(group) => group.id === match.group_id,
(group) => group.id === match.groupId,
);
const round = bracket.data.round.find(
(round) => round.id === match.round_id,
(round) => round.id === match.roundId,
);
roundName = `Groups ${group?.number ? groupNumberToLetters(group.number) : ""}${round?.number ?? ""}.${match.number}`;
} else if (bracket.type === "swiss") {
const group = bracket.data.group.find(
(group) => group.id === match.group_id,
(group) => group.id === match.groupId,
);
const round = bracket.data.round.find(
(round) => round.id === match.round_id,
(round) => round.id === match.roundId,
);
const oneGroupOnly = bracket.data.group.length === 1;
@ -980,7 +883,7 @@ export class Tournament {
...getRounds({ type: "losers", bracketData: bracket.data }),
];
const round = rounds.find((round) => round.id === match.round_id);
const round = rounds.find((round) => round.id === match.roundId);
if (round) {
const specifier = () => {
@ -1112,10 +1015,7 @@ export class Tournament {
const isParticipant =
match.opponent1?.id === team.id || match.opponent2?.id === team.id;
const isNotFinished =
match.opponent1 &&
match.opponent2 &&
match.opponent1?.result !== "win" &&
match.opponent2?.result !== "win";
match.opponent1 && match.opponent2 && !match.winnerSide;
const isWaitingForTeam =
(match.opponent1 && match.opponent1.id === null) ||
(match.opponent2 && match.opponent2.id === null);
@ -1133,8 +1033,7 @@ export class Tournament {
(match) =>
(match.opponent1?.id === otherTeam.id ||
match.opponent2?.id === otherTeam.id) &&
match.opponent1?.result !== "win" &&
match.opponent2?.result !== "win",
!match.winnerSide,
)?.id !== match.id;
if (otherTeamBusyWithPreviousMatch) {
@ -1189,8 +1088,7 @@ export class Tournament {
match.opponent1?.id === team.id || match.opponent2?.id === team.id,
).length;
const notAllRoundsGenerated =
bracket.settings?.roundCount &&
setsGeneratedCount !== bracket.settings.roundCount;
setsGeneratedCount !== bracket.swissRoundCount;
if (isParticipant && notAllRoundsGenerated) {
return { type: "WAITING_FOR_ROUND" } as const;
@ -1300,7 +1198,7 @@ export class Tournament {
matchBracket,
bracketIdx,
}: {
match: Match;
match: MatchData;
matchBracket: Bracket;
bracketIdx: number;
}) {
@ -1344,8 +1242,7 @@ export class Tournament {
return bracket.data.match
.filter(
// only interested in matches of the same bracket & not the match itself
(match2) =>
match2.stage_id === match.stage_id && match2.id !== match.id,
(match2) => match2.stageId === match.stageId && match2.id !== match.id,
)
.filter((match2) => {
const hasSameParticipant =
@ -1355,7 +1252,7 @@ export class Tournament {
match2.opponent2?.id === match.opponent2?.id;
const comesAfter =
match2.group_id > match.group_id || match2.round_id > match.round_id;
match2.groupId > match.groupId || match2.roundId > match.roundId;
return hasSameParticipant && comesAfter;
});

View File

@ -1,501 +0,0 @@
// this file offers database functions specifically for the crud.server.ts file
import { sql } from "~/db/sql";
import type { Tables } from "~/db/tables";
import type {
Group as GroupType,
Match as MatchType,
Round as RoundType,
Stage as StageType,
} from "~/modules/brackets-model";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import { shortNanoid } from "~/utils/id";
const stage_getByIdStm = sql.prepare(/*sql*/ `
select
*
from
"TournamentStage"
where
"TournamentStage"."id" = @id
`);
const stage_getByTournamentIdStm = sql.prepare(/*sql*/ `
select
*
from
"TournamentStage"
where
"TournamentStage"."tournamentId" = @tournamentId
`);
const stage_insertStm = sql.prepare(/*sql*/ `
insert into
"TournamentStage"
("tournamentId", "number", "name", "type", "settings", "createdAt")
values
(@tournamentId, @number, @name, @type, @settings, @createdAt)
returning *
`);
const stage_updateSettingsStm = sql.prepare(/*sql*/ `
update
"TournamentStage"
set
"settings" = @settings
where
"TournamentStage"."id" = @id
`);
export class Stage {
id?: Tables["TournamentStage"]["id"];
tournamentId: Tables["TournamentStage"]["tournamentId"];
number: Tables["TournamentStage"]["number"];
name: Tables["TournamentStage"]["name"];
type: StageType["type"];
settings: Tables["TournamentStage"]["settings"];
constructor(
id: Tables["TournamentStage"]["id"] | undefined,
tournamentId: Tables["TournamentStage"]["tournamentId"],
number: Tables["TournamentStage"]["number"],
name: Tables["TournamentStage"]["name"],
type: StageType["type"],
settings: Tables["TournamentStage"]["settings"],
) {
this.id = id;
this.tournamentId = tournamentId;
this.number = number;
this.name = name;
this.type = type;
this.settings = settings;
}
insert() {
const stage = stage_insertStm.get({
tournamentId: this.tournamentId,
number: this.number,
name: this.name,
type: this.type,
settings: this.settings,
createdAt: dateToDatabaseTimestamp(new Date()),
}) as any;
this.id = stage.id;
return true;
}
static #convertStage(rawStage: Tables["TournamentStage"]): StageType {
return {
id: rawStage.id,
name: rawStage.name,
number: rawStage.number,
settings: JSON.parse(rawStage.settings),
tournament_id: rawStage.tournamentId,
type: rawStage.type,
createdAt: rawStage.createdAt,
};
}
static getById(id: Tables["TournamentStage"]["id"]): StageType {
const stage = stage_getByIdStm.get({ id }) as any;
if (!stage) return stage;
return Stage.#convertStage(stage);
}
static getByTournamentId(tournamentId: number): StageType[] {
return (stage_getByTournamentIdStm.all({ tournamentId }) as any[]).map(
Stage.#convertStage,
);
}
static updateSettings(
id: Tables["TournamentStage"]["id"],
settings: Tables["TournamentStage"]["settings"],
) {
stage_updateSettingsStm.run({ id, settings });
return true;
}
}
const group_getByIdStm = sql.prepare(/*sql*/ `
select *
from "TournamentGroup"
where "TournamentGroup"."id" = @id
`);
const group_getByStageIdStm = sql.prepare(/*sql*/ `
select *
from "TournamentGroup"
where "TournamentGroup"."stageId" = @stageId
`);
const group_getByStageAndNumberStm = sql.prepare(/*sql*/ `
select *
from "TournamentGroup"
where "TournamentGroup"."stageId" = @stageId
and "TournamentGroup"."number" = @number
`);
const group_insertStm = sql.prepare(/*sql*/ `
insert into
"TournamentGroup"
("stageId", "number")
values
(@stageId, @number)
returning *
`);
export class Group {
id?: Tables["TournamentGroup"]["id"];
stageId: Tables["TournamentGroup"]["stageId"];
number: Tables["TournamentGroup"]["number"];
constructor(
id: Tables["TournamentGroup"]["id"] | undefined,
stageId: Tables["TournamentGroup"]["stageId"],
number: Tables["TournamentGroup"]["number"],
) {
this.id = id;
this.stageId = stageId;
this.number = number;
}
static #convertGroup(rawGroup: Tables["TournamentGroup"]): GroupType {
return {
id: rawGroup.id,
number: rawGroup.number,
stage_id: rawGroup.stageId,
};
}
static getById(id: Tables["TournamentGroup"]["id"]): GroupType {
const group = group_getByIdStm.get({ id }) as any;
if (!group) return group;
return Group.#convertGroup(group);
}
static getByStageId(stageId: Tables["TournamentStage"]["id"]): GroupType[] {
return (group_getByStageIdStm.all({ stageId }) as any[]).map(
Group.#convertGroup,
);
}
static getByStageAndNumber(
stageId: Tables["TournamentStage"]["id"],
number: Tables["TournamentGroup"]["number"],
): GroupType {
const group = group_getByStageAndNumberStm.get({ stageId, number }) as any;
if (!group) return group;
return Group.#convertGroup(group_getByStageAndNumberStm.get(group) as any);
}
insert() {
const group = group_insertStm.get({
stageId: this.stageId,
number: this.number,
}) as any;
this.id = group.id;
return true;
}
}
const round_getByIdStm = sql.prepare(/*sql*/ `
select *
from "TournamentRound"
where "TournamentRound"."id" = @id
`);
const round_getByGroupIdStm = sql.prepare(/*sql*/ `
select *
from "TournamentRound"
where "TournamentRound"."groupId" = @groupId
`);
const round_getByStageIdStm = sql.prepare(/*sql*/ `
select *
from "TournamentRound"
where "TournamentRound"."stageId" = @stageId
`);
const round_getByGroupAndNumberStm = sql.prepare(/*sql*/ `
select *
from "TournamentRound"
where "TournamentRound"."groupId" = @groupId
and "TournamentRound"."number" = @number
`);
const round_insertStm = sql.prepare(/*sql*/ `
insert into
"TournamentRound"
("stageId", "groupId", "number")
values
(@stageId, @groupId, @number)
returning *
`);
export class Round {
id?: Tables["TournamentRound"]["id"];
stageId: Tables["TournamentRound"]["stageId"];
groupId: Tables["TournamentRound"]["groupId"];
number: Tables["TournamentRound"]["number"];
constructor(
id: Tables["TournamentRound"]["id"] | undefined,
stageId: Tables["TournamentRound"]["stageId"],
groupId: Tables["TournamentRound"]["groupId"],
number: Tables["TournamentRound"]["number"],
) {
this.id = id;
this.stageId = stageId;
this.groupId = groupId;
this.number = number;
}
insert() {
const round = round_insertStm.get({
stageId: this.stageId,
groupId: this.groupId,
number: this.number,
}) as any;
this.id = round.id;
return true;
}
static #convertRound(
rawRound: Tables["TournamentRound"] & { maps?: string | null },
): RoundType {
const parsedMaps = rawRound.maps ? JSON.parse(rawRound.maps) : null;
return {
id: rawRound.id,
group_id: rawRound.groupId,
number: rawRound.number,
stage_id: rawRound.stageId,
maps: parsedMaps
? {
count: parsedMaps.count,
type: parsedMaps.type,
pickBan: parsedMaps.pickBan,
}
: null,
};
}
static getByStageId(stageId: Tables["TournamentStage"]["id"]): RoundType[] {
return (round_getByStageIdStm.all({ stageId }) as any[]).map(
Round.#convertRound,
);
}
static getByGroupId(groupId: Tables["TournamentGroup"]["id"]): RoundType[] {
return (round_getByGroupIdStm.all({ groupId }) as any[]).map(
Round.#convertRound,
);
}
static getByGroupAndNumber(
groupId: Tables["TournamentGroup"]["id"],
number: Tables["TournamentRound"]["number"],
): RoundType {
const round = round_getByGroupAndNumberStm.get({ groupId, number }) as any;
if (!round) return round;
return Round.#convertRound(round);
}
static getById(id: Tables["TournamentRound"]["id"]): RoundType {
const round = round_getByIdStm.get({ id }) as any;
if (!round) return round;
return Round.#convertRound(round);
}
}
const match_getByIdStm = sql.prepare(/*sql*/ `
select *
from "TournamentMatch"
where "TournamentMatch"."id" = @id
`);
const match_getByRoundIdStm = sql.prepare(/*sql*/ `
select *
from "TournamentMatch"
where "TournamentMatch"."roundId" = @roundId
`);
const match_getByStageIdStm = sql.prepare(/*sql*/ `
select
"TournamentMatch".*,
sum("TournamentMatchGameResult"."opponentOnePoints") as "opponentOnePointsTotal",
sum("TournamentMatchGameResult"."opponentTwoPoints") as "opponentTwoPointsTotal",
sum(case when "TournamentMatchGameResult"."opponentOnePoints" = 100 and "TournamentMatchGameResult"."opponentTwoPoints" = 0 then 1 else 0 end) as "opponentOneKosTotal",
sum(case when "TournamentMatchGameResult"."opponentTwoPoints" = 100 and "TournamentMatchGameResult"."opponentOnePoints" = 0 then 1 else 0 end) as "opponentTwoKosTotal"
from "TournamentMatch"
left join "TournamentMatchGameResult" on "TournamentMatch"."id" = "TournamentMatchGameResult"."matchId"
where "TournamentMatch"."stageId" = @stageId
group by "TournamentMatch"."id"
`);
const match_getByRoundAndNumberStm = sql.prepare(/*sql*/ `
select *
from "TournamentMatch"
where "TournamentMatch"."roundId" = @roundId
and "TournamentMatch"."number" = @number
`);
const match_insertStm = sql.prepare(/*sql*/ `
insert into
"TournamentMatch"
("roundId", "stageId", "groupId", "number", "opponentOne", "opponentTwo", "status", "chatCode", "startedAt")
values
(@roundId, @stageId, @groupId, @number, @opponentOne, @opponentTwo, @status, @chatCode, @startedAt)
returning *
`);
const match_updateStm = sql.prepare(/*sql*/ `
update "TournamentMatch"
set
"roundId" = @roundId,
"stageId" = @stageId,
"groupId" = @groupId,
"number" = @number,
"opponentOne" = @opponentOne,
"opponentTwo" = @opponentTwo,
"status" = @status
where
"TournamentMatch"."id" = @id
`);
export class Match {
id?: Tables["TournamentMatch"]["id"];
roundId: Tables["TournamentMatch"]["roundId"];
stageId: Tables["TournamentMatch"]["stageId"];
groupId: Tables["TournamentMatch"]["groupId"];
number: Tables["TournamentMatch"]["number"];
opponentOne: string;
opponentTwo: string;
status: Tables["TournamentMatch"]["status"];
constructor(
id: Tables["TournamentMatch"]["id"] | undefined,
status: Tables["TournamentMatch"]["status"],
stageId: Tables["TournamentMatch"]["stageId"],
groupId: Tables["TournamentMatch"]["groupId"],
roundId: Tables["TournamentMatch"]["roundId"],
number: Tables["TournamentMatch"]["number"],
opponentOne: string,
opponentTwo: string,
) {
this.id = id;
this.roundId = roundId;
this.stageId = stageId;
this.groupId = groupId;
this.number = number;
this.opponentOne = opponentOne;
this.opponentTwo = opponentTwo;
this.status = status;
}
static #convertMatch(
rawMatch: Tables["TournamentMatch"] & {
opponentOne: string;
opponentTwo: string;
opponentOnePointsTotal: number | null;
opponentTwoPointsTotal: number | null;
opponentOneKosTotal: number | null;
opponentTwoKosTotal: number | null;
startedAt: number | null;
},
): MatchType {
return {
id: rawMatch.id,
group_id: rawMatch.groupId,
number: rawMatch.number,
opponent1:
rawMatch.opponentOne === "null"
? null
: {
...JSON.parse(rawMatch.opponentOne),
totalPoints: rawMatch.opponentOnePointsTotal ?? undefined,
totalKos: rawMatch.opponentOneKosTotal ?? undefined,
},
opponent2:
rawMatch.opponentTwo === "null"
? null
: {
...JSON.parse(rawMatch.opponentTwo),
totalPoints: rawMatch.opponentTwoPointsTotal ?? undefined,
totalKos: rawMatch.opponentTwoKosTotal ?? undefined,
},
round_id: rawMatch.roundId,
stage_id: rawMatch.stageId,
status: rawMatch.status,
startedAt: rawMatch.startedAt,
};
}
static getById(id: Tables["TournamentMatch"]["id"]): MatchType {
const match = match_getByIdStm.get({ id }) as any;
if (!match) return match;
return Match.#convertMatch(match);
}
static getByRoundId(roundId: Tables["TournamentRound"]["id"]): MatchType[] {
return (match_getByRoundIdStm.all({ roundId }) as any[]).map(
Match.#convertMatch,
);
}
static getByStageId(stageId: Tables["TournamentStage"]["id"]): MatchType[] {
return (match_getByStageIdStm.all({ stageId }) as any[]).map(
Match.#convertMatch,
);
}
static getByRoundAndNumber(
roundId: Tables["TournamentRound"]["id"],
number: Tables["TournamentMatch"]["number"],
): MatchType {
const match = match_getByRoundAndNumberStm.get({ roundId, number }) as any;
if (!match) return match;
return Match.#convertMatch(match);
}
insert() {
const match = match_insertStm.get({
roundId: this.roundId,
stageId: this.stageId,
groupId: this.groupId,
number: this.number,
opponentOne: this.opponentOne ?? "null",
opponentTwo: this.opponentTwo ?? "null",
status: this.status,
chatCode: shortNanoid(),
startedAt: null,
}) as any;
this.id = match.id;
return true;
}
update() {
match_updateStm.run({
id: this.id ?? null,
roundId: this.roundId,
stageId: this.stageId,
groupId: this.groupId,
number: this.number,
opponentOne: this.opponentOne ?? "null",
opponentTwo: this.opponentTwo ?? "null",
status: this.status,
});
return true;
}
}

View File

@ -1,218 +0,0 @@
import type {
CrudInterface,
DataTypes,
OmitId,
Table,
} from "~/modules/brackets-manager/types";
import { Group, Match, Round, Stage } from "./crud-db.server";
export class SqlDatabase implements CrudInterface {
insert<T extends Table>(table: T, value: OmitId<DataTypes[T]>): number;
insert<T extends Table>(table: T, values: OmitId<DataTypes[T]>[]): boolean;
insert<T extends Table>(
table: T,
arg: OmitId<DataTypes[T]> | OmitId<DataTypes[T]>[],
): number | boolean {
switch (table) {
case "stage": {
const value = arg as OmitId<DataTypes["stage"]>;
const stage = new Stage(
undefined,
value.tournament_id,
value.number,
value.name,
value.type,
JSON.stringify(value.settings),
);
stage.insert();
return stage.id!;
}
case "group": {
const value = arg as OmitId<DataTypes["group"]>;
const group = new Group(undefined, value.stage_id, value.number);
group.insert();
return group.id!;
}
case "round": {
const value = arg as OmitId<DataTypes["round"]>;
const round = new Round(
undefined,
value.stage_id,
value.group_id,
value.number,
);
round.insert();
return round.id!;
}
case "match": {
const value = arg as OmitId<DataTypes["match"]>;
const match = new Match(
undefined,
value.status,
value.stage_id,
value.group_id,
value.round_id,
value.number,
JSON.stringify(value.opponent1),
JSON.stringify(value.opponent2),
);
match.insert();
return match.id!;
}
}
}
select<T extends Table>(table: T): Array<DataTypes[T]> | null;
select<T extends Table>(table: T, id: number): DataTypes[T] | null;
select<T extends Table>(
table: T,
filter: Partial<DataTypes[T]>,
): Array<DataTypes[T]> | null;
select<T extends Table>(
table: T,
arg?: number | Partial<DataTypes[T]>,
): DataTypes[T] | Array<DataTypes[T]> | null {
switch (table) {
case "stage": {
if (typeof arg === "number") {
return Stage.getById(arg) as DataTypes[T];
}
const filter = arg as Partial<DataTypes["stage"]> | undefined;
if (filter?.tournament_id) {
return Stage.getByTournamentId(filter.tournament_id) as Array<
DataTypes[T]
>;
}
break;
}
case "group": {
if (typeof arg === "number") {
return Group.getById(arg) as DataTypes[T];
}
const filter = arg as Partial<DataTypes["group"]> | undefined;
if (filter?.stage_id && filter.number) {
const group = Group.getByStageAndNumber(
filter.stage_id,
filter.number,
);
return group ? ([group] as Array<DataTypes[T]>) : null;
}
if (filter?.stage_id) {
return Group.getByStageId(filter.stage_id) as Array<DataTypes[T]>;
}
break;
}
case "round": {
if (typeof arg === "number") {
return Round.getById(arg) as DataTypes[T];
}
const filter = arg as Partial<DataTypes["round"]> | undefined;
if (filter?.group_id && filter.number) {
const round = Round.getByGroupAndNumber(
filter.group_id,
filter.number,
);
return round ? ([round] as Array<DataTypes[T]>) : null;
}
if (filter?.group_id) {
return Round.getByGroupId(filter.group_id) as Array<DataTypes[T]>;
}
if (filter?.stage_id) {
return Round.getByStageId(filter.stage_id) as Array<DataTypes[T]>;
}
break;
}
case "match": {
if (typeof arg === "number") {
return Match.getById(arg) as DataTypes[T];
}
const filter = arg as Partial<DataTypes["match"]> | undefined;
if (filter?.round_id && filter.number) {
const match = Match.getByRoundAndNumber(
filter.round_id,
filter.number,
);
return match ? ([match] as Array<DataTypes[T]>) : null;
}
if (filter?.stage_id) {
return Match.getByStageId(filter.stage_id) as Array<DataTypes[T]>;
}
if (filter?.round_id) {
return Match.getByRoundId(filter.round_id) as Array<DataTypes[T]>;
}
break;
}
}
return null;
}
update<T extends Table>(table: T, id: number, value: DataTypes[T]): boolean;
update<T extends Table>(
table: T,
filter: Partial<DataTypes[T]>,
value: Partial<DataTypes[T]>,
): boolean;
update<T extends Table>(
table: T,
query: number | Partial<DataTypes[T]>,
value: DataTypes[T] | Partial<DataTypes[T]>,
): boolean {
switch (table) {
case "stage": {
if (typeof query === "number") {
const update = value as Partial<DataTypes["stage"]>;
return Stage.updateSettings(query, JSON.stringify(update.settings));
}
break;
}
case "match": {
if (typeof query === "number") {
const update = value as DataTypes["match"];
const match = new Match(
query,
update.status,
update.stage_id,
update.group_id,
update.round_id,
update.number,
JSON.stringify(update.opponent1),
JSON.stringify(update.opponent2),
);
return match.update();
}
break;
}
}
return false;
}
delete<T extends Table>(table: T): boolean;
delete<T extends Table>(table: T, filter: Partial<DataTypes[T]>): boolean;
delete(): boolean {
throw new Error("not implemented");
}
}

View File

@ -1 +0,0 @@
export { getTournamentManager } from "./manager";

View File

@ -1,9 +0,0 @@
import { BracketsManager } from "~/modules/brackets-manager";
import { SqlDatabase } from "./crud.server";
export function getServerTournamentManager() {
const storage = new SqlDatabase();
const manager = new BracketsManager(storage);
return manager;
}

View File

@ -1,9 +0,0 @@
import { BracketsManager } from "~/modules/brackets-manager";
import { InMemoryDatabase } from "~/modules/brackets-memory-db";
export function getTournamentManager() {
const storage = new InMemoryDatabase();
const manager = new BracketsManager(storage);
return manager;
}

View File

@ -0,0 +1,368 @@
import type {
BracketData,
Duel,
GroupData,
MatchData,
ParticipantSlot,
ResolvedCreateBracketInput,
RoundData,
Seeding,
SeedOrdering,
StageData,
StageSettings,
StandardBracketResults,
} from "../types";
import * as helpers from "./helpers";
import {
defaultMinorOrdering,
ordering,
padSeedingToPowerOfTwo,
} from "./seeding";
/**
* Accumulates the rows of a stage being created. Pure port of the old
* brackets-manager `Create` class: same call order, same inserted values, but
* rows are collected into arrays with local ids (0..n-1 per table) instead of
* being written to storage.
*/
export class StageCreator {
readonly input: ResolvedCreateBracketInput;
settings: StageSettings;
seeding: Seeding;
readonly data: BracketData;
constructor(input: ResolvedCreateBracketInput) {
this.input = input;
this.settings = structuredClone(input.settings) ?? {};
const seeding = [...input.seeding];
this.seeding =
input.type !== "round_robin" ? padSeedingToPowerOfTwo(seeding) : seeding;
this.data = { stage: [], group: [], round: [], match: [] };
if (input.type === "single_elimination")
this.settings.consolationFinal = this.settings.consolationFinal || false;
}
insertGroup(group: Omit<GroupData, "id">): number {
const id = this.data.group.length;
this.data.group.push({ id, ...group });
return id;
}
insertRound(round: Omit<RoundData, "id">): number {
const id = this.data.round.length;
this.data.round.push({ id, ...round });
return id;
}
insertMatch(match: Omit<MatchData, "id">): number {
const id = this.data.match.length;
this.data.match.push({ id, ...match });
return id;
}
/**
* Creates a round-robin group.
*
* This will make as many rounds as needed to let each participant match every other once.
*/
createRoundRobinGroup(
stageId: number,
number: number,
slots: ParticipantSlot[],
): void {
const groupId = this.insertGroup({
stageId: stageId,
number,
});
// Groups can be padded with empty slots when teams don't divide evenly
// (`null`) or by the seed ordering (`undefined`). An empty slot is just an
// absent team, so drop it to round-robin only the present teams — otherwise
// the padding becomes BYE rounds that strand real matches in later rounds.
// TBD slots (`{ id: null }`) are kept; only nullish placeholders are removed.
const presentSlots = slots.filter(
(slot) => slot !== null && slot !== undefined,
);
const rounds = helpers.makeRoundRobinMatches(presentSlots);
for (let i = 0; i < rounds.length; i++)
this.createRound(stageId, groupId, i + 1, rounds[0].length, rounds[i]);
}
/**
* Creates a bipartite round-robin group where every A team plays every B team exactly once.
*/
createAbDivisionRoundRobinGroup(
stageId: number,
number: number,
slotsA: ParticipantSlot[],
slotsB: ParticipantSlot[],
): void {
const groupId = this.insertGroup({
stageId: stageId,
number,
});
const rounds = helpers.makeAbDivisionRoundRobinMatches(slotsA, slotsB);
for (let i = 0; i < rounds.length; i++)
this.createRound(stageId, groupId, i + 1, rounds[0].length, rounds[i]);
}
/**
* Creates a standard bracket, which is the only one in single elimination and the upper one in double elimination.
*
* This will make as many rounds as needed to end with one winner.
*/
createStandardBracket(
stageId: number,
number: number,
slots: ParticipantSlot[],
): StandardBracketResults {
const roundCount = helpers.getUpperBracketRoundCount(slots.length);
const groupId = this.insertGroup({
stageId: stageId,
number,
});
let duels = helpers.makePairs(slots);
let roundNumber = 1;
const losers: ParticipantSlot[][] = [];
for (let i = roundCount - 1; i >= 0; i--) {
const matchCount = 2 ** i;
duels = this.getCurrentDuels(duels, matchCount);
losers.push(duels.map(helpers.byeLoser));
this.createRound(stageId, groupId, roundNumber++, matchCount, duels);
}
return { losers, winner: helpers.byeWinner(duels[0]) };
}
/**
* Creates a lower bracket, alternating between major and minor rounds.
*
* - A major round is a regular round.
* - A minor round matches the previous (major) round's winners against upper bracket losers of the corresponding round.
*/
createLowerBracket(
stageId: number,
number: number,
losers: ParticipantSlot[][],
): ParticipantSlot {
const participantCount = this.seeding.length;
const roundPairCount = helpers.getRoundPairCount(participantCount);
let losersId = 0;
const method = this.getMajorOrdering(participantCount);
const ordered = ordering[method](losers[losersId++]);
const groupId = this.insertGroup({
stageId: stageId,
number,
});
let duels = helpers.makePairs(ordered);
let roundNumber = 1;
for (let i = 0; i < roundPairCount; i++) {
const matchCount = 2 ** (roundPairCount - i - 1);
// Major round.
duels = this.getCurrentDuels(duels, matchCount, true);
this.createRound(stageId, groupId, roundNumber++, matchCount, duels);
// Minor round.
const minorOrdering = this.getMinorOrdering(
participantCount,
i,
roundPairCount,
);
duels = this.getCurrentDuels(
duels,
matchCount,
false,
losers[losersId++],
minorOrdering,
);
this.createRound(stageId, groupId, roundNumber++, matchCount, duels);
}
return helpers.byeWinnerToGrandFinal(duels[0]);
}
/**
* Creates a bracket with rounds that only have 1 match each. Used for finals.
*/
createUniqueMatchBracket(
stageId: number,
number: number,
duels: Duel[],
): void {
const groupId = this.insertGroup({
stageId: stageId,
number,
});
for (let i = 0; i < duels.length; i++)
this.createRound(stageId, groupId, i + 1, 1, [duels[i]]);
}
/**
* Creates a round, which contain matches.
*/
createRound(
stageId: number,
groupId: number,
roundNumber: number,
matchCount: number,
duels: Duel[],
): void {
const roundId = this.insertRound({
number: roundNumber,
stageId: stageId,
groupId: groupId,
});
for (let i = 0; i < matchCount; i++) {
this.createMatch(stageId, groupId, roundId, i + 1, duels[i]);
}
}
/**
* Creates a match of the stage.
*/
createMatch(
stageId: number,
groupId: number,
roundId: number,
matchNumber: number,
opponents: Duel,
): void {
const opponent1 = helpers.toResultWithPosition(opponents[0]);
const opponent2 = helpers.toResultWithPosition(opponents[1]);
// Round-robin matches can easily be removed. Prevent BYE vs. BYE matches.
if (
this.input.type === "round_robin" &&
opponent1 === null &&
opponent2 === null
)
return;
this.insertMatch({
number: matchNumber,
stageId: stageId,
groupId: groupId,
roundId: roundId,
opponent1,
opponent2,
winnerSide: null,
});
}
/**
* Gets the duels for the current round based on the previous one. No ordering is done for
* major rounds, it must be done beforehand for the first round. Ordering is done for LB
* minor rounds via the given method.
*/
getCurrentDuels(previousDuels: Duel[], currentDuelCount: number): Duel[];
getCurrentDuels(
previousDuels: Duel[],
currentDuelCount: number,
major: true,
): Duel[];
getCurrentDuels(
previousDuels: Duel[],
currentDuelCount: number,
major: false,
losers: ParticipantSlot[],
method?: SeedOrdering,
): Duel[];
getCurrentDuels(
previousDuels: Duel[],
currentDuelCount: number,
major?: boolean,
losers?: ParticipantSlot[],
method?: SeedOrdering,
): Duel[] {
if (
(major === undefined || major) &&
previousDuels.length === currentDuelCount
) {
// First round.
return previousDuels;
}
if (major === undefined || major) {
// From major to major (WB) or minor to major (LB).
return helpers.transitionToMajor(previousDuels);
}
// From major to minor (LB).
// Losers and method won't be undefined.
return helpers.transitionToMinor(previousDuels, losers!, method);
}
/**
* Returns the list of slots from the seeding.
*/
getSlots(): ParticipantSlot[] {
helpers.ensureValidSize(this.input.type, this.seeding.length);
helpers.ensureNoDuplicates(this.seeding);
return this.getSlotsUsingIds(this.seeding);
}
/**
* Returns the list of slots with a seeding containing IDs.
*/
private getSlotsUsingIds(seeding: Seeding): ParticipantSlot[] {
return seeding.map((slot, i) => {
if (slot === null) return null; // BYE.
return { id: slot, position: i + 1 };
});
}
/**
* The only major ordering for the lower bracket.
*/
private getMajorOrdering(participantCount: number): SeedOrdering {
return defaultMinorOrdering[participantCount]?.[0] || "natural";
}
/**
* A minor ordering for the lower bracket by its index.
*/
private getMinorOrdering(
participantCount: number,
index: number,
minorRoundCount: number,
): SeedOrdering | undefined {
// No ordering for the last minor round. There is only one participant to order.
if (index === minorRoundCount - 1) return undefined;
return defaultMinorOrdering[participantCount]?.[1 + index] || "natural";
}
/**
* Creates the stage row.
*/
createStage(): StageData {
const stage: StageData = {
id: 0,
type: this.input.type,
number: this.input.number ?? 1,
settings: this.settings,
};
this.data.stage.push(stage);
return stage;
}
}

View File

@ -0,0 +1,40 @@
import { describe, expect, test } from "vitest";
import { createResolved } from "./index";
describe("Create double elimination stage", () => {
test("should create a double elimination stage", () => {
const data = createResolved({
type: "double_elimination",
seeding: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16],
settings: {},
});
expect(data.stage[0].type).toBe("double_elimination");
expect(data.group.length).toBe(3);
expect(data.round.length).toBe(4 + 6 + 2);
expect(data.match.length).toBe(31);
});
test("should create a tournament with 256+ tournaments", () => {
expect(() =>
createResolved({
type: "double_elimination",
seeding: Array.from({ length: 256 }, (_, i) => i + 1),
settings: {},
}),
).not.toThrow();
});
test("should create a tournament with a double grand final", () => {
const data = createResolved({
type: "double_elimination",
seeding: [1, 2, 3, 4, 5, 6, 7, 8],
settings: {},
});
expect(data.group.length).toBe(3);
expect(data.round.length).toBe(3 + 4 + 2);
expect(data.match.length).toBe(15);
});
});

View File

@ -0,0 +1,44 @@
import type { Duel, ParticipantSlot } from "../types";
import type { StageCreator } from "./builder";
import * as helpers from "./helpers";
import { ordering, STANDARD_BRACKET_FIRST_ROUND_ORDERING } from "./seeding";
/**
* Creates a double elimination stage.
*
* One upper bracket (winner bracket, WB), one lower bracket (loser bracket, LB) and a double grand final
* between the winner of both brackets.
*/
export function createDoubleElimination(creator: StageCreator): void {
const slots = creator.getSlots();
const stage = creator.createStage();
const ordered = ordering[STANDARD_BRACKET_FIRST_ROUND_ORDERING](slots);
const { losers: losersWb, winner: winnerWb } = creator.createStandardBracket(
stage.id,
1,
ordered,
);
if (helpers.isDoubleEliminationNecessary(slots.length)) {
const winnerLb = creator.createLowerBracket(stage.id, 2, losersWb);
createGrandFinal(creator, stage.id, winnerWb, winnerLb);
}
}
/**
* Creates a double grand final for winners of both brackets in a double elimination stage.
*/
function createGrandFinal(
creator: StageCreator,
stageId: number,
winnerWb: ParticipantSlot,
winnerLb: ParticipantSlot,
): void {
const finalDuels: Duel[] = [
[winnerWb, winnerLb],
[{ id: null }, { id: null }],
];
creator.createUniqueMatchBracket(stageId, 3, finalDuels);
}

View File

@ -1,14 +1,11 @@
import { describe, expect, test } from "vitest";
import {
assertAbDivisionRoundRobin,
assertRoundRobin,
balanceByes,
makeAbDivisionGroups,
makeAbDivisionRoundRobinMatches,
makeGroups,
makeRoundRobinMatches,
} from "../helpers";
import { ordering } from "../ordering";
} from "./helpers";
import { ordering } from "./seeding";
describe("Round-robin groups", () => {
test("should place participants in groups", () => {
@ -289,32 +286,6 @@ describe("A/B division group distribution", () => {
});
describe("Seed ordering methods", () => {
test("should place 2 participants with inner-outer method", () => {
const teams = [1, 2];
const placement = ordering.inner_outer(teams);
expect(placement).toEqual([1, 2]);
});
test("should place 4 participants with inner-outer method", () => {
const teams = [1, 2, 3, 4];
const placement = ordering.inner_outer(teams);
expect(placement).toEqual([1, 4, 2, 3]);
});
test("should place 8 participants with inner-outer method", () => {
const teams = [1, 2, 3, 4, 5, 6, 7, 8];
const placement = ordering.inner_outer(teams);
expect(placement).toEqual([1, 8, 4, 5, 2, 7, 3, 6]);
});
test("should place 16 participants with inner-outer method", () => {
const teams = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16];
const placement = ordering.inner_outer(teams);
expect(placement).toEqual([
1, 16, 8, 9, 4, 13, 5, 12, 2, 15, 7, 10, 3, 14, 6, 11,
]);
});
test("should make a natural ordering", () => {
expect(ordering.natural([1, 2, 3, 4, 5, 6, 7, 8])).toEqual([
1, 2, 3, 4, 5, 6, 7, 8,
@ -345,23 +316,6 @@ describe("Seed ordering methods", () => {
]);
});
test("should make an effort balanced ordering for groups", () => {
expect(
ordering["groups.effort_balanced"]([1, 2, 3, 4, 5, 6, 7, 8], 4),
).toEqual([1, 5, 2, 6, 3, 7, 4, 8]);
expect(
ordering["groups.effort_balanced"](
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16],
4,
),
).toEqual([1, 5, 9, 13, 2, 6, 10, 14, 3, 7, 11, 15, 4, 8, 12, 16]);
expect(
ordering["groups.effort_balanced"]([1, 2, 3, 4, 5, 6, 7, 8], 2),
).toEqual([1, 3, 5, 7, 2, 4, 6, 8]);
});
test("should make a snake ordering for groups", () => {
expect(
ordering["groups.seed_optimized"]([1, 2, 3, 4, 5, 6, 7, 8], 4),
@ -380,131 +334,97 @@ describe("Seed ordering methods", () => {
});
});
describe("Balance BYEs", () => {
test("should ignore input BYEs in the seeding", () => {
expect(
balanceByes(
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, null, null, null, null],
16,
),
).toEqual(balanceByes([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], 16));
/**
* A helper to assert our generated round-robin is correct.
*
* @param input The input seeding.
* @param output The resulting distribution of seeds in groups.
*/
function assertRoundRobin(input: number[], output: [number, number][][]): void {
const n = input.length;
const matchPerRound = Math.floor(n / 2);
const roundCount = n % 2 === 0 ? n - 1 : n;
expect(
balanceByes(
[1, 2, 3, null, 4, 5, 6, 7, 8, null, 9, 10, null, 11, null, 12, null],
16,
),
).toEqual(balanceByes([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], 16));
});
if (output.length !== roundCount) throw Error("Round count is wrong");
if (!output.every((round) => round.length === matchPerRound))
throw Error("Not every round has the good number of matches");
test("should take the target size as an argument or calculate it", () => {
expect(balanceByes([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], 16)).toEqual(
balanceByes([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]),
);
});
const checkAllOpponents = Object.fromEntries(
input.map((element) => [element, new Set<number>()]),
) as Record<number, Set<number>>;
test("should prefer matches with only one BYE", () => {
expect(
balanceByes([
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
null,
null,
null,
null,
]),
).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, null, 10, null, 11, null, 12, null]);
for (const round of output) {
const checkUnique = new Set<number>();
expect(
balanceByes(
[
1,
2,
3,
4,
5,
6,
7,
8,
null,
null,
null,
null,
null,
null,
null,
null,
],
16,
),
).toEqual([
1,
null,
2,
null,
3,
null,
4,
null,
5,
null,
6,
null,
7,
null,
8,
null,
]);
for (const match of round) {
if (match.length !== 2) throw Error("One match is not a pair");
expect(
balanceByes(
[
1,
2,
3,
4,
5,
6,
7,
null,
null,
null,
null,
null,
null,
null,
null,
null,
],
16,
),
).toEqual([
1,
null,
2,
null,
3,
null,
4,
null,
5,
null,
6,
null,
7,
null,
null,
null,
]);
});
});
if (checkUnique.has(match[0]))
throw Error("This team is already playing");
checkUnique.add(match[0]);
if (checkUnique.has(match[1]))
throw Error("This team is already playing");
checkUnique.add(match[1]);
if (checkAllOpponents[match[0]].has(match[1]))
throw Error("The team has already matched this team");
checkAllOpponents[match[0]].add(match[1]);
if (checkAllOpponents[match[1]].has(match[0]))
throw Error("The team has already matched this team");
checkAllOpponents[match[1]].add(match[0]);
}
}
}
/**
* A helper to assert our generated bipartite round-robin is correct.
*
* @param divisionA Seeds in division A (ordered by seed).
* @param divisionB Seeds in division B (ordered by seed).
* @param output The resulting rounds of matches.
*/
function assertAbDivisionRoundRobin(
divisionA: number[],
divisionB: number[],
output: [number, number][][],
): void {
const roundCount = Math.max(divisionA.length, divisionB.length);
const matchesPerRound = Math.min(divisionA.length, divisionB.length);
if (output.length !== roundCount) throw Error("Round count is wrong");
if (!output.every((round) => round.length === matchesPerRound))
throw Error("Not every round has the good number of matches");
const aSet = new Set(divisionA);
const bSet = new Set(divisionB);
const seenPairings = new Set<string>();
for (const round of output) {
const playingInRound = new Set<number>();
for (const match of round) {
if (match.length !== 2) throw Error("One match is not a pair");
const [a, b] = match;
if (!aSet.has(a)) throw Error(`${a} is not a division A participant`);
if (!bSet.has(b)) throw Error(`${b} is not a division B participant`);
if (playingInRound.has(a)) throw Error("This team is already playing");
playingInRound.add(a);
if (playingInRound.has(b)) throw Error("This team is already playing");
playingInRound.add(b);
const pairingKey = `${a}-${b}`;
if (seenPairings.has(pairingKey))
throw Error("The teams have already been paired");
seenPairings.add(pairingKey);
}
}
if (seenPairings.size !== divisionA.length * divisionB.length)
throw Error("Not every A vs B pairing was generated");
}

View File

@ -0,0 +1,357 @@
import type { Duel, ParticipantSlot, SeedOrdering, StageType } from "../types";
import { ordering } from "./seeding";
/**
* Makes a list of rounds containing the matches of a round-robin group.
*
* @param participants The participants to distribute.
* @param mode The round-robin mode.
*/
export function makeRoundRobinMatches<T>(participants: T[]): [T, T][][] {
const n = participants.length;
const n1 = n % 2 === 0 ? n : n + 1;
const roundCount = n1 - 1;
const matchPerRound = n1 / 2;
const rounds: [T, T][][] = [];
for (let roundId = 0; roundId < roundCount; roundId++) {
const matches: [T, T][] = [];
for (let matchId = 0; matchId < matchPerRound; matchId++) {
if (matchId === 0 && n % 2 === 1) continue;
const opponentsIds = [
(roundId - matchId - 1 + n1) % (n1 - 1),
matchId === 0 ? n1 - 1 : (roundId + matchId) % (n1 - 1),
];
matches.push([
participants[opponentsIds[0]],
participants[opponentsIds[1]],
]);
}
rounds.push(matches);
}
return rounds;
}
/**
* Makes a list of rounds containing the matches of a bipartite (A/B divisions) round-robin group.
*
* Every A team plays every B team exactly once; there are no A-vs-A or B-vs-B matches.
* Round 1 is cross-seeded (strongest A vs weakest B), and B is rotated cyclically downward
* in each subsequent round.
*
* When the divisions have different sizes, the shorter side is padded with bye slots so the
* rotation still works. Those bye pairings are filtered out of the output, so each round has
* exactly `min(|A|, |B|)` real matches and the total is `|A| * |B|`.
*
* @param divisionA Participants in division A, ordered by seed.
* @param divisionB Participants in division B, ordered by seed.
*/
export function makeAbDivisionRoundRobinMatches<T>(
divisionA: T[],
divisionB: T[],
): [T, T][][] {
const n = Math.max(divisionA.length, divisionB.length);
const paddedA: (T | null)[] = [
...divisionA,
...Array(n - divisionA.length).fill(null),
];
const paddedB: (T | null)[] = [
...divisionB,
...Array(n - divisionB.length).fill(null),
];
const rounds: [T, T][][] = [];
for (let roundIdx = 0; roundIdx < n; roundIdx++) {
const matches: [T, T][] = [];
for (let i = 0; i < n; i++) {
const bIdx = (((n - 1 - i - roundIdx) % n) + n) % n;
const a = paddedA[i];
const b = paddedB[bIdx];
if (a === null || b === null) continue;
matches.push([a, b]);
}
rounds.push(matches);
}
return rounds;
}
/**
* Distributes A/B division participants into groups such that each group has an
* equal number of A and B participants.
*
* The snake ordering used by `groups.seed_optimized` is applied independently to
* each pool, so that relative seed order within each pool is preserved within
* every group.
*
* @param divisionA Participants in division A, ordered by seed.
* @param divisionB Participants in division B, ordered by seed.
* @param groupCount Number of groups to distribute into.
*/
export function makeAbDivisionGroups<T>(
divisionA: T[],
divisionB: T[],
groupCount: number,
): { a: T[]; b: T[] }[] {
if (groupCount <= 0) throw Error("Group count must be strictly positive.");
if (divisionA.length !== divisionB.length) {
if (groupCount !== 1)
throw Error(
"Uneven A/B divisions are only supported with a single group.",
);
return [{ a: divisionA, b: divisionB }];
}
if (divisionA.length % groupCount !== 0)
throw Error("Pool size must be divisible by group count.");
const aOrdered = ordering["groups.seed_optimized"](divisionA, groupCount);
const bOrdered = ordering["groups.seed_optimized"](divisionB, groupCount);
const perPoolGroupSize = divisionA.length / groupCount;
const groups: { a: T[]; b: T[] }[] = [];
for (let i = 0; i < groupCount; i++) {
groups.push({
a: aOrdered.slice(i * perPoolGroupSize, (i + 1) * perPoolGroupSize),
b: bOrdered.slice(i * perPoolGroupSize, (i + 1) * perPoolGroupSize),
});
}
return groups;
}
/**
* Distributes elements in groups of equal size.
*
* @param elements A list of elements to distribute in groups.
* @param groupCount The group count.
*/
export function makeGroups<T>(elements: T[], groupCount: number): T[][] {
const groupSize = Math.ceil(elements.length / groupCount);
const result: T[][] = [];
for (let i = 0; i < elements.length; i++) {
if (i % groupSize === 0) result.push([]);
result[result.length - 1].push(elements[i]);
}
return result;
}
/**
* Makes pairs with each element and its next one.
*
* @example [1, 2, 3, 4] --> [[1, 2], [3, 4]]
* @param array A list of elements.
*/
export function makePairs<T>(array: T[]): [T, T][] {
return array
.map((_, i) => (i % 2 === 0 ? [array[i], array[i + 1]] : []))
.filter((v): v is [T, T] => v.length === 2);
}
/**
* Ensures there are no duplicates in a list of elements.
*
* @param array A list of elements.
*/
export function ensureNoDuplicates<T>(array: (T | null)[]): void {
const nonNull = getNonNull(array);
const unique = nonNull.filter((item, index) => {
const stringifiedItem = JSON.stringify(item);
return (
nonNull.findIndex((obj) => JSON.stringify(obj) === stringifiedItem) ===
index
);
});
if (unique.length < nonNull.length)
throw new Error("The seeding has a duplicate participant.");
}
/**
* Ensures that the participant count is valid.
*
* @param stageType Type of the stage to test.
* @param participantCount The number to test.
*/
export function ensureValidSize(
stageType: StageType,
participantCount: number,
): void {
if (participantCount < 2)
throw Error("Impossible to create a stage with less than 2 participants.");
if (stageType === "round_robin") {
// Round robin supports any number of participants.
return;
}
if (!Number.isInteger(Math.log2(participantCount)))
throw Error(
"The library only supports a participant count which is a power of two.",
);
}
/**
* Converts a participant slot to a result stored in storage, with the position the participant is coming from.
*
* @param slot A participant slot.
*/
export function toResultWithPosition(slot: ParticipantSlot): ParticipantSlot {
return (
slot && {
id: slot.id,
position: slot.position,
}
);
}
/**
* Returns the pre-computed winner for a match because of BYEs.
*
* @param opponents Two opponents.
*/
export function byeWinner(opponents: Duel): ParticipantSlot {
if (opponents[0] === null && opponents[1] === null)
// Double BYE.
return null; // BYE.
if (opponents[0] === null && opponents[1] !== null)
// opponent1 BYE.
return { id: opponents[1].id }; // opponent2.
if (opponents[0] !== null && opponents[1] === null)
// opponent2 BYE.
return { id: opponents[0].id }; // opponent1.
return { id: null }; // Normal.
}
/**
* Returns the pre-computed winner for a match because of BYEs in a lower bracket.
*
* @param opponents Two opponents.
*/
export function byeWinnerToGrandFinal(opponents: Duel): ParticipantSlot {
const winner = byeWinner(opponents);
if (winner) winner.position = 1;
return winner;
}
/**
* Returns the pre-computed loser for a match because of BYEs.
*
* Only used for loser bracket.
*
* @param opponents Two opponents.
* @param index The index of the duel in the round.
*/
export function byeLoser(opponents: Duel, index: number): ParticipantSlot {
if (opponents[0] === null || opponents[1] === null)
// At least one BYE.
return null; // BYE.
return { id: null, position: index + 1 }; // Normal.
}
/**
* Makes the transition to a major round for duels of the previous round. The duel count is divided by 2.
*
* @param previousDuels The previous duels to transition from.
*/
export function transitionToMajor(previousDuels: Duel[]): Duel[] {
const currentDuelCount = previousDuels.length / 2;
const currentDuels: Duel[] = [];
for (let duelIndex = 0; duelIndex < currentDuelCount; duelIndex++) {
const prevDuelId = duelIndex * 2;
currentDuels.push([
byeWinner(previousDuels[prevDuelId]),
byeWinner(previousDuels[prevDuelId + 1]),
]);
}
return currentDuels;
}
/**
* Makes the transition to a minor round for duels of the previous round. The duel count stays the same.
*
* @param previousDuels The previous duels to transition from.
* @param losers Losers from the previous major round.
* @param method The ordering method for the losers.
*/
export function transitionToMinor(
previousDuels: Duel[],
losers: ParticipantSlot[],
method?: SeedOrdering,
): Duel[] {
const orderedLosers = method ? ordering[method](losers) : losers;
const currentDuelCount = previousDuels.length;
const currentDuels: Duel[] = [];
for (let duelIndex = 0; duelIndex < currentDuelCount; duelIndex++) {
const prevDuelId = duelIndex;
currentDuels.push([
orderedLosers[prevDuelId],
byeWinner(previousDuels[prevDuelId]),
]);
}
return currentDuels;
}
/**
* Returns the number of rounds an upper bracket has given the number of participants in the stage.
*
* @param participantCount The number of participants in the stage.
*/
export function getUpperBracketRoundCount(participantCount: number): number {
return Math.log2(participantCount);
}
/**
* Returns the count of round pairs (major & minor) in a loser bracket.
*
* @param participantCount The number of participants in the stage.
*/
export function getRoundPairCount(participantCount: number): number {
return getUpperBracketRoundCount(participantCount) - 1;
}
/**
* Determines whether a double elimination stage is really necessary.
*
* If the size is only two (less is impossible), then a lower bracket and a grand final are not necessary.
*
* @param participantCount The number of participants in the stage.
*/
export function isDoubleEliminationNecessary(
participantCount: number,
): boolean {
return participantCount > 2;
}
/**
* Returns only the non null elements.
*
* @param array The array to process.
*/
function getNonNull<T>(array: (T | null)[]): T[] {
// Use a TS type guard to exclude null from the resulting type.
const nonNull = array.filter((element): element is T => element !== null);
return nonNull;
}

View File

@ -0,0 +1,120 @@
import type { TournamentRoundMaps } from "~/db/tables";
import type {
BracketData,
CreateBracketInput,
ResolvedCreateBracketInput,
RoundMapsInput,
StageType,
} from "../types";
import { StageCreator } from "./builder";
import { createDoubleElimination } from "./double-elimination";
import { createRoundRobin } from "./round-robin";
import { resolveStageSettings } from "./settings";
import { createSingleElimination } from "./single-elimination";
import { createSwiss } from "./swiss";
/**
* Generates the full structure for a new bracket of any type from the
* user-selected settings. Pure function: returns rows with local ids
* (0..n-1 per table); the repository maps them to real row ids on insert.
* For swiss this includes the empty future rounds + round 1 matches.
*/
export function create(input: CreateBracketInput): BracketData {
const data = createResolved({
type: input.type,
seeding: input.seeding,
settings: resolveStageSettings(input),
abDivisions: input.abDivisions,
number: input.number,
});
if (input.maps) {
attachRoundMaps(data, input.maps, input.type);
}
return data;
}
/**
* Engine-internal `create` taking already-resolved internal stage settings.
* Tests use this to control knobs that are an implementation detail to the
* app (seed ordering, byes balancing).
*/
export function createResolved(input: ResolvedCreateBracketInput): BracketData {
if (input.type === "swiss") return createSwiss(input);
const creator = new StageCreator(input);
switch (input.type) {
case "round_robin":
createRoundRobin(creator);
break;
case "single_elimination":
createSingleElimination(creator);
break;
case "double_elimination":
createDoubleElimination(creator);
break;
default:
throw Error("Unknown stage type.");
}
return creator.data;
}
function attachRoundMaps(
data: BracketData,
mapsInput: RoundMapsInput[],
type: StageType,
) {
const roundsById = new Map(data.round.map((round) => [round.id, round]));
const resolveRound = (roundId: number) => {
const round = roundsById.get(roundId);
if (!round) throw Error(`No round found for map list round id ${roundId}`);
return round;
};
if (type === "round_robin" || type === "swiss") {
// groups share one map list per round number, and groups can have
// different round counts when teams divide unevenly
const distinctRoundNumberCount = new Set(
data.round.map((round) => round.number),
).size;
if (mapsInput.length !== distinctRoundNumberCount) {
throw Error("Invalid map list count");
}
const mapsByRoundNumber = new Map(
mapsInput.map((input) => [
resolveRound(input.roundId).number,
toRoundMaps(input),
]),
);
for (const round of data.round) {
const maps = mapsByRoundNumber.get(round.number);
if (!maps) throw Error(`No maps found for round number ${round.number}`);
round.maps = { ...maps };
}
return;
}
if (mapsInput.length !== data.round.length) {
throw Error("Invalid map list count");
}
for (const input of mapsInput) {
resolveRound(input.roundId).maps = toRoundMaps(input);
}
for (const round of data.round) {
if (!round.maps) throw Error(`Round id ${round.id} is missing maps`);
}
}
function toRoundMaps(input: RoundMapsInput): TournamentRoundMaps {
const { roundId, groupId, ...maps } = input;
return maps;
}

View File

@ -0,0 +1,212 @@
import { describe, expect, test } from "vitest";
import type { BracketData, MatchData } from "../types";
import { createResolved } from "./index";
describe("Create a round-robin stage", () => {
test("should create a round-robin stage", () => {
const data = createResolved({
type: "round_robin",
seeding: [1, 2, 3, 4, 5, 6, 7, 8],
settings: { groupCount: 2 },
});
expect(data.stage[0].type).toBe("round_robin");
expect(data.group.length).toBe(2);
expect(data.round.length).toBe(6);
expect(data.match.length).toBe(12);
});
test("should drop empty slots instead of creating BYE matches", () => {
const data = createResolved({
type: "round_robin",
seeding: [1, 2, 3, 4, 5, null, null, null],
settings: { groupCount: 2 },
});
// 5 teams in 2 groups -> groups of 3 and 2 with no BYE matches at all.
expect(data.match.length).toBe(4);
for (const match of data.match) {
expect(match.opponent1?.id).not.toBeNull();
expect(match.opponent2?.id).not.toBeNull();
}
});
test("should not pad a short group with empty rounds when teams divide unevenly", () => {
// 5 teams in 2 groups -> groups of 3 and 2. The 2-team group must be a
// clean single-round single-match group, not padded with BYE-only rounds
// that strand the real match in a later round.
const data = createResolved({
type: "round_robin",
seeding: [1, 2, 3, 4, 5],
settings: { groupCount: 2 },
});
const shortGroup = data.group.find(
(group) =>
data.match.filter(
(match) => match.groupId === group.id && isRealMatch(match),
).length === 1,
)!;
const rounds = data.round.filter(
(round) => round.groupId === shortGroup.id,
);
const matches = data.match.filter(
(match) => match.groupId === shortGroup.id,
);
const realMatch = matches.find(isRealMatch)!;
const realMatchRound = rounds.find(
(round) => round.id === realMatch.roundId,
)!;
// No BYE matches and no empty rounds, just the single match in round 1.
expect(matches.length).toBe(1);
expect(rounds.length).toBe(1);
expect(realMatchRound.number).toBe(1);
});
test("should create a round-robin stage split across multiple groups", () => {
const data = createResolved({
type: "round_robin",
seeding: Array.from({ length: 16 }, (_, i) => i + 1),
settings: {
groupCount: 4,
},
});
expect(data.group.length).toBe(4);
expect(data.round.length).toBe(4 * 3);
expect(data.match.length).toBe(4 * 3 * 2);
});
test("should order the groups with snake seeding", () => {
const data = createResolved({
type: "round_robin",
seeding: [1, 2, 3, 4, 5, 6, 7, 8],
settings: {
groupCount: 2,
},
});
expect(matchById(data, 0).opponent1?.id).toBe(1);
expect(matchById(data, 0).opponent2?.id).toBe(8);
});
test("should throw if no group count given", () => {
expect(() =>
createResolved({
type: "round_robin",
seeding: [1, 2, 3, 4],
settings: {},
}),
).toThrow("You must specify a group count for round-robin stages.");
});
test("should throw if the group count is not strictly positive", () => {
expect(() =>
createResolved({
type: "round_robin",
seeding: [1, 2, 3, 4],
settings: {
groupCount: 0,
},
}),
).toThrow("You must provide a strictly positive group count.");
});
test("creates an A/B divisions round-robin where every A team plays every B team once", () => {
const seeding = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
// alternate A (0) / B (1) so that seed order 1..12 gives A=[1,3,5,7,9,11], B=[2,4,6,8,10,12]
const abDivisions = seeding.map((_, i) => (i % 2 === 0 ? 0 : 1)) as (
| 0
| 1
)[];
const data = createResolved({
type: "round_robin",
seeding,
abDivisions,
settings: {
groupCount: 1,
hasAbDivisions: true,
},
});
expect(data.group.length).toBe(1);
expect(data.round.length).toBe(6);
expect(data.match.length).toBe(36);
const divisionAIds = new Set([1, 3, 5, 7, 9, 11]);
const divisionBIds = new Set([2, 4, 6, 8, 10, 12]);
const pairings = new Set<string>();
for (const match of data.match) {
const aId = match.opponent1?.id;
const bId = match.opponent2?.id;
expect(divisionAIds.has(aId!)).toBe(true);
expect(divisionBIds.has(bId!)).toBe(true);
const key = `${aId}-${bId}`;
expect(pairings.has(key)).toBe(false);
pairings.add(key);
}
expect(pairings.size).toBe(36);
});
test("throws when A/B divisions are requested but abDivisions is missing", () => {
expect(() =>
createResolved({
type: "round_robin",
seeding: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
settings: {
groupCount: 1,
hasAbDivisions: true,
},
}),
).toThrow("abDivisions must be provided when hasAbDivisions is enabled.");
});
test("creates an A/B divisions round-robin with uneven (±1) divisions and a single group", () => {
const data = createResolved({
type: "round_robin",
seeding: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11],
abDivisions: [0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0],
settings: {
groupCount: 1,
hasAbDivisions: true,
},
});
expect(data.group.length).toBe(1);
expect(data.round.length).toBe(6);
expect(data.match.length).toBe(30);
});
test("throws when A/B divisions are uneven with multiple groups", () => {
expect(() =>
createResolved({
type: "round_robin",
seeding: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11],
abDivisions: [0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0],
settings: {
groupCount: 2,
hasAbDivisions: true,
},
}),
).toThrow("Uneven A/B divisions are only supported with a single group.");
});
});
function isRealMatch(match: MatchData) {
return match.opponent1?.id != null && match.opponent2?.id != null;
}
function matchById(data: BracketData, id: number) {
const found = data.match.find((match) => match.id === id);
if (!found) throw new Error(`Match ${id} not found`);
return found;
}

View File

@ -0,0 +1,112 @@
import type { ParticipantSlot } from "../types";
import type { StageCreator } from "./builder";
import * as helpers from "./helpers";
import { ordering } from "./seeding";
/**
* Creates a round-robin stage.
*
* Group count must be given. It will distribute participants in groups and rounds.
*/
export function createRoundRobin(creator: StageCreator): void {
if (creator.settings.hasAbDivisions) {
createAbDivisionRoundRobin(creator);
return;
}
const groups = getRoundRobinGroups(creator);
const stage = creator.createStage();
for (let i = 0; i < groups.length; i++)
creator.createRoundRobinGroup(stage.id, i + 1, groups[i]);
}
/**
* Creates a bipartite (A/B divisions) round-robin stage.
*
* Participants are partitioned into two pools by `abDivisions` (parallel to the seeding).
* Each group receives equal A and B teams, and matches only pair A against B.
*/
function createAbDivisionRoundRobin(creator: StageCreator): void {
const groups = getAbDivisionGroups(creator);
const stage = creator.createStage();
for (let i = 0; i < groups.length; i++)
creator.createAbDivisionRoundRobinGroup(
stage.id,
i + 1,
groups[i].a,
groups[i].b,
);
}
/**
* Gets the slots in groups for a round-robin stage.
*/
function getRoundRobinGroups(creator: StageCreator): ParticipantSlot[][] {
if (
creator.settings.groupCount === undefined ||
!Number.isInteger(creator.settings.groupCount)
)
throw Error("You must specify a group count for round-robin stages.");
if (creator.settings.groupCount <= 0)
throw Error("You must provide a strictly positive group count.");
const slots = creator.getSlots();
const ordered = ordering["groups.seed_optimized"](
slots,
creator.settings.groupCount,
);
return helpers.makeGroups(ordered, creator.settings.groupCount);
}
/**
* Partitions the seeded slots into A and B pools then distributes them into groups
* such that each group has an equal number of A and B participants.
*/
function getAbDivisionGroups(creator: StageCreator): {
a: ParticipantSlot[];
b: ParticipantSlot[];
}[] {
if (
creator.settings.groupCount === undefined ||
!Number.isInteger(creator.settings.groupCount)
)
throw Error("You must specify a group count for round-robin stages.");
if (creator.settings.groupCount <= 0)
throw Error("You must provide a strictly positive group count.");
const abDivisions = creator.input.abDivisions;
if (!abDivisions)
throw Error("abDivisions must be provided when hasAbDivisions is enabled.");
const slots = creator.getSlots();
if (abDivisions.length !== slots.length)
throw Error("abDivisions length must match the seeding length.");
const divisionA: ParticipantSlot[] = [];
const divisionB: ParticipantSlot[] = [];
for (let i = 0; i < slots.length; i++) {
const slot = slots[i];
if (slot === null)
throw Error("BYEs are not supported with A/B divisions.");
const division = abDivisions[i];
if (division === 0) divisionA.push(slot);
else if (division === 1) divisionB.push(slot);
else
throw Error(
`Participant at seed ${i + 1} is missing an A/B division assignment.`,
);
}
return helpers.makeAbDivisionGroups(
divisionA,
divisionB,
creator.settings.groupCount,
);
}

View File

@ -1,8 +1,7 @@
// https://web.archive.org/web/20200601102344/https://tl.net/forum/sc2-tournaments/202139-superior-double-elimination-losers-bracket-seeding
import type { SeedOrdering } from "~/modules/brackets-model";
import invariant from "~/utils/invariant";
import type { OrderingMap } from "./types";
import type { OrderingMap, Seeding, SeedOrdering } from "../types";
export const ordering: OrderingMap = {
natural: <T>(array: T[]) => [...array],
@ -49,61 +48,6 @@ export const ordering: OrderingMap = {
});
}
},
inner_outer: <T>(array: T[]) => {
if (array.length === 2) return array;
const size = array.length / 4;
const innerPart = [
array.slice(size, 2 * size),
array.slice(2 * size, 3 * size),
]; // [_, X, X, _]
const outerPart = [array.slice(0, size), array.slice(3 * size, 4 * size)]; // [X, _, _, X]
const methods = {
inner(part: T[][]): T[] {
return [part[0].pop()!, part[1].shift()!];
},
outer(part: T[][]): T[] {
return [part[0].shift()!, part[1].pop()!];
},
};
const result: T[] = [];
/**
* Adds a part (inner or outer) of a part.
*
* @param part The part to process.
* @param method The method to use.
*/
function add(part: T[][], method: "inner" | "outer"): void {
if (part[0].length > 0 && part[1].length > 0)
result.push(...methods[method](part));
}
for (let i = 0; i < size / 2; i++) {
add(outerPart, "outer"); // Outer part's outer
add(innerPart, "inner"); // Inner part's inner
add(outerPart, "inner"); // Outer part's inner
add(innerPart, "outer"); // Inner part's outer
}
return result;
},
"groups.effort_balanced": <T>(array: T[], groupCount: number) => {
const result: T[] = [];
let i = 0;
let j = 0;
while (result.length < array.length) {
result.push(array[i]);
i += groupCount;
if (i >= array.length) i = ++j;
}
return result;
},
"groups.seed_optimized": <T>(array: T[], groupCount: number) => {
const groups = Array.from(Array(groupCount), (_): T[] => []);
@ -119,11 +63,12 @@ export const ordering: OrderingMap = {
return groups.flat();
},
"groups.bracket_optimized": () => {
throw Error("Not implemented.");
},
};
/** The ordering method for the first round of the upper bracket of an elimination stage. */
export const STANDARD_BRACKET_FIRST_ROUND_ORDERING: SeedOrdering =
"space_between";
export const defaultMinorOrdering: { [key: number]: SeedOrdering[] } = {
// 1 or 2: Not possible.
4: ["natural", "reverse"],
@ -141,3 +86,32 @@ export const defaultMinorOrdering: { [key: number]: SeedOrdering[] } = {
"natural",
],
};
/**
* Pads the seeding with BYEs (`null`) until its length is a power of two.
*
* @param seeding The seeding of the stage.
*/
export function padSeedingToPowerOfTwo(seeding: Seeding): Seeding {
return setArraySize(seeding, getNearestPowerOfTwo(seeding.length), null);
}
/**
* Sets the size of an array with a placeholder if the size is bigger.
*
* @param array The original array.
* @param length The new length.
* @param placeholder A placeholder to use to fill the empty space.
*/
function setArraySize<T>(array: T[], length: number, placeholder: T): T[] {
return Array.from(Array(length), (_, i) => array[i] || placeholder);
}
/**
* Returns the nearest power of two **greater than** or equal to the given number.
*
* @param input The input number.
*/
function getNearestPowerOfTwo(input: number): number {
return 2 ** Math.ceil(Math.log2(input));
}

View File

@ -0,0 +1,88 @@
import type { TournamentStageSettings } from "~/db/tables";
import { TOURNAMENT } from "~/features/tournament/tournament-constants";
import { assertUnreachable } from "~/utils/types";
import type {
BracketData,
CreateBracketInput,
StageSettings,
StageType,
} from "../types";
/**
* Resolves the user-selected settings into the engine's internal stage
* settings, applying our defaults (seed ordering, group counts etc.).
*/
export function resolveStageSettings(input: CreateBracketInput): StageSettings {
const { type, settings, seeding } = input;
switch (type) {
case "single_elimination": {
return {
consolationFinal: hasThirdPlaceMatch({
type,
settings,
participantsCount: seeding.length,
}),
};
}
case "double_elimination": {
return {};
}
case "round_robin": {
return {
groupCount: roundRobinGroupCount(settings, seeding.length),
hasAbDivisions: settings?.hasAbDivisions ?? false,
...(input.independentRounds ? { independentRounds: true } : {}),
};
}
case "swiss": {
return {
groupCount:
settings?.groupCount ?? TOURNAMENT.SWISS_DEFAULT_GROUP_COUNT,
roundCount:
settings?.roundCount ?? TOURNAMENT.SWISS_DEFAULT_ROUND_COUNT,
};
}
default: {
assertUnreachable(type);
}
}
}
/**
* How many rounds a swiss bracket was created with. Read off the stage's own
* settings (resolved and persisted when the bracket was created) so that later
* edits to the tournament's bracket progression can't change the advance and
* elimination math of a bracket that already exists.
*/
export function swissRoundCount(data: BracketData): number {
return (
data.stage[0]?.settings.roundCount ?? TOURNAMENT.SWISS_DEFAULT_ROUND_COUNT
);
}
/** Whether the bracket will include a third place match. Only possible for single elimination with at least 4 participants. */
export function hasThirdPlaceMatch(args: {
type: StageType;
settings: TournamentStageSettings | null;
participantsCount: number;
}): boolean {
if (args.type !== "single_elimination") return false;
if (args.participantsCount < 4) return false;
return (
args.settings?.thirdPlaceMatch ??
TOURNAMENT.SE_DEFAULT_HAS_THIRD_PLACE_MATCH
);
}
/** How many groups a round robin bracket will have, derived from the user-selected teams per group count and the participant count. */
export function roundRobinGroupCount(
settings: TournamentStageSettings | null,
participantsCount: number,
): number {
const teamsPerGroup =
settings?.teamsPerGroup ?? TOURNAMENT.RR_DEFAULT_TEAM_COUNT_PER_GROUP;
return Math.ceil(participantsCount / teamsPerGroup);
}

View File

@ -0,0 +1,93 @@
import { describe, expect, test } from "vitest";
import type { BracketData } from "../types";
import { createResolved } from "./index";
describe("Create single elimination stage", () => {
test("should create a single elimination stage", () => {
const data = createResolved({
type: "single_elimination",
seeding: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16],
settings: {},
});
expect(data.stage[0].type).toBe("single_elimination");
expect(data.group.length).toBe(1);
expect(data.round.length).toBe(4);
expect(data.match.length).toBe(15);
});
test("should create a single elimination stage with BYEs", () => {
const data = createResolved({
type: "single_elimination",
seeding: [1, null, 3, 4, null, null, 7, 8],
settings: {},
});
expect(matchById(data, 4).opponent1?.id).toBe(null);
expect(matchById(data, 4).opponent2?.id).toBe(4);
expect(matchById(data, 5).opponent1?.id).toBe(7);
expect(matchById(data, 5).opponent2?.id).toBe(3);
});
test("should create a single elimination stage with consolation final", () => {
const data = createResolved({
type: "single_elimination",
seeding: [1, 2, 3, 4, 5, 6, 7, 8],
settings: { consolationFinal: true },
});
expect(data.group.length).toBe(2);
expect(data.round.length).toBe(4);
expect(data.match.length).toBe(8);
});
test("should create a single elimination stage with consolation final and BYEs", () => {
const data = createResolved({
type: "single_elimination",
seeding: [null, null, null, 4, 5, 6, 7, 8],
settings: { consolationFinal: true },
});
expect(matchById(data, 4).opponent1?.id).toBe(8);
expect(matchById(data, 4).opponent2?.id).toBe(null);
// Consolation final
expect(matchById(data, 7).opponent1?.id).toBe(null);
expect(matchById(data, 7).opponent2?.id).toBe(null);
});
test("should create a single elimination stage with Bo3 matches", () => {
const data = createResolved({
type: "single_elimination",
seeding: [1, 2, 3, 4, 5, 6, 7, 8],
settings: {},
});
expect(data.group.length).toBe(1);
expect(data.round.length).toBe(3);
expect(data.match.length).toBe(7);
});
test("should throw if the seeding has duplicate participants", () => {
expect(() =>
createResolved({
type: "single_elimination",
seeding: [
1,
1, // Duplicate
3,
4,
],
settings: {},
}),
).toThrow("The seeding has a duplicate participant.");
});
});
function matchById(data: BracketData, id: number) {
const found = data.match.find((match) => match.id === id);
if (!found) throw new Error(`Match ${id} not found`);
return found;
}

View File

@ -0,0 +1,31 @@
import type { Duel, ParticipantSlot } from "../types";
import type { StageCreator } from "./builder";
import { ordering, STANDARD_BRACKET_FIRST_ROUND_ORDERING } from "./seeding";
/**
* Creates a single elimination stage.
*
* One bracket and optionally a consolation final between semi-final losers.
*/
export function createSingleElimination(creator: StageCreator): void {
const slots = creator.getSlots();
const stage = creator.createStage();
const ordered = ordering[STANDARD_BRACKET_FIRST_ROUND_ORDERING](slots);
const { losers } = creator.createStandardBracket(stage.id, 1, ordered);
createConsolationFinal(creator, stage.id, losers);
}
/**
* Creates a consolation final for the semi final losers of a single elimination stage.
*/
function createConsolationFinal(
creator: StageCreator,
stageId: number,
losers: ParticipantSlot[][],
): void {
if (!creator.settings.consolationFinal) return;
const semiFinalLosers = losers[losers.length - 2] as Duel;
creator.createUniqueMatchBracket(stageId, 2, [semiFinalLosers]);
}

View File

@ -0,0 +1,144 @@
import { TOURNAMENT } from "~/features/tournament/tournament-constants";
import { nullFilledArray } from "~/utils/arrays";
import invariant from "~/utils/invariant";
import type {
BracketData,
MatchData,
ResolvedCreateBracketInput,
} from "../types";
/**
* Creates a Swiss bracket data set: all rounds up front, matches for round 1 only.
* Ported from the old core/Swiss.ts create()/firstRoundMatches().
*/
export function createSwiss(input: ResolvedCreateBracketInput): BracketData {
const groupCount =
input.settings.groupCount ?? TOURNAMENT.SWISS_DEFAULT_GROUP_COUNT;
const roundCount =
input.settings.roundCount ?? TOURNAMENT.SWISS_DEFAULT_ROUND_COUNT;
const group = nullFilledArray(groupCount).map((_, i) => ({
id: i,
stageId: 0,
number: i + 1,
}));
let roundId = 0;
return {
group,
match: firstRoundMatches({
seeding: input.seeding,
groupCount,
roundCount,
}),
round: group.flatMap((g) =>
nullFilledArray(roundCount).map((_, i) => ({
id: roundId++,
groupId: g.id,
number: i + 1,
stageId: 0,
})),
),
stage: [
{
id: 0,
number: 1,
settings: input.settings,
type: "swiss",
},
],
};
}
function firstRoundMatches({
seeding,
groupCount,
roundCount,
}: {
seeding: ResolvedCreateBracketInput["seeding"];
groupCount: number;
roundCount: number;
}): MatchData[] {
// split the teams to one or more groups. For example with 16 teams and 3 groups this would result in
// group 1: 1, 4, 7, 10, 13, 16
// group 2: 2, 5, 8, 11, 14
// group 3: 3, 6, 9, 12, 15
const groups = splitToGroups();
const result: MatchData[] = [];
let matchId = 0;
for (const [groupIdx, participants] of groups.entries()) {
// if there is an uneven number of teams the last seed gets a bye
const bye = participants.length % 2 === 0 ? null : participants.pop();
const halfI = participants.length / 2;
const upperHalf = participants.slice(0, halfI);
const lowerHalf = participants.slice(halfI);
invariant(
upperHalf.length === lowerHalf.length,
"firstRoundMatches: halfs not equal",
);
// first round every team plays the matching team "on the opposite side"
// so for example with 8 teams match ups look like this:
// seed 1 vs. seed 5
// seed 2 vs. seed 6
// seed 3 vs. seed 7
// seed 4 vs. seed 8
// ---
// this way each match has "equal distance"
const roundId = groupIdx * roundCount;
for (let i = 0; i < upperHalf.length; i++) {
const upper = upperHalf[i];
const lower = lowerHalf[i];
result.push({
id: matchId++,
groupId: groupIdx,
stageId: 0,
roundId: roundId,
number: i + 1,
opponent1: {
id: upper,
},
opponent2: {
id: lower,
},
winnerSide: null,
});
}
if (bye) {
result.push({
id: matchId++,
groupId: groupIdx,
stageId: 0,
roundId: roundId,
number: upperHalf.length + 1,
opponent1: {
id: bye,
},
opponent2: null,
winnerSide: null,
});
}
}
return result;
function splitToGroups() {
if (!seeding) return [];
if (groupCount === 1) return [seeding.map((id) => id!)];
const groups: number[][] = nullFilledArray(groupCount).map(() => []);
for (let i = 0; i < seeding.length; i++) {
const groupIndex = i % groupCount;
groups[groupIndex].push(seeding[i]!);
}
return groups;
}
}

View File

@ -0,0 +1,258 @@
import { beforeEach, describe, expect, test } from "vitest";
import { createResolved } from "./create";
import * as Engine from "./index";
import type { BracketData } from "./types";
describe("BYE handling", () => {
test("should propagate BYEs through the brackets", () => {
const data = createResolved({
type: "double_elimination",
seeding: [1, null, null, null],
settings: {},
});
expect(matchById(data, 2).opponent1?.id).toBe(1);
expect(matchById(data, 2).opponent2).toBe(null);
expect(matchById(data, 3).opponent1).toBe(null);
expect(matchById(data, 3).opponent2).toBe(null);
expect(matchById(data, 4).opponent1).toBe(null);
expect(matchById(data, 4).opponent2).toBe(null);
expect(matchById(data, 5).opponent1?.id).toBe(1);
expect(matchById(data, 5).opponent2).toBe(null);
});
test("should handle incomplete seeding during creation", () => {
const data = createResolved({
type: "double_elimination",
seeding: [1, 2, null, null],
settings: {},
});
expect(matchById(data, 0).opponent1?.id).toBe(1);
expect(matchById(data, 0).opponent2).toBe(null);
expect(matchById(data, 1).opponent1?.id).toBe(2);
expect(matchById(data, 1).opponent2).toBe(null);
});
});
describe("Position checks", () => {
let data: BracketData;
beforeEach(() => {
data = createResolved({
type: "double_elimination",
seeding: [1, 2, 3, 4, 5, 6, 7, 8],
settings: {},
});
});
test("should not have a position when we don't need the origin of a participant", () => {
const matchFromWbRound2 = matchById(data, 4);
expect(matchFromWbRound2.opponent1?.position).toBe(undefined);
expect(matchFromWbRound2.opponent2?.position).toBe(undefined);
const matchFromLbRound2 = matchById(data, 9);
expect(matchFromLbRound2.opponent2?.position).toBe(undefined);
const matchFromGrandFinal = matchById(data, 13);
expect(matchFromGrandFinal.opponent1?.position).toBe(undefined);
});
test("should have a position where we need the origin of a participant", () => {
const matchFromWbRound1 = matchById(data, 0);
expect(matchFromWbRound1.opponent1?.position).toBe(1);
expect(matchFromWbRound1.opponent2?.position).toBe(8);
const matchFromLbRound1 = matchById(data, 7);
expect(matchFromLbRound1.opponent1?.position).toBe(1);
expect(matchFromLbRound1.opponent2?.position).toBe(2);
const matchFromLbRound2 = matchById(data, 9);
expect(matchFromLbRound2.opponent1?.position).toBe(2);
const matchFromGrandFinal = matchById(data, 13);
expect(matchFromGrandFinal.opponent2?.position).toBe(1);
});
});
describe("Special cases", () => {
test("should pad the seeding with BYEs to the next power of two", () => {
const data = createResolved({
type: "single_elimination",
seeding: [1, 2, 3, 4, 5, 6, 7],
settings: {},
});
expect(matchById(data, 0).opponent1?.id).toBe(1);
expect(matchById(data, 0).opponent2).toBe(null);
});
test("should throw if the participant count of a stage is less than two", () => {
expect(() =>
createResolved({
type: "single_elimination",
seeding: [],
settings: {},
}),
).toThrow("Impossible to create a stage with less than 2 participants.");
expect(() =>
createResolved({
type: "single_elimination",
seeding: [1],
settings: {},
}),
).toThrow("Impossible to create a stage with less than 2 participants.");
});
});
describe("Seeding and ordering in elimination", () => {
let data: BracketData;
beforeEach(() => {
data = createResolved({
type: "double_elimination",
seeding: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16],
settings: {},
});
});
test("should have the good orderings everywhere", () => {
const firstRoundMatchWB = matchById(data, 0);
expect(firstRoundMatchWB.opponent1?.position).toBe(1);
expect(firstRoundMatchWB.opponent2?.position).toBe(16);
const firstRoundMatchLB = matchById(data, 15);
expect(firstRoundMatchLB.opponent1?.position).toBe(1);
expect(firstRoundMatchLB.opponent2?.position).toBe(2);
const secondRoundMatchLB = matchById(data, 19);
expect(secondRoundMatchLB.opponent1?.position).toBe(2);
const secondRoundSecondMatchLB = matchById(data, 20);
expect(secondRoundSecondMatchLB.opponent1?.position).toBe(1);
const fourthRoundMatchLB = matchById(data, 25);
expect(fourthRoundMatchLB.opponent1?.position).toBe(2);
const finalRoundMatchLB = matchById(data, 28);
expect(finalRoundMatchLB.opponent1?.position).toBe(1);
});
});
describe("Reset match", () => {
test("should reset results of a match", () => {
// Seeds 1 and 2 are placed into the same first-round match (positions 1
// and 8) so that match 0 is a real two-team match under the default
// space_between ordering, while the rest of the bracket is BYEs.
let data = createResolved({
type: "single_elimination",
seeding: [1, null, null, null, null, null, null, 2],
settings: {},
});
data = Engine.reportResult(data, {
matchId: 0,
scores: [16, 12],
winnerSide: "opponent1",
}).data;
expect(matchById(data, 0).opponent1?.score).toBe(16);
expect(matchById(data, 0).opponent2?.score).toBe(12);
expect(matchById(data, 0).winnerSide).toBe("opponent1");
expect(matchById(data, 4).winnerSide).toBe("opponent1");
expect(matchById(data, 4).opponent2).toBe(null);
expect(matchById(data, 6).winnerSide).toBe("opponent1");
expect(matchById(data, 6).opponent2).toBe(null);
data = Engine.resetMatchResults(data, 0).data; // Score stays as is.
expect(matchById(data, 0).opponent1?.score).toBe(16);
expect(matchById(data, 0).opponent2?.score).toBe(12);
expect(matchById(data, 0).winnerSide).toBe(null);
expect(matchById(data, 4).winnerSide).toBe(null);
expect(matchById(data, 4).opponent2).toBe(null);
expect(matchById(data, 6).winnerSide).toBe(null);
expect(matchById(data, 6).opponent2).toBe(null);
});
test("should throw when at least one of the following match is locked", () => {
let data = createResolved({
type: "single_elimination",
seeding: [1, 2, 3, 4],
settings: {},
});
for (const matchId of [0, 1, 2]) {
data = Engine.reportResult(data, {
matchId,
scores: [16, 12],
winnerSide: "opponent1",
}).data;
}
expect(() => Engine.resetMatchResults(data, 0)).toThrow(
"The match is locked.",
);
});
});
describe("Engine data immutability", () => {
test("engine operations return new data and leave the input untouched", () => {
const initial = createResolved({
type: "single_elimination",
seeding: [1, 2, 3, 4],
settings: {},
});
const snapshot = structuredClone(initial);
const afterReport = Engine.reportResult(initial, {
matchId: 0,
scores: [16, 12],
winnerSide: "opponent1",
});
expect(initial).toEqual(snapshot);
expect(afterReport.data.match[0].winnerSide).toBe("opponent1");
const afterReset = Engine.resetMatchResults(afterReport.data, 0);
expect(afterReport.data.match[0].winnerSide).toBe("opponent1");
expect(afterReset.data.match[0].winnerSide).toBeNull();
});
test("changedMatches contains only genuinely changed rows", () => {
const initial = createResolved({
type: "single_elimination",
seeding: [1, 2, 3, 4],
settings: {},
});
const afterReport = Engine.reportResult(initial, {
matchId: 0,
scores: [16, 12],
winnerSide: "opponent1",
});
const changedIds = afterReport.changedMatches.map((match) => match.id);
expect(changedIds).toContain(0); // The reported match.
expect(changedIds).toContain(2); // The final receiving the winner.
expect(changedIds).not.toContain(1); // The other semi is untouched.
});
});
function matchById(data: BracketData, id: number) {
const found = data.match.find((match) => match.id === id);
if (!found) throw new Error(`Match ${id} not found`);
return found;
}

View File

@ -0,0 +1,24 @@
/**
* Pure bracket engine. No I/O anywhere in this module tree callers hydrate
* BracketData via BracketRepository, call engine functions, persist the
* returned delta via BracketRepository.
*/
export { create } from "./create";
export {
hasThirdPlaceMatch,
roundRobinGroupCount,
swissRoundCount,
} from "./create/settings";
export { endDroppedTeamMatches } from "./propagation/dropped-teams";
export { reportResult } from "./propagation/report-result";
export { resetMatchResults } from "./propagation/reset-result";
export {
endSet,
reopenMatch,
reportGameResult,
undoGameResult,
} from "./propagation/set";
export * from "./status";
export { generateRound } from "./swiss/pairing";
export * from "./types";

View File

@ -0,0 +1,273 @@
import { describe, expect, test } from "vitest";
import { createResolved } from "../create";
import * as Engine from "../index";
import type { BracketData } from "../types";
describe("Previous and next match update in double elimination stage", () => {
test("should end a match and determine next matches", () => {
let data = createResolved({
type: "double_elimination",
seeding: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16],
settings: {},
});
const before = matchById(data, 8); // First match of WB round 2
expect(before.opponent2?.id).toBeNull();
data = Engine.reportResult(data, {
matchId: 0, // First match of WB round 1
scores: [16, 12],
winnerSide: "opponent1",
}).data;
data = Engine.reportResult(data, {
matchId: 1, // Second match of WB round 1
scores: [13, 16],
winnerSide: "opponent2",
}).data;
data = Engine.reportResult(data, {
matchId: 15, // First match of LB round 1
scores: [16, 10],
winnerSide: "opponent1",
}).data;
expect(
matchById(data, 8).opponent1?.id, // Determined opponent for WB round 2
).toBe(matchById(data, 0).opponent1?.id); // Winner of first match round 1
expect(
matchById(data, 8).opponent2?.id, // Determined opponent for WB round 2
).toBe(matchById(data, 1).opponent2?.id); // Winner of second match round 1
expect(
matchById(data, 15).opponent2?.id, // Determined opponent for LB round 1
).toBe(matchById(data, 1).opponent1?.id); // Loser of second match round 1
expect(
matchById(data, 19).opponent2?.id, // Determined opponent for LB round 2
).toBe(matchById(data, 0).opponent2?.id); // Loser of first match round 1
});
test("should propagate winner when BYE is already in next match in loser bracket", () => {
let data = createResolved({
type: "double_elimination",
seeding: [1, 2, 3, null],
settings: {},
});
data = Engine.reportResult(data, {
matchId: 1, // Second match of WB round 1
scores: [16, 12],
winnerSide: "opponent1",
}).data;
const loserId = matchById(data, 1).opponent2?.id;
expect(matchById(data, 3).opponent2?.id).toBe(loserId);
expect(matchById(data, 3).winnerSide).toBe("opponent2");
expect(Engine.matchStatus(data, 3)).toBe("COMPLETED");
expect(
matchById(data, 4).opponent2?.id, // Propagated winner in LB Final because of the BYE.
).toBe(loserId);
data = Engine.resetMatchResults(data, 1).data; // Second match of WB round 1
expect(matchById(data, 3).opponent2?.id).toBeNull();
expect(matchById(data, 3).winnerSide).toBeNull();
expect(Engine.matchStatus(data, 3)).toBe("PENDING");
expect(matchById(data, 4).opponent2?.id).toBeNull(); // Propagated winner is removed.
});
test("should determine matches in grand final", () => {
let data = createResolved({
type: "double_elimination",
seeding: [1, 2, 3, 4],
settings: {},
});
data = Engine.reportResult(data, {
matchId: 0, // First match of WB round 1
scores: [16, 12],
winnerSide: "opponent1",
}).data;
data = Engine.reportResult(data, {
matchId: 1, // Second match of WB round 1
scores: [13, 16],
winnerSide: "opponent2",
}).data;
data = Engine.reportResult(data, {
matchId: 2, // WB Final
scores: [16, 9],
winnerSide: "opponent1",
}).data;
expect(
matchById(data, 5).opponent1?.id, // Determined opponent for the grand final (round 1)
).toBe(matchById(data, 0).opponent1?.id); // Winner of WB Final
data = Engine.reportResult(data, {
matchId: 3, // Only match of LB round 1
scores: [12, 8],
winnerSide: "opponent1", // Team 4
}).data;
data = Engine.reportResult(data, {
matchId: 4, // LB Final
scores: [14, 7],
winnerSide: "opponent1", // Team 3
}).data;
expect(
matchById(data, 5).opponent2?.id, // Determined opponent for the grand final (round 1)
).toBe(matchById(data, 1).opponent2?.id); // Winner of LB Final
data = Engine.reportResult(data, {
matchId: 5, // Grand Final round 1
scores: [10, 16],
winnerSide: "opponent2", // Team 3
}).data;
expect(
matchById(data, 6).opponent2?.id, // Determined opponent for the grand final (round 2)
).toBe(matchById(data, 1).opponent2?.id); // Winner of LB Final
expect(Engine.matchStatus(data, 5)).toBe("COMPLETED"); // Grand final (round 1)
expect(Engine.matchStatus(data, 6)).toBe("STARTED"); // Grand final (round 2)
expect(() =>
Engine.reportResult(data, {
matchId: 6, // Grand Final round 2
scores: [16, 10],
winnerSide: "opponent1",
}),
).not.toThrow();
});
test("should determine next matches and reset them", () => {
let data = createResolved({
type: "double_elimination",
seeding: [1, 2, 3, 4],
settings: {},
});
data = Engine.reportResult(data, {
matchId: 0, // First match of WB round 1
scores: [16, 12],
winnerSide: "opponent1",
}).data;
const beforeReset = matchById(data, 3); // Determined opponent for LB round 1
expect(beforeReset.opponent1?.id).toBe(matchById(data, 0).opponent2?.id);
expect(beforeReset.opponent1?.position).toBe(1); // Must be set.
data = Engine.resetMatchResults(data, 0).data; // First match of WB round 1
const afterReset = matchById(data, 3); // Determined opponent for LB round 1
expect(afterReset.opponent1?.id).toBeNull();
expect(afterReset.opponent1?.position).toBe(1); // It must stay.
});
test("should choose the correct previous and next matches based on losers ordering", () => {
let data = createResolved({
type: "double_elimination",
seeding: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16],
settings: {},
});
data = Engine.reportResult(data, {
matchId: 0,
winnerSide: "opponent1",
}).data; // WB 1.1
expect(
matchById(data, 15).opponent1?.id, // Determined opponent for first match of LB round 1 (natural ordering for losers)
).toBe(matchById(data, 0).opponent2?.id); // Loser of first match round 1
data = Engine.reportResult(data, {
matchId: 1,
winnerSide: "opponent1",
}).data; // WB 1.2
expect(
matchById(data, 15).opponent2?.id, // Determined opponent for first match of LB round 1 (natural ordering for losers)
).toBe(matchById(data, 1).opponent2?.id); // Loser of second match round 1
data = Engine.reportResult(data, {
matchId: 8,
winnerSide: "opponent1",
}).data; // WB 2.1
expect(
matchById(data, 20).opponent1?.id, // Determined opponent for first match of LB round 2
).toBe(matchById(data, 8).opponent2?.id); // Loser of first match round 2
for (const matchId of [
6, // WB 1.7
7, // WB 1.8
11, // WB 2.4
15, // LB 1.1
18, // LB 1.4
]) {
data = Engine.reportResult(data, {
matchId,
winnerSide: "opponent1",
}).data;
}
expect(Engine.matchStatus(data, 8)).toBe("COMPLETED"); // WB 2.1
});
test("should send the losers to the right LB matches in round 1", () => {
let data = createResolved({
type: "double_elimination",
seeding: [1, 2, 3, 4, 5, 6, 7, 8],
settings: {},
});
expect(matchById(data, 7).opponent1?.position).toBe(1);
expect(matchById(data, 7).opponent2?.position).toBe(2);
expect(matchById(data, 8).opponent1?.position).toBe(3);
expect(matchById(data, 8).opponent2?.position).toBe(4);
// Match of position 1.
data = Engine.reportResult(data, {
matchId: 0,
winnerSide: "opponent1", // Loser id: 8.
}).data;
expect(matchById(data, 7).opponent1?.id).toBe(8);
// Match of position 2.
data = Engine.reportResult(data, {
matchId: 1,
winnerSide: "opponent1", // Loser id: 5.
}).data;
expect(matchById(data, 7).opponent2?.id).toBe(5);
// Match of position 3.
data = Engine.reportResult(data, {
matchId: 2,
winnerSide: "opponent1", // Loser id: 7.
}).data;
expect(matchById(data, 8).opponent1?.id).toBe(7);
// Match of position 4.
data = Engine.reportResult(data, {
matchId: 3,
winnerSide: "opponent1", // Loser id: 6.
}).data;
expect(matchById(data, 8).opponent2?.id).toBe(6);
});
});
function matchById(data: BracketData, id: number) {
const found = data.match.find((match) => match.id === id);
if (!found) throw new Error(`Match ${id} not found`);
return found;
}

View File

@ -0,0 +1,59 @@
import type { BracketData, DroppedTeamsResult } from "../types";
import { Store } from "./store";
import { Propagator } from "./traversal";
/**
* Ends all unfinished matches involving dropped teams by awarding wins to
* their opponents. Ported from tournament-utils.server.ts. When both teams in
* a match have dropped, a random winner is picked.
*
* Matches that only gain a dropped opponent through propagation caused by this
* call are not ended (same as the old implementation, which iterated a
* snapshot); they are picked up by the next call.
*/
export function endDroppedTeamMatches(
data: BracketData,
droppedTeamIds: number[],
): DroppedTeamsResult {
const store = new Store(data);
const propagator = new Propagator(store);
const droppedTeamIdsSet = new Set(droppedTeamIds);
const endedMatchIds: number[] = [];
for (const match of data.match) {
if (!match.opponent1?.id || !match.opponent2?.id) continue;
if (match.winnerSide) continue;
const team1Dropped = droppedTeamIdsSet.has(match.opponent1.id);
const team2Dropped = droppedTeamIdsSet.has(match.opponent2.id);
if (!team1Dropped && !team2Dropped) continue;
const winnerTeamId = (() => {
if (team1Dropped && !team2Dropped) return match.opponent2.id;
if (!team1Dropped && team2Dropped) return match.opponent1.id;
return Math.random() < 0.5 ? match.opponent1.id : match.opponent2.id;
})();
const stored = store.matchById(match.id);
if (!stored) throw Error("Match not found.");
propagator.updateMatch(
stored,
{
winnerSide:
winnerTeamId === match.opponent1.id ? "opponent1" : "opponent2",
},
true,
);
endedMatchIds.push(match.id);
}
return {
data: store.data,
changedMatches: store.changedMatches(),
endedMatchIds,
};
}

View File

@ -0,0 +1,382 @@
import { defaultMinorOrdering, ordering } from "../create/seeding";
import { isMatchByeCompleted, isMatchCompleted } from "../status";
import type {
GroupType,
MatchData,
MatchResults,
MatchResultsInput,
SeedOrdering,
Side,
StageData,
StageType,
} from "../types";
export type SetNextOpponent = (
nextMatch: MatchData,
nextSide: Side,
match?: MatchData,
currentSide?: Side,
) => void;
/**
* Returns the winner side or `null` if no winner.
*
* @param match A match's results.
*/
export function getMatchResult(match: MatchResults): Side | null {
if (!isMatchCompleted(match)) return null;
if (match.winnerSide) return match.winnerSide;
// a BYE that has not been propagated yet
if (match.opponent1 === null && match.opponent2 !== null) return "opponent2";
if (match.opponent2 === null && match.opponent1 !== null) return "opponent1";
return null;
}
/**
* Gets the other side of a match.
*
* @param side The side that we don't want.
*/
export function getOtherSide(side: Side): Side {
return side === "opponent1" ? "opponent2" : "opponent1";
}
/**
* Indicates whether a match has at least one BYE or not.
*
* @param match A match's results.
*/
export function hasBye(match: MatchResults): boolean {
return match.opponent1 === null || match.opponent2 === null;
}
/**
* Updates a match results based on an input.
*
* @param stored A reference to what will be updated in the storage.
* @param scores Games won by each side. `undefined` keeps them, `null` clears them.
* @param winnerSide The resolved winner of the set, if any.
* @returns `true` if the match was completed or un-completed by the update.
*/
export function setMatchResults(
stored: MatchResults,
scores: MatchResultsInput["scores"],
winnerSide: Side | undefined,
): boolean {
const currentlyCompleted = isMatchCompleted(stored);
setScores(stored, scores);
if (winnerSide) {
stored.winnerSide = winnerSide;
return true;
}
if (currentlyCompleted) {
clearWinner(stored);
return true;
}
return false;
}
/**
* Clears the winner of a match, marking it as not completed.
*
* @param stored A reference to what will be updated in the storage.
*/
export function clearWinner(stored: MatchResults): void {
stored.winnerSide = null;
}
/**
* Completes a match that is decided by a BYE, marking the side that has an
* opponent as the winner. Does nothing to a match that is not decided by a BYE.
*
* @param match A reference to what will be updated in the storage.
*/
export function resolveByeWinner(match: MatchResults): void {
if (match.winnerSide) return;
if (!isMatchByeCompleted(match)) return;
if (match.opponent1 && !match.opponent2) {
match.winnerSide = "opponent1";
} else if (!match.opponent1 && match.opponent2) {
match.winnerSide = "opponent2";
}
}
/**
* Gets the side the winner of the current match will go to in the next match.
*
* @param matchNumber Number of the current match.
* @param roundNumber Number of the current round.
* @param roundCount Count of rounds.
* @param matchLocation Location of the current match.
*/
export function getNextSide(
matchNumber: number,
roundNumber: number,
roundCount: number,
matchLocation: GroupType,
): Side {
// The nextSide comes from the same bracket.
if (matchLocation === "loser_bracket" && roundNumber % 2 === 1)
return "opponent2";
// The nextSide comes from the loser bracket to the final group.
if (matchLocation === "loser_bracket" && roundNumber === roundCount)
return "opponent2";
return getSide(matchNumber);
}
/**
* Gets the side the winner of the current match in loser bracket will go in the next match.
*
* @param matchNumber Number of the match.
* @param nextMatch The next match.
* @param roundNumber Number of the current round.
*/
export function getNextSideLoserBracket(
matchNumber: number,
nextMatch: MatchData,
roundNumber: number,
): Side {
// The nextSide comes from the WB.
if (roundNumber > 1) return "opponent1";
// The nextSide comes from the WB round 1.
if (nextMatch.opponent1?.position === matchNumber) return "opponent1";
return "opponent2";
}
/**
* Sets an opponent in the next match he has to go.
*
* @param nextMatch A match which follows the current one.
* @param nextSide The side the opponent will be on in the next match.
* @param match The current match.
* @param currentSide The side the opponent is currently on.
*/
export function setNextOpponent(
nextMatch: MatchData,
nextSide: Side,
match?: MatchData,
currentSide?: Side,
): void {
nextMatch[nextSide] = match![currentSide!] && {
// Keep BYE.
id: getOpponentId(match!, currentSide!), // This implementation of SetNextOpponent always has those arguments.
position: nextMatch[nextSide]?.position, // Keep position.
};
nextMatch.winnerSide = null; // A match whose opponents changed can't keep its winner.
}
/**
* Resets an opponent in the match following the current one.
*
* @param nextMatch A match which follows the current one.
* @param nextSide The side the opponent will be on in the next match.
*/
export function resetNextOpponent(nextMatch: MatchData, nextSide: Side): void {
nextMatch[nextSide] = nextMatch[nextSide] && {
// Keep BYE.
id: null,
position: nextMatch[nextSide]?.position, // Keep position.
};
nextMatch.winnerSide = null; // A match whose opponents changed can't keep its winner.
}
/**
* Returns the real (because of loser ordering) number of a match in a loser bracket.
*
* @param participantCount The number of participants in a stage.
* @param roundNumber Number of the round.
* @param matchNumber Number of the match.
* @param method The method used for the round.
*/
export function findLoserMatchNumber(
participantCount: number,
roundNumber: number,
matchNumber: number,
method?: SeedOrdering,
): number {
const loserCount = getLoserRoundLoserCount(participantCount, roundNumber);
const losers = Array.from(Array(loserCount), (_, i) => i + 1);
const ordered = method ? ordering[method](losers) : losers;
const matchNumberLB = ordered.indexOf(matchNumber) + 1;
// For LB round 1, the list of losers is spread over the matches 2 by 2.
if (roundNumber === 1) return Math.ceil(matchNumberLB / 2);
return matchNumberLB;
}
/**
* Returns the ordering method of a round of a loser bracket.
*
* @param participantCount The number of participants in the stage.
* @param roundNumber Number of the round.
*/
export function getLoserOrdering(
participantCount: number,
roundNumber: number,
): SeedOrdering | undefined {
return defaultMinorOrdering[participantCount]?.[Math.floor(roundNumber / 2)];
}
/**
* Returns the match number of the corresponding match in the next round by dividing by two.
*
* @param matchNumber The current match number.
*/
export function getDiagonalMatchNumber(matchNumber: number): number {
return Math.ceil(matchNumber / 2);
}
/**
* Checks if a stage is a round-robin stage.
*
* @param stage The stage to check.
*/
export function isRoundRobin(stage: StageData): boolean {
return stage.type === "round_robin";
}
/**
* Checks if a stage is a swiss stage.
*
* @param stage The stage to check.
*/
export function isSwiss(stage: StageData): boolean {
return stage.type === "swiss";
}
/**
* Returns the type of group the match is located into.
*
* @param stageType Type of the stage.
* @param groupNumber Number of the group.
*/
export function getMatchLocation(
stageType: StageType,
groupNumber: number,
): GroupType {
if (isWinnerBracket(stageType, groupNumber)) return "winner_bracket";
if (isLoserBracket(stageType, groupNumber)) return "loser_bracket";
if (isFinalGroup(stageType, groupNumber)) return "final_group";
return "single_bracket";
}
/**
* Gets the side where the winner of the given match will go in the next match.
*
* @param matchNumber Number of the match.
*/
function getSide(matchNumber: number): Side {
return matchNumber % 2 === 1 ? "opponent1" : "opponent2";
}
/**
* Updates the scores of a match.
*
* @param stored A reference to what will be updated in the storage.
* @param scores Games won by each side. `undefined` keeps them, `null` clears them.
*/
function setScores(
stored: MatchResults,
scores: MatchResultsInput["scores"],
): void {
if (scores === undefined) return;
if (stored.opponent1) stored.opponent1.score = scores?.[0];
if (stored.opponent2) stored.opponent2.score = scores?.[1];
}
/**
* Gets the id of the opponent at the given side of the given match.
*
* @param match The match to get the opponent from.
* @param side The side where to get the opponent from.
*/
function getOpponentId(match: MatchResults, side: Side): number | null {
const opponent = match[side];
return opponent?.id ?? null;
}
/**
* Returns the count of matches in a round of a loser bracket.
*
* @param participantCount The number of participants in a stage.
* @param roundNumber Number of the round.
*/
function getLoserRoundMatchCount(
participantCount: number,
roundNumber: number,
): number {
const roundPairIndex = Math.ceil(roundNumber / 2) - 1;
const roundPairCount = Math.log2(participantCount) - 1;
const matchCount = 2 ** (roundPairCount - roundPairIndex - 1);
return matchCount;
}
/**
* Returns the count of losers in a round of a loser bracket.
*
* @param participantCount The number of participants in a stage.
* @param roundNumber Number of the round.
*/
function getLoserRoundLoserCount(
participantCount: number,
roundNumber: number,
): number {
const matchCount = getLoserRoundMatchCount(participantCount, roundNumber);
// Two per match for LB round 1 (losers coming from WB round 1).
if (roundNumber === 1) return matchCount * 2;
return matchCount; // One per match for LB minor rounds.
}
/**
* Checks if a group is a winner bracket.
*
* It's not always the opposite of `isLoserBracket()`: it could be the only bracket of a single elimination stage.
*
* @param stageType Type of the stage.
* @param groupNumber Number of the group.
*/
function isWinnerBracket(stageType: StageType, groupNumber: number): boolean {
return stageType === "double_elimination" && groupNumber === 1;
}
/**
* Checks if a group is a loser bracket.
*
* @param stageType Type of the stage.
* @param groupNumber Number of the group.
*/
function isLoserBracket(stageType: StageType, groupNumber: number): boolean {
return stageType === "double_elimination" && groupNumber === 2;
}
/**
* Checks if a group is a final group (consolation final or grand final).
*
* @param stageType Type of the stage.
* @param groupNumber Number of the group.
*/
function isFinalGroup(stageType: StageType, groupNumber: number): boolean {
return (
(stageType === "single_elimination" && groupNumber === 2) ||
(stageType === "double_elimination" && groupNumber === 3)
);
}

View File

@ -0,0 +1,25 @@
import invariant from "~/utils/invariant";
import type { BracketData, EngineResult, ReportResultInput } from "../types";
import { Store } from "./store";
import { Propagator } from "./traversal";
/**
* Applies a result to a match and propagates:
* winner/loser advancement (SE/DE), BYE cascades, grand final + bracket reset,
* next-round unlocking (RR), no downstream propagation for swiss/RR completed
* matches. Throws on locked matches.
*/
export function reportResult(
data: BracketData,
input: ReportResultInput,
): EngineResult {
const store = new Store(data);
const propagator = new Propagator(store);
const stored = store.matchById(input.matchId);
invariant(stored, "Match not found");
propagator.updateMatch(stored, input);
return { data: store.data, changedMatches: store.changedMatches() };
}

View File

@ -0,0 +1,64 @@
import {
isMatchByeCompleted,
isMatchCompleted,
isMatchStarted,
} from "../status";
import type { BracketData, EngineResult } from "../types";
import * as helpers from "./helpers";
import { Store } from "./store";
import { Propagator } from "./traversal";
/**
* Clears a match's results and rolls back everything that was propagated
* from it. 1:1 with the old reset.ts, including the swiss/round_robin early
* return.
*/
export function resetMatchResults(
data: BracketData,
matchId: number,
): EngineResult {
const store = new Store(data);
const propagator = new Propagator(store);
const stored = store.matchById(matchId);
if (!stored) throw Error("Match not found.");
const stage = store.stageById(stored.stageId);
if (!stage) throw Error("Stage not found.");
const group = store.groupById(stored.groupId);
if (!group) throw Error("Group not found.");
const { roundNumber, roundCount } = propagator.getRoundPositionalInfo(
stored.roundId,
);
const matchLocation = helpers.getMatchLocation(stage.type, group.number);
const nextMatches =
stage.type !== "round_robin" && stage.type !== "swiss"
? propagator.getNextMatches(
stored,
matchLocation,
stage,
roundNumber,
roundCount,
)
: [];
if (
nextMatches.some(
(match) =>
match &&
(isMatchStarted(match) || isMatchCompleted(match)) &&
!isMatchByeCompleted(match),
)
)
throw Error("The match is locked.");
helpers.clearWinner(stored);
store.markMatchChanged(stored);
if (!helpers.isRoundRobin(stage) && !helpers.isSwiss(stage))
propagator.updateRelatedMatches(stored);
return { data: store.data, changedMatches: store.changedMatches() };
}

View File

@ -0,0 +1,245 @@
import { beforeEach, describe, expect, test } from "vitest";
import { createResolved } from "../create";
import * as Engine from "../index";
import type { BracketData } from "../types";
describe("Update scores in a round-robin stage", () => {
let data: BracketData;
beforeEach(() => {
data = createResolved({
type: "round_robin",
seeding: [1, 2, 3, 4],
settings: { groupCount: 1 },
});
});
test("should set all the scores", () => {
const results: Engine.ReportResultInput[] = [
{
matchId: 0,
scores: [16, 9], // AQUELLEHEURE?!
winnerSide: "opponent1", // POCEBLO
},
{
matchId: 1,
scores: [3, 16], // Ballec Squad
winnerSide: "opponent2", // twitch.tv/mrs_fly
},
{
matchId: 2,
scores: [16, 0], // AQUELLEHEURE?!
winnerSide: "opponent1", // twitch.tv/mrs_fly
},
{
matchId: 3,
scores: [16, 2], // Ballec Squad
winnerSide: "opponent1", // POCEBLO
},
{
matchId: 4,
scores: [16, 12], // AQUELLEHEURE?!
winnerSide: "opponent1", // Ballec Squad
},
{
matchId: 5,
scores: [4, 16], // twitch.tv/mrs_fly
winnerSide: "opponent2", // POCEBLO
},
];
for (const result of results) {
data = Engine.reportResult(data, result).data;
}
for (const result of results) {
expect(matchById(data, result.matchId).winnerSide).toBe(
result.winnerSide,
);
}
});
test("should unlock next round matches as soon as both participants are ready", () => {
// Round robin with 4 teams: [1, 2, 3, 4]
// Round 1: Match 0 (1 vs 2), Match 1 (3 vs 4)
// Round 2: Match 2 (1 vs 3), Match 3 (2 vs 4)
// Round 3: Match 4 (1 vs 4), Match 5 (2 vs 3)
// Initially, only round 1 matches should be ready
expect(Engine.matchStatus(data, 0)).toBe("STARTED"); // Ready (1 vs 2)
expect(Engine.matchStatus(data, 1)).toBe("STARTED"); // Ready (3 vs 4)
expect(Engine.matchStatus(data, 2)).toBe("PENDING"); // Locked (1 vs 3)
expect(Engine.matchStatus(data, 3)).toBe("PENDING"); // Locked (2 vs 4)
// Complete first match of round 1 (1 vs 2)
data = Engine.reportResult(data, {
matchId: 0,
scores: [16, 9], // Team 2 loses
winnerSide: "opponent1", // Team 1 wins
}).data;
// Round 2 Match 1 (1 vs 3) should still be locked because team 3 hasn't finished
// Round 2 Match 2 (2 vs 4) should still be locked because team 4 hasn't finished
expect(Engine.matchStatus(data, 2)).toBe("PENDING"); // Still Locked
expect(Engine.matchStatus(data, 3)).toBe("PENDING"); // Still Locked
// Complete second match of round 1 (3 vs 4)
data = Engine.reportResult(data, {
matchId: 1,
scores: [3, 16], // Team 3 loses
winnerSide: "opponent2", // Team 4 wins
}).data;
// Now both matches in round 2 should be unlocked
// Match 2 (1 vs 3): both team 1 and team 3 have finished round 1
// Match 3 (2 vs 4): both team 2 and team 4 have finished round 1
expect(Engine.matchStatus(data, 2)).toBe("STARTED"); // Ready
expect(Engine.matchStatus(data, 3)).toBe("STARTED"); // Ready
});
test("should lock the next round again if a result of the previous round is reset", () => {
data = Engine.reportResult(data, {
matchId: 0,
scores: [16, 9],
winnerSide: "opponent1",
}).data;
data = Engine.reportResult(data, {
matchId: 1,
scores: [3, 16],
winnerSide: "opponent2",
}).data;
expect(Engine.matchStatus(data, 2)).toBe("STARTED");
data = Engine.resetMatchResults(data, 0).data;
expect(Engine.matchStatus(data, 2)).toBe("PENDING");
});
test("should keep a started next round match playable if a result of the previous round is reset (issue #2690)", () => {
data = Engine.reportResult(data, {
matchId: 0,
scores: [16, 9],
winnerSide: "opponent1",
}).data;
data = Engine.reportResult(data, {
matchId: 1,
scores: [3, 16],
winnerSide: "opponent2",
}).data;
// team 1 and team 3 start playing their round 2 match
data = Engine.reportResult(data, {
matchId: 2,
scores: [1, 0],
}).data;
data = Engine.resetMatchResults(data, 0).data;
expect(Engine.matchStatus(data, 2)).toBe("STARTED");
expect(() =>
Engine.reportResult(data, {
matchId: 2,
scores: [2, 0],
winnerSide: "opponent1",
}),
).not.toThrow();
});
test("should leave every match Ready when independentRounds is set", () => {
data = createResolved({
type: "round_robin",
seeding: [1, 2, 3, 4],
settings: { groupCount: 1, independentRounds: true },
});
for (const match of data.match) {
expect(Engine.matchStatus(data, match.id)).toBe("STARTED");
}
// reporting a round-2 match before round-1 finishes must not throw
expect(() =>
Engine.reportResult(data, {
matchId: 2,
scores: [16, 4],
winnerSide: "opponent1",
}),
).not.toThrow();
});
test("should let the only real match be played in a group with fewer teams than slots", () => {
// Group sized for 3 but only 2 teams placed (the 3rd slot is a BYE).
// The two real teams only meet in round 3, preceded by two BYE rounds
// that can never be reported. The real match must still be playable.
data = createResolved({
type: "round_robin",
seeding: [1, 2, null],
settings: { groupCount: 1 },
});
const realMatch = data.match.find(
(match) => match.opponent1?.id && match.opponent2?.id,
)!;
expect(Engine.matchStatus(data, realMatch.id)).toBe("STARTED");
expect(() =>
Engine.reportResult(data, {
matchId: realMatch.id,
scores: [16, 9],
winnerSide: "opponent1",
}),
).not.toThrow();
});
test("should unlock next round matches with BYE participants", () => {
// Create a round robin with 3 teams (odd number creates rounds where one team doesn't play)
data = createResolved({
type: "round_robin",
seeding: [1, 2, 3],
settings: { groupCount: 1 },
});
// With 3 teams, the rounds look like:
// Round 1: Match (teams 3 vs 2) - Team 1 doesn't play
// Round 2: Match (teams 1 vs 3) - Team 2 doesn't play
// Round 3: Match (teams 2 vs 1) - Team 3 doesn't play
// Find the actual match (not BYE vs BYE which doesn't exist)
const round1RealMatch = data.match.find(
(match) =>
match.roundId === data.round[0].id &&
match.opponent1 &&
match.opponent2,
)!;
const round2RealMatch = data.match.find(
(match) =>
match.roundId === data.round[1].id &&
match.opponent1 &&
match.opponent2,
)!;
expect(Engine.matchStatus(data, round1RealMatch.id)).toBe("STARTED");
expect(Engine.matchStatus(data, round2RealMatch.id)).toBe("PENDING"); // initially
// Complete the only real match in round 1 (teams 3 vs 2)
// Team 1 didn't play in round 1
data = Engine.reportResult(data, {
matchId: round1RealMatch.id,
scores: [16, 9],
winnerSide: "opponent1",
}).data;
// The real match in round 2 (teams 1 vs 3) should now be unlocked
// because team 1 didn't play in round 1 (considered ready)
// and team 3 just finished their match
expect(Engine.matchStatus(data, round2RealMatch.id)).toBe("STARTED");
});
});
function matchById(data: BracketData, id: number) {
const found = data.match.find((match) => match.id === id);
if (!found) throw new Error(`Match ${id} not found`);
return found;
}

View File

@ -0,0 +1,132 @@
import { beforeEach, describe, expect, test } from "vitest";
import type { TournamentRoundMaps } from "~/db/tables";
import { create } from "../create";
import * as Engine from "../index";
import type { BracketData, MatchData } from "../types";
const TEAM_ONE = 1;
const TEAM_TWO = 2;
describe("Set results in a bracket with map info", () => {
let data: BracketData;
beforeEach(() => {
data = bracketWithMaps({ count: 3, type: "BEST_OF" });
});
test("should resolve the winner from the scores once the set is over", () => {
data = Engine.reportGameResult(data, {
matchId: 0,
winnerTeamId: TEAM_ONE,
}).data;
expect(matchById(data, 0).winnerSide).toBeFalsy();
const reported = Engine.reportGameResult(data, {
matchId: 0,
winnerTeamId: TEAM_ONE,
});
data = reported.data;
expect(reported.setOver).toBe(true);
expect(matchById(data, 0).winnerSide).toBe("opponent1");
});
test("should resolve the winner when only the scores are reported", () => {
data = Engine.reportResult(data, { matchId: 0, scores: [0, 2] }).data;
expect(matchById(data, 0).winnerSide).toBe("opponent2");
});
test("should not resolve a winner for a play all set that ended in a tie", () => {
data = bracketWithMaps({ count: 2, type: "PLAY_ALL" });
data = Engine.reportResult(data, { matchId: 0, scores: [1, 1] }).data;
expect(matchById(data, 0).winnerSide).toBeFalsy();
});
test("should end the set early with the given winner, keeping the scores", () => {
data = Engine.reportGameResult(data, {
matchId: 0,
winnerTeamId: TEAM_TWO,
}).data;
data = Engine.endSet(data, { matchId: 0, winnerTeamId: TEAM_TWO }).data;
const after = matchById(data, 0);
expect(after.winnerSide).toBe("opponent2");
expect(after.opponent1?.score).toBe(0);
expect(after.opponent2?.score).toBe(1);
});
test("should clear the winner when a game is undone", () => {
for (const _ of [1, 2]) {
data = Engine.reportGameResult(data, {
matchId: 0,
winnerTeamId: TEAM_ONE,
}).data;
}
data = Engine.undoGameResult(data, {
matchId: 0,
lastGameWinnerTeamId: TEAM_ONE,
}).data;
expect(matchById(data, 0).winnerSide).toBeFalsy();
expect(matchById(data, 0).opponent1?.score).toBe(1);
});
test("should clear the winner when the match is reopened", () => {
for (const _ of [1, 2]) {
data = Engine.reportGameResult(data, {
matchId: 0,
winnerTeamId: TEAM_ONE,
}).data;
}
const reopened = Engine.reopenMatch(data, 0);
data = reopened.data;
expect(reopened.endedEarly).toBe(false);
expect(matchById(data, 0).winnerSide).toBeFalsy();
expect(matchById(data, 0).opponent1?.score).toBe(1);
});
test("should clear the winner when a set that ended early is reopened", () => {
data = Engine.reportGameResult(data, {
matchId: 0,
winnerTeamId: TEAM_ONE,
}).data;
data = Engine.endSet(data, { matchId: 0, winnerTeamId: TEAM_ONE }).data;
const reopened = Engine.reopenMatch(data, 0);
data = reopened.data;
expect(reopened.endedEarly).toBe(true);
expect(matchById(data, 0).winnerSide).toBeFalsy();
expect(matchById(data, 0).opponent1?.score).toBe(1);
});
});
function bracketWithMaps(maps: Pick<TournamentRoundMaps, "count" | "type">) {
const input = {
type: "single_elimination" as const,
seeding: [TEAM_ONE, TEAM_TWO],
settings: null,
};
return create({
...input,
maps: create(input).round.map((round) => ({
roundId: round.id,
...maps,
})),
});
}
function matchById(data: BracketData, id: number): MatchData {
const found = data.match.find((match) => match.id === id);
if (!found) throw new Error(`Match ${id} not found`);
return found;
}

View File

@ -0,0 +1,142 @@
import invariant from "~/utils/invariant";
import { isSetOverByScore, matchEndedEarly } from "../status";
import type { BracketData, EngineResult, MatchData, Side } from "../types";
import { reportResult } from "./report-result";
import { resetMatchResults } from "./reset-result";
const SIDES = ["opponent1", "opponent2"] as const satisfies Side[];
/**
* Records one game win for the given team, resolving from the round's map
* info (count & best of / play all) whether the set is now over. When it is,
* the win/loss results are set and propagated (advancements, unlocks etc.).
*/
export function reportGameResult(
data: BracketData,
input: { matchId: number; winnerTeamId: number },
): EngineResult & { setOver: boolean } {
const { match, maps } = findMatchWithMaps(data, input.matchId);
const scores = currentScores(match);
scores[sideOfTeam(match, input.winnerTeamId)]++;
const reported = reportResult(data, { matchId: input.matchId, scores });
return {
...reported,
setOver: isSetOverByScore({
scores,
count: maps.count,
countType: maps.type,
}),
};
}
/**
* Removes the last played game from a match. If the match goes back to no
* games played, the whole match result is reset, rolling back anything that
* was propagated from it.
*/
export function undoGameResult(
data: BracketData,
input: { matchId: number; lastGameWinnerTeamId: number },
): EngineResult {
const match = findMatch(data, input.matchId);
const scores = currentScores(match);
const side = sideOfTeam(match, input.lastGameWinnerTeamId);
invariant(scores[side] > 0, "No game to undo for the given team");
scores[side]--;
const shouldReset = scores[0] === 0 && scores[1] === 0;
const reported = reportResult(data, {
matchId: input.matchId,
scores: shouldReset ? null : scores,
});
if (!shouldReset) return reported;
const reset = resetMatchResults(reported.data, input.matchId);
return {
data: reset.data,
changedMatches: [...reported.changedMatches, ...reset.changedMatches],
};
}
/**
* Ends the set early with the given team as the winner, keeping the current
* scores as they are (an organizer action; e.g. the opponent can not play out
* the rest of the set).
*/
export function endSet(
data: BracketData,
input: { matchId: number; winnerTeamId: number },
): EngineResult {
const match = findMatch(data, input.matchId);
return reportResult(data, {
matchId: input.matchId,
winnerSide: SIDES[sideOfTeam(match, input.winnerTeamId)],
});
}
/**
* Reopens a completed match for further reporting, clearing the set result.
* Resolves from the round's map info whether the set had been force-ended
* early: if it was, the scores are kept as is; otherwise the deciding game's
* score is removed (the caller is expected to delete the corresponding game
* result row, see `endedEarly` in the return value).
*/
export function reopenMatch(
data: BracketData,
matchId: number,
): EngineResult & { endedEarly: boolean } {
const { match, maps } = findMatchWithMaps(data, matchId);
invariant(match.winnerSide, "Match has no result to reopen");
const endedEarly = matchEndedEarly({
opponentOne: match.opponent1,
opponentTwo: match.opponent2,
winnerSide: match.winnerSide,
count: maps.count,
countType: maps.type,
});
const scores = currentScores(match);
if (!endedEarly) {
invariant(scores[0] !== scores[1], "Scores are equal");
scores[SIDES.indexOf(match.winnerSide)]--;
}
const reported = reportResult(data, { matchId, scores });
return { ...reported, endedEarly };
}
function findMatch(data: BracketData, matchId: number): MatchData {
const match = data.match.find((match) => match.id === matchId);
invariant(match, "Match not found");
return match;
}
function findMatchWithMaps(data: BracketData, matchId: number) {
const match = findMatch(data, matchId);
const round = data.round.find((round) => round.id === match.roundId);
invariant(round?.maps, "Round of the match has no maps");
return { match, maps: round.maps };
}
function currentScores(match: MatchData): [number, number] {
return [match.opponent1?.score ?? 0, match.opponent2?.score ?? 0];
}
function sideOfTeam(match: MatchData, teamId: number): 0 | 1 {
if (match.opponent1?.id === teamId) return 0;
if (match.opponent2?.id === teamId) return 1;
throw Error(`Team id ${teamId} is not in the match`);
}

View File

@ -0,0 +1,94 @@
import { describe, expect, test } from "vitest";
import { createResolved } from "../create";
import * as Engine from "../index";
import type { BracketData } from "../types";
describe("Previous and next match update", () => {
test("should determine matches in consolation final", () => {
let data = createResolved({
type: "single_elimination",
seeding: [1, 2, 3, 4],
settings: { consolationFinal: true },
});
data = Engine.reportResult(data, {
matchId: 0, // First match of round 1
scores: [16, 12],
winnerSide: "opponent1",
}).data;
data = Engine.reportResult(data, {
matchId: 1, // Second match of round 1
scores: [13, 16],
winnerSide: "opponent2",
}).data;
expect(matchById(data, 3).opponent1?.id).toBe(
matchById(data, 0).opponent2?.id,
);
expect(matchById(data, 3).opponent2?.id).toBe(
matchById(data, 1).opponent1?.id,
);
expect(Engine.matchStatus(data, 2)).toBe("STARTED");
expect(Engine.matchStatus(data, 3)).toBe("STARTED");
});
test("should play both the final and consolation final in parallel", () => {
let data = createResolved({
type: "single_elimination",
seeding: [1, 2, 3, 4],
settings: { consolationFinal: true },
});
data = Engine.reportResult(data, {
matchId: 0, // First match of round 1
scores: [16, 12],
winnerSide: "opponent1",
}).data;
data = Engine.reportResult(data, {
matchId: 1, // Second match of round 1
scores: [13, 16],
winnerSide: "opponent2",
}).data;
data = Engine.reportResult(data, {
matchId: 2, // Final
scores: [12, 9],
}).data;
expect(Engine.matchStatus(data, 2)).toBe("STARTED");
expect(Engine.matchStatus(data, 3)).toBe("STARTED");
data = Engine.reportResult(data, {
matchId: 3, // Consolation final
scores: [12, 9],
}).data;
expect(Engine.matchStatus(data, 2)).toBe("STARTED");
expect(Engine.matchStatus(data, 3)).toBe("STARTED");
data = Engine.reportResult(data, {
matchId: 3, // Consolation final
scores: [16, 9],
winnerSide: "opponent1",
}).data;
expect(Engine.matchStatus(data, 2)).toBe("STARTED");
expect(() =>
Engine.reportResult(data, {
matchId: 2, // Final
scores: [16, 9],
winnerSide: "opponent1",
}),
).not.toThrow();
});
});
function matchById(data: BracketData, id: number) {
const found = data.match.find((match) => match.id === id);
if (!found) throw new Error(`Match ${id} not found`);
return found;
}

View File

@ -0,0 +1,120 @@
import type {
BracketData,
GroupData,
MatchData,
RoundData,
StageData,
} from "../types";
/**
* A working copy of BracketData. The input is cloned once on construction and
* every row handed out afterwards is a live reference into that copy, so
* mutating a row is the write. Callers report the match rows they mutated so
* that a delta can be emitted.
*/
export class Store {
readonly data: BracketData;
private readonly stagesById: Map<number, StageData>;
private readonly groupsById: Map<number, GroupData>;
private readonly roundsById: Map<number, RoundData>;
private readonly matchesById: Map<number, MatchData>;
private readonly groupsByStageId: Map<number, GroupData[]>;
private readonly roundsByGroupId: Map<number, RoundData[]>;
private readonly matchesByRoundId: Map<number, MatchData[]>;
private readonly changedMatchIds = new Set<number>();
constructor(data: BracketData) {
this.data = structuredClone(data);
this.stagesById = indexById(this.data.stage);
this.groupsById = indexById(this.data.group);
this.roundsById = indexById(this.data.round);
this.matchesById = indexById(this.data.match);
this.groupsByStageId = groupByKey(
this.data.group,
(group) => group.stageId,
);
this.roundsByGroupId = groupByKey(
this.data.round,
(round) => round.groupId,
);
this.matchesByRoundId = groupByKey(
this.data.match,
(match) => match.roundId,
);
}
stageById(id: number): StageData | null {
return this.stagesById.get(id) ?? null;
}
groupById(id: number): GroupData | null {
return this.groupsById.get(id) ?? null;
}
roundById(id: number): RoundData | null {
return this.roundsById.get(id) ?? null;
}
matchById(id: number): MatchData | null {
return this.matchesById.get(id) ?? null;
}
groupByNumber(stageId: number, groupNumber: number): GroupData | null {
const groups = this.groupsByStageId.get(stageId);
return groups?.find((group) => group.number === groupNumber) ?? null;
}
roundByNumber(groupId: number, roundNumber: number): RoundData | null {
const rounds = this.roundsByGroupId.get(groupId);
return rounds?.find((round) => round.number === roundNumber) ?? null;
}
matchByNumber(roundId: number, matchNumber: number): MatchData | null {
const matches = this.matchesByRoundId.get(roundId);
return matches?.find((match) => match.number === matchNumber) ?? null;
}
roundCountInGroup(groupId: number): number {
return this.roundsByGroupId.get(groupId)?.length ?? 0;
}
matchCountInRound(roundId: number): number {
return this.matchesByRoundId.get(roundId)?.length ?? 0;
}
/** Records that a match row of this store was mutated. */
markMatchChanged(match: MatchData): void {
if (this.matchesById.get(match.id) !== match)
throw Error("Match is not a row of this store.");
this.changedMatchIds.add(match.id);
}
/** Returns the final version of every match row that was written during this operation. */
changedMatches(): MatchData[] {
return this.data.match.filter((match) =>
this.changedMatchIds.has(match.id),
);
}
}
function indexById<T extends { id: number }>(rows: T[]): Map<number, T> {
return new Map(rows.map((row) => [row.id, row]));
}
function groupByKey<T>(rows: T[], key: (row: T) => number): Map<number, T[]> {
const result = new Map<number, T[]>();
for (const row of rows) {
const existing = result.get(key(row));
if (existing) {
existing.push(row);
} else {
result.set(key(row), [row]);
}
}
return result;
}

View File

@ -0,0 +1,612 @@
import { matchStatus, winnerSideByScore } from "../status";
import type {
GroupData,
GroupType,
MatchData,
MatchResultsInput,
Side,
StageData,
StageType,
} from "../types";
import type { SetNextOpponent } from "./helpers";
import * as helpers from "./helpers";
import type { Store } from "./store";
interface RoundPositionalInfo {
roundNumber: number;
roundCount: number;
}
/**
* Resolves the matches following another match and applies result propagation
* to them. Port of the old base/getter.ts + base/updater.ts, mutating the rows
* of a Store instead of writing to storage.
*/
export class Propagator {
readonly store: Store;
constructor(store: Store) {
this.store = store;
}
/* ------------------------------------------------------------------ */
/* Updater (base/updater.ts) */
/* ------------------------------------------------------------------ */
/**
* Propagates the results of a match to the matches following it.
*
* @param match A match.
*/
updateRelatedMatches(match: MatchData): void {
const { roundNumber, roundCount } = this.getRoundPositionalInfo(
match.roundId,
);
const stage = this.store.stageById(match.stageId);
if (!stage) throw Error("Stage not found.");
const group = this.store.groupById(match.groupId);
if (!group) throw Error("Group not found.");
const matchLocation = helpers.getMatchLocation(stage.type, group.number);
this.updateNext(match, matchLocation, stage, roundNumber, roundCount);
}
/**
* Updates a match based on a reported result.
*
* @param stored The match row of the store that is mutated.
* @param input Input of the update.
* @param force Whether to force update matches that can't be played yet.
*/
updateMatch(
stored: MatchData,
input: MatchResultsInput,
force?: boolean,
): void {
if (!force && matchStatus(this.store.data, stored.id) === "PENDING")
throw Error("The match is locked.");
const stage = this.store.stageById(stored.stageId);
if (!stage) throw Error("Stage not found.");
const resultChanged = helpers.setMatchResults(
stored,
input.scores,
input.winnerSide ?? this.winnerSideAfter(stored, input.scores),
);
this.store.markMatchChanged(stored);
// Don't propagate if it's a simple score update.
if (!resultChanged) return;
if (
stage.type === "single_elimination" ||
stage.type === "double_elimination"
) {
this.updateRelatedMatches(stored);
}
}
/**
* The winner the scores decide once the input has been applied. `undefined`
* when the set is not over or the round has no map count to decide it by
* (a bracket created without map lists).
*/
private winnerSideAfter(
stored: MatchData,
scores: MatchResultsInput["scores"],
): Side | undefined {
const maps = this.store.roundById(stored.roundId)?.maps;
if (!maps) return undefined;
return winnerSideByScore({
scores: scoresAfter(stored, scores),
count: maps.count,
countType: maps.type,
});
}
/**
* Updates the match(es) following the current match based on this match results.
*
* @param match Input of the update.
* @param matchLocation Location of the current match.
* @param stage The parent stage.
* @param roundNumber Number of the round.
* @param roundCount Count of rounds.
*/
private updateNext(
match: MatchData,
matchLocation: GroupType,
stage: StageData,
roundNumber: number,
roundCount: number,
): void {
const nextMatches = this.getNextMatches(
match,
matchLocation,
stage,
roundNumber,
roundCount,
);
if (nextMatches.length === 0) {
return;
}
const winnerSide = helpers.getMatchResult(match);
if (winnerSide)
this.applyToNextMatches(
helpers.setNextOpponent,
match,
matchLocation,
roundNumber,
roundCount,
nextMatches,
winnerSide,
);
else
this.applyToNextMatches(
helpers.resetNextOpponent,
match,
matchLocation,
roundNumber,
roundCount,
nextMatches,
);
}
/**
* Applies a SetNextOpponent function to matches following the current match.
*
* @param setNextOpponent The SetNextOpponent function.
* @param match The current match.
* @param matchLocation Location of the current match.
* @param roundNumber Number of the current round.
* @param roundCount Count of rounds.
* @param nextMatches The matches following the current match.
* @param winnerSide Side of the winner in the current match.
*/
private applyToNextMatches(
setNextOpponent: SetNextOpponent,
match: MatchData,
matchLocation: GroupType,
roundNumber: number,
roundCount: number,
nextMatches: (MatchData | null)[],
winnerSide?: Side,
): void {
if (matchLocation === "final_group") {
if (!nextMatches[0]) throw Error("First next match is null.");
setNextOpponent(nextMatches[0], "opponent1", match, "opponent1");
setNextOpponent(nextMatches[0], "opponent2", match, "opponent2");
this.store.markMatchChanged(nextMatches[0]);
return;
}
const nextSide = helpers.getNextSide(
match.number,
roundNumber,
roundCount,
matchLocation,
);
if (nextMatches[0]) {
setNextOpponent(nextMatches[0], nextSide, match, winnerSide);
this.propagateByeWinners(nextMatches[0]);
}
if (nextMatches.length !== 2) return;
if (!nextMatches[1]) throw Error("Second next match is null.");
// The second match is either the consolation final (single elimination) or a loser bracket match (double elimination).
if (matchLocation === "single_bracket") {
setNextOpponent(
nextMatches[1],
nextSide,
match,
winnerSide && helpers.getOtherSide(winnerSide),
);
this.store.markMatchChanged(nextMatches[1]);
} else {
const nextSideLB = helpers.getNextSideLoserBracket(
match.number,
nextMatches[1],
roundNumber,
);
setNextOpponent(
nextMatches[1],
nextSideLB,
match,
winnerSide && helpers.getOtherSide(winnerSide),
);
this.propagateByeWinners(nextMatches[1]);
}
}
/**
* Propagates winner against BYEs in related matches.
*
* @param match The current match.
*/
propagateByeWinners(match: MatchData): void {
helpers.resolveByeWinner(match); // BYE propagation is only in non round-robin stages.
this.store.markMatchChanged(match);
if (helpers.hasBye(match)) this.updateRelatedMatches(match);
}
/* ------------------------------------------------------------------ */
/* Getter (base/getter.ts) */
/* ------------------------------------------------------------------ */
/**
* Gets the positional information (number in group and total number of rounds in group) of a round based on its id.
*
* @param roundId ID of the round.
*/
getRoundPositionalInfo(roundId: number): RoundPositionalInfo {
const round = this.store.roundById(roundId);
if (!round) throw Error("Round not found.");
return {
roundNumber: round.number,
roundCount: this.store.roundCountInGroup(round.groupId),
};
}
/**
* Gets the match(es) where the opponents of the current match will go just after.
*
* @param match The current match.
* @param matchLocation Location of the current match.
* @param stage The parent stage.
* @param roundNumber The number of the current round.
* @param roundCount Count of rounds.
*/
getNextMatches(
match: MatchData,
matchLocation: GroupType,
stage: StageData,
roundNumber: number,
roundCount: number,
): (MatchData | null)[] {
switch (matchLocation) {
case "single_bracket":
return this.getNextMatchesUpperBracket(
match,
stage.type,
roundNumber,
roundCount,
);
case "winner_bracket":
return this.getNextMatchesWB(match, stage, roundNumber, roundCount);
case "loser_bracket":
return this.getNextMatchesLB(
match,
stage.type,
roundNumber,
roundCount,
);
case "final_group":
return this.getNextMatchesFinal(match, roundNumber, roundCount);
default:
throw Error("Unknown bracket kind.");
}
}
/**
* Gets the match(es) where the opponents of the current match of winner bracket will go just after.
*
* @param match The current match.
* @param stage The parent stage.
* @param roundNumber The number of the current round.
* @param roundCount Count of rounds.
*/
private getNextMatchesWB(
match: MatchData,
stage: StageData,
roundNumber: number,
roundCount: number,
): (MatchData | null)[] {
const loserBracket = this.getLoserBracket(match.stageId);
if (loserBracket === null)
// Only one match in the stage, there is no loser bracket.
return [];
const roundNumberLB = roundNumber > 1 ? (roundNumber - 1) * 2 : 1;
const participantCount = this.participantCount(match.stageId);
const method = helpers.getLoserOrdering(participantCount, roundNumberLB);
const actualMatchNumberLB = helpers.findLoserMatchNumber(
participantCount,
roundNumberLB,
match.number,
method,
);
return [
...this.getNextMatchesUpperBracket(
match,
stage.type,
roundNumber,
roundCount,
),
this.findMatch(loserBracket.id, roundNumberLB, actualMatchNumberLB),
];
}
/**
* Gets the match(es) where the opponents of the current match of an upper bracket will go just after.
*
* @param match The current match.
* @param stageType Type of the stage.
* @param roundNumber The number of the current round.
* @param roundCount Count of rounds.
*/
private getNextMatchesUpperBracket(
match: MatchData,
stageType: StageType,
roundNumber: number,
roundCount: number,
): (MatchData | null)[] {
if (stageType === "single_elimination")
return this.getNextMatchesUpperBracketSingleElimination(
match,
stageType,
roundNumber,
roundCount,
);
if (stageType === "double_elimination" && roundNumber === roundCount)
return [this.getFirstMatchFinal(match, stageType)];
return [this.getDiagonalMatch(match.groupId, roundNumber, match.number)];
}
/**
* Gets the match(es) where the opponents of the current match of the unique bracket of a single elimination will go just after.
*
* @param match The current match.
* @param stageType Type of the stage.
* @param roundNumber The number of the current round.
* @param roundCount Count of rounds.
*/
private getNextMatchesUpperBracketSingleElimination(
match: MatchData,
stageType: StageType,
roundNumber: number,
roundCount: number,
): MatchData[] {
if (roundNumber === roundCount - 1) {
const final = this.getFirstMatchFinal(match, stageType);
return [
this.getDiagonalMatch(match.groupId, roundNumber, match.number),
...(final ? [final] : []),
];
}
if (roundNumber === roundCount) return [];
return [this.getDiagonalMatch(match.groupId, roundNumber, match.number)];
}
/**
* Gets the match(es) where the opponents of the current match of loser bracket will go just after.
*
* @param match The current match.
* @param stageType Type of the stage.
* @param roundNumber The number of the current round.
* @param roundCount Count of rounds.
*/
private getNextMatchesLB(
match: MatchData,
stageType: StageType,
roundNumber: number,
roundCount: number,
): MatchData[] {
if (roundNumber === roundCount) {
const final = this.getFirstMatchFinal(match, stageType);
return final ? [final] : [];
}
if (roundNumber % 2 === 1)
return this.getMatchAfterMajorRoundLB(match, roundNumber);
return this.getMatchAfterMinorRoundLB(match, roundNumber);
}
/**
* Gets the first match of the final group (consolation final or grand final).
*
* @param match The current match.
* @param stageType Type of the stage.
*/
private getFirstMatchFinal(
match: MatchData,
stageType: StageType,
): MatchData | null {
const finalGroupId = this.getFinalGroupId(match.stageId, stageType);
if (finalGroupId === null) return null;
return this.findMatch(finalGroupId, 1, 1);
}
/**
* Gets the matches following the current match, which is in the final group (consolation final or grand final).
*
* @param match The current match.
* @param roundNumber The number of the current round.
* @param roundCount The count of rounds.
*/
private getNextMatchesFinal(
match: MatchData,
roundNumber: number,
roundCount: number,
): MatchData[] {
if (
roundNumber === roundCount ||
// avoid putting teams to bracket reset if tournament is over
match.winnerSide === "opponent1"
) {
return [];
}
return [this.findMatch(match.groupId, roundNumber + 1, 1)];
}
/**
* Gets the match(es) where the opponents of the current match of a winner bracket's major round will go just after.
*
* @param match The current match.
* @param roundNumber The number of the current round.
*/
private getMatchAfterMajorRoundLB(
match: MatchData,
roundNumber: number,
): MatchData[] {
return [this.getParallelMatch(match.groupId, roundNumber, match.number)];
}
/**
* Gets the match(es) where the opponents of the current match of a winner bracket's minor round will go just after.
*
* @param match The current match.
* @param roundNumber The number of the current round.
*/
private getMatchAfterMinorRoundLB(
match: MatchData,
roundNumber: number,
): MatchData[] {
return [this.getDiagonalMatch(match.groupId, roundNumber, match.number)];
}
/**
* Returns the id of the final group (consolation final or grand final).
*
* @param stageId ID of the stage.
* @param stageType Type of the stage.
*/
private getFinalGroupId(
stageId: number,
stageType: StageType,
): number | null {
const groupNumber =
stageType === "single_elimination"
? 2 /* Consolation final */
: 3; /* Grand final */
const finalGroup = this.store.groupByNumber(stageId, groupNumber);
if (!finalGroup) return null;
return finalGroup.id;
}
/**
* Gets the upper bracket (the only bracket if single elimination or the winner bracket in double elimination).
*
* @param stageId ID of the stage.
*/
private getUpperBracket(stageId: number): GroupData {
const winnerBracket = this.store.groupByNumber(stageId, 1);
if (!winnerBracket) throw Error("Winner bracket not found.");
return winnerBracket;
}
/**
* Gets the participant count of an elimination stage, derived from its upper
* bracket's first round (two participants per match, BYEs included).
*
* @param stageId ID of the stage.
*/
private participantCount(stageId: number): number {
const upperBracket = this.getUpperBracket(stageId);
const firstRound = this.store.roundByNumber(upperBracket.id, 1);
if (!firstRound) throw Error("First round not found.");
return this.store.matchCountInRound(firstRound.id) * 2;
}
/**
* Gets the loser bracket.
*
* @param stageId ID of the stage.
*/
private getLoserBracket(stageId: number): GroupData | null {
return this.store.groupByNumber(stageId, 2);
}
/**
* Gets the corresponding match in the next round ("diagonal match") the usual way.
*
* Just like from Round 1 to Round 2 in a single elimination stage.
*
* @param groupId ID of the group.
* @param roundNumber Number of the round in its parent group.
* @param matchNumber Number of the match in its parent round.
*/
private getDiagonalMatch(
groupId: number,
roundNumber: number,
matchNumber: number,
): MatchData {
return this.findMatch(
groupId,
roundNumber + 1,
helpers.getDiagonalMatchNumber(matchNumber),
);
}
/**
* Gets the corresponding match in the next round ("parallel match") the "major round to minor round" way.
*
* Just like from Round 1 to Round 2 in the loser bracket of a double elimination stage.
*
* @param groupId ID of the group.
* @param roundNumber Number of the round in its parent group.
* @param matchNumber Number of the match in its parent round.
*/
private getParallelMatch(
groupId: number,
roundNumber: number,
matchNumber: number,
): MatchData {
return this.findMatch(groupId, roundNumber + 1, matchNumber);
}
/**
* Finds a match in a given group. The match must have the given number in a round of which the number in group is given.
*
* @param groupId ID of the group.
* @param roundNumber Number of the round in its parent group.
* @param matchNumber Number of the match in its parent round.
*/
findMatch(
groupId: number,
roundNumber: number,
matchNumber: number,
): MatchData {
const round = this.store.roundByNumber(groupId, roundNumber);
if (!round) throw Error("Round not found.");
const match = this.store.matchByNumber(round.id, matchNumber);
if (!match) throw Error("Match not found.");
return match;
}
}
function scoresAfter(
stored: MatchData,
scores: MatchResultsInput["scores"],
): [number, number] {
if (scores === undefined) {
return [stored.opponent1?.score ?? 0, stored.opponent2?.score ?? 0];
}
return scores ?? [0, 0];
}

View File

@ -0,0 +1,178 @@
import { beforeEach, describe, expect, test } from "vitest";
import { createResolved } from "../create";
import * as Engine from "../index";
import type { BracketData, ResolvedCreateBracketInput } from "../types";
const EXAMPLE: ResolvedCreateBracketInput = {
type: "double_elimination",
seeding: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16],
settings: {},
};
describe("Update matches", () => {
let data: BracketData;
beforeEach(() => {
data = createResolved(EXAMPLE);
});
test("should start a match", () => {
expect(Engine.matchStatus(data, 0)).toBe("STARTED");
data = Engine.reportResult(data, {
matchId: 0,
scores: [0, 0],
}).data;
expect(Engine.matchStatus(data, 0)).toBe("STARTED");
expect(matchById(data, 0).opponent1?.score).toBe(0);
});
test("should update the scores for a match", () => {
data = Engine.reportResult(data, {
matchId: 0,
scores: [2, 1],
}).data;
const after = matchById(data, 0);
expect(Engine.matchStatus(data, 0)).toBe("STARTED");
expect(after.opponent1?.score).toBe(2);
// Id should stay. It shouldn't be overwritten.
expect(after.opponent1?.id).toBe(1);
});
test("should end the match by only setting the winner", () => {
expect(matchById(data, 0).winnerSide).toBeFalsy();
data = Engine.reportResult(data, {
matchId: 0,
winnerSide: "opponent1",
}).data;
expect(Engine.matchStatus(data, 0)).toBe("COMPLETED");
expect(matchById(data, 0).winnerSide).toBe("opponent1");
});
test("should change the winner of the match and update in the next match", () => {
data = Engine.reportResult(data, {
matchId: 0,
winnerSide: "opponent1",
}).data;
expect(matchById(data, 8).opponent1?.id).toBe(1);
data = Engine.reportResult(data, {
matchId: 0,
winnerSide: "opponent2",
}).data;
expect(Engine.matchStatus(data, 0)).toBe("COMPLETED");
expect(matchById(data, 0).winnerSide).toBe("opponent2");
expect(Engine.matchStatus(data, 8)).toBe("PENDING");
expect(matchById(data, 8).opponent1?.id).toBe(16);
});
test("should update the status of the next match", () => {
data = Engine.reportResult(data, {
matchId: 0,
winnerSide: "opponent1",
}).data;
expect(Engine.matchStatus(data, 8)).toBe("PENDING");
data = Engine.reportResult(data, {
matchId: 1,
winnerSide: "opponent1",
}).data;
expect(Engine.matchStatus(data, 8)).toBe("STARTED");
});
test("should remove results from a match without score", () => {
data = Engine.reportResult(data, {
matchId: 0,
winnerSide: "opponent1",
}).data;
data = Engine.resetMatchResults(data, 0).data;
expect(Engine.matchStatus(data, 0)).toBe("STARTED");
expect(matchById(data, 0).winnerSide).toBeFalsy();
});
test("should remove results from a match with score", () => {
data = Engine.reportResult(data, {
matchId: 0,
scores: [16, 12],
winnerSide: "opponent1",
}).data;
data = Engine.resetMatchResults(data, 0).data;
expect(Engine.matchStatus(data, 0)).toBe("STARTED");
expect(matchById(data, 0).winnerSide).toBeFalsy();
});
test("should keep the scores as they are if none given", () => {
data = Engine.reportResult(data, {
matchId: 1,
scores: [1, 0],
}).data;
data = Engine.reportResult(data, { matchId: 1 }).data;
const after = matchById(data, 1);
expect(Engine.matchStatus(data, 1)).toBe("STARTED");
expect(after.opponent1?.score).toBe(1);
expect(after.opponent2?.score).toBe(0);
});
test("should end the match by setting the winner and the scores", () => {
data = Engine.reportResult(data, {
matchId: 1,
scores: [6, 3],
winnerSide: "opponent2",
}).data;
const after = matchById(data, 1);
expect(Engine.matchStatus(data, 1)).toBe("COMPLETED");
expect(after.winnerSide).toBe("opponent2");
expect(after.opponent1?.score).toBe(6);
expect(after.opponent2?.score).toBe(3);
});
});
describe("Locked matches", () => {
let data: BracketData;
beforeEach(() => {
data = createResolved(EXAMPLE);
});
test("should throw when the matches leading to the match have not been completed yet", () => {
expect(() => Engine.reportResult(data, { matchId: 0 })).not.toThrow(); // No problem when no previous match.
expect(() => Engine.reportResult(data, { matchId: 8 })).toThrow(
"The match is locked.",
); // First match of WB Round 2.
expect(() => Engine.reportResult(data, { matchId: 15 })).toThrow(
"The match is locked.",
); // First match of LB Round 1.
expect(() => Engine.reportResult(data, { matchId: 19 })).toThrow(
"The match is locked.",
); // First match of LB Round 1.
expect(() => Engine.reportResult(data, { matchId: 23 })).toThrow(
"The match is locked.",
); // First match of LB Round 3.
});
});
function matchById(data: BracketData, id: number) {
const found = data.match.find((match) => match.id === id);
if (!found) throw new Error(`Match ${id} not found`);
return found;
}

View File

@ -0,0 +1,100 @@
import { describe, expect, test } from "vitest";
import { matchEndedEarly } from "./status";
describe("matchEndedEarly", () => {
test("returns false when no winner", () => {
expect(
matchEndedEarly({
opponentOne: { score: 1 },
opponentTwo: { score: 1 },
winnerSide: null,
count: 3,
countType: "BEST_OF",
}),
).toBe(false);
});
test("returns false when match completed normally (best of 3)", () => {
expect(
matchEndedEarly({
opponentOne: { score: 2 },
opponentTwo: { score: 1 },
winnerSide: "opponent1",
count: 3,
countType: "BEST_OF",
}),
).toBe(false);
});
test("returns true when match ended early (best of 3)", () => {
expect(
matchEndedEarly({
opponentOne: { score: 1 },
opponentTwo: { score: 0 },
winnerSide: "opponent1",
count: 3,
countType: "BEST_OF",
}),
).toBe(true);
});
test("returns true when match ended early (best of 5)", () => {
expect(
matchEndedEarly({
opponentOne: { score: 2 },
opponentTwo: { score: 1 },
winnerSide: "opponent1",
count: 5,
countType: "BEST_OF",
}),
).toBe(true);
});
test("returns false when match completed normally (best of 5)", () => {
expect(
matchEndedEarly({
opponentOne: { score: 3 },
opponentTwo: { score: 2 },
winnerSide: "opponent1",
count: 5,
countType: "BEST_OF",
}),
).toBe(false);
});
test("returns false when all maps played (play all)", () => {
expect(
matchEndedEarly({
opponentOne: { score: 2 },
opponentTwo: { score: 1 },
winnerSide: "opponent1",
count: 3,
countType: "PLAY_ALL",
}),
).toBe(false);
});
test("returns true when not all maps played (play all)", () => {
expect(
matchEndedEarly({
opponentOne: { score: 2 },
opponentTwo: { score: 0 },
winnerSide: "opponent1",
count: 3,
countType: "PLAY_ALL",
}),
).toBe(true);
});
test("handles missing scores as 0", () => {
expect(
matchEndedEarly({
opponentOne: {},
opponentTwo: {},
winnerSide: "opponent1",
count: 3,
countType: "BEST_OF",
}),
).toBe(true);
});
});

View File

@ -0,0 +1,232 @@
import * as R from "remeda";
import type { TournamentRoundMaps } from "~/db/tables";
import type {
BracketData,
MatchData,
MatchResults,
RoundData,
Side,
} from "./types";
/**
* The state of a match. Never persisted, always derived from the opponents of
* the match and (for round robin) the progress of the previous round.
*
* - `PENDING` an opponent is still unknown, the match is a BYE or the teams are
* busy playing an earlier round.
* - `STARTED` the match can be played and might already be in progress.
* - `COMPLETED` the match has a winner.
*/
export type MatchStatus = "PENDING" | "STARTED" | "COMPLETED";
/** Derives the status of every match of the bracket, keyed by match id. */
export function matchStatuses(data: BracketData): Map<number, MatchStatus> {
const context = bracketContext(data);
return new Map(
data.match.map((match) => [match.id, resolveStatus(match, context)]),
);
}
/**
* Derives the status of one match of the bracket. Prefer {@link matchStatuses}
* when the status of many matches of the same bracket is needed.
*/
export function matchStatus(data: BracketData, matchId: number): MatchStatus {
const match = data.match.find((match) => match.id === matchId);
if (!match) throw new Error(`Match not found: ${matchId}`);
return resolveStatus(match, bracketContext(data));
}
/**
* Checks if a match has had at least one game reported.
*
* @param match A match's results.
*/
export function isMatchStarted(match: MatchResults): boolean {
return (
match.opponent1?.score !== undefined || match.opponent2?.score !== undefined
);
}
/**
* Checks if a match is completed.
*
* @param match A match's results.
*/
export function isMatchCompleted(match: MatchResults): boolean {
return isMatchByeCompleted(match) || Boolean(match.winnerSide);
}
/**
* Checks if a match is completed because of at least one BYE.
*
* A match "BYE vs. TBD" isn't considered completed yet.
*
* @param match A match's results.
*/
export function isMatchByeCompleted(match: MatchResults): boolean {
return (
(match.opponent1 === null && match.opponent2?.id !== null) || // BYE vs. someone
(match.opponent2 === null && match.opponent1?.id !== null) || // someone vs. BYE
(match.opponent1 === null && match.opponent2 === null)
); // BYE vs. BYE
}
/** Whether a set is decided given the games each side has won and the round's count settings. */
export function isSetOverByScore({
scores,
count,
countType,
}: {
scores: [number, number];
count: number;
countType: TournamentRoundMaps["type"];
}) {
if (countType === "PLAY_ALL") {
return R.sum(scores) === count;
}
const matchOverAtXWins = Math.ceil(count / 2);
return scores[0] === matchOverAtXWins || scores[1] === matchOverAtXWins;
}
/**
* The side the scores decide as the winner of the set, `undefined` while the
* set is not over yet (or a play all set ended in a tie).
*/
export function winnerSideByScore(args: {
scores: [number, number];
count: number;
countType: TournamentRoundMaps["type"];
}): Side | undefined {
if (!isSetOverByScore(args)) return undefined;
const [scoreOne, scoreTwo] = args.scores;
if (scoreOne > scoreTwo) return "opponent1";
if (scoreTwo > scoreOne) return "opponent2";
return undefined;
}
/** Whether a completed match was ended before the set was decided by the games played (e.g. by an organizer force-ending it). */
export function matchEndedEarly({
opponentOne,
opponentTwo,
winnerSide,
count,
countType,
}: {
opponentOne: { score?: number } | null;
opponentTwo: { score?: number } | null;
winnerSide: Side | null;
count: number;
countType: TournamentRoundMaps["type"];
}) {
if (!winnerSide) return false;
const scores: [number, number] = [
opponentOne?.score ?? 0,
opponentTwo?.score ?? 0,
];
return !isSetOverByScore({ scores, count, countType });
}
interface BracketContext {
roundsById: Map<number, RoundData>;
roundByGroupAndNumber: Map<string, RoundData>;
matchesByRoundId: Map<number, MatchData[]>;
hasDependentRoundsByStageId: Map<number, boolean>;
}
function resolveStatus(match: MatchData, context: BracketContext): MatchStatus {
if (isMatchCompleted(match)) return "COMPLETED";
if (!match.opponent1?.id || !match.opponent2?.id) return "PENDING";
// a match that is being played can't go back to pending e.g. because the
// result of an earlier match was reopened (issue #2690)
if (isMatchStarted(match)) return "STARTED";
if (isWaitingForPreviousRound(match, context)) return "PENDING";
return "STARTED";
}
/**
* In a round robin where the rounds are not independent both opponents are
* known from the start but they can only play once they are done with the
* previous round.
*/
function isWaitingForPreviousRound(
match: MatchData,
context: BracketContext,
): boolean {
if (!context.hasDependentRoundsByStageId.get(match.stageId)) return false;
const round = context.roundsById.get(match.roundId);
if (!round || round.number === 1) return false;
const previousRound = context.roundByGroupAndNumber.get(
roundKey(round.groupId, round.number - 1),
);
if (!previousRound) return false;
const previousMatches = context.matchesByRoundId.get(previousRound.id) ?? [];
return [match.opponent1?.id, match.opponent2?.id].some(
(opponentId) =>
opponentId && !hasFinishedRound(opponentId, previousMatches),
);
}
function hasFinishedRound(opponentId: number, roundMatches: MatchData[]) {
const match = roundMatches.find(
(match) =>
match.opponent1?.id === opponentId || match.opponent2?.id === opponentId,
);
// no match in the round = they sat the round out
if (!match) return true;
return isMatchCompleted(match);
}
function bracketContext(data: BracketData): BracketContext {
const roundsById = new Map<number, RoundData>();
const roundByGroupAndNumber = new Map<string, RoundData>();
for (const round of data.round) {
roundsById.set(round.id, round);
roundByGroupAndNumber.set(roundKey(round.groupId, round.number), round);
}
const matchesByRoundId = new Map<number, MatchData[]>();
for (const match of data.match) {
const matches = matchesByRoundId.get(match.roundId);
if (matches) {
matches.push(match);
} else {
matchesByRoundId.set(match.roundId, [match]);
}
}
const hasDependentRoundsByStageId = new Map(
data.stage.map((stage) => [
stage.id,
stage.type === "round_robin" && !stage.settings.independentRounds,
]),
);
return {
roundsById,
roundByGroupAndNumber,
matchesByRoundId,
hasDependentRoundsByStageId,
};
}
function roundKey(groupId: number, roundNumber: number) {
return `${groupId}-${roundNumber}`;
}

View File

@ -0,0 +1,295 @@
import blossom from "edmonds-blossom-fixed";
import { err, ok, type Result } from "neverthrow";
import * as R from "remeda";
import invariant from "~/utils/invariant";
import { swissRoundCount } from "../create/settings";
import type {
BracketData,
GeneratedRound,
MatchData,
SwissStanding,
} from "../types";
import { calculateTeamStatus } from "./team-status";
/**
* Generates the next round of matchups for a Swiss tournament bracket within a specific group.
*
* Considers only the matches and teams within the specified group. Teams that have dropped out are excluded from the pairing process.
* If the group has an uneven number of teams, the lowest standing team that has not already received a bye is preferred to receive one.
* Matches are generated such that teams do not replay previous opponents if possible.
*/
export function generateRound(
data: BracketData,
args: {
groupId: number;
standings: SwissStanding[];
settings: { advanceThreshold?: number } | null;
},
): Result<GeneratedRound, string> {
// lets consider only this groups matches
// in the case that there are more than one group
const groupsMatches = data.match.filter((m) => m.groupId === args.groupId);
if (groupsMatches.length === 0) return err("No matches found for group");
if (data.stage[0]?.type !== "swiss") return err("Bracket is not Swiss type");
// new matches can't be generated till old are over
if (!everyMatchOver(groupsMatches)) {
return err("Not all matches are over");
}
const groupsTeams = groupsMatches
.flatMap((match) => [match.opponent1, match.opponent2])
.filter(Boolean);
const groupsStandings = args.standings.filter((standing) => {
return groupsTeams.some((team) => team?.id === standing.team.id);
});
// teams who have dropped out are not considered
let standingsWithoutDropouts = groupsStandings.filter(
(s) => !s.team.droppedOut,
);
// filter out teams that have advanced or been eliminated if early advance/elimination is enabled
if (typeof args.settings?.advanceThreshold === "number") {
const roundCount = swissRoundCount(data);
const advanceThreshold = args.settings.advanceThreshold;
standingsWithoutDropouts = standingsWithoutDropouts.filter((standing) => {
const wins = standing.stats?.setWins ?? 0;
const losses = standing.stats?.setLosses ?? 0;
const status = calculateTeamStatus({
wins,
losses,
advanceThreshold,
roundCount,
});
return status === "active";
});
}
// if there are fewer than 2 active teams, no more matches can be generated
if (standingsWithoutDropouts.length < 2) {
return err("Not enough active teams to generate matches");
}
const teamsThatHaveHadByes = groupsMatches
.filter((m) => m.opponent2 === null)
.map((m) => m.opponent1?.id);
const pairs = pairUp(
standingsWithoutDropouts.map((standing) => ({
id: standing.team.id,
score: standing.stats?.setWins ?? 0,
receivedBye: teamsThatHaveHadByes.includes(standing.team.id),
avoid: groupsMatches.flatMap((match) => {
if (match.opponent1?.id === standing.team.id) {
return match.opponent2?.id ? [match.opponent2.id] : [];
}
if (match.opponent2?.id === standing.team.id) {
return match.opponent1?.id ? [match.opponent1.id] : [];
}
return [];
}),
})),
);
let matchNumber = 1;
const newRoundId = data.round
.slice()
.sort((a, b) => a.id - b.id)
.filter((r) => r.groupId === args.groupId)
.find(
(r) => r.id > Math.max(...groupsMatches.map((match) => match.roundId)),
)?.id;
invariant(newRoundId, "newRoundId not found");
return ok({
groupId: args.groupId,
roundId: newRoundId,
matches: pairs.map(({ opponentOne, opponentTwo }) => ({
number: matchNumber++,
opponent1: { id: opponentOne },
opponent2: typeof opponentTwo === "number" ? { id: opponentTwo } : null,
})),
});
}
function everyMatchOver(matches: MatchData[]) {
for (const match of matches) {
// bye
if (!match.opponent1 || !match.opponent2) continue;
if (!match.winnerSide) {
return false;
}
}
return true;
}
interface SwissPairingTeam {
id: number;
/** How many matches has the team won */
score: number;
/** List of tournament team ids this team already played */
avoid: Array<number>;
receivedBye?: boolean;
}
/**
* Pairs up teams for a swiss round using maximum weighted matching, avoiding
* rematches if possible and preferring teams with equal scores to play each other.
*
* Adapted from https://github.com/slashinfty/tournament-pairings
*/
export function pairUp(players: SwissPairingTeam[]) {
if (players.length < 2) {
throw new Error("Need at least two players to pair up");
}
if (players.length === 2) {
return [{ opponentOne: players[0].id, opponentTwo: players[1].id }];
}
// uncomment to add a new test case to PAIR_UP_TEST_CASES
// console.log(players);
const matches = [];
const playerArray = R.shuffle(players).map((p, i) => ({ ...p, index: i }));
const scoreGroups = [...new Set(playerArray.map((p) => p.score))].sort(
(a, b) => a - b,
);
const scoreSums = [
...new Set(
scoreGroups.flatMap((s, i, a) => {
const sums = [];
for (let j = i; j < a.length; j++) {
sums.push(s + a[j]);
}
return sums;
}),
),
].sort((a, b) => a - b);
let pairs = generateWeightedPairs({ playerArray, scoreGroups, scoreSums });
if (pairs.length === 0) {
// no possible pairs without rematches, try again allowing rematches
pairs = generateWeightedPairs({
playerArray,
scoreGroups,
scoreSums,
considerAvoid: false,
});
}
const blossomPairs = blossom(pairs, true);
const playerCopy = [...playerArray];
let byeArray = [];
do {
const indexA = playerCopy[0].index;
const indexB = blossomPairs[indexA];
if (indexB === -1) {
byeArray.push(playerCopy.splice(0, 1)[0]);
continue;
}
playerCopy.splice(0, 1);
playerCopy.splice(
playerCopy.findIndex((p) => p.index === indexB),
1,
);
const playerA = playerArray.find((p) => p.index === indexA);
const playerB = playerArray.find((p) => p.index === indexB);
invariant(playerA, "Player A not found");
invariant(playerB, "Player B not found");
matches.push({
opponentOne: playerA.id,
opponentTwo: playerB.id,
});
} while (
playerCopy.length >
blossomPairs.reduce(
(sum: number, idx: number) => (idx === -1 ? sum + 1 : sum),
0,
)
);
byeArray = [...byeArray, ...playerCopy];
for (let i = 0; i < byeArray.length; i++) {
matches.push({
opponentOne: byeArray[i].id,
opponentTwo: null,
});
}
return matches;
}
function generateWeightedPairs({
playerArray,
scoreGroups,
scoreSums,
considerAvoid = true,
}: {
playerArray: (SwissPairingTeam & { index: number })[];
scoreGroups: number[];
scoreSums: number[];
considerAvoid?: boolean;
}) {
const pairs: [number, number, number][] = [];
for (let i = 0; i < playerArray.length; i++) {
const curr = playerArray[i];
const next = playerArray.slice(i + 1);
for (let j = 0; j < next.length; j++) {
const opp = next[j];
if (
considerAvoid &&
Object.hasOwn(curr, "avoid") &&
curr.avoid.includes(opp.id)
) {
continue;
}
let wt =
75 - 75 / (scoreGroups.indexOf(Math.min(curr.score, opp.score)) + 2);
wt +=
5 - 5 / (scoreSums.findIndex((s) => s === curr.score + opp.score) + 1);
const scoreGroupDiff = Math.abs(
scoreGroups.indexOf(curr.score) - scoreGroups.indexOf(opp.score),
);
// TODO: consider "pairedUpDown"
// if (
// scoreGroupDiff === 1 &&
// curr.hasOwnProperty("pairedUpDown") &&
// curr.pairedUpDown === false &&
// opp.hasOwnProperty("pairedUpDown") &&
// opp.pairedUpDown === false
// ) {
// scoreGroupDiff -= 0.65;
// } else if (
// scoreGroupDiff > 0 &&
// ((curr.hasOwnProperty("pairedUpDown") && curr.pairedUpDown === true) ||
// (opp.hasOwnProperty("pairedUpDown") && opp.pairedUpDown === true))
// ) {
// scoreGroupDiff += 0.2;
// }
wt += 23 / (2 * (scoreGroupDiff + 2));
// Lower weight for larger score differences, we really want to avoid 2-0 playing 0-2 etc.
if (scoreGroupDiff >= 2) {
wt -= 10;
}
if (
(Object.hasOwn(curr, "receivedBye") && curr.receivedBye) ||
(Object.hasOwn(opp, "receivedBye") && opp.receivedBye)
) {
wt += 40;
}
pairs.push([curr.index, opp.index, wt]);
}
}
return pairs;
}

View File

@ -0,0 +1,94 @@
export type SwissTeamStatus = "active" | "advanced" | "eliminated";
/**
* Calculates whether a team should advance, be eliminated, or remain active
* in a Swiss tournament with early advance/elimination rules.
*
* @returns The team's status: "advanced" if they've secured advancement,
* "eliminated" if they can no longer mathematically advance, or "active" if still competing
*
* @example
* // In a 5-round Swiss where teams need 3 wins to advance:
* calculateTeamStatus({ wins: 3, losses: 1, advanceThreshold: 3, roundCount: 5 }) // "advanced"
* calculateTeamStatus({ wins: 2, losses: 3, advanceThreshold: 3, roundCount: 5 }) // "eliminated"
* calculateTeamStatus({ wins: 2, losses: 2, advanceThreshold: 3, roundCount: 5 }) // "active"
*/
export function calculateTeamStatus({
wins,
losses,
advanceThreshold,
roundCount,
}: {
/** Number of matches the team has won */
wins: number;
/** Number of matches the team has lost */
losses: number;
/** Number of wins required to advance to the next stage */
advanceThreshold: number;
/** Total number of rounds in the Swiss stage */
roundCount: number;
}): SwissTeamStatus {
if (wins >= advanceThreshold) {
return "advanced";
}
if (losses >= eliminationThreshold({ roundCount, advanceThreshold })) {
return "eliminated";
}
return "active";
}
/**
* Calculates the maximum valid advance threshold for a given round count.
* The threshold must allow for meaningful play - teams need a chance to both advance and be eliminated.
*/
export function maxAdvanceThreshold({ roundCount }: { roundCount: number }) {
return Math.ceil(roundCount / 2) + 1;
}
/**
* Calculates the maximum losses allowed before elimination given an advance threshold and round count.
*/
export function eliminationThreshold({
roundCount,
advanceThreshold,
}: {
roundCount: number;
advanceThreshold: number;
}) {
return roundCount - advanceThreshold + 1;
}
/**
* Validates if an advance threshold is valid for the given round count.
*/
export function isValidAdvanceThreshold({
roundCount,
advanceThreshold,
}: {
roundCount: number;
advanceThreshold: number;
}) {
return validAdvanceThresholdOptions({ roundCount }).includes(
advanceThreshold,
);
}
/**
* Returns a list of valid advance threshold options for a given round count.
* Starts from 2 wins minimum up to the calculated maximum.
*/
export function validAdvanceThresholdOptions({
roundCount,
}: {
roundCount: number;
}) {
const result: number[] = [];
for (let i = 2; i <= maxAdvanceThreshold({ roundCount }); i++) {
result.push(i);
}
return result;
}

View File

@ -0,0 +1,278 @@
import type {
Tables,
TournamentRoundMaps,
TournamentStageSettings,
} from "~/db/tables";
/**
* The side of an opponent. Upstream brackets-model also allowed a draw
* intentionally dropped, draws are impossible in our formats.
*/
export type Side = "opponent1" | "opponent2";
export type StageType = Tables["TournamentStage"]["type"];
/**
* All the possible types of group in an elimination stage.
*
* - `single_bracket` for single elimination.
* - `winner_bracket` and `loser_bracket` for double elimination.
* - `final_group` for both single and double elimination.
*/
export type GroupType =
| "single_bracket"
| "winner_bracket"
| "loser_bracket"
| "final_group";
export type SeedOrdering =
| "natural"
| "reverse"
| "half_shift"
| "reverse_half_shift"
| "pair_flip"
| "space_between"
| "groups.seed_optimized";
/** The seeding for a stage. Each element is a participant id or a BYE: `null`. */
export type Seeding = (number | null)[];
/**
* The possible settings for a stage. Same shape as what is persisted in
* TournamentStage.settings today (the old brackets-model StageSettings).
*/
export interface StageSettings {
/** Number of groups in a round robin or swiss stage. */
groupCount?: number;
/** Number of rounds in a swiss stage. */
roundCount?: number;
/**
* Whether to generate a bipartite round-robin where teams are split into two
* A/B divisions and every match pairs one A team with one B team.
*/
hasAbDivisions?: boolean;
/**
* Whether matches in a round-robin stage are playable independently of each other.
*
* - If `false` (default), only round 1 matches start `Ready`; later rounds start
* `Locked` and unlock as both opponents complete the previous round.
* - If `true`, every match with two opponents starts `Ready`.
*/
independentRounds?: boolean;
/** Optional final between semi-final losers. */
consolationFinal?: boolean;
}
export interface ParticipantResult {
/** `null` = to be determined (source match unfinished). */
id: number | null;
/** Seed position this slot was filled from. */
position?: number;
/** The current score of the participant. */
score?: number;
/**
* KO win count this set, aggregated on hydrate. Upstream's totalPoints is
* intentionally gone scoring is KO-only.
*/
totalKos?: number;
}
/* ------------------------------------------------------------------ */
/* Bracket data — identical shape to the old BracketData */
/* ------------------------------------------------------------------ */
export interface StageData {
id: number;
/** Only set on stages read from the database, the engine does not create names. */
name?: string;
type: StageType;
settings: StageSettings;
number: number;
createdAt?: number | null;
}
export interface GroupData {
id: number;
stageId: number;
number: number;
}
export interface RoundData {
id: number;
stageId: number;
groupId: number;
number: number;
maps?: TournamentRoundMaps | null;
}
export interface MatchResults {
opponent1: ParticipantResult | null;
opponent2: ParticipantResult | null;
/**
* The side that won the set, `null` while the match has no winner. A match
* won against a BYE gets it set once the BYE is propagated.
*/
winnerSide: Side | null;
}
export interface MatchData extends MatchResults {
id: number;
stageId: number;
groupId: number;
roundId: number;
number: number;
startedAt?: number | null;
}
/**
* The whole state of one tournament's brackets. This is the single value the
* engine reads and returns. Never mutated in place every engine operation
* returns a new BracketData.
*/
export interface BracketData {
stage: StageData[];
group: GroupData[];
round: RoundData[];
match: MatchData[];
}
/* ------------------------------------------------------------------ */
/* Engine internals */
/* ------------------------------------------------------------------ */
/** Used by the engine to handle placements. Is `null` if is a BYE. Has a `null` id if it's yet to be determined. */
export type ParticipantSlot = { id: number | null; position?: number } | null;
/** The engine only handles duels. It's one participant versus another participant. */
export type Duel = [ParticipantSlot, ParticipantSlot];
/** Type of an object implementing every ordering method. */
export type OrderingMap = Record<
SeedOrdering,
<T>(array: T[], ...args: number[]) => T[]
>;
/** Contains the losers and the winner of a standard bracket. */
export interface StandardBracketResults {
/** The list of losers for each round of the bracket. */
losers: ParticipantSlot[][];
/** The winner of the bracket. */
winner: ParticipantSlot;
}
/* ------------------------------------------------------------------ */
/* Engine inputs */
/* ------------------------------------------------------------------ */
export interface CreateBracketInput {
type: StageType;
/** Team ids in seed order; `null` = BYE. */
seeding: Seeding;
/** User-selected settings; the engine derives its internal stage settings (defaults, group counts, seed ordering) from these. */
settings: TournamentStageSettings | null;
/** (Round robin only) Whether matches are playable independently of rounds (league divisions). */
independentRounds?: boolean;
/** Parallel to seeding; required when settings.hasAbDivisions. 0 = A, 1 = B. */
abDivisions?: (0 | 1)[];
/** Stage number within the tournament. Defaults to 1 (local data; the repository assigns the real number on insert). */
number?: number;
/**
* Per-round map info to assign onto the created rounds, keyed by the local
* round ids of an identically created bracket (the preview the maps were
* picked against). For round robin and swiss one entry per distinct round
* number groups share map lists.
*/
maps?: RoundMapsInput[];
}
/** One round's map info as picked by the organizer against a bracket preview. */
export type RoundMapsInput = TournamentRoundMaps & {
roundId: number;
groupId?: number;
};
/**
* Engine-internal variant of {@link CreateBracketInput}: settings are the
* already-resolved internal {@link StageSettings}.
*/
export interface ResolvedCreateBracketInput
extends Omit<CreateBracketInput, "settings" | "independentRounds"> {
settings: StageSettings;
}
/**
* A result to apply to a match. The opponents of a match are decided by the
* bracket, never by a reported result, so only the scores and the winner can be
* given.
*/
export interface MatchResultsInput {
/**
* Games won by each side. Leaving it out keeps the current scores, `null`
* clears them.
*/
scores?: [number, number] | null;
/**
* Ends the set with this side as the winner even when the scores don't
* decide it (an organizer force-ending a set, a team dropping out). Leaving
* it out resolves the winner from the scores and the round's map count.
*/
winnerSide?: Side;
}
/** A {@link MatchResultsInput} targeted at one match of the bracket. */
export interface ReportResultInput extends MatchResultsInput {
matchId: number;
}
/** The subset of a standing the swiss round generation needs. */
export interface SwissStanding {
team: {
id: number;
/** Truthy when the team has dropped out (DB stores 0/1). */
droppedOut?: number | boolean;
};
stats?: {
setWins: number;
setLosses: number;
};
}
/* ------------------------------------------------------------------ */
/* Engine outputs */
/* ------------------------------------------------------------------ */
/**
* Every engine mutation returns the full next state plus the delta. The
* repository persists ONLY the delta; the state is for the caller to keep
* working with (chained operations, simulations, revalidation payloads).
*/
export interface EngineResult {
data: BracketData;
/** Matches whose row must be UPDATEd (opponents changed). */
changedMatches: MatchData[];
}
/** Matches to INSERT when a new round is generated (swiss). */
export interface GeneratedRound {
groupId: number;
roundId: number;
matches: Array<{
number: number;
opponent1: ParticipantResult | null;
/** null opponent = BYE */
opponent2: ParticipantResult | null;
}>;
}
export interface DroppedTeamsResult extends EngineResult {
endedMatchIds: number[];
}

View File

@ -1,9 +1,9 @@
import * as R from "remeda";
import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types";
import type { BracketData } from "~/features/tournament-bracket/core/engine/types";
import { TOURNAMENT } from "../../tournament/tournament-constants";
export function getRounds(args: {
bracketData: TournamentManagerDataSet;
bracketData: BracketData;
type: "winners" | "losers" | "single";
}) {
const groupIds = args.bracketData.group.flatMap((group) => {
@ -17,8 +17,8 @@ export function getRounds(args: {
const rounds = args.bracketData.round
.flatMap((round) => {
if (
typeof round.group_id === "number" &&
!groupIds.includes(round.group_id)
typeof round.groupId === "number" &&
!groupIds.includes(round.groupId)
) {
return [];
}
@ -30,17 +30,17 @@ export function getRounds(args: {
const grandFinalsMatch =
args.type === "winners"
? args.bracketData.match.find(
(match) => match.round_id === rounds[rounds.length - 2]?.id,
(match) => match.roundId === rounds[rounds.length - 2]?.id,
)
: undefined;
if (isBracketReset && grandFinalsMatch?.opponent1?.result === "win") {
if (isBracketReset && grandFinalsMatch?.winnerSide === "opponent1") {
showingBracketReset = false;
return false;
}
const matches = args.bracketData.match.filter(
(match) => match.round_id === round.id,
(match) => match.roundId === round.id,
);
const atLeastOneNonByeMatch = matches.some(
@ -52,7 +52,7 @@ export function getRounds(args: {
const hasThirdPlaceMatch =
args.type === "single" &&
R.unique(args.bracketData.match.map((m) => m.group_id)).length > 1;
R.unique(args.bracketData.match.map((m) => m.groupId)).length > 1;
const namedRounds = rounds.map((round, i) => {
const name = () => {
if (

View File

@ -9,12 +9,12 @@ import {
} from "~/features/mmr/mmr-utils";
import { getBracketProgressionLabel } from "~/features/tournament/tournament-utils";
import type { AllMatchResult } from "~/features/tournament-match/TournamentMatchRepository.server";
import { matchEndedEarly } from "~/features/tournament-match/tournament-match-utils";
import invariant from "~/utils/invariant";
import { roundToNDecimalPlaces } from "~/utils/number";
import type { Tables, WinLossParticipationArray } from "../../../db/tables";
import { ensureOneStandingPerUser } from "../tournament-bracket-utils";
import type { Standing } from "./Bracket";
import { matchEndedEarly } from "./engine";
import type { ParsedBracket } from "./Progression";
import * as Progression from "./Progression";
@ -76,6 +76,7 @@ export function tournamentSummary({
const endedEarly = matchEndedEarly({
opponentOne: match.opponentOne,
opponentTwo: match.opponentTwo,
winnerSide: match.winnerSide,
count: match.roundMaps.count,
countType: match.roundMaps.type,
});
@ -209,9 +210,9 @@ function calculateIndividualPlayerSkills({
*/
function matchToSetMostPlayedUsers(match: AllMatchResult) {
const winner =
match.opponentOne.result === "win" ? match.opponentOne : match.opponentTwo;
match.winnerSide === "opponent1" ? match.opponentOne : match.opponentTwo;
const loser =
match.opponentOne.result === "win" ? match.opponentTwo : match.opponentOne;
match.winnerSide === "opponent1" ? match.opponentTwo : match.opponentOne;
// Handle dropped team sets without game results - use active roster or member list
if (match.maps.length === 0) {
@ -285,13 +286,9 @@ function calculateTeamSkills({
for (const match of results) {
const winner =
match.opponentOne.result === "win"
? match.opponentOne
: match.opponentTwo;
match.winnerSide === "opponent1" ? match.opponentOne : match.opponentTwo;
const loser =
match.opponentOne.result === "win"
? match.opponentTwo
: match.opponentOne;
match.winnerSide === "opponent1" ? match.opponentTwo : match.opponentOne;
// Handle dropped team sets without game results - use active roster or member list
let winnerTeamIdentifier: string;
@ -535,11 +532,11 @@ function playerResultDeltas(
for (const otherParticipant of mostPopularParticipants) {
if (ownerParticipant.userId === otherParticipant.userId) continue;
const result =
const ownerSide =
match.opponentOne.id === ownerParticipant.tournamentTeamId
? match.opponentOne.result
: match.opponentTwo.result;
const won = result === "win";
? "opponent1"
: "opponent2";
const won = match.winnerSide === ownerSide;
addPlayerResult({
ownerUserId: ownerParticipant.userId,

View File

@ -9,14 +9,12 @@ import type { TournamentDataTeam } from "./Tournament.server";
const createOpponent = (
id: number,
result: "win" | "loss",
score: number,
droppedOut = false,
activeRosterUserIds: number[] | null = null,
memberUserIds: number[] = [],
): AllMatchResult["opponentOne"] => ({
id,
result,
score,
droppedOut,
activeRosterUserIds,
@ -195,8 +193,9 @@ describe("tournamentSummary()", () => {
winnerTeamId: 1,
},
],
opponentOne: createOpponent(1, "win", 2),
opponentTwo: createOpponent(2, "loss", 0),
opponentOne: createOpponent(1, 2),
opponentTwo: createOpponent(2, 0),
winnerSide: "opponent1",
roundMaps: {
count: 3,
type: "BEST_OF",
@ -304,8 +303,9 @@ describe("tournamentSummary()", () => {
winnerTeamId: 1,
},
],
opponentOne: createOpponent(1, "win", 2),
opponentTwo: createOpponent(2, "loss", 0),
opponentOne: createOpponent(1, 2),
opponentTwo: createOpponent(2, 0),
winnerSide: "opponent1",
roundMaps: {
count: 3,
type: "BEST_OF",
@ -344,8 +344,9 @@ describe("tournamentSummary()", () => {
winnerTeamId: 1,
},
],
opponentOne: createOpponent(1, "win", 2),
opponentTwo: createOpponent(2, "loss", 0),
opponentOne: createOpponent(1, 2),
opponentTwo: createOpponent(2, 0),
winnerSide: "opponent1",
roundMaps: {
count: 3,
type: "BEST_OF",
@ -430,8 +431,9 @@ describe("tournamentSummary()", () => {
winnerTeamId: 1,
},
],
opponentOne: createOpponent(1, "win", 2),
opponentTwo: createOpponent(2, "loss", 1),
opponentOne: createOpponent(1, 2),
opponentTwo: createOpponent(2, 1),
winnerSide: "opponent1",
roundMaps: {
count: 3,
type: "BEST_OF",
@ -595,8 +597,9 @@ describe("tournamentSummary()", () => {
winnerTeamId: 1,
},
],
opponentOne: createOpponent(1, "win", 3),
opponentTwo: createOpponent(2, "loss", 0),
opponentOne: createOpponent(1, 3),
opponentTwo: createOpponent(2, 0),
winnerSide: "opponent1",
roundMaps: {
count: 3,
type: "BEST_OF",
@ -645,8 +648,9 @@ describe("tournamentSummary()", () => {
winnerTeamId: 1,
},
],
opponentOne: createOpponent(1, "win", 2),
opponentTwo: createOpponent(2, "loss", 0),
opponentOne: createOpponent(1, 2),
opponentTwo: createOpponent(2, 0),
winnerSide: "opponent1",
roundMaps: {
count: 3,
type: "BEST_OF",
@ -901,8 +905,9 @@ describe("tournamentSummary()", () => {
winnerTeamId: 1,
},
],
opponentOne: createOpponent(1, "win", 0),
opponentTwo: createOpponent(2, "loss", 0),
opponentOne: createOpponent(1, 0),
opponentTwo: createOpponent(2, 0),
winnerSide: "opponent1",
roundMaps: {
count: 3,
type: "BEST_OF",
@ -937,8 +942,9 @@ describe("tournamentSummary()", () => {
winnerTeamId: 1,
},
],
opponentOne: createOpponent(1, "win", 1),
opponentTwo: createOpponent(2, "loss", 0),
opponentOne: createOpponent(1, 1),
opponentTwo: createOpponent(2, 0),
winnerSide: "opponent1",
roundMaps: {
count: 3,
type: "BEST_OF",
@ -962,8 +968,9 @@ describe("tournamentSummary()", () => {
winnerTeamId: 3,
},
],
opponentOne: createOpponent(3, "win", 0),
opponentTwo: createOpponent(4, "loss", 0),
opponentOne: createOpponent(3, 0),
opponentTwo: createOpponent(4, 0),
winnerSide: "opponent1",
roundMaps: {
count: 3,
type: "BEST_OF",
@ -1012,8 +1019,9 @@ describe("tournamentSummary()", () => {
winnerTeamId: 1,
},
],
opponentOne: createOpponent(1, "win", 1, false),
opponentTwo: createOpponent(2, "loss", 0, true),
opponentOne: createOpponent(1, 1, false),
opponentTwo: createOpponent(2, 0, true),
winnerSide: "opponent1",
roundMaps: {
count: 3,
type: "BEST_OF",
@ -1038,8 +1046,9 @@ describe("tournamentSummary()", () => {
results: [
{
maps: [],
opponentOne: createOpponent(1, "win", 0, false, [1, 2, 3, 4]),
opponentTwo: createOpponent(2, "loss", 0, true, [5, 6, 7, 8]),
opponentOne: createOpponent(1, 0, false, [1, 2, 3, 4]),
opponentTwo: createOpponent(2, 0, true, [5, 6, 7, 8]),
winnerSide: "opponent1",
roundMaps: {
count: 3,
type: "BEST_OF",
@ -1065,8 +1074,9 @@ describe("tournamentSummary()", () => {
{
maps: [],
// No activeRosterUserIds, but memberUserIds is set
opponentOne: createOpponent(1, "win", 0, false, null, [1, 2, 3, 4]),
opponentTwo: createOpponent(2, "loss", 0, true, null, [5, 6, 7, 8]),
opponentOne: createOpponent(1, 0, false, null, [1, 2, 3, 4]),
opponentTwo: createOpponent(2, 0, true, null, [5, 6, 7, 8]),
winnerSide: "opponent1",
roundMaps: {
count: 3,
type: "BEST_OF",

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -9,12 +9,9 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
name: "Main Bracket",
number: 1,
settings: {
swiss: {
groupCount: 1,
roundCount: 4,
},
groupCount: 1,
roundCount: 4,
},
tournament_id: 891,
type: "swiss",
createdAt: 1734685232,
},
@ -23,15 +20,15 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
{
id: 4443,
number: 1,
stage_id: 1457,
stageId: 1457,
},
],
round: [
{
id: 13715,
group_id: 4443,
groupId: 4443,
number: 1,
stage_id: 1457,
stageId: 1457,
maps: {
count: 5,
type: "BEST_OF",
@ -40,9 +37,9 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
},
{
id: 13716,
group_id: 4443,
groupId: 4443,
number: 2,
stage_id: 1457,
stageId: 1457,
maps: {
count: 5,
type: "BEST_OF",
@ -51,9 +48,9 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
},
{
id: 13717,
group_id: 4443,
groupId: 4443,
number: 3,
stage_id: 1457,
stageId: 1457,
maps: {
count: 5,
type: "BEST_OF",
@ -62,9 +59,9 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
},
{
id: 13718,
group_id: 4443,
groupId: 4443,
number: 4,
stage_id: 1457,
stageId: 1457,
maps: {
count: 5,
type: "BEST_OF",
@ -75,213 +72,195 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
match: [
{
id: 38584,
group_id: 4443,
groupId: 4443,
number: 1,
opponent1: {
id: 18248,
score: 3,
result: "win",
},
opponent2: {
id: 18266,
score: 0,
result: "loss",
},
round_id: 13715,
stage_id: 1457,
status: 4,
roundId: 13715,
stageId: 1457,
startedAt: 1734685232,
winnerSide: "opponent1",
},
{
id: 38585,
group_id: 4443,
groupId: 4443,
number: 2,
opponent1: {
id: 18037,
score: 3,
result: "win",
},
opponent2: {
id: 18212,
score: 0,
result: "loss",
},
round_id: 13715,
stage_id: 1457,
status: 4,
roundId: 13715,
stageId: 1457,
startedAt: 1734685232,
winnerSide: "opponent1",
},
{
id: 38586,
group_id: 4443,
groupId: 4443,
number: 3,
opponent1: {
id: 18255,
score: 3,
result: "win",
},
opponent2: {
id: 18019,
score: 0,
result: "loss",
},
round_id: 13715,
stage_id: 1457,
status: 4,
roundId: 13715,
stageId: 1457,
startedAt: 1734685232,
winnerSide: "opponent1",
},
{
id: 38587,
group_id: 4443,
groupId: 4443,
number: 4,
opponent1: {
id: 18210,
},
opponent2: null,
round_id: 13715,
stage_id: 1457,
status: 2,
roundId: 13715,
stageId: 1457,
startedAt: 1734685232,
winnerSide: null,
},
{
id: 38588,
group_id: 4443,
groupId: 4443,
number: 1,
opponent1: {
id: 18248,
score: 3,
result: "win",
},
opponent2: {
id: 18210,
score: 0,
result: "loss",
},
round_id: 13716,
stage_id: 1457,
status: 4,
roundId: 13716,
stageId: 1457,
startedAt: 1734687519,
winnerSide: "opponent1",
},
{
id: 38589,
group_id: 4443,
groupId: 4443,
number: 2,
opponent1: {
id: 18037,
score: 3,
result: "win",
},
opponent2: {
id: 18255,
score: 2,
result: "loss",
},
round_id: 13716,
stage_id: 1457,
status: 4,
roundId: 13716,
stageId: 1457,
startedAt: 1734687519,
winnerSide: "opponent1",
},
{
id: 38590,
group_id: 4443,
groupId: 4443,
number: 3,
opponent1: {
id: 18266,
score: 3,
result: "win",
},
opponent2: {
id: 18212,
score: 0,
result: "loss",
},
round_id: 13716,
stage_id: 1457,
status: 4,
roundId: 13716,
stageId: 1457,
startedAt: 1734687519,
winnerSide: "opponent1",
},
{
id: 38591,
group_id: 4443,
groupId: 4443,
number: 4,
opponent1: {
id: 18019,
},
opponent2: null,
round_id: 13716,
stage_id: 1457,
status: 2,
roundId: 13716,
stageId: 1457,
startedAt: 1734687519,
winnerSide: null,
},
{
id: 38592,
group_id: 4443,
groupId: 4443,
number: 1,
opponent1: {
id: 18248,
score: 0,
result: "loss",
},
opponent2: {
id: 18037,
score: 3,
result: "win",
},
round_id: 13717,
stage_id: 1457,
status: 4,
roundId: 13717,
stageId: 1457,
startedAt: 1734689680,
winnerSide: "opponent2",
},
{
id: 38593,
group_id: 4443,
groupId: 4443,
number: 2,
opponent1: {
id: 18019,
score: 0,
result: "loss",
},
opponent2: {
id: 18266,
score: 3,
result: "win",
},
round_id: 13717,
stage_id: 1457,
status: 4,
roundId: 13717,
stageId: 1457,
startedAt: 1734689680,
winnerSide: "opponent2",
},
{
id: 38594,
group_id: 4443,
groupId: 4443,
number: 3,
opponent1: {
id: 18210,
score: 0,
result: "loss",
},
opponent2: {
id: 18255,
score: 3,
result: "win",
},
round_id: 13717,
stage_id: 1457,
status: 4,
roundId: 13717,
stageId: 1457,
startedAt: 1734689680,
winnerSide: "opponent2",
},
{
id: 38595,
group_id: 4443,
groupId: 4443,
number: 4,
opponent1: {
id: 18212,
},
opponent2: null,
round_id: 13717,
stage_id: 1457,
status: 2,
roundId: 13717,
stageId: 1457,
startedAt: 1734689680,
winnerSide: null,
},
],
},

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,5 @@
import * as R from "remeda";
import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types";
import type { BracketData } from "~/features/tournament-bracket/core/engine/types";
import type * as Progression from "../Progression";
import { Tournament } from "../Tournament";
import type { TournamentData } from "../Tournament.server";
@ -47,7 +47,7 @@ export const testTournament = ({
},
ctx,
}: {
data?: TournamentManagerDataSet;
data?: BracketData;
ctx?: Partial<TournamentData["ctx"]>;
}) => {
const participant = R.pipe(
@ -57,57 +57,120 @@ export const testTournament = ({
R.unique<number[]>,
);
return new Tournament({
data,
ctx: {
eventId: 1,
id: 1,
tags: null,
organization: null,
tier: null,
tentativeTier: null,
parentTournamentId: null,
parentTournamentName: null,
hasRules: false,
logoUrl: "/test.avif",
discordUrl: null,
startTime: 1705858842,
isFinalized: 0,
name: "test",
castTwitchAccounts: [],
bracketProgressionOverrides: [],
staff: [],
tieBreakerMapPool: [],
toSetMapPool: [],
participatedUsers: [],
castStreams: [],
mapPickingStyle: "AUTO_SZ",
settings: {
bracketProgression: [
{
name: "Main Bracket",
type: "double_elimination",
requiresCheckIn: false,
settings: {},
},
],
},
castedMatchesInfo: null,
teams: nTeams(participant.length, Math.min(...participant)),
author: {
customUrl: null,
customAvatarUrl: null,
discordAvatar: null,
discordId: "123",
username: "test",
pronouns: null,
id: 1,
},
...ctx,
const tournamentCtx: TournamentData["ctx"] = {
eventId: 1,
id: 1,
tags: null,
organization: null,
tier: null,
tentativeTier: null,
parentTournamentId: null,
parentTournamentName: null,
hasRules: false,
logoUrl: "/test.avif",
discordUrl: null,
startTime: 1705858842,
isFinalized: 0,
name: "test",
castTwitchAccounts: [],
bracketProgressionOverrides: [],
staff: [],
tieBreakerMapPool: [],
toSetMapPool: [],
participatedUsers: [],
castStreams: [],
mapPickingStyle: "AUTO_SZ",
settings: {
bracketProgression: [
{
name: "Main Bracket",
type: "double_elimination",
requiresCheckIn: false,
settings: {},
},
],
},
castedMatchesInfo: null,
teams: nTeams(participant.length, Math.min(...participant)),
author: {
customUrl: null,
customAvatarUrl: null,
discordAvatar: null,
discordId: "123",
username: "test",
pronouns: null,
id: 1,
},
...ctx,
};
return new Tournament({
// engine created data has no stage names, the database assigns them from the bracket progression
data: {
...data,
stage: data.stage.map((stage, stageIdx) => ({
...stage,
name:
stage.name ??
tournamentCtx.settings.bracketProgression[stageIdx]?.name,
})),
},
ctx: tournamentCtx,
});
};
/**
* Combines separately created brackets into the bracket data of one tournament,
* offsetting the local ids of every bracket after the first the same way the
* database does when a new stage is added to an existing tournament.
*/
export const mergeStages = (...brackets: BracketData[]): BracketData => {
const merged: BracketData = { stage: [], group: [], round: [], match: [] };
for (const bracket of brackets) {
const offsets = {
stage: merged.stage.length,
group: merged.group.length,
round: merged.round.length,
match: merged.match.length,
};
merged.stage.push(
...bracket.stage.map((stage) => ({
...stage,
id: stage.id + offsets.stage,
number: offsets.stage + 1,
})),
);
merged.group.push(
...bracket.group.map((group) => ({
...group,
id: group.id + offsets.group,
stageId: group.stageId + offsets.stage,
})),
);
merged.round.push(
...bracket.round.map((round) => ({
...round,
id: round.id + offsets.round,
stageId: round.stageId + offsets.stage,
groupId: round.groupId + offsets.group,
})),
);
merged.match.push(
...bracket.match.map((match) => ({
...match,
id: match.id + offsets.match,
stageId: match.stageId + offsets.stage,
groupId: match.groupId + offsets.group,
roundId: match.roundId + offsets.round,
})),
);
}
return merged;
};
const DEFAULT_PROGRESSION_ARGS = {
requiresCheckIn: false,
settings: {},

View File

@ -3,13 +3,13 @@
import type { Tables, TournamentRoundMaps } from "~/db/tables";
import * as MapList from "~/features/map-list-generator/core/MapList";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import type { Round } from "~/modules/brackets-model";
import type { RoundData } from "~/features/tournament-bracket/core/engine/types";
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import { logger } from "~/utils/logger";
import { assertUnreachable } from "~/utils/types";
export type BracketMapCounts = Map<
// round.group_id ->
// round.groupId ->
number,
// round.number ->
Map<number, { count: number; type: "BEST_OF" }>
@ -17,7 +17,7 @@ export type BracketMapCounts = Map<
export interface GenerateTournamentRoundMaplistArgs {
pool: Array<{ mode: ModeShort; stageId: StageId }>;
rounds: Round[];
rounds: RoundData[];
mapCounts: BracketMapCounts;
type: Tables["TournamentStage"]["type"];
roundsWithPickBan: Set<number>;
@ -92,7 +92,7 @@ export function generateTournamentRoundMaplist(
}
function getFilteredRounds(
rounds: Round[],
rounds: RoundData[],
type: Tables["TournamentStage"]["type"],
) {
if (type !== "round_robin" && type !== "swiss") return rounds;
@ -101,19 +101,19 @@ function getFilteredRounds(
// (e.g. groups of 3 and 2). Use the group with the most rounds: it covers
// every round number and its map list is shared with the smaller groups.
const fullestGroupId = fullestGroupIdByRounds(rounds);
return rounds.filter((x) => x.group_id === fullestGroupId);
return rounds.filter((x) => x.groupId === fullestGroupId);
}
function fullestGroupIdByRounds(rounds: Round[]) {
function fullestGroupIdByRounds(rounds: RoundData[]) {
const roundCountByGroup = new Map<number, number>();
for (const round of rounds) {
roundCountByGroup.set(
round.group_id,
(roundCountByGroup.get(round.group_id) ?? 0) + 1,
round.groupId,
(roundCountByGroup.get(round.groupId) ?? 0) + 1,
);
}
let fullestGroupId = rounds[0].group_id;
let fullestGroupId = rounds[0].groupId;
for (const [groupId, count] of roundCountByGroup) {
if (count > roundCountByGroup.get(fullestGroupId)!)
fullestGroupId = groupId;
@ -122,8 +122,11 @@ function fullestGroupIdByRounds(rounds: Round[]) {
return fullestGroupId;
}
function sortRounds(rounds: Round[], type: Tables["TournamentStage"]["type"]) {
const groupIds = rounds.map((x) => x.group_id);
function sortRounds(
rounds: RoundData[],
type: Tables["TournamentStage"]["type"],
) {
const groupIds = rounds.map((x) => x.groupId);
const minGroupId = Math.min(...groupIds);
const maxGroupId = Math.max(...groupIds);
@ -137,13 +140,13 @@ function sortRounds(rounds: Round[], type: Tables["TournamentStage"]["type"]) {
return rounds.toSorted((a, b) => {
if (type === "double_elimination") {
const rankDiff =
doubleEliminationGroupRank(a.group_id) -
doubleEliminationGroupRank(b.group_id);
doubleEliminationGroupRank(a.groupId) -
doubleEliminationGroupRank(b.groupId);
if (rankDiff !== 0) return rankDiff;
}
if (type === "single_elimination") {
// finals and 3rd place match last
if (a.group_id !== b.group_id) return a.group_id - b.group_id;
if (a.groupId !== b.groupId) return a.groupId - b.groupId;
}
return a.number - b.number;
@ -151,7 +154,7 @@ function sortRounds(rounds: Round[], type: Tables["TournamentStage"]["type"]) {
}
function resolveRoundMapCount(
round: Round,
round: RoundData,
counts: BracketMapCounts,
type: Tables["TournamentStage"]["type"],
) {
@ -160,12 +163,12 @@ function resolveRoundMapCount(
const groupId =
type === "round_robin" || type === "swiss"
? fullestGroupIdByCounts(counts)
: round.group_id;
: round.groupId;
const count = counts.get(groupId)?.get(round.number)?.count;
if (typeof count === "undefined") {
logger.warn(
`No map count found for round ${round.number} (group ${round.group_id})`,
`No map count found for round ${round.number} (group ${round.groupId})`,
);
return 5;
}

View File

@ -1,17 +0,0 @@
import { sql } from "~/db/sql";
const stm = sql.prepare(/* sql */ `
delete from "TournamentMatchPickBanEvent"
where "matchId" = @matchId
and "number" = @number
`);
export function deletePickBanEvent({
matchId,
number,
}: {
matchId: number;
number: number;
}) {
return stm.run({ matchId, number });
}

View File

@ -321,7 +321,7 @@ function getAbDivisionsStartError(
return null;
}
const groupCount = new Set(bracket.data.round.map((r) => r.group_id)).size;
const groupCount = new Set(bracket.data.round.map((r) => r.groupId)).size;
const abDivisionsBySeedOrder = bracket.seeding.map(
(teamId) => tournament.teamById(teamId)?.abDivision,
);

View File

@ -31,36 +31,13 @@ const reportedMatchPosition = z.preprocess(
.max(Math.max(...TOURNAMENT.AVAILABLE_BEST_OF) - 1),
);
const point = z.number().int().min(0).max(100);
const points = z.preprocess(
safeJSONParse,
z
.tuple([point, point])
.nullish()
.refine(
(val) => {
if (!val) return true;
const [p1, p2] = val;
// KO
if (p1 === 100 && p2 === 0) return true;
if (p2 === 100 && p1 === 0) return true;
// ...or no points sent at all (TODO: if we decide that this KO only approach is solid then we can do a proper data model migration)
if (p1 === 0 && p2 === 0) return true;
return false;
},
{
message: "Invalid points. Valid: 100-0, 0-100 or 0-0.",
},
),
);
const ko = z.preprocess(safeJSONParse, z.boolean().nullish());
export const matchSchema = z.union([
z.object({
_action: _action("REPORT_SCORE"),
winnerTeamId: id,
position: reportedMatchPosition,
points,
ko,
}),
z.object({
_action: _action("SET_ACTIVE_ROSTER"),
@ -80,7 +57,7 @@ export const matchSchema = z.union([
_action: _action("UPDATE_REPORTED_SCORE"),
rosters: bothTeamPlayerIds,
resultId: id,
points,
ko,
}),
z.object({
_action: _action("REOPEN_MATCH"),

View File

@ -1,34 +1,9 @@
import { describe, expect, test } from "vitest";
import {
fillWithNullTillPowerOfTwo,
groupNumberToLetters,
validateBadgeReceivers,
} from "./tournament-bracket-utils";
const powerOfTwoParamsToResults: [
amountOfTeams: number,
expectedNullCount: number,
][] = [
[32, 0],
[16, 0],
[8, 0],
[31, 1],
[0, 0],
[17, 15],
];
describe("fillWithNullTillPowerOfTwo()", () => {
for (const [amountOfTeams, expectedNullCount] of powerOfTwoParamsToResults) {
test(`amountOfTeams=${amountOfTeams} -> ${expectedNullCount}`, () => {
expect(
fillWithNullTillPowerOfTwo(Array(amountOfTeams).fill("team")).filter(
(x) => x === null,
).length,
).toBe(expectedNullCount);
});
}
});
const groupNumberToLettersParamsToResult = [
{ groupNumber: 1, expected: "A" },
{ groupNumber: 26, expected: "Z" },

View File

@ -5,13 +5,6 @@ import type { Standing } from "./core/Bracket";
export const tournamentWebsocketRoom = (tournamentId: number) =>
`tournament__${tournamentId}`;
export function fillWithNullTillPowerOfTwo<T>(arr: T[]) {
const nextPowerOfTwo = 2 ** Math.ceil(Math.log2(arr.length));
const nullsToAdd = nextPowerOfTwo - arr.length;
return [...arr, ...new Array(nullsToAdd).fill(null)];
}
/**
* Converts a group number to its corresponding letter representation.
*

View File

@ -71,9 +71,15 @@ const createMatch = async (args: {
groupId: args.groupId,
roundId: args.roundId,
number: args.number,
status: 4,
opponentOne: JSON.stringify({ id: args.teamOneId, score: 2 }),
opponentTwo: JSON.stringify({ id: args.teamTwoId, score: 0 }),
opponentOne: JSON.stringify({
id: args.teamOneId,
score: 2,
}),
opponentTwo: JSON.stringify({
id: args.teamTwoId,
score: 0,
}),
winnerSide: "opponent1",
})
.returning("id")
.executeTakeFirstOrThrow();

View File

@ -1,7 +1,8 @@
import { sql } from "kysely";
import { type Insertable, sql, type Transaction } from "kysely";
import { jsonArrayFrom } from "kysely/helpers/sqlite";
import { db } from "~/db/sql";
import { TournamentMatchStatus, type TournamentRoundMaps } from "~/db/tables";
import type { DB, TournamentRoundMaps } from "~/db/tables";
import type { Side } from "~/features/tournament-bracket/core/engine/types";
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import invariant from "~/utils/invariant";
import { customAvatarUrl } from "~/utils/kysely.server";
@ -15,12 +16,6 @@ const opponentOneScore = sql<
const opponentTwoScore = sql<
number | null
>`"TournamentMatch"."opponentTwo" ->> '$.score'`;
const opponentOneResult = sql<
"win" | "loss"
>`"TournamentMatch"."opponentOne" ->> '$.result'`;
const opponentTwoResult = sql<
"win" | "loss"
>`"TournamentMatch"."opponentTwo" ->> '$.result'`;
export type FindMatchById = NonNullable<Unwrapped<typeof findMatchById>>;
export async function findMatchById(id: number) {
@ -42,9 +37,9 @@ export async function findMatchById(id: number) {
"TournamentMatch.groupId",
"TournamentMatch.opponentOne",
"TournamentMatch.opponentTwo",
"TournamentMatch.winnerSide",
"TournamentMatch.chatCode",
"TournamentMatch.startedAt",
"TournamentMatch.status",
"Tournament.mapPickingStyle",
"TournamentRound.id as roundId",
"TournamentRound.maps as roundMaps",
@ -91,26 +86,17 @@ export async function findMatchById(id: number) {
return {
...row,
opponentOne: normalizeOpponent(row.opponentOne),
opponentTwo: normalizeOpponent(row.opponentTwo),
bestOf: row.roundMaps.count,
};
}
// Kysely's ParseJSONResultsPlugin only parses strings starting with `[` or `{`,
// so the JSON `null` stored for BYE opponents survives as the literal text "null".
function normalizeOpponent<T>(value: T): T | null {
return (value as unknown) === "null" ? null : value;
}
export function findResultById(id: number) {
return db
.selectFrom("TournamentMatchGameResult")
.select([
"TournamentMatchGameResult.id",
"TournamentMatchGameResult.matchId",
"TournamentMatchGameResult.opponentOnePoints",
"TournamentMatchGameResult.opponentTwoPoints",
"TournamentMatchGameResult.ko",
"TournamentMatchGameResult.winnerTeamId",
])
.where("TournamentMatchGameResult.id", "=", id)
@ -127,8 +113,7 @@ export function findResultsByMatchId(matchId: number) {
"TournamentMatchGameResult.mode",
"TournamentMatchGameResult.source",
"TournamentMatchGameResult.createdAt",
"TournamentMatchGameResult.opponentOnePoints",
"TournamentMatchGameResult.opponentTwoPoints",
"TournamentMatchGameResult.ko",
jsonArrayFrom(
eb
.selectFrom("TournamentMatchGameResultParticipant")
@ -148,10 +133,97 @@ export function findResultsByMatchId(matchId: number) {
.execute();
}
/** Inserts a single game result, returning the id it was given. */
export function insertResult(
args: Insertable<DB["TournamentMatchGameResult"]>,
trx?: Transaction<DB>,
) {
return (trx ?? db)
.insertInto("TournamentMatchGameResult")
.values(args)
.returning("id")
.executeTakeFirstOrThrow();
}
/** Updates the KO status of a single game result. */
export function updateResultKo(
args: { id: number; ko: boolean },
trx?: Transaction<DB>,
) {
return (trx ?? db)
.updateTable("TournamentMatchGameResult")
.set({ ko: Number(args.ko) })
.where("TournamentMatchGameResult.id", "=", args.id)
.execute();
}
/** Sets the players who participated in a game result, replacing any existing ones. */
export async function setParticipants(
args: {
resultId: number;
participants: Array<
Pick<
Insertable<DB["TournamentMatchGameResultParticipant"]>,
"userId" | "tournamentTeamId"
>
>;
},
trx: Transaction<DB>,
) {
await trx
.deleteFrom("TournamentMatchGameResultParticipant")
.where(
"TournamentMatchGameResultParticipant.matchGameResultId",
"=",
args.resultId,
)
.execute();
await trx
.insertInto("TournamentMatchGameResultParticipant")
.values(
args.participants.map((participant) => ({
...participant,
matchGameResultId: args.resultId,
})),
)
.execute();
}
/** Deletes a single game result by its id. */
export function deleteResultById(id: number, trx?: Transaction<DB>) {
return (trx ?? db)
.deleteFrom("TournamentMatchGameResult")
.where("TournamentMatchGameResult.id", "=", id)
.execute();
}
/** Deletes all pick/ban events belonging to a match. */
export function deletePickBanEventsByMatchId(
matchId: number,
trx?: Transaction<DB>,
) {
return (trx ?? db)
.deleteFrom("TournamentMatchPickBanEvent")
.where("TournamentMatchPickBanEvent.matchId", "=", matchId)
.execute();
}
/** Deletes a single pick/ban event by its match and event number. */
export function deletePickBanEvent(
args: { matchId: number; number: number },
trx?: Transaction<DB>,
) {
return (trx ?? db)
.deleteFrom("TournamentMatchPickBanEvent")
.where("TournamentMatchPickBanEvent.matchId", "=", args.matchId)
.where("TournamentMatchPickBanEvent.number", "=", args.number)
.execute();
}
interface AllMatchResultOpponent {
id: number;
score: number;
result: "win" | "loss";
droppedOut: boolean;
activeRosterUserIds: number[] | null;
memberUserIds: number[];
@ -159,6 +231,7 @@ interface AllMatchResultOpponent {
export interface AllMatchResult {
opponentOne: AllMatchResultOpponent;
opponentTwo: AllMatchResultOpponent;
winnerSide: Side;
roundMaps: TournamentRoundMaps;
maps: Array<{
stageId: StageId;
@ -202,8 +275,7 @@ export async function allResultsByTournamentId(
sql<number>`"TournamentMatch"."opponentTwo" ->> '$.score'`.as(
"opponentTwoScore",
),
opponentOneResult.as("opponentOneResult"),
opponentTwoResult.as("opponentTwoResult"),
"TournamentMatch.winnerSide",
"TournamentRound.maps as roundMaps",
"Team1.droppedOut as opponentOneDroppedOut",
"Team2.droppedOut as opponentTwoDroppedOut",
@ -251,7 +323,7 @@ export async function allResultsByTournamentId(
).as("maps"),
])
.where("TournamentStage.tournamentId", "=", tournamentId)
.where(opponentOneResult, "is not", null)
.where("TournamentMatch.winnerSide", "is not", null)
// strictly speaking the order by condition is not accurate, future improvement would be to add order conditions that match the tournament structure
.orderBy("TournamentMatch.id", "asc")
.execute();
@ -260,7 +332,6 @@ export async function allResultsByTournamentId(
const opponentOne: AllMatchResultOpponent = {
id: row.opponentOneId,
score: row.opponentOneScore,
result: row.opponentOneResult,
droppedOut: row.opponentOneDroppedOut === 1,
activeRosterUserIds: row.opponentOneActiveRoster,
memberUserIds: row.opponentOneMembers.map((member) => member.userId),
@ -268,15 +339,17 @@ export async function allResultsByTournamentId(
const opponentTwo: AllMatchResultOpponent = {
id: row.opponentTwoId,
score: row.opponentTwoScore,
result: row.opponentTwoResult,
droppedOut: row.opponentTwoDroppedOut === 1,
activeRosterUserIds: row.opponentTwoActiveRoster,
memberUserIds: row.opponentTwoMembers.map((member) => member.userId),
};
invariant(row.winnerSide, "Match has no winner");
return {
opponentOne,
opponentTwo,
winnerSide: row.winnerSide,
roundMaps: row.roundMaps,
maps: row.maps.map((map) => {
invariant(map.participants.length > 0, "No participants found");
@ -429,7 +502,7 @@ export function findByTournamentTeamId(tournamentTeamId: number) {
eb(opponentTwoId, "=", tournamentTeamId),
]),
)
.where("TournamentMatch.status", ">=", TournamentMatchStatus.Completed)
.where("TournamentMatch.winnerSide", "is not", null)
.where((eb) =>
eb.exists(
eb

View File

@ -1,20 +1,22 @@
import type { Transaction } from "kysely";
import type { ActionFunction } from "react-router";
import { sql } from "~/db/sql";
import { TournamentMatchStatus } from "~/db/tables";
import { db } from "~/db/sql";
import type { DB } from "~/db/tables";
import { requireUser } from "~/features/auth/core/user.server";
import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
import * as ReportedWeaponRepository from "~/features/sendouq-match/ReportedWeaponRepository.server";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
import { endDroppedTeamMatches } from "~/features/tournament/tournament-utils.server";
import { getServerTournamentManager } from "~/features/tournament-bracket/core/brackets-manager/manager.server";
import * as BracketRepository from "~/features/tournament-bracket/BracketRepository.server";
import * as Engine from "~/features/tournament-bracket/core/engine";
import * as PickBan from "~/features/tournament-bracket/core/PickBan";
import type { Tournament } from "~/features/tournament-bracket/core/Tournament";
import {
clearTournamentDataCache,
type TournamentDataTeam,
tournamentFromDB,
} from "~/features/tournament-bracket/core/Tournament.server";
import { deletePickBanEvent } from "~/features/tournament-bracket/queries/deletePickBanEvent.server";
import {
matchPageParamsSchema,
matchSchema,
@ -37,16 +39,8 @@ import { errorIsSqliteUniqueConstraintFailure } from "~/utils/sql";
import { assertUnreachable } from "~/utils/types";
import { executeRoll } from "../core/executeRoll.server";
import { resolveMapList } from "../core/mapList.server";
import { deleteMatchPickBanEvents } from "../queries/deleteMatchPickBanEvents.server";
import { deleteParticipantsByMatchGameResultId } from "../queries/deleteParticipantsByMatchGameResultId.server";
import { deleteTournamentMatchGameResultById } from "../queries/deleteTournamentMatchGameResultById.server";
import { insertTournamentMatchGameResult } from "../queries/insertTournamentMatchGameResult.server";
import { insertTournamentMatchGameResultParticipant } from "../queries/insertTournamentMatchGameResultParticipant.server";
import { updateMatchGameResultPoints } from "../queries/updateMatchGameResultPoints.server";
import type { FindMatchById } from "../TournamentMatchRepository.server";
import {
isSetOverByScore,
matchEndedEarly,
matchIsLocked,
tournamentMatchWebsocketRoom,
} from "../tournament-match-utils";
@ -78,8 +72,7 @@ export const action: ActionFunction = async ({ params, request }) => {
);
errorToastIfFalsy(
match.status !== TournamentMatchStatus.Locked &&
match.status !== TournamentMatchStatus.Waiting,
tournament.matchStatusById(match.id) !== "PENDING",
"Match is locked, waiting for teams to finish their previous matches",
);
@ -93,8 +86,6 @@ export const action: ActionFunction = async ({ params, request }) => {
);
};
const manager = getServerTournamentManager();
const scores: [number, number] = [
match.opponentOne?.score ?? 0,
match.opponentTwo?.score ?? 0,
@ -162,37 +153,14 @@ export const action: ActionFunction = async ({ params, request }) => {
];
invariant(currentMap, "Can't resolve current map");
const scoreToIncrement = () => {
if (data.winnerTeamId === match.opponentOne?.id) return 0;
if (data.winnerTeamId === match.opponentTwo?.id) return 1;
errorToastIfFalsy(false, "Winner team id is invalid");
};
errorToastIfFalsy(
!data.points ||
data.points[0] === data.points[1] ||
(scoreToIncrement() === 0 && data.points[0] > data.points[1]) ||
(scoreToIncrement() === 1 && data.points[1] > data.points[0]),
"Points are invalid (winner must have more points than loser)",
);
const bracket = tournament.bracketByIdx(
tournament.matchIdToBracketIdx(match.id)!,
)!;
errorToastIfFalsy(
!bracket.collectResultsWithPoints || data.points,
"Points are required for this bracket",
!bracket.collectsKos || typeof data.ko === "boolean",
"KO status is required for this bracket",
);
scores[scoreToIncrement()]++;
const setOver = isSetOverByScore({
count: match.roundMaps?.count ?? match.bestOf,
countType: match.roundMaps?.type ?? "BEST_OF",
scores,
});
const teamOneRoster = tournamentTeamToActiveRosterUserIds(
tournament.teamById(match.opponentOne.id!)!,
tournament.minMembersPerTeam,
@ -212,53 +180,51 @@ export const action: ActionFunction = async ({ params, request }) => {
);
try {
sql.transaction(() => {
manager.update.match({
id: match.id,
opponent1: {
score: scores[0],
result: setOver && scores[0] > scores[1] ? "win" : undefined,
},
opponent2: {
score: scores[1],
result: setOver && scores[1] > scores[0] ? "win" : undefined,
const { result: reported, endedMatchIds } =
await executeBracketOperation({
tournamentId,
tournament,
operation: (bracketData) =>
Engine.reportGameResult(bracketData, {
matchId: match.id,
winnerTeamId: data.winnerTeamId,
}),
endDroppedTeams: (result) => result.setOver,
inTransaction: async (_result, trx) => {
const result = await TournamentMatchRepository.insertResult(
{
matchId: match.id,
mode: currentMap.mode,
stageId: currentMap.stageId,
reporterId: user.id,
winnerTeamId: data.winnerTeamId,
number: data.position + 1,
source: String(currentMap.source),
ko: bracket.collectsKos ? Number(Boolean(data.ko)) : null,
},
trx,
);
await TournamentMatchRepository.setParticipants(
{
resultId: result.id,
participants: [
...teamOneRoster.map((userId) => ({
userId,
tournamentTeamId: match.opponentOne!.id!,
})),
...teamTwoRoster.map((userId) => ({
userId,
tournamentTeamId: match.opponentTwo!.id!,
})),
],
},
trx,
);
},
});
const result = insertTournamentMatchGameResult({
matchId: match.id,
mode: currentMap.mode,
stageId: currentMap.stageId,
reporterId: user.id,
winnerTeamId: data.winnerTeamId,
number: data.position + 1,
source: String(currentMap.source),
opponentOnePoints: data.points?.[0] ?? null,
opponentTwoPoints: data.points?.[1] ?? null,
});
for (const userId of teamOneRoster) {
insertTournamentMatchGameResultParticipant({
matchGameResultId: result.id,
userId,
tournamentTeamId: match.opponentOne!.id!,
});
}
for (const userId of teamTwoRoster) {
insertTournamentMatchGameResultParticipant({
matchGameResultId: result.id,
userId,
tournamentTeamId: match.opponentTwo!.id!,
});
}
if (setOver) {
endedDroppedMatchIds = endDroppedTeamMatches({
tournament,
manager,
});
}
})();
endedDroppedMatchIds = endedMatchIds;
setIsOver = reported.setOver;
} catch (error) {
// another request already reported this game in the race window,
// let their page refresh to pick up the already-recorded result
@ -268,7 +234,7 @@ export const action: ActionFunction = async ({ params, request }) => {
throw error;
}
if (setOver) {
if (setIsOver) {
// the set ended, so weapons reported in advance for map indexes
// beyond the games actually played are trimmed
await ReportedWeaponRepository.deleteExtraByTournamentMatchId({
@ -279,7 +245,6 @@ export const action: ActionFunction = async ({ params, request }) => {
emitMatchUpdate = true;
emitTournamentUpdate = true;
setIsOver = setOver;
break;
}
@ -324,14 +289,6 @@ export const action: ActionFunction = async ({ params, request }) => {
const lastResult = results[results.length - 1];
invariant(lastResult, "Last result is missing");
const shouldReset = results.length === 1;
if (lastResult.winnerTeamId === match.opponentOne?.id) {
scores[0]--;
} else {
scores[1]--;
}
logger.info(
`Undoing score: Position: ${data.position}; User ID: ${user.id}; Match ID: ${match.id}`,
);
@ -370,27 +327,26 @@ export const action: ActionFunction = async ({ params, request }) => {
return unplayedPicks[0] ? [unplayedPicks[0].number] : [];
})();
sql.transaction(() => {
deleteTournamentMatchGameResultById(lastResult.id);
await executeBracketOperation({
tournamentId,
tournament,
operation: (bracketData) =>
Engine.undoGameResult(bracketData, {
matchId: match.id,
lastGameWinnerTeamId: lastResult.winnerTeamId,
}),
endDroppedTeams: false,
inTransaction: async (_result, trx) => {
await TournamentMatchRepository.deleteResultById(lastResult.id, trx);
manager.update.match({
id: match.id,
opponent1: {
score: shouldReset ? undefined : scores[0],
},
opponent2: {
score: shouldReset ? undefined : scores[1],
},
});
if (shouldReset) {
manager.reset.matchResults(match.id);
}
for (const number of pickBanEventNumbersToDelete) {
deletePickBanEvent({ matchId, number });
}
})();
for (const number of pickBanEventNumbersToDelete) {
await TournamentMatchRepository.deletePickBanEvent(
{ matchId, number },
trx,
);
}
},
});
emitMatchUpdate = true;
emitTournamentUpdate = true;
@ -427,61 +383,48 @@ export const action: ActionFunction = async ({ params, request }) => {
"Invalid roster",
);
const hadPoints = typeof result.opponentOnePoints === "number";
const willHavePoints = typeof data.points?.[0] === "number";
const bracket = tournament.bracketByIdx(
tournament.matchIdToBracketIdx(match.id)!,
)!;
errorToastIfFalsy(
(hadPoints && willHavePoints) || (!hadPoints && !willHavePoints),
"Points mismatch",
!bracket.collectsKos || typeof data.ko === "boolean",
"KO status is required for this bracket",
);
if (data.points) {
if (data.points[0] !== result.opponentOnePoints) {
// changing points at this point could retroactively change who advanced from the group
errorToastIfFalsy(
tournament.matchCanBeReopened(match.id),
"Bracket has progressed",
);
}
if (data.points[0] === 100) {
errorToastIfFalsy(
result.winnerTeamId === match.opponentOne!.id,
"KO winner must match the result winner",
);
} else if (data.points[1] === 100) {
errorToastIfFalsy(
result.winnerTeamId === match.opponentTwo!.id,
"KO winner must match the result winner",
);
}
const wasKo = Boolean(result.ko);
if (typeof data.ko === "boolean" && data.ko !== wasKo) {
// changing the KO status at this point could retroactively change who advanced from the group
errorToastIfFalsy(
tournament.matchCanBeReopened(match.id),
"Bracket has progressed",
);
}
sql.transaction(() => {
if (data.points) {
updateMatchGameResultPoints({
matchGameResultId: result.id,
opponentOnePoints: data.points[0],
opponentTwoPoints: data.points[1],
});
await db.transaction().execute(async (trx) => {
if (typeof data.ko === "boolean") {
await TournamentMatchRepository.updateResultKo(
{ id: result.id, ko: data.ko },
trx,
);
}
deleteParticipantsByMatchGameResultId(result.id);
for (const userId of data.rosters[0]) {
insertTournamentMatchGameResultParticipant({
matchGameResultId: result.id,
userId,
tournamentTeamId: match.opponentOne!.id!,
});
}
for (const userId of data.rosters[1]) {
insertTournamentMatchGameResultParticipant({
matchGameResultId: result.id,
userId,
tournamentTeamId: match.opponentTwo!.id!,
});
}
})();
await TournamentMatchRepository.setParticipants(
{
resultId: result.id,
participants: [
...data.rosters[0].map((userId) => ({
userId,
tournamentTeamId: match.opponentOne!.id!,
})),
...data.rosters[1].map((userId) => ({
userId,
tournamentTeamId: match.opponentTwo!.id!,
})),
],
},
trx,
);
});
emitMatchUpdate = true;
emitTournamentUpdate = true;
@ -633,11 +576,6 @@ export const action: ActionFunction = async ({ params, request }) => {
break;
}
case "REOPEN_MATCH": {
const scoreOne = match.opponentOne?.score ?? 0;
const scoreTwo = match.opponentTwo?.score ?? 0;
invariant(typeof scoreOne === "number", "Score one is missing");
invariant(typeof scoreTwo === "number", "Score two is missing");
errorToastIfFalsy(tournament.isOrganizer(user), "Not an organizer");
errorToastIfFalsy(
tournament.matchCanBeReopened(match.id),
@ -648,59 +586,42 @@ export const action: ActionFunction = async ({ params, request }) => {
await TournamentMatchRepository.findResultsByMatchId(matchId);
const lastResult = results[results.length - 1];
const endedEarly = matchEndedEarly({
opponentOne: { score: scoreOne, result: match.opponentOne?.result },
opponentTwo: { score: scoreTwo, result: match.opponentTwo?.result },
count: match.roundMaps.count,
countType: match.roundMaps.type,
});
if (!endedEarly) {
invariant(scoreOne !== scoreTwo, "Scores are equal");
invariant(lastResult, "Last result is missing");
if (lastResult.winnerTeamId === match.opponentOne?.id) {
scores[0]--;
} else {
scores[1]--;
}
}
logger.info(
`Reopening match: User ID: ${user.id}; Match ID: ${match.id}; Ended early: ${endedEarly}`,
);
const followingMatches = tournament.followingMatches(match.id);
const bracketFormat = tournament.bracketByIdx(
tournament.matchIdToBracketIdx(match.id)!,
)!.type;
sql.transaction(() => {
// edge case but for round robin we can just leave the match as is, lock it then unlock later to continue where they left off (should not really ever happen)
if (bracketFormat !== "round_robin") {
for (const followingMatch of followingMatches) {
deleteMatchPickBanEvents(followingMatch.id);
const { result: reopened } = await executeBracketOperation({
tournamentId,
tournament,
operation: (bracketData) => Engine.reopenMatch(bracketData, match.id),
endDroppedTeams: false,
inTransaction: async (result, trx) => {
// edge case but for round robin we can just leave the match as is, lock it then unlock later to continue where they left off (should not really ever happen)
if (bracketFormat !== "round_robin") {
for (const followingMatch of followingMatches) {
await TournamentMatchRepository.deletePickBanEventsByMatchId(
followingMatch.id,
trx,
);
}
}
}
// when the set was force-ended early no extra result was inserted for
// the forced win, so the last result is a genuinely played game and must
// be kept to avoid desyncing the score from the results
if (!endedEarly && lastResult) {
deleteTournamentMatchGameResultById(lastResult.id);
}
// when the set was force-ended early no extra result was inserted for
// the forced win, so the last result is a genuinely played game and must
// be kept to avoid desyncing the score from the results
if (!result.endedEarly) {
invariant(lastResult, "Last result is missing");
await TournamentMatchRepository.deleteResultById(
lastResult.id,
trx,
);
}
},
});
manager.update.match({
id: match.id,
opponent1: {
score: endedEarly ? scoreOne : scores[0],
result: undefined,
},
opponent2: {
score: endedEarly ? scoreTwo : scores[1],
result: undefined,
},
});
})();
logger.info(
`Reopening match: User ID: ${user.id}; Match ID: ${match.id}; Ended early: ${reopened.endedEarly}`,
);
// the teams advanced into following matches are being pulled back out,
// so those "waiting for teams" pages need to revalidate too
@ -744,11 +665,8 @@ export const action: ActionFunction = async ({ params, request }) => {
"Invalid Twitch account",
);
// can't lock if match status is not Locked or Waiting (team(s) busy with previous match), let's update their view to reflect that
if (
match.status !== TournamentMatchStatus.Locked &&
match.status !== TournamentMatchStatus.Waiting
) {
// can't lock if the match can already be played, let's update their view to reflect that
if (tournament.matchStatusById(match.id) !== "PENDING") {
return null;
}
@ -783,11 +701,7 @@ export const action: ActionFunction = async ({ params, request }) => {
match.opponentOne?.id && match.opponentTwo?.id,
"Teams are missing",
);
errorToastIfFalsy(
match.opponentOne?.result !== "win" &&
match.opponentTwo?.result !== "win",
"Match is already over",
);
errorToastIfFalsy(!match.winnerSide, "Match is already over");
// Determine winner (random if not specified)
const winnerTeamId = (() => {
@ -810,24 +724,17 @@ export const action: ActionFunction = async ({ params, request }) => {
`Ending set by organizer: User ID: ${user.id}; Match ID: ${match.id}; Winner: ${winnerTeamId}; Random: ${!data.winnerTeamId}`,
);
sql.transaction(() => {
manager.update.match({
id: match.id,
opponent1: {
score: match.opponentOne?.score,
result: winnerTeamId === match.opponentOne!.id ? "win" : "loss",
},
opponent2: {
score: match.opponentTwo?.score,
result: winnerTeamId === match.opponentTwo!.id ? "win" : "loss",
},
});
endedDroppedMatchIds = endDroppedTeamMatches({
tournament,
manager,
});
})();
const { endedMatchIds } = await executeBracketOperation({
tournamentId,
tournament,
operation: (bracketData) =>
Engine.endSet(bracketData, {
matchId: match.id,
winnerTeamId,
}),
endDroppedTeams: true,
});
endedDroppedMatchIds = endedMatchIds;
// the set ended early so no further games will be played; trim weapons
// reported in advance for map indexes beyond the games actually played
@ -938,8 +845,66 @@ function canReportTournamentScore({
isMemberOfATeamInTheMatch: boolean;
isOrganizer: boolean;
}) {
const matchIsOver =
match.opponentOne?.result === "win" || match.opponentTwo?.result === "win";
return !matchIsOver && (isMemberOfATeamInTheMatch || isOrganizer);
return !match.winnerSide && (isMemberOfATeamInTheMatch || isOrganizer);
}
/**
* Runs an engine operation against freshly hydrated bracket data and persists
* the resulting match changes, all in one transaction: hydrate operate
* (end dropped teams' matches) apply changes extra statements.
*/
async function executeBracketOperation<T extends Engine.EngineResult>({
tournamentId,
tournament,
operation,
endDroppedTeams,
inTransaction,
}: {
tournamentId: number;
tournament: Tournament;
operation: (bracketData: Engine.BracketData) => T;
/** Whether unfinished matches of dropped out teams should be ended after the operation (resolved from its result when given a function). */
endDroppedTeams: boolean | ((result: T) => boolean);
/** Extra statements to run inside the same transaction, after the match changes have been applied. */
inTransaction?: (result: T, trx: Transaction<DB>) => void | Promise<void>;
}): Promise<{ result: T; endedMatchIds: number[] }> {
let result!: T;
let endedMatchIds: number[] = [];
await db.transaction().execute(async (trx) => {
const bracketData = await BracketRepository.findByTournamentId(
tournamentId,
trx,
);
result = operation(bracketData);
let applied: Engine.EngineResult = result;
const shouldEndDroppedTeamMatches =
typeof endDroppedTeams === "function"
? endDroppedTeams(result)
: endDroppedTeams;
if (shouldEndDroppedTeamMatches) {
const droppedResult = endDroppedTeamMatches({
tournament,
data: result.data,
});
endedMatchIds = droppedResult.endedMatchIds;
applied = {
data: droppedResult.data,
changedMatches: [
...result.changedMatches,
...droppedResult.changedMatches,
],
};
}
await BracketRepository.applyMatchChanges(
{ previousData: bracketData, result: applied },
trx,
);
await inTransaction?.(result, trx);
});
return { result, endedMatchIds };
}

View File

@ -9,11 +9,11 @@ import { useMatchWeaponReport } from "~/components/match-page/useMatchWeaponRepo
import { WeaponReporter } from "~/components/match-page/WeaponReporter";
import { useUser } from "~/features/auth/core/user";
import { useTournament } from "~/features/tournament/routes/to.$id";
import { isSetOverByScore } from "~/features/tournament-bracket/core/engine";
import { databaseTimestampToJavascriptTimestamp } from "~/utils/dates";
import type { CommonUser } from "~/utils/kysely.server";
import type { TournamentMatchLoaderData } from "../loaders/to.$id.matches.$mid.server";
import { useMatch } from "../match-page-context";
import { isSetOverByScore } from "../tournament-match-utils";
export function TournamentMatchActionTab({
data,
@ -54,9 +54,9 @@ export function TournamentMatchActionTab({
if (!teamOne || !teamTwo) return null;
const withPoints = tournament.bracketByIdxOrDefault(
const withKo = tournament.bracketByIdxOrDefault(
tournament.matchIdToBracketIdx(data.match.id) ?? 0,
).collectResultsWithPoints;
).collectsKos;
const count = data.match.roundMaps.count;
const countType = data.match.roundMaps.type;
@ -113,16 +113,16 @@ export function TournamentMatchActionTab({
ownTeamId={ownTeamId}
stageId={currentMap.stageId}
mode={currentMap.mode}
withPoints={withPoints}
withKo={withKo}
setEnding={setEnding}
isSubmitting={reportFetcher.state !== "idle"}
onSubmit={({ winnerId, points }) => {
onSubmit={({ winnerId, ko }) => {
reportFetcher.submit(
{
_action: "REPORT_SCORE",
winnerTeamId: String(winnerId),
position: String(scoreSum),
...(points ? { points: JSON.stringify(points) } : {}),
...(typeof ko === "boolean" ? { ko: String(ko) } : {}),
},
{ method: "post" },
);
@ -271,13 +271,7 @@ function buildSetEndingData({
alpha: alphaParticipants,
bravo: bravoParticipants,
},
points:
result.opponentOnePoints != null && result.opponentTwoPoints != null
? ([result.opponentOnePoints, result.opponentTwoPoints] as [
number,
number,
])
: undefined,
ko: result.ko != null ? Boolean(result.ko) : undefined,
};
});

View File

@ -14,9 +14,9 @@ import { InfoPopover } from "~/components/InfoPopover";
import { Label } from "~/components/Label";
import { TAB_KEYS } from "~/components/match-page/MatchTabs";
import { SubmitButton } from "~/components/SubmitButton";
import { TournamentMatchStatus } from "~/db/tables";
import { useUser } from "~/features/auth/core/user";
import { useTournament } from "~/features/tournament/routes/to.$id";
import type { MatchStatus } from "~/features/tournament-bracket/core/engine";
import type { TournamentDataTeam } from "~/features/tournament-bracket/core/Tournament.server";
import type { TournamentMatchLoaderData } from "../loaders/to.$id.matches.$mid.server";
import { useMatch } from "../match-page-context";
@ -78,7 +78,7 @@ function AdminCastSection({
matchStatus,
}: {
matchId: number;
matchStatus: number;
matchStatus: MatchStatus;
}) {
const { t } = useTranslation(["tournament"]);
const tournament = useTournament();
@ -95,10 +95,7 @@ function AdminCastSection({
castedMatchesInfo?.lockedMatches?.some((lm) => lm.matchId === matchId) ??
false;
const canLock =
(matchStatus === TournamentMatchStatus.Locked ||
matchStatus === TournamentMatchStatus.Waiting) &&
!isLocked;
const canLock = matchStatus === "PENDING" && !isLocked;
const canUnlock = !isMatchStarted && isLocked;
return (
@ -349,9 +346,9 @@ function EditReportedScoresSection({
const { t } = useTranslation(["tournament"]);
const tournament = useTournament();
const withPoints = tournament.bracketByIdxOrDefault(
const withKo = tournament.bracketByIdxOrDefault(
tournament.matchIdToBracketIdx(data.match.id) ?? 0,
).collectResultsWithPoints;
).collectsKos;
return (
<div className={styles.editSection}>
@ -363,7 +360,7 @@ function EditReportedScoresSection({
index={index}
result={result}
teams={teams}
withPoints={withPoints}
withKo={withKo}
/>
))}
</div>
@ -375,12 +372,12 @@ function EditReportedScoreRow({
index,
result,
teams,
withPoints,
withKo,
}: {
index: number;
result: TournamentMatchLoaderData["results"][number];
teams: [TournamentDataTeam, TournamentDataTeam];
withPoints: boolean;
withKo: boolean;
}) {
const { t } = useTranslation(["common", "game-misc", "tournament"]);
const tournament = useTournament();
@ -399,8 +396,7 @@ function EditReportedScoreRow({
previousFetcherStateRef.current = fetcher.state;
}, [fetcher.state, fetcher.data]);
const isKo =
result.opponentOnePoints === 100 || result.opponentTwoPoints === 100;
const isKo = Boolean(result.ko);
if (!editing) {
return (
@ -433,7 +429,7 @@ function EditReportedScoreRow({
fetcher={fetcher}
result={result}
teams={teams}
withPoints={withPoints}
withKo={withKo}
minMembersPerTeam={tournament.minMembersPerTeam}
onCancel={() => setEditing(false)}
index={index}
@ -445,7 +441,7 @@ function EditReportedScoreForm({
fetcher,
result,
teams,
withPoints,
withKo,
minMembersPerTeam,
onCancel,
index,
@ -453,7 +449,7 @@ function EditReportedScoreForm({
fetcher: ReturnType<typeof useFetcher>;
result: TournamentMatchLoaderData["results"][number];
teams: [TournamentDataTeam, TournamentDataTeam];
withPoints: boolean;
withKo: boolean;
minMembersPerTeam: number;
onCancel: () => void;
index: number;
@ -471,16 +467,7 @@ function EditReportedScoreForm({
.map((p) => p.userId),
];
});
const [isKO, setIsKO] = React.useState(
result.opponentOnePoints === 100 || result.opponentTwoPoints === 100,
);
const team0Won = result.winnerTeamId === teams[0].id;
const points: [number, number] = isKO
? team0Won
? [100, 0]
: [0, 100]
: [0, 0];
const [isKO, setIsKO] = React.useState(Boolean(result.ko));
const formValid = checkedPlayers.every(
(team) => team.length === minMembersPerTeam,
@ -536,9 +523,9 @@ function EditReportedScoreForm({
name="rosters"
value={JSON.stringify(checkedPlayers)}
/>
{withPoints ? (
{withKo ? (
<>
<input type="hidden" name="points" value={JSON.stringify(points)} />
<input type="hidden" name="ko" value={String(isKO)} />
<label className="stack horizontal sm items-center mx-auto">
<input
type="checkbox"

View File

@ -256,9 +256,7 @@ function TournamentMatchBannerTopRow({
score={{
alpha: scores[0],
bravo: scores[1],
isFinal:
data.match.opponentOne?.result === "win" ||
data.match.opponentTwo?.result === "win",
isFinal: Boolean(data.match.winnerSide),
count: data.match.roundMaps.count,
bestOf: data.match.roundMaps.type === "BEST_OF",
}}
@ -529,10 +527,10 @@ function resolveDroppedOutTeamName({
if (!data.matchIsOver || data.results.length > 0) return null;
const droppedOutId =
data.match.opponentOne?.result === "loss"
? data.match.opponentOne.id
: data.match.opponentTwo?.result === "loss"
? data.match.opponentTwo.id
data.match.winnerSide === "opponent2"
? data.match.opponentOne?.id
: data.match.winnerSide === "opponent1"
? data.match.opponentTwo?.id
: null;
if (!droppedOutId) return null;

View File

@ -153,9 +153,6 @@ function resolveTimelineMaps(
}));
return data.results.map((result, mapIndex) => {
const hasPoints =
result.opponentOnePoints !== null && result.opponentTwoPoints !== null;
const alphaRoster = resolveRoster(result.participants, opponentOneId);
const bravoRoster = resolveRoster(result.participants, opponentTwoId);
@ -185,12 +182,7 @@ function resolveTimelineMaps(
weapons: hasAnyWeapon
? { alpha: alphaWeapons, bravo: bravoWeapons }
: undefined,
points: hasPoints
? ([result.opponentOnePoints, result.opponentTwoPoints] as [
number,
number,
])
: undefined,
ko: result.ko != null ? Boolean(result.ko) : undefined,
};
});
}

View File

@ -1,9 +1,7 @@
import type { Tables, TournamentRoundMaps } from "~/db/tables";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import { mapPickingStyleToModes } from "~/features/tournament/tournament-utils";
import type { Bracket } from "~/features/tournament-bracket/core/Bracket";
import type * as PickBan from "~/features/tournament-bracket/core/PickBan";
import type { Round } from "~/modules/brackets-model";
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import { generateBalancedMapList } from "~/modules/tournament-map-list-generator/balanced-map-list";
import { starterMap } from "~/modules/tournament-map-list-generator/starter-map";
@ -12,7 +10,6 @@ import type {
TournamentMaplistSource,
} from "~/modules/tournament-map-list-generator/types";
import { syncCached } from "~/utils/cache.server";
import invariant from "~/utils/invariant";
import { logger } from "~/utils/logger";
import { assertUnreachable } from "~/utils/types";
@ -240,76 +237,3 @@ function resolveFreshTeamPickedMapList(
});
}
}
export function roundMapsFromInput({
roundsFromDB,
virtualRounds,
maps,
bracket,
}: {
roundsFromDB: Round[];
virtualRounds: Round[];
maps: (TournamentRoundMaps & { roundId: number })[];
bracket: Bracket;
}) {
const expandedMaps =
bracket.type === "round_robin" || bracket.type === "swiss"
? expandMaps({ maps, virtualRounds })
: maps;
const virtualGroupIdToReal = (virtualGroupId: number) => {
const minRealGroupId = Math.min(...roundsFromDB.map((r) => r.group_id));
const minVirtualGroupId = Math.min(...virtualRounds.map((r) => r.group_id));
return virtualGroupId - minVirtualGroupId + minRealGroupId;
};
return expandedMaps.map((map) => {
const virtualRound = virtualRounds.find((r) => r.id === map.roundId);
invariant(
virtualRound,
`No virtual round found for map with round id: ${map.roundId}`,
);
const realRoundId = roundsFromDB.find(
(r) =>
r.number === virtualRound.number &&
r.group_id === virtualGroupIdToReal(virtualRound.group_id),
)?.id;
invariant(realRoundId, "No real round found for virtual round");
return { ...map, roundId: realRoundId };
});
}
function expandMaps({
virtualRounds,
maps,
}: {
virtualRounds: Round[];
maps: (TournamentRoundMaps & { roundId: number })[];
}) {
const result: typeof maps = [];
const mapsByNumber = maps.reduce(
(acc, map) => {
const number = virtualRounds.find((r) => r.id === map.roundId)?.number;
invariant(number, "No number found for round id");
acc.set(number, map);
return acc;
},
new Map() as Map<number, (typeof maps)[number]>,
);
for (const round of virtualRounds) {
const maps = mapsByNumber.get(round.number);
invariant(maps, "No maps found for round number");
result.push({
...maps,
roundId: round.id,
});
}
return result;
}

Some files were not shown because too many files have changed in this diff Show More