mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-07-30 15:53:59 -05:00
Bracket engine refactor (#3249)
This commit is contained in:
parent
a28819654c
commit
6dc2dd2986
|
|
@ -21,7 +21,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
.withPoints {
|
||||
.withKo {
|
||||
grid-template-areas:
|
||||
"header header header"
|
||||
"actions actions actions"
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 :(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
379
app/features/tournament-bracket/BracketRepository.server.ts
Normal file
379
app/features/tournament-bracket/BracketRepository.server.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { getTournamentManager } from "./manager";
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
368
app/features/tournament-bracket/core/engine/create/builder.ts
Normal file
368
app/features/tournament-bracket/core/engine/create/builder.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
357
app/features/tournament-bracket/core/engine/create/helpers.ts
Normal file
357
app/features/tournament-bracket/core/engine/create/helpers.ts
Normal 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;
|
||||
}
|
||||
120
app/features/tournament-bracket/core/engine/create/index.ts
Normal file
120
app/features/tournament-bracket/core/engine/create/index.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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]);
|
||||
}
|
||||
144
app/features/tournament-bracket/core/engine/create/swiss.ts
Normal file
144
app/features/tournament-bracket/core/engine/create/swiss.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
258
app/features/tournament-bracket/core/engine/general.test.ts
Normal file
258
app/features/tournament-bracket/core/engine/general.test.ts
Normal 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;
|
||||
}
|
||||
24
app/features/tournament-bracket/core/engine/index.ts
Normal file
24
app/features/tournament-bracket/core/engine/index.ts
Normal 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";
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
|
@ -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() };
|
||||
}
|
||||
|
|
@ -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() };
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
142
app/features/tournament-bracket/core/engine/propagation/set.ts
Normal file
142
app/features/tournament-bracket/core/engine/propagation/set.ts
Normal 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`);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
120
app/features/tournament-bracket/core/engine/propagation/store.ts
Normal file
120
app/features/tournament-bracket/core/engine/propagation/store.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
100
app/features/tournament-bracket/core/engine/status.test.ts
Normal file
100
app/features/tournament-bracket/core/engine/status.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
232
app/features/tournament-bracket/core/engine/status.ts
Normal file
232
app/features/tournament-bracket/core/engine/status.ts
Normal 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}`;
|
||||
}
|
||||
295
app/features/tournament-bracket/core/engine/swiss/pairing.ts
Normal file
295
app/features/tournament-bracket/core/engine/swiss/pairing.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
278
app/features/tournament-bracket/core/engine/types.ts
Normal file
278
app/features/tournament-bracket/core/engine/types.ts
Normal 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[];
|
||||
}
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
|
@ -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: {},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue
Block a user