Swiss bracket format (#1723)

* Initial

* Swiss UI initial

* Start Swiss bracket

* Match up generation skeleton

* Bracket advancing

* Misc

* Progress

* Remove unneeded script

* Timed bans feature

* Add badge

* Add tournament logo

* Add Heavy Splatling MoveSpeed_Charge Closes #1461

* Require verified email for new account registration

* Weapon stats/builds use absolute URL

* Allow mod to ban users

* Prevent app from crashing on unhandled rejection

Context: https://github.com/remix-run/remix/issues/9178#issuecomment-2032102431

* Try changing authenticate call to match docs example

* Limit fresh account queue joining

* Fix tournament avatar not showing

* Limit when weapon shown on leaderboard

* Nuke reported weapons script

* FC scripts

* Increase tournament rules max length

* Add Discord URL to tournament page

* Hotfix reopen

* Fix matchAffectsAnotherBracket logic better

* Switch to Patreon API v2 Closes #1389

Also fixes problem with people who canceled not
getting access.

* Fix reset showing for first round

* Dropping out

* Progress

* Range inputs

* better way to create rounds

* Fix TODO

* E2E test

* Small round count info text

* Fix lint
This commit is contained in:
Kalle 2024-04-21 22:45:33 +03:00 committed by GitHub
parent bd0d21f030
commit f19ec211d8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
44 changed files with 2447 additions and 120 deletions

View File

@ -74,20 +74,27 @@ import { SENDOUQ_DEFAULT_MAPS } from "~/modules/tournament-map-list-generator/co
const calendarEventWithToToolsRegOpen = () =>
calendarEventWithToTools("PICNIC", true);
const calendarEventWithToToolsSz = () => calendarEventWithToTools("ITZ");
const calendarEventWithToToolsTeamsSz = () =>
calendarEventWithToToolsTeams("ITZ");
const calendarEventWithToToolsPP = () => calendarEventWithToTools("PP");
const calendarEventWithToToolsPPRegOpen = () =>
calendarEventWithToTools("PP", true);
const calendarEventWithToToolsTeamsPP = () =>
calendarEventWithToToolsTeams("PP");
const calendarEventWithToToolsSOS = () => calendarEventWithToTools("SOS");
const calendarEventWithToToolsTeamsSOS = () =>
calendarEventWithToToolsTeams("SOS");
const calendarEventWithToToolsTeamsSOSSmall = () =>
calendarEventWithToToolsTeams("SOS", true);
const calendarEventWithToToolsDepths = () => calendarEventWithToTools("DEPTHS");
const calendarEventWithToToolsTeamsDepths = () =>
calendarEventWithToToolsTeams("DEPTHS");
const basicSeeds = (variation?: SeedVariation | null) => [
adminUser,
makeAdminPatron,
@ -132,6 +139,8 @@ const basicSeeds = (variation?: SeedVariation | null) => [
? calendarEventWithToToolsTeamsSOSSmall
: calendarEventWithToToolsTeamsSOS,
calendarEventWithToToolsToSetMapPool,
calendarEventWithToToolsDepths,
calendarEventWithToToolsTeamsDepths,
tournamentSubs,
adminBuilds,
manySplattershotBuilds,
@ -845,7 +854,7 @@ async function calendarEventResults() {
const TO_TOOLS_CALENDAR_EVENT_ID = 201;
function calendarEventWithToTools(
event: "PICNIC" | "ITZ" | "PP" | "SOS" = "PICNIC",
event: "PICNIC" | "ITZ" | "PP" | "SOS" | "DEPTHS" = "PICNIC",
registrationOpen: boolean = false,
) {
const tournamentId = {
@ -853,80 +862,93 @@ function calendarEventWithToTools(
ITZ: 2,
PP: 3,
SOS: 4,
DEPTHS: 5,
}[event];
const eventId = {
PICNIC: TO_TOOLS_CALENDAR_EVENT_ID + 0,
ITZ: TO_TOOLS_CALENDAR_EVENT_ID + 1,
PP: TO_TOOLS_CALENDAR_EVENT_ID + 2,
SOS: TO_TOOLS_CALENDAR_EVENT_ID + 3,
DEPTHS: TO_TOOLS_CALENDAR_EVENT_ID + 4,
}[event];
const name = {
PICNIC: "PICNIC #2",
ITZ: "In The Zone 22",
PP: "Paddling Pool 253",
SOS: "Swim or Sink 101",
DEPTHS: "The Depths 5",
}[event];
const settings: Tables["Tournament"]["settings"] =
event === "SOS"
event === "DEPTHS"
? {
bracketProgression: [
{ type: "round_robin", name: "Groups stage" },
{
type: "single_elimination",
name: "Great White",
sources: [{ bracketIdx: 0, placements: [1] }],
},
{
type: "single_elimination",
name: "Hammerhead",
sources: [{ bracketIdx: 0, placements: [2] }],
},
{
type: "single_elimination",
name: "Mako",
sources: [{ bracketIdx: 0, placements: [3] }],
},
{
type: "single_elimination",
name: "Lantern",
sources: [{ bracketIdx: 0, placements: [4] }],
},
],
bracketProgression: [{ type: "swiss", name: "Swiss" }],
enableNoScreenToggle: true,
isRanked: false,
swiss: {
groupCount: 2,
roundCount: 4,
},
}
: event === "PP"
: event === "SOS"
? {
bracketProgression: [
{ type: "round_robin", name: "Groups stage" },
{
type: "single_elimination",
name: "Final stage",
sources: [{ bracketIdx: 0, placements: [1, 2] }],
name: "Great White",
sources: [{ bracketIdx: 0, placements: [1] }],
},
{
type: "single_elimination",
name: "Underground bracket",
sources: [{ bracketIdx: 0, placements: [3, 4] }],
name: "Hammerhead",
sources: [{ bracketIdx: 0, placements: [2] }],
},
{
type: "single_elimination",
name: "Mako",
sources: [{ bracketIdx: 0, placements: [3] }],
},
{
type: "single_elimination",
name: "Lantern",
sources: [{ bracketIdx: 0, placements: [4] }],
},
],
enableNoScreenToggle: true,
}
: event === "ITZ"
: event === "PP"
? {
bracketProgression: [
{ type: "double_elimination", name: "Main bracket" },
{ type: "round_robin", name: "Groups stage" },
{
type: "single_elimination",
name: "Final stage",
sources: [{ bracketIdx: 0, placements: [1, 2] }],
},
{
type: "single_elimination",
name: "Underground bracket",
sources: [{ bracketIdx: 0, placements: [-1, -2] }],
sources: [{ bracketIdx: 0, placements: [3, 4] }],
},
],
}
: {
bracketProgression: [
{ type: "double_elimination", name: "Main bracket" },
],
};
: event === "ITZ"
? {
bracketProgression: [
{ type: "double_elimination", name: "Main bracket" },
{
type: "single_elimination",
name: "Underground bracket",
sources: [{ bracketIdx: 0, placements: [-1, -2] }],
},
],
}
: {
bracketProgression: [
{ type: "double_elimination", name: "Main bracket" },
],
};
sql
.prepare(
@ -1085,7 +1107,7 @@ const availablePairs = rankedModesShort
)
.filter((pair) => !tiebreakerPicks.has(pair));
function calendarEventWithToToolsTeams(
event: "PICNIC" | "ITZ" | "PP" | "SOS" = "PICNIC",
event: "PICNIC" | "ITZ" | "PP" | "SOS" | "DEPTHS" = "PICNIC",
isSmall: boolean = false,
) {
const userIds = userIdsInAscendingOrderById();
@ -1098,6 +1120,7 @@ function calendarEventWithToToolsTeams(
ITZ: 2,
PP: 3,
SOS: 4,
DEPTHS: 5,
}[event];
const teamIdAddition = {
@ -1105,6 +1128,7 @@ function calendarEventWithToToolsTeams(
ITZ: 100,
PP: 200,
SOS: 300,
DEPTHS: 400,
}[event];
for (let id = 1; id <= (isSmall ? 4 : 16); id++) {

View File

@ -393,6 +393,10 @@ export interface TournamentSettings {
autonomousSubs?: boolean;
/** Timestamp (SQLite format) when reg closes, if missing then means closes at start time */
regClosesAt?: number;
swiss?: {
groupCount: number;
roundCount: number;
};
}
export interface CastedMatchesInfo {
@ -421,6 +425,18 @@ export interface TournamentBadgeOwner {
userId: number;
}
/** A group is a logical structure used to group multiple rounds together.
- In round-robin stages, a group is a pool.
- In swiss, a group is also a pool (can have one or multiple groups)
- In elimination stages, a group is a bracket.
- A single elimination stage can have one or two groups:
- The unique bracket.
- If enabled, the Consolation Final.
- A double elimination stage can have two or three groups:
- Upper and lower brackets.
- If enabled, the Grand Final.
*/
export interface TournamentGroup {
id: GeneratedAlways<number>;
number: number;
@ -439,6 +455,8 @@ export interface TournamentMatch {
roundId: number;
stageId: number;
status: number;
// used only for swiss because it's the only stage type where matches are not created in advance
createdAt: Generated<number>;
}
export interface TournamentMatchPickBanEvent {
@ -486,6 +504,13 @@ export interface TournamentRoundMaps {
pickBan?: "COUNTERPICK" | "BAN_2" | null;
}
/**
* A round is a logical structure used to group multiple matches together.
- In round-robin stages, a round can be viewed as a list of matches that can be played at the same time.
- In swiss, a round is a list of matches that are played at the same time.
- In elimination stages, a round is a round of a bracket, e.g. 8th finals, semi-finals, etc.
*/
export interface TournamentRound {
groupId: number;
id: GeneratedAlways<number>;
@ -494,13 +519,14 @@ export interface TournamentRound {
maps: ColumnType<TournamentRoundMaps | null, string | null, string | null>;
}
/** A stage is an intermediate phase in a tournament. In essence a bracket. */
export interface TournamentStage {
id: GeneratedAlways<number>;
name: string;
number: number;
settings: string;
tournamentId: number;
type: "double_elimination" | "single_elimination" | "round_robin";
type: "double_elimination" | "single_elimination" | "round_robin" | "swiss";
// not Generated<> because SQLite doesn't allow altering tables to add columns with default values :(
createdAt: number | null;
}
@ -529,6 +555,7 @@ export interface TournamentTeam {
name: string;
prefersNotToHost: Generated<number>;
noScreen: Generated<number>;
droppedOut: Generated<number>;
seed: number | null;
tournamentId: number;
teamId: number | null;

View File

@ -413,6 +413,8 @@ type CreateArgs = Pick<
regClosesAt?: number;
rules: string | null;
tournamentToCopyId?: number | null;
swissGroupCount?: number;
swissRoundCount?: number;
};
export async function create(args: CreateArgs) {
const copiedStaff = args.tournamentToCopyId
@ -437,6 +439,13 @@ export async function create(args: CreateArgs) {
autonomousSubs: args.autonomousSubs,
regClosesAt: args.regClosesAt,
autoCheckInAll: args.autoCheckInAll,
swiss:
args.swissGroupCount && args.swissRoundCount
? {
groupCount: args.swissGroupCount,
roundCount: args.swissRoundCount,
}
: undefined,
};
tournamentId = (
@ -530,6 +539,13 @@ export async function update(args: UpdateArgs) {
autonomousSubs: args.autonomousSubs,
regClosesAt: args.regClosesAt,
autoCheckInAll: args.autoCheckInAll,
swiss:
args.swissGroupCount && args.swissRoundCount
? {
groupCount: args.swissGroupCount,
roundCount: args.swissRoundCount,
}
: undefined,
};
const { mapPickingStyle: _mapPickingStyle } = await trx

View File

@ -98,6 +98,8 @@ export const newCalendarEventActionSchema = z
.min(TOURNAMENT.MIN_GROUP_SIZE)
.max(TOURNAMENT.MAX_GROUP_SIZE)
.nullish(),
swissGroupCount: z.coerce.number().int().positive().nullish(),
swissRoundCount: z.coerce.number().int().positive().nullish(),
followUpBrackets: z.preprocess(
safeJSONParse,
z

View File

@ -34,6 +34,13 @@ export function formValuesToBracketProgression(
}
}
if (args.format === "SWISS") {
result.push({
name: BRACKET_NAMES.MAIN,
type: "swiss",
});
}
if (args.format === "SE") {
result.push({
name: BRACKET_NAMES.MAIN,
@ -46,7 +53,13 @@ export function formValuesToBracketProgression(
args.teamsPerGroup &&
args.followUpBrackets
) {
if (validateFollowUpBrackets(args.followUpBrackets, args.teamsPerGroup)) {
if (
validateFollowUpBrackets(
args.followUpBrackets,
args.format,
args.teamsPerGroup,
)
) {
return null;
}
@ -64,6 +77,25 @@ export function formValuesToBracketProgression(
}
}
if (args.format === "SWISS_TO_SE" && args.followUpBrackets) {
if (validateFollowUpBrackets(args.followUpBrackets, args.format)) {
return null;
}
result.push({
name: BRACKET_NAMES.GROUPS,
type: "swiss",
});
for (const bracket of args.followUpBrackets) {
result.push({
name: bracket.name,
type: "single_elimination",
sources: [{ bracketIdx: 0, placements: bracket.placements }],
});
}
}
// should not happen
if (result.length === 0) return null;

View File

@ -14,6 +14,13 @@ export function bracketProgressionToShortTournamentFormat(
): TournamentFormatShort {
if (bp.length === 1 && bp[0].type === "single_elimination") return "SE";
if (bp.some((b) => b.type === "double_elimination")) return "DE";
if (bp.length === 1 && bp[0].type === "swiss") return "SWISS";
if (
bp.some(({ type }) => type === "swiss") &&
bp.some(({ type }) => type === "single_elimination")
) {
return "SWISS_TO_SE";
}
return "RR_TO_SE";
}
@ -27,7 +34,8 @@ export const calendarEventMaxDate = () => {
export function validateFollowUpBrackets(
brackets: FollowUpBracket[],
teamsPerGroup: number,
format: TournamentFormatShort,
teamsPerGroup?: number,
) {
const placementsFound: number[] = [];
@ -50,7 +58,11 @@ export function validateFollowUpBrackets(
}
}
if (placementsFound.some((p) => p > teamsPerGroup)) {
if (
format === "RR_TO_SE" &&
typeof teamsPerGroup === "number" &&
placementsFound.some((p) => p > teamsPerGroup)
) {
return `Placement higher than teams per group`;
}

View File

@ -129,6 +129,8 @@ export const action: ActionFunction = async ({ request }) => {
enableNoScreenToggle: data.enableNoScreenToggle ?? undefined,
autoCheckInAll: data.autoCheckInAll ?? undefined,
autonomousSubs: data.autonomousSubs ?? undefined,
swissGroupCount: data.swissGroupCount ?? undefined,
swissRoundCount: data.swissRoundCount ?? undefined,
tournamentToCopyId: data.tournamentToCopyId,
regClosesAt: data.regClosesAt
? dateToDatabaseTimestamp(
@ -1124,6 +1126,12 @@ function TournamentFormatSelector() {
const [teamsPerGroup, setTeamsPerGroup] = React.useState(
baseEvent?.tournamentCtx?.settings.teamsPerGroup ?? 4,
);
const [swissGroupCount, setSwissGroupCount] = React.useState(
baseEvent?.tournamentCtx?.settings.swiss?.groupCount ?? 1,
);
const [swissRoundCount, setSwissRoundCount] = React.useState(
baseEvent?.tournamentCtx?.settings.swiss?.roundCount ?? 5,
);
return (
<div className="stack md w-full">
@ -1142,6 +1150,8 @@ function TournamentFormatSelector() {
<option value="RR_TO_SE">
Round robin -{">"} Single-elimination
</option>
<option value="SWISS">Swiss</option>
<option value="SWISS_TO_SE">Swiss -{">"} Single-elimination</option>
</select>
</div>
@ -1181,7 +1191,53 @@ function TournamentFormatSelector() {
</div>
) : null}
{format === "SWISS_TO_SE" ? (
<div>
<Label htmlFor="swissGroupCount">Swiss groups count</Label>
<select
value={swissGroupCount}
onChange={(e) => setSwissGroupCount(Number(e.target.value))}
className="w-max"
name="swissGroupCount"
id="swissGroupCount"
>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
</select>
</div>
) : null}
{format === "SWISS" || format === "SWISS_TO_SE" ? (
<div>
<Label htmlFor="swissRoundCount">Swiss round count</Label>
<select
value={swissRoundCount}
onChange={(e) => setSwissRoundCount(Number(e.target.value))}
className="w-max"
name="swissRoundCount"
id="swissRoundCount"
>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
<option value="6">6</option>
<option value="7">7</option>
<option value="8">8</option>
</select>
{format === "SWISS" ? (
<FormMessage type="info">
In swiss using the correct round count corresponding to the player
count is recommended. Examples: at most 16 players = 4 rounds, at
most 32 players = 5 rounds, at most 64 players = 6 rounds.
</FormMessage>
) : null}
</div>
) : null}
{format === "RR_TO_SE" ||
format === "SWISS_TO_SE" ||
format === "SE" ||
(format === "DE" && withUndergroundBracket) ? (
<div>
@ -1196,14 +1252,20 @@ function TournamentFormatSelector() {
</div>
) : null}
{format === "RR_TO_SE" ? (
<FollowUpBrackets teamsPerGroup={teamsPerGroup} />
{format === "RR_TO_SE" || format === "SWISS_TO_SE" ? (
<FollowUpBrackets teamsPerGroup={teamsPerGroup} format={format} />
) : null}
</div>
);
}
function FollowUpBrackets({ teamsPerGroup }: { teamsPerGroup: number }) {
function FollowUpBrackets({
teamsPerGroup,
format,
}: {
teamsPerGroup: number;
format: TournamentFormatShort;
}) {
const baseEvent = useBaseEvent();
const [autoCheckInAll, setAutoCheckInAll] = React.useState(
baseEvent?.tournamentCtx?.settings.autoCheckInAll ?? false,
@ -1212,8 +1274,9 @@ function FollowUpBrackets({ teamsPerGroup }: { teamsPerGroup: number }) {
() => {
if (
baseEvent?.tournamentCtx &&
baseEvent.tournamentCtx.settings.bracketProgression[0].type ===
"round_robin"
["round_robin", "swiss"].includes(
baseEvent.tournamentCtx.settings.bracketProgression[0].type,
)
) {
return baseEvent.tournamentCtx.settings.bracketProgression
.slice(1)
@ -1230,10 +1293,17 @@ function FollowUpBrackets({ teamsPerGroup }: { teamsPerGroup: number }) {
const brackets = _brackets.map((b) => ({
...b,
// handle teams per group changing after group placements have been set
placements: b.placements.filter((p) => p <= teamsPerGroup),
placements:
format === "RR_TO_SE"
? b.placements.filter((p) => p <= teamsPerGroup)
: b.placements,
}));
const validationErrorMsg = validateFollowUpBrackets(brackets, teamsPerGroup);
const validationErrorMsg = validateFollowUpBrackets(
brackets,
format,
teamsPerGroup,
);
return (
<>
@ -1276,13 +1346,23 @@ function FollowUpBrackets({ teamsPerGroup }: { teamsPerGroup: number }) {
}}
bracket={b}
nth={i + 1}
format={format}
/>
))}
<div className="stack sm horizontal">
<Button
size="tiny"
onClick={() => {
setBrackets([...brackets, { name: "", placements: [] }]);
const currentMaxPlacement = Math.max(
...brackets.flatMap((b) => b.placements),
);
const placements =
format === "RR_TO_SE"
? []
: [currentMaxPlacement + 1, currentMaxPlacement + 2];
setBrackets([...brackets, { name: "", placements }]);
}}
data-testid="add-bracket"
>
@ -1315,11 +1395,13 @@ function FollowUpBracketInputs({
bracket,
onChange,
nth,
format,
}: {
teamsPerGroup: number;
bracket: FollowUpBracket;
onChange: (bracket: FollowUpBracket) => void;
nth: number;
format: TournamentFormatShort;
}) {
const id = React.useId();
return (
@ -1334,30 +1416,122 @@ function FollowUpBracketInputs({
id={id}
/>
</div>
<div className="stack items-center horizontal md flex-wrap">
<Label spaced={false}>Group placements</Label>
{nullFilledArray(teamsPerGroup).map((_, i) => {
const placement = i + 1;
return (
<div key={i} className="stack horizontal items-center xs">
<Label spaced={false} htmlFor={`${id}-${i}`}>
<Placement placement={placement} />
</Label>
<input
id={`${id}-${i}`}
data-testid={`placement-${nth}-${placement}`}
type="checkbox"
checked={bracket.placements.includes(placement)}
onChange={(e) => {
const newPlacements = e.target.checked
? [...bracket.placements, placement]
: bracket.placements.filter((p) => p !== placement);
onChange({ ...bracket, placements: newPlacements });
}}
/>
</div>
);
})}
{format === "RR_TO_SE" ? (
<FollowUpBracketGroupPlacementCheckboxes
teamsPerGroup={teamsPerGroup}
bracket={bracket}
onChange={onChange}
nth={nth}
/>
) : (
<FollowUpBracketRangeInputs bracket={bracket} onChange={onChange} />
)}
</div>
);
}
function FollowUpBracketGroupPlacementCheckboxes({
teamsPerGroup,
bracket,
onChange,
nth,
}: {
teamsPerGroup: number;
bracket: FollowUpBracket;
onChange: (bracket: FollowUpBracket) => void;
nth: number;
}) {
const id = React.useId();
return (
<div className="stack items-center horizontal md flex-wrap">
<Label spaced={false}>Group placements</Label>
{nullFilledArray(teamsPerGroup).map((_, i) => {
const placement = i + 1;
return (
<div key={i} className="stack horizontal items-center xs">
<Label spaced={false} htmlFor={id}>
<Placement placement={placement} />
</Label>
<input
id={id}
data-testid={`placement-${nth}-${placement}`}
type="checkbox"
checked={bracket.placements.includes(placement)}
onChange={(e) => {
const newPlacements = e.target.checked
? [...bracket.placements, placement]
: bracket.placements.filter((p) => p !== placement);
onChange({ ...bracket, placements: newPlacements });
}}
/>
</div>
);
})}
</div>
);
}
const rangeToPlacements = ([start, end]: [number, number]) => {
if (start > end) {
return [];
}
const result: number[] = [];
for (let i = start; i <= end; i++) {
result.push(i);
}
return result;
};
const placementsToRange = (placements: number[]): [number, number] => {
if (placements.length === 0) {
return [1, 2];
}
return [placements[0], placements[placements.length - 1]];
};
function FollowUpBracketRangeInputs({
bracket,
onChange,
}: {
bracket: FollowUpBracket;
onChange: (bracket: FollowUpBracket) => void;
}) {
const [range, setRange] = React.useState<[number, number]>(
placementsToRange(bracket.placements),
);
const handleRangeChange = (newRange: [number, number]) => {
setRange(newRange);
onChange({ ...bracket, placements: rangeToPlacements(newRange) });
};
return (
<div className="stack items-center horizontal md flex-wrap">
<Label spaced={false}>Group placements (both inclusive)</Label>
<div className="stack horizontal sm items-center text-xs">
from
<Input
className="calendar-new__range-input"
type="number"
value={String(range[0])}
onChange={(e) =>
handleRangeChange([Number(e.target.value), range[1]])
}
/>
to
<Input
className="calendar-new__range-input"
type="number"
value={String(range[1])}
onChange={(e) =>
handleRangeChange([range[0], Number(e.target.value)])
}
/>
</div>
</div>
);

View File

@ -78,8 +78,8 @@ export const damagePriorities: Array<
["MAIN", [3040], "DIRECT", "Slosher_WashtubBombCore"],
["MAIN", [3040], "DISTANCE", "Slosher_Washtub"],
["MAIN", [6000], "NORMAL_MAX", "ShelterShot"], // TODO: could also list damage caused by Shield bump
["MAIN", [6000], "NORMAL_MIN", "ShelterShot"],
["MAIN", [6000, 6030], "NORMAL_MAX", "ShelterShot"], // TODO: could also list damage caused by Shield bump
["MAIN", [6000, 6030], "NORMAL_MIN", "ShelterShot"],
["MAIN", [6010], "NORMAL_MAX", "ShelterShot_Wide"],
["MAIN", [6010], "NORMAL_MIN", "ShelterShot_Wide"],
["MAIN", [6020], "NORMAL_MAX", "ShelterShot_Compact"],

View File

@ -257,7 +257,9 @@ function PlacementsTable({
>
<span> {dest.name}</span>
</td>
) : null}
) : (
<td />
)}
</tr>
);
})}

View File

@ -0,0 +1,432 @@
import type { Bracket as BracketType } from "../../core/Bracket";
import { RoundHeader } from "./RoundHeader";
import { Match } from "./Match";
import type { Match as MatchType } from "~/modules/brackets-model";
import { groupNumberToLetter } from "../../tournament-bracket-utils";
import { Button } from "~/components/Button";
import clsx from "clsx";
import {
useBracketExpanded,
useTournament,
} from "~/features/tournament/routes/to.$id";
import { useUser } from "~/features/auth/core/user";
import { SubmitButton } from "~/components/SubmitButton";
import { Link, useFetcher } from "@remix-run/react";
import { tournamentTeamPage } from "~/utils/urls";
import { logger } from "~/utils/logger";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { useSearchParamState } from "~/hooks/useSearchParamState";
export function SwissBracket({
bracket,
bracketIdx,
}: {
bracket: BracketType;
bracketIdx: number;
}) {
const user = useUser();
const tournament = useTournament();
const { bracketExpanded } = useBracketExpanded();
const groups = getGroups(bracket);
const [selectedGroupId, setSelectedGroupId] = useSearchParamState({
defaultValue: groups[0].groupId,
name: "group",
revive: (id) =>
groups.find((g) => g.groupId === Number(id))
? Number(id)
: groups[0].groupId,
});
const fetcher = useFetcher();
const selectedGroup = groups.find((g) => g.groupId === selectedGroupId)!;
const rounds = bracket.data.round.filter(
(r) => r.group_id === selectedGroupId,
);
// when bracket starts we go from "virtual id" to a real one
// which would cause the admin to see empty group after starting
// bracket
if (!groups.some((g) => g.groupId === selectedGroupId)) {
setSelectedGroupId(groups[0].groupId);
}
const someMatchOngoing = (matches: MatchType[]) =>
matches.some(
(match) =>
match.opponent1 &&
match.opponent2 &&
match.opponent1.result !== "win" &&
match.opponent2.result !== "win",
);
const allRoundsFinished = () => {
for (const round of rounds) {
const matches = bracket.data.match.filter(
(match) =>
match.round_id === round.id && match.group_id === selectedGroupId,
);
if (matches.length === 0 || someMatchOngoing(matches)) {
return false;
}
}
return true;
};
const roundThatCanBeStartedId = () => {
if (!tournament.isOrganizer(user)) return undefined;
for (const round of rounds) {
const matches = bracket.data.match.filter(
(match) =>
match.round_id === round.id && match.group_id === selectedGroupId,
);
if (someMatchOngoing(matches) && matches.length > 0) {
return undefined;
}
if (matches.length === 0) {
return round.id;
}
}
return;
};
return (
<div className="stack xl">
<div className="stack lg">
{groups.length > 1 && (
<div className="stack horizontal">
{groups.map((g) => (
<Button
key={g.groupId}
onClick={() => setSelectedGroupId(g.groupId)}
className={clsx(
"tournament-bracket__bracket-nav__link tournament-bracket__bracket-nav__link__big",
{
"tournament-bracket__bracket-nav__link__selected":
selectedGroupId === g.groupId,
},
)}
testId={`group-${g.groupName.split(" ")[1]}-button`}
>
{g.groupName.split(" ")[1]}
</Button>
))}
</div>
)}
<div className="stack lg">
{rounds.map((round, roundI) => {
const matches = bracket.data.match.filter(
(match) =>
match.round_id === round.id &&
match.group_id === selectedGroupId,
);
if (
matches.length > 0 &&
!bracketExpanded &&
!someMatchOngoing(matches) &&
roundI !== rounds.length - 1
) {
return null;
}
const bestOf = round.maps?.count;
const teamWithByeId = matches.find((m) => !m.opponent2)?.opponent1
?.id;
const teamWithBye = teamWithByeId
? tournament.teamById(teamWithByeId)
: null;
return (
<div
key={round.id}
className={matches.length > 0 ? "stack md-plus" : "stack"}
>
<div className="stack sm horizontal">
<RoundHeader
roundId={round.id}
name={`Round ${round.number}`}
bestOf={bestOf}
showInfos={someMatchOngoing(matches)}
maps={round.maps}
/>
{roundThatCanBeStartedId() === round.id ? (
<fetcher.Form method="post">
<input
type="hidden"
name="groupId"
value={selectedGroupId}
/>
<input
type="hidden"
name="bracketIdx"
value={bracketIdx}
/>
<SubmitButton
_action="ADVANCE_BRACKET"
state={fetcher.state}
testId="start-round-button"
>
Start round
</SubmitButton>
</fetcher.Form>
) : null}
{someMatchOngoing(matches) &&
tournament.isOrganizer(user) &&
roundI > 0 ? (
<FormWithConfirm
dialogHeading={`Delete all matches of round ${round.number}?`}
fields={[
["groupId", selectedGroupId],
["roundId", round.id],
["bracketIdx", bracketIdx],
["_action", "UNADVANCE_BRACKET"],
]}
>
<Button
variant="minimal-destructive"
type="submit"
className="build__small-text mb-4"
size="tiny"
testId="reset-round-button"
>
Reset round
</Button>
</FormWithConfirm>
) : null}
</div>
<div className="stack horizontal md lg-row flex-wrap">
{matches.length === 0 ? (
<div className="text-lighter text-md font-bold">
Waiting for the previous round to finish
</div>
) : null}
{matches.map((match) => {
if (!match.opponent1 || !match.opponent2) {
return null;
}
return (
<Match
key={match.id}
match={match}
roundNumber={round.number}
isPreview={bracket.preview}
showSimulation={false}
bracket={bracket}
type="groups"
group={selectedGroup.groupName.split(" ")[1]}
/>
);
})}
</div>
{teamWithBye ? (
<div className="text-xs text-lighter font-semi-bold">
BYE: {teamWithBye.name}
</div>
) : null}
</div>
);
})}
</div>
<PlacementsTable
bracket={bracket}
groupId={selectedGroupId}
allMatchesFinished={allRoundsFinished()}
/>
</div>
</div>
);
}
function getGroups(bracket: BracketType) {
const result: Array<{
groupName: string;
matches: MatchType[];
groupId: number;
}> = [];
for (const group of bracket.data.group) {
const matches = bracket.data.match.filter(
(match) => match.group_id === group.id,
);
result.push({
groupName: `Group ${groupNumberToLetter(group.number)}`,
matches,
groupId: group.id,
});
}
return result;
}
function PlacementsTable({
groupId,
bracket,
allMatchesFinished,
}: {
groupId: number;
bracket: BracketType;
allMatchesFinished: boolean;
}) {
const _standings = bracket
.currentStandings(true)
.filter((s) => s.groupId === groupId);
const missingTeams = bracket.data.match.reduce((acc, cur) => {
if (cur.group_id !== groupId) return acc;
if (
cur.opponent1?.id &&
!_standings.some((s) => s.team.id === cur.opponent1!.id) &&
!acc.includes(cur.opponent1.id)
) {
acc.push(cur.opponent1.id);
}
if (
cur.opponent2?.id &&
!_standings.some((s) => s.team.id === cur.opponent2!.id) &&
!acc.includes(cur.opponent2.id)
) {
acc.push(cur.opponent2.id);
}
return acc;
}, [] as number[]);
const standings = _standings
.concat(
missingTeams.map((id) => ({
team: bracket.tournament.teamById(id)!,
stats: {
mapLosses: 0,
mapWins: 0,
points: 0,
setLosses: 0,
setWins: 0,
winsAgainstTied: 0,
},
placement: Math.max(..._standings.map((s) => s.placement)) + 1,
groupId,
})),
)
.sort((a, b) => {
if (a.placement === b.placement && a.team.seed && b.team.seed) {
return a.team.seed - b.team.seed;
}
return a.placement - b.placement;
});
const destinationBracket = (placement: number) =>
bracket.tournament.brackets.find(
(b) =>
b.id !== bracket.id &&
b.sources?.some(
(s) => s.bracketIdx === 0 && s.placements.includes(placement),
),
);
return (
<table className="rr__placements-table" cellSpacing={0}>
<thead>
<tr>
<th>Team</th>
<th>
<abbr title="Set wins and losses">W/L</abbr>
</th>
<th>
<abbr title="Wins against tied opponents">TB</abbr>
</th>
<th>
<abbr title="Map wins and losses">W/L (M)</abbr>
</th>
<th>
<abbr title="Buchholz (summed set wins of opponents)">Buch.</abbr>
</th>
<th>
<abbr title="Buchholz (summed map wins of opponents)">
Buch. (M)
</abbr>
</th>
<th>Seed</th>
<th />
</tr>
</thead>
<tbody>
{standings.map((s, i) => {
const stats = s.stats!;
if (!stats) {
logger.error("No stats for team", s.team);
return null;
}
const team = bracket.tournament.teamById(s.team.id);
const dest = destinationBracket(i + 1);
return (
<tr key={s.team.id}>
<td>
<Link
to={tournamentTeamPage({
tournamentId: bracket.tournament.ctx.id,
tournamentTeamId: s.team.id,
})}
>
{s.team.name}{" "}
</Link>
{s.team.droppedOut ? (
<span className="text-warning text-xxxs font-bold">
Drop-out
</span>
) : null}
</td>
<td>
<span>
{stats.setWins}/{stats.setLosses}
</span>
</td>
<td>
<span>{stats.winsAgainstTied}</span>
</td>
<td>
<span>
{stats.mapWins}/{stats.mapLosses}
</span>
</td>
<td>
<span>{stats.buchholzSets}</span>
</td>
<td>
<span>{stats.buchholzMaps}</span>
</td>
<td>{team?.seed}</td>
{dest ? (
<td
className={clsx({
"italic text-lighter": !allMatchesFinished,
})}
>
<span> {dest.name}</span>
</td>
) : (
<td />
)}
</tr>
);
})}
</tbody>
</table>
);
}

View File

@ -2,8 +2,15 @@ import { useBracketExpanded } from "~/features/tournament/routes/to.$id";
import type { Bracket as BracketType } from "../../core/Bracket";
import { EliminationBracketSide } from "./Elimination";
import { RoundRobinBracket } from "./RoundRobin";
import { SwissBracket } from "./Swiss";
export function Bracket({ bracket }: { bracket: BracketType }) {
export function Bracket({
bracket,
bracketIdx,
}: {
bracket: BracketType;
bracketIdx: number;
}) {
const { bracketExpanded } = useBracketExpanded();
if (bracket.type === "round_robin") {
@ -14,6 +21,14 @@ export function Bracket({ bracket }: { bracket: BracketType }) {
);
}
if (bracket.type === "swiss") {
return (
<BracketContainer>
<SwissBracket bracket={bracket} bracketIdx={bracketIdx} />
</BracketContainer>
);
}
if (bracket.type === "single_elimination") {
return (
<BracketContainer>

View File

@ -36,7 +36,9 @@ export function useDeadline(roundId: number, bestOf: number) {
if (!round) return null;
const isFirstRoundOfBracket =
roundIdx === 0 || (bracket.type === "round_robin" && round.number === 1);
roundIdx === 0 ||
((bracket.type === "round_robin" || bracket.type === "swiss") &&
round.number === 1);
const matches = bracket.data.match.filter((m) => m.round_id === roundId);
const everyMatchHasStarted = matches.every(
@ -60,6 +62,8 @@ export function useDeadline(roundId: number, bestOf: number) {
round.group_id !== losersGroupId)
) {
dl = dateByPreviousRound(bracket, round);
} else if (bracket.type === "swiss") {
dl = dateByRoundMatch(bracket, round);
} else if (bracket.type === "round_robin") {
dl = dateByManyPreviousRounds(bracket, round);
} else {
@ -112,6 +116,16 @@ function dateByPreviousRound(bracket: Bracket, round: Round) {
return databaseTimestampToDate(maxFinishedAt);
}
function dateByRoundMatch(bracket: Bracket, round: Round) {
const roundMatch = bracket.data.match.find((m) => m.round_id === round.id);
if (!roundMatch?.createdAt) {
return null;
}
return databaseTimestampToDate(roundMatch.createdAt);
}
function dateByManyPreviousRounds(bracket: Bracket, round: Round) {
const relevantRounds = bracket.data.round.filter(
(r) => r.number === round.number - 1,

View File

@ -62,7 +62,7 @@ export function BracketMapListDialog({
const [hoveredMap, setHoveredMap] = React.useState<string | null>(null);
const rounds = React.useMemo(() => {
if (bracket.type === "round_robin") {
if (bracket.type === "round_robin" || bracket.type === "swiss") {
return Array.from(maps.keys()).map((roundId, i) => {
return {
id: roundId,
@ -140,6 +140,9 @@ export function BracketMapListDialog({
const lacksToSetMapPool =
toSetMapPool.length === 0 && tournament.ctx.mapPickingStyle === "TO";
const globalSelections =
bracket.type === "round_robin" || bracket.type === "swiss";
return (
<Dialog isOpen={isOpen} close={close} className="w-max">
<fetcher.Form method="post" className="map-list-dialog__container">
@ -172,7 +175,7 @@ export function BracketMapListDialog({
pickBanStyle={pickBanStyle}
onPickBanStyleChange={(pickBanStyle) => {
let newRoundsWithPickBan = roundsWithPickBan;
if (bracket.type === "round_robin") {
if (globalSelections) {
newRoundsWithPickBan =
mapCountsWithGlobalPickBanStyle(pickBanStyle);
}
@ -190,7 +193,7 @@ export function BracketMapListDialog({
);
}}
/>
{bracket.type === "round_robin" ? (
{globalSelections ? (
<GlobalMapCountInput
onSetCount={(newCount) => {
const newMapCounts = mapCountsWithGlobalCount(newCount);
@ -207,7 +210,7 @@ export function BracketMapListDialog({
}}
/>
) : null}
{bracket.type === "round_robin" ? (
{globalSelections ? (
<GlobalCountTypeSelect onSetCountType={setCountType} />
) : null}
</div>

View File

@ -42,6 +42,8 @@ export interface Standing {
mapLosses: number;
points: number;
winsAgainstTied: number;
buchholzSets?: number;
buchholzMaps?: number;
};
}
@ -87,6 +89,7 @@ export abstract class Bracket {
private createdSimulation() {
if (
this.type === "round_robin" ||
this.type === "swiss" ||
this.preview ||
this.tournament.ctx.isFinalized
)
@ -316,6 +319,9 @@ export abstract class Bracket {
case "round_robin": {
return new RoundRobinBracket(args);
}
case "swiss": {
return new SwissBracket(args);
}
default: {
assertUnreachable(args.type);
}
@ -1042,3 +1048,380 @@ class RoundRobinBracket extends Bracket {
return result;
}
}
class SwissBracket extends Bracket {
constructor(args: CreateBracketArgs) {
super(args);
}
get collectResultsWithPoints() {
return false;
}
source(placements: number[]): {
relevantMatchesFinished: boolean;
teams: { id: number; name: string }[];
} {
if (placements.some((p) => p < 0)) {
throw new Error("Negative placements not implemented");
}
const standings = this.standings;
const relevantMatchesFinished = this.data.round.every((round) => {
const roundsMatches = this.data.match.filter(
(match) => match.round_id === 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"
) {
return false;
}
return true;
});
});
const uniquePlacements = removeDuplicates(
standings.map((s) => s.placement),
);
// 1,3,5 -> 1,2,3 e.g.
const placementNormalized = (p: number) => {
return uniquePlacements.indexOf(p) + 1;
};
return {
relevantMatchesFinished,
teams: standings
.filter((s) => placements.includes(placementNormalized(s.placement)))
.map((s) => ({ id: s.team.id, name: s.team.name })),
};
}
get standings(): Standing[] {
return this.currentStandings();
}
currentStandings(includeUnfinishedGroups = false) {
const groupIds = this.data.group.map((group) => group.id);
const placements: (Standing & { groupId: number })[] = [];
for (const groupId of groupIds) {
const matches = this.data.match.filter(
(match) => match.group_id === groupId,
);
const groupIsFinished = matches.every(
(match) =>
// BYE
match.opponent1 === null ||
match.opponent2 === null ||
// match was played out
match.opponent1?.result === "win" ||
match.opponent2?.result === "win",
);
if (!groupIsFinished && !includeUnfinishedGroups) continue;
const teams: {
id: number;
setWins: number;
setLosses: number;
mapWins: number;
mapLosses: number;
winsAgainstTied: number;
buchholzSets: number;
buchholzMaps: number;
}[] = [];
const updateTeam = ({
teamId,
setWins = 0,
setLosses = 0,
mapWins = 0,
mapLosses = 0,
buchholzSets = 0,
buchholzMaps = 0,
}: {
teamId: number;
setWins?: number;
setLosses?: number;
mapWins?: number;
mapLosses?: number;
buchholzSets?: number;
buchholzMaps?: number;
}) => {
const team = teams.find((team) => team.id === teamId);
if (team) {
team.setWins += setWins;
team.setLosses += setLosses;
team.mapWins += mapWins;
team.mapLosses += mapLosses;
team.buchholzSets += buchholzSets;
team.buchholzMaps += buchholzMaps;
} else {
teams.push({
id: teamId,
setWins,
setLosses,
mapWins,
mapLosses,
winsAgainstTied: 0,
buchholzMaps,
buchholzSets,
});
}
};
const matchUps = new Map<number, number[]>();
for (const match of matches) {
if (match.opponent1?.id && match.opponent2?.id) {
const opponentOneMatchUps = matchUps.get(match.opponent1.id) ?? [];
const opponentTwoMatchUps = matchUps.get(match.opponent2.id) ?? [];
matchUps.set(match.opponent1.id, [
...opponentOneMatchUps,
match.opponent2.id,
]);
matchUps.set(match.opponent2.id, [
...opponentTwoMatchUps,
match.opponent1.id,
]);
}
if (
match.opponent1?.result !== "win" &&
match.opponent2?.result !== "win"
) {
continue;
}
const winner =
match.opponent1?.result === "win" ? match.opponent1 : match.opponent2;
const loser =
match.opponent1?.result === "win" ? match.opponent2 : match.opponent1;
if (!winner || !loser) continue;
invariant(
typeof winner.id === "number" &&
typeof loser.id === "number" &&
typeof winner.score === "number" &&
typeof loser.score === "number",
"RoundRobinBracket.standings: winner or loser id not found",
);
updateTeam({
teamId: winner.id,
setWins: 1,
setLosses: 0,
mapWins: winner.score,
mapLosses: loser.score,
});
updateTeam({
teamId: loser.id,
setWins: 0,
setLosses: 1,
mapWins: loser.score,
mapLosses: winner.score,
});
}
// BYES
for (const match of matches) {
if (match.opponent1 && match.opponent2) {
continue;
}
const winner = match.opponent1 ? match.opponent1 : match.opponent2;
if (!winner?.id) {
logger.warn("SwissBracket.currentStandings: winner not found");
continue;
}
const round = this.data.round.find(
(round) => round.id === match.round_id,
);
const mapWins =
round?.maps?.type === "PLAY_ALL"
? round?.maps?.count
: Math.ceil((round?.maps?.count ?? 0) / 2);
if (!mapWins) {
logger.warn("SwissBracket.currentStandings: mapWins not found");
continue;
}
updateTeam({
teamId: winner.id,
setWins: 1,
setLosses: 0,
mapWins: mapWins,
mapLosses: 0,
});
}
// buchholz
for (const team of teams) {
const teamsWhoPlayedAgainst = matchUps.get(team.id) ?? [];
let buchholzSets = 0;
let buchholzMaps = 0;
for (const teamId of teamsWhoPlayedAgainst) {
const opponent = teams.find((t) => t.id === teamId);
if (!opponent) {
logger.warn("SwissBracket.currentStandings: opponent not found", {
teamId,
});
continue;
}
buchholzSets += opponent.setWins;
buchholzMaps += opponent.mapWins;
}
updateTeam({
teamId: team.id,
buchholzSets,
buchholzMaps,
});
}
// wins against tied
for (const team of teams) {
for (const team2 of teams) {
if (team.id === team2.id) continue;
if (team.setWins !== team2.setWins) continue;
// they are different teams and are tied, let's check who won
const wonTheirMatch = matches.some(
(match) =>
(match.opponent1?.id === team.id &&
match.opponent2?.id === team2.id &&
match.opponent1?.result === "win") ||
(match.opponent1?.id === team2.id &&
match.opponent2?.id === team.id &&
match.opponent2?.result === "win"),
);
if (wonTheirMatch) {
team.winsAgainstTied++;
}
}
}
const droppedOutTeams = this.tournament.ctx.teams
.filter((t) => t.droppedOut)
.map((t) => t.id);
placements.push(
...teams
.sort((a, b) => {
const aDroppedOut = droppedOutTeams.includes(a.id);
const bDroppedOut = droppedOutTeams.includes(b.id);
if (aDroppedOut && !bDroppedOut) return 1;
if (!aDroppedOut && bDroppedOut) return -1;
if (a.setWins > b.setWins) return -1;
if (a.setWins < b.setWins) return 1;
if (a.winsAgainstTied > b.winsAgainstTied) return -1;
if (a.winsAgainstTied < b.winsAgainstTied) return 1;
if (a.mapWins > b.mapWins) return -1;
if (a.mapWins < b.mapWins) return 1;
if (a.buchholzSets > b.buchholzSets) return -1;
if (a.buchholzSets < b.buchholzSets) return 1;
if (a.buchholzMaps > b.buchholzMaps) return -1;
if (a.buchholzMaps < b.buchholzMaps) return 1;
const aSeed = Number(this.tournament.teamById(a.id)?.seed);
const bSeed = Number(this.tournament.teamById(b.id)?.seed);
if (aSeed < bSeed) return -1;
if (aSeed > bSeed) return 1;
return 0;
})
.map((team, i) => {
return {
team: this.tournament.teamById(team.id)!,
placement: i + 1,
groupId,
stats: {
setWins: team.setWins,
setLosses: team.setLosses,
mapWins: team.mapWins,
mapLosses: team.mapLosses,
winsAgainstTied: team.winsAgainstTied,
buchholzSets: team.buchholzSets,
buchholzMaps: team.buchholzMaps,
points: 0,
},
};
}),
);
}
const sorted = placements.sort((a, b) => {
if (a.placement < b.placement) return -1;
if (a.placement > b.placement) return 1;
if (a.groupId < b.groupId) return -1;
if (a.groupId > b.groupId) return 1;
return 0;
});
let lastPlacement = 0;
let currentPlacement = 1;
let teamsEncountered = 0;
return this.standingsWithoutNonParticipants(
sorted.map((team) => {
if (team.placement !== lastPlacement) {
lastPlacement = team.placement;
currentPlacement = teamsEncountered + 1;
}
teamsEncountered++;
return {
...team,
placement: currentPlacement,
stats: team.stats,
};
}),
);
}
get type(): TournamentBracketProgression[number]["type"] {
return "swiss";
}
get defaultRoundBestOfs() {
const result: BracketMapCounts = new Map();
for (const round of this.data.round) {
if (!result.get(round.group_id)) {
result.set(round.group_id, new Map());
}
result
.get(round.group_id)!
.set(round.number, { count: 3, type: "BEST_OF" });
}
return result;
}
}

View File

@ -0,0 +1,571 @@
// separate from brackets-manager as this wasn't part of the original brackets-manager library
import invariant from "tiny-invariant";
import type { DataTypes, ValueToArray } from "~/modules/brackets-manager/types";
import type { InputStage, Match } from "~/modules/brackets-model";
import { nullFilledArray } from "~/utils/arrays";
import type { Bracket, Standing } from "./Bracket";
import type { TournamentRepositoryInsertableMatch } from "~/features/tournament/TournamentRepository.server";
interface CreateArgs extends Omit<InputStage, "type" | "seeding" | "number"> {
seeding: Array<{ id: number; name: string }>;
}
export function create(args: CreateArgs): ValueToArray<DataTypes> {
const swissSettings = args.settings?.swiss;
const groupCount = swissSettings?.groupCount ?? 1;
const roundCount = swissSettings?.roundCount ?? 5;
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 }),
participant: args.seeding.map((p) => ({
id: p.id,
name: p.name,
tournament_id: args.tournamentId,
})),
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: CreateArgs["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.id,
},
opponent2: {
id: lower.id,
},
status: 2,
});
}
if (bye) {
result.push({
id: matchId++,
group_id: groupIdx,
stage_id: 0,
round_id: roundId,
number: upperHalf.length + 1,
opponent1: {
id: bye.id,
},
opponent2: null,
status: 2,
});
}
}
return result;
function splitToGroups() {
if (!seeding) return [];
if (groupCount === 1) return [seeding];
const groups: CreateArgs["seeding"][] = nullFilledArray(groupCount).map(
() => [],
);
for (let i = 0; i < seeding.length; i++) {
const groupIndex = i % groupCount;
groups[groupIndex].push(seeding[i]);
}
return groups;
}
}
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,
);
invariant(groupsMatches.length > 0, "No matches found for group");
// new matches can't be generated till old are over
if (!everyMatchOver(groupsMatches)) {
throw new Error("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
const standingsWithoutDropouts = groupsStandings.filter(
(s) => !s.team.droppedOut,
);
// if group has uneven number of teams
// the lowest standing team gets a bye
// that did not already receive one
const { bye, play } = splitToByeAndPlay(
standingsWithoutDropouts,
groupsMatches,
);
// split participating teams to sections
// each section resolves matches between teams of that section
// section could look something like this (team counts inaccurate):
// 3-0'ers - 4 members
// 2-1'ers - 6 members
// 1-2'ers - 6 members
// 0-3'ers - 4 members
// ---
// if a section has an uneven number of teams
// the lowest standing team gets dropped to the section below
// or if the lowest section is unevent the highest team of the lowest section
// gets promoted to the section above
let sections = splitPlayingTeamsToSections(play);
let iteration = 0;
let matches: [opponentOneId: number, opponentTwoId: number][] = [];
while (true) {
iteration++;
if (iteration > 100) {
throw new Error("Swiss bracket generation failed (too many iterations)");
}
// lets attempt to create matches for the current sections
// might fail if some section can't be matches so that nobody replays
const maybeMatches = sectionsToMatches(sections, groupsMatches);
// ok good matches found!
if (Array.isArray(maybeMatches)) {
matches = maybeMatches;
break;
}
// for some reason we couldn't find new opponent for everyone
// even with everyone in the same section, so let's just replay
// (should not be possible to happen if running swiss normally)
if (sections.length === 1) {
const maybeMatches = sectionsToMatches(sections, groupsMatches, true);
if (Array.isArray(maybeMatches)) {
matches = maybeMatches;
break;
}
throw new Error(
"Swiss bracket generation failed (failed to generate matches even with fallback behavior)",
);
}
// let's unify sections so that we can try again with a better chance
sections = unifySections(sections, maybeMatches.impossibleSectionIdx);
}
// finally lets just convert the generated pairs to match objects
// for the database
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");
let matchNumber = 1;
const result: TournamentRepositoryInsertableMatch[] = matches.map(
([opponentOneId, opponentTwoId]) => ({
groupId,
number: matchNumber++,
roundId: newRoundId,
stageId: groupsMatches[0].stage_id,
opponentOne: JSON.stringify({
id: opponentOneId,
}),
opponentTwo: JSON.stringify({
id: opponentTwoId,
}),
}),
);
if (bye) {
result.push({
groupId,
stageId: groupsMatches[0].stage_id,
roundId: newRoundId,
number: matchNumber,
opponentOne: JSON.stringify({
id: bye.team.id,
}),
opponentTwo: JSON.stringify(null),
});
}
return result;
}
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;
}
function splitToByeAndPlay(standings: Standing[], matches: Match[]) {
if (standings.length % 2 === 0) {
return {
bye: null,
play: standings,
};
}
const teamsThatHaveHadByes = matches
.filter((m) => m.opponent2 === null)
.map((m) => m.opponent1?.id);
const play = standings.slice();
const bye = play
.slice()
.reverse()
.find((s) => !teamsThatHaveHadByes.includes(s.team.id));
// should not happen
if (!bye) {
const reBye = play[play.length - 1];
return {
bye: reBye,
play: play.filter((s) => s.team.id !== reBye.team.id),
};
}
return {
bye: bye,
play: play.filter((s) => s.team.id !== bye.team.id),
};
}
type TournamentDataTeamSections = Standing[][];
function splitPlayingTeamsToSections(standings: Standing[]) {
let result: TournamentDataTeamSections = [];
let lastMapWins = -1;
let currentSection: Standing[] = [];
for (const standing of standings) {
const mapWins = standing.stats?.mapWins;
invariant(mapWins !== undefined, "mapWins not found");
if (mapWins !== lastMapWins) {
if (currentSection.length > 0) result.push(currentSection);
currentSection = [];
}
currentSection.push(standing);
lastMapWins = mapWins;
}
result.push(currentSection);
result = evenOutSectionsForward(result);
result = evenOutSectionsBackward(result);
return result;
}
function evenOutSectionsForward(sections: TournamentDataTeamSections) {
if (sections.every((section) => section.length % 2 === 0)) {
return sections;
}
const result: TournamentDataTeamSections = [];
let pushedStanding: Standing | null = null;
for (const [i, section] of sections.entries()) {
const newSection = section.slice();
if (pushedStanding) {
newSection.unshift(pushedStanding);
pushedStanding = null;
}
if (newSection.length % 2 !== 0 && i < sections.length - 1) {
pushedStanding = newSection.pop()!;
}
result.push(newSection);
}
return result;
}
function evenOutSectionsBackward(sections: TournamentDataTeamSections) {
if (sections.every((section) => section.length % 2 === 0)) {
return sections;
}
const result: TournamentDataTeamSections = [];
let pushedTeam: Standing | null = null;
for (const [i, section] of sections.slice().reverse().entries()) {
const newSection = section.slice();
if (pushedTeam) {
newSection.push(pushedTeam);
pushedTeam = null;
}
if (newSection.length % 2 !== 0) {
if (i === sections.length - 1) {
throw new Error("Can't even out sections");
}
pushedTeam = newSection.shift()!;
}
result.unshift(newSection);
}
return result;
}
function sectionsToMatches(
sections: TournamentDataTeamSections,
previousMatches: Match[],
fallbackBehaviorWithReplays = false,
):
| [opponentOneId: number, opponentTwoId: number][]
| { impossibleSectionIdx: number } {
const matches: [opponentOneId: number, opponentTwoId: number][] = [];
for (const [i, section] of sections.entries()) {
const isLossless = section.every(
(standing) => standing.stats!.setLosses === 0,
);
const isWinless = section.every(
(standing) => standing.stats!.setWins === 0,
);
if (isLossless || isWinless || fallbackBehaviorWithReplays) {
// doing it like this to make it so that if everyone plays to their seed
// then seeds 1 & 2 meet in the final round (assuming proper amount of rounds)
// these sections can't have replays no matter how we divide them
matches.push(...matchesBySeed(section));
} else {
const sectionMatches = matchesByNotPlayedBefore(section, previousMatches);
if (sectionMatches === null) {
return { impossibleSectionIdx: i };
}
matches.push(...sectionMatches);
}
}
return matches;
}
function unifySections(
sections: TournamentDataTeamSections,
sectionToUnifyIdx: number,
) {
const result: TournamentDataTeamSections = sections.slice();
if (sectionToUnifyIdx < sections.length - 1) {
// Combine section at sectionToUnifyIdx with the section after it
const currentSection = result[sectionToUnifyIdx];
const nextSection = result[sectionToUnifyIdx + 1];
const combinedSection = [...currentSection, ...nextSection];
result[sectionToUnifyIdx] = combinedSection;
result.splice(sectionToUnifyIdx + 1, 1);
} else {
// Combine last section with the section before it
const lastSection = result.pop()!;
const previousSection = result.pop()!;
const combinedSection = [...previousSection, ...lastSection];
result.push(combinedSection);
}
invariant(
sections.length - 1 === result.length,
"unifySections: length invalid",
);
return result;
}
function matchesBySeed(
teams: Standing[],
): [opponentOneId: number, opponentTwoId: number][] {
// we know that here nobody has played each other
const sortedBySeed = teams.slice().sort((a, b) => {
invariant(a.team.seed, "matchesBySeed: a.seed is falsy");
invariant(b.team.seed, "matchesBySeed: b.seed is falsy");
return a.team.seed - b.team.seed;
});
const matches: [opponentOneId: number, opponentTwoId: number][] = [];
while (sortedBySeed.length > 0) {
const one = sortedBySeed.shift()!;
const two = sortedBySeed.pop()!;
matches.push([one.team.id, two.team.id]);
}
return matches;
}
function matchesByNotPlayedBefore(
teams: Standing[],
previousMatches: Match[],
): [opponentOneId: number, opponentTwoId: number][] | null {
invariant(teams.length % 2 === 0, "matchesByNotPlayedBefore: uneven teams");
const alreadyPlayed = previousMatches.reduce((acc, cur) => {
if (!cur.opponent1?.id || !cur.opponent2?.id) return acc;
if (!acc.has(cur.opponent1.id)) {
acc.set(cur.opponent1.id, new Set());
}
acc.get(cur.opponent1.id)!.add(cur.opponent2.id);
if (!acc.has(cur.opponent2.id)) {
acc.set(cur.opponent2.id, new Set());
}
acc.get(cur.opponent2.id)!.add(cur.opponent1.id);
return acc;
}, new Map<number, Set<number>>());
const possibleRounds = makeRounds(teams.length);
for (const round of possibleRounds) {
let allNew = true;
for (const pair of round) {
const one = teams[pair[0]];
const two = teams[pair[1]];
if (alreadyPlayed.get(one.team.id)?.has(two.team.id)) {
allNew = false;
break;
}
}
if (!allNew) continue;
const matches: [opponentOneId: number, opponentTwoId: number][] = [];
for (const pair of round) {
const one = teams[pair[0]];
const two = teams[pair[1]];
matches.push([one.team.id, two.team.id]);
}
return matches;
}
return null;
}
// https://stackoverflow.com/a/75330248
function makeRounds(n: number) {
const sets: Record<number, number>[] = [];
const rounds: [number, number][][] = [];
for (let r = 0; r < n - 1; r++) {
sets.push({});
rounds.push([]);
}
for (let i = 0; i < n - 1; i++) {
for (let j = i + 1; j < n; j++) {
for (let r = 0; r < n - 1; r++) {
if (!sets[r][i] && !sets[r][j]) {
sets[r][i] = sets[r][j] = 1;
rounds[r].push([i, j]);
break;
}
}
}
}
return rounds;
}

View File

@ -1,5 +1,8 @@
import invariant from "tiny-invariant";
import type { TournamentBracketProgression } from "~/db/tables";
import type {
TournamentBracketProgression,
TournamentStage,
} from "~/db/tables";
import type { DataTypes, ValueToArray } from "~/modules/brackets-manager/types";
import { logger } from "~/utils/logger";
import { assertUnreachable } from "~/utils/types";
@ -23,6 +26,7 @@ import { BRACKET_NAMES } from "~/features/tournament/tournament-constants";
import { currentSeason } from "~/features/mmr/season";
import { getTournamentManager } from "./brackets-manager";
import { userSubmittedImage } from "~/utils/urls";
import * as Swiss from "./Swiss";
export type OptionalIdObject = { id: number } | undefined;
@ -132,6 +136,43 @@ export class Tournament {
type,
}),
);
} else if (type === "swiss") {
const { teams, relevantMatchesFinished } = sources
? this.resolveTeamsFromSources(sources)
: {
teams: this.ctx.teams,
relevantMatchesFinished: true,
};
const { checkedInTeams, notCheckedInTeams } =
this.divideTeamsToCheckedInAndNotCheckedIn({
teams,
bracketIdx,
});
this.brackets.push(
Bracket.create({
id: -1 * bracketIdx,
tournament: this,
seeding: checkedInTeams,
preview: true,
name,
data: Swiss.create({
tournamentId: this.ctx.id,
name,
seeding: checkedInTeams,
settings: this.bracketSettings(type, checkedInTeams.length),
}),
type,
sources,
createdAt: null,
canBeStarted:
checkedInTeams.length >= TOURNAMENT.ENOUGH_TEAMS_TO_START &&
(sources ? relevantMatchesFinished : this.regularCheckInHasEnded),
teamsPendingCheckIn:
bracketIdx !== 0 ? notCheckedInTeams.map((t) => t.id) : undefined,
}),
);
} else {
const manager = getTournamentManager();
const { teams, relevantMatchesFinished } = sources
@ -243,7 +284,9 @@ export class Tournament {
}
// should not happen but just in case
if (bracket.type === "round_robin") return teams;
if (bracket.type === "round_robin" || bracket.type === "swiss") {
return teams;
}
const sourceBracketEncounters = sourceBracket.data.match.reduce(
(acc, cur) => {
@ -275,7 +318,10 @@ export class Tournament {
manager.create({
tournamentId: this.ctx.id,
name: "X",
type: bracket.type,
type: bracket.type as Exclude<
TournamentStage["type"],
"round_robin" | "swiss"
>,
seeding: fillWithNullTillPowerOfTwo(candidateTeams),
settings: this.bracketSettings(bracket.type, candidateTeams.length),
});
@ -417,6 +463,11 @@ export class Tournament {
),
seedOrdering: ["groups.seed_optimized"],
};
case "swiss": {
return {
swiss: this.ctx.settings.swiss,
};
}
default: {
assertUnreachable(type);
}
@ -586,7 +637,26 @@ export class Tournament {
(b) => !b.preview || !b.isUnderground,
);
const everyRoundHasMatches = () => {
// only in swiss matches get generated as tournament progresses
if (
this.ctx.settings.bracketProgression.length > 1 ||
this.ctx.settings.bracketProgression[0].type !== "swiss"
) {
return true;
}
return this.brackets[0].data.round.every((round) => {
const hasMatches = this.brackets[0].data.match.some(
(match) => match.round_id === round.id,
);
return hasMatches;
});
};
return (
everyRoundHasMatches() &&
relevantBrackets.every((b) => b.everyMatchOver) &&
this.isOrganizer(user) &&
!this.ctx.isFinalized
@ -796,6 +866,10 @@ export class Tournament {
const anotherMatchBlocking = this.followingMatches(matchId).some(
(match) =>
// in swiss matches are generated round by round and the existance
// of a following match in itself is blocking even if they didn't start yet
bracket.type === "swiss" ||
// match is not in progress in un-swiss bracket, ok to reopen
(match.opponent1?.score && match.opponent1.score > 0) ||
(match.opponent2?.score && match.opponent2.score > 0),
);
@ -817,8 +891,9 @@ export class Tournament {
);
if (ongoingFollowUpBrackets.length === 0) return false;
// TODO: or swiss
if (matchBracket.type === "round_robin") return true;
if (matchBracket.type === "round_robin" || matchBracket.type === "swiss") {
return true;
}
const participantInAnotherBracket = ongoingFollowUpBrackets
.flatMap((b) => b.data.participant)

View File

@ -444,6 +444,7 @@ export class Match {
opponentOnePointsTotal: number | null;
opponentTwoPointsTotal: number | null;
lastGameFinishedAt: number | null;
createdAt: number | null;
},
): MatchType {
return {
@ -468,6 +469,7 @@ export class Match {
stage_id: rawMatch.stageId,
status: rawMatch.status,
lastGameFinishedAt: rawMatch.lastGameFinishedAt,
createdAt: rawMatch.createdAt,
};
}

View File

@ -191,7 +191,9 @@ export function roundMapsFromInput({
bracket: Bracket;
}) {
const expandedMaps =
bracket.type === "round_robin" ? expandMaps({ maps, virtualRounds }) : maps;
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));

View File

@ -27,6 +27,7 @@ const createTeam = (teamId: number, userIds: number[]): TournamentDataTeam => ({
})),
name: "Team " + teamId,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
seed: 1,

View File

@ -2347,6 +2347,7 @@ export const PADDLING_POOL_257 = () =>
name: "Le classique à Cam",
seed: 1,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -2451,6 +2452,7 @@ export const PADDLING_POOL_257 = () =>
name: "New Generation",
seed: 2,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -2567,6 +2569,7 @@ export const PADDLING_POOL_257 = () =>
name: "Mafia mbappe",
seed: 3,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -2671,6 +2674,7 @@ export const PADDLING_POOL_257 = () =>
name: "better gaming chair",
seed: 4,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -2775,6 +2779,7 @@ export const PADDLING_POOL_257 = () =>
name: "Seaya",
seed: 5,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -2891,6 +2896,7 @@ export const PADDLING_POOL_257 = () =>
name: "NEVER BACK DOWN NEVER WHAT?",
seed: 6,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -3007,6 +3013,7 @@ export const PADDLING_POOL_257 = () =>
name: "Hazard",
seed: 7,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -3123,6 +3130,7 @@ export const PADDLING_POOL_257 = () =>
name: "ASC Shokkai ",
seed: 8,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -3227,6 +3235,7 @@ export const PADDLING_POOL_257 = () =>
name: "Naw, Id win",
seed: 9,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -3355,6 +3364,7 @@ export const PADDLING_POOL_257 = () =>
name: "Chippeur arrête de Chipper",
seed: 10,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -3471,6 +3481,7 @@ export const PADDLING_POOL_257 = () =>
name: "Theres a snake in my boot 🐍",
seed: 11,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -3713,6 +3724,7 @@ export const PADDLING_POOL_257 = () =>
name: "ASC Niji",
seed: 13,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -3829,6 +3841,7 @@ export const PADDLING_POOL_257 = () =>
name: "Smoking Moais ",
seed: 14,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -3957,6 +3970,7 @@ export const PADDLING_POOL_257 = () =>
name: "SAN DIMAS HS FOOTBALL RULES!",
seed: 15,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -4065,6 +4079,7 @@ export const PADDLING_POOL_257 = () =>
name: "Pickup oder so ig",
seed: 16,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -4173,6 +4188,7 @@ export const PADDLING_POOL_257 = () =>
name: "DistInkt",
seed: 17,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -4403,6 +4419,7 @@ export const PADDLING_POOL_257 = () =>
name: "Blaze",
seed: 19,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -4613,6 +4630,7 @@ export const PADDLING_POOL_257 = () =>
name: "Squid Emoji",
seed: 21,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -4717,6 +4735,7 @@ export const PADDLING_POOL_257 = () =>
name: "Müll🚮",
seed: 22,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -4821,6 +4840,7 @@ export const PADDLING_POOL_257 = () =>
name: "Rogueport Rascals",
seed: 23,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -4929,6 +4949,7 @@ export const PADDLING_POOL_257 = () =>
name: "Shade",
seed: 24,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -5155,6 +5176,7 @@ export const PADDLING_POOL_257 = () =>
name: "Second Try",
seed: 26,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -5275,6 +5297,7 @@ export const PADDLING_POOL_257 = () =>
name: "1HP",
seed: 27,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -5391,6 +5414,7 @@ export const PADDLING_POOL_257 = () =>
name: "Préférence Pêche ",
seed: 28,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -5495,6 +5519,7 @@ export const PADDLING_POOL_257 = () =>
name: "AquaSonix",
seed: 29,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -5729,6 +5754,7 @@ export const PADDLING_POOL_257 = () =>
name: "Intrusive thoughts ",
seed: 31,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -5833,6 +5859,7 @@ export const PADDLING_POOL_257 = () =>
name: "Heaven sent Lunatics",
seed: 32,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -5941,6 +5968,7 @@ export const PADDLING_POOL_257 = () =>
name: "<_>Placeholder",
seed: 33,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -6061,6 +6089,7 @@ export const PADDLING_POOL_257 = () =>
name: "Big Tommy and the Flops",
seed: 34,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -6165,6 +6194,7 @@ export const PADDLING_POOL_257 = () =>
name: "G Gaming Gaming",
seed: 35,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -8263,6 +8293,7 @@ export const PADDLING_POOL_255 = () =>
name: "Enperries 200p",
seed: 2,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -8367,6 +8398,7 @@ export const PADDLING_POOL_255 = () =>
name: "allo kayora ?",
seed: 3,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -8597,6 +8629,7 @@ export const PADDLING_POOL_255 = () =>
name: "Rule them",
seed: 5,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -8701,6 +8734,7 @@ export const PADDLING_POOL_255 = () =>
name: "NEVER BACK DOWN NEVER WHAT",
seed: 6,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -8817,6 +8851,7 @@ export const PADDLING_POOL_255 = () =>
name: "iPad jaune",
seed: 7,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -9023,6 +9058,7 @@ export const PADDLING_POOL_255 = () =>
name: "Hazard",
seed: 9,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -9151,6 +9187,7 @@ export const PADDLING_POOL_255 = () =>
name: "ASC Shokkai ",
seed: 10,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -9369,6 +9406,7 @@ export const PADDLING_POOL_255 = () =>
name: "Smoking Moais ",
seed: 12,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -9611,6 +9649,7 @@ export const PADDLING_POOL_255 = () =>
name: "https://youtu.be/Euq7uTeYCP0?si=",
seed: 14,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -9715,6 +9754,7 @@ export const PADDLING_POOL_255 = () =>
name: "Gambawaffeln",
seed: 15,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -9831,6 +9871,7 @@ export const PADDLING_POOL_255 = () =>
name: "ici ça bzzz",
seed: 16,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -9935,6 +9976,7 @@ export const PADDLING_POOL_255 = () =>
name: "Amoura",
seed: 17,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -10153,6 +10195,7 @@ export const PADDLING_POOL_255 = () =>
name: "Grandma Sicko Mode",
seed: 19,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -10269,6 +10312,7 @@ export const PADDLING_POOL_255 = () =>
name: "youtube.com/watch?v=dQw4w9WgXcQ",
seed: 20,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -10377,6 +10421,7 @@ export const PADDLING_POOL_255 = () =>
name: "Müll🚮",
seed: 21,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -10497,6 +10542,7 @@ export const PADDLING_POOL_255 = () =>
name: "Hisense RL170D4BWE Freestanding ",
seed: 22,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -10731,6 +10777,7 @@ export const PADDLING_POOL_255 = () =>
name: "Blaze",
seed: 24,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -10835,6 +10882,7 @@ export const PADDLING_POOL_255 = () =>
name: "Squid Emoji",
seed: 25,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -10951,6 +10999,7 @@ export const PADDLING_POOL_255 = () =>
name: "DistInkt",
seed: 26,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -11067,6 +11116,7 @@ export const PADDLING_POOL_255 = () =>
name: "We Are Innocent Caterpillars",
seed: 27,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -11171,6 +11221,7 @@ export const PADDLING_POOL_255 = () =>
name: "Ink Souls Maria",
seed: 28,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -11303,6 +11354,7 @@ export const PADDLING_POOL_255 = () =>
name: "Yoghurt Party",
seed: 29,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -11419,6 +11471,7 @@ export const PADDLING_POOL_255 = () =>
name: "Second Try",
seed: 30,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -11539,6 +11592,7 @@ export const PADDLING_POOL_255 = () =>
name: "ASC Niji~K",
seed: 31,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -11761,6 +11815,7 @@ export const PADDLING_POOL_255 = () =>
name: "Stream easy by lesserafim",
seed: 33,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -11869,6 +11924,7 @@ export const PADDLING_POOL_255 = () =>
name: "Bloody Wave",
seed: 34,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -12013,6 +12069,7 @@ export const PADDLING_POOL_255 = () =>
name: "Fresh takos ",
seed: 35,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -14277,6 +14334,7 @@ export const IN_THE_ZONE_32 = () =>
name: "Starburst",
seed: 1,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -14369,6 +14427,7 @@ export const IN_THE_ZONE_32 = () =>
name: "Jackpot",
seed: 2,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -14461,6 +14520,7 @@ export const IN_THE_ZONE_32 = () =>
name: "Grougrou ",
seed: 3,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -14553,6 +14613,7 @@ export const IN_THE_ZONE_32 = () =>
name: "atomic bomb ",
seed: 4,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -14669,6 +14730,7 @@ export const IN_THE_ZONE_32 = () =>
name: "Celeste",
seed: 5,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -14773,6 +14835,7 @@ export const IN_THE_ZONE_32 = () =>
name: "Moonlight",
seed: 6,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -14877,6 +14940,7 @@ export const IN_THE_ZONE_32 = () =>
name: "BEt",
seed: 7,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -14993,6 +15057,7 @@ export const IN_THE_ZONE_32 = () =>
name: "As you wish",
seed: 8,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -15109,6 +15174,7 @@ export const IN_THE_ZONE_32 = () =>
name: "UK MAFIA",
seed: 9,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -15201,6 +15267,7 @@ export const IN_THE_ZONE_32 = () =>
name: "Black Lotus ",
seed: 10,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -15293,6 +15360,7 @@ export const IN_THE_ZONE_32 = () =>
name: "ASC Tenshi",
seed: 11,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -15385,6 +15453,7 @@ export const IN_THE_ZONE_32 = () =>
name: "Hypernova",
seed: 12,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -15477,6 +15546,7 @@ export const IN_THE_ZONE_32 = () =>
name: "Zenith",
seed: 13,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -15581,6 +15651,7 @@ export const IN_THE_ZONE_32 = () =>
name: "Yaotl Teotl",
seed: 14,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -15673,6 +15744,7 @@ export const IN_THE_ZONE_32 = () =>
name: "FOAMS 34",
seed: 15,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -15765,6 +15837,7 @@ export const IN_THE_ZONE_32 = () =>
name: "New Generation ",
seed: 16,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -15869,6 +15942,7 @@ export const IN_THE_ZONE_32 = () =>
name: "Gen BOB Ten-Piece Chicken Nugget",
seed: 17,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -15977,6 +16051,7 @@ export const IN_THE_ZONE_32 = () =>
name: "Joga Bonito",
seed: 18,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -16081,6 +16156,7 @@ export const IN_THE_ZONE_32 = () =>
name: "JumpingCatapult ",
seed: 19,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -16197,6 +16273,7 @@ export const IN_THE_ZONE_32 = () =>
name: "metal pipe",
seed: 20,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -16289,6 +16366,7 @@ export const IN_THE_ZONE_32 = () =>
name: "Gentlemates",
seed: 21,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -16409,6 +16487,7 @@ export const IN_THE_ZONE_32 = () =>
name: "Hades",
seed: 22,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -16508,6 +16587,7 @@ export const IN_THE_ZONE_32 = () =>
name: "Hazard",
seed: 23,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -16600,6 +16680,7 @@ export const IN_THE_ZONE_32 = () =>
name: "Reputation (Taylor's Version)",
seed: 24,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -16692,6 +16773,7 @@ export const IN_THE_ZONE_32 = () =>
name: "Smoking Moais ",
seed: 25,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -16808,6 +16890,7 @@ export const IN_THE_ZONE_32 = () =>
name: "Zoneando",
seed: 26,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -16912,6 +16995,7 @@ export const IN_THE_ZONE_32 = () =>
name: "OVERTIME!!",
seed: 27,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -17020,6 +17104,7 @@ export const IN_THE_ZONE_32 = () =>
name: "11:11; Make a Wish",
seed: 28,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -17124,6 +17209,7 @@ export const IN_THE_ZONE_32 = () =>
name: "delulu ",
seed: 29,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -17342,6 +17428,7 @@ export const IN_THE_ZONE_32 = () =>
name: "Peaky P-Key ",
seed: 31,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -17540,6 +17627,7 @@ export const IN_THE_ZONE_32 = () =>
name: "all my homie hate pencil ",
seed: 33,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,
@ -17644,6 +17732,7 @@ export const IN_THE_ZONE_32 = () =>
name: "Whaa",
seed: 34,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
team: null,
inviteCode: null,

View File

@ -17,6 +17,7 @@ const tournamentCtxTeam = (
members: [],
name: "Team " + teamId,
prefersNotToHost: 0,
droppedOut: 0,
noScreen: 0,
seed: teamId + 1,
...partial,

View File

@ -96,7 +96,7 @@ function getFilteredRounds(
rounds: Round[],
type: TournamentBracketProgression[number]["type"],
) {
if (type !== "round_robin") return rounds;
if (type !== "round_robin" && type !== "swiss") return rounds;
// highest group id because lower group id's can have byes that higher don't
const highestGroupId = Math.max(...rounds.map((x) => x.group_id));
@ -128,10 +128,10 @@ function resolveRoundMapCount(
counts: BracketMapCounts,
type: TournamentBracketProgression[number]["type"],
) {
// with rr we just take the first group id
// with rr/swiss we just take the first group id
// as every group has the same map list
const groupId =
type === "round_robin"
type === "round_robin" || type === "swiss"
? Math.max(...Array.from(counts.keys()))
: round.group_id;

View File

@ -12,6 +12,7 @@ import type { TournamentRoundMaps } from "~/db/tables";
const stm = sql.prepare(/* sql */ `
select
"TournamentMatch"."id",
"TournamentMatch"."groupId",
"TournamentMatch"."opponentOne",
"TournamentMatch"."opponentTwo",
"TournamentMatch"."bestOf",
@ -56,7 +57,7 @@ export const findMatchById = (id: number) => {
const row = stm.get({ id }) as
| ((Pick<
TournamentMatch,
"id" | "opponentOne" | "opponentTwo" | "bestOf" | "chatCode"
"id" | "groupId" | "opponentOne" | "opponentTwo" | "bestOf" | "chatCode"
> &
Pick<Tournament, "mapPickingStyle"> & { players: string }) & {
roundMaps: string | null;

View File

@ -63,7 +63,10 @@ import { roundMapsFromInput } from "../core/mapList.server";
import { updateRoundMaps } from "~/features/tournament/queries/updateRoundMaps.server";
import { checkInMany } from "~/features/tournament/queries/checkInMany.server";
import { logger } from "~/utils/logger";
import * as Swiss from "../core/Swiss";
import { createSwissBracketInTransaction } from "~/features/tournament/queries/createSwissBracketInTransaction.server";
import { refreshUserSkills } from "~/features/mmr/tiered.server";
import type { Tournament } from "../core/Tournament";
import "../components/Bracket/bracket.css";
import "../tournament-bracket.css";
@ -89,24 +92,41 @@ export const action: ActionFunction = async ({ params, request }) => {
const groupCount = new Set(bracket.data.round.map((r) => r.group_id))
.size;
validate(
bracket.type === "round_robin"
bracket.type === "round_robin" || bracket.type === "swiss"
? bracket.data.round.length / groupCount === data.maps.length
: bracket.data.round.length === data.maps.length,
"Invalid map count",
);
sql.transaction(() => {
const stage = manager.create({
tournamentId,
name: bracket.name,
type: bracket.type,
seeding:
bracket.type === "round_robin"
? seeding
: fillWithNullTillPowerOfTwo(seeding),
settings: tournament.bracketSettings(bracket.type, seeding.length),
});
const stage =
bracket.type === "swiss"
? createSwissBracketInTransaction(
Swiss.create({
name: bracket.name,
seeding,
tournamentId,
settings: tournament.bracketSettings(
bracket.type,
seeding.length,
),
}),
)
: manager.create({
tournamentId,
name: bracket.name,
type: bracket.type as "round_robin",
seeding:
bracket.type === "round_robin"
? seeding
: fillWithNullTillPowerOfTwo(seeding),
settings: tournament.bracketSettings(
bracket.type,
seeding.length,
),
});
updateRoundMaps(
roundMapsFromInput({
@ -146,6 +166,33 @@ export const action: ActionFunction = async ({ params, request }) => {
break;
}
case "ADVANCE_BRACKET": {
const bracket = tournament.bracketByIdx(data.bracketIdx);
validate(bracket, "Bracket not found");
validate(bracket.type === "swiss", "Can't advance non-swiss bracket");
const matches = Swiss.generateMatchUps({
bracket,
groupId: data.groupId,
});
await TournamentRepository.insertSwissMatches(matches);
break;
}
case "UNADVANCE_BRACKET": {
const bracket = tournament.bracketByIdx(data.bracketIdx);
validate(bracket, "Bracket not found");
validate(bracket.type === "swiss", "Can't unadvance non-swiss bracket");
validateNoFollowUpBrackets(tournament);
await TournamentRepository.deleteSwissMatches({
groupId: data.groupId,
roundId: data.roundId,
});
break;
}
case "FINALIZE_TOURNAMENT": {
validate(tournament.canFinalize(user), "Can't finalize tournament");
@ -209,6 +256,17 @@ export const action: ActionFunction = async ({ params, request }) => {
return null;
};
function validateNoFollowUpBrackets(tournament: Tournament) {
const followUpBrackets = tournament.brackets.filter(
(b) => b.sources && b.sources.some((source) => source.bracketIdx === 0),
);
validate(
followUpBrackets.every((b) => b.preview),
"Follow-up brackets are already started",
);
}
export default function TournamentBracketsPage() {
const { t } = useTranslation(["tournament"]);
const visibility = useVisibilityChange();
@ -383,7 +441,9 @@ export default function TournamentBracketsPage() {
<CompactifyButton />
) : null}
</div>
{bracket.enoughTeams ? <Bracket bracket={bracket} /> : null}
{bracket.enoughTeams ? (
<Bracket bracket={bracket} bracketIdx={bracketIdx} />
) : null}
</div>
{!bracket.enoughTeams ? (
<div>

View File

@ -493,6 +493,7 @@ export default function TournamentMatchPage() {
to={tournamentBracketsPage({
tournamentId: tournament.ctx.id,
bracketIdx: tournament.matchIdToBracketIdx(data.match.id),
groupId: data.match.groupId,
})}
variant="outlined"
size="tiny"
@ -561,6 +562,17 @@ function MatchHeader() {
);
roundName = `Groups ${group?.number ? groupNumberToLetter(group.number) : ""}${round?.number ?? ""}.${match.number}`;
} else if (bracket.type === "swiss") {
const group = bracket.data.group.find(
(group) => group.id === match.group_id,
);
const round = bracket.data.round.find(
(round) => round.id === match.round_id,
);
const oneGroupOnly = bracket.data.group.length === 1;
roundName = `Swiss${oneGroupOnly ? "" : " Group"} ${group?.number && !oneGroupOnly ? groupNumberToLetter(group.number) : ""} ${round?.number ?? ""}.${match.number}`;
} else if (
bracket.type === "single_elimination" ||
bracket.type === "double_elimination"

View File

@ -104,6 +104,17 @@ export const bracketSchema = z.union([
bracketIdx,
maps: z.preprocess(safeJSONParse, z.array(tournamentRoundMaps)),
}),
z.object({
_action: _action("ADVANCE_BRACKET"),
groupId: id,
bracketIdx,
}),
z.object({
_action: _action("UNADVANCE_BRACKET"),
groupId: id,
roundId: id,
bracketIdx,
}),
z.object({
_action: _action("FINALIZE_TOURNAMENT"),
}),

View File

@ -453,6 +453,10 @@
border-radius: 0;
}
.tournament-bracket__bracket-nav__link__big {
font-size: var(--fonts-lg);
}
.tournament-bracket__bracket-nav__link:active {
transform: translateY(0px);
}

View File

@ -1,7 +1,9 @@
import type { Insertable, NotNull, Transaction } from "kysely";
import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite";
import { nanoid } from "nanoid";
import { db } from "~/db/sql";
import type { CastedMatchesInfo, DB, Tables } from "~/db/tables";
import { Status } from "~/modules/brackets-model";
import { modesShort } from "~/modules/in-game-lists";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import { COMMON_USER_FIELDS, userChatNameColor } from "~/utils/kysely.server";
@ -71,6 +73,7 @@ export async function findById(id: number) {
"TournamentTeam.seed",
"TournamentTeam.prefersNotToHost",
"TournamentTeam.noScreen",
"TournamentTeam.droppedOut",
"TournamentTeam.inviteCode",
"TournamentTeam.createdAt",
jsonArrayFrom(
@ -367,6 +370,40 @@ export function updateTeamName({
.execute();
}
export function dropTeamOut({
tournamentTeamId,
previewBracketIdxs,
}: {
tournamentTeamId: number;
previewBracketIdxs: number[];
}) {
return db.transaction().execute(async (trx) => {
await trx
.deleteFrom("TournamentTeamCheckIn")
.where("tournamentTeamId", "=", tournamentTeamId)
.where("TournamentTeamCheckIn.bracketIdx", "in", previewBracketIdxs)
.execute();
await trx
.updateTable("TournamentTeam")
.set({
droppedOut: 1,
})
.where("id", "=", tournamentTeamId)
.execute();
});
}
export function undoDropTeamOut(tournamentTeamId: number) {
return db
.updateTable("TournamentTeam")
.set({
droppedOut: 0,
})
.where("id", "=", tournamentTeamId)
.execute();
}
export function addStaff({
tournamentId,
userId,
@ -577,3 +614,47 @@ export function resetBracket(tournamentStageId: number) {
.execute();
});
}
export type TournamentRepositoryInsertableMatch = Omit<
Insertable<DB["TournamentMatch"]>,
"status" | "bestOf" | "chatCode"
>;
export function insertSwissMatches(
matches: TournamentRepositoryInsertableMatch[],
) {
if (matches.length === 0) {
throw new Error("No matches to insert");
}
return db
.insertInto("TournamentMatch")
.values(
matches.map((match) => ({
groupId: match.groupId,
number: match.number,
opponentOne: match.opponentOne,
opponentTwo: match.opponentTwo,
roundId: match.roundId,
stageId: match.stageId,
status: Status.Ready,
createdAt: dateToDatabaseTimestamp(new Date()),
chatCode: nanoid(10),
})),
)
.execute();
}
export function deleteSwissMatches({
groupId,
roundId,
}: {
groupId: number;
roundId: number;
}) {
return db
.deleteFrom("TournamentMatch")
.where("groupId", "=", groupId)
.where("roundId", "=", roundId)
.execute();
}

View File

@ -10,13 +10,12 @@ import { sourceTypes } from "~/modules/tournament-map-list-generator";
import invariant from "tiny-invariant";
import type { Tables } from "~/db/tables";
import { logger } from "~/utils/logger";
import { BRACKET_NAMES } from "../tournament-constants";
export interface PlayedSet {
tournamentMatchId: number;
score: [teamBeingViewed: number, opponent: number];
round: {
type: "winners" | "losers" | "single_elim" | "round_robin";
type: "winners" | "losers" | "single_elim" | "round_robin" | "swiss";
round: number | "finals" | "grand_finals" | "bracket_reset";
};
stageName: string;
@ -94,6 +93,10 @@ export function tournamentTeamSets({
allRounds.find((round) => round.stageId === set.stageId) ?? allRounds[0];
const resolveRound = () => {
if (round.stageType === "round_robin" || round.stageType === "swiss") {
return set.roundNumber;
}
if (set.groupNumber === 3) {
if (set.roundNumber === 2) return "bracket_reset";
@ -110,10 +113,7 @@ export function tournamentTeamSets({
.map((round) => round.roundNumber),
);
if (
round.stageName !== BRACKET_NAMES.GROUPS &&
set.roundNumber === maxRoundNumberOfGroup
) {
if (set.roundNumber === maxRoundNumberOfGroup) {
return "finals";
}
@ -193,6 +193,10 @@ function resolveRoundType({
return "round_robin";
}
if (stageType === "swiss") {
return "swiss";
}
if (groupNumber === 1 || groupNumber === 3) {
return "winners";
}

View File

@ -0,0 +1,130 @@
import { nanoid } from "nanoid";
import invariant from "tiny-invariant";
import { sql } from "~/db/sql";
import type { Tables } from "~/db/tables";
import type { DataTypes, ValueToArray } from "~/modules/brackets-manager/types";
import { dateToDatabaseTimestamp } from "~/utils/dates";
const createTournamentStageStm = sql.prepare(/* sql */ `
insert into "TournamentStage" (
"tournamentId",
"type",
"createdAt",
"settings",
"number",
"name"
) values (
@tournamentId,
@type,
@createdAt,
@settings,
@number,
@name
) returning *
`);
const createTournamentGroupStm = sql.prepare(/* sql */ `
insert into "TournamentGroup" (
"number",
"stageId"
) values (
@number,
@stageId
) returning *
`);
const createTournamentRoundStm = sql.prepare(/* sql */ `
insert into "TournamentRound" (
"groupId",
"number",
"stageId"
) values (
@groupId,
@number,
@stageId
) returning *
`);
const createTournamentMatchStm = sql.prepare(/* sql */ `
insert into "TournamentMatch" (
"chatCode",
"groupId",
"number",
"opponentOne",
"opponentTwo",
"roundId",
"stageId",
"status",
"createdAt"
) values (
@chatCode,
@groupId,
@number,
@opponentOne,
@opponentTwo,
@roundId,
@stageId,
@status,
@createdAt
)
`);
export function createSwissBracketInTransaction(
input: ValueToArray<DataTypes>,
) {
const stageInput = input.stage[0];
invariant(stageInput, "Stage input is required");
invariant(stageInput.type === "swiss", "Invalid stage type");
const stageFromDB = createTournamentStageStm.get({
tournamentId: stageInput.tournament_id,
type: stageInput.type,
createdAt: dateToDatabaseTimestamp(new Date()),
settings: JSON.stringify(stageInput.settings),
number: stageInput.number,
name: stageInput.name,
}) as Tables["TournamentStage"];
for (const group of input.group) {
const groupFromDB = createTournamentGroupStm.get({
number: group.number,
stageId: stageFromDB.id,
}) as Tables["TournamentGroup"];
for (const round of input.round) {
if (round.group_id !== group.id) {
continue;
}
const roundFromDB = createTournamentRoundStm.get({
groupId: groupFromDB.id,
number: round.number,
stageId: stageFromDB.id,
}) as Tables["TournamentRound"];
for (const match of input.match) {
if (match.round_id !== round.id) {
continue;
}
createTournamentMatchStm.run({
chatCode: nanoid(10),
groupId: groupFromDB.id,
number: match.number,
opponentOne: match.opponent1
? JSON.stringify(match.opponent1)
: "null",
opponentTwo: match.opponent2
? JSON.stringify(match.opponent2)
: "null",
roundId: roundFromDB.id,
stageId: stageFromDB.id,
status: match.status,
createdAt: dateToDatabaseTimestamp(new Date()),
});
}
}
}
return stageFromDB;
}

View File

@ -255,6 +255,22 @@ export const action: ActionFunction = async ({ request, params }) => {
});
break;
}
case "DROP_TEAM_OUT": {
validateIsTournamentOrganizer();
await TournamentRepository.dropTeamOut({
tournamentTeamId: data.teamId,
previewBracketIdxs: tournament.brackets.flatMap((b, idx) =>
b.preview ? idx : [],
),
});
break;
}
case "UNDO_DROP_TEAM_OUT": {
validateIsTournamentOrganizer();
await TournamentRepository.undoDropTeamOut(data.teamId);
break;
}
case "RESET_BRACKET": {
validateIsTournamentOrganizer();
validate(!tournament.ctx.isFinalized, "Tournament is finalized");
@ -395,6 +411,16 @@ const actions = [
inputs: ["REGISTERED_TEAM"] as Input[],
when: ["TOURNAMENT_BEFORE_START"],
},
{
type: "DROP_TEAM_OUT",
inputs: ["REGISTERED_TEAM"] as Input[],
when: ["TOURNAMENT_AFTER_START", "IS_SWISS"],
},
{
type: "UNDO_DROP_TEAM_OUT",
inputs: ["REGISTERED_TEAM"] as Input[],
when: ["TOURNAMENT_AFTER_START", "IS_SWISS"],
},
] as const;
function TeamActions() {
@ -431,6 +457,19 @@ function TeamActions() {
}
break;
}
case "TOURNAMENT_AFTER_START": {
if (!tournament.hasStarted) {
return false;
}
break;
}
case "IS_SWISS": {
if (!tournament.brackets.some((b) => b.type === "swiss")) {
return false;
}
break;
}
default: {
assertUnreachable(when);
}

View File

@ -18,5 +18,11 @@ export const BRACKET_NAMES = {
FINALS: "Final stage",
};
export const FORMATS_SHORT = ["DE", "RR_TO_SE", "SE"] as const;
export const FORMATS_SHORT = [
"DE",
"SE",
"RR_TO_SE",
"SWISS",
"SWISS_TO_SE",
] as const;
export type TournamentFormatShort = (typeof FORMATS_SHORT)[number];

View File

@ -104,6 +104,14 @@ export const adminActionSchema = z.union([
_action: _action("REMOVE_STAFF"),
userId: id,
}),
z.object({
_action: _action("DROP_TEAM_OUT"),
teamId: id,
}),
z.object({
_action: _action("UNDO_DROP_TEAM_OUT"),
teamId: id,
}),
z.object({
_action: _action("UPDATE_CAST_TWITCH_ACCOUNTS"),
castTwitchAccounts: z.preprocess(

View File

@ -162,7 +162,7 @@ export class BaseUpdater extends BaseGetter {
// Don't update related matches if it's a simple score update.
if (!statusChanged && !resultChanged) return;
if (!helpers.isRoundRobin(stage))
if (!helpers.isRoundRobin(stage) && !helpers.isSwiss(stage))
this.updateRelatedMatches(stored, statusChanged, resultChanged);
}

View File

@ -1761,6 +1761,10 @@ export function isRoundRobin(stage: Stage): boolean {
return stage.type === "round_robin";
}
export function isSwiss(stage: Stage): boolean {
return stage.type === "swiss";
}
/**
* Throws if a stage is round-robin.
*

View File

@ -25,7 +25,7 @@ export class Reset extends BaseUpdater {
);
const matchLocation = helpers.getMatchLocation(stage.type, group.number);
const nextMatches =
stage.type !== "round_robin"
stage.type !== "round_robin" && stage.type !== "swiss"
? this.getNextMatches(
stored,
matchLocation,
@ -48,7 +48,7 @@ export class Reset extends BaseUpdater {
helpers.resetMatchResults(stored);
this.applyMatchUpdate(stored);
if (!helpers.isRoundRobin(stage))
if (!helpers.isRoundRobin(stage) && !helpers.isSwiss(stage))
this.updateRelatedMatches(stored, true, true);
}

View File

@ -123,4 +123,9 @@ export interface StageSettings {
* It might be fairer since it gives the WB winner the right to lose once during the stage...
*/
grandFinal?: GrandFinalType;
swiss?: {
groupCount: number;
roundCount: number;
};
}

View File

@ -100,4 +100,6 @@ export interface Match extends MatchResults {
number: number;
lastGameFinishedAt?: number | null;
createdAt?: number | null;
}

View File

@ -8,7 +8,8 @@
export type StageType =
| "round_robin"
| "single_elimination"
| "double_elimination";
| "double_elimination"
| "swiss";
/**
* All the possible types of group in an elimination stage.

View File

@ -18,3 +18,7 @@
.calendar-new__day-label {
margin: 0;
}
.calendar-new__range-input {
width: 4.25rem;
}

View File

@ -243,13 +243,24 @@ export const tournamentAdminPage = (tournamentId: number) =>
export const tournamentBracketsPage = ({
tournamentId,
bracketIdx,
groupId,
}: {
tournamentId: number;
bracketIdx?: number | null;
}) =>
`/to/${tournamentId}/brackets${
typeof bracketIdx === "number" ? `?idx=${bracketIdx}` : ""
groupId?: number;
}) => {
const query = new URLSearchParams();
if (typeof bracketIdx === "number") {
query.set("idx", String(bracketIdx));
}
if (typeof groupId === "number") {
query.set("group", String(groupId));
}
return `/to/${tournamentId}/brackets${
query.size > 0 ? `?${query.toString()}` : ""
}`;
};
export const tournamentBracketsSubscribePage = (tournamentId: number) =>
`/to/${tournamentId}/brackets/subscribe`;
export const tournamentMatchPage = ({

View File

@ -673,6 +673,59 @@ test.describe("Tournament bracket", () => {
});
});
test("swiss tournament with bracket advancing/unadvancing & dropping out a team", async ({
page,
}) => {
const tournamentId = 5;
await seed(page);
await impersonate(page);
await navigate({
page,
url: tournamentBracketsPage({ tournamentId }),
});
await page.getByTestId("finalize-bracket-button").click();
await page.getByTestId("confirm-finalize-bracket-button").click();
// report all group A round 1 scores
for (const id of [1, 2, 3, 4]) {
await page.locator(`[data-match-id="${id}"]`).click();
await reportResult({
page,
amountOfMapsToReport: 2,
sidesWithMoreThanFourPlayers: id === 1 ? [] : ["last"],
});
await backToBracket(page);
}
// test that we can change to view different group
await expect(page.getByTestId("start-round-button")).toBeVisible();
await page.getByTestId("group-B-button").click();
await isNotVisible(page.getByTestId("start-round-button"));
await page.getByTestId("group-A-button").click();
await page.getByTestId("start-round-button").click();
await expect(page.locator(`[data-match-id="9"]`)).toBeVisible();
await page.getByTestId("admin-tab").click();
await page.getByLabel("Action").selectOption("DROP_TEAM_OUT");
await page.getByLabel("Team").selectOption("401");
await submit(page);
await navigate({
page,
url: tournamentBracketsPage({ tournamentId }),
});
await page.getByTestId("reset-round-button").click();
await page.getByTestId("confirm-button").click();
await page.getByTestId("start-round-button").click();
await expect(page.getByText("BYE")).toBeVisible();
});
for (const pickBan of ["COUNTERPICK", "BAN_2"]) {
for (const mapPickingStyle of ["AUTO_SZ", "TO"]) {
test(`ban/pick ${pickBan} (${mapPickingStyle})`, async ({ page }) => {

View File

@ -75,6 +75,8 @@
"admin.actions.REMOVE_MEMBER": "Remove member",
"admin.actions.DELETE_TEAM": "Delete team",
"admin.actions.ADD_TEAM": "Register team",
"admin.actions.DROP_TEAM_OUT": "Drop out team",
"admin.actions.UNDO_DROP_TEAM_OUT": "Undo drop out",
"staff.role.ORGANIZER": "organizer",
"staff.role.STREAMER": "streamer",
@ -85,6 +87,7 @@
"bracket.losers.finals": "Losers Finals",
"bracket.single_elim": "Round {{round}}",
"bracket.round_robin": "Groups Round {{round}}",
"bracket.swiss": "Swiss Round {{round}}",
"bracket.single_elim.finals": "Finals",
"bracket.grand_finals": "Grand Finals",
"bracket.grand_finals.bracket_reset": "Bracket Reset",

11
migrations/054-swiss.js Normal file
View File

@ -0,0 +1,11 @@
export function up(db) {
db.transaction(() => {
db.prepare(
/* sql */ `alter table "TournamentTeam" add "droppedOut" integer default 0`,
).run();
db.prepare(
/* sql */ `alter table "TournamentMatch" add "createdAt" integer`,
).run();
})();
}